# Messenger Phase 2 — Offline Messages Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Messages sent to an offline friend are stored and delivered on the recipient's next login, tagged "sent while offline". **Architecture:** No new packets. The emulator stores send-to-offline in the existing `messenger_offline` table; on login it replays them through the existing `FriendChatMessageComposer` (extended with an optional `extraData` marker) so the client's existing `NewConsoleMessageEvent` path renders them. The client adds an `offlineDelivered` flag (derived from `extraData === "offline"`) and a subtle marker in the thread. **Tech Stack:** Arcturus (Java 21/Maven/HikariCP), Nitro-V3 (React 19, Vite, Vitest). No renderer change. --- ## Branches All repos are already on `feat/messenger-groups-receipts` (continuing the messenger initiative). Continue committing there. Client commits use the house-rule author override `git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com`. No Co-Authored-By / AI attribution anywhere. ## Pre-flight: how messages flow today (read once) - `FriendPrivateMessageEvent` (incoming) → `buddy.onMessageReceived(sender, message)` which delivers via `FriendChatMessageComposer` ONLY if the recipient is online (else the message is silently dropped — that's the gap we close). - On login the client sends `MessengerInitComposer` → emulator `RequestInitFriendsEvent` sends `MessengerInitComposer` + the friend list. - `FriendChatMessageComposer.composeInternal()` appends: `toId` (the sender id shown to the recipient), message text, `secondsSinceSent` (= now − message.timestamp). For group chat (`toId < 0`) it appends an extra `name/look/id` string. For 1:1 it appends nothing after `secondsSinceSent`. - Client: `useMessenger` subscribes to `NewConsoleMessageEvent` and calls `sendMessage(thread, parser.senderId, parser.messageText, parser.secondsSinceSent, parser.extraData)`, which stores `extraData` on the created `MessengerThreadChat`. - `messenger_offline` columns (verified): `id` (PK auto), `user_id` (recipient), `user_from_id` (sender), `message` varchar(500), `sended_on` int (unix). ## File map **Emulator (`Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/`):** - Modify `habbohotel/messenger/Message.java` — add a `(fromId, toId, message, timestamp)` constructor. - Modify `messages/outgoing/friends/FriendChatMessageComposer.java` — optional `extraData` appended for 1:1. - Modify `habbohotel/messenger/Messenger.java` — `addOfflineMessage(...)` + `deliverOfflineMessages(...)` + cap constant. - Modify `messages/incoming/friends/FriendPrivateMessageEvent.java` — branch online vs offline. - Modify `messages/incoming/friends/RequestInitFriendsEvent.java` — deliver offline on login. **Client (`Nitro-V3/src/`):** - Modify `api/friends/MessengerThreadChat.ts` — `offlineDelivered` getter. - Create `api/friends/MessengerThreadChat.test.ts` — getter test. - Modify `components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx` — render marker. - Modify `public/configuration/UITexts.example` — add `messenger.offline.delivered` text key. - Modify a messenger CSS file — `.messenger-offline-tag` style. --- ## Task 1: Emulator — `Message` timestamp constructor + composer `extraData` **Files:** - Modify: `Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Message.java` - Modify: `Emulator/src/main/java/com/eu/habbo/messages/outgoing/friends/FriendChatMessageComposer.java` > Emulator has no unit tests; verification is `mvn compile`. - [ ] **Step 1: Add a timestamp constructor to `Message`** `Message` has `private final int timestamp;` set to `Emulator.getIntUnixTimestamp()` in the existing constructor. Add a second constructor that accepts an explicit timestamp (needed so a replayed offline message reports its original age). Add it right after the existing constructor (after the block ending at the line `this.timestamp = Emulator.getIntUnixTimestamp(); }`): ```java public Message(int fromId, int toId, String message, int timestamp) { this.fromId = fromId; this.toId = toId; this.message = message; this.timestamp = timestamp; } ``` - [ ] **Step 2: Add optional `extraData` to `FriendChatMessageComposer`** Add an `extraData` field + a 4-arg constructor, and append it for the 1:1 path. Replace the field/constructor region and the `composeInternal` tail. Add the field next to the existing fields: ```java private String extraData = null; ``` Add this constructor after the existing `FriendChatMessageComposer(Message message, int toId, int fromId)`: ```java public FriendChatMessageComposer(Message message, int toId, int fromId, String extraData) { this.message = message; this.toId = toId; this.fromId = fromId; this.extraData = extraData; } ``` In `composeInternal()`, the existing `if (this.toId < 0) { ...group chat... }` block stays. Immediately AFTER that `if` block (before `return this.response;`), add an `else if` so 1:1 messages with a marker append it (online 1:1 messages pass `extraData == null` and append nothing — wire unchanged): ```java else if (this.extraData != null) { this.response.appendString(this.extraData); } ``` The result is: ```java if (this.toId < 0) // group chat { // ... existing group block unchanged ... this.response.appendString(name + "/" + look + "/" + this.fromId); } else if (this.extraData != null) { this.response.appendString(this.extraData); } return this.response; ``` - [ ] **Step 3: Compile** Run: `cd Arcturus-Morningstar-Extended/Emulator && mvn -q compile` Expected: BUILD SUCCESS. - [ ] **Step 4: Commit (only these 2 files)** ```bash cd Arcturus-Morningstar-Extended git add Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Message.java Emulator/src/main/java/com/eu/habbo/messages/outgoing/friends/FriendChatMessageComposer.java git commit -m "feat(messenger): Message timestamp ctor + optional extraData on chat composer" ``` Verify with `git show --stat HEAD` that ONLY these 2 files are committed (the working tree also has an unrelated `soundboard/SoundboardPlayEvent.java` modification and untracked jars — never stage those). --- ## Task 2: Emulator — offline message store + deliver helpers in `Messenger` **Files:** - Modify: `Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Messenger.java` `Messenger` already has static DB methods (e.g. `unfriend`) using `Emulator.getDatabase().getDataSource().getConnection()` with try-with-resources, and a `LOGGER`. `Message` and `MessengerCategory` are in the same package (no import needed). You WILL need imports for `java.sql.ResultSet`, `java.util.ArrayList`, `java.util.List`, `com.eu.habbo.habbohotel.gameclients.GameClient`, and `com.eu.habbo.messages.outgoing.friends.FriendChatMessageComposer` — check the existing import block and add whichever are missing. - [ ] **Step 1: Add the cap constant** Near the other `Messenger` constants (e.g. by `MAXIMUM_FRIENDS`), add: ```java public static final int MAXIMUM_OFFLINE_MESSAGES = 200; ``` - [ ] **Step 2: Add `addOfflineMessage`** Stores one offline message for `toId`, evicting the oldest if the per-user inbox is at the cap. Add as a static method: ```java public static void addOfflineMessage(int fromId, int toId, String message) { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) { try (PreparedStatement count = connection.prepareStatement("SELECT COUNT(*) FROM messenger_offline WHERE user_id = ?")) { count.setInt(1, toId); try (ResultSet set = count.executeQuery()) { if (set.next() && set.getInt(1) >= MAXIMUM_OFFLINE_MESSAGES) { try (PreparedStatement delete = connection.prepareStatement("DELETE FROM messenger_offline WHERE user_id = ? ORDER BY id ASC LIMIT 1")) { delete.setInt(1, toId); delete.execute(); } } } } try (PreparedStatement insert = connection.prepareStatement("INSERT INTO messenger_offline (user_id, user_from_id, message, sended_on) VALUES (?, ?, ?, ?)")) { insert.setInt(1, toId); insert.setInt(2, fromId); insert.setString(3, message); insert.setInt(4, Emulator.getIntUnixTimestamp()); insert.execute(); } } catch (SQLException e) { LOGGER.error("Caught SQL exception", e); } } ``` - [ ] **Step 3: Add `deliverOfflineMessages`** Loads any stored messages for the logging-in user (oldest first), replays each through `FriendChatMessageComposer` with the `"offline"` marker and the original timestamp, then deletes the delivered rows. ```java public static void deliverOfflineMessages(GameClient client) { if (client == null || client.getHabbo() == null) return; int userId = client.getHabbo().getHabboInfo().getId(); List messages = new ArrayList<>(); try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT user_from_id, message, sended_on FROM messenger_offline WHERE user_id = ? ORDER BY sended_on ASC, id ASC")) { statement.setInt(1, userId); try (ResultSet set = statement.executeQuery()) { while (set.next()) { messages.add(new Message(set.getInt("user_from_id"), userId, set.getString("message"), set.getInt("sended_on"))); } } } catch (SQLException e) { LOGGER.error("Caught SQL exception", e); } if (messages.isEmpty()) return; for (Message message : messages) { client.sendResponse(new FriendChatMessageComposer(message, message.getFromId(), message.getFromId(), "offline")); } try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("DELETE FROM messenger_offline WHERE user_id = ?")) { statement.setInt(1, userId); statement.execute(); } catch (SQLException e) { LOGGER.error("Caught SQL exception", e); } } ``` - [ ] **Step 4: Compile** Run: `cd Arcturus-Morningstar-Extended/Emulator && mvn -q compile` Expected: BUILD SUCCESS. If it fails on a missing symbol, add the missing import (see the list at the top of this task). - [ ] **Step 5: Commit (only Messenger.java)** ```bash cd Arcturus-Morningstar-Extended git add Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Messenger.java git commit -m "feat(messenger): store + deliver offline messages (capped inbox)" ``` --- ## Task 3: Emulator — wire send-to-offline + deliver-on-login **Files:** - Modify: `Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/FriendPrivateMessageEvent.java` - Modify: `Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/RequestInitFriendsEvent.java` - [ ] **Step 1: Branch online vs offline in `FriendPrivateMessageEvent`** Currently the handler ends with `buddy.onMessageReceived(this.client.getHabbo(), message);`. Replace that single line with a branch: deliver if the recipient is online, otherwise store (word-filtered, matching the filtering the online path applies before sending). Add imports `com.eu.habbo.habbohotel.messenger.Messenger` and `com.eu.habbo.habbohotel.modtool.WordFilter`. Replace: ```java buddy.onMessageReceived(this.client.getHabbo(), message); ``` with: ```java if (Emulator.getGameServer().getGameClientManager().getHabbo(userId) != null) { buddy.onMessageReceived(this.client.getHabbo(), message); } else { String stored = message; if (WordFilter.ENABLED_FRIENDCHAT) { stored = Emulator.getGameEnvironment().getWordFilter().filter(message, this.client.getHabbo()); } Messenger.addOfflineMessage(this.client.getHabbo().getHabboInfo().getId(), userId, stored); } ``` (`Emulator` is already imported in this file. `userId` is the recipient read at the top of `handle()`.) - [ ] **Step 2: Deliver offline messages on login in `RequestInitFriendsEvent`** After the existing `this.client.sendResponses(messages);`, add a call to deliver any stored offline messages (sent AFTER the friend list so the client's thread lookup can resolve the sender as a known friend). Add import `com.eu.habbo.habbohotel.messenger.Messenger`. The method becomes: ```java public void handle() throws Exception { ArrayList messages = new ArrayList<>(); messages.add(new MessengerInitComposer(this.client.getHabbo()).compose()); messages.addAll(FriendsComposer.getMessagesForBuddyList(this.client.getHabbo().getMessenger().getFriends().values())); this.client.sendResponses(messages); Messenger.deliverOfflineMessages(this.client); } ``` - [ ] **Step 3: Build the fat jar** Run: `cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests` Expected: BUILD SUCCESS. - [ ] **Step 4: Commit (only these 2 files)** ```bash cd Arcturus-Morningstar-Extended git add Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/FriendPrivateMessageEvent.java Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/RequestInitFriendsEvent.java git commit -m "feat(messenger): persist messages to offline friends, replay on login" ``` Verify `git show --stat HEAD` shows exactly 2 files. --- ## Task 4: Client — `offlineDelivered` getter on `MessengerThreadChat` (TDD) **Files:** - Modify: `Nitro-V3/src/api/friends/MessengerThreadChat.ts` - Test: `Nitro-V3/src/api/friends/MessengerThreadChat.test.ts` `MessengerThreadChat` already stores `_extraData` and `_type`, with `CHAT = 0`. The emulator sends `extraData === "offline"` only for replayed 1:1 messages. - [ ] **Step 1: Write the failing test** Create `src/api/friends/MessengerThreadChat.test.ts`: ```typescript import { describe, expect, it } from 'vitest'; import { MessengerThreadChat } from './MessengerThreadChat'; describe('MessengerThreadChat.offlineDelivered', () => { it('is true for a CHAT message with extraData "offline"', () => { const chat = new MessengerThreadChat(5, 'hello', 60, 'offline', MessengerThreadChat.CHAT); expect(chat.offlineDelivered).toBe(true); }); it('is false for a normal CHAT message with no extraData', () => { const chat = new MessengerThreadChat(5, 'hello', 0, null, MessengerThreadChat.CHAT); expect(chat.offlineDelivered).toBe(false); }); it('is false when extraData is some other value (e.g. group chat data)', () => { const chat = new MessengerThreadChat(5, 'hi', 0, 'Bob/figurestr/5', MessengerThreadChat.CHAT); expect(chat.offlineDelivered).toBe(false); }); it('is false for a non-CHAT type even if extraData is "offline"', () => { const chat = new MessengerThreadChat(5, 'hi', 0, 'offline', MessengerThreadChat.ROOM_INVITE); expect(chat.offlineDelivered).toBe(false); }); }); ``` - [ ] **Step 2: Run it, confirm FAIL** Run: `cd Nitro-V3 && yarn test --run src/api/friends/MessengerThreadChat.test.ts` Expected: FAIL — `offlineDelivered` is not a function/getter. - [ ] **Step 3: Add the getter** In `MessengerThreadChat.ts`, add after the `extraData` getter: ```typescript public get offlineDelivered(): boolean { return (this._type === MessengerThreadChat.CHAT) && (this._extraData === 'offline'); } ``` - [ ] **Step 4: Run the test, confirm PASS (4 cases).** - [ ] **Step 5: Type-check + full suite** Run: `cd Nitro-V3 && yarn typecheck && yarn test --run` Expected: typecheck shows only the known pre-existing `FloorplanCanvasSVG.tsx(143,20): TS2503`; tests green except the 3 known pre-existing floorplan failures. - [ ] **Step 6: Commit** ```bash cd Nitro-V3 git add src/api/friends/MessengerThreadChat.ts src/api/friends/MessengerThreadChat.test.ts git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): offlineDelivered flag on thread chat" ``` --- ## Task 5: Client — render the "sent while offline" marker **Files:** - Modify: `Nitro-V3/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx` - Modify: `Nitro-V3/public/configuration/UITexts.example` - Modify: a messenger CSS file (see Step 3) - [ ] **Step 1: Add the localization key** In `public/configuration/UITexts.example`, add a key near other `messenger.*` entries (match the file's JSON format — confirm by reading an existing `messenger.` line): ```json "messenger.offline.delivered": "Sent while you were offline", ``` (The user can localize the value in their live `UITexts` later. `LocalizeText` falls back to the key string if absent, so this never crashes.) - [ ] **Step 2: Render the marker in the message bubble** In `FriendsMessengerThreadGroup.tsx`, the bubble maps `group.chats`. `LocalizeText` is already imported. In the NON-translation branch (the `if(!chat.showTranslation)` return), append the marker when `chat.offlineDelivered`. Replace: ```tsx if(!chat.showTranslation) { return { chat.message }; } ``` with: ```tsx if(!chat.showTranslation) { return ( { chat.message } { chat.offlineDelivered && { LocalizeText('messenger.offline.delivered') } } ); } ``` (Leave the translation branch as-is — an offline message that is also auto-translated is a rare combination and the marker on the plain branch covers the normal case.) - [ ] **Step 3: Add the marker style** Find the CSS file that styles the messenger thread (search for an existing class used here, e.g. `messenger-message-bubble` or `messenger-message-time`): Run: `grep -rl "messenger-message-bubble" Nitro-V3/src/css` Append to that file a subtle style: ```css .messenger-offline-tag { display: block; margin-top: 2px; font-size: 10px; font-style: italic; opacity: 0.6; } ``` If the grep finds no file (the classes are global/elsewhere), append the same rule to `src/css/friends/FriendsView.css` instead, and note that in your report. - [ ] **Step 4: Type-check + full suite** Run: `cd Nitro-V3 && yarn typecheck && yarn test --run` Expected: only the known pre-existing typecheck error; no new test failures. - [ ] **Step 5: Commit** ```bash cd Nitro-V3 git add src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx public/configuration/UITexts.example # plus the CSS file you edited: git add git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): show 'sent while offline' marker in thread" ``` --- ## Task 6: Integration verification **Files:** none (manual + automated checks; fix-up commits only). - [ ] **Step 1: Automated checks** ``` cd Nitro-V3 && yarn typecheck && yarn test --run cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests ``` Expected: client typecheck shows only the pre-existing `FloorplanCanvasSVG.tsx(143,20): TS2503`; client tests green except the 3 known floorplan failures (+4 new MessengerThreadChat cases passing); emulator BUILD SUCCESS. (No renderer change this phase.) - [ ] **Step 2: Live two-session manual test** Run the new jar + `yarn start`. With two accounts A and B who are friends: 1. **Store while offline:** B logs out. A opens the messenger thread with B and sends a message. (A's client shows the sent message as normal.) 2. **Deliver on login:** B logs in → the message appears in B's thread with A, carrying the "Sent while you were offline" marker. 3. **Order + multiple:** A sends 3 messages while B is offline → on B's login all 3 appear in order, each marked, and the `messenger_offline` rows for B are gone (delivered + deleted): `SELECT * FROM messenger_offline WHERE user_id = ;` → 0 rows after login. 4. **Online still instant:** with both online, messages deliver immediately and show NO offline marker (wire unchanged for online 1:1). 5. **Cap:** (optional) inserting >200 stored messages for one user evicts the oldest. 6. **No regressions:** room invites, group/staff chat, and normal messaging still work. - [ ] **Step 3: Commit any fix-ups** (only if needed) ```bash cd Nitro-V3 git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -am "fix(messenger): offline message integration fixes" ``` --- ## Notes / scope boundaries - **Word filter:** offline messages are word-filtered at store time (sender online) so the recipient sees filtered text, matching the online path. They are NOT written to `chatlogs_private` (offline messages were never chat-logged before; adding that is out of scope). - **Displayed time:** the thread shows the client receive-time (existing behavior); `secondsSinceSent` is sent but the bubble timestamp is local. The "sent while offline" marker is what signals the message is delayed; back-dating the bubble timestamp is out of scope. - **Read receipts (Phase 3)** will mark these delivered-on-login messages as read once the recipient opens the thread — not part of Phase 2. - Do NOT push/merge automatically; the branch already carries Phase 1 + the user's own `feat(chat)` commit.