Files
Nitro-V3/docs/superpowers/plans/2026-06-02-messenger-phase1-friend-groups.md
simoleo89 76b7e21494 docs(messenger): Phase 1 implementation plan — friend groups
Bite-sized TDD plan across all three codebases for the friend-groups
feature: 4 client->server category packets (renderer composers +
Arcturus handlers), DB persistence (reusing existing
messenger_categories + messenger_friendships.category), server-
authoritative re-push via existing MessengerInit/UpdateFriend
composers, and client store actions + chip-filter UI + manager modal +
per-friend assign control + pure filter helper with tests.
2026-06-02 17:17:29 +02:00

50 KiB
Raw Permalink Blame History

Messenger Phase 1 — Friend Groups Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add full custom friend groups (create / rename / delete / assign) to the existing React friends list, with an Online/Offline-primary view plus a per-group chip filter.

Architecture: Four new client→server packets (renderer composers + Arcturus handlers) drive category CRUD + friend assignment. The server persists to the existing messenger_categories table and the messenger_friendships.category column, then re-pushes authoritative state through the existing MessengerInitComposer (category list) and UpdateFriendComposer (a friend's new categoryId). The client already receives categories via MessengerInitEvent and categoryId per friend — we add CRUD actions + UI and a pure group-filter helper.

Tech Stack: Arcturus (Java 21/Maven/HikariCP), Nitro_Render_V3 (TypeScript, yarn workspaces, Vitest), Nitro-V3 (React 19, Vite, Vitest).


Cross-codebase header-ID contract

Renderer Outgoing header N == Arcturus Incoming header N (verified across 8 existing friend packets, e.g. SET_RELATIONSHIP_STATUS = 3768 == ChangeRelationEvent = 3768). Phase 1 reuses existing server→client headers, so it needs 4 new client→server header IDs only, used identically in OutgoingHeader.ts (renderer) and Incoming.java (Arcturus):

Logical packet Renderer composer Arcturus handler Constant name (both sides)
Add category AddFriendCategoryComposer(name) AddFriendCategoryEvent ADD_FRIEND_CATEGORY / AddFriendCategoryEvent
Rename category RenameFriendCategoryComposer(categoryId, name) RenameFriendCategoryEvent RENAME_FRIEND_CATEGORY / RenameFriendCategoryEvent
Remove category RemoveFriendCategoryComposer(categoryId) RemoveFriendCategoryEvent REMOVE_FRIEND_CATEGORY / RemoveFriendCategoryEvent
Assign friend → category MoveFriendToCategoryComposer(friendId, categoryId) MoveFriendToCategoryEvent MOVE_FRIEND_TO_CATEGORY / MoveFriendToCategoryEvent

The concrete numbers are chosen and verified in Task 1.

File map

Arcturus (Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/):

  • Modify messages/incoming/Incoming.java — 4 new constants
  • Modify messages/PacketManager.java — 4 registerHandler lines
  • Modify habbohotel/messenger/MessengerBuddy.javasetCategoryId(int)
  • Modify habbohotel/users/HabboInfo.javarenameMessengerCategory(int, String)
  • Create messages/incoming/friends/AddFriendCategoryEvent.java
  • Create messages/incoming/friends/RenameFriendCategoryEvent.java
  • Create messages/incoming/friends/RemoveFriendCategoryEvent.java
  • Create messages/incoming/friends/MoveFriendToCategoryEvent.java

Renderer (Nitro_Render_V3/packages/communication/src/):

  • Modify messages/outgoing/OutgoingHeader.ts — 4 constants
  • Modify NitroMessages.ts — imports + 4 _composers.set
  • Modify messages/outgoing/friendlist/index.ts — 4 exports
  • Create messages/outgoing/friendlist/AddFriendCategoryComposer.ts
  • Create messages/outgoing/friendlist/RenameFriendCategoryComposer.ts
  • Create messages/outgoing/friendlist/RemoveFriendCategoryComposer.ts
  • Create messages/outgoing/friendlist/MoveFriendToCategoryComposer.ts
  • Create messages/outgoing/friendlist/__tests__/FriendCategoryComposers.test.ts

Client (Nitro-V3/src/):

  • Create api/friends/friendCategory.helpers.ts + .test.ts
  • Modify api/friends/index.ts — export helper
  • Modify hooks/friends/useFriends.ts — 4 actions + composer imports
  • Modify components/friends/views/friends-list/FriendsListView.tsx — chip row + filter
  • Create components/friends/views/friends-list/FriendsListGroupChipsView.tsx — chip filter row
  • Create components/friends/views/friends-list/FriendsCategoryManagerView.tsx — create/rename/delete modal
  • Modify components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx — assign control
  • Modify css/friends/FriendsView.css — chip + manager + assign styles

Task 1: Allocate & verify the 4 category header IDs

Files: none yet (decision + verification only).

  • Step 1: Try official IDs, then pick verified-free fallbacks

The user prefers official Habbo revision IDs for category packets. First check whether the connecting revision shipped friend-category management packets:

Run (renderer): grep -rin "categor" Nitro_Render_V3/packages/communication/src/messages/outgoing/OutgoingHeader.ts Expected: only MESSENGER_* friend headers, no add/rename/remove/move-category constant.

If no official category constants are found (expected — the bundled revision's OutgoingHeader.ts has none), use the custom fallback quartet 4081, 4082, 4083, 4084 and verify they are free on BOTH sides.

  • Step 2: Verify the four numbers are unused on both sides

Run:

grep -rnE "= ?408[1-4]\b" Nitro_Render_V3/packages/communication/src/messages/outgoing/OutgoingHeader.ts
grep -rnE "= ?408[1-4]\b" Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java

Expected: no output from either command (the IDs are free).

If either prints a match, increment the base (try 40854088, etc.) and re-run until both commands return nothing. Record the final quartet here before continuing:

ADD_FRIEND_CATEGORY      = 4081
RENAME_FRIEND_CATEGORY   = 4082
REMOVE_FRIEND_CATEGORY   = 4083
MOVE_FRIEND_TO_CATEGORY  = 4084

All later tasks reference the constant names, so only Tasks 2 and 4 (the constant definitions) carry the raw numbers. If you changed the numbers above, use your values in Tasks 2 and 4.

  • Step 3: No commit (decision task — nothing changed on disk yet).

Task 2: Renderer — 4 outgoing composers + registration + test

Files:

  • Create: Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/AddFriendCategoryComposer.ts

  • Create: Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/RenameFriendCategoryComposer.ts

  • Create: Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/RemoveFriendCategoryComposer.ts

  • Create: Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/MoveFriendToCategoryComposer.ts

  • Modify: Nitro_Render_V3/packages/communication/src/messages/outgoing/OutgoingHeader.ts

  • Modify: Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/index.ts

  • Modify: Nitro_Render_V3/packages/communication/src/NitroMessages.ts

  • Test: Nitro_Render_V3/packages/communication/src/messages/outgoing/friendlist/__tests__/FriendCategoryComposers.test.ts

  • Step 1: Write the failing test

Create __tests__/FriendCategoryComposers.test.ts:

import { describe, expect, it } from 'vitest';
import { AddFriendCategoryComposer } from '../AddFriendCategoryComposer';
import { RenameFriendCategoryComposer } from '../RenameFriendCategoryComposer';
import { RemoveFriendCategoryComposer } from '../RemoveFriendCategoryComposer';
import { MoveFriendToCategoryComposer } from '../MoveFriendToCategoryComposer';

describe('friend category composers', () =>
{
    it('AddFriendCategoryComposer carries the name', () =>
    {
        expect(new AddFriendCategoryComposer('Best friends').getMessageArray()).toEqual([ 'Best friends' ]);
    });

    it('RenameFriendCategoryComposer carries id + name', () =>
    {
        expect(new RenameFriendCategoryComposer(5, 'Staff').getMessageArray()).toEqual([ 5, 'Staff' ]);
    });

    it('RemoveFriendCategoryComposer carries the id', () =>
    {
        expect(new RemoveFriendCategoryComposer(7).getMessageArray()).toEqual([ 7 ]);
    });

    it('MoveFriendToCategoryComposer carries friendId + categoryId', () =>
    {
        expect(new MoveFriendToCategoryComposer(42, 3).getMessageArray()).toEqual([ 42, 3 ]);
    });
});
  • Step 2: Run it to confirm it fails

Run: cd Nitro_Render_V3 && yarn test --run packages/communication/src/messages/outgoing/friendlist/__tests__/FriendCategoryComposers.test.ts Expected: FAIL — cannot find module ../AddFriendCategoryComposer (files not created yet).

  • Step 3: Create the four composers

AddFriendCategoryComposer.ts:

import { IMessageComposer } from '@nitrots/api';

export class AddFriendCategoryComposer implements IMessageComposer<ConstructorParameters<typeof AddFriendCategoryComposer>>
{
    private _data: ConstructorParameters<typeof AddFriendCategoryComposer>;

    constructor(name: string)
    {
        this._data = [ name ];
    }

    public getMessageArray()
    {
        return this._data;
    }

    public dispose(): void
    {
        return;
    }
}

RenameFriendCategoryComposer.ts:

import { IMessageComposer } from '@nitrots/api';

export class RenameFriendCategoryComposer implements IMessageComposer<ConstructorParameters<typeof RenameFriendCategoryComposer>>
{
    private _data: ConstructorParameters<typeof RenameFriendCategoryComposer>;

    constructor(categoryId: number, name: string)
    {
        this._data = [ categoryId, name ];
    }

    public getMessageArray()
    {
        return this._data;
    }

    public dispose(): void
    {
        return;
    }
}

RemoveFriendCategoryComposer.ts:

import { IMessageComposer } from '@nitrots/api';

export class RemoveFriendCategoryComposer implements IMessageComposer<ConstructorParameters<typeof RemoveFriendCategoryComposer>>
{
    private _data: ConstructorParameters<typeof RemoveFriendCategoryComposer>;

    constructor(categoryId: number)
    {
        this._data = [ categoryId ];
    }

    public getMessageArray()
    {
        return this._data;
    }

    public dispose(): void
    {
        return;
    }
}

MoveFriendToCategoryComposer.ts:

import { IMessageComposer } from '@nitrots/api';

export class MoveFriendToCategoryComposer implements IMessageComposer<ConstructorParameters<typeof MoveFriendToCategoryComposer>>
{
    private _data: ConstructorParameters<typeof MoveFriendToCategoryComposer>;

    constructor(friendId: number, categoryId: number)
    {
        this._data = [ friendId, categoryId ];
    }

    public getMessageArray()
    {
        return this._data;
    }

    public dispose(): void
    {
        return;
    }
}
  • Step 4: Run the test to confirm it passes

Run: cd Nitro_Render_V3 && yarn test --run packages/communication/src/messages/outgoing/friendlist/__tests__/FriendCategoryComposers.test.ts Expected: PASS (4 tests).

  • Step 5: Add the four header constants

In OutgoingHeader.ts, next to the other friend headers (after public static FRIEND_LIST_UPDATE = 1419;), add (use your Task 1 numbers):

public static ADD_FRIEND_CATEGORY = 4081;
public static RENAME_FRIEND_CATEGORY = 4082;
public static REMOVE_FRIEND_CATEGORY = 4083;
public static MOVE_FRIEND_TO_CATEGORY = 4084;
  • Step 6: Export the composers from the friendlist barrel

In messages/outgoing/friendlist/index.ts, add:

export * from './AddFriendCategoryComposer';
export * from './MoveFriendToCategoryComposer';
export * from './RemoveFriendCategoryComposer';
export * from './RenameFriendCategoryComposer';
  • Step 7: Register the composers in NitroMessages

First find how friendlist composers are imported in NitroMessages.ts: Run: grep -n "SendMessageComposer" Nitro_Render_V3/packages/communication/src/NitroMessages.ts This shows both the import line and the _composers.set(...) line.

Add the four classes to that same import statement (the one that imports SendMessageComposer, SetRelationshipStatusComposer, etc.). Then, next to this._composers.set(OutgoingHeader.SET_RELATIONSHIP_STATUS, SetRelationshipStatusComposer);, add:

this._composers.set(OutgoingHeader.ADD_FRIEND_CATEGORY, AddFriendCategoryComposer);
this._composers.set(OutgoingHeader.RENAME_FRIEND_CATEGORY, RenameFriendCategoryComposer);
this._composers.set(OutgoingHeader.REMOVE_FRIEND_CATEGORY, RemoveFriendCategoryComposer);
this._composers.set(OutgoingHeader.MOVE_FRIEND_TO_CATEGORY, MoveFriendToCategoryComposer);
  • Step 8: Type-check + full test run

Run: cd Nitro_Render_V3 && yarn compile:fast && yarn test --run Expected: compile clean; all tests pass (138 prior + 4 new = 142).

  • Step 9: Commit
cd Nitro_Render_V3
git add packages/communication/src/messages/outgoing/friendlist/ packages/communication/src/messages/outgoing/OutgoingHeader.ts packages/communication/src/NitroMessages.ts
git commit -m "feat(messenger): add friend-category client composers (add/rename/remove/move)"

Task 3: Emulator — category persistence helpers

Files:

  • Modify: Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/MessengerBuddy.java
  • Modify: Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java

The emulator has no unit-test infrastructure (confirmed: no src/test, no JUnit in pom.xml). Verification for Tasks 34 is a successful mvn package + the manual integration checklist in Task 11.

  • Step 1: Add setCategoryId to MessengerBuddy

MessengerBuddy already has private int categoryId = 0;, private int userOne = 0; (the owner's id), this.id (the friend's id), and getCategoryId(). Mirror the existing setRelation/run() DB idiom. Add after getCategoryId() (around line 141):

    public void setCategoryId(int categoryId) {
        this.categoryId = categoryId;

        final int cat = categoryId;
        final int owner = this.userOne;
        final int friend = this.id;

        Emulator.getThreading().run(() -> {
            try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("UPDATE messenger_friendships SET category = ? WHERE user_one_id = ? AND user_two_id = ?")) {
                statement.setInt(1, cat);
                statement.setInt(2, owner);
                statement.setInt(3, friend);
                statement.execute();
            } catch (SQLException e) {
                LOGGER.error("Caught SQL exception", e);
            }
        });
    }

(Connection, PreparedStatement, SQLException, Emulator, and LOGGER are already imported in this file.)

  • Step 2: Add renameMessengerCategory to HabboInfo

HabboInfo already has loadMessengerCategories(), addMessengerCategory(MessengerCategory) (INSERT + sets generated id), deleteMessengerCategory(MessengerCategory) (removes + DELETE via SqlQueries.update), and getMessengerCategories(). Add a rename helper after deleteMessengerCategory (around line 238), reusing the same SqlQueries.update idiom:

    public void renameMessengerCategory(int categoryId, String name) {
        for (MessengerCategory category : this.messengerCategories) {
            if (category.getId() == categoryId) {
                category.setName(name);
                break;
            }
        }

        try {
            SqlQueries.update("UPDATE messenger_categories SET name = ? WHERE id = ? AND user_id = ?", name, categoryId, this.id);
        } catch (SqlQueries.DataAccessException e) {
            LOGGER.error("Caught SQL exception", e);
        }
    }

(SqlQueries, MessengerCategory, and LOGGER are already imported/available in this file.)

  • Step 3: Compile

Run: cd Arcturus-Morningstar-Extended/Emulator && mvn -q compile Expected: BUILD SUCCESS (no compile errors).

  • Step 4: Commit
cd Arcturus-Morningstar-Extended
git add Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/MessengerBuddy.java Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java
git commit -m "feat(messenger): persist friend category assignment + category rename"

Task 4: Emulator — 4 incoming handlers + registration

Files:

  • Create: Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/AddFriendCategoryEvent.java

  • Create: .../friends/RenameFriendCategoryEvent.java

  • Create: .../friends/RemoveFriendCategoryEvent.java

  • Create: .../friends/MoveFriendToCategoryEvent.java

  • Modify: .../messages/incoming/Incoming.java

  • Modify: .../messages/PacketManager.java

  • Step 1: Add the 4 header constants to Incoming.java

Next to the other friend constants (e.g. after public static final int InviteFriendsEvent = 1276;), add (use your Task 1 numbers — must match the renderer):

    public static final int AddFriendCategoryEvent = 4081;
    public static final int RenameFriendCategoryEvent = 4082;
    public static final int RemoveFriendCategoryEvent = 4083;
    public static final int MoveFriendToCategoryEvent = 4084;
  • Step 2: Create AddFriendCategoryEvent

Caps: ≤ 20 categories/user, name 125 chars, case-insensitive de-dupe. Persists via the existing HabboInfo.addMessengerCategory (which sets the generated id), then re-pushes the category list with the existing MessengerInitComposer.

package com.eu.habbo.messages.incoming.friends;

import com.eu.habbo.habbohotel.messenger.MessengerCategory;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.friends.MessengerInitComposer;

public class AddFriendCategoryEvent extends MessageHandler {
    @Override
    public void handle() throws Exception {
        String name = this.packet.readString();
        Habbo habbo = this.client.getHabbo();

        if (habbo == null || name == null) return;

        name = name.trim();
        if (name.isEmpty() || name.length() > 25) return;
        if (habbo.getHabboInfo().getMessengerCategories().size() >= 20) return;

        for (MessengerCategory existing : habbo.getHabboInfo().getMessengerCategories()) {
            if (existing.getName().equalsIgnoreCase(name)) return;
        }

        MessengerCategory category = new MessengerCategory(name, habbo.getHabboInfo().getId(), 0);
        habbo.getHabboInfo().addMessengerCategory(category);

        this.client.sendResponse(new MessengerInitComposer(habbo));
    }
}
  • Step 3: Create RenameFriendCategoryEvent
package com.eu.habbo.messages.incoming.friends;

import com.eu.habbo.habbohotel.messenger.MessengerCategory;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.friends.MessengerInitComposer;

public class RenameFriendCategoryEvent extends MessageHandler {
    @Override
    public void handle() throws Exception {
        int categoryId = this.packet.readInt();
        String name = this.packet.readString();
        Habbo habbo = this.client.getHabbo();

        if (habbo == null || name == null) return;

        name = name.trim();
        if (name.isEmpty() || name.length() > 25) return;

        boolean found = false;
        for (MessengerCategory category : habbo.getHabboInfo().getMessengerCategories()) {
            if (category.getId() == categoryId) {
                found = true;
                break;
            }
        }
        if (!found) return;

        habbo.getHabboInfo().renameMessengerCategory(categoryId, name);

        this.client.sendResponse(new MessengerInitComposer(habbo));
    }
}
  • Step 4: Create RemoveFriendCategoryEvent

Deleting a group resets its members to category 0, pushing each via the existing UpdateFriendComposer, then re-pushes the (now shorter) category list.

package com.eu.habbo.messages.incoming.friends;

import com.eu.habbo.habbohotel.messenger.MessengerBuddy;
import com.eu.habbo.habbohotel.messenger.MessengerCategory;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.friends.MessengerInitComposer;
import com.eu.habbo.messages.outgoing.friends.UpdateFriendComposer;

public class RemoveFriendCategoryEvent extends MessageHandler {
    @Override
    public void handle() throws Exception {
        int categoryId = this.packet.readInt();
        Habbo habbo = this.client.getHabbo();

        if (habbo == null) return;

        MessengerCategory target = null;
        for (MessengerCategory category : habbo.getHabboInfo().getMessengerCategories()) {
            if (category.getId() == categoryId) {
                target = category;
                break;
            }
        }
        if (target == null) return;

        habbo.getHabboInfo().deleteMessengerCategory(target);

        for (MessengerBuddy buddy : habbo.getMessenger().getFriends().values()) {
            if (buddy.getCategoryId() == categoryId) {
                buddy.setCategoryId(0);
                this.client.sendResponse(new UpdateFriendComposer(habbo, buddy, 0));
            }
        }

        this.client.sendResponse(new MessengerInitComposer(habbo));
    }
}
  • Step 5: Create MoveFriendToCategoryEvent

categoryId == 0 means "uncategorized"; any other id must be an existing category. Persists via MessengerBuddy.setCategoryId and pushes the updated friend via UpdateFriendComposer.

package com.eu.habbo.messages.incoming.friends;

import com.eu.habbo.habbohotel.messenger.MessengerBuddy;
import com.eu.habbo.habbohotel.messenger.MessengerCategory;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.friends.UpdateFriendComposer;

public class MoveFriendToCategoryEvent extends MessageHandler {
    @Override
    public void handle() throws Exception {
        int friendId = this.packet.readInt();
        int categoryId = this.packet.readInt();
        Habbo habbo = this.client.getHabbo();

        if (habbo == null) return;

        MessengerBuddy buddy = habbo.getMessenger().getFriends().get(friendId);
        if (buddy == null) return;

        if (categoryId != 0) {
            boolean exists = false;
            for (MessengerCategory category : habbo.getHabboInfo().getMessengerCategories()) {
                if (category.getId() == categoryId) {
                    exists = true;
                    break;
                }
            }
            if (!exists) return;
        }

        buddy.setCategoryId(categoryId);
        this.client.sendResponse(new UpdateFriendComposer(habbo, buddy, 0));
    }
}
  • Step 6: Register the 4 handlers

In PacketManager.java, inside registerFriends(), add:

        this.registerHandler(Incoming.AddFriendCategoryEvent, AddFriendCategoryEvent.class);
        this.registerHandler(Incoming.RenameFriendCategoryEvent, RenameFriendCategoryEvent.class);
        this.registerHandler(Incoming.RemoveFriendCategoryEvent, RemoveFriendCategoryEvent.class);
        this.registerHandler(Incoming.MoveFriendToCategoryEvent, MoveFriendToCategoryEvent.class);

Then add the four imports near the other com.eu.habbo.messages.incoming.friends.* imports at the top of the file:

import com.eu.habbo.messages.incoming.friends.AddFriendCategoryEvent;
import com.eu.habbo.messages.incoming.friends.RenameFriendCategoryEvent;
import com.eu.habbo.messages.incoming.friends.RemoveFriendCategoryEvent;
import com.eu.habbo.messages.incoming.friends.MoveFriendToCategoryEvent;

(If PacketManager.java already uses a wildcard import com.eu.habbo.messages.incoming.friends.*;, skip the explicit imports — check with grep -n "incoming.friends" PacketManager.java first.)

  • Step 7: Build the fat jar

Run: cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests Expected: BUILD SUCCESS; jar produced under target/. A failure here most likely means a duplicate header (the registerHandler guard throws Header already registered) — return to Task 1 and pick different free IDs.

  • Step 8: Commit
cd Arcturus-Morningstar-Extended
git add Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/ Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java
git commit -m "feat(messenger): friend-category CRUD + assign packet handlers"

Task 5: Client — pure group-filter helper (TDD)

Files:

  • Create: Nitro-V3/src/api/friends/friendCategory.helpers.ts

  • Test: Nitro-V3/src/api/friends/friendCategory.helpers.test.ts

  • Modify: Nitro-V3/src/api/friends/index.ts

  • Step 1: Write the failing test

friendCategory.helpers.test.ts:

import { describe, expect, it } from 'vitest';
import { MessengerFriend } from './MessengerFriend';
import { countFriendsByCategory, filterFriendsByCategory } from './friendCategory.helpers';

const makeFriend = (id: number, categoryId: number): MessengerFriend =>
{
    const friend = new MessengerFriend();
    friend.id = id;
    friend.categoryId = categoryId;
    return friend;
};

describe('filterFriendsByCategory', () =>
{
    const friends = [ makeFriend(1, 0), makeFriend(2, 5), makeFriend(3, 5), makeFriend(4, 8) ];

    it('returns all friends when categoryId is 0 (All)', () =>
    {
        expect(filterFriendsByCategory(friends, 0)).toHaveLength(4);
    });

    it('returns only the friends in the given category', () =>
    {
        expect(filterFriendsByCategory(friends, 5).map(f => f.id)).toEqual([ 2, 3 ]);
    });

    it('returns an empty array for a category with no members', () =>
    {
        expect(filterFriendsByCategory(friends, 99)).toEqual([]);
    });

    it('is null-safe', () =>
    {
        expect(filterFriendsByCategory(null, 5)).toEqual([]);
    });
});

describe('countFriendsByCategory', () =>
{
    const friends = [ makeFriend(1, 0), makeFriend(2, 5), makeFriend(3, 5) ];

    it('counts members per category id', () =>
    {
        const counts = countFriendsByCategory(friends);
        expect(counts.get(0)).toBe(1);
        expect(counts.get(5)).toBe(2);
    });

    it('is null-safe', () =>
    {
        expect(countFriendsByCategory(null).size).toBe(0);
    });
});
  • Step 2: Run it to confirm it fails

Run: cd Nitro-V3 && yarn test --run src/api/friends/friendCategory.helpers.test.ts Expected: FAIL — cannot resolve ./friendCategory.helpers.

  • Step 3: Implement the helper

friendCategory.helpers.ts:

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;
};
  • Step 4: Run the test to confirm it passes

