Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/fluux/src/components/BrowseRoomsModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))
Expand Down
17 changes: 7 additions & 10 deletions apps/fluux/src/components/BrowseRoomsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useRoom,
WELL_KNOWN_MUC_SERVERS,
getLocalPart,
resolveDefaultMucNick,
generateConsistentColorHexSync,
} from '@fluux/sdk'
import { useChatStore } from '@fluux/sdk/react'
Expand Down Expand Up @@ -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])

Expand Down
10 changes: 6 additions & 4 deletions apps/fluux/src/components/CreateQuickChatModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -26,10 +26,12 @@ export function CreateQuickChatModal({ onClose }: CreateQuickChatModalProps) {
const inputRef = useModalInput<HTMLInputElement>()
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])
Expand Down
17 changes: 7 additions & 10 deletions apps/fluux/src/components/JoinRoomModal.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -30,16 +30,13 @@ export function JoinRoomModal({ onClose }: JoinRoomModalProps) {
const passwordRef = useRef<HTMLInputElement>(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])

Expand Down
5 changes: 3 additions & 2 deletions packages/fluux-sdk/src/core/bookmarkItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { NS_BOOKMARKS, NS_FLUUX } from './namespaces'
* A parsed XEP-0402 `<conference>` 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
* `<nick>` still rejoins under a sensible nick.
*/
export interface ParsedBookmark {
jid: string
Expand Down
53 changes: 50 additions & 3 deletions packages/fluux-sdk/src/core/modules/MUC.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 <nick> 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 <nick> 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 },
])
})
})

Expand Down
20 changes: 15 additions & 5 deletions packages/fluux-sdk/src/core/modules/MUC.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 <nick>: 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) {
Expand All @@ -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,
Expand All @@ -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 <nick>, else profile default)
// so an autojoin bookmark that omits <nick> still rejoins on reconnect.
if (autojoin && resolvedNick) {
roomsToAutojoin.push({ jid, nick: resolvedNick, password })
}
}
} catch (err) {
Expand Down
25 changes: 24 additions & 1 deletion packages/fluux-sdk/src/core/nick.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
24 changes: 24 additions & 0 deletions packages/fluux-sdk/src/core/nick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions packages/fluux-sdk/src/hooks/useEvents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand Down
6 changes: 4 additions & 2 deletions packages/fluux-sdk/src/hooks/useEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/fluux-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading