Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
1ea37d0
feat: add useAlive composable
jvxz Apr 30, 2026
ccce857
fix: prevent pagination spam in useEventPagination when using samew i…
jvxz Apr 30, 2026
2941f06
refactor: make isFullyLoaded flag in useRoomEvents reactive to room
jvxz Apr 30, 2026
5910e65
feat: use useAlive inside layout app slot to prevent persistent renders
jvxz Apr 30, 2026
d34de41
refactor: move page logic to space route + add comment in room page
jvxz Apr 30, 2026
bddb244
fix: run handleOnMounted in event list component on room change
jvxz Apr 30, 2026
5c91b8d
fix: fix type error
jvxz Apr 30, 2026
1fa1d3d
fix: remove redundant v-show
jvxz Apr 30, 2026
e6188f5
refactor: remove redundant guard
jvxz Apr 30, 2026
18f17c1
fix: restore removed defer
jvxz Apr 30, 2026
07f981c
fix: wrap event list + input inside a v-for template block to prevent…
jvxz Apr 30, 2026
dcc32be
fix: consolidate mount logic + use it for room update
jvxz Apr 30, 2026
5b30bb4
fix: retain reactivity for roomEventsFullyLoadedSet by wrapping it in…
jvxz Apr 30, 2026
91ff27f
fix: fix type errors
jvxz Apr 30, 2026
cb4ba1a
fix: fix potential concurrency bug in event list
jvxz Apr 30, 2026
86fa62b
fix: fix bug with stale sentinel state when swapping rooms
jvxz Apr 30, 2026
e5b8f3d
fix: fix invalid room id acquire before mutex acquire in useEventPagi…
jvxz Apr 30, 2026
4c7c7df
feat: add environment-specific plugin that displays page load time
jvxz Apr 30, 2026
70b55ed
fix: fix load time indicator
jvxz Apr 30, 2026
5d05c39
refactor: allow usage for MaybeRoomOrId type in useRoomEventHooks
jvxz Apr 30, 2026
6adf612
refactor: edit Img component
jvxz May 1, 2026
4663890
chore(deps): update deps
jvxz May 1, 2026
84d31b9
chore(deps): add virtua
jvxz May 1, 2026
9c99fe0
refactor: extract resize handle styles to shortcut
jvxz May 1, 2026
2d8756c
feat: add onMemberUpdate hook to useRoomEventHooks
jvxz May 1, 2026
9743a4d
feat: add useRoomMembers composable
jvxz May 1, 2026
25c620f
feat: add members list components
jvxz May 1, 2026
57c6530
feat: add members list to space page
jvxz May 1, 2026
c6e009e
chore(scripts): update test:typecheck script
jvxz May 1, 2026
6678542
fix(deps): downgrade to nuxt 4.4.2 to fix type errors
jvxz May 1, 2026
e16b58c
fix: prevent pagination spam in useEventPagination when using samew i…
jvxz Apr 30, 2026
16c0791
feat: use useAlive inside layout app slot to prevent persistent renders
jvxz Apr 30, 2026
1b96fb2
refactor: move page logic to space route + add comment in room page
jvxz Apr 30, 2026
b94cfdd
fix: run handleOnMounted in event list component on room change
jvxz Apr 30, 2026
64d8269
fix: fix type error
jvxz Apr 30, 2026
211bf43
fix: remove redundant v-show
jvxz Apr 30, 2026
f363ced
refactor: remove redundant guard
jvxz Apr 30, 2026
434b005
chore(lint): apply lint fixes
autofix-ci[bot] Apr 30, 2026
48daa91
fix: fix invalid room id acquire before mutex acquire in useEventPagi…
jvxz Apr 30, 2026
a933220
chore(deps): update deps
jvxz May 1, 2026
a76d25f
fix(deps): downgrade to nuxt 4.4.2 to fix type errors
jvxz May 1, 2026
35ac489
fix: fix membership parsing bug
jvxz May 1, 2026
f29c7ab
refactor: edit skeleton component
jvxz May 1, 2026
a7a6e84
fix: prevent lingering members list elements by awaiting next tick
jvxz May 1, 2026
afa424b
feat: add useCurrentRoomCreator composable
jvxz May 1, 2026
4200b01
chore: add key to spaceId route group to keep component instances alive
jvxz May 1, 2026
3321972
feat: enhance members list components
jvxz May 1, 2026
feebe2b
fix: use members list header title as key
jvxz May 1, 2026
4394ed8
fix: remove unused prop
jvxz May 1, 2026
f185211
feat: allow member power level mutations to trigger list refresh
jvxz May 1, 2026
2a2a038
fix: fix rebase error
jvxz May 1, 2026
cf51450
fix: correct early return in useRoomMembers
jvxz May 1, 2026
9694a09
feat: add resolveRoomId util
jvxz May 2, 2026
7888bdc
fix: prevent pagination spam in useEventPagination when using samew i…
jvxz Apr 30, 2026
2e25d8f
fix: remove duplicate declaration in useEventPagination
jvxz Apr 30, 2026
34270ae
fix: run handleOnMounted in event list component on room change
jvxz Apr 30, 2026
3bf300d
fix: fix type error
jvxz Apr 30, 2026
fb84df1
refactor: remove redundant guard
jvxz Apr 30, 2026
f30205a
fix: remove duplicate watch on roomId in event list
jvxz Apr 30, 2026
676afeb
chore(lint): apply lint fixes
autofix-ci[bot] Apr 30, 2026
6654353
fix: fix invalid room id acquire before mutex acquire in useEventPagi…
jvxz Apr 30, 2026
0c11265
chore(deps): update deps
jvxz May 1, 2026
557ea1b
fix(deps): downgrade to nuxt 4.4.2 to fix type errors
jvxz May 1, 2026
1aac754
feat: improve useRoomMember composable
jvxz May 3, 2026
c8c4df5
feat: add onMembers to useRoomEventHooks
jvxz May 3, 2026
829b28c
feat: add useRoomMembership composable
jvxz May 3, 2026
894171d
chore: update lock file
jvxz May 4, 2026
bd86be6
feat: improve useRoomMember composable
jvxz May 4, 2026
c0e26b7
feat: allow frozen references in useProfilePopover
jvxz May 4, 2026
1cfc4b5
fix: add revalidation on room/user change in useRoomMembership
jvxz May 4, 2026
e2455f3
refactor: allow undefined power level to be provided in getPowerLevel…
jvxz May 4, 2026
d6f3f0f
feat: add floating-ui types from source code
jvxz May 4, 2026
516ea25
feat: improve + separate logic of room member grouping + fetching
jvxz May 4, 2026
8ff964e
refactor: edit profile popover components
jvxz May 4, 2026
e5aec62
feat: add useRoomMemberPowerLevel composable
jvxz May 4, 2026
bfd0a6b
refactor: remove unneded watcher in event list
jvxz May 4, 2026
0f73a33
feat: enhance members list components
jvxz May 4, 2026
2b88cee
chore: remove unused composable
jvxz May 4, 2026
ce1ee4a
feat: add shimmer option to skeleton component
jvxz May 4, 2026
be9f6ea
feat: enhance room members components
jvxz May 4, 2026
7c55251
feat: enhance members list component
jvxz May 4, 2026
2d30c16
Merge remote-tracking branch 'origin/main' into feat/room-member-sidebar
jvxz May 4, 2026
2e6f3d5
chore(lint): apply lint fixes
autofix-ci[bot] May 4, 2026
77ec296
fix: fix invalid cache access
jvxz May 4, 2026
e8b2321
test: fix e2e tests by adding missing fields to room mocks
jvxz May 4, 2026
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
51 changes: 51 additions & 0 deletions app/components/page/room/members-list.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<script lang="ts" setup>
import { VList } from 'virtua/vue'

