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
30 changes: 23 additions & 7 deletions apps/fluux/src/components/RoomHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { InviteToRoomModal } from './InviteToRoomModal'
import { RoomConfigModal } from './RoomConfigModal'
import { RoomMembersModal } from './RoomMembersModal'
import { RoomHatsModal } from './RoomHatsModal'
import { RoomInfoModal } from './RoomInfoModal'
import { HeaderSubmenuButton } from './header/HeaderSubmenuButton'
import { HeaderOverflowKebab, type OverflowEntry } from './header/HeaderOverflowKebab'
import { buildNotifyGroup, buildManagementGroup, notifyModeOf } from './header/roomHeaderActions'
Expand Down Expand Up @@ -68,6 +69,7 @@ export function RoomHeader({
const [showAvatarModal, setShowAvatarModal] = useState(false)
const [showMembersModal, setShowMembersModal] = useState(false)
const [showHatsModal, setShowHatsModal] = useState(false)
const [showInfoModal, setShowInfoModal] = useState(false)
const [avatarError, setAvatarError] = useState<string | null>(null)
const { dragRegionProps } = useWindowDrag()
const configModalOpen = useRoomUiStore((s) => s.configModalOpen)
Expand Down Expand Up @@ -121,13 +123,20 @@ export function RoomHeader({
size="header"
/>

{/* Name and info */}
<div className="flex-1 min-w-0">
<h2 className="font-semibold text-fluux-text truncate leading-tight">{room.name}</h2>
<p className="text-xs text-fluux-muted truncate">
{room.subject ? renderTextWithLinks(room.subject) : room.jid}
</p>
</div>
{/* Name and info — opens Room Info modal; tooltip peeks the full topic */}
<Tooltip content={room.subject?.trim() || room.jid} position="bottom" className="flex-1 min-w-0">
<button
type="button"
onClick={() => setShowInfoModal(true)}
aria-label={`${t('rooms.showRoomInfo', 'Room info')}: ${room.name}`}
className="w-full min-w-0 text-start rounded-md px-1 -mx-1 py-0.5 hover:bg-fluux-hover transition-colors"
>
<span className="block font-semibold text-fluux-text truncate leading-tight">{room.name}</span>
<p className="text-xs text-fluux-muted truncate">
{room.subject?.trim() ? renderTextWithLinks(room.subject) : room.jid}
</p>
</button>
</Tooltip>

{/* Notification settings — inline copy (wide tier) */}
<div className={inlineClass('wide')}>
Expand Down Expand Up @@ -277,6 +286,13 @@ export function RoomHeader({
onClose={() => setShowHatsModal(false)}
/>
)}

{showInfoModal && (
<RoomInfoModal
room={room}
onClose={() => setShowInfoModal(false)}
/>
)}
</header>
)
}
69 changes: 69 additions & 0 deletions apps/fluux/src/components/RoomInfoModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { RoomInfoModal } from './RoomInfoModal'
import type { Room } from '@fluux/sdk'

// Surface i18n keys verbatim so assertions target keys, not translated copy.
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))

function makeRoom(overrides: Partial<Room> = {}): Room {
return {
jid: 'general@conference.example.com',
name: 'General',
subject: 'Welcome to the general room',
...overrides,
} as Room
}

// Helper to force the topic element to report overflow in jsdom (which
// otherwise reports scrollHeight === clientHeight === 0).
function forceOverflow(scrollHeight: number, clientHeight: number) {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true, get() { return scrollHeight },
})
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true, get() { return clientHeight },
})
}

afterEach(() => {
// Restore jsdom defaults so overflow overrides don't leak between tests.
delete (HTMLElement.prototype as unknown as Record<string, unknown>).scrollHeight
delete (HTMLElement.prototype as unknown as Record<string, unknown>).clientHeight
})

