diff --git a/apps/fluux/src/components/BrowseRoomsModal.test.tsx b/apps/fluux/src/components/BrowseRoomsModal.test.tsx index 4beae14e..444bda96 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 5f5ea00c..c769842b 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 fa450638..b6c083d5 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 17649503..26abf18b 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 a20514c5..8c73fcc1 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 394a7a07..1da64d22 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 aafd7128..751093c5 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 7eb77a88..e62471e9 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 0181d85c..fad347ff 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 dd7afaa2..36411a3d 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 9a396b98..982690de 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 587b4f30..7a6be082 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)