From 5eca9ec8eed1eb707f7505cca385d360b4208e7e Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:45:43 +0000 Subject: [PATCH] ui: show XFTP servers used for a file in message info Surface the XFTP servers that hosted a file's chunks in the message info screen (Android, desktop, iOS), so a user can see which servers they are downloading from (or, for sent files, uploading to) whenever they want to know. APIGetChatItemInfo now returns fileXftpServers, derived from the stored file description (private snd descr for sent items, rcv descr for received items); extraction is best-effort and never fails the call. --- .../Shared/Views/Chat/ChatItemInfoView.swift | 6 + apps/ios/SimpleXChat/ChatTypes.swift | 1 + .../chat/simplex/common/model/ChatModel.kt | 3 +- .../common/views/chat/ChatItemInfoView.kt | 14 ++ .../commonMain/resources/MR/base/strings.xml | 2 + ...26-06-17-message-info-file-xftp-servers.md | 140 ++++++++++++++++++ src/Simplex/Chat/Library/Commands.hs | 3 +- src/Simplex/Chat/Library/Internal.hs | 29 +++- src/Simplex/Chat/Messages.hs | 5 +- src/Simplex/Chat/Store/Files.hs | 10 ++ 10 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 plans/2026-06-17-message-info-file-xftp-servers.md diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 3858d15252..8224bf3bb9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -195,6 +195,9 @@ struct ChatItemInfoView: View { } } } + if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty { + infoRow("File servers", servers.map(serverHostname).joined(separator: "\n")) + } } } @@ -509,6 +512,9 @@ struct ChatItemInfoView: View { shareText += [String.localizedStringWithFormat(NSLocalizedString("File status: %@", comment: "copied message info"), file.fileStatus.id)] } } + if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty { + shareText += [String.localizedStringWithFormat(NSLocalizedString("File servers: %@", comment: "copied message info"), servers.map(serverHostname).joined(separator: ", "))] + } if let qi = ci.quotedItem { shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")] let t = qi.text diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 3302850b64..ff7cee67f6 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -5774,6 +5774,7 @@ public struct ChatItemInfo: Decodable, Hashable { public var itemVersions: [ChatItemVersion] public var memberDeliveryStatuses: [MemberDeliveryStatus]? public var forwardedFromChatItem: AChatItem? + public var fileXftpServers: [String]? } public struct ChatItemVersion: Decodable, Hashable { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index f3024d7ca1..4abaec484c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -5207,7 +5207,8 @@ data class ChatTag( class ChatItemInfo( val itemVersions: List, val memberDeliveryStatuses: List?, - val forwardedFromChatItem: AChatItem? + val forwardedFromChatItem: AChatItem?, + val fileXftpServers: List = emptyList() ) @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 9c36f4896b..5065ee4732 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -30,6 +30,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.group.MemberProfileImage import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.usersettings.networkAndServers.serverHostname import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlinx.serialization.encodeToString @@ -280,6 +281,16 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools } } } + if (ci.file != null && ciInfo.fileXftpServers.isNotEmpty()) { + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionView(stringResource(MR.strings.info_row_file_servers)) { + ciInfo.fileXftpServers.forEach { server -> + SectionItemView { + Text(serverHostname(server), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth()) + } + } + } + } } @Composable @@ -565,6 +576,9 @@ fun itemInfoShareText(chatModel: ChatModel, ci: ChatItem, chatItemInfo: ChatItem shareText.add(String.format(generalGetString(MR.strings.share_text_file_status), jsonShort.encodeToString(ci.file.fileStatus))) } } + if (ci.file != null && chatItemInfo.fileXftpServers.isNotEmpty()) { + shareText.add(String.format(generalGetString(MR.strings.share_text_file_servers), chatItemInfo.fileXftpServers.joinToString(", ") { serverHostname(it) })) + } val qi = ci.quotedItem if (qi != null) { shareText.add("") diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 5bca9402a8..17f42ee875 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1961,6 +1961,7 @@ Record updated at Message status File status + File servers Sent at Created at Received at @@ -1971,6 +1972,7 @@ Record updated at: %s Message status: %s File status: %s + File servers: %s Sent at: %s Created at: %s Received at: %s diff --git a/plans/2026-06-17-message-info-file-xftp-servers.md b/plans/2026-06-17-message-info-file-xftp-servers.md new file mode 100644 index 0000000000..f7803984cf --- /dev/null +++ b/plans/2026-06-17-message-info-file-xftp-servers.md @@ -0,0 +1,140 @@ +# Plan: Show XFTP servers used for a file in Message Info + +## Goal +In the message-info screen, when the message contains a file, show the list of XFTP servers that hosted the file's chunks ("servers used to upload the file"). + +## Why this is needed +For transparency: a recipient downloads a file's chunks from XFTP relays without any visible indication of which servers those are. Surfacing them in message info lets a user see exactly what servers they are downloading from (and, for sent files, uploading to) whenever they want to know — useful for trust, debugging, and deciding whether to download. + +## Decisions (confirmed) +- **Direction:** Both sent and received files. +- **Visibility:** Always shown when the item has a file (not gated behind Developer tools). +- **Platform:** Android, desktop, and iOS. + +## Why this needs a core change (not Kotlin-only) +The server list is **already known to the app** — the file description (which lists each chunk's XFTP server replicas) is what the agent uses to upload/download. But that data lives in the **core (Haskell) layer**: it's stored in the `files` table (`private_snd_file_descr` for sent, the rcv file descr row for received) and parsed inside the core. It is **never surfaced across the core→client API boundary**: the client-facing `CIFile` and `ChatItemInfo` types carry no server info. So this is a **plumbing task** (expose existing data through the API response), not an algorithmic one. The core already has the extraction helper. + +## Existing building blocks (no new logic needed) +- `Internal.hs:764-766` — `fileServers` extracts the unique `[XFTPServer]` from a parsed description's chunk replicas. Currently a local `where` helper; will be lifted to a reusable top-level function. +- `Internal.hs:747` — `parseFileDescription :: Text -> CM (ValidFileDescription 'FRecipient)`. +- `Store/Files.hs:42` — `getRcvFileDescrByRcvFileId` returns `RcvFileDescr {fileDescrText, fileDescrComplete}` (received files). +- `Store/Files.hs:197` — `setSndFTPrivateSndDescr` stores `private_snd_file_descr` (sent files). A matching getter must be added (none exists today). +- `XFTPServer` already JSON-encodes to a string on the wire (see `XFTPServerSummary.xftpServer :: XFTPServer` → Kotlin `xftpServer: String`), so the Kotlin field can be `List`. + +--- + +## Implementation + +### 1. Core — extraction helper (`src/Simplex/Chat/Library/Internal.hs`) +- Lift `fileServers` out of `receiveViaCompleteFD`'s `where` block to a top-level function, generalized to work on any party's description chunks: + ```haskell + fileDescrServers :: FD.FileDescription p -> [XFTPServer] + fileDescrServers FD.FileDescription {chunks} = + S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks + ``` + (Keep `receiveViaCompleteFD` working by calling the lifted helper.) +- Add a convenience that resolves a file's servers by direction, returning `[]` when no XFTP description exists (inline/legacy files): + ```haskell + getChatItemFileServers :: User -> ChatItem c d -> CM [XFTPServer] + ``` + - For **received** (`SMDRcv`) with an XFTP file: `getRcvFileDescrByRcvFileId` → `parseFileDescription` → `fileDescrServers`. + - For **sent** (`SMDSnd`) with an XFTP file: read `private_snd_file_descr` (new getter) → parse the sender description → `fileDescrServers`. + - Guard on `fileProtocol == FPXFTP`; wrap parse in tolerant error handling so a missing/partial descr yields `[]` (no crash, no section). + +### 2. Core — store getter (`src/Simplex/Chat/Store/Files.hs`) +- Add `getSndFTPrivateSndDescr :: DB.Connection -> User -> FileTransferId -> IO (Maybe Text)` reading `private_snd_file_descr` from `files` (mirror of `setSndFTPrivateSndDescr`; column already selected at `Files.hs:799`). Export it. + +### 3. Core — extend `ChatItemInfo` (`src/Simplex/Chat/Messages.hs`) +- Add field: + ```haskell + data ChatItemInfo = ChatItemInfo + { itemVersions :: [ChatItemVersion], + memberDeliveryStatuses :: Maybe (NonEmpty MemberDeliveryStatus), + forwardedFromChatItem :: Maybe AChatItem, + fileXftpServers :: [XFTPServer] -- NEW (empty when no file / not XFTP) + } + ``` +- `$(JQ.deriveJSON defaultJSON ''ChatItemInfo)` at `Messages.hs:1531` regenerates the instance automatically. Import `XFTPServer` if not already in scope. +- Note: using `[XFTPServer]` (not `Maybe`) keeps the JSON additive and the Kotlin side simple; empty list = nothing to show. + +### 4. Core — populate in handler (`src/Simplex/Chat/Library/Commands.hs:619`) +- In `APIGetChatItemInfo`, after computing `forwardedFromChatItem`: + ```haskell + fileXftpServers <- getChatItemFileServers user ci + pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses, forwardedFromChatItem, fileXftpServers} + ``` + +### 5. Kotlin model (`apps/multiplatform/.../model/ChatModel.kt:5207`) +```kotlin +class ChatItemInfo( + val itemVersions: List, + val memberDeliveryStatuses: List?, + val forwardedFromChatItem: AChatItem?, + val fileXftpServers: List = emptyList() // NEW; server hosts as strings +) +``` +(`XFTPServer` serializes to a string, matching the existing `XFTPServerSummary.xftpServer: String`.) + +### 6. Desktop/shared UI (`apps/multiplatform/.../views/chat/ChatItemInfoView.kt`) +- In `Details()` (`:250-283`), after the existing file-status block, add (always-visible, when present): + ```kotlin + if (ci.file != null && ciInfo.fileXftpServers.isNotEmpty()) { + // section header + one InfoRow/host per server + } + ``` + - Render as its own `SectionView` with a title (e.g. "File servers") and one row per server host, styled like the existing info rows. For long host strings, single-line with ellipsis (consistent with the long-name handling already on this branch). +- Add the servers to the shareable text in `itemInfoShareText` (`:534-580`), near the existing file-status share line (`:564-565`), so "Share" / copy includes them. + +### 7. Translations (additive only — never rename/remove keys) +- Add one new key to `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml`, e.g.: + ```xml + File servers + ``` + Base locale only; Weblate fans out to other locales. Do not touch existing keys. + +### 8. iOS model (`apps/ios/SimpleXChat/ChatTypes.swift:5773`) +Same core change (shared `libsimplex`), so iOS only needs the Swift model + view. Add the optional field (optional so an absent key never breaks decoding): +```swift +public struct ChatItemInfo: Decodable, Hashable { + public var itemVersions: [ChatItemVersion] + public var memberDeliveryStatuses: [MemberDeliveryStatus]? + public var forwardedFromChatItem: AChatItem? + public var fileXftpServers: [String]? // NEW +} +``` + +### 9. iOS UI (`apps/ios/Shared/Views/Chat/ChatItemInfoView.swift`) +- In `details()`, after the `developerTools` block (always-visible when present): + ```swift + if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty { + infoRow("File servers", servers.map(serverHostname).joined(separator: "\n")) + } + ``` + `serverHostname(_:)` is public in `SimpleXChat` (`ErrorAlert.swift:142`). +- Add the same to `itemInfoShareText()` (joined with ", "). Strings are inline `NSLocalizedString` (no separate resource file to edit). + +--- + +## Edge cases / behavior +- **Inline / legacy files** (no XFTP description): `fileXftpServers == []` → section hidden. No crash. +- **Sent file before upload completes** (`private_snd_file_descr` not yet set): `[]` → hidden until available. +- **Received file not yet accepted/downloaded:** the rcv description exists as soon as the offer is received (it's what enables download), so servers are available even before download. ✓ +- **Multiple chunks across different servers:** deduped via `S.fromList`; all distinct servers listed. +- **Large files (any size up to the limits):** servers are accurate regardless of size. Chat-message files **never** use XFTP redirect — the description is split across multiple `XMsgFileDescr` messages (`splitFileDescr`) and reassembled in full by the recipient (`appendRcvFD`), so a received chat file always carries the real data-chunk servers. (`maxFileSize` = 1 GB soft, `maxFileSizeHard` = 5 GB; XFTP chunk sizes 64 KB / 256 KB / 1 MB / 4 MB.) +- **Redirect descriptions:** XFTP redirect (a small `redirect = Just` description whose chunks point to the relay hosting the real description) is used **only** for *standalone file links* (`SFDONE` `Nothing` branch → `xftpSndFileRedirect`), which have no chat item and never appear in message info. The chat-message branch never redirects, so no redirect description can reach `getChatItemFileServers` — no guard is needed. + +## Build / verification +- **Heaviest step:** rebuild the native core lib (`libsimplex`) consumed by the multiplatform app, since core types changed. Without the rebuild, the new JSON field is simply ignored by the old lib (the Kotlin default `emptyList()` keeps it backward-compatible — section just won't appear). +- Tests: a core unit/integration check that `APIGetChatItemInfo` returns a non-empty `fileXftpServers` for an XFTP file (sent and received), empty for a text message and for an inline file. +- Manual: send a file → open message info → confirm the server list; repeat on the receiving device for the received item. + +## Touch list +- `src/Simplex/Chat/Library/Internal.hs` — lift `fileDescrServers`, add `getChatItemFileServers`. +- `src/Simplex/Chat/Store/Files.hs` — add `getSndFTPrivateSndDescr` (+ export). +- `src/Simplex/Chat/Messages.hs` — add `fileXftpServers` to `ChatItemInfo`. +- `src/Simplex/Chat/Library/Commands.hs` — populate it in `APIGetChatItemInfo`. +- `apps/multiplatform/.../model/ChatModel.kt` — add Kotlin field. +- `apps/multiplatform/.../views/chat/ChatItemInfoView.kt` — UI section + share text. +- `apps/multiplatform/.../resources/MR/base/strings.xml` — new label key. +- `apps/ios/SimpleXChat/ChatTypes.swift` — add `fileXftpServers` to the Swift `ChatItemInfo`. +- `apps/ios/Shared/Views/Chat/ChatItemInfoView.swift` — UI row in `details()` + `itemInfoShareText()`. diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b72eae7c72..7b612ec055 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -624,7 +624,8 @@ processChatCommand cxt nm = \case (SCTGroup, SMDSnd) -> L.nonEmpty <$> withFastStore' (`getGroupSndStatuses` itemId) _ -> pure Nothing forwardedFromChatItem <- getForwardedFromItem user ci - pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses, forwardedFromChatItem} + fileXftpServers <- getChatItemFileServers user dir ci + pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses, forwardedFromChatItem, fileXftpServers} where getForwardedFromItem :: User -> ChatItem c d -> CM (Maybe AChatItem) getForwardedFromItem user ChatItem {meta = CIMeta {itemForwarded}} = case itemForwarded of diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 052c09b7cd..d33a76d863 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -748,7 +748,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} if userApprovedRelays then receive' rd True else do - let srvs = fileServers rd + let srvs = fileDescrServers rd unknownSrvs <- getUnknownSrvs srvs let approved = null unknownSrvs ifM @@ -761,9 +761,6 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) rd cfArgs approved startReceivingFile user fileId withStore' $ \db -> updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId) - fileServers :: ValidFileDescription 'FRecipient -> [XFTPServer] - fileServers (FD.ValidFileDescription FD.FileDescription {chunks}) = - S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks getUnknownSrvs :: [XFTPServer] -> CM [XFTPServer] getUnknownSrvs srvs = do knownSrvs <- L.map protoServer' <$> getKnownAgentServers SPXFTP user @@ -1591,6 +1588,30 @@ parseFileDescription :: FilePartyI p => Text -> CM (ValidFileDescription p) parseFileDescription = liftEither . first (ChatError . CEInvalidFileDescription) . (strDecode . encodeUtf8) +-- | Unique XFTP servers hosting the file's chunks, parsed from a stored file description. +fileDescrServers :: ValidFileDescription p -> [XFTPServer] +fileDescrServers (FD.ValidFileDescription FD.FileDescription {chunks}) = + S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks + +-- | XFTP servers the file's data chunks were uploaded to (sender's servers for sent items, +-- the same servers via the recipient description for received items). +-- Returns [] for non-XFTP/inline files or when no description is available; never fails the caller. +getChatItemFileServers :: User -> SMsgDirection d -> ChatItem c d -> CM [XFTPServer] +getChatItemFileServers user dir ci = case ci of + ChatItem {file = Just CIFile {fileId, fileProtocol = FPXFTP}} -> + itemFileServers fileId `catchAllErrors` \_ -> pure [] + _ -> pure [] + where + itemFileServers fileId = case dir of + SMDSnd -> do + sfd_ <- withStore' $ \db -> getSndFTPrivateSndDescr db user fileId + case sfd_ of + Just sfdText -> fileDescrServers <$> (parseFileDescription sfdText :: CM (ValidFileDescription 'FSender)) + Nothing -> pure [] + SMDRcv -> do + RcvFileDescr {fileDescrText} <- withStore $ \db -> getRcvFileDescrByRcvFileId db fileId + fileDescrServers <$> (parseFileDescription fileDescrText :: CM (ValidFileDescription 'FRecipient)) + sendDirectFileInline :: User -> Contact -> FileTransferMeta -> SharedMsgId -> CM () sendDirectFileInline user ct ft sharedMsgId = do msgDeliveryId <- sendFileInline_ ft sharedMsgId $ sendDirectContactMessage user ct diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 5800ab5bdd..167c253f84 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -55,7 +55,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, sumTypeJSON) -import Simplex.Messaging.Protocol (BlockingInfo, MsgBody) +import Simplex.Messaging.Protocol (BlockingInfo, MsgBody, XFTPServer) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection @@ -1336,7 +1336,8 @@ instance TextEncoding CIForwardedFromTag where data ChatItemInfo = ChatItemInfo { itemVersions :: [ChatItemVersion], memberDeliveryStatuses :: Maybe (NonEmpty MemberDeliveryStatus), - forwardedFromChatItem :: Maybe AChatItem + forwardedFromChatItem :: Maybe AChatItem, + fileXftpServers :: [XFTPServer] } deriving (Show) diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 6cb8e39bbc..b456dae277 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -20,6 +20,7 @@ module Simplex.Chat.Store.Files createSndFileTransferXFTP, createSndFTDescrXFTP, setSndFTPrivateSndDescr, + getSndFTPrivateSndDescr, updateSndFTDescrXFTP, createExtraSndFTDescrs, updateSndFTDeliveryXFTP, @@ -202,6 +203,15 @@ setSndFTPrivateSndDescr db User {userId} fileId sfdText = do "UPDATE files SET private_snd_file_descr = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (sfdText, currentTs, userId, fileId) +getSndFTPrivateSndDescr :: DB.Connection -> User -> FileTransferId -> IO (Maybe Text) +getSndFTPrivateSndDescr db User {userId} fileId = + fmap (maybe Nothing fromOnly) $ + maybeFirstRow id $ + DB.query + db + "SELECT private_snd_file_descr FROM files WHERE user_id = ? AND file_id = ?" + (userId, fileId) + updateSndFTDescrXFTP :: DB.Connection -> User -> SndFileTransfer -> Text -> IO () updateSndFTDescrXFTP db user@User {userId} sft@SndFileTransfer {fileId, fileDescrId} rfdText = do currentTs <- getCurrentTime