const currentRoom = useCurrentRoom()

const { isLoaded, members } = useRoomMembers(currentRoom)
const membersGrouped = useRoomMemberGrouping(members, () => currentRoom.value?.roomId)

const listRef = useTemplateRef('list')
watch(() => currentRoom.value?.roomId, () => listRef.value?.scrollTo(0))
</script>

<template>
<div class="border-l border-border shrink-0 h-full w-72">
<VList
v-if="membersGrouped && isLoaded"
:key="currentRoom?.roomId"
v-slot="{ item }"
ref="list"
:item-size="40"
:data="membersGrouped.members"
class="px-2 py-1"
@vue:mounted="console.log"
>
<PageRoomMembersListHeader
v-if="'type' in item && item.type === 'header'"
:key="item.title"
:title="item.title"
:total="membersGrouped.groupTotals[item.title]"
/>

<PageRoomMembersListCard
v-else
:key="item.userId"
:is-owner="item.powerLevel >= 100"
:user-id="item.userId"
/>
</VList>

<div v-else class="p-2 h-full relative">
<!-- <div class="size-full inset-0 absolute z-1 from-transparent to-card to-80% bg-gradient-to-b" /> -->

<!-- <div class="h-10" /> -->
<USkeleton
v-for="item in 8"
:key="item"
class="mb-3 h-10 w-full"
/>
</div>
</div>
</template>
58 changes: 58 additions & 0 deletions app/components/page/room/members-list/card.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<script lang="ts" setup>
const props = defineProps<{
userId: string
}>()
Comment thread
jvxz marked this conversation as resolved.

