From 98d75a5c960d6a4f0364264af6bc9b84401a9462 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 17:02:46 +0200 Subject: [PATCH 01/61] docs(messenger): design spec for groups, offline messages, read receipts, typing Brainstormed design for extending the existing React friends/messenger: - full custom friend groups (CRUD + assign) with Online/Offline primary view + group chip filter - offline messages stored in messenger_offline, replayed on login (no new packets) - 2-state read receipts (sent/read) via per-conversation last-read timestamp - typing indicator (ephemeral packets) Cross-component: Nitro-V3 client + Nitro_Render_V3 renderer + Arcturus emulator. Reuse official Habbo header IDs for category packets; custom IDs for receipts/typing. --- ...essenger-groups-offline-receipts-design.md | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-messenger-groups-offline-receipts-design.md diff --git a/docs/superpowers/specs/2026-06-02-messenger-groups-offline-receipts-design.md b/docs/superpowers/specs/2026-06-02-messenger-groups-offline-receipts-design.md new file mode 100644 index 0000000..b6938dd --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-messenger-groups-offline-receipts-design.md @@ -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). From 76b7e21494321d557c51fa6f674255669030a2d0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 17:17:29 +0200 Subject: [PATCH 02/61] =?UTF-8?q?docs(messenger):=20Phase=201=20implementa?= =?UTF-8?q?tion=20plan=20=E2=80=94=20friend=20groups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bite-sized TDD plan across all three codebases for the friend-groups feature: 4 client->server category packets (renderer composers + Arcturus handlers), DB persistence (reusing existing messenger_categories + messenger_friendships.category), server- authoritative re-push via existing MessengerInit/UpdateFriend composers, and client store actions + chip-filter UI + manager modal + per-friend assign control + pure filter helper with tests. --- ...26-06-02-messenger-phase1-friend-groups.md | 1213 +++++++++++++++++ 1 file changed, 1213 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-messenger-phase1-friend-groups.md diff --git a/docs/superpowers/plans/2026-06-02-messenger-phase1-friend-groups.md b/docs/superpowers/plans/2026-06-02-messenger-phase1-friend-groups.md new file mode 100644 index 0000000..98a7ca9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-messenger-phase1-friend-groups.md @@ -0,0 +1,1213 @@ +# Messenger Phase 1 — Friend Groups 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:** Add full custom friend groups (create / rename / delete / assign) to the existing React friends list, with an Online/Offline-primary view plus a per-group chip filter. + +**Architecture:** Four new client→server packets (renderer composers + Arcturus handlers) drive category CRUD + friend assignment. The server persists to the existing `messenger_categories` table and the `messenger_friendships.category` column, then re-pushes authoritative state through the **existing** `MessengerInitComposer` (category list) and `UpdateFriendComposer` (a friend's new `categoryId`). The client already receives `categories` via `MessengerInitEvent` and `categoryId` per friend — we add CRUD actions + UI and a pure group-filter helper. + +**Tech Stack:** Arcturus (Java 21/Maven/HikariCP), Nitro_Render_V3 (TypeScript, yarn workspaces, Vitest), Nitro-V3 (React 19, Vite, Vitest). + +--- + +## Cross-codebase header-ID contract + +Renderer **Outgoing** header N == Arcturus **Incoming** header N (verified across 8 existing friend packets, e.g. `SET_RELATIONSHIP_STATUS = 3768 == ChangeRelationEvent = 3768`). Phase 1 reuses existing server→client headers, so it needs **4 new client→server header IDs only**, used identically in `OutgoingHeader.ts` (renderer) and `Incoming.java` (Arcturus): + +| Logical packet | Renderer composer | Arcturus handler | Constant name (both sides) | +|---|---|---|---| +| Add category | `AddFriendCategoryComposer(name)` | `AddFriendCategoryEvent` | `ADD_FRIEND_CATEGORY` / `AddFriendCategoryEvent` | +| Rename category | `RenameFriendCategoryComposer(categoryId, name)` | `RenameFriendCategoryEvent` | `RENAME_FRIEND_CATEGORY` / `RenameFriendCategoryEvent` | +| Remove category | `RemoveFriendCategoryComposer(categoryId)` | `RemoveFriendCategoryEvent` | `REMOVE_FRIEND_CATEGORY` / `RemoveFriendCategoryEvent` | +| Assign friend → category | `MoveFriendToCategoryComposer(friendId, categoryId)` | `MoveFriendToCategoryEvent` | `MOVE_FRIEND_TO_CATEGORY` / `MoveFriendToCategoryEvent` | + +The concrete numbers are chosen and verified in **Task 1**. + +## File map + +**Arcturus (`Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/`):** +- Modify `messages/incoming/Incoming.java` — 4 new constants +- Modify `messages/PacketManager.java` — 4 `registerHandler` lines +- Modify `habbohotel/messenger/MessengerBuddy.java` — `setCategoryId(int)` +- Modify `habbohotel/users/HabboInfo.java` — `renameMessengerCategory(int, String)` +- Create `messages/incoming/friends/AddFriendCategoryEvent.java` +- Create `messages/incoming/friends/RenameFriendCategoryEvent.java` +- Create `messages/incoming/friends/RemoveFriendCategoryEvent.java` +- Create `messages/incoming/friends/MoveFriendToCategoryEvent.java` + +**Renderer (`Nitro_Render_V3/packages/communication/src/`):** +- Modify `messages/outgoing/OutgoingHeader.ts` — 4 constants +- Modify `NitroMessages.ts` — imports + 4 `_composers.set` +- Modify `messages/outgoing/friendlist/index.ts` — 4 exports +- Create `messages/outgoing/friendlist/AddFriendCategoryComposer.ts` +- Create `messages/outgoing/friendlist/RenameFriendCategoryComposer.ts` +- Create `messages/outgoing/friendlist/RemoveFriendCategoryComposer.ts` +- Create `messages/outgoing/friendlist/MoveFriendToCategoryComposer.ts` +- Create `messages/outgoing/friendlist/__tests__/FriendCategoryComposers.test.ts` + +**Client (`Nitro-V3/src/`):** +- Create `api/friends/friendCategory.helpers.ts` + `.test.ts` +- Modify `api/friends/index.ts` — export helper +- Modify `hooks/friends/useFriends.ts` — 4 actions + composer imports +- Modify `components/friends/views/friends-list/FriendsListView.tsx` — chip row + filter +- Create `components/friends/views/friends-list/FriendsListGroupChipsView.tsx` — chip filter row +- Create `components/friends/views/friends-list/FriendsCategoryManagerView.tsx` — create/rename/delete modal +- Modify `components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx` — assign control +- Modify `css/friends/FriendsView.css` — chip + manager + assign styles + +--- + +## Task 1: Allocate & verify the 4 category header IDs + +**Files:** none yet (decision + verification only). + +- [ ] **Step 1: Try official IDs, then pick verified-free fallbacks** + +The user prefers official Habbo revision IDs for category packets. First check whether the connecting revision shipped friend-category management packets: + +Run (renderer): `grep -rin "categor" Nitro_Render_V3/packages/communication/src/messages/outgoing/OutgoingHeader.ts` +Expected: only `MESSENGER_*` friend headers, **no** add/rename/remove/move-category constant. + +If no official category constants are found (expected — the bundled revision's `OutgoingHeader.ts` has none), use the custom fallback quartet **4081, 4082, 4083, 4084** and verify they are free on BOTH sides. + +- [ ] **Step 2: Verify the four numbers are unused on both sides** + +Run: +``` +grep -rnE "= ?408[1-4]\b" Nitro_Render_V3/packages/communication/src/messages/outgoing/OutgoingHeader.ts +grep -rnE "= ?408[1-4]\b" Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java +``` +Expected: **no output** from either command (the IDs are free). + +If either prints a match, increment the base (try 4085–4088, etc.) and re-run until both commands return nothing. Record the final quartet here before continuing: + +``` +ADD_FRIEND_CATEGORY = 4081 +RENAME_FRIEND_CATEGORY = 4082 +REMOVE_FRIEND_CATEGORY = 4083 +MOVE_FRIEND_TO_CATEGORY = 4084 +``` + +All later tasks reference the **constant names**, so only Tasks 2 and 4 (the constant definitions) carry the raw numbers. If you changed the numbers above, use your values in Tasks 2 and 4. + +- [ ] **Step 3: No commit** (decision task — nothing changed on disk yet). + +--- + +## Task 2: Renderer — 4 outgoing composers + registration + test + +**Files:** +- Create: `Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/AddFriendCategoryComposer.ts` +- Create: `Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/RenameFriendCategoryComposer.ts` +- Create: `Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/RemoveFriendCategoryComposer.ts` +- Create: `Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/MoveFriendToCategoryComposer.ts` +- Modify: `Nitro_Render_V3/packages/communication/src/messages/outgoing/OutgoingHeader.ts` +- Modify: `Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/index.ts` +- Modify: `Nitro_Render_V3/packages/communication/src/NitroMessages.ts` +- Test: `Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/__tests__/FriendCategoryComposers.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `__tests__/FriendCategoryComposers.test.ts`: +```typescript +import { describe, expect, it } from 'vitest'; +import { AddFriendCategoryComposer } from '../AddFriendCategoryComposer'; +import { RenameFriendCategoryComposer } from '../RenameFriendCategoryComposer'; +import { RemoveFriendCategoryComposer } from '../RemoveFriendCategoryComposer'; +import { MoveFriendToCategoryComposer } from '../MoveFriendToCategoryComposer'; + +describe('friend category composers', () => +{ + it('AddFriendCategoryComposer carries the name', () => + { + expect(new AddFriendCategoryComposer('Best friends').getMessageArray()).toEqual([ 'Best friends' ]); + }); + + it('RenameFriendCategoryComposer carries id + name', () => + { + expect(new RenameFriendCategoryComposer(5, 'Staff').getMessageArray()).toEqual([ 5, 'Staff' ]); + }); + + it('RemoveFriendCategoryComposer carries the id', () => + { + expect(new RemoveFriendCategoryComposer(7).getMessageArray()).toEqual([ 7 ]); + }); + + it('MoveFriendToCategoryComposer carries friendId + categoryId', () => + { + expect(new MoveFriendToCategoryComposer(42, 3).getMessageArray()).toEqual([ 42, 3 ]); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `cd Nitro_Render_V3 && yarn test --run packages/communication/src/messages/outgoing/friendlist/__tests__/FriendCategoryComposers.test.ts` +Expected: FAIL — cannot find module `../AddFriendCategoryComposer` (files not created yet). + +- [ ] **Step 3: Create the four composers** + +`AddFriendCategoryComposer.ts`: +```typescript +import { IMessageComposer } from '@nitrots/api'; + +export class AddFriendCategoryComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor(name: string) + { + this._data = [ name ]; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} +``` + +`RenameFriendCategoryComposer.ts`: +```typescript +import { IMessageComposer } from '@nitrots/api'; + +export class RenameFriendCategoryComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor(categoryId: number, name: string) + { + this._data = [ categoryId, name ]; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} +``` + +`RemoveFriendCategoryComposer.ts`: +```typescript +import { IMessageComposer } from '@nitrots/api'; + +export class RemoveFriendCategoryComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor(categoryId: number) + { + this._data = [ categoryId ]; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} +``` + +`MoveFriendToCategoryComposer.ts`: +```typescript +import { IMessageComposer } from '@nitrots/api'; + +export class MoveFriendToCategoryComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor(friendId: number, categoryId: number) + { + this._data = [ friendId, categoryId ]; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +Run: `cd Nitro_Render_V3 && yarn test --run packages/communication/src/messages/outgoing/friendlist/__tests__/FriendCategoryComposers.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Add the four header constants** + +In `OutgoingHeader.ts`, next to the other friend headers (after `public static FRIEND_LIST_UPDATE = 1419;`), add (use your Task 1 numbers): +```typescript +public static ADD_FRIEND_CATEGORY = 4081; +public static RENAME_FRIEND_CATEGORY = 4082; +public static REMOVE_FRIEND_CATEGORY = 4083; +public static MOVE_FRIEND_TO_CATEGORY = 4084; +``` + +- [ ] **Step 6: Export the composers from the friendlist barrel** + +In `messages/outgoing/friendlist/index.ts`, add: +```typescript +export * from './AddFriendCategoryComposer'; +export * from './MoveFriendToCategoryComposer'; +export * from './RemoveFriendCategoryComposer'; +export * from './RenameFriendCategoryComposer'; +``` + +- [ ] **Step 7: Register the composers in NitroMessages** + +First find how friendlist composers are imported in `NitroMessages.ts`: +Run: `grep -n "SendMessageComposer" Nitro_Render_V3/packages/communication/src/NitroMessages.ts` +This shows both the import line and the `_composers.set(...)` line. + +Add the four classes to that same import statement (the one that imports `SendMessageComposer`, `SetRelationshipStatusComposer`, etc.). Then, next to `this._composers.set(OutgoingHeader.SET_RELATIONSHIP_STATUS, SetRelationshipStatusComposer);`, add: +```typescript +this._composers.set(OutgoingHeader.ADD_FRIEND_CATEGORY, AddFriendCategoryComposer); +this._composers.set(OutgoingHeader.RENAME_FRIEND_CATEGORY, RenameFriendCategoryComposer); +this._composers.set(OutgoingHeader.REMOVE_FRIEND_CATEGORY, RemoveFriendCategoryComposer); +this._composers.set(OutgoingHeader.MOVE_FRIEND_TO_CATEGORY, MoveFriendToCategoryComposer); +``` + +- [ ] **Step 8: Type-check + full test run** + +Run: `cd Nitro_Render_V3 && yarn compile:fast && yarn test --run` +Expected: compile clean; all tests pass (138 prior + 4 new = 142). + +- [ ] **Step 9: Commit** + +```bash +cd Nitro_Render_V3 +git add packages/communication/src/messages/outgoing/friendlist/ packages/communication/src/messages/outgoing/OutgoingHeader.ts packages/communication/src/NitroMessages.ts +git commit -m "feat(messenger): add friend-category client composers (add/rename/remove/move)" +``` + +--- + +## Task 3: Emulator — category persistence helpers + +**Files:** +- Modify: `Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/MessengerBuddy.java` +- Modify: `Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java` + +> The emulator has **no unit-test infrastructure** (confirmed: no `src/test`, no JUnit in `pom.xml`). Verification for Tasks 3–4 is a successful `mvn package` + the manual integration checklist in Task 11. + +- [ ] **Step 1: Add `setCategoryId` to MessengerBuddy** + +`MessengerBuddy` already has `private int categoryId = 0;`, `private int userOne = 0;` (the owner's id), `this.id` (the friend's id), and `getCategoryId()`. Mirror the existing `setRelation`/`run()` DB idiom. Add after `getCategoryId()` (around line 141): +```java + public void setCategoryId(int categoryId) { + this.categoryId = categoryId; + + final int cat = categoryId; + final int owner = this.userOne; + final int friend = this.id; + + Emulator.getThreading().run(() -> { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("UPDATE messenger_friendships SET category = ? WHERE user_one_id = ? AND user_two_id = ?")) { + statement.setInt(1, cat); + statement.setInt(2, owner); + statement.setInt(3, friend); + statement.execute(); + } catch (SQLException e) { + LOGGER.error("Caught SQL exception", e); + } + }); + } +``` +(`Connection`, `PreparedStatement`, `SQLException`, `Emulator`, and `LOGGER` are already imported in this file.) + +- [ ] **Step 2: Add `renameMessengerCategory` to HabboInfo** + +`HabboInfo` already has `loadMessengerCategories()`, `addMessengerCategory(MessengerCategory)` (INSERT + sets generated id), `deleteMessengerCategory(MessengerCategory)` (removes + DELETE via `SqlQueries.update`), and `getMessengerCategories()`. Add a rename helper after `deleteMessengerCategory` (around line 238), reusing the same `SqlQueries.update` idiom: +```java + public void renameMessengerCategory(int categoryId, String name) { + for (MessengerCategory category : this.messengerCategories) { + if (category.getId() == categoryId) { + category.setName(name); + break; + } + } + + try { + SqlQueries.update("UPDATE messenger_categories SET name = ? WHERE id = ? AND user_id = ?", name, categoryId, this.id); + } catch (SqlQueries.DataAccessException e) { + LOGGER.error("Caught SQL exception", e); + } + } +``` +(`SqlQueries`, `MessengerCategory`, and `LOGGER` are already imported/available in this file.) + +- [ ] **Step 3: Compile** + +Run: `cd Arcturus-Morningstar-Extended/Emulator && mvn -q compile` +Expected: BUILD SUCCESS (no compile errors). + +- [ ] **Step 4: Commit** + +```bash +cd Arcturus-Morningstar-Extended +git add Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/MessengerBuddy.java Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java +git commit -m "feat(messenger): persist friend category assignment + category rename" +``` + +--- + +## Task 4: Emulator — 4 incoming handlers + registration + +**Files:** +- Create: `Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/AddFriendCategoryEvent.java` +- Create: `.../friends/RenameFriendCategoryEvent.java` +- Create: `.../friends/RemoveFriendCategoryEvent.java` +- Create: `.../friends/MoveFriendToCategoryEvent.java` +- Modify: `.../messages/incoming/Incoming.java` +- Modify: `.../messages/PacketManager.java` + +- [ ] **Step 1: Add the 4 header constants to Incoming.java** + +Next to the other friend constants (e.g. after `public static final int InviteFriendsEvent = 1276;`), add (use your Task 1 numbers — must match the renderer): +```java + public static final int AddFriendCategoryEvent = 4081; + public static final int RenameFriendCategoryEvent = 4082; + public static final int RemoveFriendCategoryEvent = 4083; + public static final int MoveFriendToCategoryEvent = 4084; +``` + +- [ ] **Step 2: Create AddFriendCategoryEvent** + +Caps: ≤ 20 categories/user, name 1–25 chars, case-insensitive de-dupe. Persists via the existing `HabboInfo.addMessengerCategory` (which sets the generated id), then re-pushes the category list with the existing `MessengerInitComposer`. +```java +package com.eu.habbo.messages.incoming.friends; + +import com.eu.habbo.habbohotel.messenger.MessengerCategory; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.friends.MessengerInitComposer; + +public class AddFriendCategoryEvent extends MessageHandler { + @Override + public void handle() throws Exception { + String name = this.packet.readString(); + Habbo habbo = this.client.getHabbo(); + + if (habbo == null || name == null) return; + + name = name.trim(); + if (name.isEmpty() || name.length() > 25) return; + if (habbo.getHabboInfo().getMessengerCategories().size() >= 20) return; + + for (MessengerCategory existing : habbo.getHabboInfo().getMessengerCategories()) { + if (existing.getName().equalsIgnoreCase(name)) return; + } + + MessengerCategory category = new MessengerCategory(name, habbo.getHabboInfo().getId(), 0); + habbo.getHabboInfo().addMessengerCategory(category); + + this.client.sendResponse(new MessengerInitComposer(habbo)); + } +} +``` + +- [ ] **Step 3: Create RenameFriendCategoryEvent** + +```java +package com.eu.habbo.messages.incoming.friends; + +import com.eu.habbo.habbohotel.messenger.MessengerCategory; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.friends.MessengerInitComposer; + +public class RenameFriendCategoryEvent extends MessageHandler { + @Override + public void handle() throws Exception { + int categoryId = this.packet.readInt(); + String name = this.packet.readString(); + Habbo habbo = this.client.getHabbo(); + + if (habbo == null || name == null) return; + + name = name.trim(); + if (name.isEmpty() || name.length() > 25) return; + + boolean found = false; + for (MessengerCategory category : habbo.getHabboInfo().getMessengerCategories()) { + if (category.getId() == categoryId) { + found = true; + break; + } + } + if (!found) return; + + habbo.getHabboInfo().renameMessengerCategory(categoryId, name); + + this.client.sendResponse(new MessengerInitComposer(habbo)); + } +} +``` + +- [ ] **Step 4: Create RemoveFriendCategoryEvent** + +Deleting a group resets its members to category `0`, pushing each via the existing `UpdateFriendComposer`, then re-pushes the (now shorter) category list. +```java +package com.eu.habbo.messages.incoming.friends; + +import com.eu.habbo.habbohotel.messenger.MessengerBuddy; +import com.eu.habbo.habbohotel.messenger.MessengerCategory; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.friends.MessengerInitComposer; +import com.eu.habbo.messages.outgoing.friends.UpdateFriendComposer; + +public class RemoveFriendCategoryEvent extends MessageHandler { + @Override + public void handle() throws Exception { + int categoryId = this.packet.readInt(); + Habbo habbo = this.client.getHabbo(); + + if (habbo == null) return; + + MessengerCategory target = null; + for (MessengerCategory category : habbo.getHabboInfo().getMessengerCategories()) { + if (category.getId() == categoryId) { + target = category; + break; + } + } + if (target == null) return; + + habbo.getHabboInfo().deleteMessengerCategory(target); + + for (MessengerBuddy buddy : habbo.getMessenger().getFriends().values()) { + if (buddy.getCategoryId() == categoryId) { + buddy.setCategoryId(0); + this.client.sendResponse(new UpdateFriendComposer(habbo, buddy, 0)); + } + } + + this.client.sendResponse(new MessengerInitComposer(habbo)); + } +} +``` + +- [ ] **Step 5: Create MoveFriendToCategoryEvent** + +`categoryId == 0` means "uncategorized"; any other id must be an existing category. Persists via `MessengerBuddy.setCategoryId` and pushes the updated friend via `UpdateFriendComposer`. +```java +package com.eu.habbo.messages.incoming.friends; + +import com.eu.habbo.habbohotel.messenger.MessengerBuddy; +import com.eu.habbo.habbohotel.messenger.MessengerCategory; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.friends.UpdateFriendComposer; + +public class MoveFriendToCategoryEvent extends MessageHandler { + @Override + public void handle() throws Exception { + int friendId = this.packet.readInt(); + int categoryId = this.packet.readInt(); + Habbo habbo = this.client.getHabbo(); + + if (habbo == null) return; + + MessengerBuddy buddy = habbo.getMessenger().getFriends().get(friendId); + if (buddy == null) return; + + if (categoryId != 0) { + boolean exists = false; + for (MessengerCategory category : habbo.getHabboInfo().getMessengerCategories()) { + if (category.getId() == categoryId) { + exists = true; + break; + } + } + if (!exists) return; + } + + buddy.setCategoryId(categoryId); + this.client.sendResponse(new UpdateFriendComposer(habbo, buddy, 0)); + } +} +``` + +- [ ] **Step 6: Register the 4 handlers** + +In `PacketManager.java`, inside `registerFriends()`, add: +```java + this.registerHandler(Incoming.AddFriendCategoryEvent, AddFriendCategoryEvent.class); + this.registerHandler(Incoming.RenameFriendCategoryEvent, RenameFriendCategoryEvent.class); + this.registerHandler(Incoming.RemoveFriendCategoryEvent, RemoveFriendCategoryEvent.class); + this.registerHandler(Incoming.MoveFriendToCategoryEvent, MoveFriendToCategoryEvent.class); +``` +Then add the four imports near the other `com.eu.habbo.messages.incoming.friends.*` imports at the top of the file: +```java +import com.eu.habbo.messages.incoming.friends.AddFriendCategoryEvent; +import com.eu.habbo.messages.incoming.friends.RenameFriendCategoryEvent; +import com.eu.habbo.messages.incoming.friends.RemoveFriendCategoryEvent; +import com.eu.habbo.messages.incoming.friends.MoveFriendToCategoryEvent; +``` +(If `PacketManager.java` already uses a wildcard `import com.eu.habbo.messages.incoming.friends.*;`, skip the explicit imports — check with `grep -n "incoming.friends" PacketManager.java` first.) + +- [ ] **Step 7: Build the fat jar** + +Run: `cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests` +Expected: BUILD SUCCESS; jar produced under `target/`. A failure here most likely means a duplicate header (the `registerHandler` guard throws `Header already registered`) — return to Task 1 and pick different free IDs. + +- [ ] **Step 8: Commit** + +```bash +cd Arcturus-Morningstar-Extended +git add Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/ Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java +git commit -m "feat(messenger): friend-category CRUD + assign packet handlers" +``` + +--- + +## Task 5: Client — pure group-filter helper (TDD) + +**Files:** +- Create: `Nitro-V3/src/api/friends/friendCategory.helpers.ts` +- Test: `Nitro-V3/src/api/friends/friendCategory.helpers.test.ts` +- Modify: `Nitro-V3/src/api/friends/index.ts` + +- [ ] **Step 1: Write the failing test** + +`friendCategory.helpers.test.ts`: +```typescript +import { describe, expect, it } from 'vitest'; +import { MessengerFriend } from './MessengerFriend'; +import { countFriendsByCategory, filterFriendsByCategory } from './friendCategory.helpers'; + +const makeFriend = (id: number, categoryId: number): MessengerFriend => +{ + const friend = new MessengerFriend(); + friend.id = id; + friend.categoryId = categoryId; + return friend; +}; + +describe('filterFriendsByCategory', () => +{ + const friends = [ makeFriend(1, 0), makeFriend(2, 5), makeFriend(3, 5), makeFriend(4, 8) ]; + + it('returns all friends when categoryId is 0 (All)', () => + { + expect(filterFriendsByCategory(friends, 0)).toHaveLength(4); + }); + + it('returns only the friends in the given category', () => + { + expect(filterFriendsByCategory(friends, 5).map(f => f.id)).toEqual([ 2, 3 ]); + }); + + it('returns an empty array for a category with no members', () => + { + expect(filterFriendsByCategory(friends, 99)).toEqual([]); + }); + + it('is null-safe', () => + { + expect(filterFriendsByCategory(null, 5)).toEqual([]); + }); +}); + +describe('countFriendsByCategory', () => +{ + const friends = [ makeFriend(1, 0), makeFriend(2, 5), makeFriend(3, 5) ]; + + it('counts members per category id', () => + { + const counts = countFriendsByCategory(friends); + expect(counts.get(0)).toBe(1); + expect(counts.get(5)).toBe(2); + }); + + it('is null-safe', () => + { + expect(countFriendsByCategory(null).size).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `cd Nitro-V3 && yarn test --run src/api/friends/friendCategory.helpers.test.ts` +Expected: FAIL — cannot resolve `./friendCategory.helpers`. + +- [ ] **Step 3: Implement the helper** + +`friendCategory.helpers.ts`: +```typescript +import { MessengerFriend } from './MessengerFriend'; + +/** + * Filter a friend list to a single category. categoryId 0 means + * "All" (no filtering) and returns the list unchanged. + */ +export const filterFriendsByCategory = (friends: MessengerFriend[], categoryId: number): MessengerFriend[] => +{ + if(!friends) return []; + + if(!categoryId) return friends; + + return friends.filter(friend => (friend.categoryId === categoryId)); +}; + +/** + * Count how many friends belong to each category id. Used to render + * member counts on the group chips. + */ +export const countFriendsByCategory = (friends: MessengerFriend[]): Map => +{ + const counts = new Map(); + + if(!friends) return counts; + + for(const friend of friends) + { + counts.set(friend.categoryId, (counts.get(friend.categoryId) ?? 0) + 1); + } + + return counts; +}; +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +Run: `cd Nitro-V3 && yarn test --run src/api/friends/friendCategory.helpers.test.ts` +Expected: PASS (6 cases). + +- [ ] **Step 5: Export from the friends api barrel** + +In `src/api/friends/index.ts`, add: +```typescript +export * from './friendCategory.helpers'; +``` + +- [ ] **Step 6: Commit** + +```bash +cd Nitro-V3 +git add src/api/friends/friendCategory.helpers.ts src/api/friends/friendCategory.helpers.test.ts src/api/friends/index.ts +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(friends): pure category filter + count helpers" +``` + +--- + +## Task 6: Client — category CRUD + assign actions in the friends store + +**Files:** +- Modify: `Nitro-V3/src/hooks/friends/useFriends.ts` + +The store re-pushes authoritative state through the server (the existing `MessengerInitEvent` handler updates `settings.categories`; `FriendListUpdateEvent` updates each friend's `categoryId`), so these actions are thin send-wrappers — no optimistic local mutation. + +- [ ] **Step 1: Import the new composers** + +In the top import from `@nitrots/nitro-renderer` in `useFriends.ts`, add `AddFriendCategoryComposer`, `MoveFriendToCategoryComposer`, `RemoveFriendCategoryComposer`, `RenameFriendCategoryComposer` to the named-import list. + +- [ ] **Step 2: Add the four actions inside `useFriendsStore`** + +Add alongside `followFriend` / `updateRelationship` (around line 42), before the `return`: +```typescript + const addCategory = (name: string) => + { + const trimmed = (name ?? '').trim(); + + if(!trimmed.length || (trimmed.length > 25)) return; + + SendMessageComposer(new AddFriendCategoryComposer(trimmed)); + }; + + const renameCategory = (categoryId: number, name: string) => + { + const trimmed = (name ?? '').trim(); + + if(!categoryId || !trimmed.length || (trimmed.length > 25)) return; + + SendMessageComposer(new RenameFriendCategoryComposer(categoryId, trimmed)); + }; + + const removeCategory = (categoryId: number) => + { + if(!categoryId) return; + + SendMessageComposer(new RemoveFriendCategoryComposer(categoryId)); + }; + + const moveFriendToCategory = (friendId: number, categoryId: number) => + { + if(!friendId) return; + + SendMessageComposer(new MoveFriendToCategoryComposer(friendId, categoryId)); + }; +``` + +- [ ] **Step 3: Expose them from the store return** + +In `useFriendsStore`'s `return { ... }` add: `addCategory, renameCategory, removeCategory, moveFriendToCategory`. + +- [ ] **Step 4: Expose them via `useFriendsActions`** + +In the `useFriendsActions` destructure-from-`useBetween` AND its `return`, add the four names so consumers can pull them: +```typescript +export const useFriendsActions = () => +{ + const { + requestFriend, + requestResponse, + followFriend, + updateRelationship, + addCategory, + renameCategory, + removeCategory, + moveFriendToCategory + } = useBetween(useFriendsStore); + + return { + requestFriend, + requestResponse, + followFriend, + updateRelationship, + addCategory, + renameCategory, + removeCategory, + moveFriendToCategory + }; +}; +``` +(The deprecated `useFriends` shim returns the whole store, so it picks these up automatically.) + +- [ ] **Step 5: Type-check + full test run** + +Run: `cd Nitro-V3 && yarn typecheck && yarn test --run` +Expected: typecheck clean; all tests pass (no regressions). + +- [ ] **Step 6: Commit** + +```bash +cd Nitro-V3 +git add src/hooks/friends/useFriends.ts +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(friends): category CRUD + assign actions in friends store" +``` + +--- + +## Task 7: Client — group chip-filter row + +**Files:** +- Create: `Nitro-V3/src/components/friends/views/friends-list/FriendsListGroupChipsView.tsx` +- Modify: `Nitro-V3/src/components/friends/views/friends-list/FriendsListView.tsx` + +- [ ] **Step 1: Create the chip row component** + +It renders an "All" chip plus one chip per category (with member count), and a gear button to open the manager (wired in Task 8). It is a controlled component: parent owns `selectedCategoryId`. +```tsx +import { FC } from 'react'; +import { FriendCategoryData } from '@nitrots/nitro-renderer'; +import { countFriendsByCategory, LocalizeText, MessengerFriend } from '../../../../api'; +import { Flex } from '../../../../common'; + +interface FriendsListGroupChipsViewProps +{ + categories: FriendCategoryData[]; + friends: MessengerFriend[]; + selectedCategoryId: number; + setSelectedCategoryId: (id: number) => void; + onManageClick: () => void; +} + +export const FriendsListGroupChipsView: FC = props => +{ + const { categories = [], friends = [], selectedCategoryId = 0, setSelectedCategoryId = null, onManageClick = null } = props; + + const counts = countFriendsByCategory(friends); + + return ( + + +
setSelectedCategoryId(0) }> + { LocalizeText('friendlist.friends') } ({ friends.length }) +
+ { categories.map(category => ( +
setSelectedCategoryId(category.id) }> + { category.name } ({ counts.get(category.id) ?? 0 }) +
+ )) } +
+
+ ⚙ +
+
+ ); +}; +``` +(If `Flex` is not exported from `../../../../common`, check the import other friends views use — `FriendsListView.tsx` already imports `Flex`, `Button` from `../../../../common`; match that path.) + +- [ ] **Step 2: Wire the chip row + filtering into FriendsListView** + +In `FriendsListView.tsx`: +1. Add imports: +```tsx +import { useState } from 'react'; +import { filterFriendsByCategory } from '../../../../api'; +import { FriendsListGroupChipsView } from './FriendsListGroupChipsView'; +import { FriendsCategoryManagerView } from './FriendsCategoryManagerView'; +``` +(`useState` may already be imported — merge into the existing react import. `FriendsCategoryManagerView` is created in Task 8; this import is fine to add now and will resolve after Task 8.) + +2. Pull `settings` from the friends state hook used in this component (it already destructures from `useFriendsState`/`useFriends`). Ensure `settings` (and `friends` for the unfiltered counts) are in scope: +```tsx +const { onlineFriends, offlineFriends, requests, settings, friends } = useFriendsState(); +``` +(Adjust to match the existing hook call in this file — keep whatever names it already destructures and add `settings` + `friends`.) + +3. Add local UI state near the other `useState` calls: +```tsx +const [ selectedCategoryId, setSelectedCategoryId ] = useState(0); +const [ showCategoryManager, setShowCategoryManager ] = useState(false); + +const categories = settings?.categories ?? []; +const filteredOnlineFriends = filterFriendsByCategory(onlineFriends, selectedCategoryId); +const filteredOfflineFriends = filterFriendsByCategory(offlineFriends, selectedCategoryId); +``` + +4. Insert the chip row directly under `` (before the ``): +```tsx + setShowCategoryManager(true) } /> +``` + +5. Replace every reference to `onlineFriends` / `offlineFriends` that feeds the **rendered list and counts** with the filtered versions (six edits): + - online section header count: `(${ filteredOnlineFriends.length })` + - online `select_all` toolbar: map/every over `filteredOnlineFriends` + - online `` + - offline section header count: `(${ filteredOfflineFriends.length })` + - offline `select_all` toolbar: map/every over `filteredOfflineFriends` + - offline `` + + (Leave the unfiltered `friends` for the chip counts and `onlineFriends`/`offlineFriends` references that are NOT about the rendered sections, if any.) + +6. Render the manager modal at the end of the returned fragment (next to the existing `showRoomInvite` / `showRemoveFriendsConfirmation` blocks): +```tsx +{ showCategoryManager && + setShowCategoryManager(false) } /> } +``` + +- [ ] **Step 3: Type-check** + +Run: `cd Nitro-V3 && yarn typecheck` +Expected: clean (a TS2307 for `FriendsCategoryManagerView` is acceptable here only until Task 8 lands; if you implement Task 8 next there should be no errors). If you want a green checkpoint now, temporarily comment the manager import + render block, then restore in Task 8. + +- [ ] **Step 4: Commit** + +```bash +cd Nitro-V3 +git add src/components/friends/views/friends-list/FriendsListGroupChipsView.tsx src/components/friends/views/friends-list/FriendsListView.tsx +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(friends): group chip-filter row over online/offline list" +``` + +--- + +## Task 8: Client — category manager (create / rename / delete) + +**Files:** +- Create: `Nitro-V3/src/components/friends/views/friends-list/FriendsCategoryManagerView.tsx` + +- [ ] **Step 1: Create the manager modal** + +A small `NitroCardView` with an add-input and a list of categories, each with inline rename + delete. Uses `useFriendsActions`. +```tsx +import { FC, useState } from 'react'; +import { FriendCategoryData } from '@nitrots/nitro-renderer'; +import { LocalizeText } from '../../../../api'; +import { Button, Column, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; +import { useFriendsActions } from '../../../../hooks'; + +interface FriendsCategoryManagerViewProps +{ + categories: FriendCategoryData[]; + onCloseClick: () => void; +} + +export const FriendsCategoryManagerView: FC = props => +{ + const { categories = [], onCloseClick = null } = props; + const { addCategory, renameCategory, removeCategory } = useFriendsActions(); + const [ newName, setNewName ] = useState(''); + const [ editingId, setEditingId ] = useState(0); + const [ editingName, setEditingName ] = useState(''); + + const submitAdd = () => + { + const trimmed = newName.trim(); + + if(!trimmed.length) return; + + addCategory(trimmed); + setNewName(''); + }; + + const submitRename = () => + { + const trimmed = editingName.trim(); + + if(editingId && trimmed.length) renameCategory(editingId, trimmed); + + setEditingId(0); + setEditingName(''); + }; + + return ( + + + + + setNewName(event.target.value) } onKeyDown={ event => (event.key === 'Enter') && submitAdd() } /> + + + + { categories.map(category => ( + + { (editingId === category.id) ? + <> + setEditingName(event.target.value) } onKeyDown={ event => (event.key === 'Enter') && submitRename() } /> + + + : + <> + { category.name } +
{ setEditingId(category.id); setEditingName(category.name); } } /> +
removeCategory(category.id) } /> + } + + )) } + { !categories.length && + { LocalizeText('friendlist.friends.offlinecaption') } } + + + + ); +}; +``` + +> **Verify imports against the codebase.** `Column`, `Flex`, `Button`, and the `NitroCard*` components come from `../../../../common`; confirm the exact set with `grep -n "from '../../../../common'" src/components/friends/views/friends-list/FriendsListView.tsx`. The localization keys above (`generic.create` / `generic.save` / `generic.edit` / `generic.delete`) are placeholders for whatever your locale files already define — if a key renders raw, swap it for an existing one or add it to the locale JSON. The `icon-edit` / `icon-deselect` spritesheet classes: reuse whatever exists in `FriendsView.css`; if absent, use a plain text label ("✎" / "✕") instead. + +- [ ] **Step 2: Type-check + test** + +Run: `cd Nitro-V3 && yarn typecheck && yarn test --run` +Expected: clean; tests green. (If you commented the manager wiring in Task 7 Step 3, restore it now.) + +- [ ] **Step 3: Commit** + +```bash +cd Nitro-V3 +git add src/components/friends/views/friends-list/FriendsCategoryManagerView.tsx src/components/friends/views/friends-list/FriendsListView.tsx +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(friends): create/rename/delete group manager" +``` + +--- + +## Task 9: Client — per-friend "assign to group" control + +**Files:** +- Modify: `Nitro-V3/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx` + +- [ ] **Step 1: Add categories + action to the component** + +At the top of the component body, pull categories from state and the move action from actions: +```tsx +import { useFriendsActions, useFriendsState } from '../../../../../hooks'; +``` +(Match the existing relative depth — this file is one level deeper than `FriendsListView.tsx`, so it's `../../../../../hooks`; confirm with the file's existing imports.) + +Inside the component: +```tsx +const { settings } = useFriendsState(); +const { moveFriendToCategory } = useFriendsActions(); +const [ isGroupMenuOpen, setIsGroupMenuOpen ] = useState(false); +const categories = settings?.categories ?? []; +``` +(`useState` is likely already imported; if not, add it.) + +- [ ] **Step 2: Add the assign control to the actions row** + +In the `friends-list-actions` div (currently the follow / chat / relationship icons), add — only when the friend is a real user (`friend.id > 0`) and at least one category exists — a group icon that toggles a small menu: +```tsx +{ (friend.id > 0) && (categories.length > 0) && +
+
setIsGroupMenuOpen(prev => !prev) } /> + { isGroupMenuOpen && +
+
{ moveFriendToCategory(friend.id, 0); setIsGroupMenuOpen(false); } }> + { LocalizeText('friendlist.friends') } +
+ { categories.map(category => ( +
{ moveFriendToCategory(friend.id, category.id); setIsGroupMenuOpen(false); } }> + { category.name } +
+ )) } +
} +
} +``` +(Reuse an existing spritesheet class for `icon-group` if one fits, or fall back to a text glyph. The "uncategorized" entry uses `friend.categoryId === 0`.) + +- [ ] **Step 3: Type-check + test** + +Run: `cd Nitro-V3 && yarn typecheck && yarn test --run` +Expected: clean; tests green. + +- [ ] **Step 4: Commit** + +```bash +cd Nitro-V3 +git add src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(friends): per-friend assign-to-group control" +``` + +--- + +## Task 10: Client — styles for chips, manager, assign menu + +**Files:** +- Modify: `Nitro-V3/src/css/friends/FriendsView.css` + +- [ ] **Step 1: Append styles** + +Match the existing palette in this file (grays `#f3f3ef`/`#e6e6e6`, accent blue `#bfe7f6`, `#111` text). Append: +```css +.friends-group-chips { border-bottom: 1px solid #e6e6e6; } +.friends-group-chips-scroll { overflow-x: auto; flex-wrap: nowrap; } +.friends-group-chips-scroll::-webkit-scrollbar { height: 4px; } +.friends-group-chip { + flex: 0 0 auto; + padding: 1px 8px; + border: 1px solid #d0d0c8; + border-radius: 10px; + background: #f3f3ef; + font-size: 11px; + line-height: 16px; + white-space: nowrap; + cursor: pointer; + user-select: none; +} +.friends-group-chip.active { background: #bfe7f6; border-color: #7fb9d6; } +.friends-group-chip-manage { padding: 1px 6px; } + +.nitro-friends-category-manager { width: 280px; } + +.friends-list-group-assign { display: inline-flex; } +.friends-list-group-menu { + position: absolute; + right: 0; + top: 100%; + z-index: 20; + min-width: 120px; + max-height: 180px; + overflow-y: auto; + background: #fff; + border: 1px solid #c0c0b8; + border-radius: 4px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); +} +.friends-list-group-menu-item { + padding: 3px 8px; + font-size: 11px; + cursor: pointer; + white-space: nowrap; +} +.friends-list-group-menu-item:hover { background: #efefef; } +.friends-list-group-menu-item.active { background: #bfe7f6; } +``` + +- [ ] **Step 2: Visual sanity (build only)** + +Run: `cd Nitro-V3 && yarn typecheck` +Expected: clean (CSS isn't type-checked, but confirm nothing else broke). + +- [ ] **Step 3: Commit** + +```bash +cd Nitro-V3 +git add src/css/friends/FriendsView.css +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "style(friends): group chips, category manager, assign menu" +``` + +--- + +## Task 11: Integration verification (two live sessions) + +**Files:** none (manual verification + final checks). Fix-up commits only if something fails. + +- [ ] **Step 1: Apply the DB-side prerequisite check** + +The `messenger_categories` and `messenger_friendships.category` schema already exist (verified). Confirm against the running DB: +``` +SELECT COUNT(*) FROM messenger_categories; +SHOW COLUMNS FROM messenger_friendships LIKE 'category'; +``` +(Per house rules, if a destructive statement is ever needed, hand the user a one-liner to run under `E:\laragon\bin\mysql\...` — but nothing destructive is required here.) + +- [ ] **Step 2: Run the new emulator jar + client dev server** + +- Emulator: run the jar built in Task 4 (`java -jar Arcturus-Morningstar-Extended/Emulator/target/Habbo-*-jar-with-dependencies.jar`). +- Client: `cd Nitro-V3 && yarn start` (Vite picks up the renderer source live — no renderer build needed). + +- [ ] **Step 3: Manual test matrix (log in, open Friends)** + +Verify each: +1. **Create group:** open the manager (⚙), type a name, Create → a new chip appears (server re-pushed `MessengerInit`). +2. **Rename group:** edit a category name → chip label updates after round-trip. +3. **Delete group:** delete a category → chip disappears; any friend that was in it shows as uncategorized (its assign menu no longer marks that group active). +4. **Assign friend:** open a friend's group menu, pick a group → switch the chip filter to that group and confirm the friend appears there; switch to "All" and confirm they still appear once. +5. **Filter:** click each chip → only that group's friends show in both Online and Offline sections; counts in headers match; "All" shows everyone. +6. **Caps:** creating a 21st group is rejected (Create disabled at 20); a >25-char name is truncated by `maxLength`/server. +7. **Persistence:** relog → groups and assignments survive (DB-backed). +8. **No regressions:** follow, chat, relationship icons, requests, search, room-invite, remove-friend still work. + +- [ ] **Step 4: Final automated checks** + +Run: +``` +cd Nitro_Render_V3 && yarn compile:fast && yarn test --run +cd Nitro-V3 && yarn typecheck && yarn test --run && yarn eslint +cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests +``` +Expected: renderer tests green (142), client tests green (existing + 6 new helper cases), client typecheck + eslint clean, emulator BUILD SUCCESS. + +- [ ] **Step 5: Commit any fix-ups** + +```bash +# only if Step 3/4 required changes +cd Nitro-V3 +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -am "fix(friends): integration fixes for friend groups" +``` + +--- + +## Notes for the next phase + +- Phase 2 (offline messages) reuses the same `FriendChatMessageComposer` replay path — no new packets — and the `messenger_offline` table that already exists. +- Phases 3–4 (read receipts, typing) will add custom packets following the same renderer-composer/Arcturus-handler pattern proven here, plus a new server→client event+parser (mirror `NewConsoleMessageEvent`/`NewConsoleMessageParser`) which this phase did not need. +- Do NOT push automatically (the auto-push rule is scoped to the react19 branches only). Open PRs against the correct base per the duckietm `--base dev`/`Dev` convention when the user asks. From f9320b4582c48561570dae4ad2ecb17df29d5177 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 17:54:30 +0200 Subject: [PATCH 03/61] feat(friends): pure category filter + count helpers --- .../friends/friendCategory.helpers.test.ts | 53 +++++++++++++++++++ src/api/friends/friendCategory.helpers.ts | 32 +++++++++++ src/api/friends/index.ts | 1 + 3 files changed, 86 insertions(+) create mode 100644 src/api/friends/friendCategory.helpers.test.ts create mode 100644 src/api/friends/friendCategory.helpers.ts diff --git a/src/api/friends/friendCategory.helpers.test.ts b/src/api/friends/friendCategory.helpers.test.ts new file mode 100644 index 0000000..740769d --- /dev/null +++ b/src/api/friends/friendCategory.helpers.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { MessengerFriend } from './MessengerFriend'; +import { countFriendsByCategory, filterFriendsByCategory } from './friendCategory.helpers'; + +const makeFriend = (id: number, categoryId: number): MessengerFriend => +{ + const friend = new MessengerFriend(); + friend.id = id; + friend.categoryId = categoryId; + return friend; +}; + +describe('filterFriendsByCategory', () => +{ + const friends = [ makeFriend(1, 0), makeFriend(2, 5), makeFriend(3, 5), makeFriend(4, 8) ]; + + it('returns all friends when categoryId is 0 (All)', () => + { + expect(filterFriendsByCategory(friends, 0)).toHaveLength(4); + }); + + it('returns only the friends in the given category', () => + { + expect(filterFriendsByCategory(friends, 5).map(f => f.id)).toEqual([ 2, 3 ]); + }); + + it('returns an empty array for a category with no members', () => + { + expect(filterFriendsByCategory(friends, 99)).toEqual([]); + }); + + it('is null-safe', () => + { + expect(filterFriendsByCategory(null, 5)).toEqual([]); + }); +}); + +describe('countFriendsByCategory', () => +{ + const friends = [ makeFriend(1, 0), makeFriend(2, 5), makeFriend(3, 5) ]; + + it('counts members per category id', () => + { + const counts = countFriendsByCategory(friends); + expect(counts.get(0)).toBe(1); + expect(counts.get(5)).toBe(2); + }); + + it('is null-safe', () => + { + expect(countFriendsByCategory(null).size).toBe(0); + }); +}); diff --git a/src/api/friends/friendCategory.helpers.ts b/src/api/friends/friendCategory.helpers.ts new file mode 100644 index 0000000..0c29e91 --- /dev/null +++ b/src/api/friends/friendCategory.helpers.ts @@ -0,0 +1,32 @@ +import { MessengerFriend } from './MessengerFriend'; + +/** + * Filter a friend list to a single category. categoryId 0 means + * "All" (no filtering) and returns the list unchanged. + */ +export const filterFriendsByCategory = (friends: MessengerFriend[], categoryId: number): MessengerFriend[] => +{ + if(!friends) return []; + + if(!categoryId) return friends; + + return friends.filter(friend => (friend.categoryId === categoryId)); +}; + +/** + * Count how many friends belong to each category id. Used to render + * member counts on the group chips. + */ +export const countFriendsByCategory = (friends: MessengerFriend[]): Map => +{ + const counts = new Map(); + + if(!friends) return counts; + + for(const friend of friends) + { + counts.set(friend.categoryId, (counts.get(friend.categoryId) ?? 0) + 1); + } + + return counts; +}; diff --git a/src/api/friends/index.ts b/src/api/friends/index.ts index ce1ed60..621bc50 100644 --- a/src/api/friends/index.ts +++ b/src/api/friends/index.ts @@ -1,3 +1,4 @@ +export * from './friendCategory.helpers'; export * from './GetGroupChatData'; export * from './IGroupChatData'; export * from './MessengerFriend'; From 5be38e7df3f9beb4c5c9f6c090c02d338ad865ab Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 17:59:13 +0200 Subject: [PATCH 04/61] feat(friends): category CRUD + assign actions in friends store --- src/hooks/friends/useFriends.ts | 48 ++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/src/hooks/friends/useFriends.ts b/src/hooks/friends/useFriends.ts index 4eee569..503fca4 100644 --- a/src/hooks/friends/useFriends.ts +++ b/src/hooks/friends/useFriends.ts @@ -1,4 +1,4 @@ -import { AcceptFriendMessageComposer, DeclineFriendMessageComposer, FollowFriendFailedEvent, FollowFriendMessageComposer, FriendListFragmentEvent, FriendListUpdateComposer, FriendListUpdateEvent, FriendParser, FriendRequestsEvent, GetFriendRequestsComposer, GetSessionDataManager, MessengerInitComposer, MessengerInitEvent, NewFriendRequestEvent, RequestFriendComposer, SetRelationshipStatusComposer } from '@nitrots/nitro-renderer'; +import { AcceptFriendMessageComposer, AddFriendCategoryComposer, DeclineFriendMessageComposer, FollowFriendFailedEvent, FollowFriendMessageComposer, FriendListFragmentEvent, FriendListUpdateComposer, FriendListUpdateEvent, FriendParser, FriendRequestsEvent, GetFriendRequestsComposer, GetSessionDataManager, MessengerInitComposer, MessengerInitEvent, MoveFriendToCategoryComposer, NewFriendRequestEvent, RemoveFriendCategoryComposer, RenameFriendCategoryComposer, RequestFriendComposer, SetRelationshipStatusComposer } from '@nitrots/nitro-renderer'; import { useEffect, useMemo, useState } from 'react'; import { useBetween } from 'use-between'; import { CloneObject, LocalizeText, MessengerFriend, MessengerRequest, MessengerSettings, NotificationAlertType, SendMessageComposer } from '../../api'; @@ -43,6 +43,38 @@ const useFriendsStore = () => const updateRelationship = (friend: MessengerFriend, type: number) => ((type !== friend.relationshipStatus) && SendMessageComposer(new SetRelationshipStatusComposer(friend.id, type))); + const addCategory = (name: string) => + { + const trimmed = (name ?? '').trim(); + + if(!trimmed.length || (trimmed.length > 25)) return; + + SendMessageComposer(new AddFriendCategoryComposer(trimmed)); + }; + + const renameCategory = (categoryId: number, name: string) => + { + const trimmed = (name ?? '').trim(); + + if(!categoryId || !trimmed.length || (trimmed.length > 25)) return; + + SendMessageComposer(new RenameFriendCategoryComposer(categoryId, trimmed)); + }; + + const removeCategory = (categoryId: number) => + { + if(!categoryId) return; + + SendMessageComposer(new RemoveFriendCategoryComposer(categoryId)); + }; + + const moveFriendToCategory = (friendId: number, categoryId: number) => + { + if(!friendId) return; + + SendMessageComposer(new MoveFriendToCategoryComposer(friendId, categoryId)); + }; + const getFriend = (userId: number) => { for(const friend of friends) @@ -259,7 +291,7 @@ const useFriendsStore = () => }; }, []); - return { friends, requests, sentRequests, dismissedRequestIds, setDismissedRequestIds, settings, onlineFriends, offlineFriends, getFriend, canRequestFriend, requestFriend, requestResponse, followFriend, updateRelationship }; + return { friends, requests, sentRequests, dismissedRequestIds, setDismissedRequestIds, settings, onlineFriends, offlineFriends, getFriend, canRequestFriend, requestFriend, requestResponse, followFriend, updateRelationship, addCategory, renameCategory, removeCategory, moveFriendToCategory }; }; /** @@ -312,14 +344,22 @@ export const useFriendsActions = () => requestFriend, requestResponse, followFriend, - updateRelationship + updateRelationship, + addCategory, + renameCategory, + removeCategory, + moveFriendToCategory } = useBetween(useFriendsStore); return { requestFriend, requestResponse, followFriend, - updateRelationship + updateRelationship, + addCategory, + renameCategory, + removeCategory, + moveFriendToCategory }; }; From 39bcfdb0cd4cd4bae5f8a1751947cccb4ec46440 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 18:07:40 +0200 Subject: [PATCH 05/61] feat(friends): category manager modal (create/rename/delete groups) --- .../FriendsCategoryManagerView.tsx | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/components/friends/views/friends-list/FriendsCategoryManagerView.tsx diff --git a/src/components/friends/views/friends-list/FriendsCategoryManagerView.tsx b/src/components/friends/views/friends-list/FriendsCategoryManagerView.tsx new file mode 100644 index 0000000..1dd77dd --- /dev/null +++ b/src/components/friends/views/friends-list/FriendsCategoryManagerView.tsx @@ -0,0 +1,96 @@ +import { FC, MouseEvent, useState } from 'react'; +import { FriendCategoryData } from '@nitrots/nitro-renderer'; +import { LocalizeText } from '../../../../api'; +import { Button, Column, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; +import { useFriendsActions } from '../../../../hooks'; + +interface FriendsCategoryManagerViewProps +{ + categories: FriendCategoryData[]; + onCloseClick: (event: MouseEvent) => void; +} + +export const FriendsCategoryManagerView: FC = props => +{ + const { categories = [], onCloseClick = null } = props; + const { addCategory, renameCategory, removeCategory } = useFriendsActions(); + const [ newName, setNewName ] = useState(''); + const [ editingId, setEditingId ] = useState(0); + const [ editingName, setEditingName ] = useState(''); + + const submitAdd = () => + { + const trimmed = newName.trim(); + if(!trimmed.length) return; + addCategory(trimmed); + setNewName(''); + }; + + const submitRename = () => + { + const trimmed = editingName.trim(); + if(editingId && trimmed.length) renameCategory(editingId, trimmed); + setEditingId(0); + setEditingName(''); + }; + + return ( + + + + + setNewName(event.target.value) } + onKeyDown={ event => (event.key === 'Enter') && submitAdd() } /> + + + + { categories.map(category => ( + + { (editingId === category.id) ? + <> + setEditingName(event.target.value) } + onKeyDown={ event => (event.key === 'Enter') && submitRename() } /> + + + : + <> + { category.name } + { setEditingId(category.id); setEditingName(category.name); } }> + { '✎' } + + removeCategory(category.id) }> + { '✕' } + + } + + )) } + { !categories.length && + + { LocalizeText('friendlist.search.nofriendsfound') } + } + + + + ); +}; From 7913952f718cb51ad5cc6e78a4fc72c02cbfe119 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 18:12:53 +0200 Subject: [PATCH 06/61] fix(friends): reset stale edit state on category refresh + localize manager labels where keys exist --- .../friends-list/FriendsCategoryManagerView.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/friends/views/friends-list/FriendsCategoryManagerView.tsx b/src/components/friends/views/friends-list/FriendsCategoryManagerView.tsx index 1dd77dd..3fa427e 100644 --- a/src/components/friends/views/friends-list/FriendsCategoryManagerView.tsx +++ b/src/components/friends/views/friends-list/FriendsCategoryManagerView.tsx @@ -1,4 +1,4 @@ -import { FC, MouseEvent, useState } from 'react'; +import { FC, MouseEvent, useEffect, useState } from 'react'; import { FriendCategoryData } from '@nitrots/nitro-renderer'; import { LocalizeText } from '../../../../api'; import { Button, Column, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; @@ -18,6 +18,15 @@ export const FriendsCategoryManagerView: FC = p const [ editingId, setEditingId ] = useState(0); const [ editingName, setEditingName ] = useState(''); + useEffect(() => + { + if(editingId && !categories.some(category => (category.id === editingId))) + { + setEditingId(0); + setEditingName(''); + } + }, [ categories, editingId ]); + const submitAdd = () => { const trimmed = newName.trim(); @@ -47,7 +56,7 @@ export const FriendsCategoryManagerView: FC = p onChange={ event => setNewName(event.target.value) } onKeyDown={ event => (event.key === 'Enter') && submitAdd() } /> @@ -64,7 +73,7 @@ export const FriendsCategoryManagerView: FC = p onChange={ event => setEditingName(event.target.value) } onKeyDown={ event => (event.key === 'Enter') && submitRename() } /> : From 860ec07dfd18b72f13a8ad4795e479df3abf7a22 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 18:20:31 +0200 Subject: [PATCH 07/61] feat(friends): group chip-filter row over online/offline list + manager wiring --- .../FriendsListGroupChipsView.tsx | 38 +++++++++++++++++++ .../views/friends-list/FriendsListView.tsx | 36 +++++++++++++----- 2 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 src/components/friends/views/friends-list/FriendsListGroupChipsView.tsx diff --git a/src/components/friends/views/friends-list/FriendsListGroupChipsView.tsx b/src/components/friends/views/friends-list/FriendsListGroupChipsView.tsx new file mode 100644 index 0000000..2d61efc --- /dev/null +++ b/src/components/friends/views/friends-list/FriendsListGroupChipsView.tsx @@ -0,0 +1,38 @@ +import { FriendCategoryData } from '@nitrots/nitro-renderer'; +import { FC } from 'react'; +import { LocalizeText, MessengerFriend, countFriendsByCategory } from '../../../../api'; +import { Flex } from '../../../../common'; + +interface FriendsListGroupChipsViewProps +{ + categories: FriendCategoryData[]; + friends: MessengerFriend[]; + selectedCategoryId: number; + setSelectedCategoryId: (id: number) => void; + onManageClick: () => void; +} + +export const FriendsListGroupChipsView: FC = props => +{ + const { categories = [], friends = [], selectedCategoryId = 0, setSelectedCategoryId = null, onManageClick = null } = props; + + const counts = countFriendsByCategory(friends); + + return ( + + +
setSelectedCategoryId(0) }> + { LocalizeText('friendlist.friends') } ({ friends.length }) +
+ { categories.map(category => ( +
setSelectedCategoryId(category.id) }> + { category.name } ({ counts.get(category.id) ?? 0 }) +
+ )) } +
+
+ { '⚙' } +
+
+ ); +}; diff --git a/src/components/friends/views/friends-list/FriendsListView.tsx b/src/components/friends/views/friends-list/FriendsListView.tsx index 957aaf2..7c1c3dc 100644 --- a/src/components/friends/views/friends-list/FriendsListView.tsx +++ b/src/components/friends/views/friends-list/FriendsListView.tsx @@ -1,11 +1,13 @@ import { AddLinkEventTracker, ILinkEventTracker, RemoveFriendComposer, RemoveLinkEventTracker, SendRoomInviteComposer } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { LocalizeText, MessengerFriend, SendMessageComposer } from '../../../../api'; +import { LocalizeText, MessengerFriend, SendMessageComposer, filterFriendsByCategory } from '../../../../api'; import { Button, Flex, NitroCardAccordionSetView, NitroCardAccordionView, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; import { useFriends } from '../../../../hooks'; +import { FriendsCategoryManagerView } from './FriendsCategoryManagerView'; import { FriendsRemoveConfirmationView } from './FriendsListRemoveConfirmationView'; import { FriendsRoomInviteView } from './FriendsListRoomInviteView'; import { FriendsSearchView } from './FriendsListSearchView'; +import { FriendsListGroupChipsView } from './FriendsListGroupChipsView'; import { FriendsListGroupView } from './friends-list-group/FriendsListGroupView'; import { FriendsListRequestView } from './friends-list-request/FriendsListRequestView'; @@ -15,7 +17,13 @@ export const FriendsListView: FC<{}> = props => const [ selectedFriendsIds, setSelectedFriendsIds ] = useState([]); const [ showRoomInvite, setShowRoomInvite ] = useState(false); const [ showRemoveFriendsConfirmation, setShowRemoveFriendsConfirmation ] = useState(false); - const { onlineFriends = [], offlineFriends = [], requests = [], requestFriend = null } = useFriends(); + const [ selectedCategoryId, setSelectedCategoryId ] = useState(0); + const [ showCategoryManager, setShowCategoryManager ] = useState(false); + const { friends = [], onlineFriends = [], offlineFriends = [], requests = [], requestFriend = null, settings = null } = useFriends(); + + const categories = settings?.categories ?? []; + const filteredOnlineFriends = filterFriendsByCategory(onlineFriends, selectedCategoryId); + const filteredOfflineFriends = filterFriendsByCategory(offlineFriends, selectedCategoryId); const removeFriendsText = useMemo(() => { @@ -145,32 +153,38 @@ export const FriendsListView: FC<{}> = props => setIsVisible(false) } /> + setShowCategoryManager(true) } /> - + { - event.stopPropagation(); toggleSelectFriends(onlineFriends.map(friend => friend.id)); + event.stopPropagation(); toggleSelectFriends(filteredOnlineFriends.map(friend => friend.id)); } }> - { onlineFriends.length && onlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0)) + { filteredOnlineFriends.length && filteredOnlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0)) ? LocalizeText('friendlist.unselect_all') : LocalizeText('friendlist.select_all') } - + - + { - event.stopPropagation(); toggleSelectFriends(offlineFriends.map(friend => friend.id)); + event.stopPropagation(); toggleSelectFriends(filteredOfflineFriends.map(friend => friend.id)); } }> - { offlineFriends.length && offlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0)) + { filteredOfflineFriends.length && filteredOfflineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0)) ? LocalizeText('friendlist.unselect_all') : LocalizeText('friendlist.select_all') } - + @@ -186,6 +200,8 @@ export const FriendsListView: FC<{}> = props => setShowRoomInvite(false) } /> } { showRemoveFriendsConfirmation && setShowRemoveFriendsConfirmation(false) } /> } + { showCategoryManager && + setShowCategoryManager(false) } /> } ); }; From 2291d3cfa82bf654d605ef09e54175a08d249c11 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 18:26:44 +0200 Subject: [PATCH 08/61] feat(friends): per-friend assign-to-group control --- .../FriendsListGroupItemView.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx b/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx index dc23a3b..707777e 100644 --- a/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx +++ b/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx @@ -9,7 +9,9 @@ export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: b { const { friend = null, selected = false, selectFriend = null } = props; const [ isRelationshipOpen, setIsRelationshipOpen ] = useState(false); - const { followFriend = null, updateRelationship = null } = useFriends(); + const { followFriend = null, updateRelationship = null, moveFriendToCategory = null, settings = null } = useFriends(); + const [ isGroupMenuOpen, setIsGroupMenuOpen ] = useState(false); + const categories = settings?.categories ?? []; const clickFollowFriend = (event: MouseEvent) => { @@ -74,6 +76,21 @@ export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: b
} { friend.online &&
} + { (friend.id > 0) && (categories.length > 0) && +
+
{ event.stopPropagation(); setIsGroupMenuOpen(prev => !prev); } }>{ '📁' }
+ { isGroupMenuOpen && +
+
{ event.stopPropagation(); moveFriendToCategory(friend.id, 0); setIsGroupMenuOpen(false); } }> + { LocalizeText('friendlist.friends') } +
+ { categories.map(category => ( +
{ event.stopPropagation(); moveFriendToCategory(friend.id, category.id); setIsGroupMenuOpen(false); } }> + { category.name } +
+ )) } +
} +
} { (friend.id > 0) &&
} } From 1af038806b135ba71c3cfd32764afa66e72e5f48 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 18:31:49 +0200 Subject: [PATCH 09/61] feat(chat): improve command autocomplete and command alerts --- src/App.tsx | 3 + .../RoomWidgetUpdateChatInputContentEvent.ts | 1 + .../NotificationDefaultAlertView.tsx | 73 +++++++++++++++++-- .../ChatInputCommandSelectorView.tsx | 18 +++-- .../room/widgets/chat-input/ChatInputView.tsx | 28 ++++++- .../notification/NotificationCenterView.css | 73 ++++++++++++++++++- .../useChatCommandSelector.helpers.test.ts | 51 +++++++++++++ .../widgets/useChatCommandSelector.helpers.ts | 57 +++++++++++++++ .../rooms/widgets/useChatCommandSelector.ts | 63 ++++++++-------- 9 files changed, 316 insertions(+), 51 deletions(-) create mode 100644 src/hooks/rooms/widgets/useChatCommandSelector.helpers.test.ts create mode 100644 src/hooks/rooms/widgets/useChatCommandSelector.helpers.ts diff --git a/src/App.tsx b/src/App.tsx index 55fce02..12d32a6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { LoginView } from './components/login/LoginView'; import { MainView } from './components/MainView'; import { ReconnectView } from './components/reconnect/ReconnectView'; import { useMessageEvent, useNitroEvent } from './hooks'; +import { ensureChatCommandListener } from './hooks/rooms/widgets/useChatCommandSelector'; NitroVersion.UI_VERSION = GetUIVersion(); @@ -557,7 +558,9 @@ export const App: FC<{}> = props => bumpProgress(85, taskLabel('loading.task.rooms', 'Loading rooms...')); await GetRoomEngine().init(); bumpProgress(92, taskLabel('loading.task.engine', 'Loading graphics engine...')); + ensureChatCommandListener(); await GetCommunication().init(); + ensureChatCommandListener(); bumpProgress(98, taskLabel('generic.reconnecting', 'Connecting to server...')); })(); } diff --git a/src/api/room/events/RoomWidgetUpdateChatInputContentEvent.ts b/src/api/room/events/RoomWidgetUpdateChatInputContentEvent.ts index 9352372..aea7193 100644 --- a/src/api/room/events/RoomWidgetUpdateChatInputContentEvent.ts +++ b/src/api/room/events/RoomWidgetUpdateChatInputContentEvent.ts @@ -5,6 +5,7 @@ export class RoomWidgetUpdateChatInputContentEvent extends RoomWidgetUpdateEvent public static CHAT_INPUT_CONTENT: string = 'RWUCICE_CHAT_INPUT_CONTENT'; public static WHISPER: string = 'whisper'; public static SHOUT: string = 'shout'; + public static TEXT: string = 'text'; private _chatMode: string = ''; private _userName: string = ''; diff --git a/src/components/notification-center/views/alert-layouts/NotificationDefaultAlertView.tsx b/src/components/notification-center/views/alert-layouts/NotificationDefaultAlertView.tsx index d9905e6..14c289d 100644 --- a/src/components/notification-center/views/alert-layouts/NotificationDefaultAlertView.tsx +++ b/src/components/notification-center/views/alert-layouts/NotificationDefaultAlertView.tsx @@ -1,5 +1,5 @@ -import { FC, useState } from 'react'; -import { LocalizeText, NotificationAlertItem, NotificationAlertType, OpenUrl } from '../../../../api'; +import { FC, useMemo, useState } from 'react'; +import { DispatchUiEvent, LocalizeText, NotificationAlertItem, NotificationAlertType, OpenUrl, RoomWidgetUpdateChatInputContentEvent } from '../../../../api'; import { Button, Column, Flex, LayoutNotificationAlertView, LayoutNotificationAlertViewProps } from '../../../../common'; interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewProps @@ -7,11 +7,57 @@ interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewP item: NotificationAlertItem; } +const COMMAND_LINE_PATTERN = /^\s*:[\w.-]+(?:\s.*)?$/; + +interface CommandTemplateEntry +{ + command: string; + description: string; +} + export const NotificationDefaultAlertView: FC = props => { - const { item = null, title = ((props.item && props.item.title) || ''), onClose = null, ...rest } = props; + const { item = null, title = ((props.item && props.item.title) || ''), onClose = null, classNames = [], ...rest } = props; const [ imageFailed, setImageFailed ] = useState(false); + const alertLines = useMemo(() => item.messages.flatMap(message => message.split(/\r\n|\r|\n/g)), [ item.messages ]); + const hasCommandTemplate = useMemo(() => + { + const commandLines = alertLines.filter(line => COMMAND_LINE_PATTERN.test(line)); + + return commandLines.length >= 4 || alertLines.some(line => /^Your Commands\(\d+\):?/i.test(line.trim())); + }, [ alertLines ]); + const commandTemplateContent = useMemo(() => + { + const intro: string[] = []; + const commands: CommandTemplateEntry[] = []; + + for(const rawLine of alertLines) + { + const text = rawLine.trim(); + + if(!text.length) continue; + + if(COMMAND_LINE_PATTERN.test(text)) + { + commands.push({ command: text, description: '' }); + continue; + } + + if(commands.length) + { + const lastCommand = commands[commands.length - 1]; + + lastCommand.description = lastCommand.description ? `${ lastCommand.description } ${ text }` : text; + continue; + } + + intro.push(text); + } + + return { intro, commands }; + }, [ alertLines ]); + const visitUrl = () => { OpenUrl(item.clickUrl); @@ -19,10 +65,18 @@ export const NotificationDefaultAlertView: FC onClose(); }; + const copyCommandToChatInput = (command: string) => + { + const chatValue = command.endsWith(' ') ? command : `${ command } `; + + DispatchUiEvent(new RoomWidgetUpdateChatInputContentEvent(RoomWidgetUpdateChatInputContentEvent.TEXT, chatValue)); + }; + const hasFrank = (item.alertType === NotificationAlertType.DEFAULT); + const alertClassNames = hasCommandTemplate ? [ ...classNames, 'nitro-alert-command-list' ] : classNames; return ( - + { hasFrank && !item.imageUrl &&
} { item.imageUrl && !imageFailed && { @@ -30,7 +84,16 @@ export const NotificationDefaultAlertView: FC setImageFailed(true); } } /> }
- { (item.messages.length > 0) && item.messages.map((message, index) => + { hasCommandTemplate &&
+ { commandTemplateContent.intro.map((text, index) => +
{ text }
) } + { commandTemplateContent.commands.map((entry, index) => + ) } +
} + { !hasCommandTemplate && (item.messages.length > 0) && item.messages.map((message, index) => { const htmlText = message.replace(/\r\n|\r|\n/g, '
'); diff --git a/src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx b/src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx index 5eae3d2..d3e0eae 100644 --- a/src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx @@ -1,9 +1,10 @@ import { FC, useEffect, useRef } from 'react'; -import { CommandDefinition } from '../../../../api'; +import type { CommandDefinition } from '../../../../api'; +import type { RankedCommandDefinition } from '../../../../hooks/rooms/widgets/useChatCommandSelector.helpers'; interface ChatInputCommandSelectorViewProps { - commands: CommandDefinition[]; + commands: RankedCommandDefinition[]; selectedIndex: number; onSelect: (command: CommandDefinition) => void; onHover: (index: number) => void; @@ -24,17 +25,18 @@ export const ChatInputCommandSelectorView: FC }, [ selectedIndex ]); return ( -
+
{ commands.map((cmd, index) => ( -
onSelect(cmd) } onMouseEnter={ () => onHover(index) } > - :{ cmd.key } - { cmd.description } -
+ :{ cmd.key } + { cmd.description } + )) }
); diff --git a/src/components/room/widgets/chat-input/ChatInputView.tsx b/src/components/room/widgets/chat-input/ChatInputView.tsx index 9aa9491..9bfc9b6 100644 --- a/src/components/room/widgets/chat-input/ChatInputView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputView.tsx @@ -8,7 +8,7 @@ import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView'; import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView'; import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView'; -export const ChatInputView: FC<{}> = props => +export const ChatInputView: FC = () => { const [ chatValue, setChatValue ] = useState(''); const { chatStyleId = 0, updateChatStyleId = null } = useSessionInfo(); @@ -42,6 +42,23 @@ export const ChatInputView: FC<{}> = props => inputRef.current.setSelectionRange((inputRef.current.value.length * 2), (inputRef.current.value.length * 2)); }, [ inputRef ]); + const setChatInputValue = useCallback((value: string, markTyping: boolean = true) => + { + setChatValue(value); + + if(markTyping) + { + setIsTyping(!!value.length); + setIsIdle(!!value.length); + } + + requestAnimationFrame(() => + { + inputRef.current?.focus(); + inputRef.current?.setSelectionRange(value.length, value.length); + }); + }, [ setIsTyping, setIsIdle ]); + const checkSpecialKeywordForInput = useCallback(() => { setChatValue(prevValue => @@ -157,7 +174,7 @@ export const ChatInputView: FC<{}> = props => if(selected) { event.preventDefault(); - setChatValue(':' + selected.key + ' '); + setChatInputValue(':' + selected.key + ' '); return; } break; @@ -194,12 +211,15 @@ export const ChatInputView: FC<{}> = props => return; } - }, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector ]); + }, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, setChatInputValue, closeCommandSelector ]); useUiEvent(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event => { switch(event.chatMode) { + case RoomWidgetUpdateChatInputContentEvent.TEXT: + setChatInputValue(event.userName); + return; case RoomWidgetUpdateChatInputContentEvent.WHISPER: { setChatValue(`${ chatModeIdWhisper } ${ event.userName } `); return; @@ -286,7 +306,7 @@ export const ChatInputView: FC<{}> = props => selectedIndex={ selectedIndex } onSelect={ (cmd) => { - setChatValue(':' + cmd.key + ' '); inputRef.current?.focus(); + setChatInputValue(':' + cmd.key + ' '); } } onHover={ setSelectedIndex } /> } diff --git a/src/css/notification/NotificationCenterView.css b/src/css/notification/NotificationCenterView.css index c24cb67..2d43f52 100644 --- a/src/css/notification/NotificationCenterView.css +++ b/src/css/notification/NotificationCenterView.css @@ -20,6 +20,77 @@ } } + &.nitro-alert-command-list { + width: min(430px, calc(100vw - 18px)); + min-height: 210px; + max-height: min(520px, calc(100vh - 24px)); + + .content-area { + padding: 9px 10px 8px; + } + + .notification-text { + min-width: 0; + padding-right: 3px; + font-family: Ubuntu, sans-serif; + line-height: 1.25; + } + + .notification-command-template { + display: flex; + flex-direction: column; + gap: 4px; + padding-bottom: 2px; + } + + .notification-command-heading { + font-weight: 700; + color: #101010; + margin-bottom: 3px; + } + + .notification-command-copy { + color: #262626; + margin-bottom: 6px; + } + + .notification-command-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + min-height: 34px; + padding: 5px 8px; + color: #123b4c; + background: linear-gradient(180deg, #ffffff 0%, #dceaf0 100%); + border: 1px solid #8ca6b1; + border-radius: 4px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85); + text-align: left; + word-break: break-word; + } + + .notification-command-row:hover { + background: linear-gradient(180deg, #ffffff 0%, #cfe2eb 100%); + border-color: #4f879b; + } + + .notification-command-name { + font-weight: 700; + color: #123b4c; + } + + .notification-command-description { + font-size: 11px; + line-height: 1.2; + color: #3d4a50; + } + + .notification-command-spacer { + height: 3px; + } + } + &.nitro-alert-credits { width: 370px; .notification-text { @@ -390,4 +461,4 @@ position: relative; background-image: url("@/assets/images/notifications/nitro_v3.png"); background-repeat: no-repeat; -} \ No newline at end of file +} diff --git a/src/hooks/rooms/widgets/useChatCommandSelector.helpers.test.ts b/src/hooks/rooms/widgets/useChatCommandSelector.helpers.test.ts new file mode 100644 index 0000000..9daa747 --- /dev/null +++ b/src/hooks/rooms/widgets/useChatCommandSelector.helpers.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { CommandDefinition } from '../../../api'; +import { getChatCommandQuery, getRankedCommandSuggestions } from './useChatCommandSelector.helpers'; + +const commands: CommandDefinition[] = [ + { key: 'commands', description: 'Mostra tutti i comandi' }, + { key: 'empty', description: 'Svuota la stanza' }, + { key: 'emptybots', description: 'Svuota inventario bot' }, + { key: 'xempty', description: 'Comando di test' }, + { key: 'ejectall', description: 'Espelli tutti i furni' }, + { key: 'togglefps', description: 'Mostra o nasconde FPS' } +]; + +describe('getChatCommandQuery', () => +{ + it('returns null when the input is not a command prefix', () => + { + expect(getChatCommandQuery('ciao')).toBeNull(); + expect(getChatCommandQuery(':empty ')).toBeNull(); + }); + + it('returns the normalized command query', () => + { + expect(getChatCommandQuery(':Em')).toBe('em'); + }); +}); + +describe('getRankedCommandSuggestions', () => +{ + it('ranks prefix matches before contains and description matches', () => + { + const result = getRankedCommandSuggestions(commands, 'em', 10); + + expect(result.map(command => command.key)).toEqual([ 'empty', 'emptybots', 'xempty' ]); + }); + + it('matches command descriptions when the key does not match', () => + { + const result = getRankedCommandSuggestions(commands, 'furni', 10); + + expect(result.map(command => command.key)).toEqual([ 'ejectall' ]); + expect(result[0].matchType).toBe('description'); + }); + + it('limits the visible suggestions', () => + { + const result = getRankedCommandSuggestions(commands, '', 2); + + expect(result).toHaveLength(2); + }); +}); diff --git a/src/hooks/rooms/widgets/useChatCommandSelector.helpers.ts b/src/hooks/rooms/widgets/useChatCommandSelector.helpers.ts new file mode 100644 index 0000000..b9bc98f --- /dev/null +++ b/src/hooks/rooms/widgets/useChatCommandSelector.helpers.ts @@ -0,0 +1,57 @@ +import { CommandDefinition } from '../../../api'; + +export interface RankedCommandDefinition extends CommandDefinition +{ + matchType: 'prefix' | 'contains' | 'description' | 'all'; +} + +const normalize = (value: string) => value.trim().toLowerCase(); + +export const getChatCommandQuery = (chatValue: string): string | null => +{ + if(!chatValue.startsWith(':') || chatValue.includes(' ')) return null; + + return normalize(chatValue.slice(1)); +}; + +const getCommandScore = (command: CommandDefinition, query: string): { score: number; matchType: RankedCommandDefinition['matchType'] } | null => +{ + const key = normalize(command.key); + const description = normalize(command.description || ''); + + if(!query) return { score: 100 + key.length, matchType: 'all' }; + if(key === query) return { score: 0, matchType: 'prefix' }; + if(key.startsWith(query)) return { score: 10 + (key.length - query.length), matchType: 'prefix' }; + if(key.includes(query)) return { score: 40 + key.indexOf(query), matchType: 'contains' }; + if(description.includes(query)) return { score: 70 + description.indexOf(query), matchType: 'description' }; + + return null; +}; + +export const getRankedCommandSuggestions = (commands: CommandDefinition[], query: string, limit: number): RankedCommandDefinition[] => +{ + const seen = new Set(); + + return commands + .map(command => + { + const match = getCommandScore(command, query); + + if(!match) return null; + + return { command, score: match.score, matchType: match.matchType }; + }) + .filter((entry): entry is { command: CommandDefinition; score: number; matchType: RankedCommandDefinition['matchType'] } => !!entry) + .sort((a, b) => (a.score - b.score) || a.command.key.localeCompare(b.command.key)) + .filter(entry => + { + const key = normalize(entry.command.key); + + if(seen.has(key)) return false; + + seen.add(key); + return true; + }) + .slice(0, limit) + .map(entry => ({ ...entry.command, matchType: entry.matchType })); +}; diff --git a/src/hooks/rooms/widgets/useChatCommandSelector.ts b/src/hooks/rooms/widgets/useChatCommandSelector.ts index 9584e92..fc573e5 100644 --- a/src/hooks/rooms/widgets/useChatCommandSelector.ts +++ b/src/hooks/rooms/widgets/useChatCommandSelector.ts @@ -3,6 +3,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { CommandDefinition } from '../../../api'; import { createNitroStore } from '../../../state/createNitroStore'; import { useMessageEvent } from '../../events'; +import { getChatCommandQuery, getRankedCommandSuggestions } from './useChatCommandSelector.helpers'; + +const MAX_VISIBLE_COMMANDS = 8; // Client-only commands are static; safe to keep at module scope. const CLIENT_COMMANDS: CommandDefinition[] = [ @@ -60,7 +63,7 @@ const useChatCommandStore = createNitroStore()((set) => ({ markListenerRegistered: () => set({ isListenerRegistered: true }) })); -const ensureGlobalListener = (): void => +export const ensureChatCommandListener = (): void => { if(useChatCommandStore.getState().isListenerRegistered) return; @@ -84,20 +87,20 @@ const ensureGlobalListener = (): void => // Try once at module load so the server's response landing before any // React mount still hits the cache. -ensureGlobalListener(); +ensureChatCommandListener(); export const useChatCommandSelector = (chatValue: string) => { const serverCommands = useChatCommandStore(s => s.serverCommands); const setServerCommands = useChatCommandStore(s => s.setServerCommands); const [ selectedIndex, setSelectedIndex ] = useState(0); - const [ dismissed, setDismissed ] = useState(false); + const [ dismissedQuery, setDismissedQuery ] = useState(null); useEffect(() => { // Cover the case where the module-level registration failed // because GetCommunication() wasn't ready at import time. - ensureGlobalListener(); + ensureChatCommandListener(); }, []); // Late updates (rank change, etc.) — go through the store so all @@ -120,61 +123,55 @@ export const useChatCommandSelector = (chatValue: string) => return merged.sort((a, b) => a.key.localeCompare(b.key)); }, [ serverCommands ]); - const filterText = useMemo(() => - { - if(!chatValue.startsWith(':') || chatValue.includes(' ')) return ''; - - return chatValue.slice(1).toLowerCase(); - }, [ chatValue ]); + const filterText = useMemo(() => getChatCommandQuery(chatValue), [ chatValue ]); const filteredCommands = useMemo(() => { - if(!filterText && !chatValue.startsWith(':')) return []; + if(filterText === null) return []; - return allCommands.filter(cmd => cmd.key.toLowerCase().startsWith(filterText)); - }, [ allCommands, filterText, chatValue ]); + return getRankedCommandSuggestions(allCommands, filterText, MAX_VISIBLE_COMMANDS); + }, [ allCommands, filterText ]); const isVisible = useMemo(() => { - return chatValue.startsWith(':') && !chatValue.includes(' ') && filteredCommands.length > 0 && !dismissed; - }, [ chatValue, filteredCommands, dismissed ]); + return filterText !== null && filteredCommands.length > 0 && dismissedQuery !== filterText; + }, [ filterText, filteredCommands, dismissedQuery ]); + + const boundedSelectedIndex = useMemo(() => + { + if(!filteredCommands.length) return 0; + + return Math.min(selectedIndex, filteredCommands.length - 1); + }, [ filteredCommands.length, selectedIndex ]); const moveUp = useCallback(() => { - setSelectedIndex(prev => (prev <= 0 ? filteredCommands.length - 1 : prev - 1)); + if(!filteredCommands.length) return; + + setSelectedIndex(prev => ((prev <= 0 || prev >= filteredCommands.length) ? filteredCommands.length - 1 : prev - 1)); }, [ filteredCommands.length ]); const moveDown = useCallback(() => { + if(!filteredCommands.length) return; + setSelectedIndex(prev => (prev >= filteredCommands.length - 1 ? 0 : prev + 1)); }, [ filteredCommands.length ]); const selectCurrent = useCallback((): CommandDefinition | null => { - if(selectedIndex >= 0 && selectedIndex < filteredCommands.length) + if(boundedSelectedIndex >= 0 && boundedSelectedIndex < filteredCommands.length) { - return filteredCommands[selectedIndex]; + return filteredCommands[boundedSelectedIndex]; } return null; - }, [ selectedIndex, filteredCommands ]); + }, [ boundedSelectedIndex, filteredCommands ]); const close = useCallback(() => { - setDismissed(true); - }, []); - - // Reset dismissed when chatValue changes to a new command start - useEffect(() => - { - if(chatValue === ':' || chatValue === '') setDismissed(false); - }, [ chatValue ]); - - // Reset selectedIndex when filtered list changes - useEffect(() => - { - setSelectedIndex(0); + setDismissedQuery(filterText); }, [ filterText ]); - return { isVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close }; + return { isVisible, filteredCommands, selectedIndex: boundedSelectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close }; }; From 94418478183094156b0e31d6fd80ec5fdc8e0f19 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 18:32:01 +0200 Subject: [PATCH 10/61] style(friends): group chips, category manager, assign-group dropdown --- src/css/friends/FriendsView.css | 94 +++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/src/css/friends/FriendsView.css b/src/css/friends/FriendsView.css index 2b2ea17..f86b228 100644 --- a/src/css/friends/FriendsView.css +++ b/src/css/friends/FriendsView.css @@ -800,3 +800,97 @@ } } } + +/* Friend group chip filter row */ +.friends-group-chips { + border-bottom: 1px solid #e6e6e6; +} + +.friends-group-chips-scroll { + overflow-x: auto; + flex-wrap: nowrap; + min-width: 0; +} + +.friends-group-chips-scroll::-webkit-scrollbar { + height: 4px; +} + +.friends-group-chips-scroll::-webkit-scrollbar-thumb { + background: #c0c0b8; + border-radius: 2px; +} + +.friends-group-chip { + flex: 0 0 auto; + padding: 1px 8px; + border: 1px solid #d0d0c8; + border-radius: 10px; + background: #f3f3ef; + font-size: 11px; + line-height: 16px; + white-space: nowrap; + cursor: pointer; + user-select: none; +} + +.friends-group-chip:hover { + background: #efefef; +} + +.friends-group-chip.active { + background: #bfe7f6; + border-color: #7fb9d6; +} + +.friends-group-chip-manage { + flex: 0 0 auto; + padding: 1px 6px; +} + +/* Per-friend assign-to-group dropdown */ +.friends-list-group-assign { + display: inline-flex; +} + +.friends-list-group-toggle { + cursor: pointer; + font-size: 13px; + line-height: 1; + user-select: none; +} + +.friends-list-group-menu { + position: absolute; + right: 0; + top: 100%; + z-index: 20; + min-width: 120px; + max-height: 180px; + overflow-y: auto; + background: #fff; + border: 1px solid #c0c0b8; + border-radius: 4px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); +} + +.friends-list-group-menu-item { + padding: 3px 8px; + font-size: 11px; + cursor: pointer; + white-space: nowrap; +} + +.friends-list-group-menu-item:hover { + background: #efefef; +} + +.friends-list-group-menu-item.active { + background: #bfe7f6; +} + +/* Category manager: keep the list scrollable */ +.nitro-friends-category-manager .friends-category-list { + max-height: 220px; + overflow-y: auto; +} From 030015afcaef99a16a6ce835c7039e3a7ac9228d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 18:52:17 +0200 Subject: [PATCH 11/61] =?UTF-8?q?docs(messenger):=20Phase=202=20implementa?= =?UTF-8?q?tion=20plan=20=E2=80=94=20offline=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store messages to offline friends in messenger_offline (capped inbox), replay on login via the existing FriendChatMessageComposer with an "offline" extraData marker, and render a "sent while offline" tag in the client thread. No new packets; emulator + small client touch. --- ...06-02-messenger-phase2-offline-messages.md | 447 ++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-messenger-phase2-offline-messages.md diff --git a/docs/superpowers/plans/2026-06-02-messenger-phase2-offline-messages.md b/docs/superpowers/plans/2026-06-02-messenger-phase2-offline-messages.md new file mode 100644 index 0000000..d6b6d37 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-messenger-phase2-offline-messages.md @@ -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 messages = new ArrayList<>(); + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT user_from_id, message, sended_on FROM messenger_offline WHERE user_id = ? ORDER BY sended_on ASC, id ASC")) { + statement.setInt(1, userId); + try (ResultSet set = statement.executeQuery()) { + while (set.next()) { + messages.add(new Message(set.getInt("user_from_id"), userId, set.getString("message"), set.getInt("sended_on"))); + } + } + } catch (SQLException e) { + LOGGER.error("Caught SQL exception", e); + } + + if (messages.isEmpty()) return; + + for (Message message : messages) { + client.sendResponse(new FriendChatMessageComposer(message, message.getFromId(), message.getFromId(), "offline")); + } + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("DELETE FROM messenger_offline WHERE user_id = ?")) { + statement.setInt(1, userId); + statement.execute(); + } catch (SQLException e) { + LOGGER.error("Caught SQL exception", e); + } + } +``` + +- [ ] **Step 4: Compile** + +Run: `cd Arcturus-Morningstar-Extended/Emulator && mvn -q compile` +Expected: BUILD SUCCESS. If it fails on a missing symbol, add the missing import (see the list at the top of this task). + +- [ ] **Step 5: Commit (only Messenger.java)** +```bash +cd Arcturus-Morningstar-Extended +git add Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Messenger.java +git commit -m "feat(messenger): store + deliver offline messages (capped inbox)" +``` + +--- + +## Task 3: Emulator — wire send-to-offline + deliver-on-login + +**Files:** +- Modify: `Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/FriendPrivateMessageEvent.java` +- Modify: `Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/RequestInitFriendsEvent.java` + +- [ ] **Step 1: Branch online vs offline in `FriendPrivateMessageEvent`** + +Currently the handler ends with `buddy.onMessageReceived(this.client.getHabbo(), message);`. Replace that single line with a branch: deliver if the recipient is online, otherwise store (word-filtered, matching the filtering the online path applies before sending). Add imports `com.eu.habbo.habbohotel.messenger.Messenger` and `com.eu.habbo.habbohotel.modtool.WordFilter`. + +Replace: +```java + buddy.onMessageReceived(this.client.getHabbo(), message); +``` +with: +```java + if (Emulator.getGameServer().getGameClientManager().getHabbo(userId) != null) { + buddy.onMessageReceived(this.client.getHabbo(), message); + } else { + String stored = message; + if (WordFilter.ENABLED_FRIENDCHAT) { + stored = Emulator.getGameEnvironment().getWordFilter().filter(message, this.client.getHabbo()); + } + Messenger.addOfflineMessage(this.client.getHabbo().getHabboInfo().getId(), userId, stored); + } +``` +(`Emulator` is already imported in this file. `userId` is the recipient read at the top of `handle()`.) + +- [ ] **Step 2: Deliver offline messages on login in `RequestInitFriendsEvent`** + +After the existing `this.client.sendResponses(messages);`, add a call to deliver any stored offline messages (sent AFTER the friend list so the client's thread lookup can resolve the sender as a known friend). Add import `com.eu.habbo.habbohotel.messenger.Messenger`. + +The method becomes: +```java + public void handle() throws Exception { + ArrayList messages = new ArrayList<>(); + messages.add(new MessengerInitComposer(this.client.getHabbo()).compose()); + messages.addAll(FriendsComposer.getMessagesForBuddyList(this.client.getHabbo().getMessenger().getFriends().values())); + this.client.sendResponses(messages); + + Messenger.deliverOfflineMessages(this.client); + } +``` + +- [ ] **Step 3: Build the fat jar** + +Run: `cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests` +Expected: BUILD SUCCESS. + +- [ ] **Step 4: Commit (only these 2 files)** +```bash +cd Arcturus-Morningstar-Extended +git add Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/FriendPrivateMessageEvent.java Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/RequestInitFriendsEvent.java +git commit -m "feat(messenger): persist messages to offline friends, replay on login" +``` +Verify `git show --stat HEAD` shows exactly 2 files. + +--- + +## Task 4: Client — `offlineDelivered` getter on `MessengerThreadChat` (TDD) + +**Files:** +- Modify: `Nitro-V3/src/api/friends/MessengerThreadChat.ts` +- Test: `Nitro-V3/src/api/friends/MessengerThreadChat.test.ts` + +`MessengerThreadChat` already stores `_extraData` and `_type`, with `CHAT = 0`. The emulator sends `extraData === "offline"` only for replayed 1:1 messages. + +- [ ] **Step 1: Write the failing test** + +Create `src/api/friends/MessengerThreadChat.test.ts`: +```typescript +import { describe, expect, it } from 'vitest'; +import { MessengerThreadChat } from './MessengerThreadChat'; + +describe('MessengerThreadChat.offlineDelivered', () => +{ + it('is true for a CHAT message with extraData "offline"', () => + { + const chat = new MessengerThreadChat(5, 'hello', 60, 'offline', MessengerThreadChat.CHAT); + expect(chat.offlineDelivered).toBe(true); + }); + + it('is false for a normal CHAT message with no extraData', () => + { + const chat = new MessengerThreadChat(5, 'hello', 0, null, MessengerThreadChat.CHAT); + expect(chat.offlineDelivered).toBe(false); + }); + + it('is false when extraData is some other value (e.g. group chat data)', () => + { + const chat = new MessengerThreadChat(5, 'hi', 0, 'Bob/figurestr/5', MessengerThreadChat.CHAT); + expect(chat.offlineDelivered).toBe(false); + }); + + it('is false for a non-CHAT type even if extraData is "offline"', () => + { + const chat = new MessengerThreadChat(5, 'hi', 0, 'offline', MessengerThreadChat.ROOM_INVITE); + expect(chat.offlineDelivered).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run it, confirm FAIL** + +Run: `cd Nitro-V3 && yarn test --run src/api/friends/MessengerThreadChat.test.ts` +Expected: FAIL — `offlineDelivered` is not a function/getter. + +- [ ] **Step 3: Add the getter** + +In `MessengerThreadChat.ts`, add after the `extraData` getter: +```typescript + public get offlineDelivered(): boolean + { + return (this._type === MessengerThreadChat.CHAT) && (this._extraData === 'offline'); + } +``` + +- [ ] **Step 4: Run the test, confirm PASS (4 cases).** + +- [ ] **Step 5: Type-check + full suite** + +Run: `cd Nitro-V3 && yarn typecheck && yarn test --run` +Expected: typecheck shows only the known pre-existing `FloorplanCanvasSVG.tsx(143,20): TS2503`; tests green except the 3 known pre-existing floorplan failures. + +- [ ] **Step 6: Commit** +```bash +cd Nitro-V3 +git add src/api/friends/MessengerThreadChat.ts src/api/friends/MessengerThreadChat.test.ts +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): offlineDelivered flag on thread chat" +``` + +--- + +## Task 5: Client — render the "sent while offline" marker + +**Files:** +- Modify: `Nitro-V3/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx` +- Modify: `Nitro-V3/public/configuration/UITexts.example` +- Modify: a messenger CSS file (see Step 3) + +- [ ] **Step 1: Add the localization key** + +In `public/configuration/UITexts.example`, add a key near other `messenger.*` entries (match the file's JSON format — confirm by reading an existing `messenger.` line): +```json +"messenger.offline.delivered": "Sent while you were offline", +``` +(The user can localize the value in their live `UITexts` later. `LocalizeText` falls back to the key string if absent, so this never crashes.) + +- [ ] **Step 2: Render the marker in the message bubble** + +In `FriendsMessengerThreadGroup.tsx`, the bubble maps `group.chats`. `LocalizeText` is already imported. In the NON-translation branch (the `if(!chat.showTranslation)` return), append the marker when `chat.offlineDelivered`. Replace: +```tsx + if(!chat.showTranslation) + { + return { chat.message }; + } +``` +with: +```tsx + if(!chat.showTranslation) + { + return ( + + { chat.message } + { chat.offlineDelivered && + { LocalizeText('messenger.offline.delivered') } } + + ); + } +``` +(Leave the translation branch as-is — an offline message that is also auto-translated is a rare combination and the marker on the plain branch covers the normal case.) + +- [ ] **Step 3: Add the marker style** + +Find the CSS file that styles the messenger thread (search for an existing class used here, e.g. `messenger-message-bubble` or `messenger-message-time`): +Run: `grep -rl "messenger-message-bubble" Nitro-V3/src/css` +Append to that file a subtle style: +```css +.messenger-offline-tag { + display: block; + margin-top: 2px; + font-size: 10px; + font-style: italic; + opacity: 0.6; +} +``` +If the grep finds no file (the classes are global/elsewhere), append the same rule to `src/css/friends/FriendsView.css` instead, and note that in your report. + +- [ ] **Step 4: Type-check + full suite** + +Run: `cd Nitro-V3 && yarn typecheck && yarn test --run` +Expected: only the known pre-existing typecheck error; no new test failures. + +- [ ] **Step 5: Commit** +```bash +cd Nitro-V3 +git add src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx public/configuration/UITexts.example +# plus the CSS file you edited: +git add +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): show 'sent while offline' marker in thread" +``` + +--- + +## Task 6: Integration verification + +**Files:** none (manual + automated checks; fix-up commits only). + +- [ ] **Step 1: Automated checks** +``` +cd Nitro-V3 && yarn typecheck && yarn test --run +cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests +``` +Expected: client typecheck shows only the pre-existing `FloorplanCanvasSVG.tsx(143,20): TS2503`; client tests green except the 3 known floorplan failures (+4 new MessengerThreadChat cases passing); emulator BUILD SUCCESS. (No renderer change this phase.) + +- [ ] **Step 2: Live two-session manual test** + +Run the new jar + `yarn start`. With two accounts A and B who are friends: +1. **Store while offline:** B logs out. A opens the messenger thread with B and sends a message. (A's client shows the sent message as normal.) +2. **Deliver on login:** B logs in → the message appears in B's thread with A, carrying the "Sent while you were offline" marker. +3. **Order + multiple:** A sends 3 messages while B is offline → on B's login all 3 appear in order, each marked, and the `messenger_offline` rows for B are gone (delivered + deleted): + `SELECT * FROM messenger_offline WHERE user_id = ;` → 0 rows after login. +4. **Online still instant:** with both online, messages deliver immediately and show NO offline marker (wire unchanged for online 1:1). +5. **Cap:** (optional) inserting >200 stored messages for one user evicts the oldest. +6. **No regressions:** room invites, group/staff chat, and normal messaging still work. + +- [ ] **Step 3: Commit any fix-ups** (only if needed) +```bash +cd Nitro-V3 +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -am "fix(messenger): offline message integration fixes" +``` + +--- + +## Notes / scope boundaries +- **Word filter:** offline messages are word-filtered at store time (sender online) so the recipient sees filtered text, matching the online path. They are NOT written to `chatlogs_private` (offline messages were never chat-logged before; adding that is out of scope). +- **Displayed time:** the thread shows the client receive-time (existing behavior); `secondsSinceSent` is sent but the bubble timestamp is local. The "sent while offline" marker is what signals the message is delayed; back-dating the bubble timestamp is out of scope. +- **Read receipts (Phase 3)** will mark these delivered-on-login messages as read once the recipient opens the thread — not part of Phase 2. +- Do NOT push/merge automatically; the branch already carries Phase 1 + the user's own `feat(chat)` commit. From 83140de98b547c6443366b8323e6d9781b43099f Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 19:07:56 +0200 Subject: [PATCH 12/61] feat(messenger): offlineDelivered flag on thread chat --- src/api/friends/MessengerThreadChat.test.ts | 29 +++++++++++++++++++++ src/api/friends/MessengerThreadChat.ts | 5 ++++ 2 files changed, 34 insertions(+) create mode 100644 src/api/friends/MessengerThreadChat.test.ts diff --git a/src/api/friends/MessengerThreadChat.test.ts b/src/api/friends/MessengerThreadChat.test.ts new file mode 100644 index 0000000..53320c4 --- /dev/null +++ b/src/api/friends/MessengerThreadChat.test.ts @@ -0,0 +1,29 @@ +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); + }); +}); diff --git a/src/api/friends/MessengerThreadChat.ts b/src/api/friends/MessengerThreadChat.ts index 5e37167..c58b366 100644 --- a/src/api/friends/MessengerThreadChat.ts +++ b/src/api/friends/MessengerThreadChat.ts @@ -74,6 +74,11 @@ export class MessengerThreadChat return this._extraData; } + public get offlineDelivered(): boolean + { + return (this._type === MessengerThreadChat.CHAT) && (this._extraData === 'offline'); + } + public get date(): Date { return this._date; From db050b99e7d7e90fa83d9faaeebb989f4c903baa Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 19:11:05 +0200 Subject: [PATCH 13/61] feat(messenger): show 'sent while offline' marker in thread --- public/configuration/UITexts.example | 3 ++- .../messenger-thread/FriendsMessengerThreadGroup.tsx | 8 +++++++- src/css/friends/FriendsView.css | 8 ++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/public/configuration/UITexts.example b/public/configuration/UITexts.example index cf24e72..7a6fc2b 100644 --- a/public/configuration/UITexts.example +++ b/public/configuration/UITexts.example @@ -265,5 +265,6 @@ "loading.task.userdata": "loading user data...", "loading.task.rooms": "loading rooms...", "loading.task.engine": "loading graphics engine...", - "catalog.gift_wrapping.gift_sent": "Done!" + "catalog.gift_wrapping.gift_sent": "Done!", + "messenger.offline.delivered": "Sent while you were offline" } diff --git a/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx b/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx index 97d74bf..f640843 100644 --- a/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx +++ b/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx @@ -65,7 +65,13 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M { if(!chat.showTranslation) { - return { chat.message }; + return ( + + { chat.message } + { chat.offlineDelivered && + { LocalizeText('messenger.offline.delivered') } } + + ); } return ( diff --git a/src/css/friends/FriendsView.css b/src/css/friends/FriendsView.css index f86b228..219b91d 100644 --- a/src/css/friends/FriendsView.css +++ b/src/css/friends/FriendsView.css @@ -894,3 +894,11 @@ max-height: 220px; overflow-y: auto; } + +.messenger-offline-tag { + display: block; + margin-top: 2px; + font-size: 10px; + font-style: italic; + opacity: 0.6; +} From f9c65000bd4015392165e940c1ee036822217338 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 19:57:51 +0200 Subject: [PATCH 14/61] =?UTF-8?q?docs(messenger):=20Phase=203=20implementa?= =?UTF-8?q?tion=20plan=20=E2=80=94=20read=20receipts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2-state read receipts (sent / read) via two custom packets: MarkConsoleRead (client->server, header 4085) sent on thread focus, relayed by the emulator to the peer as ConsoleReadReceipt (server-> client, header 4086). Client marks own messages READ and renders checkmarks. Live-relay only (no DB table / login batch) since the client doesn't persist message history across sessions. --- ...26-06-02-messenger-phase3-read-receipts.md | 586 ++++++++++++++++++ 1 file changed, 586 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-messenger-phase3-read-receipts.md diff --git a/docs/superpowers/plans/2026-06-02-messenger-phase3-read-receipts.md b/docs/superpowers/plans/2026-06-02-messenger-phase3-read-receipts.md new file mode 100644 index 0000000..fb41845 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-messenger-phase3-read-receipts.md @@ -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> +{ + private _data: ConstructorParameters; + + 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, 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, 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` `` (inside `.messenger-message-body`), add a status indicator shown only for own 1:1 CHAT groups. Compute the last chat once and render: +```tsx + { group.chats[0].date.toLocaleTimeString() } + { isOwnChat && (group.type === MessengerGroupType.PRIVATE_CHAT) && (group.chats[group.chats.length - 1].type === MessengerThreadChat.CHAT) && + + { (group.chats[group.chats.length - 1].status === MessengerThreadChat.READ) ? '✓✓' : '✓' } + } +``` +(Insert this block immediately after the existing `messenger-message-time` line, still inside the `.messenger-message-body` ``.) + +- [ ] **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 1–2 + the user's own parallel commits. From f5e179f6c9c9a17d84ab2d5ac745cbf39adf7802 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 20:16:08 +0200 Subject: [PATCH 15/61] feat(messenger): SENT/READ status on thread chats + mark-read helper --- src/api/friends/MessengerThread.test.ts | 26 +++++++++++++++++++++ src/api/friends/MessengerThread.ts | 10 ++++++++ src/api/friends/MessengerThreadChat.test.ts | 16 +++++++++++++ src/api/friends/MessengerThreadChat.ts | 13 +++++++++++ 4 files changed, 65 insertions(+) create mode 100644 src/api/friends/MessengerThread.test.ts diff --git a/src/api/friends/MessengerThread.test.ts b/src/api/friends/MessengerThread.test.ts new file mode 100644 index 0000000..1045c50 --- /dev/null +++ b/src/api/friends/MessengerThread.test.ts @@ -0,0 +1,26 @@ +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); + const theirs = thread.addMessage(7, 'b', 0, null, MessengerThreadChat.CHAT); + + thread.setMessagesReadFromUser(100); + + expect(mine.status).toBe(MessengerThreadChat.READ); + expect(theirs.status).toBe(MessengerThreadChat.SENT); + }); +}); diff --git a/src/api/friends/MessengerThread.ts b/src/api/friends/MessengerThread.ts index a8cdb3c..d64ecdb 100644 --- a/src/api/friends/MessengerThread.ts +++ b/src/api/friends/MessengerThread.ts @@ -99,6 +99,16 @@ export class MessengerThread this._unreadCount = 0; } + 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); + } + } + public get threadId(): number { return this._threadId; diff --git a/src/api/friends/MessengerThreadChat.test.ts b/src/api/friends/MessengerThreadChat.test.ts index 53320c4..1a31e0d 100644 --- a/src/api/friends/MessengerThreadChat.test.ts +++ b/src/api/friends/MessengerThreadChat.test.ts @@ -27,3 +27,19 @@ describe('MessengerThreadChat.offlineDelivered', () => expect(chat.offlineDelivered).toBe(false); }); }); + +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); + }); +}); diff --git a/src/api/friends/MessengerThreadChat.ts b/src/api/friends/MessengerThreadChat.ts index c58b366..cbf5c9a 100644 --- a/src/api/friends/MessengerThreadChat.ts +++ b/src/api/friends/MessengerThreadChat.ts @@ -4,10 +4,13 @@ export class MessengerThreadChat public static ROOM_INVITE: number = 1; public static STATUS_NOTIFICATION: number = 2; public static SECURITY_NOTIFICATION: number = 3; + public static SENT: number = 0; + public static READ: number = 1; private static CHAT_ID: number = 0; private _id: number; private _type: number; + private _status: number = MessengerThreadChat.SENT; private _senderId: number; private _message: string; private _secondsSinceSent: number; @@ -79,6 +82,16 @@ export class MessengerThreadChat return (this._type === MessengerThreadChat.CHAT) && (this._extraData === 'offline'); } + public get status(): number + { + return this._status; + } + + public setStatus(status: number): void + { + this._status = status; + } + public get date(): Date { return this._date; From ef60aa8ebda03b124f75b849f37630e849e6bbd3 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 20:19:38 +0200 Subject: [PATCH 16/61] feat(messenger): send mark-read on focus, mark own messages read on receipt --- src/hooks/friends/useMessenger.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/hooks/friends/useMessenger.ts b/src/hooks/friends/useMessenger.ts index 7e54076..11c5090 100644 --- a/src/hooks/friends/useMessenger.ts +++ b/src/hooks/friends/useMessenger.ts @@ -1,4 +1,4 @@ -import { GetSessionDataManager, NewConsoleMessageEvent, RoomInviteErrorEvent, RoomInviteEvent, SendMessageComposer as SendMessageComposerPacket } from '@nitrots/nitro-renderer'; +import { ConsoleReadReceiptEvent, GetSessionDataManager, MarkConsoleReadComposer, NewConsoleMessageEvent, RoomInviteErrorEvent, RoomInviteEvent, SendMessageComposer as SendMessageComposerPacket } from '@nitrots/nitro-renderer'; import { useEffect, useMemo, useState } from 'react'; import { useBetween } from 'use-between'; import { CloneObject, LocalizeText, MessengerIconState, MessengerThread, MessengerThreadChat, NotificationAlertType, PlaySound, SendMessageComposer, SoundNames } from '../../api'; @@ -156,6 +156,7 @@ const useMessengerState = () => 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)); }); useMessageEvent(RoomInviteEvent, event => @@ -175,10 +176,32 @@ const useMessengerState = () => simpleAlert(`Received room invite error: ${ parser.errorCode },recipients: ${ parser.failedRecipients.join(',') }`, NotificationAlertType.DEFAULT, null, null, LocalizeText('friendlist.alert.title')); }); + useMessageEvent(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; + }); + }); + useEffect(() => { if (activeThreadId <= 0) return; + let participantId = 0; + setMessageThreads(prevValue => { const newValue = [...prevValue]; @@ -187,12 +210,14 @@ const useMessengerState = () => 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]); useEffect(() => From 775e8b27afc558223a433b92fa62329293c6291e Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 20:25:03 +0200 Subject: [PATCH 17/61] feat(messenger): render sent/read checkmarks on own messages --- .../messenger-thread/FriendsMessengerThreadGroup.tsx | 4 ++++ src/css/friends/FriendsView.css | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx b/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx index f640843..9276c73 100644 --- a/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx +++ b/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx @@ -89,6 +89,10 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M }) } { group.chats[0].date.toLocaleTimeString() } + { isOwnChat && (group.type === MessengerGroupType.PRIVATE_CHAT) && (group.chats[group.chats.length - 1].type === MessengerThreadChat.CHAT) && + + { (group.chats[group.chats.length - 1].status === MessengerThreadChat.READ) ? '✓✓' : '✓' } + } { isOwnChat && diff --git a/src/css/friends/FriendsView.css b/src/css/friends/FriendsView.css index 219b91d..34b9544 100644 --- a/src/css/friends/FriendsView.css +++ b/src/css/friends/FriendsView.css @@ -902,3 +902,15 @@ font-style: italic; opacity: 0.6; } + +.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; +} From 8ae48005f366ba5b6b057b3c26cbb8b7eee2cc10 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 20:37:20 +0200 Subject: [PATCH 18/61] fix(messenger): reliably send mark-read on thread focus via latest-value ref --- src/hooks/friends/useMessenger.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/hooks/friends/useMessenger.ts b/src/hooks/friends/useMessenger.ts index 11c5090..79c399a 100644 --- a/src/hooks/friends/useMessenger.ts +++ b/src/hooks/friends/useMessenger.ts @@ -1,5 +1,5 @@ import { ConsoleReadReceiptEvent, GetSessionDataManager, MarkConsoleReadComposer, NewConsoleMessageEvent, RoomInviteErrorEvent, RoomInviteEvent, SendMessageComposer as SendMessageComposerPacket } from '@nitrots/nitro-renderer'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useBetween } from 'use-between'; import { CloneObject, LocalizeText, MessengerIconState, MessengerThread, MessengerThreadChat, NotificationAlertType, PlaySound, SendMessageComposer, SoundNames } from '../../api'; import { useMessageEvent } from '../events'; @@ -17,6 +17,9 @@ const useMessengerState = () => const { simpleAlert = null } = useNotification(); const { settings, translateIncoming } = useTranslation(); + const messageThreadsRef = useRef(messageThreads); + messageThreadsRef.current = messageThreads; + const visibleThreads = useMemo(() => messageThreads.filter(thread => (hiddenThreadIds.indexOf(thread.threadId) === -1)), [messageThreads, hiddenThreadIds]); const activeThread = useMemo(() => ((activeThreadId > 0) && visibleThreads.find(thread => (thread.threadId === activeThreadId) || null)), [activeThreadId, visibleThreads]); @@ -200,7 +203,8 @@ const useMessengerState = () => { if (activeThreadId <= 0) return; - let participantId = 0; + const activeThreadValue = messageThreadsRef.current.find(thread => (thread.threadId === activeThreadId)); + const participantId = activeThreadValue?.participant?.id ?? 0; setMessageThreads(prevValue => { @@ -211,7 +215,6 @@ const useMessengerState = () => { newValue[index] = CloneObject(newValue[index]); newValue[index].setRead(); - participantId = newValue[index].participant?.id ?? 0; } return newValue; From 7a2dac87597b314b063f28d2eeee6ce0d3a98775 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 20:52:37 +0200 Subject: [PATCH 19/61] =?UTF-8?q?docs(messenger):=20Phase=204=20implementa?= =?UTF-8?q?tion=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. From 361ab27853a1ac9e53ffe0d2a39fbda484b9272a Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 21:11:03 +0200 Subject: [PATCH 20/61] feat(messenger): incoming typing state + outgoing typing action --- src/hooks/friends/useMessenger.ts | 46 +++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/src/hooks/friends/useMessenger.ts b/src/hooks/friends/useMessenger.ts index 79c399a..7131f6e 100644 --- a/src/hooks/friends/useMessenger.ts +++ b/src/hooks/friends/useMessenger.ts @@ -1,4 +1,4 @@ -import { ConsoleReadReceiptEvent, GetSessionDataManager, MarkConsoleReadComposer, NewConsoleMessageEvent, RoomInviteErrorEvent, RoomInviteEvent, SendMessageComposer as SendMessageComposerPacket } from '@nitrots/nitro-renderer'; +import { ConsoleReadReceiptEvent, ConsoleTypingComposer, FriendIsTypingEvent, GetSessionDataManager, MarkConsoleReadComposer, NewConsoleMessageEvent, RoomInviteErrorEvent, RoomInviteEvent, SendMessageComposer as SendMessageComposerPacket } from '@nitrots/nitro-renderer'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useBetween } from 'use-between'; import { CloneObject, LocalizeText, MessengerIconState, MessengerThread, MessengerThreadChat, NotificationAlertType, PlaySound, SendMessageComposer, SoundNames } from '../../api'; @@ -17,6 +17,9 @@ const useMessengerState = () => const { simpleAlert = null } = useNotification(); const { settings, translateIncoming } = useTranslation(); + const [typingUserIds, setTypingUserIds] = useState([]); + const typingTimersRef = useRef>>(new Map()); + const messageThreadsRef = useRef(messageThreads); messageThreadsRef.current = messageThreads; @@ -151,6 +154,13 @@ const useMessengerState = () => }); }; + const sendTypingStatus = (peerId: number, isTyping: boolean) => + { + if (!peerId || (peerId <= 0)) return; + + SendMessageComposer(new ConsoleTypingComposer(peerId, isTyping)); + }; + useMessageEvent(NewConsoleMessageEvent, event => { const parser = event.getParser(); @@ -179,6 +189,38 @@ const useMessengerState = () => simpleAlert(`Received room invite error: ${ parser.errorCode },recipients: ${ parser.failedRecipients.join(',') }`, NotificationAlertType.DEFAULT, null, null, LocalizeText('friendlist.alert.title')); }); + 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))); + } + }); + useMessageEvent(ConsoleReadReceiptEvent, event => { const parser = event.getParser(); @@ -247,7 +289,7 @@ const useMessengerState = () => }); }, [visibleThreads]); - return { messageThreads, activeThread, iconState, visibleThreads, getMessageThread, setActiveThreadId, closeThread, sendMessage }; + return { messageThreads, activeThread, iconState, visibleThreads, getMessageThread, setActiveThreadId, closeThread, sendMessage, typingUserIds, sendTypingStatus }; }; export const useMessenger = () => useBetween(useMessengerState); From 7d89ce14d04231ba08b628e44786f676361a0080 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 21:15:53 +0200 Subject: [PATCH 21/61] feat(messenger): send typing status + show 'is typing' indicator --- public/configuration/UITexts.example | 3 +- .../views/messenger/FriendsMessengerView.tsx | 66 ++++++++++++++++++- src/css/friends/FriendsView.css | 7 ++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/public/configuration/UITexts.example b/public/configuration/UITexts.example index 7a6fc2b..8c2aba2 100644 --- a/public/configuration/UITexts.example +++ b/public/configuration/UITexts.example @@ -266,5 +266,6 @@ "loading.task.rooms": "loading rooms...", "loading.task.engine": "loading graphics engine...", "catalog.gift_wrapping.gift_sent": "Done!", - "messenger.offline.delivered": "Sent while you were offline" + "messenger.offline.delivered": "Sent while you were offline", + "messenger.typing": "%FRIEND_NAME% is typing..." } diff --git a/src/components/friends/views/messenger/FriendsMessengerView.tsx b/src/components/friends/views/messenger/FriendsMessengerView.tsx index 0eaf484..b5fdd94 100644 --- a/src/components/friends/views/messenger/FriendsMessengerView.tsx +++ b/src/components/friends/views/messenger/FriendsMessengerView.tsx @@ -11,10 +11,52 @@ export const FriendsMessengerView: FC<{}> = props => const [ isVisible, setIsVisible ] = useState(false); const [ lastThreadId, setLastThreadId ] = useState(-1); const [ messageText, setMessageText ] = useState(''); - const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null } = useMessenger(); + const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null, typingUserIds = [], sendTypingStatus = null } = useMessenger(); const { report = null } = useHelp(); const { settings, translateOutgoing } = useTranslation(); const messagesBox = useRef(null); + 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); + }; const followFriend = () => (activeThread && activeThread.participant && SendMessageComposer(new FollowFriendMessageComposer(activeThread.participant.id))); const openProfile = () => (activeThread && activeThread.participant && GetUserProfile(activeThread.participant.id)); @@ -23,6 +65,8 @@ export const FriendsMessengerView: FC<{}> = props => { if(!activeThread || !messageText.length) return; + stopTyping(); + const trimmedText = messageText.trimStart(); const shouldTranslateOutgoing = settings.enabled && !!trimmedText.length && (trimmedText.charAt(0) !== ':'); @@ -100,6 +144,19 @@ export const FriendsMessengerView: FC<{}> = props => messagesBox.current.scrollTop = messagesBox.current.scrollHeight; }, [ isVisible, activeThread ]); + useEffect(() => + { + return () => + { + if(typingTimeoutRef.current) + { + clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = null; + } + isTypingRef.current = false; + }; + }, [ activeThread ]); + useEffect(() => { if(isVisible && !activeThread) @@ -172,8 +229,13 @@ export const FriendsMessengerView: FC<{}> = props =>
+ { activeThread.participant && (activeThread.participant.id > 0) && (typingUserIds.indexOf(activeThread.participant.id) >= 0) && +
+ { LocalizeText('messenger.typing', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } +
} +
- setMessageText(event.target.value) } onKeyDown={ onKeyDown } /> + handleInputChange(event.target.value) } onKeyDown={ onKeyDown } /> diff --git a/src/css/friends/FriendsView.css b/src/css/friends/FriendsView.css index 34b9544..7383616 100644 --- a/src/css/friends/FriendsView.css +++ b/src/css/friends/FriendsView.css @@ -914,3 +914,10 @@ color: #4fc3f7; opacity: 1; } + +.messenger-typing-indicator { + padding: 2px 8px; + font-size: 11px; + font-style: italic; + opacity: 0.7; +} From dc7012794eaecc50537a2dd3304cf2aeb7b5b71a Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 21:25:21 +0200 Subject: [PATCH 22/61] fix(messenger): send typing-stop to peer when switching conversations --- .../friends/views/messenger/FriendsMessengerView.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/components/friends/views/messenger/FriendsMessengerView.tsx b/src/components/friends/views/messenger/FriendsMessengerView.tsx index b5fdd94..7cb0fa7 100644 --- a/src/components/friends/views/messenger/FriendsMessengerView.tsx +++ b/src/components/friends/views/messenger/FriendsMessengerView.tsx @@ -148,12 +148,7 @@ export const FriendsMessengerView: FC<{}> = props => { return () => { - if(typingTimeoutRef.current) - { - clearTimeout(typingTimeoutRef.current); - typingTimeoutRef.current = null; - } - isTypingRef.current = false; + stopTyping(); }; }, [ activeThread ]); From 05c9d649f5b0190050dc30e5f62dfecfcb3f9b66 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 20:13:01 +0200 Subject: [PATCH 23/61] =?UTF-8?q?docs(furni):=20spec=20=E2=80=94=20server-?= =?UTF-8?q?authoritative=20furni=20names/descriptions=20from=20JSON=20+=20?= =?UTF-8?q?live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for sourcing furni display names from furnidata JSON (DB keeps technical data), with a live delta-broadcast pipeline (emulator file-watch -> renderer patch -> client refresh) and a security hardening section. Cross-repo reference copy. --- ...-04-furni-names-from-json-server-design.md | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-04-furni-names-from-json-server-design.md diff --git a/docs/superpowers/specs/2026-06-04-furni-names-from-json-server-design.md b/docs/superpowers/specs/2026-06-04-furni-names-from-json-server-design.md new file mode 100644 index 0000000..dfe2e3e --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-furni-names-from-json-server-design.md @@ -0,0 +1,264 @@ +# Furni names from JSON (server-authoritative) — Design + +- **Date:** 2026-06-04 +- **Status:** Draft for review +- **Scope:** Cross-repo — Arcturus (emulator), Nitro_Render_V3 (renderer), Nitro-V3 (client) +- **Out of scope:** furni-editor feature/packets, NitroV3-Housekeeping (CMS), server-side multi-language, description rendering in the infostand. + +## 1. Problem & motivation + +Today a furni's display name lives in **two independent places** that drift apart: + +- **DB** — `items_base.public_name` (`Item.fullName`), used by the emulator. +- **furnidata JSON** — used by the client (the client already resolves all visible furni + names/descriptions from furnidata, keyed by classname). + +This forces admins to maintain names twice and causes mismatches. We want **one source of +truth**: the **furnidata JSON owns display names & descriptions**, the **DB owns technical +data**. Editing furnidata should reflect everywhere — server-pronounced strings and every +connected client — **live**, with no DB edit and no restart. + +This is a single, unified refactor whose payoff is admin furni management: one place to edit, +consistent everywhere. + +## 2. Source-of-truth contract + +| Concern | Owner | Storage | Read by | +|---|---|---|---| +| `classname` (`item_name` / `Item.name`) | **DB** | `items_base.item_name` | join key → furnidata **and** `.nitro` asset; `isPet/isBot`; wired `wf_` fallback | +| technical data (dimensions, `stateCount`, flags, interaction, effects) | **DB** | `items_base.*` | emulator simulation | +| **display name** | **JSON** | furnidata (per classname) | emulator (`getDisplayName`) + client (furnidata, unchanged) | +| **description** | **JSON** | furnidata (per classname) | client only (catalog) — **no server consumer** | + +Invariants: + +1. The **bridge is `classname`**, not a numeric id. `Item.name` ↔ furnidata `classname`. +2. `public_name` (`Item.fullName`) is **NOT removed**: it remains (a) the fallback when a + classname is missing from furnidata, and (b) the technical token for wired furni + (`Item.java:107-116` reads `fullName.startsWith("wf_")`). No schema migration. No DROP. +3. There is **no `description` column** in `items_base`; description is JSON-only and has no + server consumer → the emulator gets **no** `getDescription()`. +4. **One furnidata artifact** is shared truth: the file the emulator indexes must be the same + furnidata the client loads (deploy invariant, §7). +5. Server emits names in the **base locale** of the furnidata file. Player-facing multi-language + stays a client localization-layer concern (unchanged). + +## 3. Architecture — two independent pieces + +The refactor is two pieces that share only the furnidata file and one new packet. They do not +depend on each other. + +- **Piece 1 — Server-authoritative names.** The emulator's pronounced names come from furnidata. +- **Piece 2 — Liveness via delta.** When the furnidata file changes, connected clients (and the + server index) update without reconnecting, via a minimal delta broadcast. + +## 4. Piece 1 — Emulator (server-authoritative names) + +### 4.1 `FurnidataReader` (new, package `com.eu.habbo.habbohotel.items`) + +A neutral, shared reader extracted so the editor is **not touched**. Responsibilities: + +- Resolve the furnidata source reusing the **same already-configured** path as the editor: + `furni.editor.renderer.config.path` → `furnidata.url` → `furni.editor.asset.base.path` + (see `FurniDataManager.resolveSource()` for the exact resolution we mirror). Default to those + values so admins configure **once**. +- Support both layouts the editor already supports: **single file** (`FurnitureData.json`) and + **split-tier directory** (`core/custom/seasonal`, `manifest.json5`, JSON5 with comments; + later tiers override earlier). Reuse the JSON5 strip logic (extract to the shared reader). +- Parse `roomitemtypes` (floor) and `wallitemtypes` (wall) → return a flat list of + `FurnidataEntry { int id, String classname, FurnitureType type, String name, String description }`. + +**Security requirements on the reader (furnidata is untrusted input):** + +- **Path-traversal guard.** When resolving split-tier manifest entries + (`tiers[]`, `files[]`) via `dir.resolve(name)`, normalize the result and **reject any path that + escapes the configured base dir** (absolute paths, `..`). The existing `FurniDataManager` lacks + this guard — the shared reader MUST add it (do not propagate the gap). +- **Size cap.** Refuse to load a furnidata file/dir above a configurable max (default e.g. 64 MB) + to bound parse cost. +- **Sanitization at the boundary.** Every `name`/`description` is sanitized on load: + truncate to **256 chars**, strip control characters and newlines, and **neutralize `%` tokens** + (so they cannot inject into `String.replace` placeholder chains, server- or wired-side). + Normal text/emoji/non-latin scripts pass through. +- **Fail-safe.** Any IO/parse error is caught and logged; the provider keeps the **last-good + index** (or empty on first load) and never throws — boot must not crash on a bad furnidata. + +### 4.2 `FurnitureTextProvider` (new, package `items`) + +- Holds `volatile Map`. +- `reindex()`: read via `FurnidataReader` → build a new immutable map → compute delta vs the + previous map (§5) → atomically swap the reference → return the delta. +- Initialized in `GameEnvironment.load` near `ItemManager`. Resolution is **lazy**, so boot order + is not critical and `Item` objects do not depend on the provider at load time. +- Toggle `items.furnidata.names.enabled` (default `true`). When `false`, `getDisplayName()` + returns the DB value (instant rollback, no recompile). + +### 4.3 `Item.getDisplayName()` + +``` +String getDisplayName(): + if !enabled: return fullName + FurniText t = FurnitureTextProvider.get(this.name /* classname, lowercased */) + return (t != null && t.name not blank) ? t.name : this.fullName // never null +``` + +No `getDescription()` on the server (no consumer). + +### 4.4 Swap list (exhaustive — verified) + +Replace `item.getFullName()` → `item.getDisplayName()` at exactly these 6 sites: + +| Site | Context | +|---|---| +| `CatalogBuyItemAsGiftEvent.java:251` | LTD daily-total alert (gift) | +| `CatalogBuyItemAsGiftEvent.java:262` | LTD daily-item alert (gift) | +| `CatalogManager.java:1057` | LTD daily-total alert (buy) | +| `CatalogManager.java:1063` | LTD daily-item alert (buy) | +| `WiredTextPlaceholderUtil.java:282` | wired `%furni.name%` (keep existing `getName()` ultimate fallback) | +| `WatchAndEarnRewardComposer.java:21` | `appendString(...)` — sends name in a packet | + +**Do NOT change** (technical, use `item_name`/classname): `PresentItemOpenedComposer:24`, +`GiftCommand:72`, `SendGift:82`, `SellItemEvent:37,45`, `CloseDiceEvent:34`, `isPet/isBot`, and the +wired `wf_` fallback in `Item.load`. The catalog offer/page serialization sends **no** display +name (`CatalogItem` serializes `catalog_name` + sprite only) — confirmed, nothing to change there. + +## 5. Piece 2 — Liveness via delta + +### 5.1 Server: file watcher + diff + broadcast + +- A `WatchService` watches the resolved furnidata location on a **single, serialized watcher + thread** (so reindex never races itself). For the **split-tier** layout, register the base dir + and each tier dir. **Debounce** (~750 ms) to coalesce burst writes, plus a **minimum interval + between broadcasts** (e.g. ≥5 s) to cap amplification. +- On settle → `FurnitureTextProvider.reindex()` → diff old vs new **by classname**: + - **added** (new classname) and **changed** (name **or** description differs) → included. + - **removed** classnames → **ignored** (rare; resolved on client reconnect). +- Broadcast decision (anti-DoS): + - delta empty → no broadcast. + - delta size ≤ **cap** (e.g. 500 entries) → broadcast `FurnitureDataReload` in **delta mode**. + - delta size > cap (mass replace) → broadcast in **reload-hint mode** (compact signal; clients + re-load furnidata at next opportunity) instead of a giant per-client payload. +- The broadcast is triggered **only** by the file watcher — there is **no client-initiated reload + path**. This is a security property to preserve (clients cannot induce reindex/broadcast). + +### 5.2 Wire contract — new packet `FurnitureDataReload` + +- **Composer (Arcturus):** `FurnitureDataReloadComposer`, new dedicated header id (pick a free id; + document on both sides). Two modes: + ``` + int mode // 0 = delta, 1 = reload-hint + // mode == 0 (delta): + int count // bounded by the server cap; the client MUST also bound it on read + count × { + string type // "S" (floor) | "I" (wall) + int id // furnidata numeric id (for localization-key + FurnitureData lookup) + string classname + string name // already sanitized server-side + string description + } + // mode == 1 (reload-hint): no further fields (optionally an int revision for cache-busting) + ``` +- **Parser/Event (renderer):** `FurnitureDataReloadEvent` + `FurnitureDataReloadParser` reading the + same shape. The parser **bounds `count`** (reject/clamp absurd values) and tolerates truncation + (`bytesAvailable` pattern) so a malformed/MITM payload cannot allocate unbounded memory. + Registered in `SessionDataManager.init()` via + `GetCommunication().registerMessageEvent(new FurnitureDataReloadEvent(...))` (same pattern as the + existing `FurniDataUpdatedEvent` registration, but a **distinct** handler). + +### 5.3 Renderer: separate patch path (no editor reuse) + +- New method, e.g. `SessionDataManager.applyFurnidataDelta(entries)` — **distinct** from the + editor's `applyLiveFurnitureNameUpdate(...)` (`SessionDataManager.ts:84`), which we leave intact. +- **Delta mode (0):** for each entry, patch the corresponding `FurnitureData` (floor/wall, by `id`) + — update `_localizedName` and `_description` — and re-register the localization keys + `roomItem.name/desc.{id}` / `wallItem.name/desc.{id}` (mirrors `FurnitureDataLoader:105-110`). +- **Reload-hint mode (1):** re-run the furnidata load (`FurnitureDataLoader`, re-fetching + `furnidata.url` with cache-bust) — the appropriate response to a mass change. +- In both modes, after the batch dispatch the window event **once**: + `window.dispatchEvent(new CustomEvent('nitro-localization-updated'))`. + +### 5.4 Client: zero changes + +All three furni surfaces already subscribe to `nitro-localization-updated` and re-derive: + +- catalog — `useCatalog.ts:919` +- inventory — `useInventoryFurni.ts:137` (→ `refreshGroupItemsLocalization`) +- infostand — `useAvatarInfoWidget.ts:425` (→ `getFurniInfo`, which reads `furnitureData.name`) + +No Nitro-V3 edits are required for Piece 2. + +## 6. Admin-facing outcome + +Edit one place — the **furnidata JSON** — and display names update **live** across: +server-pronounced strings (catalog LTD alerts, wired `%furni.name%`, Watch&Earn), and every +connected client's catalog, inventory, and furni infostand. No DB edit, no restart, no double +maintenance. + +## 7. Constraints, risks, invariants + +1. **Locale no-clobber.** If per-locale furni text override files are in use (they override + `roomItem.name.{id}` after furnidata load), a live delta that re-registers base names would + revert overridden ids to base. Mitigation options for the plan: re-apply active overrides after + the delta, or skip the localization-key patch for ids with an active override (still patch the + `FurnitureData` object). For single-furnidata setups (typical retro) there is no override and no + issue. **Document the limitation.** +2. **Deploy invariant.** `furni.editor.asset.base.path`/`furnidata.url` (what the emulator watches) + and the furnidata the client loaded must be the **same artifact**, else the server delta + references entries the client doesn't have. +3. **`public_name` fallback.** Wired `wf_` items absent from furnidata would show the raw `wf_…` + token as their display name (internal/invisible furni — acceptable). +4. **Split-layout watcher.** The watcher must register all tier dirs; missing a tier dir means live + updates from that tier are not detected (resolved on reconnect). +5. **Performance.** `getDisplayName()` is a single `HashMap` lookup on cold paths (catalog alerts, + wired text, Watch&Earn) — negligible. + +## 8. Security + +With this refactor the **furnidata becomes a security-relevant input**: its strings now flow into +server output (catalog LTD alerts, wired `%furni.name%`, the Watch&Earn packet) and into a +broadcast to every connected client. Regular players cannot influence names (names are admin-owned, +keyed by classname); the threat is **untrusted furnidata content** (third-party furni packs, +imports, a compromised editor/supply chain). Controls: + +1. **Boundary sanitization** (see §4.1): cap 256 chars, strip control/newline, **neutralize `%`**. + Neutralizing `%` at load makes every `String.replace("%itemname%", name)` / + `%furni.name%` site injection-safe; as defense-in-depth, substitute the (untrusted) furni name + **last** in any placeholder chain. +2. **Path-traversal guard** in the shared reader (§4.1) — reject manifest paths escaping the base + dir. Closes a gap the current editor reader does not cover. +3. **DoS / amplification controls** (§5.1): single serialized watcher thread, debounce + minimum + broadcast interval, delta-size cap with **reload-hint fallback** for mass changes, furnidata + file-size cap. +4. **Fail-safe loading** (§4.1): bad/corrupt furnidata never crashes boot; last-good index is kept; + `getDisplayName()` falls back to `public_name`. +5. **Robust client parser** (§5.2): bound `count`, tolerate truncation — a malformed/MITM + `FurnitureDataReload` cannot allocate unbounded memory client-side. +6. **No client-triggered reload** (§5.1): only the file watcher broadcasts. Do not add any + client→server reload request. Preserve this property. +7. **Minimal disclosure**: the delta carries **only** `name`/`description` (already public via + furnidata) — never other fields from the server-side file. +8. **Concurrency**: `volatile` index reference + atomic swap + single reindex thread → no torn reads. + +## 9. Testing + +- **Emulator (JUnit):** `FurnidataReader` parses single-file and split-tier (JSON5, tier override); + `FurnitureTextProvider` lookup by lowercased classname, **fallback to `public_name`** when absent, + atomic reindex; `reindex()` diff produces correct added/changed delta and ignores removals; + `Item.getDisplayName()` honors the enable toggle. +- **Renderer (Vitest):** `FurnitureDataReloadParser` reads the payload shape; `applyFurnidataDelta` + patches floor/wall `FurnitureData` by id, re-registers localization keys, dispatches + `nitro-localization-updated` once. +- **Client (Vitest):** existing subscribers (`useCatalog`, `useInventoryFurni`, `useAvatarInfoWidget`) + refresh on `nitro-localization-updated` (regression guard; no new code). +- **Manual acceptance:** edit a furni name in furnidata → live update in catalog + inventory + + infostand without refresh; a wired `%furni.name%` sign and a Watch&Earn reward show the new name. +- **Security tests:** reader rejects a split-tier manifest with `../` traversal; a name containing + `%limit%`/`%user.name%` does not inject into catalog alerts or wired text (`%` neutralized); + oversized furnidata is refused; corrupt furnidata keeps last-good index and does not crash; + a mass change emits a reload-hint (not a giant delta); the client parser clamps an absurd `count`. + +## 10. Open questions + +- Free header id for `FurnitureDataReload` (assign during implementation; document both sides). +- Whether any retro on this stack actually ships per-locale furni override files (governs whether + constraint §7.1 is live or moot). From 25b813c1dbc4b190cb69d9d3c9a81f8cd27bfc2c Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 22:39:49 +0200 Subject: [PATCH 24/61] docs: note furni names are furnidata-driven + live refresh event --- CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index b64a97f..4e9578a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -440,3 +440,13 @@ See `docs/ARCHITECTURE.md` "Recently fixed" for fix shapes. classes/enums kept around just so the `src/api/*` barrel cascade imports without throwing. **Grow this file when a new test needs a symbol; prefer real deterministic stubs over `vi.fn()`.** + +## Furni names (furnidata-driven) + +Furni name/description are furnidata-driven (`FurnitureData` by classname) — the +client does NOT get furni display names from the server. The 3 furni surfaces +refresh live on the window event `nitro-localization-updated`: catalog +(`useCatalog.ts`), inventory (`useInventoryFurni.ts`), infostand +(`useAvatarInfoWidget.ts`). The renderer's `FurnitureDataReload` packet (header +10047) dispatches that event on server-pushed furnidata changes — no client code +needed. From 2504b2e932e1b2bfd85255b849548f467af36ff0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 00:15:22 +0200 Subject: [PATCH 25/61] fix(pixiPatch): repair truncated comment header (upstream cdf962a7 broke it) --- src/pixiPatch.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pixiPatch.ts b/src/pixiPatch.ts index 8fd5a9f..bea0f24 100644 --- a/src/pixiPatch.ts +++ b/src/pixiPatch.ts @@ -1,5 +1,3 @@ -actly once, idempotent across HMR reloads. - */ import * as PIXI from 'pixi.js'; type AnyFn = (...args: unknown[]) => unknown; From 403bdb5abecb73c203262dd2ed24f1fe9df38f6a Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 02:40:24 +0200 Subject: [PATCH 26/61] =?UTF-8?q?docs(furni-editor):=20client/renderer=20p?= =?UTF-8?q?lan=20=E2=80=94=20furnidata=20editing=20UI=20+=20typography=20r?= =?UTF-8?q?efresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-06-06-furni-editor-furnidata-client.md | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-06-furni-editor-furnidata-client.md diff --git a/docs/superpowers/plans/2026-06-06-furni-editor-furnidata-client.md b/docs/superpowers/plans/2026-06-06-furni-editor-furnidata-client.md new file mode 100644 index 0000000..44c57ec --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-furni-editor-furnidata-client.md @@ -0,0 +1,240 @@ +# Furni editor — furnidata editing UI + typography refresh (Client/Renderer) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`). + +**Goal:** Expose the server-side furnidata name/description editing (Plan A, already on Arcturus `main`) in the React furni editor: make Classname/Public Name read-only, add an editable **Furnidata** section (Display Name + Description) with diff-confirm + revert, search by furnidata name, and refresh the editor's typography/colors to the theme tokens. + +**Architecture:** Renderer (`Nitro_Render_V3`) gains 2 outgoing composers matching the server's incoming headers (update **10046**, revert **10048**); the success result reuses the existing `FurniEditorResult` (10044) and live propagation reuses the merged `FurnitureDataReload` (10047). Client (`Nitro-V3`) adds hook actions + UI. A small server tweak lets search match furnidata display names. + +**Tech Stack:** React 19 + Vite + TailwindCSS 4 (theme tokens in `tailwind.config.js`), TS, Vitest (client); TS/PixiJS (renderer); Java/Maven (server tweak). Server feature already built (Plan A). + +**Companion:** spec `Arcturus-Morningstar-Extended/docs/superpowers/specs/2026-06-06-furni-editor-furnidata-names-design.md`; server plan `…/plans/2026-06-06-furni-editor-furnidata-names-server.md`. Exploration of the client (exact file:line) is in this session's history — follow the cited patterns. + +**Server header contract (already on Arcturus main):** incoming `FurniEditorUpdateFurnidataEvent = 10046` reads `int itemId` + `String` (JSON `{name,description}`); incoming `FurniEditorRevertFurnidataEvent = 10048` reads `int itemId`; both respond with `FurniEditorResultComposer` (10044) and broadcast `FurnitureDataReloadComposer` (10047). + +--- + +## Task 1 (renderer): outgoing composers + headers + +**Files (in `E:\Users\simol\Desktop\DEV\Nitro_Render_V3\packages\communication\src\messages`):** +- Modify: `outgoing/OutgoingHeader.ts` (after `FURNI_EDITOR_DELETE = 10045`, ~line 505) +- Create: `outgoing/furnieditor/FurniEditorUpdateFurnidataComposer.ts` +- Create: `outgoing/furnieditor/FurniEditorRevertFurnidataComposer.ts` +- Modify: the furnieditor `index.ts` barrel (same folder as the existing furni-editor composers) + +- [ ] **Step 1: Add headers** in `OutgoingHeader.ts`: +```ts + public static readonly FURNI_EDITOR_UPDATE_FURNIDATA = 10046; + public static readonly FURNI_EDITOR_REVERT_FURNIDATA = 10048; +``` +(Match the real declaration style in that file — `public static readonly NAME: number = id;` or the enum/const pattern actually used. Verify 10046/10048 are unused in OutgoingHeader.) + +- [ ] **Step 2: Create `FurniEditorUpdateFurnidataComposer.ts`** (mirror the existing `FurniEditorUpdateComposer` in the same folder): +```ts +import { IMessageComposer } from '../../../../api'; +import { OutgoingHeader } from '../OutgoingHeader'; + +export class FurniEditorUpdateFurnidataComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor(itemId: number, jsonFields: string) + { + this._data = [ itemId, jsonFields ]; + } + + public getMessageArray() { return this._data; } + public dispose() { this._data = null; } + public getHeader() { return OutgoingHeader.FURNI_EDITOR_UPDATE_FURNIDATA; } +} +``` +**Before writing, open the real `FurniEditorUpdateComposer.ts`** and copy its EXACT structure/imports (the `IMessageComposer` import path + the `getMessageArray/getHeader/dispose` shape may differ from the above; match it verbatim, only changing the header constant and that the payload is `[itemId, jsonFields]`). + +- [ ] **Step 3: Create `FurniEditorRevertFurnidataComposer.ts`** — same pattern, constructor `(itemId: number)`, payload `[ itemId ]`, header `FURNI_EDITOR_REVERT_FURNIDATA`. + +- [ ] **Step 4: Export both** from the furnieditor composers `index.ts` barrel (add the two `export * from './FurniEditor...Composer';` lines next to the existing furni-editor composer exports). + +- [ ] **Step 5: Build** — `cd E:\Users\simol\Desktop\DEV\Nitro_Render_V3 && yarn compile:fast` (or the real compile script in package.json). Expected: clean, no TS errors. + +- [ ] **Step 6: Commit** (renderer repo): +``` +git -C "E:/Users/simol/Desktop/DEV/Nitro_Render_V3" add packages/communication/src/messages/outgoing/OutgoingHeader.ts packages/communication/src/messages/outgoing/furnieditor/ +git -C "E:/Users/simol/Desktop/DEV/Nitro_Render_V3" commit -m "feat(furnieditor): outgoing composers for furnidata update (10046) + revert (10048)" +``` +NO `Co-Authored-By` trailer. + +--- + +## Task 2 (client): hook actions + +**Files:** Modify `E:\Users\simol\Desktop\DEV\Nitro-V3\src\hooks\furni-editor\useFurniEditor.ts` + +- [ ] **Step 1: Parse furnidata name/desc into state.** Where the detail handler parses `furniDataJson` into `furniDataEntry` (lines ~140–152), also derive convenience strings. The `furniDataEntry` is `Record` with `name`/`description` keys. No new state needed — the EditView will read `furniDataEntry?.name`/`furniDataEntry?.description`. (No change required here if the EditView reads `furniDataEntry`; otherwise expose `furniDataName`/`furniDataDescription` strings. Choose the minimal path — prefer reading `furniDataEntry` directly in the view.) + +- [ ] **Step 2: Add actions.** Mirror `updateItem` (lines ~233–239). Add inside the hook body and to the return object (lines ~254–259): +```ts +const updateFurnidata = useCallback((id: number, name: string, description: string) => +{ + pendingActionRef.current = { type: 'update', id }; + setLoading(true); + SendMessageComposer(new FurniEditorUpdateFurnidataComposer(id, JSON.stringify({ name, description }))); +}, []); + +const revertFurnidata = useCallback((id: number) => +{ + pendingActionRef.current = { type: 'update', id }; + setLoading(true); + SendMessageComposer(new FurniEditorRevertFurnidataComposer(id)); +}, []); +``` +Use the REAL send-composer helper this hook already uses (the exploration shows `updateItem` sends `new FurniEditorUpdateComposer(...)` — copy its exact send mechanism, whether `SendMessageComposer(...)` or a local `send`). Import the two new composers from `@nitrots/nitro-renderer`. Reusing `pendingActionRef.type='update'` makes the existing `FurniEditorResultEvent` success handler (lines ~162–210) auto-reload the detail — which is what we want after a furnidata write. + +- [ ] **Step 3: Export** `updateFurnidata`, `revertFurnidata` in the hook's return object. + +- [ ] **Step 4: Typecheck** — `cd E:\Users\simol\Desktop\DEV\Nitro-V3 && yarn typecheck`. Expected: no new errors (pre-existing renderer-SDK TS2307 in a sandbox without the renderer are acceptable, but here the renderer IS present so it should be clean for these files). + +- [ ] **Step 5: Commit:** +``` +git -C "E:/Users/simol/Desktop/DEV/Nitro-V3" add src/hooks/furni-editor/useFurniEditor.ts +git -C "E:/Users/simol/Desktop/DEV/Nitro-V3" commit -m "feat(furni-editor): updateFurnidata/revertFurnidata hook actions" +``` +NO `Co-Authored-By`. + +--- + +## Task 3 (client): EditView — read-only classname/public_name + editable Furnidata section + props + +**Files:** Modify `src\components\furni-editor\views\FurniEditorEditView.tsx` and `src\components\furni-editor\FurniEditorView.tsx`. + +- [ ] **Step 1: Thread props.** In `FurniEditorEditViewProps` add `onUpdateFurnidata: (id: number, name: string, description: string) => void;` and `onRevertFurnidata: (id: number) => void;`. In `FurniEditorView.tsx` (where `` is rendered, ~lines 149–158), pass `onUpdateFurnidata={ updateFurnidata }` and `onRevertFurnidata={ revertFurnidata }` (destructure them from `useFurniEditor()`). + +- [ ] **Step 2: Make Classname + Public Name read-only.** In the Basic Info section (lines ~232–256): replace the **Item Name** `` with a read-only display, relabel to **"Classname"**, and render the value in monospace on a muted background (see Task 4 classes). Same for **Public Name** (label it "Public Name (DB fallback)"). Use a shared `readonlyClass` (Task 4). Keep `form.itemName`/`form.publicName` in state (so `updateItem` still sends unchanged values harmlessly) but do NOT let them be edited. Example: +```tsx +
+ +
{ form.itemName }
+
+
+ +
{ form.publicName }
+
+``` + +- [ ] **Step 3: New editable Furnidata section.** Replace the read-only `FurniData.json` section (lines ~323–334) with: +```tsx +
+ +
+ + setFurniName(e.target.value) } maxLength={ 256 } /> +
+
+ +