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
176 changes: 67 additions & 109 deletions src/components/NcUserStatusIcon/NcUserStatusIcon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,127 +29,85 @@ This component displays a user status icon.
```
</docs>

<template>
<span v-if="activeStatus"
class="user-status-icon"
:class="{
'user-status-icon--invisible': ['invisible', 'offline'].includes(status),
}"
role="img"
:aria-hidden="ariaHidden"
:aria-label="ariaLabel"
v-html="activeSvg" /> <!-- eslint-disable-line vue/no-v-html -->
</template>

<script>
<script setup lang="ts">
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import { getCapabilities } from '@nextcloud/capabilities'
import { computed, watch } from 'vue'
import { getUserStatusText } from '../../utils/UserStatus.ts'
import { t } from '../../l10n.js'

import onlineSvg from '../../assets/status-icons/user-status-online.svg?raw'
import awaySvg from '../../assets/status-icons/user-status-away.svg?raw'
import dndSvg from '../../assets/status-icons/user-status-dnd.svg?raw'
import invisibleSvg from '../../assets/status-icons/user-status-invisible.svg?raw'

import { getUserStatusText } from '../../utils/UserStatus.ts'
import { t } from '../../l10n.js'

export default {
name: 'NcUserStatusIcon',

props: {
/**
* Set the user id to fetch the status
*/
user: {
type: String,
default: null,
},

/**
* Set the status
*
* @type {'online' | 'away' | 'busy' | 'dnd' | 'invisible' | 'offline'}
*/
status: {
type: String,
default: null,
validator: (value) => [
'online',
'away',
'busy',
'dnd',
'invisible',
'offline',
].includes(value),
},

/**
* Set the `aria-hidden` attribute
*
* @type {'true' | 'false'}
*/
ariaHidden: {
type: String,
default: null,
validator: (value) => [
'true',
'false',
].includes(value),
},
},

data() {
return {
fetchedUserStatus: null,
import logger from '../../utils/logger.ts'

const props = withDefaults(defineProps<{
/**
* Set the user id to fetch the status
*/
user?: string

/**
* Set the `aria-hidden` attribute
*/
ariaHidden?: boolean | 'true' | 'false'
}>(), {
user: undefined,
ariaHidden: false,
})

/**
* The user preloaded user status.
*/
const status = defineModel<'online' | 'away' | 'busy' | 'dnd' | 'invisible' | 'offline'>('status')
const isInvisible = computed(() => status.value && ['invisible', 'offline'].includes(status.value))

/**
* Aria label to set on the element (will be set when ariaHidden is not set)
*/
const ariaLabel = computed(() => (
status.value && (!props.ariaHidden || props.ariaHidden === 'false')
? t('User status: {status}', { status: getUserStatusText(status.value) })
: undefined
))

watch(() => props.user, async (user) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!status.value && user && (getCapabilities() as any)?.user_status?.enabled) {
try {
const { data } = await axios.get(generateOcsUrl('/apps/user_status/api/v1/statuses/{user}', { user }))
status.value = data.ocs?.data?.status
} catch (error) {
logger.debug('Error while fetching user status', { error })
}
},

computed: {
activeStatus() {
return this.status ?? this.fetchedUserStatus
},

activeSvg() {
const matchSvg = {
online: onlineSvg,
away: awaySvg,
busy: awaySvg,
dnd: dndSvg,
invisible: invisibleSvg,
offline: invisibleSvg,
}
return matchSvg[this.activeStatus] ?? null
},

ariaLabel() {
if (this.ariaHidden === 'true') {
return null
}
return t('User status: {status}', { status: getUserStatusText(this.activeStatus) })
},
},

watch: {
user: {
immediate: true,
async handler(user) {
if (!user || !getCapabilities()?.user_status?.enabled) {
this.fetchedUserStatus = null
return
}
try {
const { data } = await axios.get(generateOcsUrl('/apps/user_status/api/v1/statuses/{user}', { user }))
this.fetchedUserStatus = data.ocs?.data?.status
} catch (error) {
this.fetchedUserStatus = null
}
},
},
},
}
}, { immediate: true })

const matchSvg = {
online: onlineSvg,
away: awaySvg,
busy: awaySvg,
dnd: dndSvg,
invisible: invisibleSvg,
offline: invisibleSvg,
}
const activeSvg = computed(() => status.value && matchSvg[status.value])
</script>

<template>
<span v-if="status"
class="user-status-icon"
:class="{
'user-status-icon--invisible': isInvisible,
}"
:aria-hidden="!ariaLabel || undefined"
:aria-label
role="img"
v-html="activeSvg" /> <!-- eslint-disable-line vue/no-v-html -->
</template>

<style lang="scss" scoped>
.user-status-icon {
display: flex;
Expand Down
2 changes: 1 addition & 1 deletion src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,5 @@ export { default as NcTextArea } from './NcTextArea/index.js'
export { default as NcTextField } from './NcTextField/index.js'
export { default as NcTimezonePicker } from './NcTimezonePicker/index.ts'
export { default as NcUserBubble } from './NcUserBubble/index.js'
export { default as NcUserStatusIcon } from './NcUserStatusIcon/index.js'
export { default as NcUserStatusIcon } from './NcUserStatusIcon/index.ts'
export { default as NcVNodes } from './NcVNodes/index.ts'
5 changes: 0 additions & 5 deletions src/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,6 @@ declare const appVersion: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const TRANSLATIONS: { locale: string, translations: any }[]

declare module '*?raw' {
const content: string
export default content
}

declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
9 changes: 9 additions & 0 deletions src/vite.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare module '*?raw' {
const content: string
export default content
}
107 changes: 107 additions & 0 deletions tests/component/components/NcUserStatus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect, test } from '@playwright/experimental-ct-vue'
import NcUserStatusIcon from '../../../src/components/NcUserStatusIcon/NcUserStatusIcon.vue'

test('fetches the user status', async ({ mount, page }) => {
await page.addScriptTag({ content: 'window._oc_capabilities = { user_status: { enabled: true } };' })
await page.route('**/ocs/v2.php/apps/user_status/api/v1/statuses/jdoe', (route) => {
route.fulfill({ status: 200, json: { ocs: { data: { status: 'online' } } } })
})

const component = await mount(NcUserStatusIcon, {
props: {
user: 'jdoe',
},
})

await expect(page.getByRole('img', { name: 'User status: online' })).toBeVisible()
await expect(component).not.toHaveAttribute('aria-hidden')
})

test('does not fetch the user status if preloaded', async ({ mount, page }) => {
await page.addScriptTag({ content: 'window._oc_capabilities = { user_status: { enabled: true } };' })
await page.route('**/ocs/v2.php/apps/user_status/api/v1/statuses/jdoe', (route) => {
route.abort()
throw new Error('Should not be accessed')
})

const component = await mount(NcUserStatusIcon, {
props: {
user: 'jdoe',
status: 'dnd',
},
})

await expect(page.getByRole('img', { name: 'User status: do not disturb' })).toBeVisible()
await expect(component).not.toHaveAttribute('aria-hidden')
})

test('explicitily make element visible for accessibility', async ({ mount, page }) => {
await page.addScriptTag({ content: 'window._oc_capabilities = { user_status: { enabled: true } };' })
await page.route('**/ocs/v2.php/apps/user_status/api/v1/statuses/jdoe', (route) => {
route.abort()
throw new Error('Should not be accessed')
})

const component = await mount(NcUserStatusIcon, {
props: {
user: 'jdoe',
status: 'dnd',
ariaHidden: false,
},
})

await expect(page.getByRole('img', { name: 'User status: do not disturb' })).toBeVisible()
await expect(component).not.toHaveAttribute('aria-hidden')
})

test('explicitily make element visible for accessibility (legacy)', async ({ mount, page }) => {
await page.addScriptTag({ content: 'window._oc_capabilities = { user_status: { enabled: true } };' })
await page.route('**/ocs/v2.php/apps/user_status/api/v1/statuses/jdoe', (route) => {
route.abort()
throw new Error('Should not be accessed')
})

const component = await mount(NcUserStatusIcon, {
props: {
user: 'jdoe',
status: 'dnd',
ariaHidden: 'false',
},
})

await expect(page.getByRole('img', { name: 'User status: do not disturb' })).toBeVisible()
await expect(component).not.toHaveAttribute('aria-hidden')
})

test('can hide the element from accessibility tree', async ({ mount, page }) => {
const component = await mount(NcUserStatusIcon, {
props: {
user: 'jdoe',
status: 'dnd',
ariaHidden: true,
},
})

await expect(page.locator('svg')).toBeVisible()
await expect(component).not.toHaveAttribute('aria-label')
await expect(component).toHaveAttribute('aria-hidden', 'true')
})

test('can hide the element from accessibility tree (legacy)', async ({ mount, page }) => {
const component = await mount(NcUserStatusIcon, {
props: {
user: 'jdoe',
status: 'dnd',
ariaHidden: 'true',
},
})

await expect(page.locator('svg')).toBeVisible()
await expect(component).not.toHaveAttribute('aria-label')
await expect(component).toHaveAttribute('aria-hidden', 'true')
})
Loading