const profile = useUserProfile(props.userId)
const creator = useCurrentRoomCreator()

const triggerRef = useTemplateRef('trigger')
onMounted(() => {
const btn = unrefElement(triggerRef)
if (btn) {
// workaround to prevent virtual list from scrolling when closing popover
const nativeFocus = btn.focus.bind(btn)
btn.focus = (options?: FocusOptions) => nativeFocus({ ...options, preventScroll: true })
}
})
</script>

<template>
<UProfilePopoverTrigger
freeze-reference
:user="userId"
:content-props="{
side: 'left',
align: 'start',
collisionPadding: 12,
sideOffset: 22,
disableUpdateOnLayoutShift: true,
}"
as-child
>
<UButton
ref="trigger"
class="text-foreground font-normal gap-2 h-10 w-full justify-start data-[popover-open]:bg-muted/75"
variant="ghost"
>
<MatrixAvatar :user="userId" class="shrink-0 size-6" />

<p class="shrink-0 truncate">
{{ profile?.displayname }}
</p>

<UTooltipRoot>
<UTooltipTrigger as-child>
<Icon
v-if="userId === creator"
name="tabler:crown"
class="text-primary"
/>
</UTooltipTrigger>
<UTooltipContent>
Owner
</UTooltipContent>
</UTooltipRoot>
</UButton>
</UProfilePopoverTrigger>
</template>
12 changes: 12 additions & 0 deletions app/components/page/room/members-list/header.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
title: PowerLevelName
total: number
}>()
</script>

<template>
<div class="text-xs text-muted-foreground font-medium px-2 pb-0.5 flex h-8 w-full items-end">
<p>{{ upperFirst(title) }} — {{ total }}</p>
</div>
</template>
23 changes: 14 additions & 9 deletions app/components/u/profile-popover/content.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
<script lang="ts" setup>
const { anchorElement, contentProps, open, user } = useProfilePopover()
import { KnownMembership } from 'matrix-js-sdk'

const { contentProps, open, referenceElement, user } = useProfilePopover()

const profile = useUserProfile(() => user.value?.userId)
const { self } = useSelf()

const currentRoom = useCurrentRoom()
const roomId = computed(() => currentRoom.value?.roomId)
const userId = computed(() => user.value?.userId)