Run: cd Nitro-V3 && yarn test --run src/api/friends/friendCategory.helpers.test.ts Expected: PASS (6 cases).

  • Step 5: Export from the friends api barrel

In src/api/friends/index.ts, add:

export * from './friendCategory.helpers';
  • Step 6: Commit
cd Nitro-V3
git add src/api/friends/friendCategory.helpers.ts src/api/friends/friendCategory.helpers.test.ts src/api/friends/index.ts
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(friends): pure category filter + count helpers"

Task 6: Client — category CRUD + assign actions in the friends store

Files:

  • Modify: Nitro-V3/src/hooks/friends/useFriends.ts

The store re-pushes authoritative state through the server (the existing MessengerInitEvent handler updates settings.categories; FriendListUpdateEvent updates each friend's categoryId), so these actions are thin send-wrappers — no optimistic local mutation.

  • Step 1: Import the new composers

In the top import from @nitrots/nitro-renderer in useFriends.ts, add AddFriendCategoryComposer, MoveFriendToCategoryComposer, RemoveFriendCategoryComposer, RenameFriendCategoryComposer to the named-import list.

  • Step 2: Add the four actions inside useFriendsStore

Add alongside followFriend / updateRelationship (around line 42), before the return:

    const addCategory = (name: string) =>
    {
        const trimmed = (name ?? '').trim();

        if(!trimmed.length || (trimmed.length > 25)) return;

        SendMessageComposer(new AddFriendCategoryComposer(trimmed));
    };

    const renameCategory = (categoryId: number, name: string) =>
    {
        const trimmed = (name ?? '').trim();

        if(!categoryId || !trimmed.length || (trimmed.length > 25)) return;

        SendMessageComposer(new RenameFriendCategoryComposer(categoryId, trimmed));
    };

    const removeCategory = (categoryId: number) =>
    {
        if(!categoryId) return;

        SendMessageComposer(new RemoveFriendCategoryComposer(categoryId));
    };

    const moveFriendToCategory = (friendId: number, categoryId: number) =>
    {
        if(!friendId) return;

        SendMessageComposer(new MoveFriendToCategoryComposer(friendId, categoryId));
    };
  • Step 3: Expose them from the store return

In useFriendsStore's return { ... } add: addCategory, renameCategory, removeCategory, moveFriendToCategory.

  • Step 4: Expose them via useFriendsActions

In the useFriendsActions destructure-from-useBetween AND its return, add the four names so consumers can pull them:

export const useFriendsActions = () =>
{
    const {
        requestFriend,
        requestResponse,
        followFriend,
        updateRelationship,
        addCategory,
        renameCategory,
        removeCategory,
        moveFriendToCategory
    } = useBetween(useFriendsStore);

    return {
        requestFriend,
        requestResponse,
        followFriend,
        updateRelationship,
        addCategory,
        renameCategory,
        removeCategory,
        moveFriendToCategory
    };
};

(The deprecated useFriends shim returns the whole store, so it picks these up automatically.)

  • Step 5: Type-check + full test run

Run: cd Nitro-V3 && yarn typecheck && yarn test --run Expected: typecheck clean; all tests pass (no regressions).

  • Step 6: Commit
cd Nitro-V3
git add src/hooks/friends/useFriends.ts
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(friends): category CRUD + assign actions in friends store"

Task 7: Client — group chip-filter row

Files:

  • Create: Nitro-V3/src/components/friends/views/friends-list/FriendsListGroupChipsView.tsx

  • Modify: Nitro-V3/src/components/friends/views/friends-list/FriendsListView.tsx

  • Step 1: Create the chip row component

It renders an "All" chip plus one chip per category (with member count), and a gear button to open the manager (wired in Task 8). It is a controlled component: parent owns selectedCategoryId.

import { FC } from 'react';
import { FriendCategoryData } from '@nitrots/nitro-renderer';
import { countFriendsByCategory, LocalizeText, MessengerFriend } from '../../../../api';
import { Flex } from '../../../../common';

interface FriendsListGroupChipsViewProps
{
    categories: FriendCategoryData[];
    friends: MessengerFriend[];
    selectedCategoryId: number;
    setSelectedCategoryId: (id: number) => void;
    onManageClick: () => void;
}

export const FriendsListGroupChipsView: FC<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>
    );
};

