Merge remote-tracking branch 'fork/main'

# Conflicts:
#	public/configuration/UITexts_en.json5.example
#	public/configuration/UITexts_it.json5.example
#	src/components/MainView.tsx
#	src/css/friends/FriendsView.css
This commit is contained in:
simoleo89
2026-06-06 00:17:32 +02:00
23 changed files with 3621 additions and 22 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,447 @@
# 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.
@@ -0,0 +1,586 @@
# Messenger Phase 3 — Read Receipts (✓ / ✓✓) 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:** WhatsApp-style 2-state read receipts on your own sent messages — `✓` sent, `✓✓` read — driven by a live relay (no persistence).
**Architecture:** Two new CUSTOM packets. Client→server `MarkConsoleRead(peerId)` is sent when you focus/read a conversation. The emulator relays it to the peer (if online and a friend) as server→client `ConsoleReadReceipt(readerId)`. The recipient's client marks its own messages in that conversation as READ and renders `✓✓`.
**Design note — no DB, live-relay only (refinement of the spec):** the spec proposed a `messenger_read_state` table + a login-time receipt batch. The Nitro client does NOT persist per-message history across sessions, so a persisted receipt would have no message to update on next login. Persistence is therefore omitted; receipts are a live in-session relay. This keeps Phase 3 simpler with no loss of user-visible behavior. (If cross-session receipts are ever wanted, they'd require persisting client-side message history first — out of scope.)
**Tech Stack:** Arcturus (Java 21/Maven), Nitro_Render_V3 (TypeScript, Vitest), Nitro-V3 (React 19, Vitest).
---
## Branches & rules
All repos on `feat/messenger-groups-receipts`. Client commits use `git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com`. No Co-Authored-By / AI attribution. Emulator working tree has an UNRELATED modified `soundboard/SoundboardPlayEvent.java` + untracked jars — never stage those; `git add` only the listed files.
## Header IDs (custom, verified free in all 4 files)
| Packet | Direction | Renderer header | Emulator header | Value |
|---|---|---|---|---|
| MarkConsoleRead | client→server | `OutgoingHeader.MARK_CONSOLE_READ` | `Incoming.MarkConsoleReadEvent` | **4085** |
| ConsoleReadReceipt | server→client | `IncomingHeader.CONSOLE_READ_RECEIPT` | `Outgoing.ConsoleReadReceiptComposer` | **4086** |
(Renderer Outgoing N == Emulator Incoming N; Renderer Incoming N == Emulator Outgoing N — the verified convention.)
## File map
**Renderer (`Nitro_Render_V3/packages/communication/src/`):**
- Modify `messages/outgoing/OutgoingHeader.ts`, `messages/incoming/IncomingHeader.ts`, `NitroMessages.ts`, the 3 friendlist `index.ts` barrels.
- Create `messages/outgoing/friendlist/MarkConsoleReadComposer.ts`
- Create `messages/incoming/friendlist/ConsoleReadReceiptEvent.ts`
- Create `messages/parser/friendlist/ConsoleReadReceiptParser.ts`
- Create `messages/parser/friendlist/__tests__/ConsoleReadReceiptParser.test.ts`
**Emulator (`Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/`):**
- Modify `messages/incoming/Incoming.java`, `messages/outgoing/Outgoing.java`, `messages/PacketManager.java`
- Create `messages/incoming/friends/MarkConsoleReadEvent.java`
- Create `messages/outgoing/friends/ConsoleReadReceiptComposer.java`
**Client (`Nitro-V3/src/`):**
- Modify `api/friends/MessengerThreadChat.ts` (+ test) and `api/friends/MessengerThread.ts` (+ test)
- Modify `hooks/friends/useMessenger.ts`
- Modify `components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx`
- Modify `src/css/friends/FriendsView.css`
---
## Task P3-1: Renderer — packets + registration + parser test
**Files:** see File map (renderer).
- [ ] **Step 1: Write the failing parser test**
Create `packages/communication/src/messages/parser/friendlist/__tests__/ConsoleReadReceiptParser.test.ts` (mirror the existing `__tests__/FriendCategoryComposers.test.ts` / mentions parser test style with a `TestWrapper` over `BinaryReader`/`BinaryWriter`):
```typescript
import { describe, expect, it } from 'vitest';
import { BinaryReader, BinaryWriter } from '@nitrots/utils';
import { ConsoleReadReceiptParser } from '../ConsoleReadReceiptParser';
class TestWrapper
{
constructor(private reader: BinaryReader) {}
readByte() { return this.reader.readByte(); }
readShort() { return this.reader.readShort(); }
readInt() { return this.reader.readInt(); }
readString() { const len = this.reader.readShort(); return this.reader.readBytes(len).toString(); }
header = 0;
get bytesAvailable() { return this.reader.remaining() > 0; }
}
describe('ConsoleReadReceiptParser', () =>
{
it('parses the reader id', () =>
{
const w = new BinaryWriter();
w.writeInt(42);
const parser = new ConsoleReadReceiptParser();
parser.flush();
parser.parse(new TestWrapper(new BinaryReader(w.getBuffer())) as any);
expect(parser.readerId).toBe(42);
});
});
```
- [ ] **Step 2: Run it, confirm FAIL**
Run: `cd Nitro_Render_V3 && yarn test --run packages/communication/src/messages/parser/friendlist/__tests__/ConsoleReadReceiptParser.test.ts`
Expected: FAIL (module not found).
- [ ] **Step 3: Create the parser**
`packages/communication/src/messages/parser/friendlist/ConsoleReadReceiptParser.ts` (mirror `NewConsoleMessageParser`):
```typescript
import { IMessageDataWrapper, IMessageParser } from '@nitrots/api';
export class ConsoleReadReceiptParser implements IMessageParser
{
private _readerId: number;
public flush(): boolean
{
this._readerId = 0;
return true;
}
public parse(wrapper: IMessageDataWrapper): boolean
{
if(!wrapper) return false;
this._readerId = wrapper.readInt();
return true;
}
public get readerId(): number
{
return this._readerId;
}
}
```
- [ ] **Step 4: Create the incoming event**
`packages/communication/src/messages/incoming/friendlist/ConsoleReadReceiptEvent.ts` (mirror `NewConsoleMessageEvent`):
```typescript
import { IMessageEvent } from '@nitrots/api';
import { MessageEvent } from '@nitrots/events';
import { ConsoleReadReceiptParser } from '../../parser';
export class ConsoleReadReceiptEvent extends MessageEvent implements IMessageEvent
{
constructor(callBack: Function)
{
super(callBack, ConsoleReadReceiptParser);
}
public getParser(): ConsoleReadReceiptParser
{
return this.parser as ConsoleReadReceiptParser;
}
}
```
- [ ] **Step 5: Create the outgoing composer**
`packages/communication/src/messages/outgoing/friendlist/MarkConsoleReadComposer.ts` (mirror `SetRelationshipStatusComposer`):
```typescript
import { IMessageComposer } from '@nitrots/api';
export class MarkConsoleReadComposer implements IMessageComposer<ConstructorParameters<typeof MarkConsoleReadComposer>>
{
private _data: ConstructorParameters<typeof MarkConsoleReadComposer>;
constructor(peerId: number)
{
this._data = [ peerId ];
}
public getMessageArray()
{
return this._data;
}
public dispose(): void
{
return;
}
}
```
- [ ] **Step 6: Add header constants**
In `OutgoingHeader.ts` (near the friend headers): `public static MARK_CONSOLE_READ = 4085;`
In `IncomingHeader.ts` (near the messenger headers): `public static CONSOLE_READ_RECEIPT = 4086;`
- [ ] **Step 7: Barrel exports**
- `messages/outgoing/friendlist/index.ts`: `export * from './MarkConsoleReadComposer';`
- `messages/incoming/friendlist/index.ts`: `export * from './ConsoleReadReceiptEvent';`
- `messages/parser/friendlist/index.ts`: `export * from './ConsoleReadReceiptParser';`
- [ ] **Step 8: Register in NitroMessages**
In `NitroMessages.ts`: add the two classes to the existing friendlist imports, then:
- in the events block (next to `this._events.set(IncomingHeader.MESSENGER_CHAT, NewConsoleMessageEvent);`): `this._events.set(IncomingHeader.CONSOLE_READ_RECEIPT, ConsoleReadReceiptEvent);`
- in the composers block (next to `this._composers.set(OutgoingHeader.MESSENGER_CHAT, SendMessageComposer);`): `this._composers.set(OutgoingHeader.MARK_CONSOLE_READ, MarkConsoleReadComposer);`
- [ ] **Step 9: Compile + test**
Run: `cd Nitro_Render_V3 && yarn compile:fast && yarn test --run`
Expected: compile clean; all tests pass (142 prior + 1 new = 143).
- [ ] **Step 10: Commit**
```bash
cd Nitro_Render_V3
git add packages/communication/src/messages/ packages/communication/src/NitroMessages.ts
git commit -m "feat(messenger): read-receipt packets (MarkConsoleRead + ConsoleReadReceipt)"
```
---
## Task P3-2: Emulator — handler + composer + registration
**Files:** see File map (emulator).
> No emulator unit tests; verify with `mvn package`.
- [ ] **Step 1: Header constants**
In `Incoming.java` (near the friend constants): `public static final int MarkConsoleReadEvent = 4085;`
In `Outgoing.java` (near the friend composers): `public final static int ConsoleReadReceiptComposer = 4086;`
- [ ] **Step 2: Create the outgoing composer**
`messages/outgoing/friends/ConsoleReadReceiptComposer.java`:
```java
package com.eu.habbo.messages.outgoing.friends;
import com.eu.habbo.messages.ServerMessage;
import com.eu.habbo.messages.outgoing.MessageComposer;
import com.eu.habbo.messages.outgoing.Outgoing;
public class ConsoleReadReceiptComposer extends MessageComposer {
private final int readerId;
public ConsoleReadReceiptComposer(int readerId) {
this.readerId = readerId;
}
@Override
protected ServerMessage composeInternal() {
this.response.init(Outgoing.ConsoleReadReceiptComposer);
this.response.appendInt(this.readerId);
return this.response;
}
}
```
- [ ] **Step 3: Create the incoming handler**
`messages/incoming/friends/MarkConsoleReadEvent.java`. The reader (me) tells the server it read `peerId`'s messages; the server relays a receipt to `peerId` IF `peerId` is online AND a friend (anti-spoof). 1:1 only — `peerId <= 0` (e.g. StaffChat = -1) is ignored.
```java
package com.eu.habbo.messages.incoming.friends;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.friends.ConsoleReadReceiptComposer;
public class MarkConsoleReadEvent extends MessageHandler {
@Override
public void handle() throws Exception {
int peerId = this.packet.readInt();
Habbo me = this.client.getHabbo();
if (me == null || peerId <= 0) return;
if (me.getMessenger().getFriend(peerId) == null) return;
Habbo peer = Emulator.getGameServer().getGameClientManager().getHabbo(peerId);
if (peer == null || peer.getClient() == null) return;
peer.getClient().sendResponse(new ConsoleReadReceiptComposer(me.getHabboInfo().getId()));
}
}
```
Before writing, confirm `me.getMessenger().getFriend(int)` exists (it's used in `FriendPrivateMessageEvent`) and `Emulator.getGameServer().getGameClientManager().getHabbo(int)` (used in `MessengerBuddy.onMessageReceived`). Adapt + report if a signature differs.
- [ ] **Step 4: Register the handler**
In `PacketManager.registerFriends()`: `this.registerHandler(Incoming.MarkConsoleReadEvent, MarkConsoleReadEvent.class);`
(`registerFriends` uses a wildcard `import com.eu.habbo.messages.incoming.friends.*;` — confirm with `grep -n "incoming.friends" PacketManager.java`; if explicit imports are used instead, add `import ...MarkConsoleReadEvent;`.)
- [ ] **Step 5: Build**
Run: `cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests`
Expected: BUILD SUCCESS.
- [ ] **Step 6: Commit (only the 4 files)**
```bash
cd Arcturus-Morningstar-Extended
git add Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/MarkConsoleReadEvent.java Emulator/src/main/java/com/eu/habbo/messages/outgoing/friends/ConsoleReadReceiptComposer.java Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java
git commit -m "feat(messenger): relay read receipts between friends"
```
`git show --stat HEAD` → exactly 5 files (no soundboard, no jars).
---
## Task P3-3: Client — message status model (TDD)
**Files:**
- Modify: `Nitro-V3/src/api/friends/MessengerThreadChat.ts` + Test `MessengerThreadChat.test.ts` (extend the existing test file from Phase 2)
- Modify: `Nitro-V3/src/api/friends/MessengerThread.ts` + Test `Nitro-V3/src/api/friends/MessengerThread.test.ts`
- [ ] **Step 1: Write the failing tests**
Append to the existing `src/api/friends/MessengerThreadChat.test.ts`:
```typescript
describe('MessengerThreadChat status', () =>
{
it('defaults to SENT', () =>
{
const chat = new MessengerThreadChat(5, 'hi', 0, null, MessengerThreadChat.CHAT);
expect(chat.status).toBe(MessengerThreadChat.SENT);
});
it('can be set to READ', () =>
{
const chat = new MessengerThreadChat(5, 'hi', 0, null, MessengerThreadChat.CHAT);
chat.setStatus(MessengerThreadChat.READ);
expect(chat.status).toBe(MessengerThreadChat.READ);
});
});
```
Create `src/api/friends/MessengerThread.test.ts`:
```typescript
import { describe, expect, it } from 'vitest';
import { MessengerFriend } from './MessengerFriend';
import { MessengerThread } from './MessengerThread';
import { MessengerThreadChat } from './MessengerThreadChat';
const makeThread = (participantId: number): MessengerThread =>
{
const friend = new MessengerFriend();
friend.id = participantId;
return new MessengerThread(friend);
};
describe('MessengerThread.setMessagesReadFromUser', () =>
{
it('marks only the given user\'s messages as READ', () =>
{
const thread = makeThread(7);
const mine = thread.addMessage(100, 'a', 0, null, MessengerThreadChat.CHAT); // my message
const theirs = thread.addMessage(7, 'b', 0, null, MessengerThreadChat.CHAT); // their message
thread.setMessagesReadFromUser(100);
expect(mine.status).toBe(MessengerThreadChat.READ);
expect(theirs.status).toBe(MessengerThreadChat.SENT);
});
});
```
- [ ] **Step 2: Run, confirm FAIL**
Run: `cd Nitro-V3 && yarn test --run src/api/friends/MessengerThreadChat.test.ts src/api/friends/MessengerThread.test.ts`
Expected: FAIL (SENT/READ/status/setStatus/setMessagesReadFromUser missing).
- [ ] **Step 3: Add status to MessengerThreadChat**
In `MessengerThreadChat.ts`, add the constants next to the existing `CHAT`/`ROOM_INVITE` statics:
```typescript
public static SENT: number = 0;
public static READ: number = 1;
```
Add the field next to the other private fields:
```typescript
private _status: number = MessengerThreadChat.SENT;
```
Add getter + setter (next to the `offlineDelivered` getter):
```typescript
public get status(): number
{
return this._status;
}
public setStatus(status: number): void
{
this._status = status;
}
```
- [ ] **Step 4: Add the marking method to MessengerThread**
In `MessengerThread.ts`, add (e.g. after `setRead()`):
```typescript
public setMessagesReadFromUser(userId: number): void
{
for(const group of this._groups)
{
if(group.userId !== userId) continue;
for(const chat of group.chats) chat.setStatus(MessengerThreadChat.READ);
}
}
```
(`MessengerThreadChat` is already imported in this file.)
- [ ] **Step 5: Run, confirm PASS** (Chat: 6 cases now; Thread: 1 case).
- [ ] **Step 6: typecheck + full suite**
Run: `cd Nitro-V3 && yarn typecheck && yarn test --run`
Expected: only the known pre-existing `FloorplanCanvasSVG.tsx(143,20): TS2503`; no new failures (3 known floorplan failures remain).
- [ ] **Step 7: Commit**
```bash
cd Nitro-V3
git add src/api/friends/MessengerThreadChat.ts src/api/friends/MessengerThreadChat.test.ts src/api/friends/MessengerThread.ts src/api/friends/MessengerThread.test.ts
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): SENT/READ status on thread chats + mark-read helper"
```
---
## Task P3-4: Client — wire receipts into useMessenger
**Files:**
- Modify: `Nitro-V3/src/hooks/friends/useMessenger.ts`
- [ ] **Step 1: Import the packets**
In the top `@nitrots/nitro-renderer` import of `useMessenger.ts`, add `ConsoleReadReceiptEvent` and `MarkConsoleReadComposer` (alongside `NewConsoleMessageEvent`, `SendMessageComposer as SendMessageComposerPacket`). `GetSessionDataManager` is already imported.
- [ ] **Step 2: Send MarkConsoleRead when a conversation is focused**
The existing `useEffect([activeThreadId])` marks the active thread read locally. Extend it to also tell the peer. Replace that effect's body so that, after computing the active thread, it sends the composer for a real 1:1 participant:
```typescript
useEffect(() =>
{
if (activeThreadId <= 0) return;
let participantId = 0;
setMessageThreads(prevValue =>
{
const newValue = [...prevValue];
const index = newValue.findIndex(newThread => (newThread.threadId === activeThreadId));
if (index >= 0)
{
newValue[index] = CloneObject(newValue[index]);
newValue[index].setRead();
participantId = newValue[index].participant?.id ?? 0;
}
return newValue;
});
if (participantId > 0) SendMessageComposer(new MarkConsoleReadComposer(participantId));
}, [activeThreadId]);
```
- [ ] **Step 3: Also mark-read when a message arrives in the already-active thread**
In the `NewConsoleMessageEvent` handler, after `sendMessage(...)`, notify the peer if this thread is the one currently open:
```typescript
useMessageEvent<NewConsoleMessageEvent>(NewConsoleMessageEvent, event =>
{
const parser = event.getParser();
const thread = getMessageThread(parser.senderId);
if (!thread) return;
sendMessage(thread, parser.senderId, parser.messageText, parser.secondsSinceSent, parser.extraData);
if ((thread.threadId === activeThreadId) && (parser.senderId > 0)) SendMessageComposer(new MarkConsoleReadComposer(parser.senderId));
});
```
- [ ] **Step 4: Handle the incoming receipt — mark own messages READ**
Add a new event subscription (near the other `useMessageEvent` calls). `parser.readerId` is the friend who read MY messages; find the thread with that participant and mark my own messages READ:
```typescript
useMessageEvent<ConsoleReadReceiptEvent>(ConsoleReadReceiptEvent, event =>
{
const parser = event.getParser();
const ownUserId = GetSessionDataManager().userId;
setMessageThreads(prevValue =>
{
const index = prevValue.findIndex(thread => (thread.participant && (thread.participant.id === parser.readerId)));
if (index === -1) return prevValue;
const newValue = [...prevValue];
newValue[index] = CloneObject(newValue[index]);
newValue[index].setMessagesReadFromUser(ownUserId);
return newValue;
});
});
```
- [ ] **Step 5: typecheck + full suite**
Run: `cd Nitro-V3 && yarn typecheck && yarn test --run`
Expected: only the pre-existing typecheck error; no new test failures.
- [ ] **Step 6: Commit**
```bash
cd Nitro-V3
git add src/hooks/friends/useMessenger.ts
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): send mark-read on focus, mark own messages read on receipt"
```
---
## Task P3-5: Client — render ✓ / ✓✓ + CSS
**Files:**
- Modify: `Nitro-V3/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx`
- Modify: `Nitro-V3/src/css/friends/FriendsView.css`
- [ ] **Step 1: Render the status indicator on own private-chat bubbles**
In `FriendsMessengerThreadGroup.tsx`, the final `return (...)` renders the message row; the own-message avatar is gated by `isOwnChat`. `MessengerThreadChat` and `MessengerGroupType` are already imported. After the `.messenger-message-time` `<Base>` (inside `.messenger-message-body`), add a status indicator shown only for own 1:1 CHAT groups. Compute the last chat once and render:
```tsx
<Base className="messenger-message-time">{ group.chats[0].date.toLocaleTimeString() }</Base>
{ isOwnChat && (group.type === MessengerGroupType.PRIVATE_CHAT) && (group.chats[group.chats.length - 1].type === MessengerThreadChat.CHAT) &&
<Base className={ 'messenger-message-status ' + ((group.chats[group.chats.length - 1].status === MessengerThreadChat.READ) ? 'read' : '') }>
{ (group.chats[group.chats.length - 1].status === MessengerThreadChat.READ) ? '✓✓' : '✓' }
</Base> }
```
(Insert this block immediately after the existing `messenger-message-time` line, still inside the `.messenger-message-body` `<Base>`.)
- [ ] **Step 2: Add CSS**
Append to `src/css/friends/FriendsView.css`:
```css
.messenger-message-status {
margin-top: 1px;
font-size: 10px;
line-height: 10px;
text-align: right;
opacity: 0.6;
}
.messenger-message-status.read {
color: #4fc3f7;
opacity: 1;
}
```
- [ ] **Step 3: typecheck + full suite**
Run: `cd Nitro-V3 && yarn typecheck && yarn test --run`
Expected: only the pre-existing typecheck error; no new test failures.
- [ ] **Step 4: Commit**
```bash
cd Nitro-V3
git add src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx src/css/friends/FriendsView.css
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): render sent/read checkmarks on own messages"
```
---
## Task P3-6: Integration verification
**Files:** none (automated + manual; fix-ups only).
- [ ] **Step 1: Automated checks**
```
cd Nitro_Render_V3 && yarn compile:fast && yarn test --run
cd Nitro-V3 && yarn typecheck && yarn test --run
cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests
```
Expected: renderer tests green (143); client typecheck shows only the pre-existing `FloorplanCanvasSVG.tsx(143,20): TS2503`, tests green except the 3 known floorplan failures (+ the new model cases pass); emulator BUILD SUCCESS.
- [ ] **Step 2: Live two-session manual test**
Run the new jar + `yarn start`. Accounts A and B (friends), both online:
1. **Sent (✓):** A sends B a message while B's messenger thread with A is NOT focused → on A's side the message shows a single `✓`.
2. **Read (✓✓):** B opens/focuses the conversation with A → A's message flips to `✓✓` (blue) live.
3. **New message after read:** A sends another message → shows `✓` again; when B (thread still focused) receives it, A flips to `✓✓` (the active-thread mark-read path).
4. **Offline interplay (Phase 2):** A messages B while B offline → A shows `✓`; B logs in and opens the thread → A (if still online) sees `✓✓`.
5. **No receipts for non-1:1:** opening the Staff Chat / a group chat thread does not produce errors and shows no checkmarks on those messages.
6. **Privacy/abuse:** a receipt only arrives for actual friends (the handler ignores non-friends and `peerId <= 0`).
7. **No regressions:** sending, receiving, offline markers (Phase 2), and groups (Phase 1) all 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): read-receipt integration fixes"
```
---
## Scope boundaries
- **No persistence / no login batch** (see Design note): receipts are live-relay only; ✓✓ applies within the session the sender is online for.
- **2-state only:** `✓` (sent) and `✓✓` (read). No separate "delivered" state.
- **1:1 only:** group chat, staff chat, and bots never produce receipts (`peerId <= 0` and non-friends are ignored server-side; the client only renders checks on `PRIVATE_CHAT` CHAT groups).
- **Receipt marks ALL current own messages in the thread read** (not a per-message timestamp diff) — correct for the 2-state model since a focus/read means everything visible is read; messages sent afterward start at `✓` again.
- No renderer/client message-history persistence is added.
- Do NOT push/merge automatically; the branch carries Phases 12 + the user's own parallel commits.
@@ -0,0 +1,533 @@
# Messenger Phase 4 — Typing Indicator 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:** Show "X sta scrivendo…" in a 1:1 conversation while the friend is typing, WhatsApp-style.
**Architecture:** Two ephemeral CUSTOM packets (never stored). Client→server `ConsoleTyping(peerId, isTyping)` is sent when the user starts/stops typing in a thread; the emulator relays it (friend + online only) to the peer as server→client `FriendTyping(senderId, isTyping)`. The recipient's client shows a typing indicator for that friend, auto-expiring after a few seconds.
**Tech Stack:** Arcturus (Java 21/Maven), Nitro_Render_V3 (TypeScript, Vitest), Nitro-V3 (React 19, Vitest). No DB.
---
## Branches & rules
All repos on `feat/messenger-groups-receipts`. Client commits use `git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com`. No Co-Authored-By / AI attribution. Emulator working tree has an UNRELATED modified `soundboard/SoundboardPlayEvent.java` + untracked jars — never stage those; `git add` only the listed files.
## Header IDs (custom, verified free in all 4 files)
| Packet | Direction | Renderer header | Emulator header | Value |
|---|---|---|---|---|
| ConsoleTyping | client→server | `OutgoingHeader.CONSOLE_TYPING` | `Incoming.ConsoleTypingEvent` | **4087** |
| FriendTyping | server→client | `IncomingHeader.FRIEND_TYPING` | `Outgoing.FriendTypingComposer` | **4088** |
Wire: ConsoleTyping = `int peerId`, `boolean isTyping`. FriendTyping = `int senderId`, `boolean isTyping`. (Booleans are supported in composers/parsers — e.g. `DeclineFriendMessageComposer` sends a boolean; `FriendParser` reads booleans.)
## File map
**Renderer (`Nitro_Render_V3/packages/communication/src/`):**
- Create `messages/outgoing/friendlist/ConsoleTypingComposer.ts`
- Create `messages/incoming/friendlist/FriendIsTypingEvent.ts`
- Create `messages/parser/friendlist/FriendIsTypingParser.ts`
- Create `messages/parser/friendlist/__tests__/FriendIsTypingParser.test.ts`
- Modify `messages/outgoing/OutgoingHeader.ts`, `messages/incoming/IncomingHeader.ts`, `NitroMessages.ts`, the 3 friendlist `index.ts`
**Emulator (`Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/`):**
- Create `messages/incoming/friends/ConsoleTypingEvent.java`
- Create `messages/outgoing/friends/FriendTypingComposer.java`
- Modify `messages/incoming/Incoming.java`, `messages/outgoing/Outgoing.java`, `messages/PacketManager.java`
**Client (`Nitro-V3/src/`):**
- Modify `hooks/friends/useMessenger.ts` (incoming typing state + outgoing action)
- Modify `components/friends/views/messenger/FriendsMessengerView.tsx` (send typing + render indicator)
- Modify `public/configuration/UITexts.example` (`messenger.typing` key)
- Modify `src/css/friends/FriendsView.css` (`.messenger-typing-indicator`)
---
## Task P4-1: Renderer — typing packets + parser test
**Files:** see File map (renderer).
- [ ] **Step 1: Failing parser test**
Create `packages/communication/src/messages/parser/friendlist/__tests__/FriendIsTypingParser.test.ts`:
```typescript
import { describe, expect, it } from 'vitest';
import { BinaryReader, BinaryWriter } from '@nitrots/utils';
import { FriendIsTypingParser } from '../FriendIsTypingParser';
class TestWrapper
{
constructor(private reader: BinaryReader) {}
readByte() { return this.reader.readByte(); }
readBoolean() { return this.reader.readByte() === 1; }
readShort() { return this.reader.readShort(); }
readInt() { return this.reader.readInt(); }
readString() { const len = this.reader.readShort(); return this.reader.readBytes(len).toString(); }
header = 0;
get bytesAvailable() { return this.reader.remaining() > 0; }
}
describe('FriendIsTypingParser', () =>
{
it('parses senderId + isTyping=true', () =>
{
const w = new BinaryWriter();
w.writeInt(42); w.writeByte(1);
const parser = new FriendIsTypingParser();
parser.flush();
parser.parse(new TestWrapper(new BinaryReader(w.getBuffer())) as any);
expect(parser.senderId).toBe(42);
expect(parser.isTyping).toBe(true);
});
it('parses isTyping=false', () =>
{
const w = new BinaryWriter();
w.writeInt(42); w.writeByte(0);
const parser = new FriendIsTypingParser();
parser.flush();
parser.parse(new TestWrapper(new BinaryReader(w.getBuffer())) as any);
expect(parser.isTyping).toBe(false);
});
});
```
Run `cd Nitro_Render_V3 && yarn test --run packages/communication/src/messages/parser/friendlist/__tests__/FriendIsTypingParser.test.ts` → FAIL.
(Confirm `BinaryWriter` has `writeByte`/`writeInt` — the mentions/category tests use `writeInt`/`writeString`; if `writeByte` is named differently, use the real method that writes a single byte, mirroring how the existing parser tests write a boolean/byte. If unsure, write the boolean as `w.writeInt(1)` and read with `readInt() === 1` in BOTH parser and test — but prefer a real 1-byte boolean to match the emulator's `appendBoolean`/`readBoolean`. Inspect an existing parser test that round-trips a boolean to copy the exact writer call.)
- [ ] **Step 2: Create the parser**
`packages/communication/src/messages/parser/friendlist/FriendIsTypingParser.ts`:
```typescript
import { IMessageDataWrapper, IMessageParser } from '@nitrots/api';
export class FriendIsTypingParser implements IMessageParser
{
private _senderId: number;
private _isTyping: boolean;
public flush(): boolean
{
this._senderId = 0;
this._isTyping = false;
return true;
}
public parse(wrapper: IMessageDataWrapper): boolean
{
if(!wrapper) return false;
this._senderId = wrapper.readInt();
this._isTyping = wrapper.readBoolean();
return true;
}
public get senderId(): number
{
return this._senderId;
}
public get isTyping(): boolean
{
return this._isTyping;
}
}
```
(Confirm `IMessageDataWrapper` has `readBoolean()``FriendParser` uses it. If not, use `wrapper.readInt() === 1`.)
- [ ] **Step 3: Create the incoming event**
`packages/communication/src/messages/incoming/friendlist/FriendIsTypingEvent.ts`:
```typescript
import { IMessageEvent } from '@nitrots/api';
import { MessageEvent } from '@nitrots/events';
import { FriendIsTypingParser } from '../../parser';
export class FriendIsTypingEvent extends MessageEvent implements IMessageEvent
{
constructor(callBack: Function)
{
super(callBack, FriendIsTypingParser);
}
public getParser(): FriendIsTypingParser
{
return this.parser as FriendIsTypingParser;
}
}
```
- [ ] **Step 4: Create the outgoing composer**
`packages/communication/src/messages/outgoing/friendlist/ConsoleTypingComposer.ts`:
```typescript
import { IMessageComposer } from '@nitrots/api';
export class ConsoleTypingComposer implements IMessageComposer<ConstructorParameters<typeof ConsoleTypingComposer>>
{
private _data: ConstructorParameters<typeof ConsoleTypingComposer>;
constructor(peerId: number, isTyping: boolean)
{
this._data = [ peerId, isTyping ];
}
public getMessageArray()
{
return this._data;
}
public dispose(): void
{
return;
}
}
```
- [ ] **Step 5: Header constants**
- `OutgoingHeader.ts`: `public static CONSOLE_TYPING = 4087;`
- `IncomingHeader.ts`: `public static FRIEND_TYPING = 4088;`
- [ ] **Step 6: Barrel exports**
- `messages/outgoing/friendlist/index.ts`: `export * from './ConsoleTypingComposer';`
- `messages/incoming/friendlist/index.ts`: `export * from './FriendIsTypingEvent';`
- `messages/parser/friendlist/index.ts`: `export * from './FriendIsTypingParser';`
- [ ] **Step 7: Register in NitroMessages**
Add the two classes to the friendlist imports, then:
- events: `this._events.set(IncomingHeader.FRIEND_TYPING, FriendIsTypingEvent);`
- composers: `this._composers.set(OutgoingHeader.CONSOLE_TYPING, ConsoleTypingComposer);`
- [ ] **Step 8: Compile + test**
Run: `cd Nitro_Render_V3 && yarn compile:fast && yarn test --run`
Expected: compile clean; all tests pass (143 prior + 2 new = 145).
- [ ] **Step 9: Commit**
```bash
cd Nitro_Render_V3
git add packages/communication/src/messages/ packages/communication/src/NitroMessages.ts
git commit -m "feat(messenger): typing packets (ConsoleTyping + FriendTyping)"
```
---
## Task P4-2: Emulator — typing relay
**Files:** see File map (emulator). No emulator unit tests; verify with `mvn package`.
- [ ] **Step 1: Header constants**
- `Incoming.java`: `public static final int ConsoleTypingEvent = 4087;`
- `Outgoing.java`: `public final static int FriendTypingComposer = 4088;`
- [ ] **Step 2: Outgoing composer**
`messages/outgoing/friends/FriendTypingComposer.java`:
```java
package com.eu.habbo.messages.outgoing.friends;
import com.eu.habbo.messages.ServerMessage;
import com.eu.habbo.messages.outgoing.MessageComposer;
import com.eu.habbo.messages.outgoing.Outgoing;
public class FriendTypingComposer extends MessageComposer {
private final int senderId;
private final boolean isTyping;
public FriendTypingComposer(int senderId, boolean isTyping) {
this.senderId = senderId;
this.isTyping = isTyping;
}
@Override
protected ServerMessage composeInternal() {
this.response.init(Outgoing.FriendTypingComposer);
this.response.appendInt(this.senderId);
this.response.appendBoolean(this.isTyping);
return this.response;
}
}
```
(Confirm `ServerMessage.appendBoolean(boolean)` exists — `UpdateFriendComposer`/`MessengerBuddy.serialize` both use `appendBoolean`. It does.)
- [ ] **Step 3: Incoming handler**
`messages/incoming/friends/ConsoleTypingEvent.java`. Reads `peerId` + `isTyping`; relays to `peerId` if online AND a friend; ignores `peerId <= 0` (1:1 only). Ephemeral — no storage.
```java
package com.eu.habbo.messages.incoming.friends;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.friends.FriendTypingComposer;
public class ConsoleTypingEvent extends MessageHandler {
@Override
public void handle() throws Exception {
int peerId = this.packet.readInt();
boolean isTyping = this.packet.readBoolean();
Habbo me = this.client.getHabbo();
if (me == null || peerId <= 0) return;
if (me.getMessenger().getFriend(peerId) == null) return;
Habbo peer = Emulator.getGameServer().getGameClientManager().getHabbo(peerId);
if (peer == null || peer.getClient() == null) return;
peer.getClient().sendResponse(new FriendTypingComposer(me.getHabboInfo().getId(), isTyping));
}
}
```
(Confirm `this.packet.readBoolean()` exists — `ClientMessage.readBoolean()` is used across handlers. It does.)
- [ ] **Step 4: Register handler**
In `PacketManager.registerFriends()`: `this.registerHandler(Incoming.ConsoleTypingEvent, ConsoleTypingEvent.class);`
(The `incoming.friends.*` wildcard import covers it — confirm with `grep -n "incoming.friends" PacketManager.java`.)
- [ ] **Step 5: Build**
Run: `cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests`
Expected: BUILD SUCCESS.
- [ ] **Step 6: Commit (only the 5 files)**
```bash
cd Arcturus-Morningstar-Extended
git add Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/ConsoleTypingEvent.java Emulator/src/main/java/com/eu/habbo/messages/outgoing/friends/FriendTypingComposer.java Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java
git commit -m "feat(messenger): relay typing status between friends"
```
`git show --stat HEAD` → exactly 5 files (no soundboard, no jars).
---
## Task P4-3: Client — typing state + action in useMessenger
**Files:** Modify `Nitro-V3/src/hooks/friends/useMessenger.ts`.
> No clean unit test here (timer + event-bus via the renderer mock). Verified by typecheck + the live test in P4-5. Keep the implementation tight.
- [ ] **Step 1: Imports**
Add `ConsoleTypingComposer` and `FriendIsTypingEvent` to the `@nitrots/nitro-renderer` import line. Ensure `useRef` is imported from 'react' (the file imports `useEffect, useMemo, useRef, useState` after Phase 3 — confirm `useRef` is present).
- [ ] **Step 2: Typing state + timers ref**
Inside `useMessengerState`, near the other `useState` calls, add:
```typescript
const [typingUserIds, setTypingUserIds] = useState<number[]>([]);
const typingTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
```
- [ ] **Step 3: Outgoing action**
Add (near `sendMessage` / other actions):
```typescript
const sendTypingStatus = (peerId: number, isTyping: boolean) =>
{
if (!peerId || (peerId <= 0)) return;
SendMessageComposer(new ConsoleTypingComposer(peerId, isTyping));
};
```
- [ ] **Step 4: Incoming handler with auto-expire**
Add a new `useMessageEvent` (near the others). When a friend is typing, add their id and (re)arm a 6s expiry; when they stop, remove immediately.
```typescript
useMessageEvent<FriendIsTypingEvent>(FriendIsTypingEvent, event =>
{
const parser = event.getParser();
const senderId = parser.senderId;
if (senderId <= 0) return;
const timers = typingTimersRef.current;
const existing = timers.get(senderId);
if (existing)
{
clearTimeout(existing);
timers.delete(senderId);
}
if (parser.isTyping)
{
setTypingUserIds(prev => (prev.indexOf(senderId) >= 0) ? prev : [...prev, senderId]);
timers.set(senderId, setTimeout(() =>
{
typingTimersRef.current.delete(senderId);
setTypingUserIds(prev => prev.filter(id => (id !== senderId)));
}, 6000));
}
else
{
setTypingUserIds(prev => prev.filter(id => (id !== senderId)));
}
});
```
- [ ] **Step 5: Expose**
Add `typingUserIds` and `sendTypingStatus` to the `useMessengerState` return object (the bottom `return { ... }`).
- [ ] **Step 6: typecheck + tests + lint:hooks**
Run: `cd Nitro-V3 && yarn typecheck && yarn test --run && yarn lint:hooks`
Expected: typecheck only the pre-existing floorplan error; no new test failures; `lint:hooks` 0 errors.
- [ ] **Step 7: Commit**
```bash
cd Nitro-V3
git add src/hooks/friends/useMessenger.ts
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): incoming typing state + outgoing typing action"
```
---
## Task P4-4: Client — send typing + render indicator
**Files:**
- Modify `Nitro-V3/src/components/friends/views/messenger/FriendsMessengerView.tsx`
- Modify `Nitro-V3/public/configuration/UITexts.example`
- Modify `Nitro-V3/src/css/friends/FriendsView.css`
- [ ] **Step 1: Pull the new hook members**
In `FriendsMessengerView.tsx`, the `useMessenger()` destructure currently grabs `visibleThreads, activeThread, getMessageThread, sendMessage, setActiveThreadId, closeThread`. Add `typingUserIds = [], sendTypingStatus = null`.
- [ ] **Step 2: Outgoing typing notifier (refs + idle timer)**
Add near the other refs/state at the top of the component:
```tsx
const isTypingRef = useRef<boolean>(false);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const stopTyping = () =>
{
if(typingTimeoutRef.current)
{
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = null;
}
if(isTypingRef.current && activeThread && activeThread.participant && (activeThread.participant.id > 0))
{
sendTypingStatus(activeThread.participant.id, false);
}
isTypingRef.current = false;
};
const handleInputChange = (value: string) =>
{
setMessageText(value);
const peerId = (activeThread && activeThread.participant) ? activeThread.participant.id : 0;
if(peerId <= 0) return;
if(!value.length)
{
stopTyping();
return;
}
if(!isTypingRef.current)
{
sendTypingStatus(peerId, true);
isTypingRef.current = true;
}
if(typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => stopTyping(), 4000);
};
```
`useRef` is already imported in this file.
- [ ] **Step 3: Wire the input + send**
- Change the input's `onChange` from `event => setMessageText(event.target.value)` to `event => handleInputChange(event.target.value)`.
- In `send()`, after each `setMessageText('')` (there are a few early returns — simplest: call `stopTyping()` once at the START of `send()` after the `if(!activeThread || !messageText.length) return;` guard, so any in-progress typing is cleared and a `false` is sent before the message). Add `stopTyping();` right after that guard line.
- [ ] **Step 4: Stop typing when switching away from / closing a thread**
The component already has an effect on `[ isVisible, activeThread, ... ]`. To avoid a stale typing flag when the active thread changes, add a small effect:
```tsx
useEffect(() =>
{
// when the active conversation changes (or closes), clear local typing state
return () =>
{
if(typingTimeoutRef.current)
{
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = null;
}
isTypingRef.current = false;
};
}, [ activeThread ]);
```
(This clears the local flag/timer on thread switch; the peer's indicator auto-expires after 6s, so an explicit "false" on switch isn't required.)
- [ ] **Step 5: Render the indicator**
Between the `chat-messages` div and the `messenger-input-row`, add:
```tsx
{ activeThread.participant && (activeThread.participant.id > 0) && (typingUserIds.indexOf(activeThread.participant.id) >= 0) &&
<div className="messenger-typing-indicator">
{ LocalizeText('messenger.typing', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) }
</div> }
```
- [ ] **Step 6: Localization key**
In `public/configuration/UITexts.example`, add (keep valid JSON):
```json
"messenger.typing": "%FRIEND_NAME% is typing...",
```
- [ ] **Step 7: CSS**
Append to `src/css/friends/FriendsView.css`:
```css
.messenger-typing-indicator {
padding: 2px 8px;
font-size: 11px;
font-style: italic;
opacity: 0.7;
}
```
- [ ] **Step 8: typecheck + tests**
Run: `cd Nitro-V3 && yarn typecheck && yarn test --run`
Expected: only the pre-existing floorplan typecheck error; no new test failures.
- [ ] **Step 9: Commit**
```bash
cd Nitro-V3
git add src/components/friends/views/messenger/FriendsMessengerView.tsx public/configuration/UITexts.example src/css/friends/FriendsView.css
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): send typing status + show 'is typing' indicator"
```
---
## Task P4-5: Integration verification
**Files:** none (automated + manual; fix-ups only).
- [ ] **Step 1: Automated checks**
```
cd Nitro_Render_V3 && yarn compile:fast && yarn test --run
cd Nitro-V3 && yarn typecheck && yarn test --run && yarn lint:hooks
cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests
```
Expected: renderer 145 tests green; client typecheck only the pre-existing floorplan error, tests green except the 3 known floorplan failures, `lint:hooks` 0 errors; emulator BUILD SUCCESS.
- [ ] **Step 2: Live two-session manual test**
Run the new jar + `yarn start`. Accounts A and B (friends), both online, conversation open on both sides:
1. **Typing shows:** A starts typing in the thread with B → B sees "A is typing..." above the input.
2. **Stops on idle:** A stops typing → after ~4s (A's idle timer sends stop) B's indicator disappears; even if the stop packet is lost, B's indicator auto-expires after ~6s.
3. **Stops on send:** A types then sends → B's indicator disappears (stop sent at send time) and the message arrives.
4. **1:1 only:** typing in the Staff Chat / a group thread produces no errors and no indicator (server ignores `peerId <= 0` / non-friends).
5. **No regressions:** sending, receipts (Phase 3 ✓/✓✓), offline markers (Phase 2), and groups (Phase 1) all 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): typing indicator integration fixes"
```
---
## Scope boundaries
- **Ephemeral only:** typing status is never stored; relayed only between online friends.
- **1:1 only:** group/staff/bots produce no typing (server ignores `peerId <= 0` and non-friends; client only renders for `participant.id > 0`).
- **Throttling:** the client sends `true` once per typing burst and `false` on idle(4s)/send/empty; the recipient auto-expires the indicator after 6s as a safety net for lost stop packets.
- This completes the messenger initiative (Phases 14). Do NOT push/merge automatically; the branch carries all four phases + the user's own parallel commits.
@@ -0,0 +1,127 @@
# Messenger upgrade — Friend groups, offline messages, read receipts, typing
**Date:** 2026-06-02
**Status:** Approved design (brainstorming) — pending implementation plan
**Scope:** Cross-component (Nitro-V3 client + Nitro_Render_V3 renderer + Arcturus emulator + DB). CMS untouched.
## Goal
Extend the existing (already-React) friends list & instant messenger with four features:
1. **Friend groups** — full custom: create / rename / delete named groups and assign friends to them.
2. **Offline messages** — messages to an offline friend are stored and delivered on their next login, tagged "sent while offline".
3. **Read receipts** — 2-state, WhatsApp-style: `✓` sent, `✓✓` read.
4. **Typing indicator** — "X is typing…" inside a conversation thread.
**No rewrite.** We build on the existing React components, hooks, emulator messenger classes, and renderer protocol. We reuse existing structures wherever they exist and add packets only where unavoidable.
## Non-goals (explicitly out of scope this round)
- Read-receipt privacy toggle (receipts always on).
- "Last seen / online status" text and in-conversation message search.
- 3-state receipts (no separate "delivered" step).
- Per-message IDs (we use a last-read-timestamp model instead — see §Read receipts).
- Any CMS / Prisma change.
- Group chats, bots, and StaffChat are excluded from receipts and typing.
## Current state (verified)
- **Client (Nitro-V3):** friends + messenger are already React/TSX under `src/components/friends/**`, driven by `useFriends` / `useFriendsState` / `useFriendsActions` / `useMessenger`. `MessengerFriend.categoryId` and `MessengerSettings.categories` exist in the data model but there is **no group UI**. No receipts, no typing, no offline UI.
- **Renderer (Nitro_Render_V3):** `MessengerInitParser` exposes `categories: FriendCategoryData[]`; `FriendParser` carries `categoryId`. `NewConsoleMessageParser` exposes `senderId, messageText, secondsSinceSent, extraData`. **No** category-management composers, **no** receipt/typing/messageId for the messenger. Typing exists only for *room* chat.
- **Emulator (Arcturus):** `Messenger`, `MessengerBuddy`, `Message`, `MessengerCategory` exist. `MessengerInitComposer` sends categories; `FriendsComposer` serializes `categoryId`. **No** category create/rename/delete/assign handlers and **no DB setter** for category. Instant messages are fire-and-forget (delivered only if recipient online, else dropped). `messenger_offline` table exists but is **never read/written**. No receipts, no messenger typing.
- **Build integration:** `Nitro-V3/vite.config.mjs` aliases `@nitrots/nitro-renderer` directly to local `../Nitro_Render_V3/index.ts` **source**. New renderer code is picked up live by the client dev server — no separate renderer build/publish step required.
## Protocol strategy
- **Friend-category packets:** reuse the **official Habbo header IDs** for the revision the client connects with, where the official client shipped that op. If an op never existed officially, use a free custom ID. *(Planning task: confirm the connecting revision and pull the official IDs; fall back to custom per-op.)*
- **Read receipts & typing:** never existed in the official messenger → **custom** header IDs.
- **Offline messages:** **no new packets** — replayed through the existing `FriendChatMessageComposer`.
- Header IDs are a contract: every new packet needs a constant in Arcturus `Incoming.java`/`Outgoing.java` **and** an identical-ID parser/event or composer in the renderer. The spec's §"Packet table" is the single source of truth; keep both sides in lockstep.
## Data model (owned by Arcturus; Prisma/CMS untouched)
| Table | State | Change |
|---|---|---|
| `messenger_categories(id, user_id, name)` | exists, unwritten | Add create/rename/delete persistence. Cap **20 groups/user**, name ≤ 25 chars (column limit). |
| `messenger_friendships.category` | exists, no setter | Add setter + `UPDATE` to assign a friend to a group. Deleting a group resets members to `0`. |
| `messenger_offline(id, user_id, user_from_id, message, sended_on)` | exists, unused | `INSERT` on send-to-offline; `SELECT`+`DELETE` on recipient login. Cap per-user inbox (default **200**, configurable). |
| `messenger_read_state(reader_id, peer_id, last_read)` PK(reader_id, peer_id) | **new** | Drives read receipts via last-read timestamp per conversation. |
## Feature designs
### 1. Friend groups (CRUD + assign)
**Server (Arcturus):**
- New incoming handlers in `messages/incoming/friends/`, registered in `PacketManager.registerFriends()`:
`AddFriendCategoryEvent(name)`, `RenameFriendCategoryEvent(id, name)`, `RemoveFriendCategoryEvent(id)`, `MoveFriendToCategoryEvent(friendId, categoryId)`.
- Persistence added to `Messenger` / `MessengerCategory`; add `MessengerBuddy.setCategoryId()` + DB `UPDATE`.
- Responses reuse existing composers: `MessengerInitComposer` (refreshed categories list) and `UpdateFriendComposer` (moved friend's new `categoryId`).
- Limits enforced server-side (≤20 groups, name length, dedupe). Delete → members → category `0`.
**Renderer (Nitro_Render_V3):** new outgoing composers `AddFriendCategoryComposer`, `RenameFriendCategoryComposer`, `RemoveFriendCategoryComposer`, `MoveFriendToCategoryComposer` with the official/fallback header IDs. (Categories arrive via existing `MessengerInitParser`; add a small `FriendCategoriesEvent` only if a standalone refresh is needed.)
**Client (Nitro-V3):**
- `useFriendsState` exposes `categories`; `useFriendsActions` adds `addCategory / renameCategory / removeCategory / moveFriendToCategory` wired to the composers.
- **Layout decision:** Online/Offline remains the **primary** view. A **chip-filter row** at the top of `FriendsListView` (one chip per group, like the navigator filter chips) filters the list to a single group. Groups *filter*, they do not restructure the Online/Offline sections.
- **Group management UI:** an "manage groups" affordance in `FriendsListView` (add / rename / delete) and a per-friend assignment control (dropdown / context action) in `FriendsListGroupItemView`.
### 2. Offline messages
**Server:** in `FriendPrivateMessageEvent`, if the recipient is offline → `INSERT` into `messenger_offline` (respect inbox cap; drop oldest when full). On recipient login, after the friend list is sent (`RequestInitFriendsEvent`), replay each stored row as `FriendChatMessageComposer(fromId, message, secondsSinceSent = now - sended_on, extraData = "offline")`, then `DELETE` the delivered rows.
**Renderer:** no change — `NewConsoleMessageParser` already exposes `extraData`.
**Client:** when `extraData === "offline"`, tag the message in the thread with a subtle "📨 inviato mentre eri offline" marker (`MessengerThreadChat.offlineDelivered = true`). Sender side: the message already shows `✓` (it left the client and was stored); it flips to `✓✓` when the recipient reads it after login (via the read-receipt catch-up batch, §3).
### 3. Read receipts (2-state ✓ / ✓✓)
**Model:** per-conversation **last-read timestamp** (no per-message IDs). `✓✓` applies to every own message in the thread with `date ≤ T`.
**Packets (custom):**
- Incoming `MarkConsoleRead(peerId)` — "I've read everything from `peerId` up to now."
- Outgoing `ConsoleReadReceipt(readerId, timestamp)` — "`readerId` has read up to `timestamp`."
**Server:** on `MarkConsoleRead` → upsert `read_state(me, peer, now)`; if peer online, send them `ConsoleReadReceipt(myId, now)`. On login, send a batch of `ConsoleReadReceipt` (one per conversation with a stored read_state) so an offline-sender catches up. 1:1 only.
**Client:**
- `MessengerThreadChat` gains `status: 'SENT' | 'READ'`.
- On send → `SENT` (`✓`). On thread focus/open → send `MarkConsoleRead(peerId)`.
- On `ConsoleReadReceipt(readerId, T)` → mark all own messages to `readerId` with `date ≤ T` as `READ` (`✓✓`).
- Render `✓` / `✓✓` on own messages in `FriendsMessengerThreadGroup`.
### 4. Typing indicator
**Packets (custom, ephemeral, never stored):**
- Incoming `ConsoleTyping(peerId, isTyping)`.
- Outgoing `FriendTyping(senderId, isTyping)`.
**Server:** relay to peer if online; light cooldown to prevent flooding. 1:1 only.
**Client:** debounce the message input → `ConsoleTyping(start)` while typing, `ConsoleTyping(stop)` on idle/blur/send. On `FriendTyping(isTyping)` show a "X sta scrivendo…" row in the thread with an auto-timeout fallback.
## Edge cases
- Group deleted → members fall back to uncategorized (`0`).
- Offline inbox full → drop oldest (configurable; alternative reject documented).
- Typing & receipts: 1:1 only — never StaffChat, group chat, or bots.
- Receipts always on (no privacy toggle this round).
- Renderer and emulator header IDs must stay in lockstep (this spec is the source of truth).
- Self-messages / messages to non-friends rejected as today.
## Testing
- **Renderer (Vitest, currently 138):** one test per new parser/composer — header init + field read/write order.
- **Client (Vitest, currently 214):** `useMessenger` status transitions (SENT→READ, offline tag), category actions/reducer, rendering of `✓✓` / typing row / offline marker.
- **Emulator:** manual two-session integration — offline send→login replay, receipt round-trip (online + offline catch-up), typing relay, group CRUD + assignment + delete-fallback. (Arcturus has limited unit-test infra.)
## Build sequencing (one spec, phased plan — each phase independently shippable)
1. **Friend groups** — most self-contained (CRUD + chip-filter UI + assignment).
2. **Offline messages** — server + DB, no new packets, small client marker.
3. **Read receipts** — packets across all three components + new table.
4. **Typing indicator** — packets, smallest.
## Open items for planning
- Confirm the client's connecting revision and source the official friend-category header IDs (custom fallback per-op).
- Decide the feature branch base in each repo (current branches are mid-`mentions-system` work — do **not** build on top of those).