const displayName = computed(() => profile.value?.displayname ?? getDisplayNameFallback(user.value?.userId))
const avatarUrl = computed(() => resolveAvatarUrl(user.value?.avatarUrl, { size: 'small' }))
const parsedUserId = computed(() => parseUserId(user.value?.userId))
const roomMember = useRoomMember(roomId, userId)
const displayName = computed(() => roomMember.value ? resolveUserName(roomMember.value) : getDisplayNameFallback(user.value?.userId))
const avatarUrl = computed(() => resolveAvatarUrl(roomMember.value?.getMxcAvatarUrl(), { size: 'small' }))
const parsedUserId = computed(() => parseUserId(roomMember.value?.userId))

const powerLevel = useUserRoomPowerLevel(() => currentRoom.value?.roomId, () => user.value?.userId)
const powerLevelName = computed(() => upperFirst(isNull(powerLevel.value) ? 'member' : getPowerLevelName(powerLevel.value)))
const membership = useRoomMembership(roomId, userId)
const powerLevel = useRoomMemberPowerLevel(roomId, userId)
const powerLevelName = computed(() => upperFirst(getPowerLevelName(powerLevel.value)))

const isSelf = computed(() => self.value?.userId === user.value?.userId)

Expand All @@ -24,7 +29,7 @@ const { copy } = useClipboard()
v-bind="contentProps"
as-child
disable-outside-pointer-events
:reference="anchorElement ?? undefined"
:reference="referenceElement ?? undefined"
:class="cn('z-1', $attrs.class)"
>
<UCard class="p-0 border-none bg-card-light gap-0 w-74 transition-transform duration-100 relative overflow-clip animate-in animate-ease-out data-[state=open]:slide-in-from-r-3">
Expand Down Expand Up @@ -88,7 +93,7 @@ const { copy } = useClipboard()

<UProfilePopoverMutualRooms v-if="!isSelf" />

<div class="flex flex-wrap gap-1 *:text-xs *:font-normal *:rounded-full *:max-w-28 *:block *:truncate">
<div v-if="isDefined(membership) && membership === KnownMembership.Join" class="flex flex-wrap gap-1 *:text-xs *:font-normal *:rounded-full *:max-w-28 *:block *:truncate">
<UBadge class="" variant="outline">
{{ powerLevelName }}
</UBadge>
Expand Down
11 changes: 7 additions & 4 deletions app/components/u/profile-popover/trigger.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
<script lang="ts" setup>
import type { PopoverContentProps, PopoverTriggerProps } from 'reka-ui'

const props = defineProps<PopoverTriggerProps & { user: MaybeUserOrId, contentProps?: PopoverContentProps }>()
const { anchorElement, openProfilePopover } = useProfilePopover()
const props = defineProps<PopoverTriggerProps & {
user: MaybeUserOrId
contentProps?: PopoverContentProps
freezeReference?: boolean
}>()
const { openProfilePopover } = useProfilePopover()

function handleOpen(e: Event) {
const currentTarget = e.currentTarget
assert(currentTarget instanceof HTMLElement, '`currentTarget` was not an instance of an HTML element when handling open on profile popover trigger')

anchorElement.value = currentTarget
openProfilePopover(currentTarget, resolveUserId(props.user), props.contentProps)
openProfilePopover(currentTarget, resolveUserId(props.user), props.contentProps, { freezeReference: props.freezeReference })
}
</script>

Expand Down
9 changes: 8 additions & 1 deletion app/components/u/skeleton.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
<script lang="ts" setup>
const { delay = 1400 } = defineProps<{ delay?: number }>()
withDefaults(
defineProps<{ delay?: number, shimmer?: boolean }>(),
{
delay: 1400,
shimmer: true,
},
)
</script>