(If Flex is not exported from ../../../../common, check the import other friends views use — FriendsListView.tsx already imports Flex, Button from ../../../../common; match that path.)

  • Step 2: Wire the chip row + filtering into FriendsListView

In FriendsListView.tsx:

  1. Add imports:
import { useState } from 'react';
import { filterFriendsByCategory } from '../../../../api';
import { FriendsListGroupChipsView } from './FriendsListGroupChipsView';
import { FriendsCategoryManagerView } from './FriendsCategoryManagerView';

(useState may already be imported — merge into the existing react import. FriendsCategoryManagerView is created in Task 8; this import is fine to add now and will resolve after Task 8.)

  1. Pull settings from the friends state hook used in this component (it already destructures from useFriendsState/useFriends). Ensure settings (and friends for the unfiltered counts) are in scope:
const { onlineFriends, offlineFriends, requests, settings, friends } = useFriendsState();

(Adjust to match the existing hook call in this file — keep whatever names it already destructures and add settings + friends.)

  1. Add local UI state near the other useState calls:
const [ selectedCategoryId, setSelectedCategoryId ] = useState<number>(0);
const [ showCategoryManager, setShowCategoryManager ] = useState<boolean>(false);

const categories = settings?.categories ?? [];
const filteredOnlineFriends = filterFriendsByCategory(onlineFriends, selectedCategoryId);
const filteredOfflineFriends = filterFriendsByCategory(offlineFriends, selectedCategoryId);
  1. Insert the chip row directly under <NitroCardContentView ...> (before the <NitroCardAccordionView>):
