Files
Nitro-V3/docs/superpowers/plans/2026-06-02-messenger-phase2-offline-messages.md
T
simoleo89 030015afca docs(messenger): Phase 2 implementation plan — offline messages
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.
2026-06-02 18:52:17 +02:00

448 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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)**
```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<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)**
```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 <Base key={ index } className="text-break">{ chat.message }</Base>;
}
```
with:
```tsx
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:
```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 <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:
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 = <B 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.