mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-29 09:49:04 +00:00
Merge branch 'master' into ep/privacy
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
*.iml
|
||||
.gradle
|
||||
.kotlin
|
||||
/local.properties
|
||||
/.idea
|
||||
!/.idea/codeStyles/*
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+277
-98
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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) }
|
||||
}
|
||||
|
||||
+4
-2
@@ -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 {
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+19
-10
@@ -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 |
+4
@@ -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 |
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -766,6 +766,7 @@ Group:
|
||||
- itemTimed: [CITimed](#citimed)?
|
||||
- itemLive: bool?
|
||||
- userMention: bool
|
||||
- hasLink: bool
|
||||
- deletable: bool
|
||||
- editable: bool
|
||||
- forwardedByMember: int64?
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")])
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")])
|
||||
|
||||
@@ -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")])
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"textColor": "white",
|
||||
"iconBg": "green",
|
||||
"enabled": true,
|
||||
"home": true,
|
||||
"rtl": true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user