<FriendsListGroupChipsView
    categories={ categories }
    friends={ friends }
    selectedCategoryId={ selectedCategoryId }
    setSelectedCategoryId={ setSelectedCategoryId }
    onManageClick={ () => setShowCategoryManager(true) } />
  1. Replace every reference to onlineFriends / offlineFriends that feeds the rendered list and counts with the filtered versions (six edits):

    • online section header count: (${ filteredOnlineFriends.length })
    • online select_all toolbar: map/every over filteredOnlineFriends
    • online <FriendsListGroupView list={ filteredOnlineFriends } ... />
    • offline section header count: (${ filteredOfflineFriends.length })
    • offline select_all toolbar: map/every over filteredOfflineFriends
    • offline <FriendsListGroupView list={ filteredOfflineFriends } ... />

    (Leave the unfiltered friends for the chip counts and onlineFriends/offlineFriends references that are NOT about the rendered sections, if any.)

  2. Render the manager modal at the end of the returned fragment (next to the existing showRoomInvite / showRemoveFriendsConfirmation blocks):

{ showCategoryManager &&
    <FriendsCategoryManagerView categories={ categories } onCloseClick={ () => setShowCategoryManager(false) } /> }
  • Step 3: Type-check

Run: cd Nitro-V3 && yarn typecheck Expected: clean (a TS2307 for FriendsCategoryManagerView is acceptable here only until Task 8 lands; if you implement Task 8 next there should be no errors). If you want a green checkpoint now, temporarily comment the manager import + render block, then restore in Task 8.

  • Step 4: Commit
