mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 06:56:20 +00:00
Merge pull request #216 from simoleo89/feat/furnidata-debug-diagnostics
Show furnidata diagnostics in editor
This commit is contained in:
+40
-26
@@ -54,9 +54,12 @@ jobs:
|
||||
# 2. repository variables (vars.RENDERER_REPO / vars.RENDERER_REF)
|
||||
# → per-fork config set under Settings → Variables, applies
|
||||
# to push and pull_request runs without editing this file.
|
||||
# 3. upstream default
|
||||
# → UPSTREAM_RENDERER_REPO, ref `main` when the client build
|
||||
# context is `main`, otherwise `Dev`.
|
||||
# 3. dynamic, owner-aware default (no hardcoded fork name)
|
||||
# → <github.repository_owner>/Nitro_Render_V3 when it carries
|
||||
# the resolved ref, else UPSTREAM_RENDERER_REPO. Ref is
|
||||
# `main` on a main build, otherwise `Dev`. So a fork's
|
||||
# client pairs with the fork's renderer when the companion
|
||||
# code lives there, and with upstream when it doesn't.
|
||||
#
|
||||
# The two repos must stay wire-aligned (composer/parser
|
||||
# signatures); pairing the client with a stale renderer is what
|
||||
@@ -72,42 +75,53 @@ jobs:
|
||||
VAR_REPO: ${{ vars.RENDERER_REPO }}
|
||||
VAR_REF: ${{ vars.RENDERER_REF }}
|
||||
run: |
|
||||
# Branch-aware auto pairing — the default when neither a
|
||||
# dispatch input nor a repo variable is supplied.
|
||||
# Dynamic, owner-aware renderer pairing — nothing is hardcoded to a
|
||||
# specific fork. The companion renderer is discovered from the
|
||||
# client repo's OWNER first, then the upstream fallback: "if the
|
||||
# companion code is on my fork, pair with my fork; otherwise pair
|
||||
# with upstream". For PRs the context is the base ref.
|
||||
#
|
||||
# Everything (including the custom features — rare values,
|
||||
# fortune wheel, soundboard) now lives on duckietm's own
|
||||
# `main` / `Dev` branches, so the renderer always pairs
|
||||
# against UPSTREAM_RENDERER_REPO: `main` when the client build
|
||||
# context is `main`, otherwise `Dev`. For PRs the context is
|
||||
# the base ref.
|
||||
# Precedence (most specific wins):
|
||||
# 1. workflow_dispatch inputs (renderer_repo / renderer_ref)
|
||||
# 2. repo variables (vars.RENDERER_REPO / vars.RENDERER_REF)
|
||||
# 3. dynamic: <owner>/Nitro_Render_V3 when it carries the ref,
|
||||
# else ${UPSTREAM_RENDERER_REPO}.
|
||||
case "${GITHUB_EVENT_NAME}" in
|
||||
pull_request)
|
||||
CTX="${GITHUB_BASE_REF}"
|
||||
;;
|
||||
*)
|
||||
CTX="${GITHUB_REF_NAME}"
|
||||
;;
|
||||
pull_request) CTX="${GITHUB_BASE_REF}" ;;
|
||||
*) CTX="${GITHUB_REF_NAME}" ;;
|
||||
esac
|
||||
|
||||
AUTO_REPO="${UPSTREAM_RENDERER_REPO}"
|
||||
# Branch-aware desired ref: main on a main build, else Dev.
|
||||
case "$CTX" in
|
||||
main) AUTO_REF="main" ;;
|
||||
*) AUTO_REF="Dev" ;;
|
||||
esac
|
||||
|
||||
# Precedence (most specific wins): dispatch input → repo
|
||||
# variable → branch-aware auto default. The auto default is
|
||||
# the final fallback so a Dev/feat build never silently pairs
|
||||
# against a renderer that's missing its companion exports.
|
||||
REPO="$IN_REPO"
|
||||
[ -z "$REPO" ] && REPO="$VAR_REPO"
|
||||
[ -z "$REPO" ] && REPO="$AUTO_REPO"
|
||||
|
||||
REF="$IN_REF"
|
||||
[ -z "$REF" ] && REF="$VAR_REF"
|
||||
[ -z "$REF" ] && REF="$AUTO_REF"
|
||||
|
||||
# Probe whether <repo> has branch <ref> (remote-only, no checkout).
|
||||
has_ref() { git ls-remote --exit-code --heads "https://github.com/$1.git" "$2" >/dev/null 2>&1; }
|
||||
|
||||
REPO="$IN_REPO"
|
||||
[ -z "$REPO" ] && REPO="$VAR_REPO"
|
||||
if [ -z "$REPO" ]; then
|
||||
OWN_REPO="${GITHUB_REPOSITORY_OWNER}/Nitro_Render_V3"
|
||||
if has_ref "$OWN_REPO" "$REF"; then
|
||||
REPO="$OWN_REPO" # companion lives on my own fork
|
||||
else
|
||||
REPO="$UPSTREAM_RENDERER_REPO" # fall back to upstream
|
||||
fi
|
||||
fi
|
||||
|
||||
# Safety net: never pair against a repo/ref that doesn't exist.
|
||||
if ! has_ref "$REPO" "$REF"; then
|
||||
echo "::warning::renderer '$REPO' has no branch '$REF' — falling back to ${UPSTREAM_RENDERER_REPO}"
|
||||
REPO="$UPSTREAM_RENDERER_REPO"
|
||||
has_ref "$REPO" "$REF" || REF="Dev"
|
||||
fi
|
||||
|
||||
echo "repo=$REPO" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved renderer pairing: $REPO @ $REF (client ctx: $CTX, event: ${GITHUB_EVENT_NAME})"
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,447 @@
|
||||
# Messenger Phase 2 — Offline Messages Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Messages sent to an offline friend are stored and delivered on the recipient's next login, tagged "sent while offline".
|
||||
|
||||
**Architecture:** No new packets. The emulator stores send-to-offline in the existing `messenger_offline` table; on login it replays them through the existing `FriendChatMessageComposer` (extended with an optional `extraData` marker) so the client's existing `NewConsoleMessageEvent` path renders them. The client adds an `offlineDelivered` flag (derived from `extraData === "offline"`) and a subtle marker in the thread.
|
||||
|
||||
**Tech Stack:** Arcturus (Java 21/Maven/HikariCP), Nitro-V3 (React 19, Vite, Vitest). No renderer change.
|
||||
|
||||
---
|
||||
|
||||
## Branches
|
||||
All repos are already on `feat/messenger-groups-receipts` (continuing the messenger initiative). Continue committing there. Client commits use the house-rule author override `git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com`. No Co-Authored-By / AI attribution anywhere.
|
||||
|
||||
## Pre-flight: how messages flow today (read once)
|
||||
- `FriendPrivateMessageEvent` (incoming) → `buddy.onMessageReceived(sender, message)` which delivers via `FriendChatMessageComposer` ONLY if the recipient is online (else the message is silently dropped — that's the gap we close).
|
||||
- On login the client sends `MessengerInitComposer` → emulator `RequestInitFriendsEvent` sends `MessengerInitComposer` + the friend list.
|
||||
- `FriendChatMessageComposer.composeInternal()` appends: `toId` (the sender id shown to the recipient), message text, `secondsSinceSent` (= now − message.timestamp). For group chat (`toId < 0`) it appends an extra `name/look/id` string. For 1:1 it appends nothing after `secondsSinceSent`.
|
||||
- Client: `useMessenger` subscribes to `NewConsoleMessageEvent` and calls `sendMessage(thread, parser.senderId, parser.messageText, parser.secondsSinceSent, parser.extraData)`, which stores `extraData` on the created `MessengerThreadChat`.
|
||||
- `messenger_offline` columns (verified): `id` (PK auto), `user_id` (recipient), `user_from_id` (sender), `message` varchar(500), `sended_on` int (unix).
|
||||
|
||||
## File map
|
||||
**Emulator (`Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/`):**
|
||||
- Modify `habbohotel/messenger/Message.java` — add a `(fromId, toId, message, timestamp)` constructor.
|
||||
- Modify `messages/outgoing/friends/FriendChatMessageComposer.java` — optional `extraData` appended for 1:1.
|
||||
- Modify `habbohotel/messenger/Messenger.java` — `addOfflineMessage(...)` + `deliverOfflineMessages(...)` + cap constant.
|
||||
- Modify `messages/incoming/friends/FriendPrivateMessageEvent.java` — branch online vs offline.
|
||||
- Modify `messages/incoming/friends/RequestInitFriendsEvent.java` — deliver offline on login.
|
||||
|
||||
**Client (`Nitro-V3/src/`):**
|
||||
- Modify `api/friends/MessengerThreadChat.ts` — `offlineDelivered` getter.
|
||||
- Create `api/friends/MessengerThreadChat.test.ts` — getter test.
|
||||
- Modify `components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx` — render marker.
|
||||
- Modify `public/configuration/UITexts.example` — add `messenger.offline.delivered` text key.
|
||||
- Modify a messenger CSS file — `.messenger-offline-tag` style.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Emulator — `Message` timestamp constructor + composer `extraData`
|
||||
|
||||
**Files:**
|
||||
- Modify: `Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Message.java`
|
||||
- Modify: `Emulator/src/main/java/com/eu/habbo/messages/outgoing/friends/FriendChatMessageComposer.java`
|
||||
|
||||
> Emulator has no unit tests; verification is `mvn compile`.
|
||||
|
||||
- [ ] **Step 1: Add a timestamp constructor to `Message`**
|
||||
|
||||
`Message` has `private final int timestamp;` set to `Emulator.getIntUnixTimestamp()` in the existing constructor. Add a second constructor that accepts an explicit timestamp (needed so a replayed offline message reports its original age). Add it right after the existing constructor (after the block ending at the line `this.timestamp = Emulator.getIntUnixTimestamp(); }`):
|
||||
```java
|
||||
public Message(int fromId, int toId, String message, int timestamp) {
|
||||
this.fromId = fromId;
|
||||
this.toId = toId;
|
||||
this.message = message;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add optional `extraData` to `FriendChatMessageComposer`**
|
||||
|
||||
Add an `extraData` field + a 4-arg constructor, and append it for the 1:1 path. Replace the field/constructor region and the `composeInternal` tail.
|
||||
|
||||
Add the field next to the existing fields:
|
||||
```java
|
||||
private String extraData = null;
|
||||
```
|
||||
Add this constructor after the existing `FriendChatMessageComposer(Message message, int toId, int fromId)`:
|
||||
```java
|
||||
public FriendChatMessageComposer(Message message, int toId, int fromId, String extraData) {
|
||||
this.message = message;
|
||||
this.toId = toId;
|
||||
this.fromId = fromId;
|
||||
this.extraData = extraData;
|
||||
}
|
||||
```
|
||||
In `composeInternal()`, the existing `if (this.toId < 0) { ...group chat... }` block stays. Immediately AFTER that `if` block (before `return this.response;`), add an `else if` so 1:1 messages with a marker append it (online 1:1 messages pass `extraData == null` and append nothing — wire unchanged):
|
||||
```java
|
||||
else if (this.extraData != null) {
|
||||
this.response.appendString(this.extraData);
|
||||
}
|
||||
```
|
||||
The result is:
|
||||
```java
|
||||
if (this.toId < 0) // group chat
|
||||
{
|
||||
// ... existing group block unchanged ...
|
||||
this.response.appendString(name + "/" + look + "/" + this.fromId);
|
||||
}
|
||||
else if (this.extraData != null) {
|
||||
this.response.appendString(this.extraData);
|
||||
}
|
||||
|
||||
return this.response;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Compile**
|
||||
|
||||
Run: `cd Arcturus-Morningstar-Extended/Emulator && mvn -q compile`
|
||||
Expected: BUILD SUCCESS.
|
||||
|
||||
- [ ] **Step 4: Commit (only these 2 files)**
|
||||
```bash
|
||||
cd Arcturus-Morningstar-Extended
|
||||
git add Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Message.java Emulator/src/main/java/com/eu/habbo/messages/outgoing/friends/FriendChatMessageComposer.java
|
||||
git commit -m "feat(messenger): Message timestamp ctor + optional extraData on chat composer"
|
||||
```
|
||||
Verify with `git show --stat HEAD` that ONLY these 2 files are committed (the working tree also has an unrelated `soundboard/SoundboardPlayEvent.java` modification and untracked jars — never stage those).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Emulator — offline message store + deliver helpers in `Messenger`
|
||||
|
||||
**Files:**
|
||||
- Modify: `Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Messenger.java`
|
||||
|
||||
`Messenger` already has static DB methods (e.g. `unfriend`) using `Emulator.getDatabase().getDataSource().getConnection()` with try-with-resources, and a `LOGGER`. `Message` and `MessengerCategory` are in the same package (no import needed). You WILL need imports for `java.sql.ResultSet`, `java.util.ArrayList`, `java.util.List`, `com.eu.habbo.habbohotel.gameclients.GameClient`, and `com.eu.habbo.messages.outgoing.friends.FriendChatMessageComposer` — check the existing import block and add whichever are missing.
|
||||
|
||||
- [ ] **Step 1: Add the cap constant**
|
||||
|
||||
Near the other `Messenger` constants (e.g. by `MAXIMUM_FRIENDS`), add:
|
||||
```java
|
||||
public static final int MAXIMUM_OFFLINE_MESSAGES = 200;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `addOfflineMessage`**
|
||||
|
||||
Stores one offline message for `toId`, evicting the oldest if the per-user inbox is at the cap. Add as a static method:
|
||||
```java
|
||||
public static void addOfflineMessage(int fromId, int toId, String message) {
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) {
|
||||
try (PreparedStatement count = connection.prepareStatement("SELECT COUNT(*) FROM messenger_offline WHERE user_id = ?")) {
|
||||
count.setInt(1, toId);
|
||||
try (ResultSet set = count.executeQuery()) {
|
||||
if (set.next() && set.getInt(1) >= MAXIMUM_OFFLINE_MESSAGES) {
|
||||
try (PreparedStatement delete = connection.prepareStatement("DELETE FROM messenger_offline WHERE user_id = ? ORDER BY id ASC LIMIT 1")) {
|
||||
delete.setInt(1, toId);
|
||||
delete.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement insert = connection.prepareStatement("INSERT INTO messenger_offline (user_id, user_from_id, message, sended_on) VALUES (?, ?, ?, ?)")) {
|
||||
insert.setInt(1, toId);
|
||||
insert.setInt(2, fromId);
|
||||
insert.setString(3, message);
|
||||
insert.setInt(4, Emulator.getIntUnixTimestamp());
|
||||
insert.execute();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Caught SQL exception", e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `deliverOfflineMessages`**
|
||||
|
||||
Loads any stored messages for the logging-in user (oldest first), replays each through `FriendChatMessageComposer` with the `"offline"` marker and the original timestamp, then deletes the delivered rows.
|
||||
```java
|
||||
public static void deliverOfflineMessages(GameClient client) {
|
||||
if (client == null || client.getHabbo() == null) return;
|
||||
|
||||
int userId = client.getHabbo().getHabboInfo().getId();
|
||||
List<Message> messages = new ArrayList<>();
|
||||
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("SELECT user_from_id, message, sended_on FROM messenger_offline WHERE user_id = ? ORDER BY sended_on ASC, id ASC")) {
|
||||
statement.setInt(1, userId);
|
||||
try (ResultSet set = statement.executeQuery()) {
|
||||
while (set.next()) {
|
||||
messages.add(new Message(set.getInt("user_from_id"), userId, set.getString("message"), set.getInt("sended_on")));
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Caught SQL exception", e);
|
||||
}
|
||||
|
||||
if (messages.isEmpty()) return;
|
||||
|
||||
for (Message message : messages) {
|
||||
client.sendResponse(new FriendChatMessageComposer(message, message.getFromId(), message.getFromId(), "offline"));
|
||||
}
|
||||
|
||||
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("DELETE FROM messenger_offline WHERE user_id = ?")) {
|
||||
statement.setInt(1, userId);
|
||||
statement.execute();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Caught SQL exception", e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Compile**
|
||||
|
||||
Run: `cd Arcturus-Morningstar-Extended/Emulator && mvn -q compile`
|
||||
Expected: BUILD SUCCESS. If it fails on a missing symbol, add the missing import (see the list at the top of this task).
|
||||
|
||||
- [ ] **Step 5: Commit (only Messenger.java)**
|
||||
```bash
|
||||
cd Arcturus-Morningstar-Extended
|
||||
git add Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Messenger.java
|
||||
git commit -m "feat(messenger): store + deliver offline messages (capped inbox)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Emulator — wire send-to-offline + deliver-on-login
|
||||
|
||||
**Files:**
|
||||
- Modify: `Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/FriendPrivateMessageEvent.java`
|
||||
- Modify: `Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/RequestInitFriendsEvent.java`
|
||||
|
||||
- [ ] **Step 1: Branch online vs offline in `FriendPrivateMessageEvent`**
|
||||
|
||||
Currently the handler ends with `buddy.onMessageReceived(this.client.getHabbo(), message);`. Replace that single line with a branch: deliver if the recipient is online, otherwise store (word-filtered, matching the filtering the online path applies before sending). Add imports `com.eu.habbo.habbohotel.messenger.Messenger` and `com.eu.habbo.habbohotel.modtool.WordFilter`.
|
||||
|
||||
Replace:
|
||||
```java
|
||||
buddy.onMessageReceived(this.client.getHabbo(), message);
|
||||
```
|
||||
with:
|
||||
```java
|
||||
if (Emulator.getGameServer().getGameClientManager().getHabbo(userId) != null) {
|
||||
buddy.onMessageReceived(this.client.getHabbo(), message);
|
||||
} else {
|
||||
String stored = message;
|
||||
if (WordFilter.ENABLED_FRIENDCHAT) {
|
||||
stored = Emulator.getGameEnvironment().getWordFilter().filter(message, this.client.getHabbo());
|
||||
}
|
||||
Messenger.addOfflineMessage(this.client.getHabbo().getHabboInfo().getId(), userId, stored);
|
||||
}
|
||||
```
|
||||
(`Emulator` is already imported in this file. `userId` is the recipient read at the top of `handle()`.)
|
||||
|
||||
- [ ] **Step 2: Deliver offline messages on login in `RequestInitFriendsEvent`**
|
||||
|
||||
After the existing `this.client.sendResponses(messages);`, add a call to deliver any stored offline messages (sent AFTER the friend list so the client's thread lookup can resolve the sender as a known friend). Add import `com.eu.habbo.habbohotel.messenger.Messenger`.
|
||||
|
||||
The method becomes:
|
||||
```java
|
||||
public void handle() throws Exception {
|
||||
ArrayList<ServerMessage> messages = new ArrayList<>();
|
||||
messages.add(new MessengerInitComposer(this.client.getHabbo()).compose());
|
||||
messages.addAll(FriendsComposer.getMessagesForBuddyList(this.client.getHabbo().getMessenger().getFriends().values()));
|
||||
this.client.sendResponses(messages);
|
||||
|
||||
Messenger.deliverOfflineMessages(this.client);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build the fat jar**
|
||||
|
||||
Run: `cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests`
|
||||
Expected: BUILD SUCCESS.
|
||||
|
||||
- [ ] **Step 4: Commit (only these 2 files)**
|
||||
```bash
|
||||
cd Arcturus-Morningstar-Extended
|
||||
git add Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/FriendPrivateMessageEvent.java Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/RequestInitFriendsEvent.java
|
||||
git commit -m "feat(messenger): persist messages to offline friends, replay on login"
|
||||
```
|
||||
Verify `git show --stat HEAD` shows exactly 2 files.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Client — `offlineDelivered` getter on `MessengerThreadChat` (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Nitro-V3/src/api/friends/MessengerThreadChat.ts`
|
||||
- Test: `Nitro-V3/src/api/friends/MessengerThreadChat.test.ts`
|
||||
|
||||
`MessengerThreadChat` already stores `_extraData` and `_type`, with `CHAT = 0`. The emulator sends `extraData === "offline"` only for replayed 1:1 messages.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/api/friends/MessengerThreadChat.test.ts`:
|
||||
```typescript
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MessengerThreadChat } from './MessengerThreadChat';
|
||||
|
||||
describe('MessengerThreadChat.offlineDelivered', () =>
|
||||
{
|
||||
it('is true for a CHAT message with extraData "offline"', () =>
|
||||
{
|
||||
const chat = new MessengerThreadChat(5, 'hello', 60, 'offline', MessengerThreadChat.CHAT);
|
||||
expect(chat.offlineDelivered).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a normal CHAT message with no extraData', () =>
|
||||
{
|
||||
const chat = new MessengerThreadChat(5, 'hello', 0, null, MessengerThreadChat.CHAT);
|
||||
expect(chat.offlineDelivered).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when extraData is some other value (e.g. group chat data)', () =>
|
||||
{
|
||||
const chat = new MessengerThreadChat(5, 'hi', 0, 'Bob/figurestr/5', MessengerThreadChat.CHAT);
|
||||
expect(chat.offlineDelivered).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a non-CHAT type even if extraData is "offline"', () =>
|
||||
{
|
||||
const chat = new MessengerThreadChat(5, 'hi', 0, 'offline', MessengerThreadChat.ROOM_INVITE);
|
||||
expect(chat.offlineDelivered).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, confirm FAIL**
|
||||
|
||||
Run: `cd Nitro-V3 && yarn test --run src/api/friends/MessengerThreadChat.test.ts`
|
||||
Expected: FAIL — `offlineDelivered` is not a function/getter.
|
||||
|
||||
- [ ] **Step 3: Add the getter**
|
||||
|
||||
In `MessengerThreadChat.ts`, add after the `extraData` getter:
|
||||
```typescript
|
||||
public get offlineDelivered(): boolean
|
||||
{
|
||||
return (this._type === MessengerThreadChat.CHAT) && (this._extraData === 'offline');
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test, confirm PASS (4 cases).**
|
||||
|
||||
- [ ] **Step 5: Type-check + full suite**
|
||||
|
||||
Run: `cd Nitro-V3 && yarn typecheck && yarn test --run`
|
||||
Expected: typecheck shows only the known pre-existing `FloorplanCanvasSVG.tsx(143,20): TS2503`; tests green except the 3 known pre-existing floorplan failures.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
cd Nitro-V3
|
||||
git add src/api/friends/MessengerThreadChat.ts src/api/friends/MessengerThreadChat.test.ts
|
||||
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): offlineDelivered flag on thread chat"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Client — render the "sent while offline" marker
|
||||
|
||||
**Files:**
|
||||
- Modify: `Nitro-V3/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx`
|
||||
- Modify: `Nitro-V3/public/configuration/UITexts.example`
|
||||
- Modify: a messenger CSS file (see Step 3)
|
||||
|
||||
- [ ] **Step 1: Add the localization key**
|
||||
|
||||
In `public/configuration/UITexts.example`, add a key near other `messenger.*` entries (match the file's JSON format — confirm by reading an existing `messenger.` line):
|
||||
```json
|
||||
"messenger.offline.delivered": "Sent while you were offline",
|
||||
```
|
||||
(The user can localize the value in their live `UITexts` later. `LocalizeText` falls back to the key string if absent, so this never crashes.)
|
||||
|
||||
- [ ] **Step 2: Render the marker in the message bubble**
|
||||
|
||||
In `FriendsMessengerThreadGroup.tsx`, the bubble maps `group.chats`. `LocalizeText` is already imported. In the NON-translation branch (the `if(!chat.showTranslation)` return), append the marker when `chat.offlineDelivered`. Replace:
|
||||
```tsx
|
||||
if(!chat.showTranslation)
|
||||
{
|
||||
return <Base key={ index } className="text-break">{ chat.message }</Base>;
|
||||
}
|
||||
```
|
||||
with:
|
||||
```tsx
|
||||
if(!chat.showTranslation)
|
||||
{
|
||||
return (
|
||||
<Base key={ index } className="text-break">
|
||||
{ chat.message }
|
||||
{ chat.offlineDelivered &&
|
||||
<span className="messenger-offline-tag">{ LocalizeText('messenger.offline.delivered') }</span> }
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
```
|
||||
(Leave the translation branch as-is — an offline message that is also auto-translated is a rare combination and the marker on the plain branch covers the normal case.)
|
||||
|
||||
- [ ] **Step 3: Add the marker style**
|
||||
|
||||
Find the CSS file that styles the messenger thread (search for an existing class used here, e.g. `messenger-message-bubble` or `messenger-message-time`):
|
||||
Run: `grep -rl "messenger-message-bubble" Nitro-V3/src/css`
|
||||
Append to that file a subtle style:
|
||||
```css
|
||||
.messenger-offline-tag {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
```
|
||||
If the grep finds no file (the classes are global/elsewhere), append the same rule to `src/css/friends/FriendsView.css` instead, and note that in your report.
|
||||
|
||||
- [ ] **Step 4: Type-check + full suite**
|
||||
|
||||
Run: `cd Nitro-V3 && yarn typecheck && yarn test --run`
|
||||
Expected: only the known pre-existing typecheck error; no new test failures.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
cd Nitro-V3
|
||||
git add src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx public/configuration/UITexts.example
|
||||
# plus the CSS file you edited:
|
||||
git add <the css file from Step 3>
|
||||
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): show 'sent while offline' marker in thread"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Integration verification
|
||||
|
||||
**Files:** none (manual + automated checks; fix-up commits only).
|
||||
|
||||
- [ ] **Step 1: Automated checks**
|
||||
```
|
||||
cd Nitro-V3 && yarn typecheck && yarn test --run
|
||||
cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests
|
||||
```
|
||||
Expected: client typecheck shows only the pre-existing `FloorplanCanvasSVG.tsx(143,20): TS2503`; client tests green except the 3 known floorplan failures (+4 new MessengerThreadChat cases passing); emulator BUILD SUCCESS. (No renderer change this phase.)
|
||||
|
||||
- [ ] **Step 2: Live two-session manual test**
|
||||
|
||||
Run the new jar + `yarn start`. With two accounts A and B who are friends:
|
||||
1. **Store while offline:** B logs out. A opens the messenger thread with B and sends a message. (A's client shows the sent message as normal.)
|
||||
2. **Deliver on login:** B logs in → the message appears in B's thread with A, carrying the "Sent while you were offline" marker.
|
||||
3. **Order + multiple:** A sends 3 messages while B is offline → on B's login all 3 appear in order, each marked, and the `messenger_offline` rows for B are gone (delivered + deleted):
|
||||
`SELECT * FROM messenger_offline WHERE user_id = <B id>;` → 0 rows after login.
|
||||
4. **Online still instant:** with both online, messages deliver immediately and show NO offline marker (wire unchanged for online 1:1).
|
||||
5. **Cap:** (optional) inserting >200 stored messages for one user evicts the oldest.
|
||||
6. **No regressions:** room invites, group/staff chat, and normal messaging still work.
|
||||
|
||||
- [ ] **Step 3: Commit any fix-ups** (only if needed)
|
||||
```bash
|
||||
cd Nitro-V3
|
||||
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -am "fix(messenger): offline message integration fixes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes / scope boundaries
|
||||
- **Word filter:** offline messages are word-filtered at store time (sender online) so the recipient sees filtered text, matching the online path. They are NOT written to `chatlogs_private` (offline messages were never chat-logged before; adding that is out of scope).
|
||||
- **Displayed time:** the thread shows the client receive-time (existing behavior); `secondsSinceSent` is sent but the bubble timestamp is local. The "sent while offline" marker is what signals the message is delayed; back-dating the bubble timestamp is out of scope.
|
||||
- **Read receipts (Phase 3)** will mark these delivered-on-login messages as read once the recipient opens the thread — not part of Phase 2.
|
||||
- Do NOT push/merge automatically; the branch already carries Phase 1 + the user's own `feat(chat)` commit.
|
||||
@@ -0,0 +1,586 @@
|
||||
# Messenger Phase 3 — Read Receipts (✓ / ✓✓) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** WhatsApp-style 2-state read receipts on your own sent messages — `✓` sent, `✓✓` read — driven by a live relay (no persistence).
|
||||
|
||||
**Architecture:** Two new CUSTOM packets. Client→server `MarkConsoleRead(peerId)` is sent when you focus/read a conversation. The emulator relays it to the peer (if online and a friend) as server→client `ConsoleReadReceipt(readerId)`. The recipient's client marks its own messages in that conversation as READ and renders `✓✓`.
|
||||
|
||||
**Design note — no DB, live-relay only (refinement of the spec):** the spec proposed a `messenger_read_state` table + a login-time receipt batch. The Nitro client does NOT persist per-message history across sessions, so a persisted receipt would have no message to update on next login. Persistence is therefore omitted; receipts are a live in-session relay. This keeps Phase 3 simpler with no loss of user-visible behavior. (If cross-session receipts are ever wanted, they'd require persisting client-side message history first — out of scope.)
|
||||
|
||||
**Tech Stack:** Arcturus (Java 21/Maven), Nitro_Render_V3 (TypeScript, Vitest), Nitro-V3 (React 19, Vitest).
|
||||
|
||||
---
|
||||
|
||||
## Branches & rules
|
||||
All repos on `feat/messenger-groups-receipts`. Client commits use `git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com`. No Co-Authored-By / AI attribution. Emulator working tree has an UNRELATED modified `soundboard/SoundboardPlayEvent.java` + untracked jars — never stage those; `git add` only the listed files.
|
||||
|
||||
## Header IDs (custom, verified free in all 4 files)
|
||||
| Packet | Direction | Renderer header | Emulator header | Value |
|
||||
|---|---|---|---|---|
|
||||
| MarkConsoleRead | client→server | `OutgoingHeader.MARK_CONSOLE_READ` | `Incoming.MarkConsoleReadEvent` | **4085** |
|
||||
| ConsoleReadReceipt | server→client | `IncomingHeader.CONSOLE_READ_RECEIPT` | `Outgoing.ConsoleReadReceiptComposer` | **4086** |
|
||||
|
||||
(Renderer Outgoing N == Emulator Incoming N; Renderer Incoming N == Emulator Outgoing N — the verified convention.)
|
||||
|
||||
## File map
|
||||
**Renderer (`Nitro_Render_V3/packages/communication/src/`):**
|
||||
- Modify `messages/outgoing/OutgoingHeader.ts`, `messages/incoming/IncomingHeader.ts`, `NitroMessages.ts`, the 3 friendlist `index.ts` barrels.
|
||||
- Create `messages/outgoing/friendlist/MarkConsoleReadComposer.ts`
|
||||
- Create `messages/incoming/friendlist/ConsoleReadReceiptEvent.ts`
|
||||
- Create `messages/parser/friendlist/ConsoleReadReceiptParser.ts`
|
||||
- Create `messages/parser/friendlist/__tests__/ConsoleReadReceiptParser.test.ts`
|
||||
|
||||
**Emulator (`Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/`):**
|
||||
- Modify `messages/incoming/Incoming.java`, `messages/outgoing/Outgoing.java`, `messages/PacketManager.java`
|
||||
- Create `messages/incoming/friends/MarkConsoleReadEvent.java`
|
||||
- Create `messages/outgoing/friends/ConsoleReadReceiptComposer.java`
|
||||
|
||||
**Client (`Nitro-V3/src/`):**
|
||||
- Modify `api/friends/MessengerThreadChat.ts` (+ test) and `api/friends/MessengerThread.ts` (+ test)
|
||||
- Modify `hooks/friends/useMessenger.ts`
|
||||
- Modify `components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx`
|
||||
- Modify `src/css/friends/FriendsView.css`
|
||||
|
||||
---
|
||||
|
||||
## Task P3-1: Renderer — packets + registration + parser test
|
||||
|
||||
**Files:** see File map (renderer).
|
||||
|
||||
- [ ] **Step 1: Write the failing parser test**
|
||||
|
||||
Create `packages/communication/src/messages/parser/friendlist/__tests__/ConsoleReadReceiptParser.test.ts` (mirror the existing `__tests__/FriendCategoryComposers.test.ts` / mentions parser test style with a `TestWrapper` over `BinaryReader`/`BinaryWriter`):
|
||||
```typescript
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BinaryReader, BinaryWriter } from '@nitrots/utils';
|
||||
import { ConsoleReadReceiptParser } from '../ConsoleReadReceiptParser';
|
||||
|
||||
class TestWrapper
|
||||
{
|
||||
constructor(private reader: BinaryReader) {}
|
||||
readByte() { return this.reader.readByte(); }
|
||||
readShort() { return this.reader.readShort(); }
|
||||
readInt() { return this.reader.readInt(); }
|
||||
readString() { const len = this.reader.readShort(); return this.reader.readBytes(len).toString(); }
|
||||
header = 0;
|
||||
get bytesAvailable() { return this.reader.remaining() > 0; }
|
||||
}
|
||||
|
||||
describe('ConsoleReadReceiptParser', () =>
|
||||
{
|
||||
it('parses the reader id', () =>
|
||||
{
|
||||
const w = new BinaryWriter();
|
||||
w.writeInt(42);
|
||||
const parser = new ConsoleReadReceiptParser();
|
||||
parser.flush();
|
||||
parser.parse(new TestWrapper(new BinaryReader(w.getBuffer())) as any);
|
||||
expect(parser.readerId).toBe(42);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, confirm FAIL**
|
||||
|
||||
Run: `cd Nitro_Render_V3 && yarn test --run packages/communication/src/messages/parser/friendlist/__tests__/ConsoleReadReceiptParser.test.ts`
|
||||
Expected: FAIL (module not found).
|
||||
|
||||
- [ ] **Step 3: Create the parser**
|
||||
|
||||
`packages/communication/src/messages/parser/friendlist/ConsoleReadReceiptParser.ts` (mirror `NewConsoleMessageParser`):
|
||||
```typescript
|
||||
import { IMessageDataWrapper, IMessageParser } from '@nitrots/api';
|
||||
|
||||
export class ConsoleReadReceiptParser implements IMessageParser
|
||||
{
|
||||
private _readerId: number;
|
||||
|
||||
public flush(): boolean
|
||||
{
|
||||
this._readerId = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public parse(wrapper: IMessageDataWrapper): boolean
|
||||
{
|
||||
if(!wrapper) return false;
|
||||
|
||||
this._readerId = wrapper.readInt();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public get readerId(): number
|
||||
{
|
||||
return this._readerId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create the incoming event**
|
||||
|
||||
`packages/communication/src/messages/incoming/friendlist/ConsoleReadReceiptEvent.ts` (mirror `NewConsoleMessageEvent`):
|
||||
```typescript
|
||||
import { IMessageEvent } from '@nitrots/api';
|
||||
import { MessageEvent } from '@nitrots/events';
|
||||
import { ConsoleReadReceiptParser } from '../../parser';
|
||||
|
||||
export class ConsoleReadReceiptEvent extends MessageEvent implements IMessageEvent
|
||||
{
|
||||
constructor(callBack: Function)
|
||||
{
|
||||
super(callBack, ConsoleReadReceiptParser);
|
||||
}
|
||||
|
||||
public getParser(): ConsoleReadReceiptParser
|
||||
{
|
||||
return this.parser as ConsoleReadReceiptParser;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create the outgoing composer**
|
||||
|
||||
`packages/communication/src/messages/outgoing/friendlist/MarkConsoleReadComposer.ts` (mirror `SetRelationshipStatusComposer`):
|
||||
```typescript
|
||||
import { IMessageComposer } from '@nitrots/api';
|
||||
|
||||
export class MarkConsoleReadComposer implements IMessageComposer<ConstructorParameters<typeof MarkConsoleReadComposer>>
|
||||
{
|
||||
private _data: ConstructorParameters<typeof MarkConsoleReadComposer>;
|
||||
|
||||
constructor(peerId: number)
|
||||
{
|
||||
this._data = [ peerId ];
|
||||
}
|
||||
|
||||
public getMessageArray()
|
||||
{
|
||||
return this._data;
|
||||
}
|
||||
|
||||
public dispose(): void
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add header constants**
|
||||
|
||||
In `OutgoingHeader.ts` (near the friend headers): `public static MARK_CONSOLE_READ = 4085;`
|
||||
In `IncomingHeader.ts` (near the messenger headers): `public static CONSOLE_READ_RECEIPT = 4086;`
|
||||
|
||||
- [ ] **Step 7: Barrel exports**
|
||||
|
||||
- `messages/outgoing/friendlist/index.ts`: `export * from './MarkConsoleReadComposer';`
|
||||
- `messages/incoming/friendlist/index.ts`: `export * from './ConsoleReadReceiptEvent';`
|
||||
- `messages/parser/friendlist/index.ts`: `export * from './ConsoleReadReceiptParser';`
|
||||
|
||||
- [ ] **Step 8: Register in NitroMessages**
|
||||
|
||||
In `NitroMessages.ts`: add the two classes to the existing friendlist imports, then:
|
||||
- in the events block (next to `this._events.set(IncomingHeader.MESSENGER_CHAT, NewConsoleMessageEvent);`): `this._events.set(IncomingHeader.CONSOLE_READ_RECEIPT, ConsoleReadReceiptEvent);`
|
||||
- in the composers block (next to `this._composers.set(OutgoingHeader.MESSENGER_CHAT, SendMessageComposer);`): `this._composers.set(OutgoingHeader.MARK_CONSOLE_READ, MarkConsoleReadComposer);`
|
||||
|
||||
- [ ] **Step 9: Compile + test**
|
||||
|
||||
Run: `cd Nitro_Render_V3 && yarn compile:fast && yarn test --run`
|
||||
Expected: compile clean; all tests pass (142 prior + 1 new = 143).
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
```bash
|
||||
cd Nitro_Render_V3
|
||||
git add packages/communication/src/messages/ packages/communication/src/NitroMessages.ts
|
||||
git commit -m "feat(messenger): read-receipt packets (MarkConsoleRead + ConsoleReadReceipt)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task P3-2: Emulator — handler + composer + registration
|
||||
|
||||
**Files:** see File map (emulator).
|
||||
|
||||
> No emulator unit tests; verify with `mvn package`.
|
||||
|
||||
- [ ] **Step 1: Header constants**
|
||||
|
||||
In `Incoming.java` (near the friend constants): `public static final int MarkConsoleReadEvent = 4085;`
|
||||
In `Outgoing.java` (near the friend composers): `public final static int ConsoleReadReceiptComposer = 4086;`
|
||||
|
||||
- [ ] **Step 2: Create the outgoing composer**
|
||||
|
||||
`messages/outgoing/friends/ConsoleReadReceiptComposer.java`:
|
||||
```java
|
||||
package com.eu.habbo.messages.outgoing.friends;
|
||||
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
|
||||
public class ConsoleReadReceiptComposer extends MessageComposer {
|
||||
private final int readerId;
|
||||
|
||||
public ConsoleReadReceiptComposer(int readerId) {
|
||||
this.readerId = readerId;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServerMessage composeInternal() {
|
||||
this.response.init(Outgoing.ConsoleReadReceiptComposer);
|
||||
this.response.appendInt(this.readerId);
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create the incoming handler**
|
||||
|
||||
`messages/incoming/friends/MarkConsoleReadEvent.java`. The reader (me) tells the server it read `peerId`'s messages; the server relays a receipt to `peerId` IF `peerId` is online AND a friend (anti-spoof). 1:1 only — `peerId <= 0` (e.g. StaffChat = -1) is ignored.
|
||||
```java
|
||||
package com.eu.habbo.messages.incoming.friends;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.friends.ConsoleReadReceiptComposer;
|
||||
|
||||
public class MarkConsoleReadEvent extends MessageHandler {
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
int peerId = this.packet.readInt();
|
||||
Habbo me = this.client.getHabbo();
|
||||
|
||||
if (me == null || peerId <= 0) return;
|
||||
|
||||
if (me.getMessenger().getFriend(peerId) == null) return;
|
||||
|
||||
Habbo peer = Emulator.getGameServer().getGameClientManager().getHabbo(peerId);
|
||||
if (peer == null || peer.getClient() == null) return;
|
||||
|
||||
peer.getClient().sendResponse(new ConsoleReadReceiptComposer(me.getHabboInfo().getId()));
|
||||
}
|
||||
}
|
||||
```
|
||||
Before writing, confirm `me.getMessenger().getFriend(int)` exists (it's used in `FriendPrivateMessageEvent`) and `Emulator.getGameServer().getGameClientManager().getHabbo(int)` (used in `MessengerBuddy.onMessageReceived`). Adapt + report if a signature differs.
|
||||
|
||||
- [ ] **Step 4: Register the handler**
|
||||
|
||||
In `PacketManager.registerFriends()`: `this.registerHandler(Incoming.MarkConsoleReadEvent, MarkConsoleReadEvent.class);`
|
||||
(`registerFriends` uses a wildcard `import com.eu.habbo.messages.incoming.friends.*;` — confirm with `grep -n "incoming.friends" PacketManager.java`; if explicit imports are used instead, add `import ...MarkConsoleReadEvent;`.)
|
||||
|
||||
- [ ] **Step 5: Build**
|
||||
|
||||
Run: `cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests`
|
||||
Expected: BUILD SUCCESS.
|
||||
|
||||
- [ ] **Step 6: Commit (only the 4 files)**
|
||||
```bash
|
||||
cd Arcturus-Morningstar-Extended
|
||||
git add Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/MarkConsoleReadEvent.java Emulator/src/main/java/com/eu/habbo/messages/outgoing/friends/ConsoleReadReceiptComposer.java Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java
|
||||
git commit -m "feat(messenger): relay read receipts between friends"
|
||||
```
|
||||
`git show --stat HEAD` → exactly 5 files (no soundboard, no jars).
|
||||
|
||||
---
|
||||
|
||||
## Task P3-3: Client — message status model (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Nitro-V3/src/api/friends/MessengerThreadChat.ts` + Test `MessengerThreadChat.test.ts` (extend the existing test file from Phase 2)
|
||||
- Modify: `Nitro-V3/src/api/friends/MessengerThread.ts` + Test `Nitro-V3/src/api/friends/MessengerThread.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to the existing `src/api/friends/MessengerThreadChat.test.ts`:
|
||||
```typescript
|
||||
describe('MessengerThreadChat status', () =>
|
||||
{
|
||||
it('defaults to SENT', () =>
|
||||
{
|
||||
const chat = new MessengerThreadChat(5, 'hi', 0, null, MessengerThreadChat.CHAT);
|
||||
expect(chat.status).toBe(MessengerThreadChat.SENT);
|
||||
});
|
||||
|
||||
it('can be set to READ', () =>
|
||||
{
|
||||
const chat = new MessengerThreadChat(5, 'hi', 0, null, MessengerThreadChat.CHAT);
|
||||
chat.setStatus(MessengerThreadChat.READ);
|
||||
expect(chat.status).toBe(MessengerThreadChat.READ);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Create `src/api/friends/MessengerThread.test.ts`:
|
||||
```typescript
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MessengerFriend } from './MessengerFriend';
|
||||
import { MessengerThread } from './MessengerThread';
|
||||
import { MessengerThreadChat } from './MessengerThreadChat';
|
||||
|
||||
const makeThread = (participantId: number): MessengerThread =>
|
||||
{
|
||||
const friend = new MessengerFriend();
|
||||
friend.id = participantId;
|
||||
return new MessengerThread(friend);
|
||||
};
|
||||
|
||||
describe('MessengerThread.setMessagesReadFromUser', () =>
|
||||
{
|
||||
it('marks only the given user\'s messages as READ', () =>
|
||||
{
|
||||
const thread = makeThread(7);
|
||||
const mine = thread.addMessage(100, 'a', 0, null, MessengerThreadChat.CHAT); // my message
|
||||
const theirs = thread.addMessage(7, 'b', 0, null, MessengerThreadChat.CHAT); // their message
|
||||
|
||||
thread.setMessagesReadFromUser(100);
|
||||
|
||||
expect(mine.status).toBe(MessengerThreadChat.READ);
|
||||
expect(theirs.status).toBe(MessengerThreadChat.SENT);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run, confirm FAIL**
|
||||
|
||||
Run: `cd Nitro-V3 && yarn test --run src/api/friends/MessengerThreadChat.test.ts src/api/friends/MessengerThread.test.ts`
|
||||
Expected: FAIL (SENT/READ/status/setStatus/setMessagesReadFromUser missing).
|
||||
|
||||
- [ ] **Step 3: Add status to MessengerThreadChat**
|
||||
|
||||
In `MessengerThreadChat.ts`, add the constants next to the existing `CHAT`/`ROOM_INVITE` statics:
|
||||
```typescript
|
||||
public static SENT: number = 0;
|
||||
public static READ: number = 1;
|
||||
```
|
||||
Add the field next to the other private fields:
|
||||
```typescript
|
||||
private _status: number = MessengerThreadChat.SENT;
|
||||
```
|
||||
Add getter + setter (next to the `offlineDelivered` getter):
|
||||
```typescript
|
||||
public get status(): number
|
||||
{
|
||||
return this._status;
|
||||
}
|
||||
|
||||
public setStatus(status: number): void
|
||||
{
|
||||
this._status = status;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the marking method to MessengerThread**
|
||||
|
||||
In `MessengerThread.ts`, add (e.g. after `setRead()`):
|
||||
```typescript
|
||||
public setMessagesReadFromUser(userId: number): void
|
||||
{
|
||||
for(const group of this._groups)
|
||||
{
|
||||
if(group.userId !== userId) continue;
|
||||
|
||||
for(const chat of group.chats) chat.setStatus(MessengerThreadChat.READ);
|
||||
}
|
||||
}
|
||||
```
|
||||
(`MessengerThreadChat` is already imported in this file.)
|
||||
|
||||
- [ ] **Step 5: Run, confirm PASS** (Chat: 6 cases now; Thread: 1 case).
|
||||
|
||||
- [ ] **Step 6: typecheck + full suite**
|
||||
|
||||
Run: `cd Nitro-V3 && yarn typecheck && yarn test --run`
|
||||
Expected: only the known pre-existing `FloorplanCanvasSVG.tsx(143,20): TS2503`; no new failures (3 known floorplan failures remain).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
```bash
|
||||
cd Nitro-V3
|
||||
git add src/api/friends/MessengerThreadChat.ts src/api/friends/MessengerThreadChat.test.ts src/api/friends/MessengerThread.ts src/api/friends/MessengerThread.test.ts
|
||||
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): SENT/READ status on thread chats + mark-read helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task P3-4: Client — wire receipts into useMessenger
|
||||
|
||||
**Files:**
|
||||
- Modify: `Nitro-V3/src/hooks/friends/useMessenger.ts`
|
||||
|
||||
- [ ] **Step 1: Import the packets**
|
||||
|
||||
In the top `@nitrots/nitro-renderer` import of `useMessenger.ts`, add `ConsoleReadReceiptEvent` and `MarkConsoleReadComposer` (alongside `NewConsoleMessageEvent`, `SendMessageComposer as SendMessageComposerPacket`). `GetSessionDataManager` is already imported.
|
||||
|
||||
- [ ] **Step 2: Send MarkConsoleRead when a conversation is focused**
|
||||
|
||||
The existing `useEffect([activeThreadId])` marks the active thread read locally. Extend it to also tell the peer. Replace that effect's body so that, after computing the active thread, it sends the composer for a real 1:1 participant:
|
||||
```typescript
|
||||
useEffect(() =>
|
||||
{
|
||||
if (activeThreadId <= 0) return;
|
||||
|
||||
let participantId = 0;
|
||||
|
||||
setMessageThreads(prevValue =>
|
||||
{
|
||||
const newValue = [...prevValue];
|
||||
const index = newValue.findIndex(newThread => (newThread.threadId === activeThreadId));
|
||||
|
||||
if (index >= 0)
|
||||
{
|
||||
newValue[index] = CloneObject(newValue[index]);
|
||||
newValue[index].setRead();
|
||||
participantId = newValue[index].participant?.id ?? 0;
|
||||
}
|
||||
|
||||
return newValue;
|
||||
});
|
||||
|
||||
if (participantId > 0) SendMessageComposer(new MarkConsoleReadComposer(participantId));
|
||||
}, [activeThreadId]);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Also mark-read when a message arrives in the already-active thread**
|
||||
|
||||
In the `NewConsoleMessageEvent` handler, after `sendMessage(...)`, notify the peer if this thread is the one currently open:
|
||||
```typescript
|
||||
useMessageEvent<NewConsoleMessageEvent>(NewConsoleMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const thread = getMessageThread(parser.senderId);
|
||||
|
||||
if (!thread) return;
|
||||
|
||||
sendMessage(thread, parser.senderId, parser.messageText, parser.secondsSinceSent, parser.extraData);
|
||||
|
||||
if ((thread.threadId === activeThreadId) && (parser.senderId > 0)) SendMessageComposer(new MarkConsoleReadComposer(parser.senderId));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Handle the incoming receipt — mark own messages READ**
|
||||
|
||||
Add a new event subscription (near the other `useMessageEvent` calls). `parser.readerId` is the friend who read MY messages; find the thread with that participant and mark my own messages READ:
|
||||
```typescript
|
||||
useMessageEvent<ConsoleReadReceiptEvent>(ConsoleReadReceiptEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const ownUserId = GetSessionDataManager().userId;
|
||||
|
||||
setMessageThreads(prevValue =>
|
||||
{
|
||||
const index = prevValue.findIndex(thread => (thread.participant && (thread.participant.id === parser.readerId)));
|
||||
|
||||
if (index === -1) return prevValue;
|
||||
|
||||
const newValue = [...prevValue];
|
||||
|
||||
newValue[index] = CloneObject(newValue[index]);
|
||||
newValue[index].setMessagesReadFromUser(ownUserId);
|
||||
|
||||
return newValue;
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: typecheck + full suite**
|
||||
|
||||
Run: `cd Nitro-V3 && yarn typecheck && yarn test --run`
|
||||
Expected: only the pre-existing typecheck error; no new test failures.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
cd Nitro-V3
|
||||
git add src/hooks/friends/useMessenger.ts
|
||||
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): send mark-read on focus, mark own messages read on receipt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task P3-5: Client — render ✓ / ✓✓ + CSS
|
||||
|
||||
**Files:**
|
||||
- Modify: `Nitro-V3/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx`
|
||||
- Modify: `Nitro-V3/src/css/friends/FriendsView.css`
|
||||
|
||||
- [ ] **Step 1: Render the status indicator on own private-chat bubbles**
|
||||
|
||||
In `FriendsMessengerThreadGroup.tsx`, the final `return (...)` renders the message row; the own-message avatar is gated by `isOwnChat`. `MessengerThreadChat` and `MessengerGroupType` are already imported. After the `.messenger-message-time` `<Base>` (inside `.messenger-message-body`), add a status indicator shown only for own 1:1 CHAT groups. Compute the last chat once and render:
|
||||
```tsx
|
||||
<Base className="messenger-message-time">{ group.chats[0].date.toLocaleTimeString() }</Base>
|
||||
{ isOwnChat && (group.type === MessengerGroupType.PRIVATE_CHAT) && (group.chats[group.chats.length - 1].type === MessengerThreadChat.CHAT) &&
|
||||
<Base className={ 'messenger-message-status ' + ((group.chats[group.chats.length - 1].status === MessengerThreadChat.READ) ? 'read' : '') }>
|
||||
{ (group.chats[group.chats.length - 1].status === MessengerThreadChat.READ) ? '✓✓' : '✓' }
|
||||
</Base> }
|
||||
```
|
||||
(Insert this block immediately after the existing `messenger-message-time` line, still inside the `.messenger-message-body` `<Base>`.)
|
||||
|
||||
- [ ] **Step 2: Add CSS**
|
||||
|
||||
Append to `src/css/friends/FriendsView.css`:
|
||||
```css
|
||||
.messenger-message-status {
|
||||
margin-top: 1px;
|
||||
font-size: 10px;
|
||||
line-height: 10px;
|
||||
text-align: right;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.messenger-message-status.read {
|
||||
color: #4fc3f7;
|
||||
opacity: 1;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: typecheck + full suite**
|
||||
|
||||
Run: `cd Nitro-V3 && yarn typecheck && yarn test --run`
|
||||
Expected: only the pre-existing typecheck error; no new test failures.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
cd Nitro-V3
|
||||
git add src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx src/css/friends/FriendsView.css
|
||||
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): render sent/read checkmarks on own messages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task P3-6: Integration verification
|
||||
|
||||
**Files:** none (automated + manual; fix-ups only).
|
||||
|
||||
- [ ] **Step 1: Automated checks**
|
||||
```
|
||||
cd Nitro_Render_V3 && yarn compile:fast && yarn test --run
|
||||
cd Nitro-V3 && yarn typecheck && yarn test --run
|
||||
cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests
|
||||
```
|
||||
Expected: renderer tests green (143); client typecheck shows only the pre-existing `FloorplanCanvasSVG.tsx(143,20): TS2503`, tests green except the 3 known floorplan failures (+ the new model cases pass); emulator BUILD SUCCESS.
|
||||
|
||||
- [ ] **Step 2: Live two-session manual test**
|
||||
|
||||
Run the new jar + `yarn start`. Accounts A and B (friends), both online:
|
||||
1. **Sent (✓):** A sends B a message while B's messenger thread with A is NOT focused → on A's side the message shows a single `✓`.
|
||||
2. **Read (✓✓):** B opens/focuses the conversation with A → A's message flips to `✓✓` (blue) live.
|
||||
3. **New message after read:** A sends another message → shows `✓` again; when B (thread still focused) receives it, A flips to `✓✓` (the active-thread mark-read path).
|
||||
4. **Offline interplay (Phase 2):** A messages B while B offline → A shows `✓`; B logs in and opens the thread → A (if still online) sees `✓✓`.
|
||||
5. **No receipts for non-1:1:** opening the Staff Chat / a group chat thread does not produce errors and shows no checkmarks on those messages.
|
||||
6. **Privacy/abuse:** a receipt only arrives for actual friends (the handler ignores non-friends and `peerId <= 0`).
|
||||
7. **No regressions:** sending, receiving, offline markers (Phase 2), and groups (Phase 1) all still work.
|
||||
|
||||
- [ ] **Step 3: Commit any fix-ups** (only if needed)
|
||||
```bash
|
||||
cd Nitro-V3
|
||||
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -am "fix(messenger): read-receipt integration fixes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scope boundaries
|
||||
- **No persistence / no login batch** (see Design note): receipts are live-relay only; ✓✓ applies within the session the sender is online for.
|
||||
- **2-state only:** `✓` (sent) and `✓✓` (read). No separate "delivered" state.
|
||||
- **1:1 only:** group chat, staff chat, and bots never produce receipts (`peerId <= 0` and non-friends are ignored server-side; the client only renders checks on `PRIVATE_CHAT` CHAT groups).
|
||||
- **Receipt marks ALL current own messages in the thread read** (not a per-message timestamp diff) — correct for the 2-state model since a focus/read means everything visible is read; messages sent afterward start at `✓` again.
|
||||
- No renderer/client message-history persistence is added.
|
||||
- Do NOT push/merge automatically; the branch carries Phases 1–2 + the user's own parallel commits.
|
||||
@@ -0,0 +1,533 @@
|
||||
# Messenger Phase 4 — Typing Indicator Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Show "X sta scrivendo…" in a 1:1 conversation while the friend is typing, WhatsApp-style.
|
||||
|
||||
**Architecture:** Two ephemeral CUSTOM packets (never stored). Client→server `ConsoleTyping(peerId, isTyping)` is sent when the user starts/stops typing in a thread; the emulator relays it (friend + online only) to the peer as server→client `FriendTyping(senderId, isTyping)`. The recipient's client shows a typing indicator for that friend, auto-expiring after a few seconds.
|
||||
|
||||
**Tech Stack:** Arcturus (Java 21/Maven), Nitro_Render_V3 (TypeScript, Vitest), Nitro-V3 (React 19, Vitest). No DB.
|
||||
|
||||
---
|
||||
|
||||
## Branches & rules
|
||||
All repos on `feat/messenger-groups-receipts`. Client commits use `git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com`. No Co-Authored-By / AI attribution. Emulator working tree has an UNRELATED modified `soundboard/SoundboardPlayEvent.java` + untracked jars — never stage those; `git add` only the listed files.
|
||||
|
||||
## Header IDs (custom, verified free in all 4 files)
|
||||
| Packet | Direction | Renderer header | Emulator header | Value |
|
||||
|---|---|---|---|---|
|
||||
| ConsoleTyping | client→server | `OutgoingHeader.CONSOLE_TYPING` | `Incoming.ConsoleTypingEvent` | **4087** |
|
||||
| FriendTyping | server→client | `IncomingHeader.FRIEND_TYPING` | `Outgoing.FriendTypingComposer` | **4088** |
|
||||
|
||||
Wire: ConsoleTyping = `int peerId`, `boolean isTyping`. FriendTyping = `int senderId`, `boolean isTyping`. (Booleans are supported in composers/parsers — e.g. `DeclineFriendMessageComposer` sends a boolean; `FriendParser` reads booleans.)
|
||||
|
||||
## File map
|
||||
**Renderer (`Nitro_Render_V3/packages/communication/src/`):**
|
||||
- Create `messages/outgoing/friendlist/ConsoleTypingComposer.ts`
|
||||
- Create `messages/incoming/friendlist/FriendIsTypingEvent.ts`
|
||||
- Create `messages/parser/friendlist/FriendIsTypingParser.ts`
|
||||
- Create `messages/parser/friendlist/__tests__/FriendIsTypingParser.test.ts`
|
||||
- Modify `messages/outgoing/OutgoingHeader.ts`, `messages/incoming/IncomingHeader.ts`, `NitroMessages.ts`, the 3 friendlist `index.ts`
|
||||
|
||||
**Emulator (`Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/`):**
|
||||
- Create `messages/incoming/friends/ConsoleTypingEvent.java`
|
||||
- Create `messages/outgoing/friends/FriendTypingComposer.java`
|
||||
- Modify `messages/incoming/Incoming.java`, `messages/outgoing/Outgoing.java`, `messages/PacketManager.java`
|
||||
|
||||
**Client (`Nitro-V3/src/`):**
|
||||
- Modify `hooks/friends/useMessenger.ts` (incoming typing state + outgoing action)
|
||||
- Modify `components/friends/views/messenger/FriendsMessengerView.tsx` (send typing + render indicator)
|
||||
- Modify `public/configuration/UITexts.example` (`messenger.typing` key)
|
||||
- Modify `src/css/friends/FriendsView.css` (`.messenger-typing-indicator`)
|
||||
|
||||
---
|
||||
|
||||
## Task P4-1: Renderer — typing packets + parser test
|
||||
|
||||
**Files:** see File map (renderer).
|
||||
|
||||
- [ ] **Step 1: Failing parser test**
|
||||
|
||||
Create `packages/communication/src/messages/parser/friendlist/__tests__/FriendIsTypingParser.test.ts`:
|
||||
```typescript
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BinaryReader, BinaryWriter } from '@nitrots/utils';
|
||||
import { FriendIsTypingParser } from '../FriendIsTypingParser';
|
||||
|
||||
class TestWrapper
|
||||
{
|
||||
constructor(private reader: BinaryReader) {}
|
||||
readByte() { return this.reader.readByte(); }
|
||||
readBoolean() { return this.reader.readByte() === 1; }
|
||||
readShort() { return this.reader.readShort(); }
|
||||
readInt() { return this.reader.readInt(); }
|
||||
readString() { const len = this.reader.readShort(); return this.reader.readBytes(len).toString(); }
|
||||
header = 0;
|
||||
get bytesAvailable() { return this.reader.remaining() > 0; }
|
||||
}
|
||||
|
||||
describe('FriendIsTypingParser', () =>
|
||||
{
|
||||
it('parses senderId + isTyping=true', () =>
|
||||
{
|
||||
const w = new BinaryWriter();
|
||||
w.writeInt(42); w.writeByte(1);
|
||||
const parser = new FriendIsTypingParser();
|
||||
parser.flush();
|
||||
parser.parse(new TestWrapper(new BinaryReader(w.getBuffer())) as any);
|
||||
expect(parser.senderId).toBe(42);
|
||||
expect(parser.isTyping).toBe(true);
|
||||
});
|
||||
|
||||
it('parses isTyping=false', () =>
|
||||
{
|
||||
const w = new BinaryWriter();
|
||||
w.writeInt(42); w.writeByte(0);
|
||||
const parser = new FriendIsTypingParser();
|
||||
parser.flush();
|
||||
parser.parse(new TestWrapper(new BinaryReader(w.getBuffer())) as any);
|
||||
expect(parser.isTyping).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
Run `cd Nitro_Render_V3 && yarn test --run packages/communication/src/messages/parser/friendlist/__tests__/FriendIsTypingParser.test.ts` → FAIL.
|
||||
|
||||
(Confirm `BinaryWriter` has `writeByte`/`writeInt` — the mentions/category tests use `writeInt`/`writeString`; if `writeByte` is named differently, use the real method that writes a single byte, mirroring how the existing parser tests write a boolean/byte. If unsure, write the boolean as `w.writeInt(1)` and read with `readInt() === 1` in BOTH parser and test — but prefer a real 1-byte boolean to match the emulator's `appendBoolean`/`readBoolean`. Inspect an existing parser test that round-trips a boolean to copy the exact writer call.)
|
||||
|
||||
- [ ] **Step 2: Create the parser**
|
||||
|
||||
`packages/communication/src/messages/parser/friendlist/FriendIsTypingParser.ts`:
|
||||
```typescript
|
||||
import { IMessageDataWrapper, IMessageParser } from '@nitrots/api';
|
||||
|
||||
export class FriendIsTypingParser implements IMessageParser
|
||||
{
|
||||
private _senderId: number;
|
||||
private _isTyping: boolean;
|
||||
|
||||
public flush(): boolean
|
||||
{
|
||||
this._senderId = 0;
|
||||
this._isTyping = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public parse(wrapper: IMessageDataWrapper): boolean
|
||||
{
|
||||
if(!wrapper) return false;
|
||||
|
||||
this._senderId = wrapper.readInt();
|
||||
this._isTyping = wrapper.readBoolean();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public get senderId(): number
|
||||
{
|
||||
return this._senderId;
|
||||
}
|
||||
|
||||
public get isTyping(): boolean
|
||||
{
|
||||
return this._isTyping;
|
||||
}
|
||||
}
|
||||
```
|
||||
(Confirm `IMessageDataWrapper` has `readBoolean()` — `FriendParser` uses it. If not, use `wrapper.readInt() === 1`.)
|
||||
|
||||
- [ ] **Step 3: Create the incoming event**
|
||||
|
||||
`packages/communication/src/messages/incoming/friendlist/FriendIsTypingEvent.ts`:
|
||||
```typescript
|
||||
import { IMessageEvent } from '@nitrots/api';
|
||||
import { MessageEvent } from '@nitrots/events';
|
||||
import { FriendIsTypingParser } from '../../parser';
|
||||
|
||||
export class FriendIsTypingEvent extends MessageEvent implements IMessageEvent
|
||||
{
|
||||
constructor(callBack: Function)
|
||||
{
|
||||
super(callBack, FriendIsTypingParser);
|
||||
}
|
||||
|
||||
public getParser(): FriendIsTypingParser
|
||||
{
|
||||
return this.parser as FriendIsTypingParser;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create the outgoing composer**
|
||||
|
||||
`packages/communication/src/messages/outgoing/friendlist/ConsoleTypingComposer.ts`:
|
||||
```typescript
|
||||
import { IMessageComposer } from '@nitrots/api';
|
||||
|
||||
export class ConsoleTypingComposer implements IMessageComposer<ConstructorParameters<typeof ConsoleTypingComposer>>
|
||||
{
|
||||
private _data: ConstructorParameters<typeof ConsoleTypingComposer>;
|
||||
|
||||
constructor(peerId: number, isTyping: boolean)
|
||||
{
|
||||
this._data = [ peerId, isTyping ];
|
||||
}
|
||||
|
||||
public getMessageArray()
|
||||
{
|
||||
return this._data;
|
||||
}
|
||||
|
||||
public dispose(): void
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Header constants**
|
||||
- `OutgoingHeader.ts`: `public static CONSOLE_TYPING = 4087;`
|
||||
- `IncomingHeader.ts`: `public static FRIEND_TYPING = 4088;`
|
||||
|
||||
- [ ] **Step 6: Barrel exports**
|
||||
- `messages/outgoing/friendlist/index.ts`: `export * from './ConsoleTypingComposer';`
|
||||
- `messages/incoming/friendlist/index.ts`: `export * from './FriendIsTypingEvent';`
|
||||
- `messages/parser/friendlist/index.ts`: `export * from './FriendIsTypingParser';`
|
||||
|
||||
- [ ] **Step 7: Register in NitroMessages**
|
||||
Add the two classes to the friendlist imports, then:
|
||||
- events: `this._events.set(IncomingHeader.FRIEND_TYPING, FriendIsTypingEvent);`
|
||||
- composers: `this._composers.set(OutgoingHeader.CONSOLE_TYPING, ConsoleTypingComposer);`
|
||||
|
||||
- [ ] **Step 8: Compile + test**
|
||||
Run: `cd Nitro_Render_V3 && yarn compile:fast && yarn test --run`
|
||||
Expected: compile clean; all tests pass (143 prior + 2 new = 145).
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
```bash
|
||||
cd Nitro_Render_V3
|
||||
git add packages/communication/src/messages/ packages/communication/src/NitroMessages.ts
|
||||
git commit -m "feat(messenger): typing packets (ConsoleTyping + FriendTyping)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task P4-2: Emulator — typing relay
|
||||
|
||||
**Files:** see File map (emulator). No emulator unit tests; verify with `mvn package`.
|
||||
|
||||
- [ ] **Step 1: Header constants**
|
||||
- `Incoming.java`: `public static final int ConsoleTypingEvent = 4087;`
|
||||
- `Outgoing.java`: `public final static int FriendTypingComposer = 4088;`
|
||||
|
||||
- [ ] **Step 2: Outgoing composer**
|
||||
|
||||
`messages/outgoing/friends/FriendTypingComposer.java`:
|
||||
```java
|
||||
package com.eu.habbo.messages.outgoing.friends;
|
||||
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||
|
||||
public class FriendTypingComposer extends MessageComposer {
|
||||
private final int senderId;
|
||||
private final boolean isTyping;
|
||||
|
||||
public FriendTypingComposer(int senderId, boolean isTyping) {
|
||||
this.senderId = senderId;
|
||||
this.isTyping = isTyping;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServerMessage composeInternal() {
|
||||
this.response.init(Outgoing.FriendTypingComposer);
|
||||
this.response.appendInt(this.senderId);
|
||||
this.response.appendBoolean(this.isTyping);
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
```
|
||||
(Confirm `ServerMessage.appendBoolean(boolean)` exists — `UpdateFriendComposer`/`MessengerBuddy.serialize` both use `appendBoolean`. It does.)
|
||||
|
||||
- [ ] **Step 3: Incoming handler**
|
||||
|
||||
`messages/incoming/friends/ConsoleTypingEvent.java`. Reads `peerId` + `isTyping`; relays to `peerId` if online AND a friend; ignores `peerId <= 0` (1:1 only). Ephemeral — no storage.
|
||||
```java
|
||||
package com.eu.habbo.messages.incoming.friends;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||
import com.eu.habbo.messages.outgoing.friends.FriendTypingComposer;
|
||||
|
||||
public class ConsoleTypingEvent extends MessageHandler {
|
||||
@Override
|
||||
public void handle() throws Exception {
|
||||
int peerId = this.packet.readInt();
|
||||
boolean isTyping = this.packet.readBoolean();
|
||||
Habbo me = this.client.getHabbo();
|
||||
|
||||
if (me == null || peerId <= 0) return;
|
||||
|
||||
if (me.getMessenger().getFriend(peerId) == null) return;
|
||||
|
||||
Habbo peer = Emulator.getGameServer().getGameClientManager().getHabbo(peerId);
|
||||
if (peer == null || peer.getClient() == null) return;
|
||||
|
||||
peer.getClient().sendResponse(new FriendTypingComposer(me.getHabboInfo().getId(), isTyping));
|
||||
}
|
||||
}
|
||||
```
|
||||
(Confirm `this.packet.readBoolean()` exists — `ClientMessage.readBoolean()` is used across handlers. It does.)
|
||||
|
||||
- [ ] **Step 4: Register handler**
|
||||
In `PacketManager.registerFriends()`: `this.registerHandler(Incoming.ConsoleTypingEvent, ConsoleTypingEvent.class);`
|
||||
(The `incoming.friends.*` wildcard import covers it — confirm with `grep -n "incoming.friends" PacketManager.java`.)
|
||||
|
||||
- [ ] **Step 5: Build**
|
||||
Run: `cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests`
|
||||
Expected: BUILD SUCCESS.
|
||||
|
||||
- [ ] **Step 6: Commit (only the 5 files)**
|
||||
```bash
|
||||
cd Arcturus-Morningstar-Extended
|
||||
git add Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/ConsoleTypingEvent.java Emulator/src/main/java/com/eu/habbo/messages/outgoing/friends/FriendTypingComposer.java Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java
|
||||
git commit -m "feat(messenger): relay typing status between friends"
|
||||
```
|
||||
`git show --stat HEAD` → exactly 5 files (no soundboard, no jars).
|
||||
|
||||
---
|
||||
|
||||
## Task P4-3: Client — typing state + action in useMessenger
|
||||
|
||||
**Files:** Modify `Nitro-V3/src/hooks/friends/useMessenger.ts`.
|
||||
|
||||
> No clean unit test here (timer + event-bus via the renderer mock). Verified by typecheck + the live test in P4-5. Keep the implementation tight.
|
||||
|
||||
- [ ] **Step 1: Imports**
|
||||
Add `ConsoleTypingComposer` and `FriendIsTypingEvent` to the `@nitrots/nitro-renderer` import line. Ensure `useRef` is imported from 'react' (the file imports `useEffect, useMemo, useRef, useState` after Phase 3 — confirm `useRef` is present).
|
||||
|
||||
- [ ] **Step 2: Typing state + timers ref**
|
||||
Inside `useMessengerState`, near the other `useState` calls, add:
|
||||
```typescript
|
||||
const [typingUserIds, setTypingUserIds] = useState<number[]>([]);
|
||||
const typingTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Outgoing action**
|
||||
Add (near `sendMessage` / other actions):
|
||||
```typescript
|
||||
const sendTypingStatus = (peerId: number, isTyping: boolean) =>
|
||||
{
|
||||
if (!peerId || (peerId <= 0)) return;
|
||||
|
||||
SendMessageComposer(new ConsoleTypingComposer(peerId, isTyping));
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Incoming handler with auto-expire**
|
||||
Add a new `useMessageEvent` (near the others). When a friend is typing, add their id and (re)arm a 6s expiry; when they stop, remove immediately.
|
||||
```typescript
|
||||
useMessageEvent<FriendIsTypingEvent>(FriendIsTypingEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const senderId = parser.senderId;
|
||||
|
||||
if (senderId <= 0) return;
|
||||
|
||||
const timers = typingTimersRef.current;
|
||||
const existing = timers.get(senderId);
|
||||
|
||||
if (existing)
|
||||
{
|
||||
clearTimeout(existing);
|
||||
timers.delete(senderId);
|
||||
}
|
||||
|
||||
if (parser.isTyping)
|
||||
{
|
||||
setTypingUserIds(prev => (prev.indexOf(senderId) >= 0) ? prev : [...prev, senderId]);
|
||||
|
||||
timers.set(senderId, setTimeout(() =>
|
||||
{
|
||||
typingTimersRef.current.delete(senderId);
|
||||
setTypingUserIds(prev => prev.filter(id => (id !== senderId)));
|
||||
}, 6000));
|
||||
}
|
||||
else
|
||||
{
|
||||
setTypingUserIds(prev => prev.filter(id => (id !== senderId)));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Expose**
|
||||
Add `typingUserIds` and `sendTypingStatus` to the `useMessengerState` return object (the bottom `return { ... }`).
|
||||
|
||||
- [ ] **Step 6: typecheck + tests + lint:hooks**
|
||||
Run: `cd Nitro-V3 && yarn typecheck && yarn test --run && yarn lint:hooks`
|
||||
Expected: typecheck only the pre-existing floorplan error; no new test failures; `lint:hooks` 0 errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
```bash
|
||||
cd Nitro-V3
|
||||
git add src/hooks/friends/useMessenger.ts
|
||||
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): incoming typing state + outgoing typing action"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task P4-4: Client — send typing + render indicator
|
||||
|
||||
**Files:**
|
||||
- Modify `Nitro-V3/src/components/friends/views/messenger/FriendsMessengerView.tsx`
|
||||
- Modify `Nitro-V3/public/configuration/UITexts.example`
|
||||
- Modify `Nitro-V3/src/css/friends/FriendsView.css`
|
||||
|
||||
- [ ] **Step 1: Pull the new hook members**
|
||||
In `FriendsMessengerView.tsx`, the `useMessenger()` destructure currently grabs `visibleThreads, activeThread, getMessageThread, sendMessage, setActiveThreadId, closeThread`. Add `typingUserIds = [], sendTypingStatus = null`.
|
||||
|
||||
- [ ] **Step 2: Outgoing typing notifier (refs + idle timer)**
|
||||
Add near the other refs/state at the top of the component:
|
||||
```tsx
|
||||
const isTypingRef = useRef<boolean>(false);
|
||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const stopTyping = () =>
|
||||
{
|
||||
if(typingTimeoutRef.current)
|
||||
{
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
typingTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if(isTypingRef.current && activeThread && activeThread.participant && (activeThread.participant.id > 0))
|
||||
{
|
||||
sendTypingStatus(activeThread.participant.id, false);
|
||||
}
|
||||
|
||||
isTypingRef.current = false;
|
||||
};
|
||||
|
||||
const handleInputChange = (value: string) =>
|
||||
{
|
||||
setMessageText(value);
|
||||
|
||||
const peerId = (activeThread && activeThread.participant) ? activeThread.participant.id : 0;
|
||||
|
||||
if(peerId <= 0) return;
|
||||
|
||||
if(!value.length)
|
||||
{
|
||||
stopTyping();
|
||||
return;
|
||||
}
|
||||
|
||||
if(!isTypingRef.current)
|
||||
{
|
||||
sendTypingStatus(peerId, true);
|
||||
isTypingRef.current = true;
|
||||
}
|
||||
|
||||
if(typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||
typingTimeoutRef.current = setTimeout(() => stopTyping(), 4000);
|
||||
};
|
||||
```
|
||||
`useRef` is already imported in this file.
|
||||
|
||||
- [ ] **Step 3: Wire the input + send**
|
||||
- Change the input's `onChange` from `event => setMessageText(event.target.value)` to `event => handleInputChange(event.target.value)`.
|
||||
- In `send()`, after each `setMessageText('')` (there are a few early returns — simplest: call `stopTyping()` once at the START of `send()` after the `if(!activeThread || !messageText.length) return;` guard, so any in-progress typing is cleared and a `false` is sent before the message). Add `stopTyping();` right after that guard line.
|
||||
|
||||
- [ ] **Step 4: Stop typing when switching away from / closing a thread**
|
||||
The component already has an effect on `[ isVisible, activeThread, ... ]`. To avoid a stale typing flag when the active thread changes, add a small effect:
|
||||
```tsx
|
||||
useEffect(() =>
|
||||
{
|
||||
// when the active conversation changes (or closes), clear local typing state
|
||||
return () =>
|
||||
{
|
||||
if(typingTimeoutRef.current)
|
||||
{
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
typingTimeoutRef.current = null;
|
||||
}
|
||||
isTypingRef.current = false;
|
||||
};
|
||||
}, [ activeThread ]);
|
||||
```
|
||||
(This clears the local flag/timer on thread switch; the peer's indicator auto-expires after 6s, so an explicit "false" on switch isn't required.)
|
||||
|
||||
- [ ] **Step 5: Render the indicator**
|
||||
Between the `chat-messages` div and the `messenger-input-row`, add:
|
||||
```tsx
|
||||
{ activeThread.participant && (activeThread.participant.id > 0) && (typingUserIds.indexOf(activeThread.participant.id) >= 0) &&
|
||||
<div className="messenger-typing-indicator">
|
||||
{ LocalizeText('messenger.typing', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) }
|
||||
</div> }
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Localization key**
|
||||
In `public/configuration/UITexts.example`, add (keep valid JSON):
|
||||
```json
|
||||
"messenger.typing": "%FRIEND_NAME% is typing...",
|
||||
```
|
||||
|
||||
- [ ] **Step 7: CSS**
|
||||
Append to `src/css/friends/FriendsView.css`:
|
||||
```css
|
||||
.messenger-typing-indicator {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: typecheck + tests**
|
||||
Run: `cd Nitro-V3 && yarn typecheck && yarn test --run`
|
||||
Expected: only the pre-existing floorplan typecheck error; no new test failures.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
```bash
|
||||
cd Nitro-V3
|
||||
git add src/components/friends/views/messenger/FriendsMessengerView.tsx public/configuration/UITexts.example src/css/friends/FriendsView.css
|
||||
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(messenger): send typing status + show 'is typing' indicator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task P4-5: Integration verification
|
||||
|
||||
**Files:** none (automated + manual; fix-ups only).
|
||||
|
||||
- [ ] **Step 1: Automated checks**
|
||||
```
|
||||
cd Nitro_Render_V3 && yarn compile:fast && yarn test --run
|
||||
cd Nitro-V3 && yarn typecheck && yarn test --run && yarn lint:hooks
|
||||
cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests
|
||||
```
|
||||
Expected: renderer 145 tests green; client typecheck only the pre-existing floorplan error, tests green except the 3 known floorplan failures, `lint:hooks` 0 errors; emulator BUILD SUCCESS.
|
||||
|
||||
- [ ] **Step 2: Live two-session manual test**
|
||||
|
||||
Run the new jar + `yarn start`. Accounts A and B (friends), both online, conversation open on both sides:
|
||||
1. **Typing shows:** A starts typing in the thread with B → B sees "A is typing..." above the input.
|
||||
2. **Stops on idle:** A stops typing → after ~4s (A's idle timer sends stop) B's indicator disappears; even if the stop packet is lost, B's indicator auto-expires after ~6s.
|
||||
3. **Stops on send:** A types then sends → B's indicator disappears (stop sent at send time) and the message arrives.
|
||||
4. **1:1 only:** typing in the Staff Chat / a group thread produces no errors and no indicator (server ignores `peerId <= 0` / non-friends).
|
||||
5. **No regressions:** sending, receipts (Phase 3 ✓/✓✓), offline markers (Phase 2), and groups (Phase 1) all still work.
|
||||
|
||||
- [ ] **Step 3: Commit any fix-ups** (only if needed)
|
||||
```bash
|
||||
cd Nitro-V3
|
||||
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -am "fix(messenger): typing indicator integration fixes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scope boundaries
|
||||
- **Ephemeral only:** typing status is never stored; relayed only between online friends.
|
||||
- **1:1 only:** group/staff/bots produce no typing (server ignores `peerId <= 0` and non-friends; client only renders for `participant.id > 0`).
|
||||
- **Throttling:** the client sends `true` once per typing burst and `false` on idle(4s)/send/empty; the recipient auto-expires the indicator after 6s as a safety net for lost stop packets.
|
||||
- This completes the messenger initiative (Phases 1–4). Do NOT push/merge automatically; the branch carries all four phases + the user's own parallel commits.
|
||||
@@ -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<ConstructorParameters<typeof FurniEditorUpdateFurnidataComposer>>
|
||||
{
|
||||
private _data: ConstructorParameters<typeof FurniEditorUpdateFurnidataComposer>;
|
||||
|
||||
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<string,unknown>` 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 `<FurniEditorEditView ... onUpdate=... onDelete=... />` 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** `<input>` 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
|
||||
<div>
|
||||
<label className={ labelClass }>Classname</label>
|
||||
<div className={ readonlyClass }>{ form.itemName }</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Public Name (DB fallback)</label>
|
||||
<div className={ readonlyClass }>{ form.publicName }</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: New editable Furnidata section.** Replace the read-only `FurniData.json` section (lines ~323–334) with:
|
||||
```tsx
|
||||
<Section title="Furnidata (display name)" defaultOpen={ true }>
|
||||
<Column gap={ 1 }>
|
||||
<div>
|
||||
<label className={ labelClass }>Display Name</label>
|
||||
<input className={ inputClass() } value={ furniName } onChange={ e => setFurniName(e.target.value) } maxLength={ 256 } />
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Description</label>
|
||||
<textarea className={ inputClass() } rows={ 3 } value={ furniDescription } onChange={ e => setFurniDescription(e.target.value) } maxLength={ 256 } />
|
||||
</div>
|
||||
{ (furniName !== (String(furniDataEntry?.name ?? '')) || furniDescription !== (String(furniDataEntry?.description ?? ''))) &&
|
||||
<span className="text-[10px] text-orange-500 font-bold">Unsaved furnidata changes</span> }
|
||||
<Flex gap={ 1 }>
|
||||
<Button variant="success" disabled={ loading } onClick={ () => setConfirmFurnidata(true) }>Save name/desc</Button>
|
||||
<Button variant="secondary" disabled={ loading } onClick={ () => onRevertFurnidata(item.id) }>Revert</Button>
|
||||
</Flex>
|
||||
</Column>
|
||||
</Section>
|
||||
```
|
||||
Add local state near the other state (lines ~71–91): `const [furniName, setFurniName] = useState('');` `const [furniDescription, setFurniDescription] = useState('');` `const [confirmFurnidata, setConfirmFurnidata] = useState(false);` and seed `furniName`/`furniDescription` from `furniDataEntry?.name`/`?.description` (falling back to `item.publicName`/`item.description`) in the same `useEffect` that syncs `form` (lines ~95–122), re-running when `furniDataEntry` changes.
|
||||
|
||||
- [ ] **Step 4: Diff + confirm modal** (mirrors the existing Delete-confirm modal, lines ~353–368). When `confirmFurnidata`, show a small modal listing old → new:
|
||||
```tsx
|
||||
{ confirmFurnidata &&
|
||||
<div className="...overlay classes copied from the delete modal...">
|
||||
<div className="...panel classes...">
|
||||
<Text bold>Apply furnidata change to ALL clients?</Text>
|
||||
<div className="text-xs"><b>Name:</b> { String(furniDataEntry?.name ?? '') } → { furniName }</div>
|
||||
<div className="text-xs"><b>Desc:</b> { String(furniDataEntry?.description ?? '') } → { furniDescription }</div>
|
||||
<Flex gap={ 1 }>
|
||||
<Button variant="success" onClick={ () => { onUpdateFurnidata(item.id, furniName, furniDescription); setConfirmFurnidata(false); } }>Confirm</Button>
|
||||
<Button variant="secondary" onClick={ () => setConfirmFurnidata(false) }>Cancel</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
</div> }
|
||||
```
|
||||
Copy the exact overlay/panel Tailwind classes from the existing delete-confirmation modal so it looks identical.
|
||||
|
||||
- [ ] **Step 5: Typecheck + manual render.** `cd Nitro-V3 && yarn typecheck` (clean). With `yarn start` running, open the editor on a furni: Classname/Public Name show read-only (monospace, muted), the Furnidata section shows the real display name from furnidata, editing + Save shows the confirm modal, Confirm sends the composer.
|
||||
|
||||
- [ ] **Step 6: Commit:**
|
||||
```
|
||||
git -C "E:/Users/simol/Desktop/DEV/Nitro-V3" add src/components/furni-editor/views/FurniEditorEditView.tsx src/components/furni-editor/FurniEditorView.tsx
|
||||
git -C "E:/Users/simol/Desktop/DEV/Nitro-V3" commit -m "feat(furni-editor): editable furnidata name/desc section + read-only classname/public_name + diff-confirm + revert"
|
||||
```
|
||||
NO `Co-Authored-By`.
|
||||
|
||||
---
|
||||
|
||||
## Task 4 (client): typography / color refresh (theme tokens)
|
||||
|
||||
The chosen direction: replace scattered hardcoded hex with theme tokens, restyle labels for hierarchy, bump input font + focus ring, and render read-only/technical values in monospace on a muted bg.
|
||||
|
||||
**Files:** `FurniEditorEditView.tsx` (the in-file helper class strings).
|
||||
|
||||
- [ ] **Step 1: Update the helper class strings** near lines ~209–211:
|
||||
```ts
|
||||
// inputs: bump xs→sm, add focus ring using the theme primary token
|
||||
const inputClass = (field?: string) =>
|
||||
`w-full px-2 py-1 text-sm leading-normal rounded-sm border border-[#bbb] focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/40 min-h-[calc(1.5em+0.5rem+2px)]${ field && errors[field] ? ' border-red-500 bg-red-50' : '' }`;
|
||||
// labels: stronger hierarchy — uppercase, tracked, secondary token
|
||||
const labelClass = 'text-[10px] font-bold text-secondary uppercase tracking-wider mb-0.5 flex items-center gap-0.5';
|
||||
// read-only / technical values: monospace on muted bg, clearly "locked"
|
||||
const readonlyClass = 'w-full px-2 py-1 text-sm font-mono rounded-sm border border-[#ddd] bg-[#f2f2eb] text-[#555] select-all';
|
||||
```
|
||||
(Match the real existing `inputClass` signature/`errors` variable name; only change the class string + add `readonlyClass`. `text-secondary`/`focus:ring-primary` resolve via `tailwind.config.js` tokens `secondary=#185D79`, `primary=#1E7295`.)
|
||||
|
||||
- [ ] **Step 2: Section titles** — they already use `<Text small bold variant="primary">` (theme `#1E7295`). Leave as-is (already token-aligned) OR, if a stronger separator is wanted, add `border-b border-[#e3e3da] pb-1` to the section header row. Keep minimal.
|
||||
|
||||
- [ ] **Step 3: Apply `font-mono` to technical inline values** already covered by `readonlyClass` (Classname/Public Name from Task 3). Also render the header `ID: {id} | Sprite: {spriteId}` (line ~223) in `font-mono text-[#555]` for consistency.
|
||||
|
||||
- [ ] **Step 4: Typecheck + visual check** — `yarn typecheck` clean; with `yarn start`, confirm labels are now uppercase secondary-tinted, inputs larger with a focus ring, classname/public-name monospace on muted bg.
|
||||
|
||||
- [ ] **Step 5: Commit:**
|
||||
```
|
||||
git -C "E:/Users/simol/Desktop/DEV/Nitro-V3" add src/components/furni-editor/views/FurniEditorEditView.tsx
|
||||
git -C "E:/Users/simol/Desktop/DEV/Nitro-V3" commit -m "style(furni-editor): theme-token typography refresh (labels, inputs focus ring, mono read-only)"
|
||||
```
|
||||
NO `Co-Authored-By`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5 (server): search also matches furnidata display name
|
||||
|
||||
Lets the Search box find furni by their real (furnidata) name, not just `item_name`/`public_name`.
|
||||
|
||||
**Files (Arcturus):** Modify `Emulator/.../messages/incoming/furnieditor/FurniEditorSearchEvent.java`.
|
||||
|
||||
- [ ] **Step 1:** Read the existing `FurniEditorSearchEvent.handle()` (it queries `items_base` by `item_name`/`public_name` LIKE the query). After collecting the DB matches, also scan the in-memory furnidata index for display-name matches and union their item ids:
|
||||
- Get the provider: `FurnitureTextProvider p = Emulator.getGameEnvironment().getFurnitureTextProvider();`
|
||||
- The provider currently exposes `getName(classname)` but not a name→classnames search. Add a method to `FurnitureTextProvider`: `public java.util.List<String> findClassnamesByName(String q)` that lowercases `q` and returns classnames whose indexed name contains it (iterate the `index` map values; cap results e.g. 200). Then map those classnames → `items_base.id` via a `SELECT id FROM items_base WHERE item_name IN (...)` and merge with the existing result rows (dedupe by id, keep the existing result row shape).
|
||||
- Keep it bounded (cap added rows) and behind the same `ACC_CATALOGFURNI` gate.
|
||||
|
||||
- [ ] **Step 2: Build** `cd Emulator && mvn -q compile` → SUCCESS.
|
||||
|
||||
- [ ] **Step 3: Commit** (Arcturus repo, `main`):
|
||||
```
|
||||
git -C "E:/Users/simol/Desktop/DEV/Arcturus-Morningstar-Extended" add Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java
|
||||
git -C "E:/Users/simol/Desktop/DEV/Arcturus-Morningstar-Extended" commit -m "feat(furnieditor): search also matches furnidata display names"
|
||||
```
|
||||
NO `Co-Authored-By`. (This task is optional/last — if it balloons, ship Tasks 1–4 first.)
|
||||
|
||||
---
|
||||
|
||||
## Task 6: final build/verify
|
||||
|
||||
- [ ] Renderer: `cd Nitro_Render_V3 && yarn compile:fast` clean.
|
||||
- [ ] Client: `cd Nitro-V3 && yarn typecheck && yarn test --run` green (pre-existing unrelated failures noted, not introduced).
|
||||
- [ ] Server (if Task 5 done): `cd Emulator && mvn -q package -DskipTests=false` SUCCESS; deploy jar to `Latest_Compiled_Version` + restart for manual end-to-end.
|
||||
- [ ] Manual acceptance: edit a furni's display name in the editor → confirm modal → live update in catalog/inventory/infostand without refresh; Revert restores; Classname/Public Name read-only; search by display name finds it; audit row written.
|
||||
|
||||
## Self-review
|
||||
- Spec §5 coverage: editable furnidata name/desc (T3), read-only classname/public_name (T3), diff+confirm (T3), revert (T2/T3), live-preview/dirty (T3), search-by-name (T5), typography (T4), composers/headers matching server (T1). ✓
|
||||
- Header consistency: client outgoing 10046/10048 == server incoming 10046/10048; result via 10044; live via 10047. ✓
|
||||
- Types: `updateFurnidata(id,name,description)`, `revertFurnidata(id)`, `onUpdateFurnidata`/`onRevertFurnidata` props, `readonlyClass` — consistent across T2/T3/T4.
|
||||
- Open: confirm the real renderer composer import path + send helper (T1/T2) and the real `inputClass`/`errors` names (T4) by reading the files first.
|
||||
@@ -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).
|
||||
@@ -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<String /*classname lowercase*/, FurniText {int id, String name, String description}>`.
|
||||
- `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).
|
||||
@@ -795,4 +795,8 @@
|
||||
'mentions.alias.description.everyone': "Everyone in the hotel",
|
||||
'mentions.alias.description.friends': "Your online friends",
|
||||
'mentions.alias.description.room': "Everyone in this room",
|
||||
// Messenger (offline delivery + typing)
|
||||
// ------------------------------------------------------------------------
|
||||
'messenger.offline.delivered': 'Sent while you were offline',
|
||||
'messenger.typing': '%FRIEND_NAME% is typing...',
|
||||
}
|
||||
|
||||
@@ -795,4 +795,8 @@
|
||||
'mentions.alias.description.everyone': "Tutti nell'hotel",
|
||||
'mentions.alias.description.friends': "I tuoi amici online",
|
||||
'mentions.alias.description.room': "Tutti in questa stanza",
|
||||
// Messenger (offline delivery + typing)
|
||||
// ------------------------------------------------------------------------
|
||||
'messenger.offline.delivered': 'Inviato mentre eri offline',
|
||||
'messenger.typing': '%FRIEND_NAME% sta scrivendo...',
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -74,6 +77,21 @@ export class MessengerThreadChat
|
||||
return this._extraData;
|
||||
}
|
||||
|
||||
public get offlineDelivered(): boolean
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<number, number> =>
|
||||
{
|
||||
const counts = new Map<number, number>();
|
||||
|
||||
if(!friends) return counts;
|
||||
|
||||
for(const friend of friends)
|
||||
{
|
||||
counts.set(friend.categoryId, (counts.get(friend.categoryId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return counts;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './friendCategory.helpers';
|
||||
export * from './GetGroupChatData';
|
||||
export * from './IGroupChatData';
|
||||
export * from './MessengerFriend';
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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';
|
||||
import { useFriendsActions } from '../../../../hooks';
|
||||
|
||||
interface FriendsCategoryManagerViewProps
|
||||
{
|
||||
categories: FriendCategoryData[];
|
||||
onCloseClick: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const FriendsCategoryManagerView: FC<FriendsCategoryManagerViewProps> = props =>
|
||||
{
|
||||
const { categories = [], onCloseClick = null } = props;
|
||||
const { addCategory, renameCategory, removeCategory } = useFriendsActions();
|
||||
const [ newName, setNewName ] = useState<string>('');
|
||||
const [ editingId, setEditingId ] = useState<number>(0);
|
||||
const [ editingName, setEditingName ] = useState<string>('');
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(editingId && !categories.some(category => (category.id === editingId)))
|
||||
{
|
||||
setEditingId(0);
|
||||
setEditingName('');
|
||||
}
|
||||
}, [ categories, editingId ]);
|
||||
|
||||
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 (
|
||||
<NitroCardView className="nitro-friends-category-manager" theme="primary-slim" uniqueKey="nitro-friends-category-manager" isResizable={ false } style={ { width: 270, minWidth: 270 } }>
|
||||
<NitroCardHeaderView headerText={ LocalizeText('friendlist.friends') } onCloseClick={ onCloseClick } />
|
||||
<NitroCardContentView className="text-black" gap={ 1 }>
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
<input
|
||||
className="form-control form-control-sm w-full"
|
||||
maxLength={ 25 }
|
||||
type="text"
|
||||
value={ newName }
|
||||
onChange={ event => setNewName(event.target.value) }
|
||||
onKeyDown={ event => (event.key === 'Enter') && submitAdd() } />
|
||||
<Button disabled={ !newName.trim().length || (categories.length >= 20) } onClick={ submitAdd }>
|
||||
{ LocalizeText('catalog.admin.create') }
|
||||
</Button>
|
||||
</Flex>
|
||||
<Column gap={ 1 }>
|
||||
{ categories.map(category => (
|
||||
<Flex key={ category.id } alignItems="center" gap={ 1 }>
|
||||
{ (editingId === category.id) ?
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control form-control-sm w-full"
|
||||
maxLength={ 25 }
|
||||
type="text"
|
||||
value={ editingName }
|
||||
onChange={ event => setEditingName(event.target.value) }
|
||||
onKeyDown={ event => (event.key === 'Enter') && submitRename() } />
|
||||
<Button onClick={ submitRename }>
|
||||
{ LocalizeText('catalog.admin.save') }
|
||||
</Button>
|
||||
</>
|
||||
:
|
||||
<>
|
||||
<span className="grow text-sm">{ category.name }</span>
|
||||
<span
|
||||
className="cursor-pointer text-base leading-none select-none"
|
||||
title={ LocalizeText('generic.edit') }
|
||||
onClick={ () => { setEditingId(category.id); setEditingName(category.name); } }>
|
||||
{ '✎' }
|
||||
</span>
|
||||
<span
|
||||
className="cursor-pointer text-base leading-none select-none"
|
||||
title={ LocalizeText('generic.delete') }
|
||||
onClick={ () => removeCategory(category.id) }>
|
||||
{ '✕' }
|
||||
</span>
|
||||
</> }
|
||||
</Flex>
|
||||
)) }
|
||||
{ !categories.length &&
|
||||
<span className="text-muted text-center py-2 text-sm">
|
||||
{ LocalizeText('friendlist.search.nofriendsfound') }
|
||||
</span> }
|
||||
</Column>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
);
|
||||
};
|
||||
@@ -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<FriendsListGroupChipsViewProps> = props =>
|
||||
{
|
||||
const { categories = [], friends = [], selectedCategoryId = 0, setSelectedCategoryId = null, onManageClick = null } = props;
|
||||
|
||||
const counts = countFriendsByCategory(friends);
|
||||
|
||||
return (
|
||||
<Flex alignItems="center" className="friends-group-chips px-2 py-1" gap={ 1 }>
|
||||
<Flex alignItems="center" className="friends-group-chips-scroll" gap={ 1 }>
|
||||
<div className={ `friends-group-chip${ (selectedCategoryId === 0) ? ' active' : '' }` } onClick={ () => setSelectedCategoryId(0) }>
|
||||
{ LocalizeText('friendlist.friends') } ({ friends.length })
|
||||
</div>
|
||||
{ categories.map(category => (
|
||||
<div key={ category.id } className={ `friends-group-chip${ (selectedCategoryId === category.id) ? ' active' : '' }` } onClick={ () => setSelectedCategoryId(category.id) }>
|
||||
{ category.name } ({ counts.get(category.id) ?? 0 })
|
||||
</div>
|
||||
)) }
|
||||
</Flex>
|
||||
<div className="friends-group-chip friends-group-chip-manage ms-auto" title={ LocalizeText('friendlist.friends') } onClick={ onManageClick }>
|
||||
{ '⚙' }
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -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<number[]>([]);
|
||||
const [ showRoomInvite, setShowRoomInvite ] = useState<boolean>(false);
|
||||
const [ showRemoveFriendsConfirmation, setShowRemoveFriendsConfirmation ] = useState<boolean>(false);
|
||||
const { onlineFriends = [], offlineFriends = [], requests = [], requestFriend = null } = useFriends();
|
||||
const [ selectedCategoryId, setSelectedCategoryId ] = useState<number>(0);
|
||||
const [ showCategoryManager, setShowCategoryManager ] = useState<boolean>(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 =>
|
||||
<NitroCardView className="nitro-friends" theme="primary-slim" uniqueKey="nitro-friends">
|
||||
<NitroCardHeaderView headerText={ LocalizeText('friendlist.friends') } onCloseClick={ event => setIsVisible(false) } />
|
||||
<NitroCardContentView className="text-black p-0" gap={ 1 } overflow="hidden">
|
||||
<FriendsListGroupChipsView
|
||||
categories={ categories }
|
||||
friends={ friends }
|
||||
selectedCategoryId={ selectedCategoryId }
|
||||
setSelectedCategoryId={ setSelectedCategoryId }
|
||||
onManageClick={ () => setShowCategoryManager(true) } />
|
||||
<NitroCardAccordionView fullHeight overflow="hidden">
|
||||
<NitroCardAccordionSetView className="friends-list-section" headerText={ LocalizeText('friendlist.friends') + ` (${ onlineFriends.length })` } isExpanded={ true }>
|
||||
<NitroCardAccordionSetView className="friends-list-section" headerText={ LocalizeText('friendlist.friends') + ` (${ filteredOnlineFriends.length })` } isExpanded={ true }>
|
||||
<Flex className="friends-list-toolbar px-2 py-1" justifyContent="end">
|
||||
<span className="friends-list-toolbar-link" onClick={ event =>
|
||||
{
|
||||
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') }
|
||||
</span>
|
||||
</Flex>
|
||||
<FriendsListGroupView list={ onlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
|
||||
<FriendsListGroupView list={ filteredOnlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
|
||||
</NitroCardAccordionSetView>
|
||||
<NitroCardAccordionSetView headerText={ LocalizeText('friendlist.friends.offlinecaption') + ` (${ offlineFriends.length })` }>
|
||||
<NitroCardAccordionSetView headerText={ LocalizeText('friendlist.friends.offlinecaption') + ` (${ filteredOfflineFriends.length })` }>
|
||||
<Flex className="friends-list-toolbar px-2 py-1" justifyContent="end">
|
||||
<span className="friends-list-toolbar-link" onClick={ event =>
|
||||
{
|
||||
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') }
|
||||
</span>
|
||||
</Flex>
|
||||
<FriendsListGroupView list={ offlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
|
||||
<FriendsListGroupView list={ filteredOfflineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
|
||||
</NitroCardAccordionSetView>
|
||||
<FriendsListRequestView headerText={ LocalizeText('friendlist.tab.friendrequests') + ` (${ requests.length })` } isExpanded={ true } />
|
||||
<FriendsSearchView headerText={ LocalizeText('people.search.title') } />
|
||||
@@ -186,6 +200,8 @@ export const FriendsListView: FC<{}> = props =>
|
||||
<FriendsRoomInviteView selectedFriendsIds={ selectedFriendsIds } sendRoomInvite={ sendRoomInvite } onCloseClick={ () => setShowRoomInvite(false) } /> }
|
||||
{ showRemoveFriendsConfirmation &&
|
||||
<FriendsRemoveConfirmationView removeFriendsText={ removeFriendsText } removeSelectedFriends={ removeSelectedFriends } selectedFriendsIds={ selectedFriendsIds } onCloseClick={ () => setShowRemoveFriendsConfirmation(false) } /> }
|
||||
{ showCategoryManager &&
|
||||
<FriendsCategoryManagerView categories={ categories } onCloseClick={ () => setShowCategoryManager(false) } /> }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+18
-1
@@ -9,7 +9,9 @@ export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: b
|
||||
{
|
||||
const { friend = null, selected = false, selectFriend = null } = props;
|
||||
const [ isRelationshipOpen, setIsRelationshipOpen ] = useState<boolean>(false);
|
||||
const { followFriend = null, updateRelationship = null } = useFriends();
|
||||
const { followFriend = null, updateRelationship = null, moveFriendToCategory = null, settings = null } = useFriends();
|
||||
const [ isGroupMenuOpen, setIsGroupMenuOpen ] = useState<boolean>(false);
|
||||
const categories = settings?.categories ?? [];
|
||||
|
||||
const clickFollowFriend = (event: MouseEvent<HTMLDivElement>) =>
|
||||
{
|
||||
@@ -74,6 +76,21 @@ export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: b
|
||||
<div className="nitro-friends-spritesheet icon-follow cursor-pointer" title={ LocalizeText('friendlist.tip.follow') } onClick={ clickFollowFriend } /> }
|
||||
{ friend.online &&
|
||||
<div className="nitro-friends-spritesheet icon-chat cursor-pointer" title={ LocalizeText('friendlist.tip.im') } onClick={ openMessengerChat } /> }
|
||||
{ (friend.id > 0) && (categories.length > 0) &&
|
||||
<div className="friends-list-group-assign position-relative">
|
||||
<div className="friends-list-group-toggle cursor-pointer" title={ LocalizeText('friendlist.friends') } onClick={ event => { event.stopPropagation(); setIsGroupMenuOpen(prev => !prev); } }>{ '📁' }</div>
|
||||
{ isGroupMenuOpen &&
|
||||
<div className="friends-list-group-menu">
|
||||
<div className={ `friends-list-group-menu-item${ (friend.categoryId === 0) ? ' active' : '' }` } onClick={ event => { event.stopPropagation(); moveFriendToCategory(friend.id, 0); setIsGroupMenuOpen(false); } }>
|
||||
{ LocalizeText('friendlist.friends') }
|
||||
</div>
|
||||
{ categories.map(category => (
|
||||
<div key={ category.id } className={ `friends-list-group-menu-item${ (friend.categoryId === category.id) ? ' active' : '' }` } onClick={ event => { event.stopPropagation(); moveFriendToCategory(friend.id, category.id); setIsGroupMenuOpen(false); } }>
|
||||
{ category.name }
|
||||
</div>
|
||||
)) }
|
||||
</div> }
|
||||
</div> }
|
||||
{ (friend.id > 0) &&
|
||||
<div className={ `nitro-friends-spritesheet icon-${ getCurrentRelationshipName() } cursor-pointer` } title={ LocalizeText('infostand.link.relationship') } onClick={ openRelationship } /> }
|
||||
</> }
|
||||
|
||||
@@ -12,11 +12,53 @@ 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 { getFriend = null } = useFriends();
|
||||
const { report = null } = useHelp();
|
||||
const { settings, translateOutgoing } = useTranslation();
|
||||
const messagesBox = useRef<HTMLDivElement>(null);
|
||||
const isTypingRef = useRef<boolean>(false);
|
||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const stopTyping = () =>
|
||||
{
|
||||
if(typingTimeoutRef.current)
|
||||
{
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
typingTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if(isTypingRef.current && activeThread && activeThread.participant && (activeThread.participant.id > 0))
|
||||
{
|
||||
sendTypingStatus(activeThread.participant.id, false);
|
||||
}
|
||||
|
||||
isTypingRef.current = false;
|
||||
};
|
||||
|
||||
const handleInputChange = (value: string) =>
|
||||
{
|
||||
setMessageText(value);
|
||||
|
||||
const peerId = (activeThread && activeThread.participant) ? activeThread.participant.id : 0;
|
||||
|
||||
if(peerId <= 0) return;
|
||||
|
||||
if(!value.length)
|
||||
{
|
||||
stopTyping();
|
||||
return;
|
||||
}
|
||||
|
||||
if(!isTypingRef.current)
|
||||
{
|
||||
sendTypingStatus(peerId, true);
|
||||
isTypingRef.current = true;
|
||||
}
|
||||
|
||||
if(typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||
typingTimeoutRef.current = setTimeout(() => stopTyping(), 4000);
|
||||
};
|
||||
|
||||
const followFriend = () => (activeThread && activeThread.participant && SendMessageComposer(new FollowFriendMessageComposer(activeThread.participant.id)));
|
||||
const openProfile = () => (activeThread && activeThread.participant && GetUserProfile(activeThread.participant.id));
|
||||
@@ -25,6 +67,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) !== ':');
|
||||
|
||||
@@ -102,6 +146,14 @@ export const FriendsMessengerView: FC<{}> = props =>
|
||||
messagesBox.current.scrollTop = messagesBox.current.scrollHeight;
|
||||
}, [ isVisible, activeThread ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
return () =>
|
||||
{
|
||||
stopTyping();
|
||||
};
|
||||
}, [ activeThread ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(isVisible && !activeThread)
|
||||
@@ -184,8 +236,13 @@ export const FriendsMessengerView: FC<{}> = props =>
|
||||
<FriendsMessengerThreadView thread={ activeThread } />
|
||||
</div>
|
||||
|
||||
{ activeThread.participant && (activeThread.participant.id > 0) && (typingUserIds.indexOf(activeThread.participant.id) >= 0) &&
|
||||
<div className="messenger-typing-indicator">
|
||||
{ LocalizeText('messenger.typing', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) }
|
||||
</div> }
|
||||
|
||||
<div className="messenger-input-row">
|
||||
<input maxLength={ 255 } placeholder={ LocalizeText('messenger.window.input.default', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } type="text" value={ messageText } onChange={ event => setMessageText(event.target.value) } onKeyDown={ onKeyDown } />
|
||||
<input maxLength={ 255 } placeholder={ LocalizeText('messenger.window.input.default', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } type="text" value={ messageText } onChange={ event => handleInputChange(event.target.value) } onKeyDown={ onKeyDown } />
|
||||
<button className="messenger-btn send" onClick={ () => void send() }>
|
||||
{ LocalizeText('widgets.chatinput.say') }
|
||||
</button>
|
||||
|
||||
+11
-1
@@ -68,7 +68,13 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
|
||||
{
|
||||
if(!chat.showTranslation)
|
||||
{
|
||||
return <Base key={ index } className="text-break">{ chat.message }</Base>;
|
||||
return (
|
||||
<Base key={ index } className="text-break">
|
||||
{ chat.message }
|
||||
{ chat.offlineDelivered &&
|
||||
<span className="messenger-offline-tag">{ LocalizeText('messenger.offline.delivered') }</span> }
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -86,6 +92,10 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
|
||||
}) }
|
||||
</Base>
|
||||
<Base className="messenger-message-time">{ group.chats[0].date.toLocaleTimeString() }</Base>
|
||||
{ isOwnChat && (group.type === MessengerGroupType.PRIVATE_CHAT) && (group.chats[group.chats.length - 1].type === MessengerThreadChat.CHAT) &&
|
||||
<Base className={ 'messenger-message-status ' + ((group.chats[group.chats.length - 1].status === MessengerThreadChat.READ) ? 'read' : '') }>
|
||||
{ (group.chats[group.chats.length - 1].status === MessengerThreadChat.READ) ? '✓✓' : '✓' }
|
||||
</Base> }
|
||||
</Base>
|
||||
{ isOwnChat &&
|
||||
<Base shrink className="message-avatar">
|
||||
|
||||
@@ -16,7 +16,7 @@ export const FurniEditorView: FC<{}> = () =>
|
||||
|
||||
const {
|
||||
items, total, page, loading, error, clearError,
|
||||
selectedItem, setSelectedItem, furniDataEntry,
|
||||
selectedItem, setSelectedItem, furniDataEntry, furniDataDiagnostic,
|
||||
interactions,
|
||||
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions,
|
||||
updateFurnidata, revertFurnidata, importText, importResult
|
||||
@@ -151,6 +151,7 @@ export const FurniEditorView: FC<{}> = () =>
|
||||
<FurniEditorEditView
|
||||
item={ selectedItem }
|
||||
furniDataEntry={ furniDataEntry }
|
||||
furniDataDiagnostic={ furniDataDiagnostic }
|
||||
interactions={ interactions }
|
||||
loading={ loading }
|
||||
onUpdate={ updateItem }
|
||||
|
||||
@@ -7,6 +7,7 @@ interface FurniEditorEditViewProps
|
||||
{
|
||||
item: FurniDetail;
|
||||
furniDataEntry: Record<string, unknown> | null;
|
||||
furniDataDiagnostic: Record<string, unknown> | null;
|
||||
interactions: string[];
|
||||
loading: boolean;
|
||||
onUpdate: (id: number, fields: Record<string, unknown>) => void;
|
||||
@@ -121,7 +122,7 @@ const CopyValue: FC<{ value: string | number }> = ({ value }) =>
|
||||
|
||||
export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||
{
|
||||
const { item, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack, onUpdateFurnidata, onRevertFurnidata, onImportText, importResult } = props;
|
||||
const { item, furniDataEntry, furniDataDiagnostic, interactions, loading, onUpdate, onDelete, onBack, onUpdateFurnidata, onRevertFurnidata, onImportText, importResult } = props;
|
||||
const saveRef = useRef<() => void>(null);
|
||||
|
||||
const [ form, setForm ] = useState({
|
||||
@@ -252,6 +253,14 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||
furniName !== String(furniDataEntry?.name ?? '') || furniDescription !== String(furniDataEntry?.description ?? ''),
|
||||
[ furniName, furniDescription, furniDataEntry ]);
|
||||
|
||||
const furnidataMissReason = useMemo(() =>
|
||||
{
|
||||
const reason = String(furniDataDiagnostic?.reason ?? '');
|
||||
return reason || 'not_found';
|
||||
}, [ furniDataDiagnostic ]);
|
||||
|
||||
const furnidataSourcePath = String(furniDataDiagnostic?.sourcePath ?? '');
|
||||
|
||||
// Apply an "Import from Habbo" result into the editable fields (review then Save).
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -391,7 +400,7 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 text-[11px] text-slate-500 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 leading-snug">
|
||||
<span className="text-[#f59e0b] text-sm leading-none mt-px">⚠</span>
|
||||
<span>This furni has no matching <b>furnidata</b> entry (e.g. a pet or custom item), so its display name can't be edited here. Clients fall back to the DB <b>Public Name</b> below.</span>
|
||||
<span>This furni has no matching <b>furnidata</b> entry ({ furnidataMissReason.replace(/_/g, ' ') }), so its display name can't be edited here. Clients fall back to the DB <b>Public Name</b> below.</span>
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
@@ -424,6 +433,20 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||
</Section>
|
||||
}
|
||||
|
||||
<Section title="Furnidata Debug" defaultOpen={ false }>
|
||||
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||
<div>
|
||||
<label className={ labelClass }>Resolution</label>
|
||||
<CopyValue value={ furnidataMissReason } />
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Source</label>
|
||||
<CopyValue value={ furnidataSourcePath || 'unresolved' } />
|
||||
</div>
|
||||
</div>
|
||||
<pre className="text-[10px] leading-snug text-slate-600 bg-slate-50 border border-slate-200 rounded-lg p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{ JSON.stringify(furniDataDiagnostic ?? {}, null, 2) }</pre>
|
||||
</Section>
|
||||
|
||||
<Section title="Dimensions">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
|
||||
@@ -236,6 +236,10 @@ export const ChatInputView: FC<{}> = props =>
|
||||
{
|
||||
switch(event.chatMode)
|
||||
{
|
||||
case RoomWidgetUpdateChatInputContentEvent.TEXT:
|
||||
setChatValue(event.userName);
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
case RoomWidgetUpdateChatInputContentEvent.WHISPER: {
|
||||
setChatValue(`${ chatModeIdWhisper } ${ event.userName } `);
|
||||
return;
|
||||
|
||||
@@ -804,6 +804,127 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.messenger-offline-tag {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
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;
|
||||
}
|
||||
|
||||
.messenger-typing-indicator {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.nitro-friends .friends-list-avatar {
|
||||
position: relative !important;
|
||||
width: 32px;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GetSessionDataManager, NewConsoleMessageEvent, RoomInviteErrorEvent, RoomInviteEvent, SendMessageComposer as SendMessageComposerPacket } from '@nitrots/nitro-renderer';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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';
|
||||
import { useMessageEvent } from '../events';
|
||||
@@ -17,6 +17,12 @@ const useMessengerState = () =>
|
||||
const { simpleAlert = null } = useNotification();
|
||||
const { settings, translateIncoming } = useTranslation();
|
||||
|
||||
const [typingUserIds, setTypingUserIds] = useState<number[]>([]);
|
||||
const typingTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
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]);
|
||||
|
||||
@@ -148,6 +154,13 @@ const useMessengerState = () =>
|
||||
});
|
||||
};
|
||||
|
||||
const sendTypingStatus = (peerId: number, isTyping: boolean) =>
|
||||
{
|
||||
if (!peerId || (peerId <= 0)) return;
|
||||
|
||||
SendMessageComposer(new ConsoleTypingComposer(peerId, isTyping));
|
||||
};
|
||||
|
||||
useMessageEvent<NewConsoleMessageEvent>(NewConsoleMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
@@ -156,6 +169,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>(RoomInviteEvent, event =>
|
||||
@@ -175,10 +189,65 @@ const useMessengerState = () =>
|
||||
simpleAlert(`Received room invite error: ${ parser.errorCode },recipients: ${ parser.failedRecipients.join(',') }`, NotificationAlertType.DEFAULT, null, null, LocalizeText('friendlist.alert.title'));
|
||||
});
|
||||
|
||||
useMessageEvent<FriendIsTypingEvent>(FriendIsTypingEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const senderId = parser.senderId;
|
||||
|
||||
if (senderId <= 0) return;
|
||||
|
||||
const timers = typingTimersRef.current;
|
||||
const existing = timers.get(senderId);
|
||||
|
||||
if (existing)
|
||||
{
|
||||
clearTimeout(existing);
|
||||
timers.delete(senderId);
|
||||
}
|
||||
|
||||
if (parser.isTyping)
|
||||
{
|
||||
setTypingUserIds(prev => (prev.indexOf(senderId) >= 0) ? prev : [...prev, senderId]);
|
||||
|
||||
timers.set(senderId, setTimeout(() =>
|
||||
{
|
||||
typingTimersRef.current.delete(senderId);
|
||||
setTypingUserIds(prev => prev.filter(id => (id !== senderId)));
|
||||
}, 6000));
|
||||
}
|
||||
else
|
||||
{
|
||||
setTypingUserIds(prev => prev.filter(id => (id !== senderId)));
|
||||
}
|
||||
});
|
||||
|
||||
useMessageEvent<ConsoleReadReceiptEvent>(ConsoleReadReceiptEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const ownUserId = GetSessionDataManager().userId;
|
||||
|
||||
setMessageThreads(prevValue =>
|
||||
{
|
||||
const index = prevValue.findIndex(thread => (thread.participant && (thread.participant.id === parser.readerId)));
|
||||
|
||||
if (index === -1) return prevValue;
|
||||
|
||||
const newValue = [...prevValue];
|
||||
|
||||
newValue[index] = CloneObject(newValue[index]);
|
||||
newValue[index].setMessagesReadFromUser(ownUserId);
|
||||
|
||||
return newValue;
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (activeThreadId <= 0) return;
|
||||
|
||||
const activeThreadValue = messageThreadsRef.current.find(thread => (thread.threadId === activeThreadId));
|
||||
const participantId = activeThreadValue?.participant?.id ?? 0;
|
||||
|
||||
setMessageThreads(prevValue =>
|
||||
{
|
||||
const newValue = [...prevValue];
|
||||
@@ -187,12 +256,13 @@ const useMessengerState = () =>
|
||||
if (index >= 0)
|
||||
{
|
||||
newValue[index] = CloneObject(newValue[index]);
|
||||
|
||||
newValue[index].setRead();
|
||||
}
|
||||
|
||||
return newValue;
|
||||
});
|
||||
|
||||
if (participantId > 0) SendMessageComposer(new MarkConsoleReadComposer(participantId));
|
||||
}, [activeThreadId]);
|
||||
|
||||
useEffect(() =>
|
||||
@@ -219,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);
|
||||
|
||||
@@ -60,6 +60,7 @@ export const useFurniEditor = () =>
|
||||
const [ catalogItems, setCatalogItems ] = useState<CatalogRef[]>([]);
|
||||
const [ interactions, setInteractions ] = useState<string[]>([]);
|
||||
const [ furniDataEntry, setFurniDataEntry ] = useState<Record<string, unknown> | null>(null);
|
||||
const [ furniDataDiagnostic, setFurniDataDiagnostic ] = useState<Record<string, unknown> | null>(null);
|
||||
const pendingActionRef = useRef<{ action: string; itemId: number } | null>(null);
|
||||
const [ importResult, setImportResult ] = useState<{ found: boolean; name: string; description: string; classname: string; nonce: number } | null>(null);
|
||||
const importNonceRef = useRef(0);
|
||||
@@ -152,6 +153,20 @@ export const useFurniEditor = () =>
|
||||
{}
|
||||
|
||||
setFurniDataEntry(furniData);
|
||||
|
||||
let diagnostic: Record<string, unknown> | null = null;
|
||||
|
||||
try
|
||||
{
|
||||
if(parser.furniDataDiagnosticJson && parser.furniDataDiagnosticJson !== '{}' && parser.furniDataDiagnosticJson !== '')
|
||||
{
|
||||
diagnostic = JSON.parse(parser.furniDataDiagnosticJson);
|
||||
}
|
||||
}
|
||||
catch(e)
|
||||
{}
|
||||
|
||||
setFurniDataDiagnostic(diagnostic);
|
||||
});
|
||||
|
||||
// Handle interaction types list
|
||||
@@ -203,6 +218,7 @@ export const useFurniEditor = () =>
|
||||
setSelectedItem(null);
|
||||
setCatalogItems([]);
|
||||
setFurniDataEntry(null);
|
||||
setFurniDataDiagnostic(null);
|
||||
|
||||
if(simpleAlert)
|
||||
{
|
||||
@@ -295,7 +311,7 @@ export const useFurniEditor = () =>
|
||||
|
||||
return {
|
||||
items, total, page, loading, error, clearError,
|
||||
selectedItem, setSelectedItem, catalogItems, furniDataEntry,
|
||||
selectedItem, setSelectedItem, catalogItems, furniDataEntry, furniDataDiagnostic,
|
||||
interactions,
|
||||
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions,
|
||||
updateFurnidata, revertFurnidata, importText, importResult
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
],
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"pixi.js": [
|
||||
"../Nitro_Render_V3/node_modules/pixi.js"
|
||||
],
|
||||
"@nitrots/nitro-renderer": [
|
||||
"../Nitro_Render_V3/index.ts"
|
||||
],
|
||||
"@nitrots/*": [
|
||||
"../Nitro_Render_V3/packages/*/src/index.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user