diff --git a/FchatiApp/Sources/FchatiApp/Features/Chat/ChatView.swift b/FchatiApp/Sources/FchatiApp/Features/Chat/ChatView.swift index c699238..4f7a609 100644 --- a/FchatiApp/Sources/FchatiApp/Features/Chat/ChatView.swift +++ b/FchatiApp/Sources/FchatiApp/Features/Chat/ChatView.swift @@ -23,7 +23,7 @@ struct ChatView: View { ForEach(session.messages) { message in MessageBubble( message: message, - isSent: message.fromName == "You" + isSent: message.from == KeychainStore.installationID ) .id(message.id) } diff --git a/FchatiApp/Sources/FchatiApp/Networking/WSClient.swift b/FchatiApp/Sources/FchatiApp/Networking/WSClient.swift index f8adede..6e04056 100644 --- a/FchatiApp/Sources/FchatiApp/Networking/WSClient.swift +++ b/FchatiApp/Sources/FchatiApp/Networking/WSClient.swift @@ -313,6 +313,7 @@ struct WSMessage: Codable, Equatable { var token: String? var peerID: String? var reason: String? + var sentAt: String? // ISO-8601 timestamp set by the sender; preserved through the relay var messageIDs: [String]? // used by read receipts: { type: "read", messageIDs: [...] } var attachment: WSAttachment? // file metadata attached to a chat.message @@ -326,6 +327,7 @@ struct WSMessage: Codable, Equatable { token: String? = nil, peerID: String? = nil, reason: String? = nil, + sentAt: String? = nil, messageIDs: [String]? = nil, attachment: WSAttachment? = nil ) { @@ -338,6 +340,7 @@ struct WSMessage: Codable, Equatable { self.token = token self.peerID = peerID self.reason = reason + self.sentAt = sentAt self.messageIDs = messageIDs self.attachment = attachment } diff --git a/FchatiApp/Sources/FchatiApp/Session/AppSession.swift b/FchatiApp/Sources/FchatiApp/Session/AppSession.swift index 95f6c2c..ce9a613 100644 --- a/FchatiApp/Sources/FchatiApp/Session/AppSession.swift +++ b/FchatiApp/Sources/FchatiApp/Session/AppSession.swift @@ -159,18 +159,32 @@ final class AppSession: ObservableObject { } } + private static let iso8601: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + private func handleIncomingMessage(_ message: WSMessage) async { switch message.type { case "chat.message": let attachment = message.attachment.map { AttachmentInfo(fileID: $0.fileID, name: $0.name, size: $0.size) } + // Use the timestamp the sender embedded; fall back to now only if missing. + let sentAt: Date + if let raw = message.sentAt, + let parsed = AppSession.iso8601.date(from: raw) { + sentAt = parsed + } else { + sentAt = Date() + } let chatMessage = ChatMessage( id: message.id ?? UUID().uuidString, from: message.from ?? "unknown", fromName: message.fromName ?? "Unknown", body: message.body ?? "", - sentAt: Date(), + sentAt: sentAt, attachment: attachment, isRead: false ) @@ -196,6 +210,7 @@ final class AppSession: ObservableObject { type: "chat.message", id: message.id, body: message.body, + sentAt: AppSession.iso8601.string(from: message.sentAt), attachment: attachment ) diff --git a/FchatiApp/Tests/FchatiAppTests/WSMessageTests.swift b/FchatiApp/Tests/FchatiAppTests/WSMessageTests.swift index dba0381..231f5ac 100644 --- a/FchatiApp/Tests/FchatiAppTests/WSMessageTests.swift +++ b/FchatiApp/Tests/FchatiAppTests/WSMessageTests.swift @@ -8,7 +8,8 @@ final class WSMessageTests: XCTestCase { id: "message-id", body: "Hello", from: "peer-id", - fromName: "Alex" + fromName: "Alex", + sentAt: "2026-07-26T18:12:00.123Z" ) let decodedMessage = try JSONDecoder().decode( diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md index d555c65..c7d0495 100644 --- a/IMPLEMENTATION_STATUS.md +++ b/IMPLEMENTATION_STATUS.md @@ -189,6 +189,38 @@ - The manual cleanup endpoint returns unavailable until `ADMIN_TOKEN` is configured. - Requests must use `Authorization: Bearer `. +### Deployment configuration hardening + +- `relay-server/.env.example` now contains a non-secret placeholder for `ADMIN_TOKEN` instead of a committed credential. Before deployment, generate and set a unique value in the untracked `relay-server/.env` file. +- Verified the Docker Compose configuration with the example environment and confirmed a local relay instance responds successfully from `GET /health`. +- Extended WebSocket serialization coverage to preserve the sender timestamp used for offline message delivery. + +## Bug Fixes — Post-Review ✅ + +**Applied by:** Claude + +### Fix 1 — `isSent` detection in ChatView + +**File:** `FchatiApp/Sources/FchatiApp/Features/Chat/ChatView.swift` + +**Problem:** `MessageBubble` was deciding which side to render on using `message.fromName == "You"`. If the user ever set their display name to "You" this would misidentify received messages as sent. + +**Fix:** Compare `message.from == KeychainStore.installationID` — the installation UUID is unique per device and never changes. + +--- + +### Fix 2 — Message timestamps for queued/offline messages + +**Files:** +- `FchatiApp/Sources/FchatiApp/Networking/WSClient.swift` — added `sentAt: String?` field to `WSMessage` +- `FchatiApp/Sources/FchatiApp/Session/AppSession.swift` — sender embeds ISO-8601 timestamp; receiver parses it + +**Problem:** When a message was delivered from the offline queue, `sentAt` was set to `Date()` (arrival time). Messages sent at 11pm but delivered after a restart showed a wrong timestamp. + +**Fix:** The sender now includes `sentAt` as an ISO-8601 string in the WebSocket payload. The relay passes it through unchanged (it's a dumb pipe). The receiver parses it; falls back to `Date()` only if the field is missing (old clients). + +--- + ## Pending -See `TASKS.md` for all remaining tasks. +Nothing remaining in the codebase. Next step: deploy relay server to production. diff --git a/relay-server/.env.example b/relay-server/.env.example index 16c034e..cb34d60 100644 --- a/relay-server/.env.example +++ b/relay-server/.env.example @@ -2,4 +2,5 @@ PORT=3000 MAX_FILE_SIZE_MB=25 UPLOADS_DIR=/data/files FILE_TTL_DAYS=30 -ADMIN_TOKEN=8545851a901067a271e8e941297acb4c3d8d68c83eddb2809a8184071e8bc46f +# Required for POST /admin/cleanup. Generate a unique value, for example: openssl rand -hex 32 +ADMIN_TOKEN=replace-with-a-unique-random-secret