cd Nitro-V3
git add src/components/friends/views/friends-list/FriendsListGroupChipsView.tsx src/components/friends/views/friends-list/FriendsListView.tsx
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(friends): group chip-filter row over online/offline list"

Task 8: Client — category manager (create / rename / delete)

Files:

  • Create: Nitro-V3/src/components/friends/views/friends-list/FriendsCategoryManagerView.tsx

  • Step 1: Create the manager modal

A small NitroCardView with an add-input and a list of categories, each with inline rename + delete. Uses useFriendsActions.

import { FC, useState } from 'react';
import { FriendCategoryData } from '@nitrots/nitro-renderer';
import { LocalizeText } from '../../../../api';
import { Button, Column, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useFriendsActions } from '../../../../hooks';

interface FriendsCategoryManagerViewProps
{
    categories: FriendCategoryData[];
    onCloseClick: () => void;
}

export const FriendsCategoryManagerView: FC<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>('');

    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">
            <NitroCardHeaderView headerText={ LocalizeText('friendlist.friends') } onCloseClick={ onCloseClick } />
            <NitroCardContentView className="text-black" gap={ 1 }>
                <Flex gap={ 1 }>
                    <input className="form-control form-control-sm" maxLength={ 25 } placeholder={ LocalizeText('friendlist.friends') } 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('generic.create') }</Button>
                </Flex>
                <Column gap={ 1 } overflow="auto">
                    { categories.map(category => (
                        <Flex key={ category.id } alignItems="center" gap={ 1 }>
                            { (editingId === category.id) ?
                                <>
                                    <input autoFocus className="form-control form-control-sm" maxLength={ 25 } type="text" value={ editingName } onChange={ event => setEditingName(event.target.value) } onKeyDown={ event => (event.key === 'Enter') && submitRename() } />
                                    <Button onClick={ submitRename }>{ LocalizeText('generic.save') }</Button>
                                </>
                                :
                                <>
                                    <span className="flex-grow-1">{ category.name }</span>
                                    <div className="nitro-friends-spritesheet icon-edit cursor-pointer" title={ LocalizeText('generic.edit') } onClick={ () => { setEditingId(category.id); setEditingName(category.name); } } />
                                    <div className="nitro-friends-spritesheet icon-deselect cursor-pointer" title={ LocalizeText('generic.delete') } onClick={ () => removeCategory(category.id) } />
                                </> }
                        </Flex>
                    )) }
                    { !categories.length &&
                        <span className="text-muted text-center py-2">{ LocalizeText('friendlist.friends.offlinecaption') }</span> }
                </Column>
            </NitroCardContentView>
        </NitroCardView>
    );
};

