Merge branch 'master' into ep/privacy

This commit is contained in:
Evgeny Poberezkin
2026-02-10 21:51:26 +00:00
67 changed files with 2547 additions and 373 deletions
+2 -2
View File
@@ -42,7 +42,7 @@
## Connect to the team
You can connect to the team via the app using "chat with the developers button" available when you have no conversations in the profile, "Send questions and ideas" in the app settings or via our [SimpleX address](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). Please connect to:
You can connect to the team via the app using "chat with the developers button" available when you have no conversations in the profile, "Send questions and ideas" in the app settings or via our [SimpleX address](https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw). Please connect to:
- to ask any questions
- to suggest any improvements
@@ -54,7 +54,7 @@ If you are interested in helping us to integrate open-source language models, an
## Join user groups
You can find the groups created by users in [SimpleX Directory](https://simplex.chat/directory/). It is also available as [SimpleX bot](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) that allows to add your own groups and communities to the directory. We are not responsible for the content shared in these groups.
You can find the groups created by users in [SimpleX Directory](https://simplex.chat/directory/). It is also available as [SimpleX bot](https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok) that allows to add your own groups and communities to the directory. We are not responsible for the content shared in these groups.
**Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only.
+92
View File
@@ -1 +1,93 @@
# SimpleX Chat iOS app
This file provides guidance when working with code in this repository.
## iOS App Overview
The iOS app is a SwiftUI application that interfaces with the Haskell core library via FFI. It shares the SimpleXChat framework with two extensions: Notification Service Extension (NSE) for push notifications and Share Extension (SE) for sharing content from other apps.
## Build & Development
Open `SimpleX.xcodeproj` in Xcode. The project has five targets:
- **SimpleX (iOS)** - Main app (Bundle ID: `chat.simplex.app`)
- **SimpleXChat** - Framework containing FFI bridge and shared types
- **SimpleX NSE** - Notification Service Extension
- **SimpleX SE** - Share Extension
- **Tests iOS** - UI tests
Build and run via Xcode (Product > Build/Run). Tests run via Product > Test or:
```bash
xcodebuild test -scheme "SimpleX (iOS)" -destination 'platform=iOS Simulator,name=iPhone 15'
```
Deployment target: iOS 15.0+, Swift 5.0.
## Architecture
### Haskell Core Integration
The app calls the Haskell core library through C FFI defined in `SimpleXChat/SimpleX.h`:
- `chat_migrate_init_key()` - Initialize/migrate database
- `chat_send_cmd_retry()` - Send command to chat controller
- `chat_recv_msg_wait()` - Receive messages from controller
Swift wrappers in `SimpleXChat/API.swift`:
- `chatMigrateInit()` - Initialize chat controller
- `sendSimpleXCmd<R>()` - Send typed commands and parse responses
- `recvSimpleXMsg<R>()` - Receive typed messages
Haskell runtime initialization (`SimpleXChat/hs_init.c`) uses different memory configurations:
- Main app: 64MB heap
- NSE: 512KB heap (minimal footprint for background processing)
- SE: 1MB heap
Pre-compiled Haskell libraries are in `Libraries/{ios,mac,sim}/`.
### State Management
- **ChatModel** (`Shared/Model/ChatModel.swift`) - Main singleton `ObservableObject` for app-wide state (chat list, active chat, users)
- **ItemsModel** - Manages chat items within a selected chat (similar to Kotlin's ChatsContext)
- **AppTheme** - Theme management and customization
### App Structure
Entry point: `Shared/SimpleXApp.swift`
Key directories in `Shared/`:
- `Model/` - Data models and API layer (`ChatModel.swift`, `SimpleXAPI.swift`)
- `Views/` - SwiftUI views organized by feature:
- `ChatList/` - Chat list and user picker
- `Chat/` - Message display and composition
- `Call/` - VoIP call UI
- `UserSettings/` - App settings
- `LocalAuth/` - Passcode and biometric authentication
- `Database/` - Database initialization and migration
### Shared Data Between Targets
All three targets share data via App Group (`group.chat.simplex.app`):
- `SimpleXChat/AppGroup.swift` - GroupDefaults wrapper for typed shared preferences
- Keychain for sensitive data: `kcDatabasePassword`, `kcAppPassword`, `kcSelfDestructPassword`
### Key Types
Types are defined in `SimpleXChat/`:
- `ChatTypes.swift` - User, Chat, Message, Group types
- `APITypes.swift` - API request/response types
Commands follow `ChatCmdProtocol` (has `cmdString` property), sent as JSON through FFI.
## Localization
31 languages supported. Localization files in `SimpleX Localizations/`.
Workflow:
- `Product > Export Localizations` - Export XLIFF files
- `Product > Import Localizations` - Import updated translations
## Background Capabilities
Configured in Info.plist:
- Background modes: audio, fetch, remote-notification, voip
- URL scheme: `simplex://` for deep linking
- BGTaskScheduler: `chat.simplex.app.receive`
+6 -5
View File
@@ -444,17 +444,17 @@ func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTa
throw r.unexpected
}
func apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope?) async throws -> [MsgContentTag] {
func apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope? = nil) async throws -> [MsgContentTag] {
let r: ChatResponse0 = try await chatSendCmd(.apiGetChatContentTypes(chatId: chatId, scope: scope))
if case let .chatContentTypes(types) = r { return types }
throw r.unexpected
}
func loadChat(chat: Chat, im: ItemsModel, search: String = "", clearItems: Bool = true) async {
await loadChat(chatId: chat.chatInfo.id, im: im, search: search, clearItems: clearItems)
func loadChat(chat: Chat, im: ItemsModel, contentTag: MsgContentTag? = nil, search: String = "", clearItems: Bool = true) async {
await loadChat(chatId: chat.chatInfo.id, im: im, contentTag: contentTag, search: search, clearItems: clearItems)
}
func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async {
func loadChat(chatId: ChatId, im: ItemsModel, contentTag: MsgContentTag? = nil, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async {
await MainActor.run {
if clearItems {
im.reversedChatItems = []
@@ -468,10 +468,11 @@ func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundIte
openAroundItemId != nil
? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage)
: (
search == ""
contentTag == nil && search == ""
? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)
)
),
contentTag,
search,
openAroundItemId,
{ 0...0 }
@@ -15,6 +15,7 @@ func apiLoadMessages(
_ chatId: ChatId,
_ im: ItemsModel,
_ pagination: ChatPagination,
_ contentTag: MsgContentTag? = nil,
_ search: String = "",
_ openAroundItemId: ChatItem.ID? = nil,
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int> = { 0 ... 0 }
@@ -22,7 +23,7 @@ func apiLoadMessages(
let chat: Chat
let navInfo: NavigationInfo
do {
(chat, navInfo) = try await apiGetChat(chatId: chatId, scope: im.groupScopeInfo?.toChatScope(), contentTag: im.contentTag, pagination: pagination, search: search)
(chat, navInfo) = try await apiGetChat(chatId: chatId, scope: im.groupScopeInfo?.toChatScope(), contentTag: contentTag ?? im.contentTag, pagination: pagination, search: search)
} catch let error {
logger.error("apiLoadMessages error: \(responseError(error))")
return
+159 -29
View File
@@ -44,6 +44,8 @@ struct ChatView: View {
@State private var showSearch = false
@State private var searchText: String = ""
@FocusState private var searchFocussed
@State private var contentFilter: ContentFilter? = nil
@State private var availableContent: [ContentFilter] = [.images, .files, .links]
// opening GroupMemberInfoView on member icon
@State private var selectedMember: GMember? = nil
// opening GroupLinkView on link button (incognito)
@@ -528,16 +530,19 @@ struct ChatView: View {
case let .direct(contact):
HStack {
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
if callsPrefEnabled {
if chatModel.activeCall == nil {
callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
endCallButton(call)
}
if let call = chatModel.activeCall, call.contact.id == cInfo.id {
endCallButton(call)
} else {
contentFilterMenu(withLabel: false)
}
Menu {
if callsPrefEnabled && chatModel.activeCall == nil {
Button {
CallController.shared.startCall(contact, .audio)
} label: {
Label("Audio call", systemImage: "phone")
}
.disabled(!contact.ready || !contact.active)
Button {
CallController.shared.startCall(contact, .video)
} label: {
@@ -545,6 +550,9 @@ struct ChatView: View {
}
.disabled(!contact.ready || !contact.active)
}
if let call = chatModel.activeCall, call.contact.id == cInfo.id {
contentFilterMenu(withLabel: true)
}
searchButton()
ToggleNtfsButton(chat: chat)
.disabled(!contact.ready || !contact.active)
@@ -554,23 +562,24 @@ struct ChatView: View {
}
case let .group(groupInfo, _):
HStack {
if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) {
groupLinkButton()
.appSheet(isPresented: $showGroupLinkSheet) {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: false
)
}
} else {
addMembersButton()
}
}
contentFilterMenu(withLabel: false)
Menu {
if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) {
groupLinkButton()
.appSheet(isPresented: $showGroupLinkSheet) {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: false
)
}
} else {
addMembersButton()
}
}
searchButton()
ToggleNtfsButton(chat: chat)
} label: {
@@ -578,7 +587,10 @@ struct ChatView: View {
}
}
case .local:
searchButton()
HStack {
contentFilterMenu(withLabel: false)
searchButton()
}
default:
EmptyView()
}
@@ -685,6 +697,7 @@ struct ChatView: View {
}
}
}
updateAvailableContent()
}
if chatModel.draftChatId == cInfo.id && !composeState.forwarding,
let draft = chatModel.draft {
@@ -698,6 +711,22 @@ struct ChatView: View {
floatingButtonModel.updateOnListChange(scrollView.listState)
}
private func updateAvailableContent() {
Task {
let content: [ContentFilter]
do {
let contentTags = Set(try await apiGetChatContentTypes(chatId: chat.chatInfo.id)).union(ContentFilter.alwaysShow)
content = ContentFilter.allCases.filter { contentTags.contains($0.contentTag) }
} catch let error {
logger.error("apiGetChatContentTypes error: \(responseError(error))")
content = ContentFilter.allCases
}
await MainActor.run {
availableContent = content
}
}
}
private func scrollToItem(_ itemId: ChatItem.ID) {
Task {
do {
@@ -732,10 +761,14 @@ struct ChatView: View {
}
private func searchToolbar() -> some View {
HStack(spacing: 12) {
let placeholder: LocalizedStringKey = contentFilter?.searchPlaceholder ?? "Search"
return HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
TextField("Search", text: $searchText)
if let contentFilter {
Image(systemName: contentFilter.icon)
}
TextField(placeholder, text: $searchText)
.focused($searchFocussed)
.foregroundColor(theme.colors.onBackground)
.frame(maxWidth: .infinity)
@@ -1052,7 +1085,7 @@ struct ChatView: View {
private func searchTextChanged(_ s: String) {
Task {
await loadChat(chat: chat, im: im, search: s)
await loadChat(chat: chat, im: im, contentTag: contentFilter?.contentTag, search: s)
mergedItems.boxedValue = MergedItems.create(im, revealedItems)
await MainActor.run {
scrollView.updateItems(mergedItems.boxedValue.items)
@@ -1255,16 +1288,52 @@ struct ChatView: View {
}
}
private func contentFilterMenu(withLabel: Bool) -> some View {
Menu {
ForEach(availableContent, id: \.self) { type in
Button {
setContentFilter(type)
} label: {
Label(type.label, systemImage: contentFilter == type ? type.iconFilled : type.icon)
}
}
if contentFilter != nil {
Button {
closeSearch()
} label: {
Label("All messages", systemImage: "bubble.left.and.text.bubble.right")
}
}
} label: {
let icon = contentFilter == nil ? "photo.on.rectangle" : "photo.on.rectangle.fill"
if withLabel {
Label("Filter", systemImage: icon)
} else {
Image(systemName: icon)
}
}
}
private func focusSearch() {
showSearch = true
searchFocussed = true
searchText = ""
}
private func setContentFilter(_ type: ContentFilter) {
if (contentFilter == type) { return }
contentFilter = type
showSearch = true
searchText = ""
searchTextChanged("")
}
private func closeSearch() {
showSearch = false
searchText = ""
searchFocussed = false
contentFilter = nil
updateAvailableContent()
}
private func closeKeyboardAndRun(_ action: @escaping () -> Void) {
@@ -1285,7 +1354,7 @@ struct ChatView: View {
Task { await chatModel.loadGroupMembers(gInfo) { showAddMembersSheet = true } }
}
} label: {
Image(systemName: "person.crop.circle.badge.plus")
Label("Invite member", systemImage: "person.crop.circle.badge.plus")
}
}
@@ -1305,7 +1374,7 @@ struct ChatView: View {
}
}
} label: {
Image(systemName: "link.badge.plus")
Label("Group link", systemImage: "link.badge.plus")
}
}
@@ -1473,6 +1542,7 @@ struct ChatView: View {
chat.chatInfo.id,
im,
pagination,
contentFilter?.contentTag,
searchText,
nil,
{ visibleItemIndexesNonReversed(im, scrollView.listState, mergedItems.boxedValue) }
@@ -2957,6 +3027,66 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) {
}
}
enum ContentFilter: CaseIterable {
case images
case videos
case voice
case files
case links
static let alwaysShow: Set<MsgContentTag> = [.image, .link]
var contentTag: MsgContentTag {
switch self {
case .images: .image
case .videos: .video
case .voice: .voice
case .files: .file
case .links: .link
}
}
var label: LocalizedStringKey {
switch self {
case .images: "Images"
case .videos: "Videos"
case .voice: "Voice messages"
case .files: "Files"
case .links: "Links"
}
}
var searchPlaceholder: LocalizedStringKey {
switch self {
case .images: "Search images"
case .videos: "Search videos"
case .voice: "Search voice messages"
case .files: "Search files"
case .links: "Search links"
}
}
var icon: String {
switch self {
case .images: "photo"
case .videos: "video"
case .voice: "mic"
case .files: "doc"
case .links: "link"
}
}
var iconFilled: String {
switch self {
case .images: "photo.fill"
case .videos: "video.fill"
case .voice: "mic.fill"
case .files: "doc.fill"
case .links: "link.circle.fill"
}
}
}
struct ChatView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
+18 -18
View File
@@ -178,8 +178,8 @@
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.8-6ZoDNN7rGWGJ2cn2Wz03rA-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.8-6ZoDNN7rGWGJ2cn2Wz03rA-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.8-6ZoDNN7rGWGJ2cn2Wz03rA.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.8-6ZoDNN7rGWGJ2cn2Wz03rA.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a */; };
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
@@ -545,8 +545,8 @@
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.8-6ZoDNN7rGWGJ2cn2Wz03rA-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.8-6ZoDNN7rGWGJ2cn2Wz03rA-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.8-6ZoDNN7rGWGJ2cn2Wz03rA.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.8-6ZoDNN7rGWGJ2cn2Wz03rA.a"; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a"; sourceTree = "<group>"; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
@@ -708,8 +708,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.8-6ZoDNN7rGWGJ2cn2Wz03rA-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.8-6ZoDNN7rGWGJ2cn2Wz03rA.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -795,8 +795,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.8-6ZoDNN7rGWGJ2cn2Wz03rA-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.8-6ZoDNN7rGWGJ2cn2Wz03rA.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -2003,7 +2003,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 319;
CURRENT_PROJECT_VERSION = 321;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2053,7 +2053,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 319;
CURRENT_PROJECT_VERSION = 321;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2095,7 +2095,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 319;
CURRENT_PROJECT_VERSION = 321;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2115,7 +2115,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 319;
CURRENT_PROJECT_VERSION = 321;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2140,7 +2140,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 319;
CURRENT_PROJECT_VERSION = 321;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2177,7 +2177,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 319;
CURRENT_PROJECT_VERSION = 321;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2214,7 +2214,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 319;
CURRENT_PROJECT_VERSION = 321;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2265,7 +2265,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 319;
CURRENT_PROJECT_VERSION = 321;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2316,7 +2316,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 319;
CURRENT_PROJECT_VERSION = 321;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2350,7 +2350,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 319;
CURRENT_PROJECT_VERSION = 321;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
+1
View File
@@ -1,5 +1,6 @@
*.iml
.gradle
.kotlin
/local.properties
/.idea
!/.idea/codeStyles/*
+99 -2
View File
@@ -1,8 +1,105 @@
# Android App Development
This readme is currently a stub and as such is in development.
This is a guide to contributing to the develop of the SimpleX android and desktop apps.
Ultimately, this readme will act as a guide to contributing to the develop of the SimpleX android app.
## Project Overview
This is the **Kotlin Multiplatform (KMP)** mobile and desktop client for SimpleX Chat, sharing code between Android and Desktop (JVM) platforms using Compose Multiplatform for UI.
## Build Commands
```bash
# Android debug APK
./gradlew assembleDebug
# Android release APK
./gradlew assembleRelease
# Desktop distribution (current OS)
./gradlew :desktop:packageDistributionForCurrentOS
# Run desktop/JVM tests
./gradlew desktopTest
# Run Android instrumented tests (requires connected device/emulator)
./gradlew connectedAndroidTest
# Build native libraries for all platforms
./gradlew common:cmakeBuild -PcrossCompile
# Clean build
./gradlew clean
```
## Architecture
### Module Structure
- **`common/`** - Shared code (Compose UI, models, business logic)
- `src/commonMain/` - Cross-platform code
- `src/androidMain/` - Android-specific implementations
- `src/desktopMain/` - Desktop-specific implementations
- **`android/`** - Android app container
- **`desktop/`** - Desktop JVM app container
### Key Components (`common/src/commonMain/kotlin/chat/simplex/common/`)
- **`model/ChatModel.kt`** - Main state container with reactive properties (MutableState, MutableStateFlow)
- **`model/SimpleXAPI.kt`** - API bindings to Haskell core library via FFI
- **`platform/Core.kt`** - FFI interface to native `libapp` library
- **`platform/`** - Platform abstraction layer (expect/actual pattern for Android/Desktop specifics)
- **`views/`** - Compose UI screens organized by feature (chat, chatlist, call, usersettings, etc.)
- **`ui/theme/`** - Design system (colors, typography, shapes)
### Native Integration
The app calls into a Haskell core library via JNI/FFI:
- CMake builds in `common/src/commonMain/cpp/android/` and `cpp/desktop/`
- Cross-compilation toolchains in `cpp/toolchains/`
- Built libraries go to `cpp/desktop/libs/` (organized by platform)
## Configuration
### `local.properties` (create from `local.properties.example`)
```properties
compression.level=0 # APK compression (0-9)
enable_debuggable=true # Debug mode
application_id.suffix=.debug # Multiple app instances on same device
app.name=SimpleX Debug # App name for debug builds
```
### `gradle.properties`
Contains versions (Kotlin, Compose, AGP) and app version info. Key settings:
- `kotlin.jvm.target=11`
- `database.backend=sqlite` (or `postgres`)
## Testing
Tests are in:
- `common/src/commonTest/kotlin/` - Cross-platform tests
- `common/src/desktopTest/kotlin/` - Desktop-specific tests (run with `./gradlew desktopTest`)
- `android/src/androidTest/` - Android instrumented tests
## Resources & Localization
- String resources: `common/src/commonMain/resources/MR/base/strings.xml` + 21 language variants
- Uses Moko Resources (`dev.icerock.moko:resources`) for cross-platform resource management
- The `adjustFormatting` gradle task validates string resources during build
## Platform-Specific Notes
### Android
- Min SDK 26, Target SDK 35
- NDK 23.1.7779620
- Supports ABI splits: `arm64-v8a`, `armeabi-v7a`
- Deep linking requires SHA certificate fingerprint in `assetlinks.json` (see README.md)
### Desktop
- Distributions: DMG (macOS), MSI/EXE (Windows), DEB (Linux)
- Mac signing/notarization configured via `local.properties`
- Video playback uses VLCJ
## Gotchas
@@ -3735,7 +3735,7 @@ sealed class CC {
}
"/_get chat ${chatRef(type, id, scope)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
}
is ApiGetChatContentTypes -> "/_get content types ${chatRef(type, id, scope)})"
is ApiGetChatContentTypes -> "/_get content types ${chatRef(type, id, scope)}"
is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id, scope)} $itemId"
is ApiSendMessages -> {
val msgs = json.encodeToString(composedMessages)
@@ -27,11 +27,12 @@ suspend fun apiLoadMessages(
chatType: ChatType,
apiId: Long,
pagination: ChatPagination,
contentTag: MsgContentTag? = null,
search: String = "",
openAroundItemId: Long? = null,
visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 }
) = coroutineScope {
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), chatsCtx.contentTag, pagination, search) ?: return@coroutineScope
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), contentTag ?: chatsCtx.contentTag, pagination, search) ?: return@coroutineScope
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
/** When [openAroundItemId] is provided, chatId can be different too */
if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last && openAroundItemId == null)
@@ -45,6 +45,7 @@ import chat.simplex.common.views.newchat.ContactConnectionInfoView
import chat.simplex.common.views.newchat.alertProfileImageSize
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.datetime.*
@@ -144,6 +145,9 @@ fun ChatView(
val scope = rememberCoroutineScope()
val selectedChatItems = rememberSaveable { mutableStateOf(null as Set<Long>?) }
val showCommandsMenu = rememberSaveable { mutableStateOf(false) }
val contentFilter = rememberSaveable { mutableStateOf<ContentFilter?>(null) }
val availableContent = remember { mutableStateOf<List<ContentFilter>>(ContentFilter.initialList) }
if (appPlatform.isAndroid) {
DisposableEffect(Unit) {
onDispose {
@@ -170,7 +174,12 @@ fun ChatView(
}
showSearch.value = false
searchText.value = ""
contentFilter.value = null
availableContent.value = ContentFilter.initialList
selectedChatItems.value = null
if (chatsCtx.secondaryContextFilter == null) {
updateAvailableContent(chatRh, activeChat, availableContent)
}
if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.activeConn != null) {
withBGApi {
val r = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId)
@@ -229,11 +238,11 @@ fun ChatView(
val sameText = searchText.value == value
// showSearch can be false with empty text when it was closed manually after clicking on message from search to load .around it
// (required on Android to have this check to prevent call to search with old text)
val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && chatsCtx.secondaryContextFilter == null
val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && chatsCtx.secondaryContextFilter == null && contentFilter.value == null
val c = chatModel.getChat(chatInfo.id)
if (sameText || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged
if ((sameText && contentFilter.value == null) || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged
withBGApi {
apiFindMessages(chatsCtx, c, value)
apiFindMessages(chatsCtx, c, contentFilter.value?.contentTag, value)
searchText.value = value
}
}
@@ -486,7 +495,7 @@ fun ChatView(
val c = chatModel.getChat(chatId)
if (chatModel.chatId.value != chatId) return@ChatLayout
if (c != null) {
apiLoadMessages(chatsCtx, c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, searchText.value, null, visibleItemIndexes)
apiLoadMessages(chatsCtx, c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, contentFilter.value?.contentTag, searchText.value, null, visibleItemIndexes)
}
},
deleteMessage = { itemId, mode ->
@@ -742,14 +751,23 @@ fun ChatView(
changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) },
onSearchValueChanged = onSearchValueChanged,
closeSearch = {
onSearchValueChanged("")
showSearch.value = false
searchText.value = ""
contentFilter.value = null
// Update available content types when search closes
if (chatsCtx.secondaryContextFilter == null) {
updateAvailableContent(chatRh, activeChat, availableContent)
}
},
onComposed,
developerTools = chatModel.controller.appPrefs.developerTools.get(),
showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(),
showSearch = showSearch,
showCommandsMenu = showCommandsMenu
showCommandsMenu = showCommandsMenu,
contentFilter = contentFilter,
availableContent = availableContent,
searchPlaceholder = contentFilter.value?.searchPlaceholder?.let { generalGetString(it) }
)
}
}
@@ -785,6 +803,23 @@ fun ChatView(
}
}
fun updateAvailableContent(chatRh: Long?, activeChat: State<Chat?>, availableContent: MutableState<List<ContentFilter>>) {
withBGApi {
Log.e(TAG, "updateAvailableContent")
val chatInfo = activeChat.value?.chatInfo
if (chatInfo == null) return@withBGApi
val types = chatModel.controller.apiGetChatContentTypes(chatRh, chatInfo.chatType, chatInfo.apiId, null)
if (activeChat.value?.chatInfo?.id != chatInfo.id) return@withBGApi
if (types == null) {
availableContent.value = ContentFilter.entries
} else {
val typeSet: Set<MsgContentTag> = types.union(ContentFilter.alwaysShow)
Log.e(TAG, "updateAvailableContent $typeSet")
availableContent.value = ContentFilter.entries.filter { it -> typeSet.contains(it.contentTag) }
}
}
}
private fun connectingText(chatInfo: ChatInfo): String? {
return when (chatInfo) {
is ChatInfo.Direct ->
@@ -879,7 +914,10 @@ fun ChatLayout(
developerTools: Boolean,
showViaProxy: Boolean,
showSearch: MutableState<Boolean>,
showCommandsMenu: MutableState<Boolean>
showCommandsMenu: MutableState<Boolean>,
contentFilter: MutableState<ContentFilter?>,
availableContent: State<List<ContentFilter>>,
searchPlaceholder: String?
) {
val chatInfo = remember { derivedStateOf { chat.value?.chatInfo } }
val scope = rememberCoroutineScope()
@@ -1063,7 +1101,7 @@ fun ChatLayout(
Box {
if (selectedChatItems.value == null) {
if (chatInfo != null) {
ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch)
ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch, contentFilter, availableContent, searchPlaceholder)
}
} else {
SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value)
@@ -1096,10 +1134,14 @@ fun BoxScope.ChatInfoToolbar(
openGroupLink: (GroupInfo) -> Unit,
changeNtfsState: (MsgFilter, currentValue: MutableState<MsgFilter>) -> Unit,
onSearchValueChanged: (String) -> Unit,
showSearch: MutableState<Boolean>
showSearch: MutableState<Boolean>,
contentFilter: MutableState<ContentFilter?>,
availableContent: State<List<ContentFilter>>,
searchPlaceholder: String?
) {
val scope = rememberCoroutineScope()
val showMenu = rememberSaveable { mutableStateOf(false) }
val showContentFilterMenu = rememberSaveable { mutableStateOf(false) }
val onBackClicked = {
if (!showSearch.value) {
@@ -1107,6 +1149,7 @@ fun BoxScope.ChatInfoToolbar(
} else {
onSearchValueChanged("")
showSearch.value = false
contentFilter.value = null
}
}
if (appPlatform.isAndroid && chatsCtx.secondaryContextFilter == null) {
@@ -1115,104 +1158,141 @@ fun BoxScope.ChatInfoToolbar(
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val menuItems = arrayListOf<@Composable () -> Unit>()
val activeCall by remember { chatModel.activeCall }
if (chatInfo is ChatInfo.Local) {
barButtons.add {
IconButton(
{
showMenu.value = false
showSearch.value = true
}, enabled = chatInfo.noteFolder.ready
) {
Icon(
painterResource(MR.images.ic_search),
stringResource(MR.strings.search_verb).capitalize(Locale.current),
tint = if (chatInfo.noteFolder.ready) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
)
}
}
} else {
menuItems.add {
ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = {
showMenu.value = false
showSearch.value = true
})
}
}
if (chatInfo is ChatInfo.Direct && chatInfo.contact.mergedPreferences.calls.enabled.forUser) {
if (activeCall == null) {
barButtons.add {
IconButton({
showMenu.value = false
startCall(CallMediaType.Audio)
}, enabled = chatInfo.contact.ready && chatInfo.contact.active
) {
Icon(
painterResource(MR.images.ic_call_500),
stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current),
tint = if (chatInfo.contact.ready && chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
)
}
}
} else if (activeCall?.contact?.id == chatInfo.id && appPlatform.isDesktop) {
barButtons.add {
val call = remember { chatModel.activeCall }.value
val connectedAt = call?.connectedAt
if (connectedAt != null) {
val time = remember { mutableStateOf(durationText(0)) }
LaunchedEffect(Unit) {
while (true) {
time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt())
delay(250)
}
}
val sp50 = with(LocalDensity.current) { 50.sp.toDp() }
Text(time.value, Modifier.widthIn(min = sp50))
}
}
barButtons.add {
IconButton({
showMenu.value = false
endCall()
}) {
Icon(
painterResource(MR.images.ic_call_end_filled),
null,
tint = MaterialTheme.colors.error
)
}
}
}
if (chatInfo.contact.ready && chatInfo.contact.active && activeCall == null) {
val showContentFilterButton = availableContent.value.isNotEmpty()
val activeCallInChat = chatInfo is ChatInfo.Direct && activeCall?.contact?.id == chatInfo.id
// Content filter button - shown in bar, or moved to menu during active call
if (showContentFilterButton) {
val enabled = chatInfo !is ChatInfo.Local || chatInfo.noteFolder.ready
if (activeCallInChat) {
menuItems.add {
ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = {
showMenu.value = false
startCall(CallMediaType.Video)
})
}
}
} else if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canAddMembers) {
if (!chatInfo.incognito) {
barButtons.add {
IconButton({
showMenu.value = false
addMembers(chatInfo.groupInfo)
}) {
Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary)
}
ItemAction(
stringResource(MR.strings.content_filter_menu_item),
painterResource(MR.images.ic_photo_library),
onClick = {
showMenu.value = false
showContentFilterMenu.value = true
}
)
}
} else {
barButtons.add {
IconButton({
showMenu.value = false
openGroupLink(chatInfo.groupInfo)
}) {
Icon(painterResource(MR.images.ic_add_link), stringResource(MR.strings.group_link), tint = MaterialTheme.colors.primary)
IconButton(
{ showContentFilterMenu.value = true },
enabled = enabled
) {
Icon(
painterResource(MR.images.ic_photo_library),
null,
tint = MaterialTheme.colors.primary
)
}
}
}
}
// Chat-type specific buttons
when (chatInfo) {
is ChatInfo.Local -> {
barButtons.add {
IconButton(
{
showMenu.value = false
showSearch.value = true
}, enabled = chatInfo.noteFolder.ready
) {
Icon(
painterResource(MR.images.ic_search),
stringResource(MR.strings.search_verb).capitalize(Locale.current),
tint = if (chatInfo.noteFolder.ready) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
)
}
}
}
is ChatInfo.Direct -> {
if (activeCall?.contact?.id == chatInfo.id) {
if (appPlatform.isDesktop) {
barButtons.add {
val call = remember { chatModel.activeCall }.value
val connectedAt = call?.connectedAt
if (connectedAt != null) {
val time = remember { mutableStateOf(durationText(0)) }
LaunchedEffect(Unit) {
while (true) {
time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt())
delay(250)
}
}
val sp50 = with(LocalDensity.current) { 50.sp.toDp() }
Text(time.value, Modifier.widthIn(min = sp50))
}
}
}
barButtons.add {
IconButton({
showMenu.value = false
endCall()
}) {
Icon(
painterResource(MR.images.ic_call_end_filled),
null,
tint = MaterialTheme.colors.error
)
}
}
}
// Call buttons moved to menu
if (chatInfo.contact.mergedPreferences.calls.enabled.forUser && chatInfo.contact.ready && chatInfo.contact.active && activeCall == null) {
menuItems.add {
ItemAction(stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current), painterResource(MR.images.ic_call_500), onClick = {
showMenu.value = false
startCall(CallMediaType.Audio)
})
}
menuItems.add {
ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = {
showMenu.value = false
startCall(CallMediaType.Video)
})
}
}
menuItems.add {
ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = {
showMenu.value = false
showSearch.value = true
})
}
}
is ChatInfo.Group -> {
// Add members / group link moved to menu
if (chatInfo.groupInfo.canAddMembers) {
if (!chatInfo.incognito) {
menuItems.add {
ItemAction(stringResource(MR.strings.icon_descr_add_members), painterResource(MR.images.ic_person_add_500), onClick = {
showMenu.value = false
addMembers(chatInfo.groupInfo)
})
}
} else {
menuItems.add {
ItemAction(stringResource(MR.strings.group_link), painterResource(MR.images.ic_add_link), onClick = {
showMenu.value = false
openGroupLink(chatInfo.groupInfo)
})
}
}
}
menuItems.add {
ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = {
showMenu.value = false
showSearch.value = true
})
}
}
else -> {}
}
val enableNtfs = chatInfo.chatSettings?.enableNtfs
if (((chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) || chatInfo is ChatInfo.Group) && enableNtfs != null) {
val ntfMode = remember { mutableStateOf(enableNtfs) }
@@ -1242,13 +1322,27 @@ fun BoxScope.ChatInfoToolbar(
}
val oneHandUI = remember { appPrefs.oneHandUI.state }
val chatBottomBar = remember { appPrefs.chatBottomBar.state }
val searchTrailingContent: @Composable (() -> Unit)? = if (showContentFilterButton) {{
IconButton({ showContentFilterMenu.value = true }) {
Icon(
painterResource(if (contentFilter.value == null) MR.images.ic_photo_library else MR.images.ic_photo_library_filled),
null,
Modifier.padding(4.dp),
tint = MaterialTheme.colors.primary
)
}
}} else null
DefaultAppBar(
navigationButton = { if (appPlatform.isAndroid || showSearch.value) { NavigationButtonBack(onBackClicked) } },
title = { ChatInfoToolbarTitle(chatInfo) },
onTitleClick = if (chatInfo is ChatInfo.Local) null else info,
showSearch = showSearch.value,
searchAlwaysVisible = contentFilter.value != null,
onTop = !oneHandUI.value || !chatBottomBar.value,
searchPlaceholder = searchPlaceholder,
onSearchValueChanged = onSearchValueChanged,
searchTrailingContent = searchTrailingContent,
buttons = { barButtons.forEach { it() } }
)
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd)) {
@@ -1269,6 +1363,65 @@ fun BoxScope.ChatInfoToolbar(
menuItems.forEach { it() }
}
}
val contentFilterWidth = remember { mutableStateOf(250.dp) }
val contentFilterHeight = remember { mutableStateOf(0.dp) }
DefaultDropdownMenu(
showContentFilterMenu,
modifier = Modifier.onSizeChanged { with(density) {
contentFilterWidth.value = it.width.toDp().coerceAtLeast(250.dp)
if (oneHandUI.value && chatBottomBar.value && (appPlatform.isDesktop || (platform.androidApiLevel ?: 0) >= 30)) contentFilterHeight.value = it.height.toDp()
} },
offset = DpOffset(-contentFilterWidth.value, if (oneHandUI.value && chatBottomBar.value) -contentFilterHeight.value else AppBarHeight)
) {
val contentFilterMenuItems: List<@Composable () -> Unit> = buildList {
availableContent.value.forEach { filter ->
val isSelected = contentFilter.value == filter
add {
ItemAction(
stringResource(filter.label),
painterResource(if (isSelected) filter.iconFilled else filter.icon),
color = if (isSelected) MaterialTheme.colors.primary else Color.Unspecified,
onClick = {
showContentFilterMenu.value = false
if (contentFilter.value == filter) return@ItemAction
contentFilter.value = filter
showSearch.value = true
scope.launch {
val c = chatModel.getChat(chatInfo.id)
if (c != null) {
apiFindMessages(chatsCtx, c, filter.contentTag, "")
}
}
}
)
}
}
if (showSearch.value) {
add {
ItemAction(
stringResource(MR.strings.content_filter_all_messages),
painterResource(MR.images.ic_forum),
onClick = {
showContentFilterMenu.value = false
contentFilter.value = null
showSearch.value = false
scope.launch {
val c = chatModel.getChat(chatInfo.id)
if (c != null) {
apiFindMessages(chatsCtx, c, null, "")
}
}
}
)
}
}
}
if (oneHandUI.value && chatBottomBar.value) {
contentFilterMenuItems.asReversed().forEach { it() }
} else {
contentFilterMenuItems.forEach { it() }
}
}
}
}
@@ -3425,7 +3578,10 @@ fun PreviewChatLayout() {
developerTools = false,
showViaProxy = false,
showSearch = remember { mutableStateOf(false) },
showCommandsMenu = remember { mutableStateOf(false) }
showCommandsMenu = remember { mutableStateOf(false) },
contentFilter = remember { mutableStateOf(null) },
availableContent = remember { mutableStateOf(ContentFilter.initialList) },
searchPlaceholder = null
)
}
}
@@ -3505,7 +3661,30 @@ fun PreviewGroupChatLayout() {
developerTools = false,
showViaProxy = false,
showSearch = remember { mutableStateOf(false) },
showCommandsMenu = remember { mutableStateOf(false) }
showCommandsMenu = remember { mutableStateOf(false) },
contentFilter = remember { mutableStateOf(null) },
availableContent = remember { mutableStateOf(ContentFilter.initialList) },
searchPlaceholder = null
)
}
}
enum class ContentFilter(
val contentTag: MsgContentTag,
val label: StringResource,
val searchPlaceholder: StringResource,
val icon: ImageResource,
val iconFilled: ImageResource
) {
Images(MsgContentTag.Image, MR.strings.content_filter_images, MR.strings.placeholder_search_images, MR.images.ic_image, MR.images.ic_image_filled),
Videos(MsgContentTag.Video, MR.strings.content_filter_videos, MR.strings.placeholder_search_videos, MR.images.ic_videocam, MR.images.ic_videocam_filled),
Voice(MsgContentTag.Voice, MR.strings.content_filter_voice_messages, MR.strings.placeholder_search_voice_messages, MR.images.ic_mic, MR.images.ic_mic_filled),
Files(MsgContentTag.File, MR.strings.content_filter_files, MR.strings.placeholder_search_files, MR.images.ic_draft, MR.images.ic_draft_filled),
Links(MsgContentTag.Link, MR.strings.content_filter_links, MR.strings.placeholder_search_links, MR.images.ic_link, MR.images.ic_link);
companion object {
val alwaysShow: Set<MsgContentTag> = setOf(MsgContentTag.Image, MsgContentTag.Link)
val initialList: List<ContentFilter> = listOf(ContentFilter.Images, ContentFilter.Files, ContentFilter.Links)
}
}
@@ -158,7 +158,10 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
val uriDecrypted = remember(media.uri.path) { mutableStateOf(if (media.fileSource?.cryptoArgs == null) media.uri else media.fileSource.decryptedGet()) }
val decrypted = uriDecrypted.value
if (decrypted != null) {
VideoView(modifier, decrypted, preview, index == settledCurrentPage, close)
// settledCurrentPage finishes **only** when fully swiped
// So we use pagerState.currentPage that changes right away as the screen is being dragged
val isCurrentPage = index == pagerState.currentPage && kotlin.math.abs(pagerState.currentPageOffsetFraction) < 0.3f
VideoView(modifier, decrypted, preview, isCurrentPage, close)
DisposableEffect(Unit) {
onDispose { playersToRelease.add(decrypted) }
}
@@ -228,6 +228,7 @@ suspend fun openChat(
} else {
ChatPagination.Initial(ChatPagination.INITIAL_COUNT)
},
contentTag = null,
"",
openAroundItemId
)
@@ -241,11 +242,12 @@ suspend fun openLoadedChat(chat: Chat) {
}
}
suspend fun apiFindMessages(chatsCtx: ChatModel.ChatsContext, ch: Chat, search: String) {
suspend fun apiFindMessages(chatsCtx: ChatModel.ChatsContext, ch: Chat, contentTag: MsgContentTag?, search: String) {
withContext(Dispatchers.Main) {
chatsCtx.chatItems.clearAndNotify()
}
apiLoadMessages(chatsCtx, ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = if (search.isNotEmpty()) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT), search = search)
val pagination = if (search.isNotEmpty() || contentTag != null) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT)
apiLoadMessages(chatsCtx, ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination, contentTag, search)
}
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope {
@@ -29,7 +29,9 @@ fun DefaultAppBar(
onTop: Boolean,
showSearch: Boolean = false,
searchAlwaysVisible: Boolean = false,
searchPlaceholder: String? = null,
onSearchValueChanged: (String) -> Unit = {},
searchTrailingContent: @Composable (() -> Unit)? = null,
buttons: @Composable RowScope.() -> Unit = {},
) {
// If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier
@@ -78,7 +80,8 @@ fun DefaultAppBar(
AppBar(
title = {
if (showSearch) {
SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = searchAlwaysVisible, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged)
val placeholder = searchPlaceholder ?: stringResource(MR.strings.search_verb)
SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = searchAlwaysVisible, placeholder = placeholder, trailingContent = searchTrailingContent, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged)
} else if (title != null) {
title()
} else if (titleText.value.isNotEmpty() && connection != null) {
@@ -10,6 +10,7 @@ import androidx.compose.material.TextFieldDefaults.indicatorLine
import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -112,18 +113,26 @@ fun SearchTextField(
placeholder = {
Text(placeholder, style = textStyle.copy(color = MaterialTheme.colors.secondary), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
trailingIcon = if (searchText.value.text.isNotEmpty()) {{
IconButton({
if (alwaysVisible) {
keyboard?.hide()
focusManager.clearFocus()
trailingIcon = if (searchText.value.text.isNotEmpty() || trailingContent != null) {{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.offset(x = 8.dp)
) {
if (searchText.value.text.isNotEmpty()) {
IconButton({
if (alwaysVisible) {
keyboard?.hide()
focusManager.clearFocus()
}
searchText.value = TextFieldValue("")
onValueChange("")
}) {
Icon(painterResource(MR.images.ic_close), stringResource(MR.strings.icon_descr_close_button), tint = MaterialTheme.colors.primary)
}
}
searchText.value = TextFieldValue("");
onValueChange("")
}, Modifier.offset(x = reducedCloseButtonPadding)) {
Icon(painterResource(MR.images.ic_close), stringResource(MR.strings.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
trailingContent?.invoke()
}
}} else trailingContent,
}} else null,
singleLine = true,
enabled = enabled,
interactionSource = interactionSource,
@@ -368,6 +368,18 @@
<string name="edit_verb">Edit</string>
<string name="info_menu">Info</string>
<string name="search_verb">Search</string>
<string name="placeholder_search_images">Search images</string>
<string name="placeholder_search_videos">Search videos</string>
<string name="placeholder_search_voice_messages">Search voice messages</string>
<string name="placeholder_search_files">Search files</string>
<string name="placeholder_search_links">Search links</string>
<string name="content_filter_images">Images</string>
<string name="content_filter_videos">Videos</string>
<string name="content_filter_voice_messages">Voice messages</string>
<string name="content_filter_files">Files</string>
<string name="content_filter_links">Links</string>
<string name="content_filter_all_messages">All messages</string>
<string name="content_filter_menu_item">Filter</string>
<string name="archive_verb">Archive</string>
<string name="archive_report">Archive report</string>
<string name="archive_reports">Archive reports</string>
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000">
<path
d="M182-124.5q-22.97 0-40.23-17.27Q124.5-159.03 124.5-182v-596q0-22.97 17.27-40.23Q159.03-835.5 182-835.5h596q22.97 0 40.23 17.27Q835.5-800.97 835.5-778v596q0 22.97-17.27 40.23Q800.97-124.5 778-124.5H182Zm58-154h481.5L577-471 446-301.5l-92-125-114 148Z" />
</svg>

After

Width:  |  Height:  |  Size: 389 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000">
<path
d="M345.5-376H731L606.5-544l-103 135-67.5-86.5L345.5-376Zm-88 176q-22.97 0-40.23-17.27Q200-234.53 200-257.5v-560q0-22.97 17.27-40.23Q234.53-875 257.5-875h560q22.97 0 40.23 17.27Q875-840.47 875-817.5v560q0 22.97-17.27 40.23Q840.47-200 817.5-200h-560Zm0-57.5h560v-560h-560v560ZM142.5-85q-22.97 0-40.23-17.27Q85-119.53 85-142.5V-760h57.5v617.5H760V-85H142.5Zm115-732.5v560-560Z" />
</svg>

After

Width:  |  Height:  |  Size: 511 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000">
<path
d="M345.5-376H731L606.5-544l-103 135-67.5-86.5L345.5-376Zm-88 176q-22.97 0-40.23-17.27Q200-234.53 200-257.5v-560q0-22.97 17.27-40.23Q234.53-875 257.5-875h560q22.97 0 40.23 17.27Q875-840.47 875-817.5v560q0 22.97-17.27 40.23Q840.47-200 817.5-200h-560Zm-115 115q-22.97 0-40.23-17.27Q85-119.53 85-142.5V-760h57.5v617.5H760V-85H142.5Z" />
</svg>

After

Width:  |  Height:  |  Size: 466 B

@@ -27,7 +27,7 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap {
.removePrefix("data:image/jpg;base64,")
return try {
ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode(imageString))).toComposeImageBitmap()
} catch (e: IOException) {
} catch (e: Throwable) {
Log.e(TAG, "base64ToBitmap error: $e")
errorBitmap()
}
+4 -4
View File
@@ -24,13 +24,13 @@ android.nonTransitiveRClass=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=6.5-beta.4
android.version_code=332
android.version_name=6.5-beta.5
android.version_code=335
android.bundle=false
desktop.version_name=6.5-beta.4
desktop.version_code=129
desktop.version_name=6.5-beta.5
desktop.version_code=131
kotlin.version=2.1.20
gradle.plugin.version=8.7.0
@@ -94,6 +94,5 @@ mkChatOpts BroadcastBotOpts {coreOptions, botDisplayName} =
autoAcceptFileSize = 0,
muteNotifications = True,
markRead = False,
createBot = Just CreateBotOpts {botDisplayName, allowFiles = False},
maintenance = False
createBot = Just CreateBotOpts {botDisplayName, allowFiles = False}
}
@@ -10,11 +10,13 @@
module Directory.Events
( DirectoryEvent (..),
DirectoryCmd (..),
DirectoryCmdTag (..),
ADirectoryCmd (..),
DirectoryHelpSection (..),
DirectoryRole (..),
SDirectoryRole (..),
crDirectoryEvent,
directoryCmdP,
directoryCmdTag,
)
where
@@ -9,6 +9,7 @@ module Directory.Options
( DirectoryOpts (..),
MigrateLog (..),
getDirectoryOpts,
directoryOpts,
mkChatOpts,
)
where
@@ -27,12 +28,14 @@ data DirectoryOpts = DirectoryOpts
adminUsers :: [KnownContact],
superUsers :: [KnownContact],
ownersGroup :: Maybe KnownGroup,
noAddress :: Bool, -- skip creating address
blockedWordsFile :: Maybe FilePath,
blockedFragmentsFile :: Maybe FilePath,
blockedExtensionRules :: Maybe FilePath,
nameSpellingFile :: Maybe FilePath,
profileNameLimit :: Int,
captchaGenerator :: Maybe FilePath,
voiceCaptchaGenerator :: Maybe FilePath,
directoryLog :: Maybe FilePath,
migrateDirectoryLog :: Maybe MigrateLog,
serviceName :: T.Text,
@@ -70,6 +73,11 @@ directoryOpts appDir defaultDbName = do
<> metavar "OWNERS_GROUP"
<> help "The group of group owners in the format GROUP_ID:DISPLAY_NAME - owners of listed groups will be invited automatically"
)
noAddress <-
switch
( long "no-address"
<> help "skip checking and creating service address"
)
blockedWordsFile <-
optional $
strOption
@@ -113,6 +121,13 @@ directoryOpts appDir defaultDbName = do
<> metavar "CAPTCHA_GENERATOR"
<> help "Executable to generate captcha files, must accept text as parameter and save file to stdout as base64 up to 12500 bytes"
)
voiceCaptchaGenerator <-
optional $
strOption
( long "voice-captcha-generator"
<> metavar "VOICE_CAPTCHA_GENERATOR"
<> help "Executable to generate voice captcha, accepts text as parameter, writes audio file, outputs file_path and duration_seconds to stdout"
)
directoryLog <-
optional $
strOption
@@ -153,12 +168,14 @@ directoryOpts appDir defaultDbName = do
adminUsers,
superUsers,
ownersGroup,
noAddress,
blockedWordsFile,
blockedFragmentsFile,
blockedExtensionRules,
nameSpellingFile,
profileNameLimit,
captchaGenerator,
voiceCaptchaGenerator,
directoryLog,
migrateDirectoryLog,
serviceName = T.pack serviceName,
@@ -194,8 +211,7 @@ mkChatOpts DirectoryOpts {coreOptions, serviceName} =
autoAcceptFileSize = 0,
muteNotifications = True,
markRead = False,
createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False},
maintenance = False
createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False}
}
parseMigrateLog :: ReadM MigrateLog
@@ -20,11 +20,14 @@ where
import Control.Concurrent (forkIO)
import Control.Concurrent.STM
import Control.Exception (SomeException, try)
import Control.Logger.Simple
import Control.Monad
import Control.Monad.Except
import Control.Monad.IO.Class
import qualified Data.Attoparsec.Text as A
import Data.Bifunctor (first)
import Data.Either (fromRight)
import Data.List (find, intercalate)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.Map.Strict as M
@@ -63,13 +66,15 @@ import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Chat.View (serializeChatError, serializeChatResponse, simplexChatContact, viewContactName, viewGroupName)
import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnectionLink (..), CreatedConnLink (..), SConnectionMode (..), sameConnReqContact, sameShortLinkContact)
import qualified Simplex.Messaging.Crypto.File as CF
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.TMap (TMap)
import qualified Simplex.Messaging.TMap as TM
import Simplex.Messaging.Util (eitherToMaybe, raceAny_, safeDecodeUtf8, tshow, unlessM, (<$$>))
import System.Directory (getAppUserDataDirectory)
import System.Directory (getAppUserDataDirectory, removeFile)
import System.Exit (exitFailure)
import System.Process (readProcess)
import Text.Read (readMaybe)
data GroupProfileUpdate
= GPNoServiceLink
@@ -97,10 +102,13 @@ data ServiceState = ServiceState
updateListingsJob :: TMVar ChatController
}
data CaptchaMode = CMText | CMAudio
data PendingCaptcha = PendingCaptcha
{ captchaText :: Text,
sentAt :: UTCTime,
attempts :: Int
attempts :: Int,
captchaMode :: CaptchaMode
}
captchaLength :: Int
@@ -184,10 +192,11 @@ directoryPreStartHook :: DirectoryOpts -> ChatController -> IO ()
directoryPreStartHook opts ChatController {config, chatStore} = runDirectoryMigrations opts config chatStore
directoryPostStartHook :: DirectoryOpts -> ServiceState -> ChatController -> IO ()
directoryPostStartHook opts env cc =
directoryPostStartHook opts@DirectoryOpts {noAddress, testing} env cc =
readTVarIO (currentUser cc) >>= \case
Nothing -> putStrLn "No current user" >> exitFailure
Just User {userId, profile = p@LocalProfile {preferences}} -> do
unless noAddress $ initializeBotAddress' (not testing) cc
listingsUpdated env cc
let cmds = fromMaybe [] $ preferences >>= commands_
unless (cmds == directoryCommands) $ do
@@ -216,7 +225,7 @@ directoryCommands =
idParam = Just "<ID>"
directoryService :: DirectoryLog -> DirectoryOpts -> ChatConfig -> IO ()
directoryService st opts@DirectoryOpts {testing} cfg = do
directoryService st opts cfg = do
env <- newServiceState opts
let chatHooks =
defaultChatHooks
@@ -224,8 +233,7 @@ directoryService st opts@DirectoryOpts {testing} cfg = do
postStartHook = Just $ directoryPostStartHook opts env,
acceptMember = Just $ acceptMemberHook opts env
}
simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \user cc -> do
initializeBotAddress' (not testing) cc
simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \user cc ->
raceAny_ $
[ forever $ void getLine,
forever $ do
@@ -555,33 +563,57 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
dePendingMember :: GroupInfo -> GroupMember -> IO ()
dePendingMember g@GroupInfo {groupProfile = GroupProfile {displayName}} m
| memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0
| memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 CMText
| otherwise = approvePendingMember a g m
where
a = groupMemberAcceptance g
captchaNotice = "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "."
captchaNotice =
"Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "."
<> if isJust (voiceCaptchaGenerator opts) then "\nSend /audio to receive a voice captcha." else ""
sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> IO ()
sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts = do
sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> CaptchaMode -> IO ()
sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts mode = do
s <- getCaptchaStr captchaLength ""
mc <- getCaptcha s
sentAt <- getCurrentTime
let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1}
let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1, captchaMode = mode}
atomically $ TM.insert gmId captcha $ pendingCaptchas env
sendCaptcha mc
case mode of
CMAudio -> do
mc <- getCaptchaContent s
sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)]
sendVoiceCaptcha sendRef s
CMText -> do
mc <- getCaptchaContent s
sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)]
where
getCaptcha s = case captchaGenerator opts of
Nothing -> pure textMsg
Just script -> content <$> readProcess script [s] ""
where
textMsg = MCText $ T.pack s
content r = case T.lines $ T.pack r of
[] -> textMsg
"" : _ -> textMsg
img : _ -> MCImage "" $ ImageData img
sendCaptcha mc = sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [(quotedId, MCText noticeText), (Nothing, mc)]
sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)
gmId = groupMemberId' m
sendVoiceCaptcha :: SendRef -> String -> IO ()
sendVoiceCaptcha sendRef s =
forM_ (voiceCaptchaGenerator opts) $ \script ->
void . forkIO $ do
voiceResult <- try $ readProcess script [s] "" :: IO (Either SomeException String)
case voiceResult of
Right r -> case lines r of
(filePath : durationStr : _)
| not (null filePath), Just duration <- readMaybe durationStr -> do
sendComposedMessageFile cc sendRef Nothing (MCVoice "" duration) (CF.plain filePath)
void (try $ removeFile filePath :: IO (Either SomeException ()))
_ -> logError "voice captcha generator: unexpected output"
Left e -> logError $ "voice captcha generator error: " <> tshow e
getCaptchaContent :: String -> IO MsgContent
getCaptchaContent s = case captchaGenerator opts of
Nothing -> pure $ MCText $ T.pack s
Just script -> content <$> readProcess script [s] ""
where
content r = case T.lines $ T.pack r of
[] -> textMsg
"" : _ -> textMsg
img : _ -> MCImage "" $ ImageData img
textMsg = MCText $ T.pack s
approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO ()
approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do
gli_ <- join . eitherToMaybe <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId)
@@ -598,16 +630,34 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
dePendingMemberMsg :: GroupInfo -> GroupMember -> ChatItemId -> Text -> IO ()
dePendingMemberMsg g@GroupInfo {groupId, groupProfile = GroupProfile {displayName = n}} m@GroupMember {memberProfile = LocalProfile {displayName}} ciId msgText
| memberRequiresCaptcha a m = do
ts <- getCurrentTime
atomically (TM.lookup (groupMemberId' m) $ pendingCaptchas env) >>= \case
Just PendingCaptcha {captchaText, sentAt, attempts}
| ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired $ attempts - 1
| matchCaptchaStr captchaText msgText -> do
sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just $ groupMemberId' m)) [(Just ciId, MCText $ "Correct, you joined the group " <> n)]
approvePendingMember a g m
| attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts
| otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts
Nothing -> sendMemberCaptcha g m (Just ciId) noCaptcha 0
let gmId = groupMemberId' m
sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)
-- /audio is matched as text, not as DirectoryCmd, because it is only valid
-- in group context at captcha stage, while DirectoryCmd is for DM commands.
isAudioCmd = T.strip msgText == "/audio"
cmd = fromRight (ADC SDRUser DCUnknownCommand) $ A.parseOnly (directoryCmdP <* A.endOfInput) $ T.strip msgText
atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case
Nothing ->
let mode = if isAudioCmd then CMAudio else CMText
in sendMemberCaptcha g m (Just ciId) noCaptcha 0 mode
Just pc@PendingCaptcha {captchaText, sentAt, attempts, captchaMode}
| isAudioCmd -> case captchaMode of
CMText -> do
atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env
sendVoiceCaptcha sendRef (T.unpack captchaText)
CMAudio ->
sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)]
| otherwise -> case cmd of
ADC SDRUser (DCSearchGroup _) -> do
ts <- getCurrentTime
if
| ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired (attempts - 1) captchaMode
| matchCaptchaStr captchaText msgText -> do
sendComposedMessages_ cc sendRef [(Just ciId, MCText $ "Correct, you joined the group " <> n)]
approvePendingMember a g m
| attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts
| otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts captchaMode
_ -> sendComposedMessages_ cc sendRef [(Just ciId, MCText unknownCommand)]
| otherwise = approvePendingMember a g m
where
a = groupMemberAcceptance g
@@ -619,11 +669,19 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
atomically $ TM.delete gmId $ pendingCaptchas env
logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g
r -> logError $ "unexpected remove member response: " <> tshow r
captchaExpired :: Text
captchaExpired = "Captcha expired, please try again."
wrongCaptcha :: Int -> Text
wrongCaptcha attempts
| attempts == maxCaptchaAttempts - 1 = "Incorrect text, please try again - this is your last attempt."
| otherwise = "Incorrect text, please try again."
noCaptcha :: Text
noCaptcha = "Unexpected message, please try again."
audioAlreadyEnabled :: Text
audioAlreadyEnabled = "Audio captcha is already enabled."
unknownCommand :: Text
unknownCommand = "Unknown command, please enter captcha text."
tooManyAttempts :: Text
tooManyAttempts = "Too many failed attempts, you can't join group."
memberRequiresCaptcha :: DirectoryMemberAcceptance -> GroupMember -> Bool
+1
View File
@@ -766,6 +766,7 @@ Group:
- itemTimed: [CITimed](#citimed)?
- itemLive: bool?
- userMention: bool
- hasLink: bool
- deletable: bool
- editable: bool
- forwardedByMember: int64?
+10 -1
View File
@@ -2,6 +2,15 @@ packages: .
-- packages: . ../simplexmq
-- packages: . ../simplexmq ../direct-sqlcipher ../sqlcipher-simple
-- uncomment two sections below to run tests with coverage
-- package *
-- coverage: True
-- library-coverage: True
-- package attoparsec
-- coverage: False
-- library-coverage: False
index-state: 2023-12-12T00:00:00Z
package cryptostore
@@ -12,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: ca26c69937083deee43b8b2200ec9ef4c004ceac
tag: 8fdc0703bc9b89dae8b2fe6820b705580a669281
source-repository-package
type: git
+25
View File
@@ -7,6 +7,31 @@ revision: 25.07.2025
# Contributing guide
## Focus on user problems
We do not make code changes to improve code - any change must address a specific user problem or request.
## Discuss the plans as early as possible
Please discuss the problem you want to solve and your detailed implementation plan with the project team prior to contributing, to avoid wasted time and additional changes. Acceptance of your contribution depends on your willingness and ability to iterate the proposed contribution to achieve the required quality level, coding style, test coverage, and alignment with user requirements as they are understood by the project team.
## Follow project structure, coding style and approaches
./contributing/PROJECT.md has information about the structure of this `simplex-chat` repository.
./contributing/CODE.md has details about general requirements common for `simplexmq` and `simplex-chat` repositories.
This files can be used with LLM prompts, e.g. if you use Claude Code you can create CLAUDE.md file in project root importing content from these files:
```markdown
@README.md
@docs/CONTRIBUTING.md
@docs/contributing/PROJECT.md
@docs/contributing/CODE.md
```
For Android/Desktop and iOS apps you can additionally import `apps/multiplatform/README.md` and `apps/ios/README.md`.
## Compiling with SQLCipher encryption enabled
Add `cabal.project.local` to project root with the location of OpenSSL headers and libraries and flag setting encryption mode:
+4 -4
View File
@@ -1,18 +1,18 @@
---
title: Transparency Reports
permalink: /transparency/index.html
revision: 15.01.2025
revision: 09.02.2026
---
# Transparency Reports
**Updated**: Jan 15, 2025
**Updated**: Feb 09, 2026
SimpleX Chat Ltd. is a company registered in the UK it develops communication software enabling users to operate and communicate via SimpleX network, without user profile identifiers of any kind, and without having their data hosted by any network infrastructure operators.
This page will include any and all reports on requests for user data.
*To date, we received none*.
In 2025 we received 12 requests from law enforcement of different countries. No responsive information was identified/provided.
In 2024 we received enquiries from several law enforcement agencies seeking information on our procedures for handling data requests. We responded by noting that we operate under the UK law and will consider such requests pursuant to UK law.
@@ -29,6 +29,6 @@ Our objective is to consistently ensure that no user data and absolute minimum o
- Trail of Bits, SimpleX cryptography and networking, [October 2022](../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
- Trail of Bits, the cryptographic review of SimpleX protocols design, [July 2024](../blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
Have a more specific question? Reach out to us via [SimpleX Chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) or via email [chat@simplex.chat](mailto:chat@simplex.chat).
Have a more specific question? Reach out to us via [SimpleX Chat](https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw) or via email [chat@simplex.chat](mailto:chat@simplex.chat).
For any sensitive questions please use SimpleX Chat or encrypted email messages using the key for this address from [keys.openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat) (its fingerprint is `FB44 AF81 A45B DE32 7319 797C 8510 7E35 7D4A 17FC`) and make your key available for a secure reply.
+94
View File
@@ -0,0 +1,94 @@
# Coding and building
This file provides guidance on coding style and approaches and on building the code.
## Code Style, Formatting and Approaches
The project uses **fourmolu** for Haskell code formatting. Configuration is in `fourmolu.yaml`.
**Key formatting rules:**
- 2-space indentation
- Trailing function arrows, commas, and import/export style
- Record brace without space: `{field = value}`
- Single newline between declarations
- Never use unicode symbols
- Inline `let` style with right-aligned `in`
**Format code before committing:**
```bash
# Format a single file
fourmolu -i src/Simplex/Messaging/Protocol.hs
```
Some files that use CPP language extension cannot be formatted as a whole, so individual code fragments need to be formatted.
**Follow existing code patterns:**
- Match the style of surrounding code
- Use qualified imports with short aliases (e.g., `import qualified Data.ByteString.Char8 as B`)
- Use record syntax for types with multiple fields
- Prefer explicit pattern matching over partial functions
**Comments policy:**
- Avoid redundant comments that restate what the code already says
- Only comment on non-obvious design decisions or tricky implementation details
- Function names and type signatures should be self-documenting
- Do not add comments like "wire format encoding" (Encoding class is always wire format) or "check if X" when the function name already says that
- Assume a competent Haskell reader
**Diff and refactoring:**
- Avoid unnecessary changes and code movements
- Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring
- Aim to minimize the code changes - do what is minimally required to solve users' problems
**Document and code structure:**
- **Never move existing code or sections around** - add new content at appropriate locations without reorganizing existing structure.
- When adding new sections to documents, continue the existing numbering scheme.
- Minimize diff size - prefer small, targeted changes over reorganization.
**Code analysis and review:**
- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug.
- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication.
- Do not save time on analysis. Read every function in the data flow even when the interface seems clear — wrong assumptions about internals are the main source of missed bugs.
### Haskell Extensions
- `StrictData` enabled by default
- Use STM for safe concurrency
- Assume concurrency in PostgreSQL queries
- Comprehensive warning flags with strict pattern matching
## Build Commands
```bash
# Standard build
cabal build
# Fast build
cabal build --ghc-options -O0
# Build specific executables
cabal build exe:simplex-chat
# Build with PostgreSQL client support
cabal build -fclient_postgres
# Client-only library build (no server code)
cabal build -fclient_library
# Find binary location
cabal list-bin exe:simplex-chat
```
### Cabal Flags
- `swift`: Enable Swift JSON format
- `client_library`: Build without server code
- `client_postgres`: Use PostgreSQL instead of SQLite for agent persistence
- `server_postgres`: PostgreSQL support for server queue/notification store
## External Dependencies
Custom forks specified in `cabal.project`:
- `aeson`, `hs-socks` (SimpleX forks)
- `direct-sqlcipher`, `sqlcipher-simple` (encrypted SQLite)
- `warp`, `warp-tls` (HTTP server)
+92
View File
@@ -0,0 +1,92 @@
# SimpleX-Chat repository
This file provides guidance on the project structure to help working with code in this repository.
## Project Overview
SimpleX Chat is a decentralized, privacy-focused messaging platform with **no user identifiers**. Users are identified by disposable, per-connection message queue addresses instead of any persistent ID.
**Key components:**
- **Core library** (Haskell): `src/Simplex/Chat/` - chat protocol, controller, message handling, database storage
- **Terminal CLI**: `src/Simplex/Chat/Terminal/`
- **Mobile apps**: `apps/multiplatform/` (Kotlin Compose Multiplatform for Android/Desktop)
- **iOS app**: `apps/ios/` (SwiftUI)
- **Bot framework**: `bots/`, `packages/simplex-chat-nodejs/`
- **Website**: `website/` (11ty + Tailwind CSS)
## Specifications
Chat protocol: docs/protocol/simplex-chat.md
RFCs: docs/rfcs
## Core Haskell Modules
- `Controller.hs` - Main chat controller, orchestrates all chat operations
- `Types.hs` - Core type definitions (contacts, groups, messages, profiles)
- `Protocol.hs` - Chat protocol encoding/decoding
- `Messages.hs` - Message types and handling
- `Store/` - Database layer (SQLite by default, PostgreSQL optional)
- `Messages.hs` - Message storage
- `Groups.hs` - Group storage
- `Direct.hs` - Direct chat storage
- `Connections.hs` - Connection management
- `Mobile.hs` - FFI interface
- `Library/` - commands and events processing
- `Commands.hs` - all supported chat commands. They can be sent via CLI or via FFI functions.
- `Subscriber.hs` - processing events from the [agent](../../../simplexmq/src/Simplex/Messaging/Agent/Protocol.hs)
### Database Migrations
SQLite migrations are in `src/Simplex/Chat/Store/SQLite/Migrations/`. PostgreSQL migrations are in `src/Simplex/Chat/Store/Postgres/Migrations/`. Each migration is a separate module named `M{YYYYMMDD}_{description}.hs`.
**Important:** The `chat_schema.sql` files in both migration directories are **auto-generated by tests** - do not edit them directly. They reflect the final schema state after all migrations are applied.
When creating a new migration:
1. Create the migration module (e.g., `M20260122_feature.hs`)
2. Register it in the corresponding `Migrations.hs` file
3. Add the module to `simplex-chat.cabal` under exposed-modules
4. Schema files will be updated automatically when tests are run
### Test Structure
Tests are in `tests/`:
- `ChatTests/` - Integration tests (Direct, Groups, Files, Profiles)
- `ProtocolTests.hs` - Protocol encoding/decoding tests
- `JSONTests.hs` - JSON serialization tests
- `Bots/` - Bot-specific tests
## Key Dependencies
The project uses several custom forks managed via `cabal.project`:
- `simplexmq` - Core SimpleX Messaging Protocol (separate [repo](../../../simplexmq/README.md))
- `direct-sqlcipher` - SQLite with encryption
- `aeson` - JSON serialization (custom fork)
## Android/Desktop (Kotlin Multiplatform)
```bash
cd apps/multiplatform
# Build Android debug APK
./gradlew assembleDebug
# Build desktop
./gradlew :desktop:packageDistributionForCurrentOS
# Run Android tests
./gradlew connectedAndroidTest
```
### iOS
Open `apps/ios/SimpleX.xcodeproj` in Xcode. Build targets include the main app, Share Extension, and Notification Service Extension.
### Website
```bash
cd website
npm install
npm run start # Dev server
npm run build # Production build
```
@@ -778,6 +778,7 @@ export interface CIMeta {
itemTimed?: CITimed
itemLive?: boolean
userMention: boolean
hasLink: boolean
deletable: boolean
editable: boolean
forwardedByMember?: number // int64
+520
View File
@@ -0,0 +1,520 @@
# Audio Captcha Improvements Plan
## Table of Contents
1. [Executive Summary](#executive-summary)
2. [High-Level Design](#high-level-design)
3. [Detailed Implementation Plan](#detailed-implementation-plan)
4. [Test Updates](#test-updates)
5. [Files Changed](#files-changed)
---
## Executive Summary
Improve the audio captcha feature by:
1. **Proper command parsing** — add `DCCaptchaMode CaptchaMode` constructor to `DirectoryCmd` GADT, using existing Attoparsec parsing infrastructure
2. **Audio captcha retry** — when user switches to audio mode, subsequent retries send voice captcha (not image)
3. **Make `/audio` clickable** — use `/'audio'` format for clickable command in chat UI
---
## High-Level Design
```
┌──────────────────────────────────────────────────────────────────┐
│ CaptchaMode (Events.hs) │
├──────────────────────────────────────────────────────────────────┤
│ CMText -- default image/text captcha │
│ CMAudio -- voice captcha mode │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ PendingCaptcha State │
├──────────────────────────────────────────────────────────────────┤
│ captchaText :: Text -- the captcha answer │
│ sentAt :: UTCTime -- when captcha was sent │
│ attempts :: Int -- number of attempts │
│ captchaMode :: CaptchaMode -- current mode (CMText/CMAudio) │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ DirectoryCmd (Events.hs) │
├──────────────────────────────────────────────────────────────────┤
│ DCCaptchaMode :: CaptchaMode -> DirectoryCmd 'DRUser │
│ (integrated into existing GADT, parsed via directoryCmdP) │
└──────────────────────────────────────────────────────────────────┘
Flow:
1. User joins group → sendMemberCaptcha (image) + captchaNotice with /'audio'
2. User sends /audio → parsed as DCCaptchaMode CMAudio → set captchaMode=CMAudio, sendVoiceCaptcha
3. User sends wrong answer:
- captchaMode=CMText → send new IMAGE captcha
- captchaMode=CMAudio → send new VOICE captcha ← NEW BEHAVIOR
4. User sends correct answer → approve member
Message parsing flow (in Service.hs dePendingMemberMsg):
┌─────────────────────────────────────────────────────────────────┐
│ 1. Parse msgText with directoryCmdP (existing infrastructure) │
│ ↓ │
│ 2. TM.lookup pendingCaptcha (ONCE, not per-branch) │
│ ↓ │
│ ├─ Nothing → sendMemberCaptcha with mode from parsed cmd │
│ └─ Just pc → case on parsed cmd: │
│ ├─ DCCaptchaMode CMAudio → set mode, send voice captcha │
│ ├─ DCSearchGroup _ → captcha answer (verify/retry) │
│ └─ _ → unknown command (error message) │
└─────────────────────────────────────────────────────────────────┘
```
---
## Detailed Implementation Plan
### 3.1 Add `CaptchaMode` type in Events.hs
**File:** `apps/simplex-directory-service/src/Directory/Events.hs`
**Location:** After `DirectoryHelpSection` (line 146)
**Add:**
```haskell
data CaptchaMode = CMText | CMAudio
deriving (Show)
```
**Update exports (line 10-19):**
```haskell
module Directory.Events
( DirectoryEvent (..),
DirectoryCmd (..),
ADirectoryCmd (..),
DirectoryHelpSection (..),
CaptchaMode (..),
DirectoryRole (..),
SDirectoryRole (..),
crDirectoryEvent,
directoryCmdP,
directoryCmdTag,
)
where
```
---
### 3.2 Add `DCCaptchaMode_` tag in Events.hs
**File:** `apps/simplex-directory-service/src/Directory/Events.hs`
**Location:** In `DirectoryCmdTag` GADT (after line 127, before admin commands)
**Add:**
```haskell
DCCaptchaMode_ :: DirectoryCmdTag 'DRUser
```
---
### 3.3 Add `DCCaptchaMode` constructor in Events.hs
**File:** `apps/simplex-directory-service/src/Directory/Events.hs`
**Location:** In `DirectoryCmd` GADT (after line 160, with other user commands)
**Add:**
```haskell
DCCaptchaMode :: CaptchaMode -> DirectoryCmd 'DRUser
```
---
### 3.4 Add "audio" tag parsing in Events.hs
**File:** `apps/simplex-directory-service/src/Directory/Events.hs`
**Location:** In `tagP` function (after line 205, in user commands section)
**Add:**
```haskell
"audio" -> u DCCaptchaMode_
```
---
### 3.5 Add `DCCaptchaMode_` case in `cmdP`
**File:** `apps/simplex-directory-service/src/Directory/Events.hs`
**Location:** In `cmdP` function (after line 237, with other simple commands)
**Add:**
```haskell
DCCaptchaMode_ -> pure $ DCCaptchaMode CMAudio
```
---
### 3.6 Add `DCCaptchaMode` case in `directoryCmdTag`
**File:** `apps/simplex-directory-service/src/Directory/Events.hs`
**Location:** In `directoryCmdTag` function (after line 316)
**Add:**
```haskell
DCCaptchaMode _ -> "audio"
```
---
### 3.7 Update `PendingCaptcha` with `captchaMode` field
**File:** `apps/simplex-directory-service/src/Directory/Service.hs`
**Location:** Lines 103-107
**Before:**
```haskell
data PendingCaptcha = PendingCaptcha
{ captchaText :: Text,
sentAt :: UTCTime,
attempts :: Int
}
```
**After:**
```haskell
data PendingCaptcha = PendingCaptcha
{ captchaText :: Text,
sentAt :: UTCTime,
attempts :: Int,
captchaMode :: CaptchaMode
}
```
---
### 3.8 Update import in Service.hs
**File:** `apps/simplex-directory-service/src/Directory/Service.hs`
**Location:** Line 41
**Before:**
```haskell
import Directory.Events
```
**After (no change needed):** The implicit import already imports all exports including the new `CaptchaMode`.
---
### 3.9 Update `sendMemberCaptcha` signature and implementation
**File:** `apps/simplex-directory-service/src/Directory/Service.hs`
**Location:** Function `sendMemberCaptcha` (lines 569-589)
**Before:**
```haskell
sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> IO ()
sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts = do
s <- getCaptchaStr captchaLength ""
mc <- getCaptcha s
sentAt <- getCurrentTime
let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1}
atomically $ TM.insert gmId captcha $ pendingCaptchas env
sendCaptcha mc
where
getCaptcha s = case captchaGenerator opts of
Nothing -> pure textMsg
Just script -> content <$> readProcess script [s] ""
where
textMsg = MCText $ T.pack s
content r = case T.lines $ T.pack r of
[] -> textMsg
"" : _ -> textMsg
img : _ -> MCImage "" $ ImageData img
sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)
sendCaptcha mc = sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)]
gmId = groupMemberId' m
```
**After:**
```haskell
sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> CaptchaMode -> IO ()
sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts mode = do
s <- getCaptchaStr captchaLength ""
sentAt <- getCurrentTime
let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1, captchaMode = mode}
atomically $ TM.insert gmId captcha $ pendingCaptchas env
case mode of
CMAudio -> do
sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText)]
sendVoiceCaptcha sendRef s
CMText -> do
mc <- getCaptcha s
sendCaptcha mc
where
getCaptcha s = case captchaGenerator opts of
Nothing -> pure textMsg
Just script -> content <$> readProcess script [s] ""
where
textMsg = MCText $ T.pack s
content r = case T.lines $ T.pack r of
[] -> textMsg
"" : _ -> textMsg
img : _ -> MCImage "" $ ImageData img
sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)
sendCaptcha mc = sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)]
gmId = groupMemberId' m
```
---
### 3.10 Update `dePendingMember` call site
**File:** `apps/simplex-directory-service/src/Directory/Service.hs`
**Location:** Line 561
**Before:**
```haskell
| memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0
```
**After:**
```haskell
| memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 CMText
```
---
### 3.11 Make `/audio` clickable in `captchaNotice`
**File:** `apps/simplex-directory-service/src/Directory/Service.hs`
**Location:** `dePendingMember` function, `captchaNotice` definition (lines 565-567)
**Before:**
```haskell
captchaNotice =
"Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "."
<> if isJust (voiceCaptchaGenerator opts) then "\nSend /audio to receive a voice captcha." else ""
```
**After:**
```haskell
captchaNotice =
"Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "."
<> if isJust (voiceCaptchaGenerator opts) then "\nSend /'audio' to receive a voice captcha." else ""
```
---
### 3.12 Refactor `dePendingMemberMsg` with inverted structure
**File:** `apps/simplex-directory-service/src/Directory/Service.hs`
**Location:** `dePendingMemberMsg` function (lines 618-656)
**Key changes:**
1. Parse command FIRST using existing `directoryCmdP`
2. Do TM.lookup ONCE (not per-branch)
3. Case on lookup result, then on command inside
**Before:**
```haskell
dePendingMemberMsg :: GroupInfo -> GroupMember -> ChatItemId -> Text -> IO ()
dePendingMemberMsg g@GroupInfo {groupId, groupProfile = GroupProfile {displayName = n}} m@GroupMember {memberProfile = LocalProfile {displayName}} ciId msgText
| memberRequiresCaptcha a m = do
let gmId = groupMemberId' m
sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)
if T.toLower (T.strip msgText) == "/audio"
then
atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case
Just PendingCaptcha {captchaText} ->
sendVoiceCaptcha sendRef (T.unpack captchaText)
Nothing -> sendMemberCaptcha g m (Just ciId) noCaptcha 0
else do
ts <- getCurrentTime
atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case
Just PendingCaptcha {captchaText, sentAt, attempts}
| ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired $ attempts - 1
| matchCaptchaStr captchaText msgText -> do
sendComposedMessages_ cc sendRef [(Just ciId, MCText $ "Correct, you joined the group " <> n)]
approvePendingMember a g m
| attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts
| otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts
Nothing -> sendMemberCaptcha g m (Just ciId) noCaptcha 0
| otherwise = approvePendingMember a g m
where
a = groupMemberAcceptance g
rejectPendingMember rjctNotice = do
let gmId = groupMemberId' m
sendComposedMessages cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [MCText rjctNotice]
sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case
Right (CRUserDeletedMembers _ _ (_ : _) _) -> do
atomically $ TM.delete gmId $ pendingCaptchas env
logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g
r -> logError $ "unexpected remove member response: " <> tshow r
captchaExpired = "Captcha expired, please try again."
wrongCaptcha attempts
| attempts == maxCaptchaAttempts - 1 = "Incorrect text, please try again - this is your last attempt."
| otherwise = "Incorrect text, please try again."
noCaptcha = "Unexpected message, please try again."
tooManyAttempts = "Too many failed attempts, you can't join group."
```
**After:**
```haskell
dePendingMemberMsg :: GroupInfo -> GroupMember -> ChatItemId -> Text -> IO ()
dePendingMemberMsg g@GroupInfo {groupId, groupProfile = GroupProfile {displayName = n}} m@GroupMember {memberProfile = LocalProfile {displayName}} ciId msgText
| memberRequiresCaptcha a m = do
let gmId = groupMemberId' m
sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)
cmd = fromRight (ADC SDRUser DCUnknownCommand) $ A.parseOnly (directoryCmdP <* A.endOfInput) $ T.strip msgText
atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case
Nothing ->
let mode = case cmd of ADC SDRUser (DCCaptchaMode CMAudio) -> CMAudio; _ -> CMText
in sendMemberCaptcha g m (Just ciId) noCaptcha 0 mode
Just pc@PendingCaptcha {captchaText, sentAt, attempts, captchaMode} -> case cmd of
ADC SDRUser (DCCaptchaMode CMAudio) -> do
atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env
sendVoiceCaptcha sendRef (T.unpack captchaText)
ADC SDRUser (DCSearchGroup _) -> do
ts <- getCurrentTime
if
| ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired (attempts - 1) captchaMode
| matchCaptchaStr captchaText msgText -> do
sendComposedMessages_ cc sendRef [(Just ciId, MCText $ "Correct, you joined the group " <> n)]
approvePendingMember a g m
| attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts
| otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts captchaMode
_ -> sendComposedMessages_ cc sendRef [(Just ciId, MCText unknownCommand)]
| otherwise = approvePendingMember a g m
where
a = groupMemberAcceptance g
rejectPendingMember rjctNotice = do
let gmId = groupMemberId' m
sendComposedMessages cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [MCText rjctNotice]
sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case
Right (CRUserDeletedMembers _ _ (_ : _) _) -> do
atomically $ TM.delete gmId $ pendingCaptchas env
logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g
r -> logError $ "unexpected remove member response: " <> tshow r
captchaExpired = "Captcha expired, please try again."
wrongCaptcha attempts
| attempts == maxCaptchaAttempts - 1 = "Incorrect text, please try again - this is your last attempt."
| otherwise = "Incorrect text, please try again."
noCaptcha = "Unexpected message, please try again."
unknownCommand = "Unknown command, please enter captcha text."
tooManyAttempts = "Too many failed attempts, you can't join group."
```
---
### 3.13 Add imports in Service.hs
**File:** `apps/simplex-directory-service/src/Directory/Service.hs`
**Location:** After existing imports (around line 28)
**Add:**
```haskell
import qualified Data.Attoparsec.Text as A
import Data.Either (fromRight)
```
**Note:** `T.strip` is already available via the existing `import qualified Data.Text as T`.
---
## Test Updates
**File:** `tests/Bots/DirectoryTests.hs`
### 4.1 Update expected output for clickable command
**Location:** Line 1278 (or wherever `"Send /audio"` appears)
**Before:**
```haskell
cath <## "Send /audio to receive a voice captcha."
```
**After:**
```haskell
cath <## "Send /'audio' to receive a voice captcha."
```
### 4.2 Add test for audio captcha retry behavior
**Location:** New test function `testVoiceCaptchaRetry` after `testVoiceCaptchaScreening`
**Strategy:** Add test that verifies wrong answer after `/audio` sends voice retry (not image).
```haskell
testVoiceCaptchaRetry :: HasCallStack => TestParams -> IO ()
testVoiceCaptchaRetry ps = do
-- Setup similar to testVoiceCaptchaScreening...
-- After receiving initial image captcha and switching to audio:
-- cath requests audio captcha
cath #> "#privacy (support) /audio"
cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)"
cath <#. "#privacy (support) 'SimpleX Directory'> sends file "
cath <##. "use /fr 1"
-- cath sends WRONG answer after switching to audio mode
cath #> "#privacy (support) wrong_answer"
cath <# "#privacy (support) 'SimpleX Directory'!> > cath wrong_answer"
cath <## " Incorrect text, please try again."
-- KEY ASSERTION: retry sends VOICE captcha (not image) because captchaMode=CMAudio
cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)"
cath <#. "#privacy (support) 'SimpleX Directory'> sends file "
cath <##. "use /fr 2"
```
---
## Files Changed
| File | Changes |
|------|---------|
| `apps/simplex-directory-service/src/Directory/Events.hs` | Add `CaptchaMode` type; add `DCCaptchaMode_` tag; add `DCCaptchaMode` constructor; add "audio" tag parsing; add `cmdP` case; add `directoryCmdTag` case; export `directoryCmdP`; update exports |
| `apps/simplex-directory-service/src/Directory/Service.hs` | Add imports (`Data.Attoparsec.Text`, `Data.Either.fromRight`); update `PendingCaptcha` with `captchaMode :: CaptchaMode`; update `sendMemberCaptcha` signature; refactor `dePendingMemberMsg` with inverted structure; make `/audio` clickable |
| `tests/Bots/DirectoryTests.hs` | Update expected output (`/'audio'`); add `testVoiceCaptchaRetry` |
---
## Summary of Changes
1. **New type in Events.hs:**
- `data CaptchaMode = CMText | CMAudio`
2. **New constructor in DirectoryCmd GADT:**
- `DCCaptchaMode :: CaptchaMode -> DirectoryCmd 'DRUser`
- Uses existing Attoparsec parsing infrastructure via `directoryCmdP`
3. **State tracking (Service.hs):**
- `PendingCaptcha { ..., captchaMode :: CaptchaMode }`
4. **Refactored `dePendingMemberMsg` (Service.hs):**
- Parses command FIRST using `directoryCmdP`
- Does `TM.lookup` ONCE (inverted structure, no duplication)
- `Nothing` case: send new captcha in mode derived from command
- `Just pc` case: switch on command type
- `DCCaptchaMode CMAudio` → set mode, send voice captcha
- `DCSearchGroup _` → captcha answer (verify/retry)
- `_` → unknown command (error message)
5. **Updated `sendMemberCaptcha` (Service.hs):**
- Takes `CaptchaMode` parameter instead of `Bool`
- Sends voice or image captcha based on mode
6. **Clickable command:**
- `"Send /'audio'"` instead of `"Send /audio"`
7. **Test coverage:**
- `testVoiceCaptchaScreening` (updated): verify clickable command format
- `testVoiceCaptchaRetry` (new): verify retry behavior with `captchaMode` persistence
+79
View File
@@ -0,0 +1,79 @@
# Directory Modules: Test Coverage Report
## Final Coverage
| Module | Expressions | Coverage | Gap |
|---|---|---|---|
| **Captcha** | 84/84 | **100%** | -- |
| **Search** | 3/3 | **100%** | -- |
| **BlockedWords** | 158/158 | **100%** | -- |
| **Events** | 527/559 | **94%** | 32 expr |
| **Options** | 223/291 | **76%** | 68 expr |
| **Store** | 1137/1306 | **87%** | 169 expr |
| **Listing** | 379/650 | **58%** | 271 expr |
84 tests, 0 failures.
## What was covered
Tests added to `tests/Bots/DirectoryTests.hs`:
- **Search**: `SearchRequest` field selectors (`searchType`, `searchTime`, `lastGroup`)
- **BlockedWords**: `BlockedWordsConfig` field selectors, `removeTriples` with `'\0'` input to force initial `False` argument
- **Options**: `directoryOpts` parser via `execParserPure` (minimal args, non-default args, all `MigrateLog` variants), `mkChatOpts` remaining fields
- **Events**: command parser edge cases (`/`, `/filter 1 name=all`, `/submit`, moderate/strong presets), `Show` instances for `DirectoryCmdTag`, `DirectoryCmd`, `SDirectoryRole`, `DirectoryHelpSection`, `DirectoryEvent`, `ADirectoryCmd` (including `showList`), `DCApproveGroup` field selectors via `OverloadedRecordDot`, `CEvtChatErrors` path
- **Store**: `Show` instances for `GroupRegStatus` constructors, `ProfileCondition`, `noJoinFilter`, `GroupReg.createdAt` field
- **Listing**: `DirectoryEntryType` JSON round-trip with field selectors
Source changes:
- `Directory/Options.hs`: exported `directoryOpts`
- `Directory/Events.hs`: exported `DirectoryCmdTag (..)`
## Why not 100%
### Events (32 expr remaining)
**Field selectors (9 expr)** on `DEGroupInvitation`, `DEServiceJoinedGroup`, `DEGroupUpdated` -- need `Contact`, `GroupInfo`, `GroupMember` types which have 20+ nested required fields each with no test constructors available.
**`crDirectoryEvent_` branches (3 expr)**: `DEItemDeleteIgnored`, `DEUnsupportedMessage`, `CEvtMessageError` -- need `AChatItem` or `User`, both strict-data types with deep dependency chains impossible to construct in unit tests.
**`DCSubmitGroup` paths (2 expr)**: constructor and `directoryCmdTag` case -- need a valid `ConnReqContact` (SMP queue URI with cryptographic keys).
**Lazy `fail` strings (2 expr)**: `"bad command tag"` and `"bad help section"` -- Attoparsec discards the string argument to `fail` without evaluating it. Inherently uncoverable by HPC.
### Options (68 expr remaining)
**Parser metadata strings (~50 expr)**: `metavar` and `help` string literals in `optparse-applicative` option declarations are evaluated lazily by the library. `execParserPure` constructs the parser but doesn't force help strings unless `--help` is invoked.
**`getDirectoryOpts` (~10 expr)**: wraps `execParser` which reads process `argv` -- can't unit-test without spawning a process.
**`parseKnownGroup` internals (~8 expr)**: the `--owners-group` arg is parsed but the `KnownContacts` parser internals are instrumented separately.
### Store (169 expr remaining)
**DB operations (~150 expr)**: `withDB'` wrappers, SQL query strings, error message literals inside database functions (`setGroupStatusStore`, `setGroupRegOwnerStore`, `searchListedGroups`, `getAllGroupRegs_`, etc.) -- all require a running SQLite database with realistic data.
**Pagination branches (~15 expr)**: `searchListedGroups` and `getAllGroupRegs_` cursor pagination -- need multi-page result sets.
**Parser failure (~4 expr)**: `GroupRegStatus` `strDecode` failure path -- needs malformed stored data.
### Listing (271 expr remaining)
**Image processing (~80 expr)**: `imgFileData`, image file Base64 encoding paths -- require groups with profile images.
**Listing generation (~120 expr)**: `generateListing`, `groupDirectoryEntry` -- require `GroupInfo` (21+ fields), `GroupLink`, `CreatedLinkContact` types with deep nesting into chat protocol internals.
**Field selectors (~40 expr)**: `DirectoryEntry` fields (`displayName`, `fullName`, `image`, `memberCount`, etc.) -- need full `DirectoryEntry` construction which requires `CreatedLinkContact`.
**TH-generated JSON (~30 expr)**: Template Haskell `deriveJSON` expressions are marked as runtime-uncovered by HPC despite executing at compile time.
## Summary
All remaining gaps fall into three categories:
1. **DB integration paths** -- require a running database (Store)
2. **Complex chat protocol types** -- types with 20+ required nested fields (Events, Listing)
3. **Lazy evaluation artifacts** -- HPC can't observe values that are never forced at runtime (Options `help` strings, Attoparsec `fail` strings, TH-generated code)
None are testable with pure unit tests without either standing up a database or constructing massive type hierarchies.
@@ -38,6 +38,27 @@
</description>
<releases>
<release version="6.4.10" date="2026-01-29">
<url type="details">https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html</url>
<description>
<p>New in v6.4.10:</p>
<ul>
<li>improve error handling</li>
</ul>
<p>New in v6.4-6.4.8:</p>
<ul>
<li>new UX to connect.</li>
<li>review new group members.</li>
<li>chat with group admins.</li>
<li>new UI languages: Catalan, Indonesian, Romanian and Vietnamese.</li>
<li>Linux app builds for aarch64 CPUs</li>
<li>UI support for bot commands.</li>
<li>support markdown hyperlinks, such as [click here](https://example.com).</li>
<li>option to remove tracking parameters from the links.</li>
<li>better information about network errors.</li>
</ul>
</description>
</release>
<release version="6.4.8" date="2025-12-11">
<url type="details">https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html</url>
<description>
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."ca26c69937083deee43b8b2200ec9ef4c004ceac" = "1p7jhxcbn95kddfwa5rjpzfx78fzic03wmy9dmh1mj3j14vyfn02";
"https://github.com/simplex-chat/simplexmq.git"."8fdc0703bc9b89dae8b2fe6820b705580a669281" = "1s3ihb8pyhvxbk1f205wmcfr7d0m7slpjq771z3zz6cvg3fyppbg";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
@@ -18,6 +18,7 @@ CMDS="curl git docker"
INIT_DIR="$PWD"
TEMPDIR="$(mktemp -d)"
PID_MAX_ORIGINAL="$(sysctl -n kernel.pid_max)"
ARCHES="${ARCHES:-aarch64 armv7a}"
@@ -32,6 +33,15 @@ cleanup() {
rm -rf -- "${TEMPDIR}"
docker rm --force "${CONTAINER_NAME}" 2>/dev/null || :
docker image rm "${IMAGE_NAME}" 2>/dev/null || :
if [ "$(sysctl -n kernel.pid_max)" != "$PID_MAX_ORIGINAL" ]; then
printf 'Adjusting kernel.pid_max back to original value...\n'
if $SUDO sysctl kernel.pid_max="$PID_MAX_ORIGINAL"; then
printf 'Successfully adjusted kernel.pid_max\n'
else
printf 'Failed to adjust kernel.pid_max. Please set the value manually with: %s sysctl kernel.pid_max=%s\n' "$SUDO" "$PID_MAX_ORIGINAL"
fi
fi
}
trap 'cleanup' EXIT INT
@@ -52,6 +62,19 @@ check() {
exit 1
fi
if [ "$PID_MAX_ORIGINAL" -gt 65535 ]; then
SUDO=$(command -v sudo || command -v doas) || { echo "No sudo or doas"; exit 1; }
printf 'Adjusting kernel.pid_max value to 65535...\n'
if $SUDO sysctl kernel.pid_max=65535; then
printf 'Successfully adjusted kernel.pid_max\n'
else
printf 'Failed to adjust kernel.pid_max, aborting.\n'
exit 1
fi
fi
set -u
}
@@ -182,6 +205,9 @@ main() {
3) build core library with nix (12-24 hours).
4) build APK and compare with downloaded one
The script will ask for sudo password to adjust kernel.pid_max (needed for armv7a build)
and set it back to otiginal value when the build is done.
Continue?'
read _
+4 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 6.5.0.8
version: 6.5.0.9
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -126,6 +126,7 @@ library
Simplex.Chat.Store.Postgres.Migrations.M20251128_migrate_member_relations
Simplex.Chat.Store.Postgres.Migrations.M20251230_strict_tables
Simplex.Chat.Store.Postgres.Migrations.M20260108_chat_indices
Simplex.Chat.Store.Postgres.Migrations.M20260122_has_link
else
exposed-modules:
Simplex.Chat.Archive
@@ -275,6 +276,7 @@ library
Simplex.Chat.Store.SQLite.Migrations.M20251128_migrate_member_relations
Simplex.Chat.Store.SQLite.Migrations.M20251230_strict_tables
Simplex.Chat.Store.SQLite.Migrations.M20260108_chat_indices
Simplex.Chat.Store.SQLite.Migrations.M20260122_has_link
other-modules:
Paths_simplex_chat
hs-source-dirs:
@@ -598,6 +600,7 @@ test-suite simplex-chat-test
apps/simplex-directory-service/src
default-extensions:
StrictData
-- add -fhpc to ghc-options below to run tests with coverage
ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded
build-depends:
QuickCheck ==2.14.*
+9 -1
View File
@@ -12,7 +12,7 @@ import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Monad
import qualified Data.ByteString.Char8 as B
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as L
import qualified Data.Map.Strict as M
import Data.Maybe (isJust)
@@ -26,6 +26,7 @@ import Simplex.Chat.Protocol (MsgContent (..))
import Simplex.Chat.Store
import Simplex.Chat.Types (Contact (..), ContactId, IsContact (..), User (..))
import Simplex.Messaging.Agent.Protocol (CreatedConnLink (..))
import Simplex.Messaging.Crypto.File (CryptoFile)
import Simplex.Messaging.Encoding.String (strEncode)
import System.Exit (exitFailure)
@@ -89,6 +90,13 @@ sendComposedMessages_ cc sendRef qmcs = do
Right (CRNewChatItems {}) -> printLog cc CLLInfo $ "sent " <> show (length cms) <> " messages to " <> show sendRef
r -> putStrLn $ "unexpected send message response: " <> show r
sendComposedMessageFile :: ChatController -> SendRef -> Maybe ChatItemId -> MsgContent -> CryptoFile -> IO ()
sendComposedMessageFile cc sendRef qiId mc file = do
let cm = ComposedMessage {fileSource = Just file, quotedItemId = qiId, msgContent = mc, mentions = M.empty}
sendChatCmd cc (APISendMessages sendRef False Nothing (cm :| [])) >>= \case
Right (CRNewChatItems {}) -> printLog cc CLLInfo $ "sent file message to " <> show sendRef
r -> putStrLn $ "unexpected send message response: " <> show r
deleteMessage :: ChatController -> Contact -> ChatItemId -> IO ()
deleteMessage cc ct chatItemId = do
let cmd = APIDeleteChatItem (contactRef ct) [chatItemId] CIDMInternal
+7 -4
View File
@@ -38,7 +38,7 @@ import Text.Read (readMaybe)
import UnliftIO.Async
simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO ()
simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations, migrationBackupPath}, createBot, maintenance} chat =
simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations, migrationBackupPath, maintenance}, createBot} chat =
case logAgent of
Just level -> do
setLogLevel level
@@ -54,13 +54,16 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@Cha
u_ <- getSelectActiveUser chatStore
let backgroundMode = maintenance
cc <- newChatController db u_ cfg opts backgroundMode
u <- maybe (createActiveUser cc createBot) pure u_
forM_ (preStartHook chatHooks) ($ cc)
u <- maybe (noMaintenance >> createActiveUser cc createBot) pure u_
unless testView $ putStrLn $ "Current user: " <> userStr u
unless maintenance $ forM_ (preStartHook chatHooks) ($ cc)
runSimplexChat opts u cc chat
noMaintenance = when maintenance $ do
putStrLn "exiting: no active user in maintenance mode"
exitFailure
runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController -> IO ()) -> IO ()
runSimplexChat ChatOpts {maintenance} u cc@ChatController {config = ChatConfig {chatHooks}} chat
runSimplexChat ChatOpts {coreOptions = CoreChatOpts {maintenance}} u cc@ChatController {config = ChatConfig {chatHooks}} chat
| maintenance = wait =<< async (chat u cc)
| otherwise = do
a1 <- runReaderT (startChatController True True) cc
+8 -7
View File
@@ -2157,7 +2157,8 @@ processChatCommand vr nm = \case
(errs, ctSndMsgs :: [(Contact, SndMessage)]) <-
partitionEithers . L.toList . zipWith3' combineResults ctConns sndMsgs <$> deliverMessagesB msgReqs_
timestamp <- liftIO getCurrentTime
lift . void $ withStoreBatch' $ \db -> map (createCI db user timestamp) ctSndMsgs
let hasLink = msgContentHasLink mc $ parseMaybeMarkdownList $ msgContentText mc
lift . void $ withStoreBatch' $ \db -> map (createCI db user hasLink timestamp) ctSndMsgs
pure CRBroadcastSent {user, msgContent = mc, successes = length ctSndMsgs, failures = length errs, timestamp}
where
addContactConn :: Contact -> [(Contact, Connection)] -> [(Contact, Connection)]
@@ -2172,9 +2173,9 @@ processChatCommand vr nm = \case
combineResults (ct, _) (Right msg') (Right _) = Right (ct, msg')
combineResults _ (Left e) _ = Left e
combineResults _ _ (Left e) = Left e
createCI :: DB.Connection -> User -> UTCTime -> (Contact, SndMessage) -> IO ()
createCI db user createdAt (ct, sndMsg) =
void $ createNewSndChatItem db user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False createdAt
createCI :: DB.Connection -> User -> Bool -> UTCTime -> (Contact, SndMessage) -> IO ()
createCI db user hasLink createdAt (ct, sndMsg) =
void $ createNewSndChatItem db user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False hasLink createdAt
SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do
contactId <- withFastStore $ \db -> getContactIdByName db user cName
quotedItemId <- withFastStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg
@@ -3719,7 +3720,7 @@ processChatCommand vr nm = \case
getShortLinkConnReq :: User -> ConnShortLink m -> CM (ConnectionRequestUri m, ConnLinkData m)
getShortLinkConnReq user l = do
l' <- restoreShortLink' l
(cReq, cData) <- withAgent $ \a -> getConnShortLink a nm (aUserId user) l'
(FixedLinkData {linkConnReq = cReq}, cData) <- withAgent $ \a -> getConnShortLink a nm (aUserId user) l'
case cData of
ContactLinkData _ UserContactData {direct} | not direct -> throwChatError CEUnsupportedConnReq
_ -> pure ()
@@ -4154,7 +4155,7 @@ agentSubscriber :: CM' ()
agentSubscriber = do
q <- asks $ subQ . smpAgent
forever (atomically (readTBQueue q) >>= process)
`E.catchAny` \e -> do
`catchOwn` \e -> do
eToView' $ ChatErrorAgent (CRITICAL True $ "Message reception stopped: " <> show e) (AgentConnId "") Nothing
E.throwIO e
where
@@ -4165,7 +4166,7 @@ agentSubscriber = do
SAERcvFile -> processAgentMsgRcvFile corrId entId msg
SAESndFile -> processAgentMsgSndFile corrId entId msg
where
run action = action `catchAllErrors'` (eToView')
run action = action `catchAllOwnErrors'` eToView'
type AgentSubResult = Map ConnId (Either AgentErrorType (Maybe ClientServiceId))
+38 -23
View File
@@ -550,7 +550,6 @@ markGroupCIsDeleted user gInfo chatScopeInfo items byGroupMember_ deletedTs = do
(errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (markDeleted db) items)
unless (null errs) $ toView $ CEvtChatErrors errs
pure deletions
-- pure $ CRChatItemsDeleted user deletions byUser False
where
markDeleted db (CChatItem md ci) = do
ci' <- markGroupChatItemDeleted db user gInfo ci byGroupMember_ deletedTs
@@ -1260,7 +1259,7 @@ encodeShortLinkData d =
s'
| B.length s > 10240 = B.cons 'X' $ Z1.compress compressionLevel s
| otherwise = s
in UserLinkData s'
in UserLinkData s'
decodeShortLinkData :: J.FromJSON a => ConnLinkData c -> IO (Maybe a)
decodeShortLinkData cData
@@ -2071,9 +2070,9 @@ memberSendAction GroupInfo {useRelays, membership} events members m@GroupMember
readyMemberConn :: GroupMember -> Maybe (GroupMemberId, Connection)
readyMemberConn GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}, memberStatus}
| (connStatus == ConnReady || connStatus == ConnSndReady)
&& not (connDisabled conn)
&& not (connInactive conn)
&& memberStatus /= GSMemRejected =
&& not (connDisabled conn)
&& not (connInactive conn)
&& memberStatus /= GSMemRejected =
Just (groupMemberId, conn)
| otherwise = Nothing
readyMemberConn GroupMember {activeConn = Nothing} = Nothing
@@ -2183,14 +2182,15 @@ saveSndChatItems user cd itemsData itemTimed live = do
createdAt <- liftIO getCurrentTime
vr <- chatVersionRange
when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $
void $ withStore' (\db -> updateChatTsStats db vr user cd createdAt Nothing)
void (withStore' $ \db -> updateChatTsStats db vr user cd createdAt Nothing)
lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData)
where
createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd))
createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId}, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded} = do
ciId <- createNewSndChatItem db user cd msg content quotedItem itemForwarded itemTimed live createdAt
let hasLink_ = ciContentHasLink content (snd itemTexts)
ciId <- createNewSndChatItem db user cd msg content quotedItem itemForwarded itemTimed live hasLink_ createdAt
forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt
let ci = mkChatItem_ cd False ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False createdAt Nothing createdAt
let ci = mkChatItem_ cd False ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing createdAt
Right <$> case cd of
CDGroupSnd g _scope | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions
_ -> pure ci
@@ -2219,12 +2219,14 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} shared
userMention' = userReply || any (\CIMention {memberId} -> sameMemberId memberId membership) mentions'
in pure (mentions', userMention')
CDDirectRcv _ -> pure (M.empty, False)
cInfo' <- if (ciRequiresAttention content || contactChatDeleted cd)
then updateChatTsStats db vr user cd createdAt (memberChatStats userMention)
else pure $ toChatInfo cd
(ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt
cInfo' <-
if ciRequiresAttention content || contactChatDeleted cd
then updateChatTsStats db vr user cd createdAt (memberChatStats userMention)
else pure $ toChatInfo cd
let hasLink_ = ciContentHasLink content ft_
(ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention hasLink_ brokerTs createdAt
forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt
let ci = mkChatItem_ cd False ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention brokerTs forwardedByMember createdAt
let ci = mkChatItem_ cd False ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember createdAt
ci' <- case cd of
CDGroupRcv g _scope _m | not (null mentions') -> createGroupCIMentions db g ci mentions'
_ -> pure ci
@@ -2240,15 +2242,26 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} shared
-- TODO [mentions] optimize by avoiding unnecessary parsing
mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d
mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs =
let ts = ciContentTexts content
in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs
let ts@(_, ft_) = ciContentTexts content
hasLink_ = ciContentHasLink content ft_
in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember currentTs
mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d
mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs =
mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d
mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember currentTs =
let itemStatus = ciCreateStatus content
meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention currentTs itemTs forwardedByMember showGroupAsSender currentTs currentTs
meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender currentTs currentTs
in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, quotedItem, reactions = [], file}
ciContentHasLink :: CIContent d -> Maybe MarkdownList -> Bool
ciContentHasLink content ft_ = case ciMsgContent content of
Just mc -> msgContentHasLink mc ft_
Nothing -> False
msgContentHasLink :: MsgContent -> Maybe MarkdownList -> Bool
msgContentHasLink mc ft_ = case msgContentTag mc of
MCLink_ -> True
_ -> maybe False hasLinks ft_
createAgentConnectionAsync :: ConnectionModeI c => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> CM (CommandId, ConnId)
createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do
cmdId <- withStore' $ \db -> createCommand db user Nothing cmdFunction
@@ -2258,7 +2271,7 @@ createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do
joinAgentConnectionAsync :: User -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> CM (CommandId, ConnId)
joinAgentConnectionAsync user enableNtfs cReqUri cInfo subMode = do
cmdId <- withStore' $ \db -> createCommand db user Nothing CFJoinConn
connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo PQSupportOff subMode
connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) Nothing enableNtfs cReqUri cInfo PQSupportOff subMode
pure (cmdId, connId)
allowAgentConnectionAsync :: MsgEncodingI e => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> CM ()
@@ -2497,7 +2510,8 @@ createChatItems user itemTs_ dirsCIContents = do
createACIs db itemTs createdAt (cd, showGroupAsSender, contents) = map createACI contents
where
createACI (content, sharedMsgId) = do
ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId itemTs createdAt
let hasLink_ = ciContentHasLink content Nothing
ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId hasLink_ itemTs createdAt
let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing createdAt
pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci
@@ -2527,10 +2541,11 @@ createLocalChatItems user cd itemsData createdAt = do
pure items
where
createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd)
createItem db (content, ciFile, itemForwarded, ts) = do
ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False createdAt Nothing createdAt
createItem db (content, ciFile, itemForwarded, ts@(_, ft_)) = do
let hasLink_ = ciContentHasLink content ft_
ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ createdAt Nothing createdAt
forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt
pure $ mkChatItem_ cd False ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False createdAt Nothing createdAt
pure $ mkChatItem_ cd False ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False hasLink_ createdAt Nothing createdAt
withUser' :: (User -> CM ChatResponse) -> CM ChatResponse
withUser' action =
+10
View File
@@ -178,6 +178,16 @@ isSimplexLink = \case
SimplexLink {} -> True
_ -> False
isLink :: Format -> Bool
isLink = \case
Uri -> True
HyperLink {} -> True
SimplexLink {} -> True
_ -> False
hasLinks :: MarkdownList -> Bool
hasLinks = any $ \(FormattedText f _) -> maybe False isLink f
markdownP :: Parser Markdown
markdownP = mconcat <$> A.many' fragmentP
where
+6 -3
View File
@@ -499,6 +499,7 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta
itemTimed :: Maybe CITimed,
itemLive :: Maybe Bool,
userMention :: Bool, -- True for messages that mention user or reply to user messages
hasLink :: BoolDef,
deletable :: Bool,
editable :: Bool,
forwardedByMember :: Maybe GroupMemberId,
@@ -510,11 +511,12 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta
type ShowGroupAsSender = Bool
mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> Bool -> UTCTime -> UTCTime -> CIMeta c d
mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive userMention currentTs itemTs forwardedByMember showGroupAsSender createdAt updatedAt =
mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> Bool -> Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> Bool -> UTCTime -> UTCTime -> CIMeta c d
mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender createdAt updatedAt =
let deletable = deletable' itemContent itemDeleted itemTs nominalDay currentTs
editable = deletable && isNothing itemForwarded
in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, userMention, deletable, editable, forwardedByMember, showGroupAsSender, createdAt, updatedAt}
hasLink = BoolDef hasLink_
in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, userMention, hasLink, deletable, editable, forwardedByMember, showGroupAsSender, createdAt, updatedAt}
deletable' :: forall c d. ChatTypeI c => CIContent d -> Maybe (CIDeleted c) -> UTCTime -> NominalDiffTime -> UTCTime -> Bool
deletable' itemContent itemDeleted itemTs allowedInterval currentTs =
@@ -540,6 +542,7 @@ dummyMeta itemId ts itemText =
itemTimed = Nothing,
itemLive = Nothing,
userMention = False,
hasLink = BoolDef False,
deletable = False,
editable = False,
forwardedByMember = Nothing,
+3 -3
View File
@@ -255,7 +255,8 @@ mobileChatOpts dbOptions =
deviceName = Nothing,
highlyAvailable = False,
yesToUpMigrations = False,
migrationBackupPath = Just ""
migrationBackupPath = Just "",
maintenance = True
},
chatCmd = "",
chatCmdDelay = 3,
@@ -268,8 +269,7 @@ mobileChatOpts dbOptions =
autoAcceptFileSize = 0,
muteNotifications = True,
markRead = False,
createBot = Nothing,
maintenance = True
createBot = Nothing
}
defaultMobileConfig :: ChatConfig
+12 -12
View File
@@ -50,8 +50,7 @@ data ChatOpts = ChatOpts
autoAcceptFileSize :: Integer,
muteNotifications :: Bool,
markRead :: Bool,
createBot :: Maybe CreateBotOpts,
maintenance :: Bool
createBot :: Maybe CreateBotOpts
}
data CoreChatOpts = CoreChatOpts
@@ -68,7 +67,8 @@ data CoreChatOpts = CoreChatOpts
deviceName :: Maybe Text,
highlyAvailable :: Bool,
yesToUpMigrations :: Bool,
migrationBackupPath :: Maybe FilePath
migrationBackupPath :: Maybe FilePath,
maintenance :: Bool
}
data CreateBotOpts = CreateBotOpts
@@ -245,6 +245,12 @@ coreChatOptsP appDir defaultDbName = do
<> help "Automatically confirm \"up\" database migrations"
)
migrationBackupPath <- migrationBackupPathP
maintenance <-
switch
( long "maintenance"
<> short 'm'
<> help "Run in maintenance mode (/_start to start chat)"
)
pure
CoreChatOpts
{ dbOptions,
@@ -271,7 +277,8 @@ coreChatOptsP appDir defaultDbName = do
deviceName,
highlyAvailable,
yesToUpMigrations,
migrationBackupPath
migrationBackupPath,
maintenance
}
where
useTcpTimeout p t = 1000000 * if t > 0 then t else maybe 7 (const 15) p
@@ -376,12 +383,6 @@ chatOptsP appDir defaultDbName = do
( long "create-bot-allow-files"
<> help "Flag for created bot to allow files (only allowed together with --create-bot option)"
)
maintenance <-
switch
( long "maintenance"
<> short 'm'
<> help "Run in maintenance mode (/_start to start chat)"
)
pure
ChatOpts
{ coreOptions,
@@ -400,8 +401,7 @@ chatOptsP appDir defaultDbName = do
Just botDisplayName -> Just CreateBotOpts {botDisplayName, allowFiles = createBotAllowFiles}
Nothing
| createBotAllowFiles -> error "--create-bot-allow-files option requires --create-bot-name option"
| otherwise -> Nothing,
maintenance
| otherwise -> Nothing
}
parseProtocolServers :: ProtocolTypeI p => ReadM [ProtoServerWithAuth p]
+16 -10
View File
@@ -227,8 +227,7 @@ instance StrEncoding AppMessageBinary where
let msgId = if B.null msgId' then Nothing else Just (SharedMsgId msgId')
pure AppMessageBinary {tag, msgId, body}
data MsgScope
= MSMember {memberId :: MemberId} -- Admins can use any member id; members can use only their own id
data MsgScope = MSMember {memberId :: MemberId} -- Admins can use any member id; members can use only their own id
deriving (Eq, Show)
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "MS") ''MsgScope)
@@ -644,6 +643,9 @@ maxEncodedMsgLength = 15602
maxCompressedMsgLength :: Int
maxCompressedMsgLength = 13380
maxDecompressedMsgLength :: Int
maxDecompressedMsgLength = 65536
-- maxEncodedMsgLength - delta between MSG and INFO + 100 (returned for forward overhead)
-- delta between MSG and INFO = e2eEncUserMsgLength (no PQ) - e2eEncConnInfoLength (no PQ) = 1008
maxEncodedInfoLength :: Int
@@ -666,20 +668,24 @@ encodeChatMessage maxSize msg = do
parseChatMessages :: ByteString -> [Either String AChatMessage]
parseChatMessages "" = [Left "empty string"]
parseChatMessages s = case B.head s of
'{' -> [ACMsg SJson <$> J.eitherDecodeStrict' s]
'[' -> case J.eitherDecodeStrict' s of
Right v -> map parseItem v
Left e -> [Left e]
'X' -> decodeCompressed (B.drop 1 s)
_ -> [ACMsg SBinary <$> (appBinaryToCM =<< strDecode s)]
parseChatMessages msg = case B.head msg of
'X' -> decodeCompressed (B.tail msg)
c -> parseUncompressed c msg
where
parseUncompressed c s = case c of
'{' -> [ACMsg SJson <$> J.eitherDecodeStrict' s]
'[' -> case J.eitherDecodeStrict' s of
Right v -> map parseItem v
Left e -> [Left e]
_ -> [ACMsg SBinary <$> (appBinaryToCM =<< strDecode s)]
parseItem :: J.Value -> Either String AChatMessage
parseItem v = ACMsg SJson <$> JT.parseEither parseJSON v
decodeCompressed :: ByteString -> [Either String AChatMessage]
decodeCompressed s' = case smpDecode s' of
Left e -> [Left e]
Right (compressed :: L.NonEmpty Compressed) -> concatMap (either (pure . Left) parseChatMessages . decompress1) compressed
Right (compressed :: L.NonEmpty Compressed) -> concatMap (either (pure . Left) parseUncompressed' . decompress1 maxDecompressedMsgLength) compressed
parseUncompressed' "" = [Left "empty string"]
parseUncompressed' s = parseUncompressed (B.head s) s
compressedBatchMsgBody_ :: MsgBody -> ByteString
compressedBatchMsgBody_ = markCompressedBatch . smpEncode . (L.:| []) . compress1
+33 -25
View File
@@ -525,9 +525,9 @@ setSupportChatMemberAttention db vr user g m memberAttention = do
m_ <- runExceptT $ getGroupMemberById db vr user (groupMemberId' m)
pure $ either (const m) id m_ -- Left shouldn't happen, but types require it
createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId
createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live createdAt =
createNewChatItem_ db user chatDirection False createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False createdAt Nothing createdAt
createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> IO ChatItemId
createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live hasLink createdAt =
createNewChatItem_ db user chatDirection False createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False hasLink createdAt Nothing createdAt
where
createdByMsgId = if msgId == 0 then Nothing else Just msgId
quoteRow :: NewQuoteRow
@@ -541,9 +541,9 @@ createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciCon
CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId)
CIQGroupRcv Nothing -> (Just False, Nothing)
createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom)
createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live userMention itemTs createdAt = do
ciId <- createNewChatItem_ db user chatDirection False (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention itemTs forwardedByMember createdAt
createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom)
createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live userMention hasLink itemTs createdAt = do
ciId <- createNewChatItem_ db user chatDirection False (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember createdAt
quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg
pure (ciId, quotedItem, itemForwarded)
where
@@ -558,15 +558,15 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forw
CDGroupRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ _ ->
(Just $ Just userMemberId == memberId, memberId)
createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> UTCTime -> UTCTime -> IO ChatItemId
createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ itemTs =
createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False itemTs Nothing
createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> UTCTime -> UTCTime -> IO ChatItemId
createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink itemTs =
createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing
where
quoteRow :: NewQuoteRow
quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing)
createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId
createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live userMention itemTs forwardedByMember createdAt = do
createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId
createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember createdAt = do
DB.execute
db
[sql|
@@ -575,20 +575,20 @@ createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ share
user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, group_scope_tag, group_scope_group_member_id,
-- meta
item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id,
forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, show_group_as_sender, timed_ttl, timed_delete_at,
forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, has_link, show_group_as_sender, timed_ttl, timed_delete_at,
-- quote
quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id,
-- forwarded from
fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
((userId, msgId_) :. idsRow :. groupScopeRow :. itemRow :. quoteRow' :. forwardedFromRow)
ciId <- insertedRowId db
forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt
pure ciId
where
itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt, BoolInt, BoolInt) :. (Maybe Int, Maybe UTCTime)
itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> (justTrue live), BI userMention, BI showGroupAsSender) :. ciTimedRow timed
itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt, BoolInt, BoolInt, BoolInt) :. (Maybe Int, Maybe UTCTime)
itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> justTrue live, BI userMention, BI hasLink, BI showGroupAsSender) :. ciTimedRow timed
quoteRow' = let (a, b, c, d, e) = quoteRow in (a, b, c, BI <$> d, e)
idsRow :: (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId, Maybe NoteFolderId)
idsRow = case chatDirection of
@@ -1045,7 +1045,7 @@ getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do
-- this function can be changed so it never fails, not only avoid failure on invalid json
toLocalChatItem :: UTCTime -> ChatItemRow -> Either StoreError (CChatItem 'CTLocal)
toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) =
toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) =
chatItem $ fromRight invalid $ dbParseACIContent itemContentText
where
invalid = ACIContent msgDir $ CIInvalidJSON itemContentText
@@ -1078,7 +1078,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex
_ -> Just (CIDeleted @'CTLocal deletedTs)
itemEdited' = maybe False unBI itemEdited
itemForwarded = toCIForwardedFrom forwardedFromRow
in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs Nothing False createdAt updatedAt
in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs Nothing False createdAt updatedAt
ciTimed :: Maybe CITimed
ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt}
@@ -1470,6 +1470,12 @@ getChatItemIDs db User {userId} cInfo contentFilter range count search = case cI
(grCond <> " AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL ")
(userId, groupId)
"item_ts"
(Nothing, Just MCLink_) ->
liftIO $
idsQuery
(grCond <> " AND has_link = 1 ")
(userId, groupId)
"item_ts"
(Nothing, Just mcTag) ->
liftIO $
idsQuery
@@ -1488,11 +1494,13 @@ getChatItemIDs db User {userId} cInfo contentFilter range count search = case cI
grCond = " user_id = ? AND group_id = ? "
DirectChat Contact {contactId} -> liftIO $ case contentFilter of
Nothing -> idsQuery ctCond (userId, contactId) "created_at"
Just MCLink_ -> idsQuery (ctCond <> " AND has_link = 1 ") (userId, contactId) "created_at"
Just mcTag -> idsQuery (ctCond <> " AND msg_content_tag = ? ") (userId, contactId, mcTag) "created_at"
where
ctCond = " user_id = ? AND contact_id = ? "
LocalChat NoteFolder {noteFolderId} -> liftIO $ case contentFilter of
Nothing -> idsQuery nfCond (userId, noteFolderId) "created_at"
Just MCLink_ -> idsQuery (nfCond <> " AND has_link = 1 ") (userId, noteFolderId) "created_at"
Just mcTag -> idsQuery (nfCond <> " AND msg_content_tag = ? ") (userId, noteFolderId, mcTag) "created_at"
where
nfCond = " user_id = ? AND note_folder_id = ? "
@@ -2191,7 +2199,7 @@ updateLocalChatItemsRead db User {userId} noteFolderId = do
type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol)
type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt)
type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt, BoolInt)
type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64)
@@ -2215,7 +2223,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir
-- this function can be changed so it never fails, not only avoid failure on invalid json
toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect)
toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) =
toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) =
chatItem $ fromRight invalid $ dbParseACIContent itemContentText
where
invalid = ACIContent msgDir $ CIInvalidJSON itemContentText
@@ -2248,7 +2256,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT
_ -> Just (CIDeleted @'CTDirect deletedTs)
itemEdited' = maybe False unBI itemEdited
itemForwarded = toCIForwardedFrom forwardedFromRow
in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs Nothing False createdAt updatedAt
in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs Nothing False createdAt updatedAt
ciTimed :: Maybe CITimed
ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt}
@@ -2286,7 +2294,7 @@ toGroupChatItem
( ( (itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId)
:. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt)
:. forwardedFromRow
:. (timedTTL, timedDeleteAt, itemLive, BI userMention)
:. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink)
:. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)
)
:. (forwardedByMember, BI showGroupAsSender)
@@ -2331,7 +2339,7 @@ toGroupChatItem
_ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_)
itemEdited' = maybe False unBI itemEdited
itemForwarded = toCIForwardedFrom forwardedFromRow
in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs forwardedByMember showGroupAsSender createdAt updatedAt
in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs forwardedByMember showGroupAsSender createdAt updatedAt
ciTimed :: Maybe CITimed
ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt}
@@ -2604,7 +2612,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do
i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id,
i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at,
i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
-- DirectQuote
@@ -2959,7 +2967,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id,
i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at,
i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
-- CIMeta forwardedByMember, showGroupAsSender
@@ -3070,7 +3078,7 @@ getLocalChatItem db User {userId} folderId itemId = ExceptT $ do
i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id,
i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at,
i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol
FROM chat_items i
@@ -25,6 +25,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector
import Simplex.Chat.Store.Postgres.Migrations.M20251128_migrate_member_relations
import Simplex.Chat.Store.Postgres.Migrations.M20251230_strict_tables
import Simplex.Chat.Store.Postgres.Migrations.M20260108_chat_indices
import Simplex.Chat.Store.Postgres.Migrations.M20260122_has_link
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Text, Maybe Text)]
@@ -49,7 +50,8 @@ schemaMigrations =
("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector),
("20251128_migrate_member_relations", m20251128_migrate_member_relations, Just down_m20251128_migrate_member_relations),
("20251230_strict_tables", m20251230_strict_tables, Just down_m20251230_strict_tables),
("20260108_chat_indices", m20260108_chat_indices, Just down_m20260108_chat_indices)
("20260108_chat_indices", m20260108_chat_indices, Just down_m20260108_chat_indices),
("20260122_has_link", m20260122_has_link, Just down_m20260122_has_link)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,32 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.Postgres.Migrations.M20260122_has_link where
import Data.Text (Text)
import Text.RawString.QQ (r)
m20260122_has_link :: Text
m20260122_has_link =
[r|
ALTER TABLE chat_items ADD COLUMN has_link SMALLINT NOT NULL DEFAULT 0;
UPDATE chat_items SET msg_content_tag = 'text' WHERE msg_content_tag = 'liveText';
UPDATE chat_items SET has_link = 1
WHERE msg_content_tag = 'link' OR item_text LIKE '%https://%';
CREATE INDEX idx_chat_items_groups_has_link_item_ts ON chat_items(user_id, group_id, has_link, item_ts);
CREATE INDEX idx_chat_items_contacts_has_link_created_at ON chat_items(user_id, contact_id, has_link, created_at);
CREATE INDEX idx_chat_items_note_folder_has_link_created_at ON chat_items(user_id, note_folder_id, has_link, created_at);
|]
down_m20260122_has_link :: Text
down_m20260122_has_link =
[r|
DROP INDEX idx_chat_items_note_folder_has_link_created_at;
DROP INDEX idx_chat_items_contacts_has_link_created_at;
DROP INDEX idx_chat_items_groups_has_link_item_ts;
ALTER TABLE chat_items DROP COLUMN has_link;
|]
@@ -342,7 +342,8 @@ CREATE TABLE test_chat_schema.chat_items (
user_mention smallint DEFAULT 0 NOT NULL,
group_scope_tag text,
group_scope_group_member_id bigint,
show_group_as_sender smallint DEFAULT 0 NOT NULL
show_group_as_sender smallint DEFAULT 0 NOT NULL,
has_link smallint DEFAULT 0 NOT NULL
);
@@ -1813,6 +1814,10 @@ CREATE INDEX idx_chat_items_contacts_created_at ON test_chat_schema.chat_items U
CREATE INDEX idx_chat_items_contacts_has_link_created_at ON test_chat_schema.chat_items USING btree (user_id, contact_id, has_link, created_at);
CREATE INDEX idx_chat_items_contacts_msg_content_tag_created_at ON test_chat_schema.chat_items USING btree (user_id, contact_id, msg_content_tag, created_at);
@@ -1873,6 +1878,10 @@ CREATE INDEX idx_chat_items_groups ON test_chat_schema.chat_items USING btree (u
CREATE INDEX idx_chat_items_groups_has_link_item_ts ON test_chat_schema.chat_items USING btree (user_id, group_id, has_link, item_ts);
CREATE INDEX idx_chat_items_groups_history ON test_chat_schema.chat_items USING btree (user_id, group_id, include_in_history, item_deleted, item_ts, chat_item_id);
@@ -1901,6 +1910,10 @@ CREATE INDEX idx_chat_items_item_status ON test_chat_schema.chat_items USING btr
CREATE INDEX idx_chat_items_note_folder_has_link_created_at ON test_chat_schema.chat_items USING btree (user_id, note_folder_id, has_link, created_at);
CREATE INDEX idx_chat_items_note_folder_msg_content_tag_created_at ON test_chat_schema.chat_items USING btree (user_id, note_folder_id, msg_content_tag, created_at);
+3 -1
View File
@@ -148,6 +148,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector
import Simplex.Chat.Store.SQLite.Migrations.M20251128_migrate_member_relations
import Simplex.Chat.Store.SQLite.Migrations.M20251230_strict_tables
import Simplex.Chat.Store.SQLite.Migrations.M20260108_chat_indices
import Simplex.Chat.Store.SQLite.Migrations.M20260122_has_link
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -295,7 +296,8 @@ schemaMigrations =
("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector),
("20251128_migrate_member_relations", m20251128_migrate_member_relations, Just down_m20251128_migrate_member_relations),
("20251230_strict_tables", m20251230_strict_tables, Just down_m20251230_strict_tables),
("20260108_chat_indices", m20260108_chat_indices, Just down_m20260108_chat_indices)
("20260108_chat_indices", m20260108_chat_indices, Just down_m20260108_chat_indices),
("20260122_has_link", m20260122_has_link, Just down_m20260122_has_link)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,33 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.SQLite.Migrations.M20260122_has_link where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20260122_has_link :: Query
m20260122_has_link =
[sql|
UPDATE chat_items SET msg_content_tag = 'text' WHERE msg_content_tag = 'liveText';
UPDATE chat_items SET msg_content_tag = CAST(msg_content_tag as TEXT) WHERE typeof(msg_content_tag) = 'blob';
ALTER TABLE chat_items ADD COLUMN has_link INTEGER NOT NULL DEFAULT 0;
UPDATE chat_items SET has_link = 1
WHERE msg_content_tag = 'link' OR item_text LIKE '%https://%';
CREATE INDEX idx_chat_items_groups_has_link_item_ts ON chat_items(user_id, group_id, has_link, item_ts);
CREATE INDEX idx_chat_items_contacts_has_link_created_at ON chat_items(user_id, contact_id, has_link, created_at);
CREATE INDEX idx_chat_items_note_folder_has_link_created_at ON chat_items(user_id, note_folder_id, has_link, created_at);
|]
down_m20260122_has_link :: Query
down_m20260122_has_link =
[sql|
DROP INDEX idx_chat_items_note_folder_has_link_created_at;
DROP INDEX idx_chat_items_contacts_has_link_created_at;
DROP INDEX idx_chat_items_groups_has_link_item_ts;
ALTER TABLE chat_items DROP COLUMN has_link;
|]
@@ -765,7 +765,7 @@ Query:
LIMIT ?
Plan:
SEARCH chat_items USING INDEX idx_chat_items_note_folder_msg_content_tag_created_at (user_id=?)
SEARCH chat_items USING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?)
USE TEMP B-TREE FOR ORDER BY
Query:
@@ -776,7 +776,7 @@ Query:
LIMIT ?
Plan:
SEARCH chat_items USING INDEX idx_chat_items_note_folder_msg_content_tag_created_at (user_id=?)
SEARCH chat_items USING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?)
USE TEMP B-TREE FOR ORDER BY
Query:
@@ -1088,7 +1088,7 @@ Query:
i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id,
i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at,
i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol
FROM chat_items i
@@ -1105,7 +1105,7 @@ Query:
i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id,
i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at,
i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
-- CIMeta forwardedByMember, showGroupAsSender
@@ -1161,7 +1161,7 @@ Query:
i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id,
i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at,
i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
-- DirectQuote
@@ -1292,7 +1292,7 @@ Query:
LIMIT ?
Plan:
SEARCH chat_items USING INDEX idx_chat_items_note_folder_msg_content_tag_created_at (user_id=?)
SEARCH chat_items USING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?)
USE TEMP B-TREE FOR ORDER BY
Query:
@@ -4166,7 +4166,7 @@ Query:
Plan:
SEARCH files USING INDEX idx_files_user_id (user_id=?)
LIST SUBQUERY 1
SEARCH chat_items USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?)
SEARCH chat_items USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=? AND note_folder_id=?)
SEARCH extra_xftp_file_descriptions USING COVERING INDEX idx_extra_xftp_file_descriptions_file_id (file_id=?)
SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?)
SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?)
@@ -4212,12 +4212,12 @@ Query:
user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, group_scope_tag, group_scope_group_member_id,
-- meta
item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id,
forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, show_group_as_sender, timed_ttl, timed_delete_at,
forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, has_link, show_group_as_sender, timed_ttl, timed_delete_at,
-- quote
quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id,
-- forwarded from
fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
@@ -5141,7 +5141,7 @@ Query:
JOIN files f ON f.chat_item_id = i.chat_item_id
WHERE i.user_id = ?
Plan:
SEARCH i USING COVERING INDEX idx_chat_items_note_folder_msg_content_tag_created_at (user_id=?)
SEARCH i USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?)
SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?)
Query:
@@ -5150,7 +5150,7 @@ Query:
JOIN files f ON f.chat_item_id = i.chat_item_id
WHERE i.user_id = ? AND i.contact_id = ?
Plan:
SEARCH i USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?)
SEARCH i USING COVERING INDEX idx_chat_items_contacts_has_link_created_at (user_id=? AND contact_id=?)
SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?)
Query:
@@ -5168,7 +5168,7 @@ Query:
JOIN files f ON f.chat_item_id = i.chat_item_id
WHERE i.user_id = ? AND i.group_id = ?
Plan:
SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?)
SEARCH i USING COVERING INDEX idx_chat_items_groups_has_link_item_ts (user_id=? AND group_id=?)
SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?)
Query:
@@ -5186,7 +5186,7 @@ Query:
JOIN files f ON f.chat_item_id = i.chat_item_id
WHERE i.user_id = ? AND i.note_folder_id = ?
Plan:
SEARCH i USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?)
SEARCH i USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=? AND note_folder_id=?)
SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?)
Query:
@@ -5469,6 +5469,10 @@ Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id =
Plan:
SEARCH chat_items USING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?)
Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND has_link = 1 ORDER BY created_at DESC, chat_item_id DESC LIMIT ?
Plan:
SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_has_link_created_at (user_id=? AND contact_id=? AND has_link=?)
Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND msg_content_tag = ? ORDER BY created_at DESC, chat_item_id DESC LIMIT ?
Plan:
SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_msg_content_tag_created_at (user_id=? AND contact_id=? AND msg_content_tag=?)
@@ -5489,6 +5493,10 @@ Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ?
Plan:
SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=?)
Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND has_link = 1 ORDER BY item_ts DESC, chat_item_id DESC LIMIT ?
Plan:
SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_has_link_item_ts (user_id=? AND group_id=? AND has_link=?)
Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? ORDER BY item_ts DESC, chat_item_id DESC LIMIT ?
Plan:
SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_item_ts (user_id=? AND group_id=? AND msg_content_tag=?)
@@ -5497,6 +5505,10 @@ Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND note_folder_i
Plan:
SEARCH chat_items USING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?)
Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND note_folder_id = ? AND has_link = 1 ORDER BY created_at DESC, chat_item_id DESC LIMIT ?
Plan:
SEARCH chat_items USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=? AND note_folder_id=? AND has_link=?)
Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND note_folder_id = ? AND msg_content_tag = ? ORDER BY created_at DESC, chat_item_id DESC LIMIT ?
Plan:
SEARCH chat_items USING COVERING INDEX idx_chat_items_note_folder_msg_content_tag_created_at (user_id=? AND note_folder_id=? AND msg_content_tag=?)
@@ -5549,7 +5561,7 @@ SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_
Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?
Plan:
SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?)
SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_has_link_created_at (user_id=? AND contact_id=?)
SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?)
SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?)
SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?)
@@ -5573,7 +5585,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?)
Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? AND item_content_tag != 'chatBanner'
Plan:
SEARCH chat_items USING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?)
SEARCH chat_items USING INDEX idx_chat_items_contacts_has_link_created_at (user_id=? AND contact_id=?)
SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?)
SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?)
SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?)
@@ -5585,7 +5597,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?)
Query: DELETE FROM chat_items WHERE user_id = ? AND group_id = ?
Plan:
SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?)
SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_has_link_item_ts (user_id=? AND group_id=?)
SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?)
SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?)
SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?)
@@ -5609,7 +5621,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?)
Query: DELETE FROM chat_items WHERE user_id = ? AND group_id = ? AND item_content_tag != 'chatBanner'
Plan:
SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?)
SEARCH chat_items USING INDEX idx_chat_items_groups_has_link_item_ts (user_id=? AND group_id=?)
SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?)
SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?)
SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?)
@@ -5621,7 +5633,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?)
Query: DELETE FROM chat_items WHERE user_id = ? AND note_folder_id = ?
Plan:
SEARCH chat_items USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?)
SEARCH chat_items USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=? AND note_folder_id=?)
SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?)
SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?)
SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?)
@@ -5903,7 +5915,7 @@ SEARCH protocol_servers USING COVERING INDEX idx_smp_servers_user_id (user_id=?)
SEARCH settings USING COVERING INDEX idx_settings_user_id (user_id=?)
SEARCH commands USING COVERING INDEX idx_commands_user_id (user_id=?)
SEARCH calls USING COVERING INDEX idx_calls_user_id (user_id=?)
SEARCH chat_items USING COVERING INDEX idx_chat_items_note_folder_msg_content_tag_created_at (user_id=?)
SEARCH chat_items USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?)
SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_2 (user_id=?)
SEARCH user_contact_links USING COVERING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?)
SEARCH connections USING COVERING INDEX idx_connections_to_subscribe (user_id=?)
@@ -6067,7 +6079,7 @@ Query: SELECT EXISTS (SELECT 1 FROM chat_items WHERE user_id = ? AND contact_id
Plan:
SCAN CONSTANT ROW
SCALAR SUBQUERY 1
SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?)
SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_has_link_created_at (user_id=? AND contact_id=?)
Query: SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit = ?
Plan:
@@ -440,7 +440,8 @@ CREATE TABLE chat_items(
user_mention INTEGER NOT NULL DEFAULT 0,
group_scope_tag TEXT,
group_scope_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE,
show_group_as_sender INTEGER NOT NULL DEFAULT 0
show_group_as_sender INTEGER NOT NULL DEFAULT 0,
has_link INTEGER NOT NULL DEFAULT 0
) STRICT;
CREATE TABLE sqlite_sequence(name,seq);
CREATE TABLE chat_item_messages(
@@ -1199,6 +1200,24 @@ CREATE INDEX idx_chat_items_note_folder_msg_content_tag_created_at ON chat_items
msg_content_tag,
created_at
);
CREATE INDEX idx_chat_items_groups_has_link_item_ts ON chat_items(
user_id,
group_id,
has_link,
item_ts
);
CREATE INDEX idx_chat_items_contacts_has_link_created_at ON chat_items(
user_id,
contact_id,
has_link,
created_at
);
CREATE INDEX idx_chat_items_note_folder_has_link_created_at ON chat_items(
user_id,
note_folder_id,
has_link,
created_at
);
CREATE TRIGGER on_group_members_insert_update_summary
AFTER INSERT ON group_members
FOR EACH ROW
+279 -4
View File
@@ -19,7 +19,8 @@ import Directory.Listing
import Directory.Options
import Directory.Service
import Directory.Store
import GHC.IO.Handle (hClose)
import System.Directory (emptyPermissions, setOwnerExecutable, setOwnerReadable, setOwnerWritable, setPermissions)
import System.IO (hClose)
import Simplex.Chat.Bot.KnownContacts
import Simplex.Chat.Controller (ChatConfig (..))
import qualified Simplex.Chat.Markdown as MD
@@ -70,10 +71,18 @@ directoryServiceTests = do
it "should list and promote user's groups" $ testListUserGroups True
describe "member admission" $ do
it "should ask member to pass captcha screen" testCapthaScreening
it "should send voice captcha on /audio command" testVoiceCaptchaScreening
it "should retry with voice captcha after switching to audio mode" testVoiceCaptchaRetry
it "should reject member after too many captcha attempts" testCaptchaTooManyAttempts
it "should respond to unknown command during captcha" testCaptchaUnknownCommand
describe "store log" $ do
it "should restore directory service state" testRestoreDirectory
describe "captcha" $ do
it "should accept some incorrect spellings" testCaptcha
it "should generate captcha of correct length" testGetCaptchaStr
describe "help commands" $ do
it "should not list audio command" testHelpNoAudio
it "should reject audio command in DM" testAudioCommandInDM
directoryProfile :: Profile
directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing}
@@ -95,12 +104,14 @@ mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder =
adminUsers = [],
superUsers,
ownersGroup,
noAddress = False,
blockedFragmentsFile = Nothing,
blockedWordsFile = Nothing,
blockedExtensionRules = Nothing,
nameSpellingFile = Nothing,
profileNameLimit = maxBound,
captchaGenerator = Nothing,
voiceCaptchaGenerator = Nothing,
directoryLog = Just $ ps </> "directory_service.log",
migrateDirectoryLog = Nothing,
serviceName = "SimpleX Directory",
@@ -403,9 +414,11 @@ testJoinGroup ps =
cath <## "connection request sent!"
cath <## "#privacy: joining the group..."
cath <## "#privacy: you joined the group"
cath <## "contact and member are merged: 'SimpleX Directory', #privacy 'SimpleX Directory_1'"
cath <## "use @'SimpleX Directory' <message> to send messages"
cath <# ("#privacy 'SimpleX Directory'> " <> welcomeMsg)
cath
<### [ "contact and member are merged: 'SimpleX Directory', #privacy 'SimpleX Directory_1'",
"use @'SimpleX Directory' <message> to send messages",
Predicate (\l -> l == welcomeMsg || dropTime_ l == Just ("#privacy 'SimpleX Directory'> " <> welcomeMsg) || dropTime_ l == Just ("#privacy 'SimpleX Directory_1'> " <> welcomeMsg))
]
cath <## "#privacy: member bob (Bob) is connected"
bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)"
bob <## "#privacy: new member cath is connected"
@@ -1224,6 +1237,152 @@ testCapthaScreening ps =
cath <## " Correct, you joined the group privacy"
cath <## "#privacy: you joined the group"
testVoiceCaptchaScreening :: HasCallStack => TestParams -> IO ()
testVoiceCaptchaScreening ps@TestParams {tmpPath} = do
let mockScript = tmpPath </> "mock_voice_gen.py"
-- Mock script writes a dummy audio file, prints path and duration
writeFile mockScript $ unlines
[ "#!/usr/bin/env python3",
"import os, tempfile",
"out = os.environ.get('VOICE_CAPTCHA_OUT')",
"if not out:",
" fd, out = tempfile.mkstemp(suffix='.m4a')",
" os.close(fd)",
"open(out, 'wb').write(b'\\x00' * 100)",
"print(out)",
"print(5)"
]
setPermissions mockScript $ setOwnerExecutable True $ setOwnerReadable True $ setOwnerWritable True emptyPermissions
withDirectoryServiceVoiceCaptcha ps mockScript $ \superUser dsLink ->
withNewTestChat ps "bob" bobProfile $ \bob ->
withNewTestChat ps "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
-- get group link
bob #> "@'SimpleX Directory' /role 1"
bob <# "'SimpleX Directory'> > /role 1"
bob <## " The initial member role for the group privacy is set to member"
bob <## "Send /'role 1 observer' to change it."
bob <## ""
note <- getTermLine bob
let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note
-- enable captcha
bob #> "@'SimpleX Directory' /filter 1 captcha"
bob <# "'SimpleX Directory'> > /filter 1 captcha"
bob <## " Spam filter settings for group privacy set to:"
bob <## "- reject long/inappropriate names: disabled"
bob <## "- pass captcha to join: enabled"
bob <## ""
bob <## "/'filter 1 name' - enable name filter"
bob <## "/'filter 1 name captcha' - enable both"
bob <## "/'filter 1 off' - disable filter"
-- cath joins, receives text captcha with /audio hint
cath ##> ("/c " <> groupLink)
cath <## "connection request sent!"
cath <## "#privacy: joining the group..."
cath <## "#privacy: you joined the group, pending approval"
cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service."
cath <## ""
cath <## "Send captcha text to join the group privacy."
cath <## "Send /audio to receive a voice captcha."
captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory'> " . dropTime <$> getTermLine cath
-- cath requests audio captcha
cath #> "#privacy (support) /audio"
cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)"
cath <#. "#privacy (support) 'SimpleX Directory'> sends file "
cath <##. "use /fr 1"
-- cath sends /audio again, already enabled
cath #> "#privacy (support) /audio"
cath <# "#privacy (support) 'SimpleX Directory'!> > cath /audio"
cath <## " Audio captcha is already enabled."
-- send correct captcha
sendCaptcha cath captcha
cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://"
cath <## "#privacy: member bob (Bob) is connected"
bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)"
bob <## "#privacy: new member cath is connected"
where
sendCaptcha cath captcha = do
cath #> ("#privacy (support) " <> captcha)
cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha)
cath <## " Correct, you joined the group privacy"
cath <## "#privacy: you joined the group"
testVoiceCaptchaRetry :: HasCallStack => TestParams -> IO ()
testVoiceCaptchaRetry ps@TestParams {tmpPath} = do
let mockScript = tmpPath </> "mock_voice_gen_retry.py"
writeFile mockScript $ unlines
[ "#!/usr/bin/env python3",
"import os, tempfile",
"out = os.environ.get('VOICE_CAPTCHA_OUT')",
"if not out:",
" fd, out = tempfile.mkstemp(suffix='.m4a')",
" os.close(fd)",
"open(out, 'wb').write(b'\\x00' * 100)",
"print(out)",
"print(5)"
]
setPermissions mockScript $ setOwnerExecutable True $ setOwnerReadable True $ setOwnerWritable True emptyPermissions
withDirectoryServiceVoiceCaptcha ps mockScript $ \superUser dsLink ->
withNewTestChat ps "bob" bobProfile $ \bob ->
withNewTestChat ps "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
bob #> "@'SimpleX Directory' /role 1"
bob <# "'SimpleX Directory'> > /role 1"
bob <## " The initial member role for the group privacy is set to member"
bob <## "Send /'role 1 observer' to change it."
bob <## ""
note <- getTermLine bob
let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note
bob #> "@'SimpleX Directory' /filter 1 captcha"
bob <# "'SimpleX Directory'> > /filter 1 captcha"
bob <## " Spam filter settings for group privacy set to:"
bob <## "- reject long/inappropriate names: disabled"
bob <## "- pass captcha to join: enabled"
bob <## ""
bob <## "/'filter 1 name' - enable name filter"
bob <## "/'filter 1 name captcha' - enable both"
bob <## "/'filter 1 off' - disable filter"
-- cath joins, receives text captcha with /audio hint
cath ##> ("/c " <> groupLink)
cath <## "connection request sent!"
cath <## "#privacy: joining the group..."
cath <## "#privacy: you joined the group, pending approval"
cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service."
cath <## ""
cath <## "Send captcha text to join the group privacy."
cath <## "Send /audio to receive a voice captcha."
_ <- getTermLine cath -- captcha image/text
-- cath requests audio captcha
cath #> "#privacy (support) /audio"
cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)"
cath <#. "#privacy (support) 'SimpleX Directory'> sends file "
cath <##. "use /fr 1"
-- cath sends WRONG answer after switching to audio mode
cath #> "#privacy (support) wrong_answer"
cath <# "#privacy (support) 'SimpleX Directory'!> > cath wrong_answer"
cath <## " Incorrect text, please try again."
-- KEY ASSERTION: retry sends BOTH image and voice because captchaMode=CMAudio
_ <- getTermLine cath -- captcha image/text
cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)"
cath <#. "#privacy (support) 'SimpleX Directory'> sends file "
cath <##. "use /fr 2"
withDirectoryServiceVoiceCaptcha :: HasCallStack => TestParams -> FilePath -> (TestCC -> String -> IO ()) -> IO ()
withDirectoryServiceVoiceCaptcha ps voiceScript test = do
dsLink <-
withNewTestChatCfg ps testCfg serviceDbPrefix directoryProfile $ \ds ->
withNewTestChatCfg ps testCfg "super_user" aliceProfile $ \superUser -> do
connectUsers ds superUser
ds ##> "/ad"
getContactLink ds True
let opts = (mkDirectoryOpts ps [KnownContact 2 "alice"] Nothing Nothing) {voiceCaptchaGenerator = Just voiceScript}
runDirectory testCfg opts $
withTestChatCfg ps testCfg "super_user" $ \superUser -> do
superUser <## "subscribed 1 connections on server localhost"
test superUser dsLink
testRestoreDirectory :: HasCallStack => TestParams -> IO ()
testRestoreDirectory ps = do
testListUserGroups False ps
@@ -1537,3 +1696,119 @@ groupNotFound_ suffix u s = do
u #> ("@'SimpleX Directory" <> suffix <> "' " <> s)
u <# ("'SimpleX Directory" <> suffix <> "'> > " <> s)
u <## " No groups found"
testCaptchaTooManyAttempts :: HasCallStack => TestParams -> IO ()
testCaptchaTooManyAttempts ps =
withDirectoryService ps $ \superUser dsLink ->
withNewTestChat ps "bob" bobProfile $ \bob ->
withNewTestChat ps "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
bob #> "@'SimpleX Directory' /role 1"
bob <# "'SimpleX Directory'> > /role 1"
bob <## " The initial member role for the group privacy is set to member"
bob <## "Send /'role 1 observer' to change it."
bob <## ""
note <- getTermLine bob
let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note
bob #> "@'SimpleX Directory' /filter 1 captcha"
bob <# "'SimpleX Directory'> > /filter 1 captcha"
bob <## " Spam filter settings for group privacy set to:"
bob <## "- reject long/inappropriate names: disabled"
bob <## "- pass captcha to join: enabled"
bob <## ""
bob <## "/'filter 1 name' - enable name filter"
bob <## "/'filter 1 name captcha' - enable both"
bob <## "/'filter 1 off' - disable filter"
cath ##> ("/c " <> groupLink)
cath <## "connection request sent!"
cath <## "#privacy: joining the group..."
cath <## "#privacy: you joined the group, pending approval"
cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service."
cath <## ""
cath <## "Send captcha text to join the group privacy."
_ <- getTermLine cath
forM_ [1 :: Int .. 4] $ \i -> do
cath #> "#privacy (support) wrong"
cath <# "#privacy (support) 'SimpleX Directory'!> > cath wrong"
if i == 4
then cath <## " Incorrect text, please try again - this is your last attempt."
else cath <## " Incorrect text, please try again."
_ <- getTermLine cath
pure ()
cath #> "#privacy (support) wrong"
cath <# "#privacy (support) 'SimpleX Directory'> Too many failed attempts, you can't join group."
-- member removal produces multiple messages
_ <- getTermLine cath
_ <- getTermLine cath
_ <- getTermLine cath
pure ()
testCaptchaUnknownCommand :: HasCallStack => TestParams -> IO ()
testCaptchaUnknownCommand ps =
withDirectoryService ps $ \superUser dsLink ->
withNewTestChat ps "bob" bobProfile $ \bob ->
withNewTestChat ps "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
bob #> "@'SimpleX Directory' /role 1"
bob <# "'SimpleX Directory'> > /role 1"
bob <## " The initial member role for the group privacy is set to member"
bob <## "Send /'role 1 observer' to change it."
bob <## ""
note <- getTermLine bob
let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note
bob #> "@'SimpleX Directory' /filter 1 captcha"
bob <# "'SimpleX Directory'> > /filter 1 captcha"
bob <## " Spam filter settings for group privacy set to:"
bob <## "- reject long/inappropriate names: disabled"
bob <## "- pass captcha to join: enabled"
bob <## ""
bob <## "/'filter 1 name' - enable name filter"
bob <## "/'filter 1 name captcha' - enable both"
bob <## "/'filter 1 off' - disable filter"
cath ##> ("/c " <> groupLink)
cath <## "connection request sent!"
cath <## "#privacy: joining the group..."
cath <## "#privacy: you joined the group, pending approval"
cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service."
cath <## ""
cath <## "Send captcha text to join the group privacy."
_ <- getTermLine cath
cath #> "#privacy (support) /help"
cath <# "#privacy (support) 'SimpleX Directory'!> > cath /help"
cath <## " Unknown command, please enter captcha text."
testHelpNoAudio :: HasCallStack => TestParams -> IO ()
testHelpNoAudio ps =
withDirectoryService ps $ \_ dsLink ->
withNewTestChat ps "bob" bobProfile $ \bob -> do
bob `connectVia` dsLink
-- commands help should not mention /audio
bob #> "@'SimpleX Directory' /help commands"
bob <# "'SimpleX Directory'> /'help commands' - receive this help message."
bob <## "/help - how to register your group to be added to directory."
bob <## "/list - list the groups you registered."
bob <## "`/role <ID>` - view and set default member role for your group."
bob <## "`/filter <ID>` - view and set spam filter settings for group."
bob <## "`/link <ID>` - view and upgrade group link."
bob <## "`/delete <ID>:<NAME>` - remove the group you submitted from directory, with ID and name as shown by /list command."
bob <## ""
bob <## "To search for groups, send the search text."
testAudioCommandInDM :: HasCallStack => TestParams -> IO ()
testAudioCommandInDM ps =
withDirectoryService ps $ \_ dsLink ->
withNewTestChat ps "bob" bobProfile $ \bob -> do
bob `connectVia` dsLink
bob #> "@'SimpleX Directory' /audio"
bob <# "'SimpleX Directory'> > /audio"
bob <## " Unknown command"
testGetCaptchaStr :: HasCallStack => TestParams -> IO ()
testGetCaptchaStr _ps = do
s0 <- getCaptchaStr 0 ""
s0 `shouldBe` ""
s7 <- getCaptchaStr 7 ""
length s7 `shouldBe` 7
all (`elem` ("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" :: String)) s7 `shouldBe` True
+6 -6
View File
@@ -53,7 +53,7 @@ import Simplex.Messaging.Client (ProtocolClientConfig (..))
import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig)
import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange)
import qualified Simplex.Messaging.Crypto.Ratchet as CR
import Simplex.Messaging.Protocol (srvHostnamesSMPClientVersion, sndAuthKeySMPClientVersion)
import Simplex.Messaging.Protocol (sndAuthKeySMPClientVersion)
import Simplex.Messaging.Server (runSMPServerBlocking)
import Simplex.Messaging.Server.Env.STM (ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration)
import Simplex.Messaging.Server.MsgStore.STM (STMMsgStore)
@@ -117,8 +117,7 @@ testOpts =
autoAcceptFileSize = 0,
muteNotifications = True,
markRead = True,
createBot = Nothing,
maintenance = False
createBot = Nothing
}
testCoreOpts :: CoreChatOpts
@@ -152,12 +151,13 @@ testCoreOpts =
deviceName = Nothing,
highlyAvailable = False,
yesToUpMigrations = False,
migrationBackupPath = Nothing
migrationBackupPath = Nothing,
maintenance = False
}
#if !defined(dbPostgres)
getTestOpts :: Bool -> ScrubbedBytes -> ChatOpts
getTestOpts maintenance dbKey = testOpts {maintenance, coreOptions = testCoreOpts {dbOptions = (dbOptions testCoreOpts) {dbKey}}}
getTestOpts maintenance dbKey = testOpts {coreOptions = testCoreOpts {maintenance, dbOptions = (dbOptions testCoreOpts) {dbKey}}}
#endif
termSettings :: VirtualTerminalSettings
@@ -303,7 +303,7 @@ insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users (user_id) V
#endif
startTestChat_ :: TestParams -> ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC
startTestChat_ TestParams {printOutput} db cfg opts@ChatOpts {maintenance} user = do
startTestChat_ TestParams {printOutput} db cfg opts@ChatOpts {coreOptions = CoreChatOpts {maintenance}} user = do
t <- withVirtualTerminal termSettings pure
ct <- newChatTerminal t opts
cc <- newChatController db (Just user) cfg opts False
+46 -4
View File
@@ -156,6 +156,8 @@ chatDirectTests = do
describe "delivery receipts" $ do
it "should send delivery receipts" testSendDeliveryReceipts
it "should send delivery receipts depending on configuration" testConfigureDeliveryReceipts
describe "link content filter" $ do
it "filter chat by link content" testLinkContentFilter
describe "negotiate connection peer chat protocol version range" $ do
describe "peer version range correctly set for new connection via invitation" $ do
testInvVRange supportedChatVRange supportedChatVRange
@@ -1352,7 +1354,7 @@ testNegotiateCall =
testMaintenanceMode :: HasCallStack => TestParams -> IO ()
testMaintenanceMode ps = do
withNewTestChat ps "bob" bobProfile $ \bob -> do
withNewTestChatOpts ps testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do
withNewTestChatOpts ps testOpts {coreOptions = testCoreOpts {maintenance = True}} "alice" aliceProfile $ \alice -> do
alice ##> "/c"
alice <## "error: chat not started"
alice ##> "/_start"
@@ -1397,7 +1399,7 @@ testChatWorking alice bob = do
testMaintenanceModeWithFiles :: HasCallStack => TestParams -> IO ()
testMaintenanceModeWithFiles ps = withXFTPServer $ do
withNewTestChat ps "bob" bobProfile $ \bob -> do
withNewTestChatOpts ps testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do
withNewTestChatOpts ps testOpts {coreOptions = testCoreOpts {maintenance = True}} "alice" aliceProfile $ \alice -> do
alice ##> "/_start"
alice <## "chat started"
alice ##> "/_files_folder ./tests/tmp/alice_files"
@@ -1443,7 +1445,7 @@ testMaintenanceModeWithFiles ps = withXFTPServer $ do
testDatabaseEncryption :: HasCallStack => TestParams -> IO ()
testDatabaseEncryption ps = do
withNewTestChat ps "bob" bobProfile $ \bob -> do
withNewTestChatOpts ps testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do
withNewTestChatOpts ps testOpts {coreOptions = testCoreOpts {maintenance = True}} "alice" aliceProfile $ \alice -> do
alice ##> "/_start"
alice <## "chat started"
connectUsers alice bob
@@ -1655,7 +1657,7 @@ testSubscribeAppNSE :: HasCallStack => TestParams -> IO ()
testSubscribeAppNSE ps =
withNewTestChat ps "bob" bobProfile $ \bob -> do
withNewTestChat ps "alice" aliceProfile $ \alice -> do
withTestChatOpts ps testOpts {maintenance = True} "alice" $ \nseAlice -> do
withTestChatOpts ps testOpts {coreOptions = testCoreOpts {maintenance = True}} "alice" $ \nseAlice -> do
alice ##> "/_app suspend 1"
alice <## "ok"
alice <## "chat suspended"
@@ -3311,3 +3313,43 @@ contactInfoChatVRange cc (VersionRange minVer maxVer) = do
cc <## "connection not verified, use /code command to see security code"
cc <## "quantum resistant end-to-end encryption"
cc <## ("peer chat protocol version range: (" <> show minVer <> ", " <> show maxVer <> ")")
testLinkContentFilter :: HasCallStack => TestParams -> IO ()
testLinkContentFilter =
testChat2 aliceProfile bobProfile $
\alice bob -> do
connectUsers alice bob
alice ##> "/c"
simplexLink <- getInvitation alice
let linkPreview = "{\"msgContent\": {\"type\": \"link\", \"text\": \"https://simplex.chat\", \"preview\": {\"uri\": \"https://simplex.chat\", \"title\": \"SimpleX Chat\", \"description\": \"SimpleX Chat\", \"image\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
alice ##> ("/_send @2 json [" <> linkPreview <> "]")
alice <# "@bob https://simplex.chat"
bob <# "alice> https://simplex.chat"
alice #> "@bob check out https://example.com"
bob <# "alice> check out https://example.com"
bob #> "@alice visit http://test.org"
alice <# "bob> visit http://test.org"
alice #> ("@bob " <> simplexLink)
bob <#. "alice> https://simplex.chat/invitation#"
bob #> "@alice [click here](https://link.example.com)"
alice <# "bob> [click here](https://link.example.com)"
alice #> "@bob visit example.com for info"
bob <# "alice> visit example.com for info"
alice #> "@bob hello, no links here"
bob <# "alice> hello, no links here"
alice ##> "/_get content types @2"
alice <## "Chat content types: link, text"
alice #$> ("/_get chat @2 content=link count=100", chat, [(1, "https://simplex.chat"), (1, "check out https://example.com"), (0, "visit http://test.org"), (1, simplexLink), (0, "[click here](https://link.example.com)"), (1, "visit example.com for info")])
bob ##> "/_get content types @2"
bob <## "Chat content types: link, text"
bob #$> ("/_get chat @2 content=link count=100", chat, [(0, "https://simplex.chat"), (0, "check out https://example.com"), (1, "visit http://test.org"), (0, simplexLink), (1, "[click here](https://link.example.com)"), (0, "visit example.com for info")])
+21 -1
View File
@@ -100,6 +100,26 @@ runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withX
bob <## "Chat content types: file, text"
bob #$> ("/_get chat @2 content=file count=100", chatF, [((0, "hi, sending a file"), Just "./tests/tmp/test.jpg")])
-- Test file with link in text - should appear in both file and link filters
alice ##> "/_send @2 json [{\"filePath\": \"./tests/fixtures/test.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"check https://example.com for docs\"}}]"
alice <# "@bob check https://example.com for docs"
alice <# "/f @bob ./tests/fixtures/test.pdf"
alice <## "use /fc 2 to cancel sending"
bob <# "alice> check https://example.com for docs"
bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)"
bob <## "use /fr 2 [<dir>/ | <path>] to receive it"
alice <## "completed uploading file 2 (test.pdf) for bob"
alice ##> "/_get content types @2"
alice <## "Chat content types: file, text"
alice #$> ("/_get chat @2 content=file count=100", chatF, [((1, "hi, sending a file"), Just "./tests/fixtures/test.jpg"), ((1, "check https://example.com for docs"), Just "./tests/fixtures/test.pdf")])
alice #$> ("/_get chat @2 content=link count=100", chatF, [((1, "check https://example.com for docs"), Just "./tests/fixtures/test.pdf")])
bob ##> "/_get content types @2"
bob <## "Chat content types: file, text"
bob #$> ("/_get chat @2 content=file count=100", chatF, [((0, "hi, sending a file"), Just "./tests/tmp/test.jpg"), ((0, "check https://example.com for docs"), Nothing)])
bob #$> ("/_get chat @2 content=link count=100", chatF, [((0, "check https://example.com for docs"), Nothing)])
testSendImage :: HasCallStack => TestParams -> IO ()
testSendImage =
testChat2 aliceProfile bobProfile $
@@ -375,7 +395,7 @@ testGroupSendImage =
bob ##> "/_get content types #1"
bob <## "Chat content types: image, text"
bob #$> ("/_get chat #1 content=image count=100", chatF, [((0, ""), Just "./tests/tmp/test.jpg")])
cath #$> ("/_get chat #1 count=3", chatF, [((0, ""), Just "./tests/tmp/test_1.jpg"), ((0, "received"), Nothing), ((1, "received too"), Nothing)])
cath ##> "/_get content types #1"
cath <## "Chat content types: image, text"
+39
View File
@@ -134,6 +134,8 @@ chatGroupTests = do
describe "group delivery receipts" $ do
it "should send delivery receipts in group" testSendGroupDeliveryReceipts
it "should send delivery receipts in group depending on configuration" testConfigureGroupDeliveryReceipts
describe "link content filter" $ do
it "filter group chat by link content" testGroupLinkContentFilter
describe "direct connections in group are not established based on chat protocol version" $ do
it "direct contacts are not created" testNoGroupDirectConns
it "members have different local display names in different groups" testNoDirectDifferentLDNs
@@ -8515,3 +8517,40 @@ testChannelsSenderDeduplicateOwn ps = do
]
where
cfg = testCfg {deliveryWorkerDelay = 250000}
testGroupLinkContentFilter :: HasCallStack => TestParams -> IO ()
testGroupLinkContentFilter =
testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do
createGroup3 "team" alice bob cath
let linkPreview = "{\"msgContent\": {\"type\": \"link\", \"text\": \"https://simplex.chat\", \"preview\": {\"uri\": \"https://simplex.chat\", \"title\": \"SimpleX Chat\", \"description\": \"SimpleX Chat\", \"image\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
alice ##> ("/_send #1 json [" <> linkPreview <> "]")
alice <# "#team https://simplex.chat"
concurrently_
(bob <# "#team alice> https://simplex.chat")
(cath <# "#team alice> https://simplex.chat")
threadDelay 1000000
bob #> "#team check out https://example.com"
concurrently_
(alice <# "#team bob> check out https://example.com")
(cath <# "#team bob> check out https://example.com")
cath #> "#team hello, no links here"
concurrently_
(alice <# "#team cath> hello, no links here")
(bob <# "#team cath> hello, no links here")
alice ##> "/_get content types #1"
alice <## "Chat content types: link, text"
alice #$> ("/_get chat #1 content=link count=100", chat, [(1, "https://simplex.chat"), (0, "check out https://example.com")])
bob ##> "/_get content types #1"
bob <## "Chat content types: link, text"
bob #$> ("/_get chat #1 content=link count=100", chat, [(0, "https://simplex.chat"), (1, "check out https://example.com")])
cath ##> "/_get content types #1"
cath <## "Chat content types: link, text"
cath #$> ("/_get chat #1 content=link count=100", chat, [(0, "https://simplex.chat"), (0, "check out https://example.com")])
+17
View File
@@ -26,6 +26,8 @@ chatLocalChatsTests = do
describe "batch create messages" $ do
it "create multiple messages api" testCreateMulti
it "create multiple messages with files" testCreateMultiFiles
describe "link content filter" $ do
it "filter notes by link content" testLinkContentFilter
testNotes :: TestParams -> IO ()
testNotes ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
@@ -231,3 +233,18 @@ testCreateMultiFiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_get chat *1 count=3"
r <- chatF <$> getTermLine alice
r `shouldBe` [((1, "message without file"), Nothing), ((1, "sending file 1"), Just "test.jpg"), ((1, "sending file 2"), Just "test.pdf")]
testLinkContentFilter :: TestParams -> IO ()
testLinkContentFilter ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
createCCNoteFolder alice
let linkPreview = "{\"msgContent\": {\"type\": \"link\", \"text\": \"https://simplex.chat\", \"preview\": {\"uri\": \"https://simplex.chat\", \"title\": \"SimpleX Chat\", \"description\": \"SimpleX Chat\", \"image\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
alice ##> ("/_create *1 json [" <> linkPreview <> "]")
alice <# "* https://simplex.chat"
alice >* "check out https://example.com"
alice >* "hello, no links here"
alice ##> "/_get content types *1"
alice <## "Chat content types: link, text"
alice #$> ("/_get chat *1 content=link count=100", chat, [(1, "https://simplex.chat"), (1, "check out https://example.com")])
+1
View File
@@ -14,6 +14,7 @@
"textColor": "white",
"iconBg": "green",
"enabled": true,
"home": true,
"rtl": true
},
{
+2 -2
View File
@@ -7,8 +7,8 @@
{% endfor %}
<header id="navbar">
<a href="/{{ '' if lang == 'en' else lang }}" class="logo flex dark:hidden ltr:mr-auto rtl:ml-auto"><img src="/img/new/logo-light.png" alt="logo"></a>
<a href="/{{ '' if lang == 'en' else lang }}" class="logo hidden dark:flex ltr:mr-auto rtl:ml-auto"><img src="/img/new/logo-dark.png" alt="logo"></a>
<a href="/{{ '' if lang == 'en' else lang }}" class="logo logo-light ltr:mr-auto rtl:ml-auto"><img src="/img/new/logo-light.png" alt="logo"></a>
<a href="/{{ '' if lang == 'en' else lang }}" class="logo logo-dark ltr:mr-auto rtl:ml-auto"><img src="/img/new/logo-dark.png" alt="logo"></a>
<nav id="menu">
<ul>
+88 -9
View File
@@ -15,9 +15,17 @@ body.change-nav-color {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
[dir="ltr"] .logo {
border-bottom-right-radius: 12px;
padding-right: 24px;
transition: all 0.3s ease;
}
[dir="rtl"] .logo {
border-bottom-left-radius: 12px;
padding-left: 24px;
}
.dark .logo {
@@ -42,9 +50,17 @@ body.change-nav-color {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
[dir="ltr"] .right-links {
border-bottom-left-radius: 12px;
padding-left: 24px;
transition: all 0.3s ease;
}
[dir="rtl"] .right-links {
border-bottom-right-radius: 12px;
padding-right: 24px;
}
.dark .right-links {
@@ -69,14 +85,46 @@ header#navbar {
header#navbar>a.logo {
position: absolute;
left: 0px;
padding-left: 30px;
padding-top: 2px;
height: 100%;
/* display: flex; */
align-items: center;
}
[dir="ltr"] header#navbar>a.logo {
left: 0px;
padding-left: 30px;
}
[dir="rtl"] header#navbar>a.logo {
right: 0px;
padding-right: 30px;
}
header#navbar>a.logo.logo-light {
display: flex;
}
header#navbar>a.logo.logo-dark {
display: none;
}
.dark header#navbar>a.logo.logo-light {
display: none;
}
.dark header#navbar>a.logo.logo-dark {
display: flex;
}
[dir="rtl"].dark .change-nav-color header#navbar>a.logo.logo-light {
display: flex;
}
[dir="rtl"].dark .change-nav-color header#navbar>a.logo.logo-dark {
display: none;
}
header#navbar>a.logo img {
height: 40px;
width: auto;
@@ -183,7 +231,7 @@ header#navbar ul a {
color: black;
}
.change-nav-color .box-btn.token {
.dark .change-nav-color .box-btn.token {
background: linear-gradient(90deg, #019bfe 0%, #2e3fa0 100%);
color: white;
}
@@ -219,9 +267,13 @@ header#navbar ul a span svg {
fill: var(--nav-color);
}
header#navbar nav#menu li.nav-link:hover span svg {
[dir="ltr"] header#navbar nav#menu li.nav-link:hover span svg {
transform: rotate(180deg);
}
[dir="rtl"] header#navbar nav#menu li.nav-link:hover span svg {
transform: rotate(-180deg);
}
}
header#navbar ul.sub-menu {
@@ -348,6 +400,11 @@ header#navbar .nav-link:focus-within .sub-menu {
right: 0;
}
[dir="rtl"] .flag-container .sub-menu {
left: 0;
right: auto;
}
header#navbar button.theme-switch-btn {
display: flex;
align-items: center;
@@ -380,14 +437,22 @@ header#navbar button.theme-switch-btn svg path {
.right-links {
position: absolute;
top: 0;
right: 0px;
padding-right: 20px;
height: 54px;
display: flex;
align-items: center;
gap: 1.5rem;
}
[dir="ltr"] .right-links {
right: 0px;
padding-right: 20px;
}
[dir="rtl"] .right-links {
left: 0px;
padding-left: 20px;
}
button#cross-btn {
background-color: transparent;
border: none;
@@ -454,9 +519,16 @@ button#cross-btn {
visibility: hidden;
opacity: 0;
transition: all .3s ease;
}
[dir="ltr"] header#navbar {
transform: translateX(-100%);
}
[dir="rtl"] header#navbar {
transform: translateX(100%);
}
.dark header#navbar {
background: #0a0f2b;
}
@@ -494,13 +566,20 @@ button#cross-btn {
header#navbar nav#menu {
position: fixed !important;
top: 54px;
left: 0;
width: 100%;
height: calc(100% - 54px);
overflow: auto;
padding: 0.5rem 0;
}
[dir="ltr"] header#navbar nav#menu {
left: 0;
}
[dir="rtl"] header#navbar nav#menu {
right: 0;
}
header#navbar nav#menu .nav-link {
width: 100%;
}
+50 -6
View File
@@ -433,7 +433,6 @@ section.cover div.content p {
.publications-btns {
position: absolute;
bottom: 24px;
left: 30px;
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -441,6 +440,14 @@ section.cover div.content p {
gap: 12px;
}
[dir="ltr"] .publications-btns {
left: 30px;
}
[dir="rtl"] .publications-btns {
right: 30px;
}
.publications-btns img {
width: 32px;
}
@@ -456,8 +463,6 @@ section.cover div.content p {
.security-btns {
position: absolute;
bottom: 24px;
left: 0;
transform: translateX(calc((100vw / 2) - 50%));
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -465,6 +470,16 @@ section.cover div.content p {
gap: 12px;
}
[dir="ltr"] .security-btns {
left: 0;
transform: translateX(calc((100vw / 2) - 50%));
}
[dir="rtl"] .security-btns {
right: 0;
transform: translateX(calc(-1 * ((100vw / 2) - 50%)));
}
.security-btns img {
height: 36px;
}
@@ -473,27 +488,38 @@ section.cover div.content p {
font-size: 14px !important;
font-family: 'Manrope', 'GT-Walsheim', sans-serif !important;
font-weight: 300 !important;
text-align: left;
color: white;
line-height: 1.2;
}
[dir="ltr"] .security-audits {
text-align: left;
}
[dir="rtl"] .security-audits {
text-align: right;
}
@media screen and (max-width: 1279px) {
.publications-btns {
display: none;
visibility: hidden;
}
.security-btns {
[dir="ltr"] .security-btns {
transform: translateX(0);
left: 16px;
}
[dir="rtl"] .security-btns {
transform: translateX(0);
right: 16px;
}
}
.socials {
position: absolute;
bottom: 22px;
right: 30px;
display: flex;
align-items: center;
justify-content: center;
@@ -501,6 +527,14 @@ section.cover div.content p {
flex-wrap: wrap;
}
[dir="ltr"] .socials {
right: 30px;
}
[dir="rtl"] .socials {
left: 30px;
}
.socials a img {
width: auto;
height: 40px;
@@ -783,6 +817,12 @@ main .section-bg {
margin-left: auto;
}
[dir="rtl"] .page-2 .text-container,
[dir="rtl"] .page-5 .text-container,
[dir="rtl"] .page-6 .text-container {
margin-right: auto;
}
.page-4 .text-container h2 {
max-width: calc(var(--sec-vwu)*26) !important;
}
@@ -929,6 +969,10 @@ main .section-bg {
float: left;
}
[dir="rtl"] .roadmap>p:first-child {
float: right;
}
.roadmap p.title span {
display: inline;
}
+9 -9
View File
@@ -166,17 +166,17 @@ active_home: true
<h2>{{ "index-roadmap-h2" | i18n({}, lang) }}</h2>
<div class="roadmap">
<p>{{ "index-roadmap-2025" | i18n({}, lang) }} </p>
<p class="title"><span>: </span>{{ "index-roadmap-2025-title" | i18n({}, lang) }}</p>
<p class="title"><span class="ltr:order-first rtl:order-last">: </span>{{ "index-roadmap-2025-title" | i18n({}, lang) }}</p>
<p>{{ "index-roadmap-2025-desc" | i18n({}, lang) }}</p>
</div>
<div class="roadmap">
<p>{{ "index-roadmap-2026" | i18n({}, lang) }} </p>
<p class="title"><span>: </span>{{ "index-roadmap-2026-title" | i18n({}, lang) }}</p>
<p class="title"><span class="ltr:order-first rtl:order-last">: </span>{{ "index-roadmap-2026-title" | i18n({}, lang) }}</p>
<p>{{ "index-roadmap-2026-desc" | i18n({}, lang) }}</p>
</div>
<div class="roadmap">
<p>{{ "index-roadmap-2027" | i18n({}, lang) }} </p>
<p class="title"><span>: </span>{{ "index-roadmap-2027-title" | i18n({}, lang) }}</p>
<p class="title"><span class="ltr:order-first rtl:order-last">: </span>{{ "index-roadmap-2027-title" | i18n({}, lang) }}</p>
<p>{{ "index-roadmap-2027-desc" | i18n({}, lang) }}</p>
</div>
</div>
@@ -235,19 +235,19 @@ active_home: true
(function navColorOnScroll(){
const header = document.querySelector('header#navbar');
const cover = document.querySelector('.page-1.cover');
if (!header || !cover || !('IntersectionObserver' in window)) {
const footer = document.querySelector('.footer.page');
if (!header || !cover || !footer || !('IntersectionObserver' in window)) {
window.addEventListener('load', () => header && document.body.classList.toggle('change-nav-color', window.scrollY > 10));
window.addEventListener('scroll', () => header && document.body.classList.toggle('change-nav-color', window.scrollY > 10));
return;
}
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
// if cover is mostly visible, keep white nav; else switch to #001796
const onCover = e.isIntersecting && e.intersectionRatio >= 0.02;
document.body.classList.toggle('change-nav-color', !onCover);
}
// if cover or footer is mostly visible, keep white nav; else switch to #001796
const onObserved = entries.some((e) => e.isIntersecting && e.intersectionRatio >= 0.02);
document.body.classList.toggle('change-nav-color', !onObserved);
}, { threshold: [0, 0.02, 1] });
io.observe(cover);
io.observe(footer);
})();
</script>
<script>
+1
View File
@@ -3,6 +3,7 @@
set -e
cp -R docs website/src
rm -rf website/src/docs/contributing
rm -rf website/src/docs/rfcs
rm website/src/docs/lang/*/README.md
rm -rf website/src/docs/dependencies