From 7a2dac87597b314b063f28d2eeee6ce0d3a98775 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 20:52:37 +0200 Subject: [PATCH] =?UTF-8?q?docs(messenger):=20Phase=204=20implementation?= =?UTF-8?q?=20plan=20=E2=80=94=20typing=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ephemeral packets: ConsoleTyping (client->server, 4087) relayed by the emulator to the peer as FriendTyping (server->client, 4088). Client sends typing-start once per burst + stop on idle/send/empty; shows "X is typing..." with a 6s auto-expire. 1:1 only, no DB. --- ...06-02-messenger-phase4-typing-indicator.md | 533 ++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-messenger-phase4-typing-indicator.md diff --git a/docs/superpowers/plans/2026-06-02-messenger-phase4-typing-indicator.md b/docs/superpowers/plans/2026-06-02-messenger-phase4-typing-indicator.md new file mode 100644 index 0000000..e1e40e0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-messenger-phase4-typing-indicator.md @@ -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> +{ + private _data: ConstructorParameters; + + 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([]); + const typingTimersRef = useRef>>(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, 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(false); + const typingTimeoutRef = useRef>(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) && +
+ { LocalizeText('messenger.typing', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } +
} +``` + +- [ ] **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 1–4). Do NOT push/merge automatically; the branch carries all four phases + the user's own parallel commits.