Verify imports against the codebase. Column, Flex, Button, and the NitroCard* components come from ../../../../common; confirm the exact set with grep -n "from '../../../../common'" src/components/friends/views/friends-list/FriendsListView.tsx. The localization keys above (generic.create / generic.save / generic.edit / generic.delete) are placeholders for whatever your locale files already define — if a key renders raw, swap it for an existing one or add it to the locale JSON. The icon-edit / icon-deselect spritesheet classes: reuse whatever exists in FriendsView.css; if absent, use a plain text label ("✎" / "✕") instead.

  • Step 2: Type-check + test

Run: cd Nitro-V3 && yarn typecheck && yarn test --run Expected: clean; tests green. (If you commented the manager wiring in Task 7 Step 3, restore it now.)

  • Step 3: Commit
cd Nitro-V3
git add src/components/friends/views/friends-list/FriendsCategoryManagerView.tsx src/components/friends/views/friends-list/FriendsListView.tsx
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(friends): create/rename/delete group manager"

Task 9: Client — per-friend "assign to group" control

Files:

  • Modify: Nitro-V3/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx

  • Step 1: Add categories + action to the component

At the top of the component body, pull categories from state and the move action from actions:

import { useFriendsActions, useFriendsState } from '../../../../../hooks';

(Match the existing relative depth — this file is one level deeper than FriendsListView.tsx, so it's ../../../../../hooks; confirm with the file's existing imports.)