<template>
Expand All @@ -9,6 +15,7 @@ const { delay = 1400 } = defineProps<{ delay?: number }>()
aria-busy
>
<div
v-if="shimmer"
class="bg-gradient-linear size-full ease from-transparent to-transparent via-foreground/3 bg-gradient-to-r/oklch"
:style="{
animation: `skeleton ${delay}ms infinite`,
Expand Down
5 changes: 5 additions & 0 deletions app/composables/use-current-room-creator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function useCurrentRoomCreator() {
const currentRoom = useCurrentRoom()

return computed(() => currentRoom.value?.getCreator())
}
29 changes: 22 additions & 7 deletions app/composables/use-profile-popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@ import type { PopoverContentProps } from 'reka-ui'

export const useProfilePopover = createSharedComposable(() => {
const open = shallowRef(false)
const anchorElement = shallowRef<MaybeElement>()
const referenceElement = shallowRef<MaybeElement | VirtualElement>()
const userIdRef = shallowRef<string>()
const contentProps = shallowRef<PopoverContentProps>()

let currentRoot: MaybeElement
whenever(() => !open.value, () => {
currentRoot?.removeAttribute('data-popover-open')
currentRoot = undefined
referenceElement.value = undefined
contentProps.value = undefined
userIdRef.value = undefined
})

function openProfilePopover(trigger: MaybeElement, userId: string, nextContentProps?: PopoverContentProps) {
if (!trigger)
return

function openProfilePopover(
trigger: HTMLElement,
userId: string,
nextContentProps?: PopoverContentProps,
options: { freezeReference?: boolean } = {},
) {
const eventRoot = trigger.closest('[data-event-id]') as HTMLElement | null
const root = eventRoot ?? trigger

Expand All @@ -25,7 +30,8 @@ export const useProfilePopover = createSharedComposable(() => {
root.setAttribute('data-popover-open', '')
currentRoot = root

anchorElement.value = trigger
referenceElement.value = options.freezeReference ? createFrozenReference(trigger) : trigger

contentProps.value = nextContentProps
userIdRef.value = userId
open.value = true
Expand All @@ -34,10 +40,19 @@ export const useProfilePopover = createSharedComposable(() => {
const user = useUser(userIdRef)

return {
anchorElement,
contentProps,
open,
openProfilePopover,
referenceElement,
user,
}
})

function createFrozenReference(trigger: HTMLElement): VirtualElement {
const rect = trigger.getBoundingClientRect()

return {
contextElement: trigger,
getBoundingClientRect: () => rect,
}
}
2 changes: 2 additions & 0 deletions app/composables/use-room-event-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type Params = Partial<{
onTimelineReset: (room: Room | undefined, eventTimelineSet: EventTimelineSet, resetAllTimelines: boolean) => void
onCurrentStateUpdated: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void
onAccountData: (event: MatrixEvent, room: Room, prevEvent?: MatrixEvent | undefined) => void
onMemberUpdate: (event: MatrixEvent, state: RoomState, member: RoomMember) => void
onRoomMemberTyping: (event: MatrixEvent, member: RoomMember) => void
onMembers: (event: MatrixEvent, state: RoomState, member: RoomMember) => void
}>
Expand All @@ -28,6 +29,7 @@ export function useRoomEventHooks(roomInput: MaybeRefOrGetter<MaybeRoomOrId | un
bindListener(RoomEvent.TimelineRefresh, params?.onTimelineRefresh, disposers, room.value)
bindListener(RoomEvent.TimelineReset, params?.onTimelineReset, disposers, room.value)
bindListener(RoomEvent.CurrentStateUpdated, params?.onCurrentStateUpdated, disposers, room.value)
bindListener(RoomStateEvent.Members, params?.onMemberUpdate, disposers, room.value)
bindListener(RoomEvent.AccountData, params?.onAccountData, disposers, room.value)
bindListener(RoomStateEvent.Members, params?.onMembers, disposers, room.value)
}, { immediate: true })
Expand Down
86 changes: 86 additions & 0 deletions app/composables/use-room-member-grouping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { Room, RoomMember } from 'matrix-js-sdk'
import { toRef } from '@vueuse/core'
import QuickLRU from 'quick-lru'

const POWER_LEVEL_ORDER = ['admin', 'moderator', 'member'] satisfies PowerLevelName[]

type Member = Pick<RoomMember, 'powerLevel' | 'rawDisplayName' | 'membership' | 'name' | 'userId'> & { type: 'member' }
interface MemberHeader { type: 'header', title: PowerLevelName }
interface MemberCachePayload {
members: Prettify<Member | MemberHeader>[]
groupTotals: Record<PowerLevelName, number>
memberCount: number
}

export type GroupedMemberListItem = Prettify<Member | MemberHeader>

const roomMemberGroupCache = new QuickLRU<Room['roomId'], MemberCachePayload>({ maxSize: 12 })

export function useRoomMemberGrouping(members: MaybeRefOrGetter<RoomMember[] | undefined>, maybeRoomOrId: MaybeRefOrGetter<MaybeRoomOrId | undefined>, force?: MaybeRefOrGetter<boolean>) {
const membersRef = toRef(members)
const roomIdRef = toRef(maybeRoomOrId)
const forceRef = toRef(force)

const membersGrouped = shallowRef<MemberCachePayload>()
const roomId = computed(() => roomIdRef.value ? resolveRoomId(roomIdRef.value) : undefined)

watch(membersRef, (members) => {
if (!members?.length || !roomId.value)
return

const cached = roomMemberGroupCache.get(roomId.value)
if (cached && !forceRef.value && cached.memberCount === members.length)
return membersGrouped.value = cached

const payload = { ...createMembersList(members), memberCount: members.length }
roomMemberGroupCache.set(roomId.value, payload)

membersGrouped.value = payload
}, { immediate: true })
Comment thread
greptile-apps[bot] marked this conversation as resolved.

return membersGrouped
}

function createMembersList(members: RoomMember[]) {
members.sort((a, b) => {
Comment on lines +44 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 createMembersList calls members.sort() which mutates the array in-place. The array passed here is the same reference stored in useRoomMembers's members shallowRef, so after this call the upstream reactive state is silently reordered. Since shallowRef only tracks reference changes, this mutation is invisible to Vue's reactivity system and could confuse any other consumer of that ref. Sorting a copy avoids the side-effect.

Suggested change
function createMembersList(members: RoomMember[]) {
members.sort((a, b) => {
function createMembersList(members: RoomMember[]) {
const sorted = [...members]
sorted.sort((a, b) => {

Fix in Cursor

if (a.powerLevel === b.powerLevel) {
const aName = resolveUserName(a)
const bName = resolveUserName(b)

return aName.localeCompare(bName)
}

return a.powerLevel > b.powerLevel ? -1 : 1
})

const membersGrouped = groupBy(members, m => getPowerLevelName(m.powerLevel, true))
const membersWithHeaders: Prettify<Member | MemberHeader>[] = []
const groupTotals: MemberCachePayload['groupTotals'] = {
admin: 0,
member: 0,
moderator: 0,
owner: 0,
unknown: 0,
}
for (const groupName of POWER_LEVEL_ORDER) {
const group = membersGrouped[groupName]
if (!group?.length)
continue

groupTotals[groupName] = group.length

membersWithHeaders.push({ title: groupName, type: 'header' })
for (let i = 0; i < group.length; i++) {
const member = group[i]
if (!member)
continue

membersWithHeaders.push({ type: 'member', ...member })
}
}

return {
groupTotals,
members: membersWithHeaders,
}
}
5 changes: 5 additions & 0 deletions app/composables/use-room-member-power-level.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function useRoomMemberPowerLevel(roomId: MaybeRefOrGetter<MaybeRoomOrId | undefined>, maybeUserOrId: MaybeRefOrGetter<MaybeUserOrId | undefined>) {
const roomMember = useRoomMember(roomId, maybeUserOrId)

return computed(() => roomMember.value?.powerLevel)
}
Loading
Loading