diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 682e3f3357..cbd83d58a4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -1624,8 +1624,9 @@ fun getTimestampText(t: Instant): String { val tz = TimeZone.currentSystemDefault() val now: LocalDateTime = Clock.System.now().toLocalDateTime(tz) val time: LocalDateTime = t.toLocalDateTime(tz) + val period = now.date.minus(time.date) val recent = now.date == time.date || - (now.date.minus(time.date).days == 1 && now.hour < 12 && time.hour >= 18 ) + (period.years == 0 && period.months == 0 && period.days == 1 && now.hour < 12 && time.hour >= 18 ) val dateFormatter = if (recent) { DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 721fc48df6..f6fa70b4ee 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -1480,10 +1480,14 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a if (active(r.user)) { chatModel.upsertGroupMember(r.groupInfo, r.member) } - is CR.ConnectedToGroupMember -> + is CR.ConnectedToGroupMember -> { if (active(r.user)) { chatModel.upsertGroupMember(r.groupInfo, r.member) } + if (r.memberContact != null) { + chatModel.setContactNetworkStatus(r.memberContact, NetworkStatus.Connected()) + } + } is CR.GroupUpdated -> if (active(r.user)) { chatModel.updateGroup(r.toGroup) @@ -3333,7 +3337,7 @@ sealed class CR { @Serializable @SerialName("groupInvitation") class GroupInvitation(val user: User, val groupInfo: GroupInfo): CR() // unused @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: User, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR() @Serializable @SerialName("groupRemoved") class GroupRemoved(val user: User, val groupInfo: GroupInfo): CR() // unused @Serializable @SerialName("groupUpdated") class GroupUpdated(val user: User, val toGroup: GroupInfo): CR() @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: User, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR() @@ -3558,7 +3562,7 @@ sealed class CR { is GroupInvitation -> withUser(user, json.encodeToString(groupInfo)) is UserJoinedGroup -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") - is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nmemberContact: $memberContact") is GroupRemoved -> withUser(user, json.encodeToString(groupInfo)) is GroupUpdated -> withUser(user, json.encodeToString(toGroup)) is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact\nmemberRole: $memberRole") diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index e005ae0c3d..5e9bceb202 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -159,6 +159,14 @@ fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showM DeleteGroupAction(chat, groupInfo, chatModel, showMenu) } } + GroupMemberStatus.MemAccepted -> { + if (groupInfo.membership.memberCurrent) { + LeaveGroupAction(groupInfo, chatModel, showMenu) + } + if (groupInfo.canDelete) { + DeleteGroupAction(chat, groupInfo, chatModel, showMenu) + } + } else -> { if (showMarkRead) { MarkReadChatAction(chat, chatModel, showMenu) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d5a58e659b..16eddca9ac 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1345,10 +1345,13 @@ func processReceivedMsg(_ res: ChatResponse) async { if active(user) { _ = m.upsertGroupMember(groupInfo, member) } - case let .connectedToGroupMember(user, groupInfo, member): + case let .connectedToGroupMember(user, groupInfo, member, memberContact): if active(user) { _ = m.upsertGroupMember(groupInfo, member) } + if let contact = memberContact { + m.setContactNetworkStatus(contact, .connected) + } case let .groupUpdated(user, toGroup): if active(user) { m.updateGroup(toGroup) diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index f64276f9b3..5ecec1a982 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -13,6 +13,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg RTCInitializeSSL() let videoEncoderFactory = RTCDefaultVideoEncoderFactory() let videoDecoderFactory = RTCDefaultVideoDecoderFactory() + videoEncoderFactory.preferredCodec = RTCVideoCodecInfo(name: kRTCVp8CodecName) return RTCPeerConnectionFactory(encoderFactory: videoEncoderFactory, decoderFactory: videoDecoderFactory) }() private static let ivTagBytes: Int = 28 @@ -301,6 +302,17 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } func startCaptureLocalVideo(_ activeCall: Call) { +#if targetEnvironment(simulator) + guard + let capturer = activeCall.localCamera as? RTCFileVideoCapturer + else { + logger.error("Unable to work with a file capturer") + return + } + capturer.stopCapture() + // Drag video file named `video.mp4` to `sounds` directory in the project from any other path in filesystem + capturer.startCapturing(fromFileNamed: "sounds/video.mp4") +#else guard let capturer = activeCall.localCamera as? RTCCameraVideoCapturer, let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == activeCall.device }) @@ -328,6 +340,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg capturer.startCapture(with: camera, format: format, fps: Int(min(24, fps.maxFrameRate))) +#endif } private func createAudioSender(_ connection: RTCPeerConnection) { diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 59ecad36e7..5733568c07 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -108,6 +108,14 @@ struct ChatListNavLink: View { .onTapGesture { AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) } + .swipeActions(edge: .trailing) { + if (groupInfo.membership.memberCurrent) { + leaveGroupChatButton(groupInfo) + } + if groupInfo.canDelete { + deleteGroupChatButton(groupInfo) + } + } default: NavLinkPlain( tag: chat.chatInfo.id, @@ -124,12 +132,7 @@ struct ChatListNavLink: View { clearChatButton() } if (groupInfo.membership.memberCurrent) { - Button { - AlertManager.shared.showAlert(leaveGroupAlert(groupInfo)) - } label: { - Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") - } - .tint(Color.yellow) + leaveGroupChatButton(groupInfo) } } .swipeActions(edge: .trailing) { @@ -177,7 +180,16 @@ struct ChatListNavLink: View { .tint(Color.orange) } - @ViewBuilder private func deleteGroupChatButton(_ groupInfo: GroupInfo) -> some View { + private func leaveGroupChatButton(_ groupInfo: GroupInfo) -> some View { + Button { + AlertManager.shared.showAlert(leaveGroupAlert(groupInfo)) + } label: { + Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") + } + .tint(Color.yellow) + } + + private func deleteGroupChatButton(_ groupInfo: GroupInfo) -> some View { Button { AlertManager.shared.showAlert(deleteGroupAlert(groupInfo)) } label: { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index f2759e8cec..7e590a40fa 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -456,7 +456,7 @@ public enum ChatResponse: Decodable, Error { case groupInvitation(user: User, groupInfo: GroupInfo) // unused case userJoinedGroup(user: User, groupInfo: GroupInfo) case joinedGroupMember(user: User, groupInfo: GroupInfo, member: GroupMember) - case connectedToGroupMember(user: User, groupInfo: GroupInfo, member: GroupMember) + case connectedToGroupMember(user: User, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?) case groupRemoved(user: User, groupInfo: GroupInfo) // unused case groupUpdated(user: User, toGroup: GroupInfo) case groupLinkCreated(user: User, groupInfo: GroupInfo, connReqContact: String, memberRole: GroupMemberRole) @@ -692,7 +692,7 @@ public enum ChatResponse: Decodable, Error { case let .groupInvitation(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .connectedToGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))") case let .groupRemoved(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) case let .groupLinkCreated(u, groupInfo, connReqContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)\nmemberRole: \(memberRole)") diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 4b49f7ecfa..1fe081d0d8 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2283,10 +2283,22 @@ let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute() let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits) public func formatTimestampText(_ date: Date) -> Text { - let now = Calendar.current.dateComponents([.day, .hour], from: .now) - let dc = Calendar.current.dateComponents([.day, .hour], from: date) - let recent = now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12) - return Text(date, format: recent ? msgTimeFormat : msgDateFormat) + return Text(date, format: recent(date) ? msgTimeFormat : msgDateFormat) +} + +private func recent(_ date: Date) -> Bool { + let now = Date() + let calendar = Calendar.current + + guard let previousDay = calendar.date(byAdding: DateComponents(day: -1), to: now), + let previousDay18 = calendar.date(bySettingHour: 18, minute: 0, second: 0, of: previousDay), + let currentDay00 = calendar.date(bySettingHour: 0, minute: 0, second: 0, of: now), + let currentDay12 = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: now) else { + return false + } + + let isSameDay = calendar.isDate(date, inSameDayAs: now) + return isSameDay || (now < currentDay12 && date >= previousDay18 && date < currentDay00) } public enum CIStatus: Decodable { diff --git a/docs/SERVER.md b/docs/SERVER.md index 07594ca10c..cee3320e4c 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -47,7 +47,10 @@ Manual installation requires some preliminary actions: ```sh # For Ubuntu - ufw allow 5223 + sudo ufw allow 5223/tcp + # For Fedora + sudo firewall-cmd --permanent --add-port=5223/tcp && \ + sudo firewall-cmd --reload ``` 4. **Optional** — If you're using distribution with `systemd`, create `/etc/systemd/system/smp-server.service` file with the following content: diff --git a/docs/WEBRTC.md b/docs/WEBRTC.md index 304f29d812..7978d21ec7 100644 --- a/docs/WEBRTC.md +++ b/docs/WEBRTC.md @@ -74,11 +74,21 @@ systemctl enable coturn && systemctl start coturn - **49152:65535** – port range that Coturn will use by default for TURN relay. ```sh -ufw allow 3478 && \ -ufw allow 443 && \ -ufw allow 5349 && \ -ufw allow 49152:65535/tcp && \ -ufw allow 49152:65535/udp +# For Ubuntu +sudo ufw allow 3478 && \ +sudo ufw allow 443 && \ +sudo ufw allow 5349 && \ +sudo ufw allow 49152:65535/tcp && \ +sudo ufw allow 49152:65535/udp + +# For Fedora +sudo firewall-cmd --permanent --add-port=443/tcp && \ +sudo firewall-cmd --permanent --add-port=443/udp && \ +sudo firewall-cmd --permanent --add-port=5349/tcp && \ +sudo firewall-cmd --permanent --add-port=5349/udp && \ +sudo firewall-cmd --permanent --add-port=49152:65535/tcp && \ +sudo firewall-cmd --permanent --add-port=49152:65535/udp && \ +sudo firewall-cmd --reload ``` ## Configure mobile apps diff --git a/docs/XFTP-SERVER.md b/docs/XFTP-SERVER.md index 567ae63b01..79161e977e 100644 --- a/docs/XFTP-SERVER.md +++ b/docs/XFTP-SERVER.md @@ -40,7 +40,10 @@ XFTP is a new file transfer protocol focussed on meta-data protection - it is ba ```sh # For Ubuntu - sudo ufw allow 443 + sudo ufw allow 443/tcp + # For Fedora + sudo firewall-cmd --permanent --add-port=443/tcp && \ + sudo firewall-cmd --reload ``` 5. **Optional** — If you're using distribution with `systemd`, create `/etc/systemd/system/xftp-server.service` file with the following content: diff --git a/docs/lang/cs/SERVER.md b/docs/lang/cs/SERVER.md index 1620ec2e1a..2030989150 100644 --- a/docs/lang/cs/SERVER.md +++ b/docs/lang/cs/SERVER.md @@ -46,7 +46,10 @@ Ruční instalace vyžaduje několik předběžných úkonů: ```sh # Pro Ubuntu - ufw allow 5233 + sudo ufw allow 5233/tcp + # Pro Fedora + sudo firewall-cmd --permanent --add-port=5223/tcp && \ + sudo firewall-cmd --reload ``` 4. **Volitelné** - Pokud používáte distribuci s `systemd`, vytvořte soubor `/etc/systemd/system/smp-server.service` s následujícím obsahem: diff --git a/docs/lang/cs/WEBRTC.md b/docs/lang/cs/WEBRTC.md index 64ebc91093..77df920255 100644 --- a/docs/lang/cs/WEBRTC.md +++ b/docs/lang/cs/WEBRTC.md @@ -73,11 +73,21 @@ systemctl enable coturn && systemctl start coturn - **49152:65535** - rozsah portů, který bude společnost Coturn ve výchozím nastavení používat pro přenos TURN. ```sh -ufw allow 3478 && \ -ufw allow 443 && \ -ufw allow 5349 && \ -ufw allow 49152:65535/tcp && \ -ufw allow 49152:65535/udp +# Pro Ubuntu +sudo ufw allow 3478 && \ +sudo ufw allow 443 && \ +sudo ufw allow 5349 && \ +sudo ufw allow 49152:65535/tcp && \ +sudo ufw allow 49152:65535/udp + +# Pro Fedora +sudo firewall-cmd --permanent --add-port=443/tcp && \ +sudo firewall-cmd --permanent --add-port=443/udp && \ +sudo firewall-cmd --permanent --add-port=5349/tcp && \ +sudo firewall-cmd --permanent --add-port=5349/udp && \ +sudo firewall-cmd --permanent --add-port=49152:65535/tcp && \ +sudo firewall-cmd --permanent --add-port=49152:65535/udp && \ +sudo firewall-cmd --reload ``` ## Konfigurace mobilních aplikací diff --git a/docs/lang/fr/SERVER.md b/docs/lang/fr/SERVER.md index 6f9c7c5990..aebddac983 100644 --- a/docs/lang/fr/SERVER.md +++ b/docs/lang/fr/SERVER.md @@ -46,7 +46,10 @@ L'installation manuelle nécessite quelques actions préalables : ```sh # Pour Ubuntu - ufw allow 5223 + sudo ufw allow 5223/tcp + # Pour Fedora + sudo firewall-cmd --permanent --add-port=5223/tcp && \ + sudo firewall-cmd --reload ``` 4. **Optionnel** - Si vous utilisez une distribution avec `systemd`, créez le fichier `/etc/systemd/system/smp-server.service` avec le contenu suivant : diff --git a/docs/lang/fr/WEBRTC.md b/docs/lang/fr/WEBRTC.md index e06ca46c18..47296274b3 100644 --- a/docs/lang/fr/WEBRTC.md +++ b/docs/lang/fr/WEBRTC.md @@ -73,11 +73,21 @@ systemctl enable coturn && systemctl start coturn - **49152:65535** – plage de ports que Coturn utilisera par défaut pour le relais TURN. ```sh -ufw allow 3478 && \ -ufw allow 443 && \ -ufw allow 5349 && \ -ufw allow 49152:65535/tcp && \ -ufw allow 49152:65535/udp +# Pour Ubuntu +sudo ufw allow 3478 && \ +sudo ufw allow 443 && \ +sudo ufw allow 5349 && \ +sudo ufw allow 49152:65535/tcp && \ +sudo ufw allow 49152:65535/udp + +# Pour Fedora +sudo firewall-cmd --permanent --add-port=443/tcp && \ +sudo firewall-cmd --permanent --add-port=443/udp && \ +sudo firewall-cmd --permanent --add-port=5349/tcp && \ +sudo firewall-cmd --permanent --add-port=5349/udp && \ +sudo firewall-cmd --permanent --add-port=49152:65535/tcp && \ +sudo firewall-cmd --permanent --add-port=49152:65535/udp && \ +sudo firewall-cmd --reload ``` ## Configurer l'app mobile diff --git a/docs/rfcs/2023-05-30-rotation-improvements.md b/docs/rfcs/2023-05-30-rotation-improvements.md new file mode 100644 index 0000000000..62da04c90a --- /dev/null +++ b/docs/rfcs/2023-05-30-rotation-improvements.md @@ -0,0 +1,114 @@ +# Connection switch improvements + +## Problems + +- State of switch is unknown to chat, making it unclear if switch is progressing or stuck. + +- Multiple connection switches can be triggered before the first one completes, leaving unused queues. + +## Solution + +Propagate switch state to UI via extended `ConnectionStats`. UI will display switch status if switch is in progress, the button to initiate switch will be disabled depending on switch state. + +### Agent backend + +Add following fields to `connections` table: + +```sql +ALTER TABLE connections ADD COLUMN rcv_switch_status TEXT; +ALTER TABLE connections ADD COLUMN snd_switch_status TEXT; +``` + +We can use either existing `SwitchPhase` type as status, or new more detailed types separate for rcv and snd switch. + +```haskell +data RcvSwitchStatus + = RSSQueuingSwch -- set in beginning of switchConnectionAsync' before queueing SWCH command + | RSSSwchStarted -- set in beginning of switchConnection' + | RSSQueuingQADD -- set in switchConnection' before queuing QADD + | RSSSentQADD -- set in runSmpQueueMsgDelivery after receiving Right in response to sending QADD + | RSSReceivedQKEY -- set on receiving QKEY, in beginning of qKeyMsg + | RSSQueueingSecure -- set before queueing ICQSecure in qKeyMsg + | RSSSecureStarted -- set in beginning of ICQSecure processing in runCommandProcessing + | RSSQueueingQUSE -- set in ICQSecure processing before queueing QUSE + | RSSMessageReceived -- set after receiving first message in the new queue (processSMPTransmission, setRcvQueuePrimary) + | RSSQueueingDelete -- set before queuing ICQDelete in processSMPTransmission + | RSSDeleteStarted -- set in beginning of ICQDelete processing in runCommandProcessing + +-- after ICQDelete processing rcv_switch_status is set back to NULL, on internal errors as well + +canStopRcvSwitch :: RcvSwitchStatus -> Bool +canStopRcvSwitch = \case + RSSSentQADD -> True + _ -> False +``` + +I don't know use for most of the statuses yet, except for debugging, and more granular control over converting switch state into UI representation. At least it seems necessary to record the very beginning of switch (async command, sync command), points of sending QADD and receiving QKEY, and to reset after ICQDelete. `SwitchPhase` type seems to be insufficient. + +When it is in progress, repeatedly switching connection in UI is only allowed in RSSSentQADD status - to avoid race conditions with internal commands processing, and sender processing. + +Logic of repeat switch of connection could be split into stopSwitchConnection and existing start switch command on client level (basically chat automating two buttons for user). + +```haskell +stopSwitchConnection :: AgentErrorMonad m => AgentClient -> ConnId -> m ConnectionStats +stopSwitchConnection c = do + -- in transaction: + -- if rcv_switch_status is RSSSentQADD: + -- - deleteConnRcvQueue + -- - reset rcv_switch_status to NULL + -- else: throw error + -- deleteQueue on server +``` + +Repeat switch would send repeat QADD to sender, sender should delete snd queue that was previously set to replace existing queue (check in qAddMsg). + +Snd switch statuses: + +```haskell +data SndSwitchStatus + = SSSReceivedQADD -- set on receiving QADD, in beginning of qAddMsg + | SSSQueueingQKEY -- set in qAddMsg before queuing QKEY + | SSSReceivedQUSE -- set on receiving QUSE, in beginning of qUseMsg + | SSSQueueingQTEST -- set in qUseMsg before queuing QTEST + | SSSSentQTEST -- set in runSmpQueueMsgDelivery after receiving Right in response to sending QTEST + +-- after processing AM_QTEST_ in runSmpQueueMsgDelivery, snd_switch_status is set back to NULL +``` + +In case of send it is enough to know that "snd switch is in progress", as sender cannot do any actions with recipient's switch, so status granularity is not necessary, but it can be useful for debugging and UI representation. + +--- +Problem: +If permanent errors in the new queue prevent switch from completing, it will be stuck forever and repeat switch will not be allowed. How to abort switch in this case while switch is in progress? + +Solution: +- In case of a permanent error on recipient side, delete queue (deleteQueue on server, catching errors; deleteConnRcvQueue in database), reset rcv_switch_status to NULL. However, if rcv_switch_status is one of RSSMessageReceived, RSSQueueingDelete, RSSDeleteStarted, it means that sender has already deleted the original queue and will not be able to revert, so the recipient has to complete switch regardless of permanent errors. +- In case of a permanent error on sender side (e.g. AUTH error when trying to send QTEST), recipient has no way of knowing switch will never complete - one option is for sender to send a new message "QERR SMPQueueInfo" ( / QFAIL) in the original queue and delete queue from database; after receiving QERR recipient tries to delete queue on server, deletes queue in database, resets rcv_switch_status to NULL. + +A new SPFailed SwitchPhase might be required for notifying client, so that switch failure is visible in client as a new chat item. + +In case this new QERR message would have to be added anyway - why allow stopping/re-triggering switch at all? Sender agent could send QERR on permanent error when trying to send QKEY as well. This would allow to reduce number or required statuses, and remove the need to introduce stopSwitchConnection api / stop logic in agent. +--- + +Type to communicate switch status to chat and UI: + +```haskell +-- rename: +-- ConnectionStats -> ConnectionInfo, +-- getConnectionServers -> getConnectionInfo + +data ConnectionInfo = ConnectionInfo + { rcvServers :: [SMPServer], + sndServers :: [SMPServer], + rcvSwitchStatus :: Maybe RcvSwitchStatus, + sndSwitchStatus :: Maybe SndSwitchStatus + } +``` + +Existing connections: + +- Should switch be allowed for existing connections with switch in progress? +- If switch should not be allowed, how to distinguish whether switch is in progress - by connection having multiple queues? +- If yes, how to account for connections with extra queues from unfinished switches? + +It's the easiest to allow switch for existing connections with switch already in progress and/or multiple switching queues.It is currently allowed, so it's not breaking anything new. diff --git a/scripts/android/build-android.sh b/scripts/android/build-android.sh index 6ff2f6e446..87f9776b1b 100755 --- a/scripts/android/build-android.sh +++ b/scripts/android/build-android.sh @@ -2,20 +2,34 @@ # Safety measures set -eu +repo="https://github.com/simplex-chat/simplex-chat" + u="$USER" -tmp=$(mktemp -d -t) +tmp="$(mktemp -d -t)" folder="$tmp/simplex-chat" + +nix_ver="nix-2.15.1" +nix_url="https://releases.nixos.org/nix/$nix_ver/install" +nix_hash="67aa37f0115195d8ddf32b5d6f471f1e60ecca0fdb3e98bcf54bc147c3078640" +nix_config="sandbox = true +max-jobs = auto +experimental-features = nix-command flakes" + commands="nix git curl gradle zip unzip zipalign" +arches="${ARCHES:-aarch64 armv7a}" + +arch_map() { + case $1 in + aarch64) android_arch="arm64-v8a" ;; + armv7a) android_arch="armeabi-v7a" ;; + esac +} nix_install() { # Pre-setup nix [ ! -d /nix ] && sudo sh -c "mkdir -p /nix && chown -R $u /nix" # Install nix - nix_ver="nix-2.14.1" - nix_url="https://releases.nixos.org/nix/$nix_ver/install" - nix_hash="565974057264f0536f600c68d59395927cd73e9fc5a60f33c1906e8f7bc33fcf" - curl -sSf "$nix_url" -o "$tmp/nix-install" printf "%s %s" "$nix_hash" "$tmp/nix-install" | sha256sum -c chmod +x "$tmp/nix-install" && "$tmp/nix-install" --no-daemon @@ -24,21 +38,17 @@ nix_install() { } nix_setup() { - printf "sandbox = true\nmax-jobs = auto\nexperimental-features = nix-command flakes\n" > "$tmp/nix.conf" + printf "%s" "$nix_config" > "$tmp/nix.conf" export NIX_CONF_DIR="$tmp/" } git_setup() { [ "$folder" != "." ] && { - git clone --depth=1 https://github.com/simplex-chat/simplex-chat "$folder" + git clone --depth=1 "$repo" "$folder" } # Switch to nix-android branch git -C "$folder" checkout "$commit" - - # Create missing folders - mkdir -p "$folder/apps/android/app/src/main/cpp/libs/arm64-v8a" - mkdir -p "$folder/apps/android/app/src/main/cpp/libs/armeabi-v7a" } checks() { @@ -70,38 +80,53 @@ checks() { } build() { - # Build simplex lib - nix build "$folder#hydraJobs.aarch64-android:lib:simplex-chat.x86_64-linux" - unzip -o "$PWD/result/pkg-aarch64-android-libsimplex.zip" -d "$folder/apps/android/app/src/main/cpp/libs/arm64-v8a" - - nix build "$folder#hydraJobs.armv7a-android:lib:simplex-chat.x86_64-linux" - unzip -o "$PWD/result/pkg-armv7a-android-libsimplex.zip" -d "$folder/apps/android/app/src/main/cpp/libs/armeabi-v7a" - - # Build android suppprt lib - nix build "$folder#hydraJobs.aarch64-android:lib:support.x86_64-linux" - unzip -o "$PWD/result/pkg-aarch64-android-libsupport.zip" -d "$folder/apps/android/app/src/main/cpp/libs/arm64-v8a" - - nix build "$folder#hydraJobs.armv7a-android:lib:support.x86_64-linux" - unzip -o "$PWD/result/pkg-armv7a-android-libsupport.zip" -d "$folder/apps/android/app/src/main/cpp/libs/armeabi-v7a" - + # Build preparations sed -i.bak 's/${extract_native_libs}/true/' "$folder/apps/android/app/src/main/AndroidManifest.xml" sed -i.bak '/android {/a lint {abortOnError false}' "$folder/apps/android/app/build.gradle" - gradle -p "$folder/apps/android/" clean build assembleRelease + for arch in $arches; do + android_simplex_lib="${folder}#hydraJobs.${arch}-android:lib:simplex-chat.x86_64-linux" + android_support_lib="${folder}#hydraJobs.${arch}-android:lib:support.x86_64-linux" + android_simplex_lib_output="${PWD}/result/pkg-${arch}-android-libsimplex.zip" + android_support_lib_output="${PWD}/result/pkg-${arch}-android-libsupport.zip" + + arch_map "$arch" - mkdir -p "$tmp/android-aarch64" - unzip -oqd "$tmp/android-aarch64/" "$folder/apps/android/app/build/outputs/apk/release/app-arm64-v8a-release-unsigned.apk" - (cd "$tmp/android-aarch64" && zip -rq5 "$tmp/simplex-chat-aarch64.apk" . && zip -rq0 "$tmp/simplex-chat-aarch64.apk" resources.arsc res) - zipalign -p -f 4 "$tmp/simplex-chat-aarch64.apk" "$PWD/simplex-chat-aarch64.apk" - - mkdir -p "$tmp/android-armv7" - unzip -oqd "$tmp/android-armv7/" "$folder/apps/android/app/build/outputs/apk/release/app-armeabi-v7a-release-unsigned.apk" - (cd "$tmp/android-armv7" && zip -rq5 "$tmp/simplex-chat-armv7.apk" . && zip -rq0 "$tmp/simplex-chat-armv7.apk" resources.arsc res) - zipalign -p -f 4 "$tmp/simplex-chat-armv7.apk" "$PWD/simplex-chat-armv7.apk" + android_tmp_folder="${tmp}/android-${arch}" + android_apk_output="${folder}/apps/android/app/build/outputs/apk/release/app-${android_arch}-release-unsigned.apk" + android_apk_output_final="simplex-chat-${android_arch}.apk" + libs_folder="$folder/apps/android/app/src/main/cpp/libs" + + # Create missing folders + mkdir -p "$libs_folder/$android_arch" + + nix build "$android_simplex_lib" + unzip -o "$android_simplex_lib_output" -d "$libs_folder/$android_arch" + + nix build "$android_support_lib" + unzip -o "$android_support_lib_output" -d "$libs_folder/$android_arch" + + # Build only one arch + sed -i.bak "s/include '.*/include '${android_arch}'/" "$folder/apps/android/app/build.gradle" + gradle -p "$folder/apps/android/" clean assembleRelease + + mkdir -p "$android_tmp_folder" + unzip -oqd "$android_tmp_folder" "$android_apk_output" + + ( + cd "$android_tmp_folder" && \ + zip -rq5 "$tmp/$android_apk_output_final" . && \ + zip -rq0 "$tmp/$android_apk_output_final" resources.arsc res + ) + + zipalign -p -f 4 "$tmp/$android_apk_output_final" "$PWD/$android_apk_output_final" + + rm -rf "$libs_folder/$android_arch" + done } final() { - printf "Simplex-chat was successfully compiled: %s/simplex-chat.apk\nDelete nix and gradle caches with 'rm -rf /nix && rm \$HOME/.nix* && \$HOME/.gradle/caches' in case if no longer needed.\n" "$PWD" + printf 'Simplex-chat was successfully compiled: %s/simplex-chat-*.apk\nDelete nix and gradle caches with "rm -rf /nix && rm $HOME/.nix* && $HOME/.gradle/caches" in case if no longer needed.\n' "$PWD" } main() { diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 1f224345c3..c101f69fd4 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -374,6 +374,7 @@ test-suite simplex-chat-test MobileTests ProtocolTests SchemaDump + ViewTests WebRTCTests Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 79a3e3653d..8dab2e8d38 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -28,7 +28,7 @@ import Data.Bifunctor (bimap, first) import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Char (isSpace) +import Data.Char (isSpace, toLower) import Data.Constraint (Dict (..)) import Data.Either (fromRight, rights) import Data.Fixed (div') @@ -46,7 +46,6 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds) import Data.Time.Clock.System (SystemTime, systemToUTCTime) -import Data.Time.LocalTime (getCurrentTimeZone, getZonedTime) import Data.Word (Word32) import qualified Database.SQLite.Simple as DB import Simplex.Chat.Archive @@ -69,7 +68,7 @@ import Simplex.Messaging.Agent.Client (AgentStatsKey (..), temporaryAgentError) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), createAgentStore, defaultAgentConfig) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew), execSQL, upMigration, withTransactionCtx) +import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew), execSQL, upMigration) import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C @@ -137,7 +136,10 @@ _defaultNtfServers :: [NtfServer] _defaultNtfServers = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"] maxImageSize :: Integer -maxImageSize = 236700 +maxImageSize = 261120 * 2 -- auto-receive on mobiles + +imageExtensions :: [String] +imageExtensions = [".jpg", ".jpeg", ".png", ".gif"] maxMsgReactions :: Int maxMsgReactions = 3 @@ -352,9 +354,9 @@ processChatCommand = \case asks currentUser >>= readTVarIO >>= \case Nothing -> throwChatError CENoActiveUser Just user -> do - smpServers <- withStore' (`getProtocolServers` user) + servers <- withStore' (`getProtocolServers` user) cfg <- asks config - pure (activeAgentServers cfg protocol smpServers, smpServers) + pure (activeAgentServers cfg protocol servers, servers) | otherwise = do defServers <- asks $ defaultServers . config pure (cfgServers protocol defServers, []) @@ -1257,7 +1259,7 @@ processChatCommand = \case saveSndChatItem user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) ) `catchError` (toView . CRChatError (Just user)) - CRBroadcastSent user mc (length cts) <$> liftIO getZonedTime + CRBroadcastSent user mc (length cts) <$> liftIO getCurrentTime SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do contactId <- withStore $ \db -> getContactIdByName db user cName quotedItemId <- withStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg @@ -1471,6 +1473,7 @@ processChatCommand = \case LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName chatResp <- processChatCommand $ APIGetChat chatRef (CPLast count) search + setActive $ chatActiveTo chatName pure $ CRChatItems user (aChatItems . chat $ chatResp) LastMessages Nothing count search -> withUser $ \user -> do chatItems <- withStore $ \db -> getAllChatItems db user (CPLast count) search @@ -1502,7 +1505,7 @@ processChatCommand = \case SendImage chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName filePath <- toFSFilePath f - unless (".jpg" `isSuffixOf` f || ".jpeg" `isSuffixOf` f) $ throwChatError CEFileImageType {filePath} + unless (any ((`isSuffixOf` map toLower f)) imageExtensions) $ throwChatError CEFileImageType {filePath} fileSize <- getFileSize filePath unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} -- TODO include file description for preview @@ -2783,7 +2786,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> pure () Just (gInfo@GroupInfo {membership}, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do - notifyMemberConnected gInfo m + notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || memberIncognito membership when (memberCategory m == GCPreMember) $ probeMatchingContacts ct connectedIncognito SENT msgId -> do @@ -2932,11 +2935,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- TODO notify member who forwarded introduction - question - where it is stored? There is via_contact but probably there should be via_member in group_members table withStore' (\db -> getViaGroupContact db user m) >>= \case Nothing -> do - notifyMemberConnected gInfo m + notifyMemberConnected gInfo m Nothing messageWarning "connected member does not have contact" Just ct@Contact {activeConn = Connection {connStatus}} -> when (connStatus == ConnReady) $ do - notifyMemberConnected gInfo m + notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || memberIncognito membership when (memberCategory m == GCPreMember) $ probeMatchingContacts ct connectedIncognito MSG msgMeta _msgFlags msgBody -> do @@ -3274,10 +3277,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do groupDescriptionChatItem gInfo m descr = createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvMsgContent $ MCText descr) Nothing - notifyMemberConnected :: GroupInfo -> GroupMember -> m () - notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} = do + notifyMemberConnected :: GroupInfo -> GroupMember -> Maybe Contact -> m () + notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} ct_ = do memberConnectedChatItem gInfo m - toView $ CRConnectedToGroupMember user gInfo m + toView $ CRConnectedToGroupMember user gInfo m ct_ let g = groupName' gInfo whenGroupNtfs user gInfo $ do setActive $ ActiveG g @@ -4416,10 +4419,9 @@ saveRcvChatItem' user cd msg sharedMsgId_ MsgMeta {broker = (_, brokerTs)} conte mkChatItem :: forall c d. MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CITimed -> Bool -> ChatItemTs -> UTCTime -> IO (ChatItem c d) mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs currentTs = do - tz <- getCurrentTimeZone let itemText = ciContentToText content itemStatus = ciCreateStatus content - meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) tz currentTs itemTs currentTs currentTs + meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) currentTs itemTs currentTs currentTs pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file} deleteDirectCI :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> Bool -> Bool -> m ChatResponse @@ -4985,7 +4987,7 @@ chatCommandP = onOffP = ("on" $> True) <|> ("off" $> False) profileNames = (,) <$> displayName <*> fullNameP newUserP = do - sameServers <- "same_smp=" *> onOffP <* A.space <|> pure False + sameServers <- "same_servers=" *> onOffP <* A.space <|> pure False (cName, fullName) <- profileNames let profile = Just Profile {displayName = cName, fullName, image = Nothing, contactLink = Nothing, preferences = Nothing} pure NewUser {profile, sameServers, pastTimestamp = False} diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index cd2916c83b..ce152fe0fe 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -32,7 +32,7 @@ import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) import Data.String import Data.Text (Text) -import Data.Time (NominalDiffTime, ZonedTime) +import Data.Time (NominalDiffTime) import Data.Time.Clock (UTCTime) import Data.Version (showVersion) import GHC.Generics (Generic) @@ -143,6 +143,12 @@ defaultInlineFilesConfig = data ActiveTo = ActiveNone | ActiveC ContactName | ActiveG GroupName deriving (Eq) +chatActiveTo :: ChatName -> ActiveTo +chatActiveTo (ChatName cType name) = case cType of + CTDirect -> ActiveC name + CTGroup -> ActiveG name + _ -> ActiveNone + data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLiteStore} data ChatController = ChatController @@ -407,7 +413,7 @@ data ChatResponse | CRChatItemReaction {user :: User, added :: Bool, reaction :: ACIReaction} | CRChatItemDeleted {user :: User, deletedChatItem :: AChatItem, toChatItem :: Maybe AChatItem, byUser :: Bool, timed :: Bool} | CRChatItemDeletedNotFound {user :: User, contact :: Contact, sharedMsgId :: SharedMsgId} - | CRBroadcastSent User MsgContent Int ZonedTime + | CRBroadcastSent User MsgContent Int UTCTime | CRMsgIntegrityError {user :: User, msgError :: MsgErrorType} | CRCmdAccepted {corr :: CorrId} | CRCmdOk {user_ :: Maybe User} @@ -485,7 +491,7 @@ data ChatResponse | CRJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} | CRMemberRole {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} | CRMemberRoleUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} - | CRConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, memberContact :: Maybe Contact} | CRDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} | CRDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRLeftMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index cb3dc505e9..67e605d067 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -26,7 +26,6 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime, diffUTCTime, nominalDay) -import Data.Time.LocalTime (TimeZone, ZonedTime, utcToZonedTime) import Data.Type.Equality import Data.Typeable (Typeable) import Database.SQLite.Simple.FromField (FromField (..)) @@ -341,19 +340,17 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta itemTimed :: Maybe CITimed, itemLive :: Maybe Bool, editable :: Bool, - localItemTs :: ZonedTime, createdAt :: UTCTime, updatedAt :: UTCTime } deriving (Show, Generic) -mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> TimeZone -> UTCTime -> ChatItemTs -> UTCTime -> UTCTime -> CIMeta c d -mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive tz currentTs itemTs createdAt updatedAt = - let localItemTs = utcToZonedTime tz itemTs - editable = case itemContent of +mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> UTCTime -> UTCTime -> CIMeta c d +mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs createdAt updatedAt = + let editable = case itemContent of CISndMsgContent _ -> diffUTCTime currentTs itemTs < nominalDay && isNothing itemDeleted _ -> False - in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, localItemTs, createdAt, updatedAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, createdAt, updatedAt} instance ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOptions diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 57b653ca98..e16beb9d4f 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -25,7 +25,7 @@ import Simplex.Chat.Controller (ChatLogLevel (..), updateStr, versionNumber, ver import Simplex.Messaging.Client (NetworkConfig (..), defaultNetworkConfig) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) -import Simplex.Messaging.Protocol (ProtocolTypeI, ProtoServerWithAuth, SMPServerWithAuth, XFTPServerWithAuth) +import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI, SMPServerWithAuth, XFTPServerWithAuth) import Simplex.Messaging.Transport.Client (SocksProxy, defaultSocksProxy) import System.FilePath (combine) @@ -88,7 +88,11 @@ coreChatOptsP appDir defaultDbFileName = do ( long "server" <> short 's' <> metavar "SERVER" - <> help "Semicolon-separated list of SMP server(s) to use (each server can have more than one hostname)" + <> help + ( ("Space-separated list of SMP server(s) to use (each server can have more than one hostname)." <> "\n") + <> ("If you pass multiple servers, surround the entire list in quotes." <> "\n") + <> "Examples: smp1.example.com, \"smp1.example.com smp2.example.com smp3.example.com\"" + ) <> value [] ) xftpServers <- @@ -96,7 +100,11 @@ coreChatOptsP appDir defaultDbFileName = do parseProtocolServers ( long "xftp-server" <> metavar "SERVER" - <> help "Semicolon-separated list of XFTP server(s) to use (each server can have more than one hostname)" + <> help + ( ("Space-separated list of XFTP server(s) to use (each server can have more than one hostname)." <> "\n") + <> ("If you pass multiple servers, surround the entire list in quotes." <> "\n") + <> "Examples: xftp1.example.com, \"xftp1.example.com xftp2.example.com xftp3.example.com\"" + ) <> value [] ) socksProxy <- @@ -270,7 +278,7 @@ serverPortP :: A.Parser (Maybe String) serverPortP = Just . B.unpack <$> A.takeWhile A.isDigit protocolServersP :: ProtocolTypeI p => A.Parser [ProtoServerWithAuth p] -protocolServersP = strP `A.sepBy1` A.char ';' +protocolServersP = strP `A.sepBy1` A.char ' ' parseLogLevel :: ReadM ChatLogLevel parseLogLevel = eitherReader $ \case diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 464ff4b01a..a1d605e715 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -320,7 +320,6 @@ import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay) -import Data.Time.LocalTime (TimeZone, getCurrentTimeZone) import Data.Type.Equality import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), SQLError, (:.) (..)) import qualified Database.SQLite.Simple as DB @@ -3787,9 +3786,8 @@ getChatPreviews db user withPCC = do getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat] getDirectChatPreviews_ db user@User {userId} = do - tz <- getCurrentTimeZone currentTs <- getCurrentTime - map (toDirectChatPreview tz currentTs) + map (toDirectChatPreview currentTs) <$> DB.query db [sql| @@ -3843,18 +3841,17 @@ getDirectChatPreviews_ db user@User {userId} = do |] (CISRcvNew, userId, ConnReady, ConnSndReady) where - toDirectChatPreview :: TimeZone -> UTCTime -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat - toDirectChatPreview tz currentTs (contactRow :. connRow :. statsRow :. ciRow_) = + toDirectChatPreview :: UTCTime -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat + toDirectChatPreview currentTs (contactRow :. connRow :. statsRow :. ciRow_) = let contact = toContact user $ contactRow :. connRow - ci_ = toDirectChatItemList tz currentTs ciRow_ + ci_ = toDirectChatItemList currentTs ciRow_ stats = toChatStats statsRow in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChat] getGroupChatPreviews_ db User {userId, userContactId} = do - tz <- getCurrentTimeZone currentTs <- getCurrentTime - map (toGroupChatPreview tz currentTs) + map (toGroupChatPreview currentTs) <$> DB.query db [sql| @@ -3915,10 +3912,10 @@ getGroupChatPreviews_ db User {userId, userContactId} = do |] (CISRcvNew, userId, userContactId) where - toGroupChatPreview :: TimeZone -> UTCTime -> GroupInfoRow :. ChatStatsRow :. MaybeGroupChatItemRow -> AChat - toGroupChatPreview tz currentTs (groupInfoRow :. statsRow :. ciRow_) = + toGroupChatPreview :: UTCTime -> GroupInfoRow :. ChatStatsRow :. MaybeGroupChatItemRow -> AChat + toGroupChatPreview currentTs (groupInfoRow :. statsRow :. ciRow_) = let groupInfo = toGroupInfo userContactId groupInfoRow - ci_ = toGroupChatItemList tz currentTs userContactId ciRow_ + ci_ = toGroupChatItemList currentTs userContactId ciRow_ stats = toChatStats statsRow in AChat SCTGroup $ Chat (GroupChat groupInfo) ci_ stats @@ -4026,9 +4023,8 @@ getDirectChatLast_ db user ct@Contact {contactId} count search = do -- the last items in reverse order (the last item in the conversation is the first in the returned list) getDirectChatItemsLast :: DB.Connection -> User -> ContactId -> Int -> String -> ExceptT StoreError IO [CChatItem 'CTDirect] getDirectChatItemsLast db User {userId} contactId count search = ExceptT $ do - tz <- getCurrentTimeZone currentTs <- getCurrentTime - mapM (toDirectChatItem tz currentTs) + mapM (toDirectChatItem currentTs) <$> DB.query db [sql| @@ -4056,9 +4052,8 @@ getDirectChatAfter_ db User {userId} ct@Contact {contactId} afterChatItemId coun where getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsAfter_ = do - tz <- getCurrentTimeZone currentTs <- getCurrentTime - mapM (toDirectChatItem tz currentTs) + mapM (toDirectChatItem currentTs) <$> DB.query db [sql| @@ -4087,9 +4082,8 @@ getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId co where getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsBefore_ = do - tz <- getCurrentTimeZone currentTs <- getCurrentTime - mapM (toDirectChatItem tz currentTs) + mapM (toDirectChatItem currentTs) <$> DB.query db [sql| @@ -4523,9 +4517,8 @@ getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId = getDirectChatItem :: DB.Connection -> User -> Int64 -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTDirect) getDirectChatItem db User {userId} contactId itemId = ExceptT $ do - tz <- getCurrentTimeZone currentTs <- getCurrentTime - join <$> firstRow (toDirectChatItem tz currentTs) (SEChatItemNotFound itemId) getItem + join <$> firstRow (toDirectChatItem currentTs) (SEChatItemNotFound itemId) getItem where getItem = DB.query @@ -4684,9 +4677,8 @@ getGroupMemberCIBySharedMsgId db user@User {userId} groupId memberId sharedMsgId getGroupChatItem :: DB.Connection -> User -> Int64 -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do - tz <- getCurrentTimeZone currentTs <- getCurrentTime - join <$> firstRow (toGroupChatItem tz currentTs userContactId) (SEChatItemNotFound itemId) getItem + join <$> firstRow (toGroupChatItem currentTs userContactId) (SEChatItemNotFound itemId) getItem where getItem = DB.query @@ -5135,8 +5127,8 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent) -- this function can be changed so it never fails, not only avoid failure on invalid json -toDirectChatItem :: TimeZone -> UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem tz currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_, fileProtocol_)) :. quoteRow) = +toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) +toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_, fileProtocol_)) :. quoteRow) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -5163,14 +5155,14 @@ toDirectChatItem tz currentTs (((itemId, itemTs, AMsgDirection msgDir, itemConte ciMeta content status = let itemDeleted' = if itemDeleted then Just (CIDeleted @'CTDirect deletedTs) else Nothing itemEdited' = fromMaybe False itemEdited - in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive tz currentTs itemTs createdAt updatedAt + in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} -toDirectChatItemList :: TimeZone -> UTCTime -> MaybeChatItemRow :. QuoteRow -> [CChatItem 'CTDirect] -toDirectChatItemList tz currentTs (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. quoteRow) = - either (const []) (: []) $ toDirectChatItem tz currentTs (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. quoteRow) -toDirectChatItemList _ _ _ = [] +toDirectChatItemList :: UTCTime -> MaybeChatItemRow :. QuoteRow -> [CChatItem 'CTDirect] +toDirectChatItemList currentTs (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. quoteRow) = + either (const []) (: []) $ toDirectChatItem currentTs (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. quoteRow) +toDirectChatItemList _ _ = [] type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow @@ -5185,8 +5177,8 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction direction _ _ = Nothing -- this function can be changed so it never fails, not only avoid failure on invalid json -toGroupChatItem :: TimeZone -> UTCTime -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem tz currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_, fileProtocol_)) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do +toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup) +toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_, fileProtocol_)) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do chatItem $ fromRight invalid $ dbParseACIContent itemContentText where member_ = toMaybeGroupMember userContactId memberRow_ @@ -5219,14 +5211,14 @@ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, AMsgDirection msgD then Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) else Nothing itemEdited' = fromMaybe False itemEdited - in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive tz currentTs itemTs createdAt updatedAt + in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} -toGroupChatItemList :: TimeZone -> UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] -toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = - either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) -toGroupChatItemList _ _ _ _ = [] +toGroupChatItemList :: UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] +toGroupChatItemList currentTs userContactId (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = + either (const []) (: []) $ toGroupChatItem currentTs userContactId (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) +toGroupChatItemList _ _ _ = [] getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> IO [ServerCfg p] getProtocolServers db User {userId} = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 8978772236..f6cec6e3b9 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -24,9 +24,10 @@ import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1) -import Data.Time.Clock (DiffTime, UTCTime) +import Data.Time (LocalTime (..), TimeOfDay (..), TimeZone (..), utcToLocalTime) +import Data.Time.Calendar (addDays) +import Data.Time.Clock (UTCTime) import Data.Time.Format (defaultTimeLocale, formatTime) -import Data.Time.LocalTime (TimeZone, ZonedTime (..), localDay, localTimeOfDay, timeOfDayToTime, utcToLocalTime, utcToZonedTime) import Data.Word (Word32) import GHC.Generics (Generic) import qualified Network.HTTP.Types as Q @@ -70,7 +71,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, testView} liveItems ts CRChatStopped -> ["chat stopped"] CRChatSuspended -> ["chat suspended"] CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [plain . bshow $ J.encode chats] - CRChats chats -> viewChats ts chats + CRChats chats -> viewChats ts tz chats CRApiChat u chat -> ttyUser u $ if testView then testViewChat chat else [plain . bshow $ J.encode chat] CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView @@ -84,17 +85,17 @@ responseToView user_ ChatConfig {logLevel, showReactions, testView} liveItems ts CRConnectionVerified u verified code -> ttyUser u [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] CRContactCode u ct code -> ttyUser u $ viewContactCode ct code testView CRGroupMemberCode u g m code -> ttyUser u $ viewGroupMemberCode g m code testView - CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewChatItem chat item False ts <> viewItemReactions item - CRChatItems u chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts <> viewItemReactions item) chatItems + CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewChatItem chat item False ts tz <> viewItemReactions item + CRChatItems u chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts tz <> viewItemReactions item) chatItems CRChatItemInfo u ci ciInfo -> ttyUser u $ viewChatItemInfo ci ciInfo tz CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId] CRChatItemStatusUpdated u _ -> ttyUser u [] - CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts + CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts tz CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci - CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts testView + CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView CRChatItemReaction u added (ACIReaction _ _ chat reaction) -> ttyUser u $ unmutedReaction chat reaction $ viewItemReaction showReactions chat reaction added ts tz CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] - CRBroadcastSent u mc n t -> ttyUser u $ viewSentBroadcast mc n ts t + CRBroadcastSent u mc n t -> ttyUser u $ viewSentBroadcast mc n ts tz t CRMsgIntegrityError u mErr -> ttyUser u $ viewMsgIntegrityError mErr CRCmdAccepted _ -> [] CRCmdOk u_ -> ttyUser' u_ ["ok"] @@ -194,7 +195,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, testView} liveItems ts CRHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h] CRHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h] CRJoinedGroupMemberConnecting u g host m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] - CRConnectedToGroupMember u g m -> ttyUser u [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] + CRConnectedToGroupMember u g m _ -> ttyUser u [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] CRMemberRole u g by m r r' -> ttyUser u $ viewMemberRoleChanged g by m r r' CRMemberRoleUser u g m r r' -> ttyUser u $ viewMemberRoleUserChanged g m r r' CRDeletedMemberUser u g by -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g @@ -352,11 +353,11 @@ showSMPServer = B.unpack . strEncode . host viewHostEvent :: AProtocolType -> TransportHost -> String viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack (strEncode h) -viewChats :: CurrentTime -> [AChat] -> [StyledString] -viewChats ts = concatMap chatPreview . reverse +viewChats :: CurrentTime -> TimeZone -> [AChat] -> [StyledString] +viewChats ts tz = concatMap chatPreview . reverse where chatPreview (AChat _ (Chat chat items _)) = case items of - CChatItem _ ci : _ -> case viewChatItem chat ci True ts of + CChatItem _ ci : _ -> case viewChatItem chat ci True ts tz of s : _ -> [let s' = sTake 120 s in if sLength s' < sLength s then s' <> "..." else s'] _ -> chatName _ -> chatName @@ -366,8 +367,8 @@ viewChats ts = concatMap chatPreview . reverse GroupChat g -> [" " <> ttyToGroup g] _ -> [] -viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> [StyledString] -viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} doShow ts = +viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] +viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} doShow ts tz = withItemDeleted <$> case chat of DirectChat c -> case chatDir of CIDirectSnd -> case content of @@ -378,8 +379,8 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} to = ttyToContact' c CIDirectRcv -> case content of CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc - CIRcvIntegrityError err -> viewRcvIntegrityError from err ts meta - CIRcvDecryptionError err n -> viewRcvDecryptionError from err n ts meta + CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta + CIRcvDecryptionError err n -> viewRcvDecryptionError from err n ts tz meta CIRcvGroupEvent {} -> showRcvItemProhibited from _ -> showRcvItem from where @@ -395,8 +396,8 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} to = ttyToGroup g CIGroupRcv m -> case content of CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc - CIRcvIntegrityError err -> viewRcvIntegrityError from err ts meta - CIRcvDecryptionError err n -> viewRcvDecryptionError from err n ts meta + CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta + CIRcvDecryptionError err n -> viewRcvDecryptionError from err n ts tz meta CIRcvGroupInvitation {} -> showRcvItemProhibited from _ -> showRcvItem from where @@ -410,17 +411,17 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} Just t -> item <> styled (colored Red) (" [" <> t <> "]") withSndFile = withFile viewSentFileInvitation withRcvFile = withFile viewReceivedFileInvitation - withFile view dir l = maybe l (\f -> l <> view dir f ts meta) file + withFile view dir l = maybe l (\f -> l <> view dir f ts tz meta) file sndMsg = msg viewSentMessage rcvMsg = msg viewReceivedMessage msg view dir quote mc = case (msgContentText mc, file, quote) of ("", Just _, []) -> [] - ("", Just CIFile {fileName}, _) -> view dir quote (MCText $ T.pack fileName) ts meta - _ -> view dir quote mc ts meta - showSndItem to = showItem $ sentWithTime_ ts [to <> plainContent content] meta - showRcvItem from = showItem $ receivedWithTime_ ts from [] meta [plainContent content] False - showSndItemProhibited to = showItem $ sentWithTime_ ts [to <> plainContent content <> " " <> prohibited] meta - showRcvItemProhibited from = showItem $ receivedWithTime_ ts from [] meta [plainContent content <> " " <> prohibited] False + ("", Just CIFile {fileName}, _) -> view dir quote (MCText $ T.pack fileName) ts tz meta + _ -> view dir quote mc ts tz meta + showSndItem to = showItem $ sentWithTime_ ts tz [to <> plainContent content] meta + showRcvItem from = showItem $ receivedWithTime_ ts tz from [] meta [plainContent content] False + showSndItemProhibited to = showItem $ sentWithTime_ ts tz [to <> plainContent content <> " " <> prohibited] meta + showRcvItemProhibited from = showItem $ receivedWithTime_ ts tz from [] meta [plainContent content <> " " <> prohibited] False showItem ss = if doShow then ss else [] plainContent = plain . ciContentToText prohibited = styled (colored Red) ("[unexpected chat item created, please report to developers]" :: String) @@ -451,18 +452,18 @@ localTs tz ts = do formattedTime = formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" localTime formattedTime -viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> [StyledString] -viewItemUpdate chat ChatItem {chatDir, meta = meta@CIMeta {itemEdited, itemLive}, content, quotedItem} liveItems ts = case chat of +viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] +viewItemUpdate chat ChatItem {chatDir, meta = meta@CIMeta {itemEdited, itemLive}, content, quotedItem} liveItems ts tz = case chat of DirectChat c -> case chatDir of CIDirectRcv -> case content of CIRcvMsgContent mc | itemLive == Just True && not liveItems -> [] - | otherwise -> viewReceivedUpdatedMessage from quote mc ts meta + | otherwise -> viewReceivedUpdatedMessage from quote mc ts tz meta _ -> [] where from = if itemEdited then ttyFromContactEdited c else ttyFromContact c CIDirectSnd -> case content of - CISndMsgContent mc -> hideLive meta $ viewSentMessage to quote mc ts meta + CISndMsgContent mc -> hideLive meta $ viewSentMessage to quote mc ts tz meta _ -> [] where to = if itemEdited then ttyToContactEdited' c else ttyToContact' c @@ -472,12 +473,12 @@ viewItemUpdate chat ChatItem {chatDir, meta = meta@CIMeta {itemEdited, itemLive} CIGroupRcv m -> case content of CIRcvMsgContent mc | itemLive == Just True && not liveItems -> [] - | otherwise -> viewReceivedUpdatedMessage from quote mc ts meta + | otherwise -> viewReceivedUpdatedMessage from quote mc ts tz meta _ -> [] where from = if itemEdited then ttyFromGroupEdited g m else ttyFromGroup g m CIGroupSnd -> case content of - CISndMsgContent mc -> hideLive meta $ viewSentMessage to quote mc ts meta + CISndMsgContent mc -> hideLive meta $ viewSentMessage to quote mc ts tz meta _ -> [] where to = if itemEdited then ttyToGroupEdited g else ttyToGroup g @@ -494,18 +495,18 @@ viewItemNotChanged (AChatItem _ msgDir _ _) = case msgDir of SMDSnd -> ["message didn't change"] SMDRcv -> [] -viewItemDelete :: ChatInfo c -> ChatItem c d -> Maybe AChatItem -> Bool -> Bool -> CurrentTime -> Bool -> [StyledString] -viewItemDelete chat ci@ChatItem {chatDir, meta, content = deletedContent} toItem byUser timed ts testView +viewItemDelete :: ChatInfo c -> ChatItem c d -> Maybe AChatItem -> Bool -> Bool -> CurrentTime -> TimeZone -> Bool -> [StyledString] +viewItemDelete chat ci@ChatItem {chatDir, meta, content = deletedContent} toItem byUser timed ts tz testView | timed = [plain ("timed message deleted: " <> T.unpack (ciContentToText deletedContent)) | testView] | byUser = [plain $ "message " <> T.unpack (fromMaybe "deleted" deletedText_)] -- deletedText_ Nothing should be impossible here | otherwise = case chat of DirectChat c -> case (chatDir, deletedContent) of - (CIDirectRcv, CIRcvMsgContent mc) -> viewReceivedMessage (ttyFromContactDeleted c deletedText_) [] mc ts meta + (CIDirectRcv, CIRcvMsgContent mc) -> viewReceivedMessage (ttyFromContactDeleted c deletedText_) [] mc ts tz meta _ -> prohibited GroupChat g -> case ciMsgContent deletedContent of Just mc -> let m = chatItemMember g ci - in viewReceivedMessage (ttyFromGroupDeleted g m deletedText_) [] mc ts meta + in viewReceivedMessage (ttyFromGroupDeleted g m deletedText_) [] mc ts tz meta _ -> prohibited _ -> prohibited where @@ -534,7 +535,7 @@ viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md (_, CIGroupSnd) -> [sentText] where view from msg - | showReactions = viewReceivedReaction from msg reactionText ts $ utcToZonedTime tz sentAt + | showReactions = viewReceivedReaction from msg reactionText ts tz sentAt | otherwise = [] reactionText = plain $ (if added then "+ " else "- ") <> [emoji] emoji = case reaction of @@ -577,11 +578,11 @@ msgPreview = msgPlain . preview . msgContentText | T.length t <= 120 = t | otherwise = T.take 120 t <> "..." -viewRcvIntegrityError :: StyledString -> MsgErrorType -> CurrentTime -> CIMeta c 'MDRcv -> [StyledString] -viewRcvIntegrityError from msgErr ts meta = receivedWithTime_ ts from [] meta (viewMsgIntegrityError msgErr) False +viewRcvIntegrityError :: StyledString -> MsgErrorType -> CurrentTime -> TimeZone -> CIMeta c 'MDRcv -> [StyledString] +viewRcvIntegrityError from msgErr ts tz meta = receivedWithTime_ ts tz from [] meta (viewMsgIntegrityError msgErr) False -viewRcvDecryptionError :: StyledString -> MsgDecryptError -> Word32 -> CurrentTime -> CIMeta c 'MDRcv -> [StyledString] -viewRcvDecryptionError from err n ts meta = receivedWithTime_ ts from [] meta [ttyError $ msgDecryptErrorText err n] False +viewRcvDecryptionError :: StyledString -> MsgDecryptError -> Word32 -> CurrentTime -> TimeZone -> CIMeta c 'MDRcv -> [StyledString] +viewRcvDecryptionError from err n ts tz meta = receivedWithTime_ ts tz from [] meta [ttyError $ msgDecryptErrorText err n] False viewMsgIntegrityError :: MsgErrorType -> [StyledString] viewMsgIntegrityError err = [ttyError $ msgIntegrityError err] @@ -1079,22 +1080,22 @@ viewContactUpdated where fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName' -viewReceivedMessage :: StyledString -> [StyledString] -> MsgContent -> CurrentTime -> CIMeta c d -> [StyledString] +viewReceivedMessage :: StyledString -> [StyledString] -> MsgContent -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString] viewReceivedMessage = viewReceivedMessage_ False -viewReceivedUpdatedMessage :: StyledString -> [StyledString] -> MsgContent -> CurrentTime -> CIMeta c d -> [StyledString] +viewReceivedUpdatedMessage :: StyledString -> [StyledString] -> MsgContent -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString] viewReceivedUpdatedMessage = viewReceivedMessage_ True -viewReceivedMessage_ :: Bool -> StyledString -> [StyledString] -> MsgContent -> CurrentTime -> CIMeta c d -> [StyledString] -viewReceivedMessage_ updated from quote mc ts meta = receivedWithTime_ ts from quote meta (ttyMsgContent mc) updated +viewReceivedMessage_ :: Bool -> StyledString -> [StyledString] -> MsgContent -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString] +viewReceivedMessage_ updated from quote mc ts tz meta = receivedWithTime_ ts tz from quote meta (ttyMsgContent mc) updated -viewReceivedReaction :: StyledString -> [StyledString] -> StyledString -> CurrentTime -> ZonedTime -> [StyledString] -viewReceivedReaction from styledMsg reactionText ts reactionTs = - prependFirst (ttyMsgTime ts reactionTs <> " " <> from) (styledMsg <> [" " <> reactionText]) +viewReceivedReaction :: StyledString -> [StyledString] -> StyledString -> CurrentTime -> TimeZone -> UTCTime -> [StyledString] +viewReceivedReaction from styledMsg reactionText ts tz reactionTs = + prependFirst (ttyMsgTime ts tz reactionTs <> " " <> from) (styledMsg <> [" " <> reactionText]) -receivedWithTime_ :: CurrentTime -> StyledString -> [StyledString] -> CIMeta c d -> [StyledString] -> Bool -> [StyledString] -receivedWithTime_ ts from quote CIMeta {localItemTs, itemId, itemEdited, itemDeleted, itemLive} styledMsg updated = do - prependFirst (ttyMsgTime ts localItemTs <> " " <> from) (quote <> prependFirst (indent <> live) styledMsg) +receivedWithTime_ :: CurrentTime -> TimeZone -> StyledString -> [StyledString] -> CIMeta c d -> [StyledString] -> Bool -> [StyledString] +receivedWithTime_ ts tz from quote CIMeta {itemId, itemTs, itemEdited, itemDeleted, itemLive} styledMsg updated = do + prependFirst (ttyMsgTime ts tz itemTs <> " " <> from) (quote <> prependFirst (indent <> live) styledMsg) where indent = if null quote then "" else " " live @@ -1106,19 +1107,25 @@ receivedWithTime_ ts from quote CIMeta {localItemTs, itemId, itemEdited, itemDel Just False -> ttyFrom "[LIVE ended] " _ -> "" -ttyMsgTime :: CurrentTime -> ZonedTime -> StyledString -ttyMsgTime ts t = - let localTime = zonedTimeToLocalTime t - tz = zonedTimeZone t - fmt = - if (localDay localTime < localDay (zonedTimeToLocalTime $ utcToZonedTime tz ts)) - && (timeOfDayToTime (localTimeOfDay localTime) > (6 * 60 * 60 :: DiffTime)) - then "%m-%d" -- if message is from yesterday or before and 6 hours has passed since midnight - else "%H:%M" +ttyMsgTime :: CurrentTime -> TimeZone -> UTCTime -> StyledString +ttyMsgTime now tz time = + let fmt = if recent now tz time then "%H:%M" else "%m-%d" + localTime = utcToLocalTime tz time in styleTime $ formatTime defaultTimeLocale fmt localTime -viewSentMessage :: StyledString -> [StyledString] -> MsgContent -> CurrentTime -> CIMeta c d -> [StyledString] -viewSentMessage to quote mc ts meta@CIMeta {itemEdited, itemDeleted, itemLive} = sentWithTime_ ts (prependFirst to $ quote <> prependFirst (indent <> live) (ttyMsgContent mc)) meta +recent :: CurrentTime -> TimeZone -> UTCTime -> Bool +recent now tz time = do + let localNow = utcToLocalTime tz now + localNowDay = localDay localNow + localTime = utcToLocalTime tz time + localTimeDay = localDay localTime + previousDay18 = LocalTime (addDays (-1) localNowDay) (TimeOfDay 18 0 0) + currentDay12 = LocalTime localNowDay (TimeOfDay 12 0 0) + localNowDay == localTimeDay + || (localNow < currentDay12 && localTime >= previousDay18 && localTimeDay < localNowDay) + +viewSentMessage :: StyledString -> [StyledString] -> MsgContent -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString] +viewSentMessage to quote mc ts tz meta@CIMeta {itemEdited, itemDeleted, itemLive} = sentWithTime_ ts tz (prependFirst to $ quote <> prependFirst (indent <> live) (ttyMsgContent mc)) meta where indent = if null quote then "" else " " live @@ -1128,12 +1135,12 @@ viewSentMessage to quote mc ts meta@CIMeta {itemEdited, itemDeleted, itemLive} = Just False -> ttyTo "[LIVE] " _ -> "" -viewSentBroadcast :: MsgContent -> Int -> CurrentTime -> ZonedTime -> [StyledString] -viewSentBroadcast mc n ts t = prependFirst (highlight' "/feed" <> " (" <> sShow n <> ") " <> ttyMsgTime ts t <> " ") (ttyMsgContent mc) +viewSentBroadcast :: MsgContent -> Int -> CurrentTime -> TimeZone -> UTCTime -> [StyledString] +viewSentBroadcast mc n ts tz time = prependFirst (highlight' "/feed" <> " (" <> sShow n <> ") " <> ttyMsgTime ts tz time <> " ") (ttyMsgContent mc) -viewSentFileInvitation :: StyledString -> CIFile d -> CurrentTime -> CIMeta c d -> [StyledString] -viewSentFileInvitation to CIFile {fileId, filePath, fileStatus} ts = case filePath of - Just fPath -> sentWithTime_ ts $ ttySentFile fPath +viewSentFileInvitation :: StyledString -> CIFile d -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString] +viewSentFileInvitation to CIFile {fileId, filePath, fileStatus} ts tz = case filePath of + Just fPath -> sentWithTime_ ts tz $ ttySentFile fPath _ -> const [] where ttySentFile fPath = ["/f " <> to <> ttyFilePath fPath] <> cancelSending @@ -1141,9 +1148,9 @@ viewSentFileInvitation to CIFile {fileId, filePath, fileStatus} ts = case filePa CIFSSndTransfer _ _ -> [] _ -> ["use " <> highlight ("/fc " <> show fileId) <> " to cancel sending"] -sentWithTime_ :: CurrentTime -> [StyledString] -> CIMeta c d -> [StyledString] -sentWithTime_ ts styledMsg CIMeta {localItemTs} = - prependFirst (ttyMsgTime ts localItemTs <> " ") styledMsg +sentWithTime_ :: CurrentTime -> TimeZone -> [StyledString] -> CIMeta c d -> [StyledString] +sentWithTime_ ts tz styledMsg CIMeta {itemTs} = + prependFirst (ttyMsgTime ts tz itemTs <> " ") styledMsg ttyMsgContent :: MsgContent -> [StyledString] ttyMsgContent = msgPlain . msgContentText @@ -1179,8 +1186,8 @@ uploadingFile status _ = [status <> " uploading file"] -- shouldn't happen sndFile :: SndFileTransfer -> StyledString sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName -viewReceivedFileInvitation :: StyledString -> CIFile d -> CurrentTime -> CIMeta c d -> [StyledString] -viewReceivedFileInvitation from file ts meta = receivedWithTime_ ts from [] meta (receivedFileInvitation_ file) False +viewReceivedFileInvitation :: StyledString -> CIFile d -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString] +viewReceivedFileInvitation from file ts tz meta = receivedWithTime_ ts tz from [] meta (receivedFileInvitation_ file) False receivedFileInvitation_ :: CIFile d -> [StyledString] receivedFileInvitation_ CIFile {fileId, fileName, fileSize, fileStatus} = diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index b1751bafe9..63ad8ca592 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -521,8 +521,9 @@ testGetSetSMPServers = alice #$> ("/smp", id, "smp://1234-w==@smp1.example.im") alice #$> ("/smp smp://1234-w==:password@smp1.example.im", id, "ok") alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im") - alice #$> ("/smp smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok") - alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im") + alice #$> ("/smp smp://2345-w==@smp2.example.im smp://3456-w==@smp3.example.im:5224", id, "ok") + alice ##> "/smp" + alice <## "smp://2345-w==@smp2.example.im" alice <## "smp://3456-w==@smp3.example.im:5224" alice #$> ("/smp default", id, "ok") alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001") @@ -551,8 +552,9 @@ testGetSetXFTPServers = alice #$> ("/xftp", id, "xftp://1234-w==@xftp1.example.im") alice #$> ("/xftp xftp://1234-w==:password@xftp1.example.im", id, "ok") alice #$> ("/xftp", id, "xftp://1234-w==:password@xftp1.example.im") - alice #$> ("/xftp xftp://2345-w==@xftp2.example.im;xftp://3456-w==@xftp3.example.im:5224", id, "ok") - alice #$> ("/xftp", id, "xftp://2345-w==@xftp2.example.im") + alice #$> ("/xftp xftp://2345-w==@xftp2.example.im xftp://3456-w==@xftp3.example.im:5224", id, "ok") + alice ##> "/xftp" + alice <## "xftp://2345-w==@xftp2.example.im" alice <## "xftp://3456-w==@xftp3.example.im:5224" alice #$> ("/xftp default", id, "ok") alice #$> ("/xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002") @@ -1135,39 +1137,55 @@ testCreateUserDefaultServers :: HasCallStack => FilePath -> IO () testCreateUserDefaultServers = testChat2 aliceProfile bobProfile $ \alice _ -> do - alice #$> ("/smp smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok") - alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im") - alice <## "smp://3456-w==@smp3.example.im:5224" + alice #$> ("/smp smp://2345-w==@smp2.example.im smp://3456-w==@smp3.example.im:5224", id, "ok") + alice #$> ("/xftp xftp://2345-w==@xftp2.example.im xftp://3456-w==@xftp3.example.im:5224", id, "ok") + checkCustomServers alice alice ##> "/create user alisa" showActiveUser alice "alisa" alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001") + alice #$> ("/xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002") - -- with same_smp=off + -- with same_servers=off alice ##> "/user alice" showActiveUser alice "alice (Alice)" - alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im") - alice <## "smp://3456-w==@smp3.example.im:5224" + checkCustomServers alice - alice ##> "/create user same_smp=off alisa2" + alice ##> "/create user same_servers=off alisa2" showActiveUser alice "alisa2" alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001") + alice #$> ("/xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002") + where + checkCustomServers alice = do + alice ##> "/smp" + alice <## "smp://2345-w==@smp2.example.im" + alice <## "smp://3456-w==@smp3.example.im:5224" + alice ##> "/xftp" + alice <## "xftp://2345-w==@xftp2.example.im" + alice <## "xftp://3456-w==@xftp3.example.im:5224" testCreateUserSameServers :: HasCallStack => FilePath -> IO () testCreateUserSameServers = testChat2 aliceProfile bobProfile $ \alice _ -> do - alice #$> ("/smp smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok") - alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im") - alice <## "smp://3456-w==@smp3.example.im:5224" + alice #$> ("/smp smp://2345-w==@smp2.example.im smp://3456-w==@smp3.example.im:5224", id, "ok") + alice #$> ("/xftp xftp://2345-w==@xftp2.example.im xftp://3456-w==@xftp3.example.im:5224", id, "ok") + checkCustomServers alice - alice ##> "/create user same_smp=on alisa" + alice ##> "/create user same_servers=on alisa" showActiveUser alice "alisa" - alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im") + checkCustomServers alice + where + checkCustomServers alice = do + alice ##> "/smp" + alice <## "smp://2345-w==@smp2.example.im" alice <## "smp://3456-w==@smp3.example.im:5224" + alice ##> "/xftp" + alice <## "xftp://2345-w==@xftp2.example.im" + alice <## "xftp://3456-w==@xftp3.example.im:5224" testDeleteUser :: HasCallStack => FilePath -> IO () testDeleteUser = diff --git a/tests/Test.hs b/tests/Test.hs index 4ea3e9ef5a..9010aefa0f 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -8,6 +8,7 @@ import ProtocolTests import SchemaDump import Test.Hspec import UnliftIO.Temporary (withTempDirectory) +import ViewTests import WebRTCTests main :: IO () @@ -15,6 +16,7 @@ main = do setLogLevel LogError -- LogDebug withGlobalLogging logCfg . hspec $ do describe "SimpleX chat markdown" markdownTests + describe "SimpleX chat view" viewTests describe "SimpleX chat protocol" protocolTests describe "WebRTC encryption" webRTCTests describe "Schema dump" schemaDumpTest diff --git a/tests/ViewTests.hs b/tests/ViewTests.hs new file mode 100644 index 0000000000..7c7a2f0e03 --- /dev/null +++ b/tests/ViewTests.hs @@ -0,0 +1,86 @@ +{-# LANGUAGE BlockArguments #-} +{-# LANGUAGE OverloadedStrings #-} + +module ViewTests where + +import Data.Time +import Simplex.Chat.View +import Test.Hspec + +viewTests :: Spec +viewTests = do + testRecent + +testRecent :: Spec +testRecent = describe "recent" $ do + let tz = hoursToTimeZone 1 + now1159 = UTCTime (fromGregorian 2023 6 7) (secondsToDiffTime $ 10 * 3600 + 59 * 60) -- 11:59 in tz + now1200 = UTCTime (fromGregorian 2023 6 7) (secondsToDiffTime $ 11 * 3600) -- 12:00 in tz + today0000 = UTCTime (fromGregorian 2023 6 6) (secondsToDiffTime $ 23 * 3600) -- 00:00 in tz + today0600 = UTCTime (fromGregorian 2023 6 7) (secondsToDiffTime $ 5 * 3600) -- 06:00 in tz + today1200 = UTCTime (fromGregorian 2023 6 7) (secondsToDiffTime $ 11 * 3600) -- 12:00 in tz + today1800 = UTCTime (fromGregorian 2023 6 7) (secondsToDiffTime $ 17 * 3600) -- 18:00 in tz + today2359 = UTCTime (fromGregorian 2023 6 7) (secondsToDiffTime $ 22 * 3600 + 59 * 60) -- 23:59 in tz + yesterday0000 = UTCTime (fromGregorian 2023 6 5) (secondsToDiffTime $ 23 * 3600) -- 00:00 in tz + yesterday1759 = UTCTime (fromGregorian 2023 6 6) (secondsToDiffTime $ 16 * 3600 + 59 * 60) -- 17:59 in tz + yesterday1800 = UTCTime (fromGregorian 2023 6 6) (secondsToDiffTime $ 17 * 3600) -- 18:00 in tz + yesterday2359 = UTCTime (fromGregorian 2023 6 6) (secondsToDiffTime $ 22 * 3600 + 59 * 60) -- 23:59 in tz + sameDayLastMonth1900 = UTCTime (fromGregorian 2023 5 7) (secondsToDiffTime $ 18 * 3600) -- 19:00 in tz + prevDayLastMonth1900 = UTCTime (fromGregorian 2023 5 6) (secondsToDiffTime $ 18 * 3600) -- 19:00 in tz + sameDayLastYear1900 = UTCTime (fromGregorian 2022 6 7) (secondsToDiffTime $ 18 * 3600) -- 19:00 in tz + prevDayLastYear1900 = UTCTime (fromGregorian 2022 6 6) (secondsToDiffTime $ 18 * 3600) -- 19:00 in tz + tomorrow0000 = UTCTime (fromGregorian 2023 6 7) (secondsToDiffTime $ 23 * 3600) -- 00:00 in tz + tomorrow1759 = UTCTime (fromGregorian 2023 6 8) (secondsToDiffTime $ 16 * 3600 + 59 * 60) -- 17:59 in tz + tomorrow1800 = UTCTime (fromGregorian 2023 6 8) (secondsToDiffTime $ 17 * 3600) -- 18:00 in tz + tomorrow2359 = UTCTime (fromGregorian 2023 6 8) (secondsToDiffTime $ 22 * 3600 + 59 * 60) -- 23:59 in tz + sameDayNextMonth1900 = UTCTime (fromGregorian 2023 7 7) (secondsToDiffTime $ 18 * 3600) -- 19:00 in tz + prevDayNextMonth1900 = UTCTime (fromGregorian 2023 7 6) (secondsToDiffTime $ 18 * 3600) -- 19:00 in tz + sameDayNextYear1900 = UTCTime (fromGregorian 2024 6 7) (secondsToDiffTime $ 18 * 3600) -- 19:00 in tz + prevDayNextYear1900 = UTCTime (fromGregorian 2024 6 6) (secondsToDiffTime $ 18 * 3600) -- 19:00 in tz + test tz now1159 today0000 True + test tz now1159 today0600 True + test tz now1159 today1200 True + test tz now1159 today1800 True + test tz now1159 today2359 True + test tz now1159 yesterday0000 False + test tz now1159 yesterday1759 False + test tz now1159 yesterday1800 True + test tz now1159 yesterday2359 True + test tz now1159 sameDayLastMonth1900 False + test tz now1159 prevDayLastMonth1900 False + test tz now1159 sameDayLastYear1900 False + test tz now1159 prevDayLastYear1900 False + test tz now1159 tomorrow0000 False + test tz now1159 tomorrow1759 False + test tz now1159 tomorrow1800 False + test tz now1159 tomorrow2359 False + test tz now1159 sameDayNextMonth1900 False + test tz now1159 prevDayNextMonth1900 False + test tz now1159 sameDayNextYear1900 False + test tz now1159 prevDayNextYear1900 False + + test tz now1200 today0000 True + test tz now1200 today0600 True + test tz now1200 today1200 True + test tz now1200 today1800 True + test tz now1200 today2359 True + test tz now1200 yesterday0000 False + test tz now1200 yesterday1759 False + test tz now1200 yesterday1800 False + test tz now1200 yesterday2359 False + test tz now1200 sameDayLastMonth1900 False + test tz now1200 prevDayLastMonth1900 False + test tz now1200 sameDayLastYear1900 False + test tz now1200 prevDayLastYear1900 False + test tz now1200 tomorrow0000 False + test tz now1200 tomorrow1759 False + test tz now1200 tomorrow1800 False + test tz now1200 tomorrow2359 False + test tz now1200 sameDayNextMonth1900 False + test tz now1200 prevDayNextMonth1900 False + test tz now1200 sameDayNextYear1900 False + test tz now1200 prevDayNextYear1900 False + where + test tz now time expected = + it ("returns " <> show expected <> " for time " <> show time <> " when time zone is " <> show tz <> " and current time is " <> show now) $ + recent now tz time `shouldBe` expected diff --git a/website/src/_includes/layouts/rightscon.html b/website/src/_includes/layouts/rightscon.html new file mode 100644 index 0000000000..00baa4f97a --- /dev/null +++ b/website/src/_includes/layouts/rightscon.html @@ -0,0 +1,21 @@ + + + + + + + {{ title }} + + + + + + + + + + + +

Open RightsCon group link

+ + diff --git a/website/src/rightscon.html b/website/src/rightscon.html new file mode 100644 index 0000000000..70b18703a3 --- /dev/null +++ b/website/src/rightscon.html @@ -0,0 +1,7 @@ +--- +layout: layouts/rightscon.html +title: "SimpleX Chat - RightsCon group" +description: "Join the group of attendees of RightsCon 2023" +groupLink: "https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FyHKMxr06RLUiKent0IREl1rwUtsc1MKs%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAwxplfiUKydkqy7Rbl-YQCWUSnrV_ADSd5fWvH17BvEs%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22B3dC9QiKk4AEpGWaLUuPxw%3D%3D%22%7D" +templateEngineOverride: njk +---