Inside the component:

const { settings } = useFriendsState();
const { moveFriendToCategory } = useFriendsActions();
const [ isGroupMenuOpen, setIsGroupMenuOpen ] = useState<boolean>(false);
const categories = settings?.categories ?? [];

(useState is likely already imported; if not, add it.)

  • Step 2: Add the assign control to the actions row

In the friends-list-actions div (currently the follow / chat / relationship icons), add — only when the friend is a real user (friend.id > 0) and at least one category exists — a group icon that toggles a small menu:

{ (friend.id > 0) && (categories.length > 0) &&
    <div className="friends-list-group-assign position-relative">
        <div className="nitro-friends-spritesheet icon-group cursor-pointer" title={ LocalizeText('friendlist.friends') } onClick={ () => setIsGroupMenuOpen(prev => !prev) } />
        { isGroupMenuOpen &&
            <div className="friends-list-group-menu">
                <div className={ `friends-list-group-menu-item${ (friend.categoryId === 0) ? ' active' : '' }` } onClick={ () => { 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={ () => { moveFriendToCategory(friend.id, category.id); setIsGroupMenuOpen(false); } }>
                        { category.name }
                    </div>
                )) }
            </div> }
    </div> }

(Reuse an existing spritesheet class for icon-group if one fits, or fall back to a text glyph. The "uncategorized" entry uses friend.categoryId === 0.)

  • Step 3: Type-check + test

