Store messages to offline friends in messenger_offline (capped inbox), replay on login via the existing FriendChatMessageComposer with an "offline" extraData marker, and render a "sent while offline" tag in the client thread. No new packets; emulator + small client touch.
22 KiB
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 viaFriendChatMessageComposerONLY if the recipient is online (else the message is silently dropped — that's the gap we close).- On login the client sends
MessengerInitComposer→ emulatorRequestInitFriendsEventsendsMessengerInitComposer+ 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 extraname/look/idstring. For 1:1 it appends nothing aftersecondsSinceSent.- Client:
useMessengersubscribes toNewConsoleMessageEventand callssendMessage(thread, parser.senderId, parser.messageText, parser.secondsSinceSent, parser.extraData), which storesextraDataon the createdMessengerThreadChat. messenger_offlinecolumns (verified):id(PK auto),user_id(recipient),user_from_id(sender),messagevarchar(500),sended_onint (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— optionalextraDataappended 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—offlineDeliveredgetter. - Create
api/friends/MessengerThreadChat.test.ts— getter test. - Modify
components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx— render marker. - Modify
public/configuration/UITexts.example— addmessenger.offline.deliveredtext key. - Modify a messenger CSS file —
.messenger-offline-tagstyle.
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(); }):
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
extraDatatoFriendChatMessageComposer
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:
private String extraData = null;
Add this constructor after the existing FriendChatMessageComposer(Message message, int toId, int fromId):
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):
else if (this.extraData != null) {
this.response.appendString(this.extraData);
}
The result is:
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)
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:
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:
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.
public static void deliverOfflineMessages(GameClient client) {
if (client == null || client.getHabbo() == null) return;
int userId = client.getHabbo().getHabboInfo().getId();
List<Message> 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)
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:
buddy.onMessageReceived(this.client.getHabbo(), message);
with:
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:
public void handle() throws Exception {
ArrayList<ServerMessage> 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)
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:
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:
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
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):
"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:
if(!chat.showTranslation)
{
return <Base key={ index } className="text-break">{ chat.message }</Base>;
}
with:
if(!chat.showTranslation)
{
return (
<Base key={ index } className="text-break">
{ chat.message }
{ chat.offlineDelivered &&
<span className="messenger-offline-tag">{ LocalizeText('messenger.offline.delivered') }</span> }
</Base>
);
}
(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:
.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
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 <the css file from Step 3>
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:
- 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.)
- Deliver on login: B logs in → the message appears in B's thread with A, carrying the "Sent while you were offline" marker.
- Order + multiple: A sends 3 messages while B is offline → on B's login all 3 appear in order, each marked, and the
messenger_offlinerows for B are gone (delivered + deleted):SELECT * FROM messenger_offline WHERE user_id = <B id>;→ 0 rows after login. - Online still instant: with both online, messages deliver immediately and show NO offline marker (wire unchanged for online 1:1).
- Cap: (optional) inserting >200 stored messages for one user evicts the oldest.
- No regressions: room invites, group/staff chat, and normal messaging still work.
- Step 3: Commit any fix-ups (only if needed)
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);
secondsSinceSentis 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.