From 38bee1d2ffe18853281e40e244dcfd2c8eb88235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Fri, 10 Jul 2026 14:32:16 +0200 Subject: [PATCH] fix(muc): use profile username as default nick everywhere (#920) MUC joins now default to the profile username (XEP-0172 PEP nick), falling back to the bare-JID local part, via a single shared helper resolveDefaultMucNick. Previously two paths ignored the profile nick: accepting a room invitation joined under the JID local part, and fetchBookmarks hardcoded 'user' when a bookmark carried no . The three join modals already used the profile nick and are refactored onto the same helper so every call site agrees. An autojoin bookmark that omits now rejoins under the resolved default instead of being silently skipped. --- .../src/components/BrowseRoomsModal.test.tsx | 2 + .../fluux/src/components/BrowseRoomsModal.tsx | 17 +++--- .../src/components/CreateQuickChatModal.tsx | 10 ++-- apps/fluux/src/components/JoinRoomModal.tsx | 17 +++--- packages/fluux-sdk/src/core/bookmarkItem.ts | 5 +- .../fluux-sdk/src/core/modules/MUC.test.ts | 53 +++++++++++++++++-- packages/fluux-sdk/src/core/modules/MUC.ts | 20 +++++-- packages/fluux-sdk/src/core/nick.test.ts | 25 ++++++++- packages/fluux-sdk/src/core/nick.ts | 24 +++++++++ .../fluux-sdk/src/hooks/useEvents.test.tsx | 25 +++++++++ packages/fluux-sdk/src/hooks/useEvents.ts | 6 ++- packages/fluux-sdk/src/index.ts | 2 +- 12 files changed, 168 insertions(+), 38 deletions(-) diff --git a/apps/fluux/src/components/BrowseRoomsModal.test.tsx b/apps/fluux/src/components/BrowseRoomsModal.test.tsx index 4beae14eb..444bda966 100644 --- a/apps/fluux/src/components/BrowseRoomsModal.test.tsx +++ b/apps/fluux/src/components/BrowseRoomsModal.test.tsx @@ -43,6 +43,8 @@ vi.mock('@fluux/sdk', () => ({ }), WELL_KNOWN_MUC_SERVERS: ['conference.process-one.net', 'muc.xmpp.org'], getLocalPart: (jid: string) => jid.split('@')[0], + resolveDefaultMucNick: (nick: string | null | undefined, jid: string | null | undefined) => + (nick?.trim() || (jid ? jid.split('@')[0] : '')), generateConsistentColorHexSync: () => '#5588aa', RoomJoinError, })) diff --git a/apps/fluux/src/components/BrowseRoomsModal.tsx b/apps/fluux/src/components/BrowseRoomsModal.tsx index 5f5ea00ca..c769842bd 100644 --- a/apps/fluux/src/components/BrowseRoomsModal.tsx +++ b/apps/fluux/src/components/BrowseRoomsModal.tsx @@ -8,6 +8,7 @@ import { useRoom, WELL_KNOWN_MUC_SERVERS, getLocalPart, + resolveDefaultMucNick, generateConsistentColorHexSync, } from '@fluux/sdk' import { useChatStore } from '@fluux/sdk/react' @@ -77,17 +78,13 @@ export function BrowseRoomsModal({ onClose }: BrowseRoomsModalProps) { } }, [mucServiceJid, selectedService]) - // Default nickname from PEP nick (XEP-0172) or JID local part (only once) + // Default nickname from profile username (PEP nick), else JID local part (only once) useEffect(() => { - if (!nicknameInitialized.current) { - // Prefer PEP nickname if available, otherwise use JID local part - if (ownNickname) { - setNickname(ownNickname) - nicknameInitialized.current = true - } else if (userJid) { - setNickname(getLocalPart(userJid)) - nicknameInitialized.current = true - } + if (nicknameInitialized.current) return + const defaultNick = resolveDefaultMucNick(ownNickname, userJid) + if (defaultNick) { + setNickname(defaultNick) + nicknameInitialized.current = true } }, [ownNickname, userJid]) diff --git a/apps/fluux/src/components/CreateQuickChatModal.tsx b/apps/fluux/src/components/CreateQuickChatModal.tsx index fa450638a..b6c083d53 100644 --- a/apps/fluux/src/components/CreateQuickChatModal.tsx +++ b/apps/fluux/src/components/CreateQuickChatModal.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react' import { TextInput } from './ui/TextInput' import { useTranslation } from 'react-i18next' import { Zap } from 'lucide-react' -import { useConnection, useRoomActions, getLocalPart } from '@fluux/sdk' +import { useConnection, useRoomActions, resolveDefaultMucNick } from '@fluux/sdk' import { useChatStore } from '@fluux/sdk/react' import { useModalInput } from '@/hooks' import { ModalShell } from './ModalShell' @@ -26,10 +26,12 @@ export function CreateQuickChatModal({ onClose }: CreateQuickChatModalProps) { const inputRef = useModalInput() const nicknameInitialized = useRef(false) - // Default nickname from PEP nickname or JID (only once) + // Default nickname from profile username (PEP nick), else JID local part (only once) useEffect(() => { - if (!nicknameInitialized.current && (ownNickname || userJid)) { - setNickname(ownNickname || (userJid ? getLocalPart(userJid) : '')) + if (nicknameInitialized.current) return + const defaultNick = resolveDefaultMucNick(ownNickname, userJid) + if (defaultNick) { + setNickname(defaultNick) nicknameInitialized.current = true } }, [ownNickname, userJid]) diff --git a/apps/fluux/src/components/JoinRoomModal.tsx b/apps/fluux/src/components/JoinRoomModal.tsx index 17649503c..26abf18b5 100644 --- a/apps/fluux/src/components/JoinRoomModal.tsx +++ b/apps/fluux/src/components/JoinRoomModal.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect } from 'react' import { TextInput } from './ui/TextInput' import { useTranslation } from 'react-i18next' -import { useConnection, useRoomActions, RoomJoinError, getLocalPart } from '@fluux/sdk' +import { useConnection, useRoomActions, RoomJoinError, resolveDefaultMucNick } from '@fluux/sdk' import { useChatStore } from '@fluux/sdk/react' import { useModalInput } from '@/hooks' import { useRoomJoinWarning } from '@/hooks/useRoomJoinWarning' @@ -30,16 +30,13 @@ export function JoinRoomModal({ onClose }: JoinRoomModalProps) { const passwordRef = useRef(null) const nicknameInitialized = useRef(false) - // Default nickname from PEP nickname or user JID (only once) + // Default nickname from profile username (PEP nick), else JID local part (only once) useEffect(() => { - if (!nicknameInitialized.current) { - if (ownNickname) { - setNickname(ownNickname) - nicknameInitialized.current = true - } else if (userJid) { - setNickname(getLocalPart(userJid)) - nicknameInitialized.current = true - } + if (nicknameInitialized.current) return + const defaultNick = resolveDefaultMucNick(ownNickname, userJid) + if (defaultNick) { + setNickname(defaultNick) + nicknameInitialized.current = true } }, [ownNickname, userJid]) diff --git a/packages/fluux-sdk/src/core/bookmarkItem.ts b/packages/fluux-sdk/src/core/bookmarkItem.ts index a20514c55..8c73fcc11 100644 --- a/packages/fluux-sdk/src/core/bookmarkItem.ts +++ b/packages/fluux-sdk/src/core/bookmarkItem.ts @@ -6,8 +6,9 @@ import { NS_BOOKMARKS, NS_FLUUX } from './namespaces' * A parsed XEP-0402 `` bookmark item. * * `nick` is left raw (possibly `undefined`) so callers can apply their own - * default — `fetchBookmarks` only auto-joins when a nick is present, while the - * live-notification path substitutes `'user'`. + * default — `fetchBookmarks` resolves it via `resolveDefaultMucNick` (profile + * username, then bare-JID local part) so an autojoin bookmark that omits + * `` still rejoins under a sensible nick. */ export interface ParsedBookmark { jid: string diff --git a/packages/fluux-sdk/src/core/modules/MUC.test.ts b/packages/fluux-sdk/src/core/modules/MUC.test.ts index 394a7a076..1da64d223 100644 --- a/packages/fluux-sdk/src/core/modules/MUC.test.ts +++ b/packages/fluux-sdk/src/core/modules/MUC.test.ts @@ -406,7 +406,7 @@ describe('MUC Module', () => { }) }) - it('does not add room to autojoin list when nick is missing', async () => { + it('autojoins a nickless bookmark under the resolved default nick', async () => { const response = createMockElement('iq', { type: 'result' }, [ { name: 'pubsub', @@ -440,9 +440,56 @@ describe('MUC Module', () => { const result = await muc.fetchBookmarks() - // Room should be in allRoomJids but NOT in roomsToAutojoin (can't join without nick) + // With no explicit and no profile nick / JID in the mock store, + // the resolved default falls back to 'user' — but the room still autojoins + // so an autojoin bookmark that omits rejoins on reconnect. expect(result.allRoomJids).toContain('nonick@conference.example.org') - expect(result.roomsToAutojoin).toHaveLength(0) + expect(result.roomsToAutojoin).toEqual([ + { jid: 'nonick@conference.example.org', nick: 'user', password: undefined }, + ]) + }) + + it('resolves the default nick from the profile username (XEP-0172)', async () => { + mockStores.connection.getOwnNickname.mockReturnValue('Alice') + const response = createMockElement('iq', { type: 'result' }, [ + { + name: 'pubsub', + attrs: { xmlns: 'http://jabber.org/protocol/pubsub' }, + children: [ + { + name: 'items', + attrs: { node: 'urn:xmpp:bookmarks:1' }, + children: [ + { + name: 'item', + attrs: { id: 'nonick@conference.example.org' }, + children: [ + { + name: 'conference', + attrs: { + xmlns: 'urn:xmpp:bookmarks:1', + name: 'No Nick Room', + autojoin: 'true', + }, + }, + ], + }, + ], + }, + ], + }, + ]) + + mockSendIQ.mockResolvedValue(response) + + const result = await muc.fetchBookmarks() + + expect(mockEmitSDK).toHaveBeenCalledWith('room:added', { + room: expect.objectContaining({ nickname: 'Alice' }), + }) + expect(result.roomsToAutojoin).toEqual([ + { jid: 'nonick@conference.example.org', nick: 'Alice', password: undefined }, + ]) }) }) diff --git a/packages/fluux-sdk/src/core/modules/MUC.ts b/packages/fluux-sdk/src/core/modules/MUC.ts index aafd71281..751093c5f 100644 --- a/packages/fluux-sdk/src/core/modules/MUC.ts +++ b/packages/fluux-sdk/src/core/modules/MUC.ts @@ -1,7 +1,7 @@ import { xml, Element } from '@xmpp/client' import { BaseModule } from './BaseModule' import { getBareJid, getLocalPart, getResource, getDomain } from '../jid' -import { stripNickWhitespace } from '../nick' +import { stripNickWhitespace, resolveDefaultMucNick } from '../nick' import { generateUUID } from '../../utils/uuid' import { generateQuickChatSlug } from '../wordlist' import { hasStableOccupantIdentity, isNonAnonymousRoom, isPrivateRoom } from '../roomCapabilities' @@ -1737,12 +1737,20 @@ export class MUC extends BaseModule { const items = response.getChild('pubsub', NS_PUBSUB)?.getChild('items') if (!items) return { roomsToAutojoin, allRoomJids } + // Fallback nick for bookmarks that carry no explicit : prefer the + // profile username (XEP-0172), then the bare-JID local part. Resolved once + // — it's the same for every bookmark in this fetch. + const ownNickname = this.deps.stores?.connection.getOwnNickname?.() + const ownJid = this.deps.stores?.connection.getJid?.() + const defaultNick = resolveDefaultMucNick(ownNickname, ownJid) || 'user' + for (const item of items.getChildren('item')) { const parsed = parseBookmarkItem(item) if (!parsed) continue const { jid, name, autojoin, nick, password, notifyAll } = parsed allRoomJids.push(jid) + const resolvedNick = nick || defaultNick const existingRoom = this.deps.stores?.room.getRoom(jid) if (existingRoom) { @@ -1751,13 +1759,13 @@ export class MUC extends BaseModule { // that races with fetchBookmarks during fresh session reconnect. this.deps.emitSDK('room:bookmark', { roomJid: jid, - bookmark: { name, nick: nick || 'user', autojoin, password, notifyAll }, + bookmark: { name, nick: resolvedNick, autojoin, password, notifyAll }, }) } else { const room: Room = { jid, name, - nickname: nick || 'user', + nickname: resolvedNick, joined: false, isBookmarked: true, autojoin, @@ -1774,8 +1782,10 @@ export class MUC extends BaseModule { this.deps.emitSDK('room:added', { room }) } - if (autojoin && nick) { - roomsToAutojoin.push({ jid, nick, password }) + // Autojoin with the resolved nick (explicit , else profile default) + // so an autojoin bookmark that omits still rejoins on reconnect. + if (autojoin && resolvedNick) { + roomsToAutojoin.push({ jid, nick: resolvedNick, password }) } } } catch (err) { diff --git a/packages/fluux-sdk/src/core/nick.test.ts b/packages/fluux-sdk/src/core/nick.test.ts index 7eb77a881..e62471e94 100644 --- a/packages/fluux-sdk/src/core/nick.test.ts +++ b/packages/fluux-sdk/src/core/nick.test.ts @@ -1,5 +1,28 @@ import { describe, it, expect } from 'vitest' -import { stripNickWhitespace, splitNickForDisplay } from './nick' +import { stripNickWhitespace, splitNickForDisplay, resolveDefaultMucNick } from './nick' + +describe('resolveDefaultMucNick', () => { + it('prefers the profile username (XEP-0172 nick)', () => { + expect(resolveDefaultMucNick('Alice', 'bob@example.com')).toBe('Alice') + }) + + it('falls back to the bare-JID local part when no profile nick', () => { + expect(resolveDefaultMucNick(null, 'bob@example.com/resource')).toBe('bob') + }) + + it('hardens the profile nick against edge/invisible characters', () => { + expect(resolveDefaultMucNick(' Alice ', 'bob@example.com')).toBe('Alice') + }) + + it('falls back to the JID when the profile nick is all whitespace', () => { + expect(resolveDefaultMucNick(' ', 'bob@example.com')).toBe('bob') + }) + + it('returns empty string when neither input is usable', () => { + expect(resolveDefaultMucNick(null, null)).toBe('') + expect(resolveDefaultMucNick(undefined, undefined)).toBe('') + }) +}) describe('stripNickWhitespace', () => { it('leaves a clean nick unchanged', () => { diff --git a/packages/fluux-sdk/src/core/nick.ts b/packages/fluux-sdk/src/core/nick.ts index 0181d85c1..fad347ffa 100644 --- a/packages/fluux-sdk/src/core/nick.ts +++ b/packages/fluux-sdk/src/core/nick.ts @@ -15,6 +15,8 @@ * whisper addressing, which is keyed on the exact nick). */ +import { getLocalPart } from './jid' + // Invisible / zero-width / bidi-control / soft-hyphen characters that have no // legitimate place in a nick. Single source of truth shared by strip + reveal. // (JS \s already covers NBSP, U+2000–200A, U+3000, U+FEFF, etc., so those are @@ -37,6 +39,28 @@ export function stripNickWhitespace(nick: string): string { return nick.replace(INVISIBLE_STRIP, '').replace(EDGE_WHITESPACE, '') } +/** + * Resolve the default MUC nickname for the local user. + * + * Single source of truth for "what nick do we join a room under when the user + * hasn't typed one." Prefers the profile username (XEP-0172 PEP nickname), then + * falls back to the bare-JID local part. The result is whitespace/invisible-char + * hardened via {@link stripNickWhitespace} so it is safe to put on the wire. + * + * @param ownNickname - The user's XEP-0172 nickname (from the connection store), or null. + * @param jid - The user's own JID (bare or full); used for the local-part fallback. + * @returns A non-empty nick when either input is usable, otherwise `''`. + */ +export function resolveDefaultMucNick( + ownNickname: string | null | undefined, + jid: string | null | undefined +): string { + const fromNick = ownNickname ? stripNickWhitespace(ownNickname) : '' + if (fromNick) return fromNick + const fromJid = jid ? stripNickWhitespace(getLocalPart(jid)) : '' + return fromJid +} + export interface NickDisplay { /** Leading edge-whitespace run (may be ''). */ leading: string diff --git a/packages/fluux-sdk/src/hooks/useEvents.test.tsx b/packages/fluux-sdk/src/hooks/useEvents.test.tsx index dd7afaa22..36411a3d2 100644 --- a/packages/fluux-sdk/src/hooks/useEvents.test.tsx +++ b/packages/fluux-sdk/src/hooks/useEvents.test.tsx @@ -207,6 +207,31 @@ describe('useEvents hook', () => { expect(result.current.mucInvitations).toHaveLength(0) }) + it('should prefer the profile username (XEP-0172 nick) over the JID local part', async () => { + const { result } = renderHook(() => useEvents(), { wrapper }) + + mockClient.muc.joinRoom.mockResolvedValue(undefined) + + act(() => { + connectionStore.getState().setJid('myuser@example.com/resource') + connectionStore.getState().setOwnNickname('Alice') + eventsStore.getState().addMucInvitation( + 'room@conference.example.com', + 'alice@example.com' + ) + }) + + await act(async () => { + await result.current.acceptInvitation('room@conference.example.com') + }) + + expect(mockClient.muc.joinRoom).toHaveBeenCalledWith( + 'room@conference.example.com', + 'Alice', // profile username, not the JID local part + { password: undefined, isQuickChat: false } + ) + }) + it('should join room with password from invitation', async () => { const { result } = renderHook(() => useEvents(), { wrapper }) diff --git a/packages/fluux-sdk/src/hooks/useEvents.ts b/packages/fluux-sdk/src/hooks/useEvents.ts index 9a396b98f..982690de4 100644 --- a/packages/fluux-sdk/src/hooks/useEvents.ts +++ b/packages/fluux-sdk/src/hooks/useEvents.ts @@ -3,6 +3,7 @@ import { connectionStore, chatStore } from '../stores' import { useEventsStore } from '../react/storeHooks' import { useXMPPContext } from '../provider' import { getLocalPart } from '../core/jid' +import { resolveDefaultMucNick } from '../core/nick' import type { Conversation } from '../core' /** @@ -176,8 +177,9 @@ export function useEvents() { async (roomJid: string, password?: string) => { // Find the invitation to get the password and isQuickChat flag const invitation = mucInvitations.find((i) => i.roomJid === roomJid) - const currentJid = connectionStore.getState().jid - const defaultNick = getLocalPart(currentJid ?? 'user') + const { jid: currentJid, ownNickname } = connectionStore.getState() + // Prefer the profile username (XEP-0172 nick) over the bare-JID local part. + const defaultNick = resolveDefaultMucNick(ownNickname, currentJid) || 'user' const roomPassword = invitation?.password || password // Join the room with isQuickChat flag from invitation diff --git a/packages/fluux-sdk/src/index.ts b/packages/fluux-sdk/src/index.ts index 587b4f304..7a6be082f 100644 --- a/packages/fluux-sdk/src/index.ts +++ b/packages/fluux-sdk/src/index.ts @@ -465,7 +465,7 @@ export { export type { ParsedJid, JidValidation } from './core/jid' // MUC nickname hygiene / display (impersonation hardening) -export { stripNickWhitespace, splitNickForDisplay } from './core/nick' +export { stripNickWhitespace, splitNickForDisplay, resolveDefaultMucNick } from './core/nick' export type { NickDisplay } from './core/nick' // Service discovery utilities (XEP-0030 / XEP-0163)