Run: cd Nitro-V3 && yarn typecheck && yarn test --run Expected: clean; tests green.

  • Step 4: Commit
cd Nitro-V3
git add src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(friends): per-friend assign-to-group control"

Task 10: Client — styles for chips, manager, assign menu

Files:

  • Modify: Nitro-V3/src/css/friends/FriendsView.css

  • Step 1: Append styles

Match the existing palette in this file (grays #f3f3ef/#e6e6e6, accent blue #bfe7f6, #111 text). Append:

.friends-group-chips { border-bottom: 1px solid #e6e6e6; }
.friends-group-chips-scroll { overflow-x: auto; flex-wrap: nowrap; }
.friends-group-chips-scroll::-webkit-scrollbar { height: 4px; }
.friends-group-chip {
    flex: 0 0 auto;
    padding: 1px 8px;
    border: 1px solid #d0d0c8;
    border-radius: 10px;
    background: #f3f3ef;
    font-size: 11px;
    line-height: 16px;
    white-space: nowrap;
    cursor: pointer;
    user-select: none;
}
.friends-group-chip.active { background: #bfe7f6; border-color: #7fb9d6; }
.friends-group-chip-manage { padding: 1px 6px; }

.nitro-friends-category-manager { width: 280px; }

.friends-list-group-assign { display: inline-flex; }
.friends-list-group-menu {
    position: absolute;
    right: 0;
    top: 100%;
    z-index: 20;
    min-width: 120px;
    max-height: 180px;
    overflow-y: auto;
    background: #fff;
    border: 1px solid #c0c0b8;
    border-radius: 4px;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.friends-list-group-menu-item {
    padding: 3px 8px;
    font-size: 11px;
    cursor: pointer;
    white-space: nowrap;
}
.friends-list-group-menu-item:hover { background: #efefef; }
.friends-list-group-menu-item.active { background: #bfe7f6; }
  • Step 2: Visual sanity (build only)

Run: cd Nitro-V3 && yarn typecheck Expected: clean (CSS isn't type-checked, but confirm nothing else broke).

  • Step 3: Commit
cd Nitro-V3
git add src/css/friends/FriendsView.css
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "style(friends): group chips, category manager, assign menu"

Task 11: Integration verification (two live sessions)

Files: none (manual verification + final checks). Fix-up commits only if something fails.

  • Step 1: Apply the DB-side prerequisite check

The messenger_categories and messenger_friendships.category schema already exist (verified). Confirm against the running DB:

SELECT COUNT(*) FROM messenger_categories;
SHOW COLUMNS FROM messenger_friendships LIKE 'category';

(Per house rules, if a destructive statement is ever needed, hand the user a one-liner to run under E:\laragon\bin\mysql\... — but nothing destructive is required here.)

  • Step 2: Run the new emulator jar + client dev server

  • Emulator: run the jar built in Task 4 (java -jar Arcturus-Morningstar-Extended/Emulator/target/Habbo-*-jar-with-dependencies.jar).

  • Client: cd Nitro-V3 && yarn start (Vite picks up the renderer source live — no renderer build needed).

  • Step 3: Manual test matrix (log in, open Friends)

Verify each:

  1. Create group: open the manager (⚙), type a name, Create → a new chip appears (server re-pushed MessengerInit).
  2. Rename group: edit a category name → chip label updates after round-trip.
  3. Delete group: delete a category → chip disappears; any friend that was in it shows as uncategorized (its assign menu no longer marks that group active).
  4. Assign friend: open a friend's group menu, pick a group → switch the chip filter to that group and confirm the friend appears there; switch to "All" and confirm they still appear once.
  5. Filter: click each chip → only that group's friends show in both Online and Offline sections; counts in headers match; "All" shows everyone.
  6. Caps: creating a 21st group is rejected (Create disabled at 20); a >25-char name is truncated by maxLength/server.
  7. Persistence: relog → groups and assignments survive (DB-backed).
  8. No regressions: follow, chat, relationship icons, requests, search, room-invite, remove-friend still work.
  • Step 4: Final automated checks

Run:

cd Nitro_Render_V3 && yarn compile:fast && yarn test --run
cd Nitro-V3 && yarn typecheck && yarn test --run && yarn eslint
cd Arcturus-Morningstar-Extended/Emulator && mvn -q clean package -DskipTests

Expected: renderer tests green (142), client tests green (existing + 6 new helper cases), client typecheck + eslint clean, emulator BUILD SUCCESS.

  • Step 5: Commit any fix-ups
# only if Step 3/4 required changes
cd Nitro-V3
git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -am "fix(friends): integration fixes for friend groups"

Notes for the next phase

  • Phase 2 (offline messages) reuses the same FriendChatMessageComposer replay path — no new packets — and the messenger_offline table that already exists.
  • Phases 34 (read receipts, typing) will add custom packets following the same renderer-composer/Arcturus-handler pattern proven here, plus a new server→client event+parser (mirror NewConsoleMessageEvent/NewConsoleMessageParser) which this phase did not need.
  • Do NOT push automatically (the auto-push rule is scoped to the react19 branches only). Open PRs against the correct base per the duckietm --base dev/Dev convention when the user asks.