describe('RoomInfoModal', () => {
it('renders the room name (title), JID and full topic', () => {
render(<RoomInfoModal room={makeRoom()} onClose={() => {}} />)
expect(screen.getByText('General')).toBeTruthy()
expect(screen.getByText('general@conference.example.com')).toBeTruthy()
expect(screen.getByText('Welcome to the general room')).toBeTruthy()
expect(screen.getByText('rooms.topic')).toBeTruthy()
})

it('omits the topic section when the room has no subject', () => {
render(<RoomInfoModal room={makeRoom({ subject: undefined })} onClose={() => {}} />)
expect(screen.queryByText('rooms.topic')).toBeNull()
// Identity still renders.
expect(screen.getByText('General')).toBeTruthy()
})

it('shows no Show more toggle when the topic fits', () => {
forceOverflow(50, 50)
render(<RoomInfoModal room={makeRoom()} onClose={() => {}} />)
expect(screen.queryByText('chat.showMore')).toBeNull()
expect(screen.queryByText('chat.showLess')).toBeNull()
})

it('shows a Show more toggle when the topic overflows, and toggles to Show less', () => {
forceOverflow(300, 120)
render(<RoomInfoModal room={makeRoom({ subject: 'x'.repeat(2000) })} onClose={() => {}} />)
const moreBtn = screen.getByText('chat.showMore')
expect(moreBtn).toBeTruthy()
fireEvent.click(moreBtn)
expect(screen.getByText('chat.showLess')).toBeTruthy()
})
})
78 changes: 78 additions & 0 deletions apps/fluux/src/components/RoomInfoModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useState, useRef, useLayoutEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDown, ChevronUp } from 'lucide-react'
import type { Room } from '@fluux/sdk'
import { ModalShell } from './ModalShell'
import { RoomAvatar } from './RoomAvatar'
import { renderTextWithLinks } from '@/utils/messageStyles'

interface RoomInfoModalProps {
room: Room
onClose: () => void
}

/**
* Read-only room details for any member: avatar, name (modal title), JID, and
* the full topic/description (`room.subject`). Long topics collapse to six lines
* behind a Show more / Show less toggle. Kept independent of the message-list
* collapse machinery (which needs a messageId + width context) so it stays
* self-contained and testable.
*/
export function RoomInfoModal({ room, onClose }: RoomInfoModalProps) {
return (
<ModalShell title={room.name} onClose={onClose} width="max-w-md" panelClassName="max-h-[80vh] flex flex-col">
<div className="p-4 flex flex-col gap-4 flex-1 min-h-0 overflow-y-auto">
{/* Identity row */}
<div className="flex items-center gap-3 min-w-0">
<RoomAvatar identifier={room.jid} name={room.name} avatarUrl={room.avatar} size="xl" />
<p className="text-sm text-fluux-muted break-all select-text">{room.jid}</p>
</div>

{/* Topic — only when set */}
{room.subject?.trim() && <RoomTopic subject={room.subject} />}
</div>
</ModalShell>
)
}

function RoomTopic({ subject }: { subject: string }) {
const { t } = useTranslation()
const topicRef = useRef<HTMLDivElement>(null)
const [expanded, setExpanded] = useState(false)
const [overflowing, setOverflowing] = useState(false)

// Measure only while collapsed; when expanded the clamp is off so scrollHeight
// would equal clientHeight. Keeping `overflowing` sticky preserves the toggle.
useLayoutEffect(() => {
const el = topicRef.current
if (!el || expanded) return
setOverflowing(el.scrollHeight > el.clientHeight + 1)
}, [subject, expanded])

return (
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-fluux-muted">
{t('rooms.topic')}
</span>
<div
ref={topicRef}
className={`text-sm text-fluux-text whitespace-pre-wrap break-words ${expanded ? '' : 'line-clamp-6'}`}
>
{renderTextWithLinks(subject)}
</div>
{overflowing && (
<button
type="button"
onClick={() => setExpanded(v => !v)}
className="flex items-center gap-1 mt-1 text-sm text-fluux-muted hover:text-fluux-text transition-colors select-none self-start"
>
{expanded ? (
<><ChevronUp className="size-4" />{t('chat.showLess')}</>
) : (
<><ChevronDown className="size-4" />{t('chat.showMore')}</>
)}
</button>
)}
</div>
)
}
24 changes: 15 additions & 9 deletions apps/fluux/src/components/__snapshots__/RoomView.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,22 @@ exports[`RoomView > Snapshots > should match snapshot for non-joined room 1`] =
<div
class="flex-1 min-w-0"
>
<h2
class="font-semibold text-fluux-text truncate leading-tight"
>
Test Room
</h2>
<p
class="text-xs text-fluux-muted truncate"
<button
aria-label="rooms.showRoomInfo: Test Room"
class="w-full min-w-0 text-start rounded-md px-1 -mx-1 py-0.5 hover:bg-fluux-hover transition-colors"
type="button"
>
room@conference.example.com
</p>
<span
class="block font-semibold text-fluux-text truncate leading-tight"
>
Test Room
</span>
<p
class="text-xs text-fluux-muted truncate"
>
room@conference.example.com
</p>
</button>
</div>
<div
class="hidden @[600px]:flex"
Expand Down
Loading
Loading