feat: User statuses (#10774)

* feat: User status

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix formatting

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-04-16 15:02:29 +07:00
committed by GitHub
parent 44e4366255
commit 3e415cb0d2
26 changed files with 1196 additions and 49 deletions
+3
View File
@@ -18563,6 +18563,9 @@ importers:
'@hcengineering/core':
specifier: workspace:^0.7.26
version: link:../../foundations/core/packages/core
'@hcengineering/emoji-resources':
specifier: workspace:^0.7.0
version: link:../emoji-resources
'@hcengineering/image-cropper':
specifier: workspace:^0.7.0
version: link:../image-cropper
+36 -2
View File
@@ -34,7 +34,8 @@ import {
type SocialIdentity,
type Status,
type SocialIdentityProvider,
type Translation
type Translation,
type WorkspaceMemberStatus
} from '@hcengineering/contact'
import {
AccountRole,
@@ -52,6 +53,7 @@ import {
type PersonId,
type PersonUuid,
type Ref,
type Space,
type Timestamp
} from '@hcengineering/core'
import { createSystemType } from '@hcengineering/model-card'
@@ -72,6 +74,7 @@ import {
TypeRef,
TypeString,
TypeTimestamp,
TypeAccountUuid,
UX,
type Builder
} from '@hcengineering/model'
@@ -301,6 +304,24 @@ export class TTranslation extends TPreference implements Translation {
dontTranslate!: string[]
}
@Model(contact.class.WorkspaceMemberStatus, core.class.Doc, DOMAIN_CONTACT)
@UX(contact.string.WorkspaceStatusNote, contact.icon.User)
export class TWorkspaceMemberStatus extends TDoc implements WorkspaceMemberStatus {
@Prop(TypeRef(core.class.Space), core.string.Space)
@Index(IndexKind.Indexed)
declare space: Ref<Space>
@Prop(TypeAccountUuid(), core.string.Members)
@Index(IndexKind.Indexed)
user!: AccountUuid
@Prop(TypeString(), core.string.Description)
message!: string
@Prop(TypeTimestamp(), contact.string.StatusDueDate)
clearAt?: Timestamp
}
export function createModel (builder: Builder): void {
builder.createModel(
TAvatarProvider,
@@ -317,13 +338,20 @@ export function createModel (builder: Builder): void {
TContactsTab,
TPersonSpace,
TUserRole,
TTranslation
TTranslation,
TWorkspaceMemberStatus
)
builder.mixin(contact.class.PersonSpace, core.class.Class, core.mixin.TxAccessLevel, {
createAccessLevel: AccountRole.Guest
})
builder.mixin(contact.class.WorkspaceMemberStatus, core.class.Class, core.mixin.TxAccessLevel, {
createAccessLevel: AccountRole.Guest,
updateAccessLevel: AccountRole.Guest,
removeAccessLevel: AccountRole.Guest
})
builder.mixin(contact.class.Person, core.class.Class, core.mixin.TxAccessLevel, {
createAccessLevel: AccountRole.Guest,
isIdentity: true
@@ -1401,6 +1429,12 @@ export function createModel (builder: Builder): void {
_class: 1,
[contact.mixin.Employee + '.active']: 1
}
},
{
keys: {
_class: 1,
user: 1
}
}
],
disabled: [{ attachedToClass: 1 }, { modifiedBy: 1 }, { createdBy: 1 }, { createdOn: -1 }, { attachedTo: 1 }]
+25 -1
View File
@@ -111,6 +111,10 @@
"ViewProfile": "Zobrazit profil",
"Viber": "Viber",
"ViberPlaceholder": "Viber",
"SocialId": "Sociální ID",
"SocialIds": "Sociální ID",
"Type": "Typ",
"Confirmed": "Potvrzeno",
"UserProfile": "Uživatelský profil",
"DeactivatedAccount": "Deaktivovaný účet",
"LocalTime": "místní čas",
@@ -125,6 +129,26 @@
"TranslateTo": "Přeložit do",
"DontTranslate": "Nepřekládat",
"SelectLanguage": "Vyberte jazyk",
"SelectLanguages": "Vyberte jazyky"
"SelectLanguages": "Vyberte jazyky",
"WorkspaceStatusNote": "Stav uživatele",
"WorkspaceStatusUntil": "Zobrazit do",
"WorkspaceStatusMenu": "Váš stav",
"WorkspaceStatusSetYour": "Nastavte svůj stav",
"WorkspaceStatusUpdateYour": "Aktualizujte svůj stav",
"WorkspaceStatusMessage": "Jaký je váš stav?",
"WorkspaceStatusAway": "Pryč",
"WorkspaceStatusVacation": "Na dovolené",
"WorkspaceStatusOutSick": "Nemoc",
"WorkspaceStatusClear": "Vymazat stav",
"WorkspaceStatusSave": "Uložit",
"WorkspaceStatusQuickPick": "Rychlé stavy",
"WorkspaceStatusComposeHint": "Krátká poznámka (volitelné)",
"WorkspaceStatusClearUntil": "Vymazat datum",
"WorkspaceStatusDoNotClear": "Stav automaticky nečistit",
"WorkspaceStatusIn30Min": "Za 30 minut",
"WorkspaceStatusIn1Hour": "Za 1 hodinu",
"WorkspaceStatusIn4Hours": "Za 4 hodiny",
"WorkspaceStatusEndOfDay": "Do konce dne",
"WorkspaceStatusPickDate": "Vyberte datum"
}
}
+25 -1
View File
@@ -111,6 +111,10 @@
"ViewProfile": "Profil anzeigen",
"Viber": "Viber",
"ViberPlaceholder": "Viber",
"SocialId": "Social-ID",
"SocialIds": "Social-IDs",
"Type": "Typ",
"Confirmed": "Bestätigt",
"UserProfile": "Benutzerprofil",
"DeactivatedAccount": "Deaktivierter Account",
"LocalTime": "Ortszeit",
@@ -125,6 +129,26 @@
"TranslateTo": "Übersetzen in",
"DontTranslate": "Nicht übersetzen",
"SelectLanguage": "Sprache auswählen",
"SelectLanguages": "Sprachen auswählen"
"SelectLanguages": "Sprachen auswählen",
"WorkspaceStatusNote": "Benutzerstatus",
"WorkspaceStatusUntil": "Anzeigen bis",
"WorkspaceStatusMenu": "Ihr Status",
"WorkspaceStatusSetYour": "Status festlegen",
"WorkspaceStatusUpdateYour": "Status aktualisieren",
"WorkspaceStatusMessage": "Wie lautet Ihr Status?",
"WorkspaceStatusAway": "Abwesend",
"WorkspaceStatusVacation": "Im Urlaub",
"WorkspaceStatusOutSick": "Krank",
"WorkspaceStatusClear": "Status löschen",
"WorkspaceStatusSave": "Speichern",
"WorkspaceStatusQuickPick": "Schnellauswahl",
"WorkspaceStatusComposeHint": "Kurze Notiz hinzufügen (optional)",
"WorkspaceStatusClearUntil": "Datum löschen",
"WorkspaceStatusDoNotClear": "Status nicht automatisch löschen",
"WorkspaceStatusIn30Min": "In 30 Minuten",
"WorkspaceStatusIn1Hour": "In 1 Stunde",
"WorkspaceStatusIn4Hours": "In 4 Stunden",
"WorkspaceStatusEndOfDay": "Ende des Tages",
"WorkspaceStatusPickDate": "Datum wählen"
}
}
+21 -1
View File
@@ -129,6 +129,26 @@
"TranslateTo": "Translate to",
"DontTranslate": "Don't translate",
"SelectLanguage": "Select language",
"SelectLanguages": "Select languages"
"SelectLanguages": "Select languages",
"WorkspaceStatusNote": "User status",
"WorkspaceStatusUntil": "Show until",
"WorkspaceStatusMenu": "Your status",
"WorkspaceStatusSetYour": "Set your status",
"WorkspaceStatusUpdateYour": "Update your status",
"WorkspaceStatusMessage": "What is your status?",
"WorkspaceStatusAway": "Away",
"WorkspaceStatusVacation": "On vacation",
"WorkspaceStatusOutSick": "Out sick",
"WorkspaceStatusClear": "Clear status",
"WorkspaceStatusSave": "Save",
"WorkspaceStatusQuickPick": "Quick statuses",
"WorkspaceStatusComposeHint": "Add a short note (optional)",
"WorkspaceStatusClearUntil": "Clear date",
"WorkspaceStatusDoNotClear": "Do not clear status",
"WorkspaceStatusIn30Min": "In 30 minutes",
"WorkspaceStatusIn1Hour": "In 1 hour",
"WorkspaceStatusIn4Hours": "In 4 hours",
"WorkspaceStatusEndOfDay": "End of day",
"WorkspaceStatusPickDate": "Choose date"
}
}
+21 -1
View File
@@ -129,6 +129,26 @@
"TranslateTo": "Traducir a",
"DontTranslate": "No traducir",
"SelectLanguage": "Seleccionar idioma",
"SelectLanguages": "Seleccionar idiomas"
"SelectLanguages": "Seleccionar idiomas",
"WorkspaceStatusNote": "Estado del usuario",
"WorkspaceStatusUntil": "Mostrar hasta",
"WorkspaceStatusMenu": "Tu estado",
"WorkspaceStatusSetYour": "Establecer tu estado",
"WorkspaceStatusUpdateYour": "Actualizar tu estado",
"WorkspaceStatusMessage": "¿Cuál es tu estado?",
"WorkspaceStatusAway": "Ausente",
"WorkspaceStatusVacation": "De vacaciones",
"WorkspaceStatusOutSick": "De baja por enfermedad",
"WorkspaceStatusClear": "Borrar estado",
"WorkspaceStatusSave": "Guardar",
"WorkspaceStatusQuickPick": "Estados rápidos",
"WorkspaceStatusComposeHint": "Añade una nota breve (opcional)",
"WorkspaceStatusClearUntil": "Borrar fecha",
"WorkspaceStatusDoNotClear": "No borrar el estado automáticamente",
"WorkspaceStatusIn30Min": "En 30 minutos",
"WorkspaceStatusIn1Hour": "En 1 hora",
"WorkspaceStatusIn4Hours": "En 4 horas",
"WorkspaceStatusEndOfDay": "Fin del día",
"WorkspaceStatusPickDate": "Elegir fecha"
}
}
+21 -1
View File
@@ -129,6 +129,26 @@
"TranslateTo": "Traduire en",
"DontTranslate": "Ne pas traduire",
"SelectLanguage": "Sélectionner la langue",
"SelectLanguages": "Sélectionner les langues"
"SelectLanguages": "Sélectionner les langues",
"WorkspaceStatusNote": "Statut utilisateur",
"WorkspaceStatusUntil": "Afficher jusquau",
"WorkspaceStatusMenu": "Votre statut",
"WorkspaceStatusSetYour": "Définir votre statut",
"WorkspaceStatusUpdateYour": "Mettre à jour votre statut",
"WorkspaceStatusMessage": "Quel est votre statut ?",
"WorkspaceStatusAway": "Absent",
"WorkspaceStatusVacation": "En congé",
"WorkspaceStatusOutSick": "Malade",
"WorkspaceStatusClear": "Effacer le statut",
"WorkspaceStatusSave": "Enregistrer",
"WorkspaceStatusQuickPick": "Statuts rapides",
"WorkspaceStatusComposeHint": "Ajouter une courte note (facultatif)",
"WorkspaceStatusClearUntil": "Effacer la date",
"WorkspaceStatusDoNotClear": "Ne pas effacer le statut automatiquement",
"WorkspaceStatusIn30Min": "Dans 30 minutes",
"WorkspaceStatusIn1Hour": "Dans 1 heure",
"WorkspaceStatusIn4Hours": "Dans 4 heures",
"WorkspaceStatusEndOfDay": "Fin de journée",
"WorkspaceStatusPickDate": "Choisir une date"
}
}
+21 -1
View File
@@ -129,6 +129,26 @@
"TranslateTo": "Traduci in",
"DontTranslate": "Non tradurre",
"SelectLanguage": "Seleziona lingua",
"SelectLanguages": "Seleziona lingue"
"SelectLanguages": "Seleziona lingue",
"WorkspaceStatusNote": "Stato utente",
"WorkspaceStatusUntil": "Mostra fino al",
"WorkspaceStatusMenu": "Il tuo stato",
"WorkspaceStatusSetYour": "Imposta il tuo stato",
"WorkspaceStatusUpdateYour": "Aggiorna il tuo stato",
"WorkspaceStatusMessage": "Qual è il tuo stato?",
"WorkspaceStatusAway": "Assente",
"WorkspaceStatusVacation": "In ferie",
"WorkspaceStatusOutSick": "Malato",
"WorkspaceStatusClear": "Cancella stato",
"WorkspaceStatusSave": "Salva",
"WorkspaceStatusQuickPick": "Stati rapidi",
"WorkspaceStatusComposeHint": "Aggiungi una breve nota (facoltativo)",
"WorkspaceStatusClearUntil": "Cancella data",
"WorkspaceStatusDoNotClear": "Non cancellare lo stato automaticamente",
"WorkspaceStatusIn30Min": "Tra 30 minuti",
"WorkspaceStatusIn1Hour": "Tra 1 ora",
"WorkspaceStatusIn4Hours": "Tra 4 ore",
"WorkspaceStatusEndOfDay": "Fine giornata",
"WorkspaceStatusPickDate": "Scegli data"
}
}
+21 -1
View File
@@ -129,6 +129,26 @@
"TranslateTo": "翻訳先",
"DontTranslate": "翻訳しない",
"SelectLanguage": "言語を選択",
"SelectLanguages": "言語を選択"
"SelectLanguages": "言語を選択",
"WorkspaceStatusNote": "ユーザーステータス",
"WorkspaceStatusUntil": "表示期限",
"WorkspaceStatusMenu": "あなたのステータス",
"WorkspaceStatusSetYour": "ステータスを設定",
"WorkspaceStatusUpdateYour": "ステータスを更新",
"WorkspaceStatusMessage": "ステータスは何ですか?",
"WorkspaceStatusAway": "離席中",
"WorkspaceStatusVacation": "休暇中",
"WorkspaceStatusOutSick": "病欠",
"WorkspaceStatusClear": "ステータスをクリア",
"WorkspaceStatusSave": "保存",
"WorkspaceStatusQuickPick": "クイックステータス",
"WorkspaceStatusComposeHint": "短いメモを追加(任意)",
"WorkspaceStatusClearUntil": "日付をクリア",
"WorkspaceStatusDoNotClear": "ステータスを自動ではクリアしない",
"WorkspaceStatusIn30Min": "30分後",
"WorkspaceStatusIn1Hour": "1時間後",
"WorkspaceStatusIn4Hours": "4時間後",
"WorkspaceStatusEndOfDay": "今日の終わりまで",
"WorkspaceStatusPickDate": "日付を選択"
}
}
+21 -1
View File
@@ -129,6 +129,26 @@
"TranslateTo": "Traduzir para",
"DontTranslate": "Não traduzir",
"SelectLanguage": "Selecionar idioma",
"SelectLanguages": "Selecionar idiomas"
"SelectLanguages": "Selecionar idiomas",
"WorkspaceStatusNote": "Status do usuário",
"WorkspaceStatusUntil": "Mostrar até",
"WorkspaceStatusMenu": "Seu status",
"WorkspaceStatusSetYour": "Definir seu status",
"WorkspaceStatusUpdateYour": "Atualizar seu status",
"WorkspaceStatusMessage": "Qual é o seu status?",
"WorkspaceStatusAway": "Ausente",
"WorkspaceStatusVacation": "De férias",
"WorkspaceStatusOutSick": "Afastado por saúde",
"WorkspaceStatusClear": "Limpar status",
"WorkspaceStatusSave": "Salvar",
"WorkspaceStatusQuickPick": "Status rápidos",
"WorkspaceStatusComposeHint": "Adicione uma nota curta (opcional)",
"WorkspaceStatusClearUntil": "Limpar data",
"WorkspaceStatusDoNotClear": "Não limpar o status automaticamente",
"WorkspaceStatusIn30Min": "Em 30 minutos",
"WorkspaceStatusIn1Hour": "Em 1 hora",
"WorkspaceStatusIn4Hours": "Em 4 horas",
"WorkspaceStatusEndOfDay": "Fim do dia",
"WorkspaceStatusPickDate": "Escolher data"
}
}
+21 -1
View File
@@ -129,6 +129,26 @@
"TranslateTo": "Traduzir para",
"DontTranslate": "Não traduzir",
"SelectLanguage": "Selecionar idioma",
"SelectLanguages": "Selecionar idiomas"
"SelectLanguages": "Selecionar idiomas",
"WorkspaceStatusNote": "Estado do utilizador",
"WorkspaceStatusUntil": "Mostrar até",
"WorkspaceStatusMenu": "O seu estado",
"WorkspaceStatusSetYour": "Definir o seu estado",
"WorkspaceStatusUpdateYour": "Atualizar o seu estado",
"WorkspaceStatusMessage": "Qual é o seu estado?",
"WorkspaceStatusAway": "Ausente",
"WorkspaceStatusVacation": "De férias",
"WorkspaceStatusOutSick": "Baixa por doença",
"WorkspaceStatusClear": "Limpar estado",
"WorkspaceStatusSave": "Guardar",
"WorkspaceStatusQuickPick": "Estados rápidos",
"WorkspaceStatusComposeHint": "Adicionar uma nota breve (opcional)",
"WorkspaceStatusClearUntil": "Limpar data",
"WorkspaceStatusDoNotClear": "Não limpar o estado automaticamente",
"WorkspaceStatusIn30Min": "Em 30 minutos",
"WorkspaceStatusIn1Hour": "Em 1 hora",
"WorkspaceStatusIn4Hours": "Em 4 horas",
"WorkspaceStatusEndOfDay": "Fim do dia",
"WorkspaceStatusPickDate": "Escolher data"
}
}
+21 -1
View File
@@ -129,6 +129,26 @@
"TranslateTo": "Переводить на",
"DontTranslate": "Не переводить",
"SelectLanguage": "Выберите язык",
"SelectLanguages": "Выберите языки"
"SelectLanguages": "Выберите языки",
"WorkspaceStatusNote": "Статус пользователя",
"WorkspaceStatusUntil": "Показывать до",
"WorkspaceStatusMenu": "Ваш статус",
"WorkspaceStatusSetYour": "Установить статус",
"WorkspaceStatusUpdateYour": "Обновить статус",
"WorkspaceStatusMessage": "Какой ваш статус?",
"WorkspaceStatusAway": "Отошёл",
"WorkspaceStatusVacation": "В отпуске",
"WorkspaceStatusOutSick": "На больничном",
"WorkspaceStatusClear": "Очистить статус",
"WorkspaceStatusSave": "Сохранить",
"WorkspaceStatusQuickPick": "Быстрые статусы",
"WorkspaceStatusComposeHint": "Краткая заметка (необязательно)",
"WorkspaceStatusClearUntil": "Сбросить дату",
"WorkspaceStatusDoNotClear": "Не сбрасывать статус автоматически",
"WorkspaceStatusIn30Min": "Через 30 минут",
"WorkspaceStatusIn1Hour": "Через 1 час",
"WorkspaceStatusIn4Hours": "Через 4 часа",
"WorkspaceStatusEndOfDay": "До конца дня",
"WorkspaceStatusPickDate": "Выбрать дату"
}
}
+26 -1
View File
@@ -124,6 +124,31 @@
"HereDescription": "Bu {title} içindeki çevrimiçi üyeleri bilgilendir",
"Guest": "Misafir",
"Deleted": "Silindi",
"CannotMerge": "Hata: Global kişiler birleştirilemez"
"CannotMerge": "Hata: Global kişiler birleştirilemez",
"AutoTranslation": "Otomatik çeviri",
"TranslateTo": "Şuna çevir",
"DontTranslate": "Çevirme",
"SelectLanguage": "Dil seç",
"SelectLanguages": "Dilleri seç",
"WorkspaceStatusNote": "Kullanıcı durumu",
"WorkspaceStatusUntil": "Şu tarihe kadar göster",
"WorkspaceStatusMenu": "Durumunuz",
"WorkspaceStatusSetYour": "Durumunuzu ayarlayın",
"WorkspaceStatusUpdateYour": "Durumunuzu güncelleyin",
"WorkspaceStatusMessage": "Durumunuz nedir?",
"WorkspaceStatusAway": "Uzakta",
"WorkspaceStatusVacation": "Tatilde",
"WorkspaceStatusOutSick": "Hastalık izni",
"WorkspaceStatusClear": "Durumu temizle",
"WorkspaceStatusSave": "Kaydet",
"WorkspaceStatusQuickPick": "Hızlı durumlar",
"WorkspaceStatusComposeHint": "Kısa bir not ekleyin (isteğe bağlı)",
"WorkspaceStatusClearUntil": "Tarihi temizle",
"WorkspaceStatusDoNotClear": "Durumu otomatik temizleme",
"WorkspaceStatusIn30Min": "30 dakika içinde",
"WorkspaceStatusIn1Hour": "1 saat içinde",
"WorkspaceStatusIn4Hours": "4 saat içinde",
"WorkspaceStatusEndOfDay": "Gün sonu",
"WorkspaceStatusPickDate": "Tarih seç"
}
}
+21 -1
View File
@@ -129,6 +129,26 @@
"TranslateTo": "翻译为",
"DontTranslate": "不翻译",
"SelectLanguage": "选择语言",
"SelectLanguages": "选择语言"
"SelectLanguages": "选择语言",
"WorkspaceStatusNote": "用户状态",
"WorkspaceStatusUntil": "显示截止日期",
"WorkspaceStatusMenu": "你的状态",
"WorkspaceStatusSetYour": "设置你的状态",
"WorkspaceStatusUpdateYour": "更新你的状态",
"WorkspaceStatusMessage": "你的状态是什么?",
"WorkspaceStatusAway": "离开",
"WorkspaceStatusVacation": "休假中",
"WorkspaceStatusOutSick": "病假",
"WorkspaceStatusClear": "清除状态",
"WorkspaceStatusSave": "保存",
"WorkspaceStatusQuickPick": "快捷状态",
"WorkspaceStatusComposeHint": "添加简短备注(可选)",
"WorkspaceStatusClearUntil": "清除日期",
"WorkspaceStatusDoNotClear": "不自动清除状态",
"WorkspaceStatusIn30Min": "30 分钟后",
"WorkspaceStatusIn1Hour": "1 小时后",
"WorkspaceStatusIn4Hours": "4 小时后",
"WorkspaceStatusEndOfDay": "今天结束",
"WorkspaceStatusPickDate": "选择日期"
}
}
+1
View File
@@ -45,6 +45,7 @@
"@hcengineering/attachment-resources": "workspace:^0.7.0",
"@hcengineering/contact": "workspace:^0.7.0",
"@hcengineering/core": "workspace:^0.7.26",
"@hcengineering/emoji-resources": "workspace:^0.7.0",
"@hcengineering/image-cropper": "workspace:^0.7.0",
"@hcengineering/login": "workspace:^0.7.0",
"@hcengineering/notification": "workspace:^0.7.0",
@@ -1,10 +1,17 @@
<script lang="ts">
import { Employee, Person } from '@hcengineering/contact'
import {
Employee,
Person,
extractLeadingStatusEmoji,
getWorkspaceMemberStatusSubtitle,
isWorkspaceMemberStatusVisible
} from '@hcengineering/contact'
import { Ref, WithLookup } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { getEmbeddedLabel, IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import ui, { IconSize } from '@hcengineering/ui'
import ui, { IconSize, tooltip } from '@hcengineering/ui'
import { employeeByIdStore, PersonLabelTooltip } from '..'
import { workspaceMemberStatusByAccountStore } from '../workspaceMemberStatus'
import PersonPresenter from '../components/PersonPresenter.svelte'
import contact from '../plugin'
import { getPreviewPopup } from './person/utils'
@@ -26,6 +33,7 @@
export let noUnderline: boolean = false
export let compact: boolean = false
export let showStatus: boolean = false
export let showWorkspaceStatusEmoji: boolean = true
const client = getClient()
const h = client.getHierarchy()
@@ -33,25 +41,58 @@
$: person = typeof value === 'string' ? $employeeByIdStore.get(value as Ref<Employee>) : (value as Person)
$: employeeValue = person != null ? h.as(person, contact.mixin.Employee) : undefined
$: active = employeeValue?.active ?? false
$: statusDoc =
employeeValue?.personUuid !== undefined
? $workspaceMemberStatusByAccountStore.get(employeeValue.personUuid)
: undefined
$: statusEmoji = extractLeadingStatusEmoji(statusDoc?.message)
$: statusTooltip = getWorkspaceMemberStatusSubtitle(statusDoc)
</script>
<PersonPresenter
value={employeeValue}
{tooltipLabels}
onEdit={onEmployeeEdit}
customTooltip={getPreviewPopup(employeeValue, showPopup)}
{shouldShowAvatar}
{shouldShowName}
{avatarSize}
{shouldShowPlaceholder}
{disabled}
{inline}
{colorInherit}
{accent}
{defaultName}
{noUnderline}
{compact}
showStatus={showStatus && active}
statusLabel={!active && shouldShowName && showStatus ? contact.string.Inactive : undefined}
on:accent-color
/>
<div class="employee-presenter">
<PersonPresenter
value={employeeValue}
{tooltipLabels}
onEdit={onEmployeeEdit}
customTooltip={getPreviewPopup(employeeValue, showPopup)}
{shouldShowAvatar}
{shouldShowName}
{avatarSize}
{shouldShowPlaceholder}
{disabled}
{inline}
{colorInherit}
{accent}
{defaultName}
{noUnderline}
{compact}
showStatus={showStatus && active}
statusLabel={!active && shouldShowName && showStatus ? contact.string.Inactive : undefined}
on:accent-color
/>
{#if showWorkspaceStatusEmoji && shouldShowName && statusEmoji !== undefined && isWorkspaceMemberStatusVisible(statusDoc)}
<span
class="status-emoji"
use:tooltip={statusTooltip !== undefined ? { label: getEmbeddedLabel(statusTooltip) } : undefined}
>
{statusEmoji}
</span>
{/if}
</div>
<style lang="scss">
.employee-presenter {
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 100%;
}
.status-emoji {
line-height: 1;
font-size: 0.875rem;
cursor: default;
}
</style>
@@ -0,0 +1,545 @@
<!--
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import contact, { type WorkspaceMemberStatus, trimWorkspaceMemberStatusMessage } from '@hcengineering/contact'
import core, { getCurrentAccount } from '@hcengineering/core'
import EmojiPopup from '@hcengineering/emoji-resources/src/components/EmojiPopup.svelte'
import { translate, type IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { createEventDispatcher, onMount } from 'svelte'
import { getCurrentLanguage } from '@hcengineering/theme'
import {
Button,
DatePopup,
EditBox,
Header,
Label,
SelectPopup,
type SelectPopupValueType,
eventToHTMLElement,
showPopup,
closePopup
} from '@hcengineering/ui'
import contactPlugin from '../plugin'
const client = getClient()
const dispatch = createEventDispatcher()
const account = getCurrentAccount()
let message = ''
type UiPreset = 'away' | 'vacation' | 'outSick' | 'custom'
let preset: UiPreset = 'custom'
let emojiText = '💬'
let clearAt: number | undefined = undefined
let existing: WorkspaceMemberStatus | undefined = undefined
type ClearMode = 'none' | 'in30m' | 'in1h' | 'in4h' | 'endOfDay' | 'pickDate'
let clearMode: ClearMode = 'none'
const CLEAR_MODE_LABEL: Record<ClearMode, IntlString> = {
none: contactPlugin.string.WorkspaceStatusDoNotClear,
in30m: contactPlugin.string.WorkspaceStatusIn30Min,
in1h: contactPlugin.string.WorkspaceStatusIn1Hour,
in4h: contactPlugin.string.WorkspaceStatusIn4Hours,
endOfDay: contactPlugin.string.WorkspaceStatusEndOfDay,
pickDate: contactPlugin.string.WorkspaceStatusPickDate
}
const CLEAR_MODE_MENU_ORDER: ClearMode[] = ['none', 'in30m', 'in1h', 'in4h', 'endOfDay', 'pickDate']
const presets: Array<{ id: Exclude<UiPreset, 'custom'>, emoji: string, label: IntlString }> = [
{ id: 'away', emoji: '⏳', label: contactPlugin.string.WorkspaceStatusAway },
{ id: 'vacation', emoji: '🏖️', label: contactPlugin.string.WorkspaceStatusVacation },
{ id: 'outSick', emoji: '🤒', label: contactPlugin.string.WorkspaceStatusOutSick }
]
/** Avoid flipping UI preset to `custom` when we programmatically set `message` from a template */
let skipMarkCustom = false
function presetEmoji (value: UiPreset): string {
if (value === 'away') return '⏳'
if (value === 'vacation') return '🏖️'
if (value === 'outSick') return '🤒'
return '💬'
}
function emojiToUiPreset (e: string): UiPreset {
if (e === '⏳') return 'away'
if (e === '🏖️') return 'vacation'
if (e === '🤒') return 'outSick'
return 'custom'
}
function splitStoredMessage (raw: string): { emoji: string, text: string } {
const t = raw.trim()
if (t === '') return { emoji: '💬', text: '' }
const sp = t.indexOf(' ')
if (sp === -1) return { emoji: t, text: '' }
return { emoji: t.slice(0, sp), text: t.slice(sp + 1).trim() }
}
function markCustom (): void {
if (skipMarkCustom) return
preset = 'custom'
}
async function applyTemplate (template: Exclude<UiPreset, 'custom'>): Promise<void> {
skipMarkCustom = true
preset = template
emojiText = presetEmoji(template)
const lang = getCurrentLanguage()
const row = presets.find((p) => p.id === template)
if (row !== undefined) {
message = await translate(row.label, {}, lang)
}
queueMicrotask(() => {
skipMarkCustom = false
})
}
onMount(() => {
void (async () => {
const doc = await client.findOne(contact.class.WorkspaceMemberStatus, { user: account.uuid })
existing = doc
if (doc === undefined) return
clearAt = doc.clearAt
syncClearModeFromTimestamp()
const legacyPreset = (doc as WorkspaceMemberStatus & { preset?: string }).preset
const raw = (doc.message ?? '').trim()
if (legacyPreset === 'vacation' && raw === '') {
await applyTemplate('vacation')
return
}
const { emoji, text } = splitStoredMessage(doc.message ?? '')
message = text
emojiText = emoji !== '' ? emoji : '💬'
preset = emojiToUiPreset(emoji)
})()
})
$: clearModeLabel = CLEAR_MODE_LABEL[clearMode]
$: untilPickerDateLabel = clearAt !== undefined ? new Date(clearAt).toLocaleDateString() : ''
$: untilPickerTimeInputValue =
clearAt === undefined
? '09:00'
: `${String(new Date(clearAt).getHours()).padStart(2, '0')}:${String(new Date(clearAt).getMinutes()).padStart(2, '0')}`
function getEndOfDayTimestamp (): number {
const d = new Date()
d.setHours(23, 59, 59, 999)
return d.getTime()
}
/** Best-effort: stored status only has `clearAt`, not which menu option was chosen */
function inferClearModeFromTimestamp (ts: number): ClearMode {
const now = Date.now()
const toleranceMs = 90 * 1000
if (ts <= now) return 'none'
const at = new Date(ts)
const endOfThatDay = new Date(at)
endOfThatDay.setHours(23, 59, 59, 999)
if (Math.abs(ts - endOfThatDay.getTime()) < toleranceMs) return 'endOfDay'
const diff = ts - now
if (diff > 0 && diff <= 6 * 60 * 60 * 1000) {
const near = (ms: number): boolean => Math.abs(diff - ms) < toleranceMs
if (near(30 * 60 * 1000)) return 'in30m'
if (near(60 * 60 * 1000)) return 'in1h'
if (near(4 * 60 * 60 * 1000)) return 'in4h'
}
return 'pickDate'
}
function applyClearMode (mode: ClearMode): void {
clearMode = mode
const now = Date.now()
if (mode === 'none') {
clearAt = undefined
return
}
if (mode === 'in30m') {
clearAt = now + 30 * 60 * 1000
return
}
if (mode === 'in1h') {
clearAt = now + 60 * 60 * 1000
return
}
if (mode === 'in4h') {
clearAt = now + 4 * 60 * 60 * 1000
return
}
if (mode === 'endOfDay') {
clearAt = getEndOfDayTimestamp()
}
}
function syncClearModeFromTimestamp (): void {
if (clearAt === undefined) {
clearMode = 'none'
return
}
const inferred = inferClearModeFromTimestamp(clearAt)
if (inferred === 'none') {
clearAt = undefined
clearMode = 'none'
return
}
clearMode = inferred
}
function finishPopup (): void {
closePopup()
dispatch('close')
}
function openUntilDatePicker (ev: MouseEvent): void {
showPopup(
DatePopup,
{
noShift: true,
withTime: true,
currentDate: clearAt !== undefined ? new Date(clearAt) : null,
label: contactPlugin.string.WorkspaceStatusUntil
},
eventToHTMLElement(ev),
(result) => {
if (result != null && result.value !== undefined) {
clearAt = result.value !== null ? result.value.getTime() : undefined
clearMode = clearAt === undefined ? 'none' : 'pickDate'
}
}
)
}
async function openClearModeMenu (ev: MouseEvent): Promise<void> {
const lang = getCurrentLanguage()
const value: SelectPopupValueType[] = await Promise.all(
CLEAR_MODE_MENU_ORDER.map(async (id) => ({
id,
text: await translate(CLEAR_MODE_LABEL[id], {}, lang)
}))
)
showPopup(
SelectPopup,
{
value,
onSelect: (id?: string | number | null) => {
if (id == null) return
const mode = String(id) as ClearMode
if (mode === 'pickDate') {
clearMode = 'pickDate'
// SelectPopup does not dispatch `close` when `onSelect` is set — dismiss menu before stacking DatePopup.
closePopup()
openUntilDatePicker(ev)
return
}
applyClearMode(mode)
closePopup()
}
},
eventToHTMLElement(ev)
)
}
function onTimeChange (ev: Event): void {
if (clearAt === undefined) return
const value = (ev.target as HTMLInputElement).value
const [h, m] = value.split(':').map((it) => Number(it))
if (!Number.isFinite(h) || !Number.isFinite(m)) return
const d = new Date(clearAt)
d.setHours(h, m, 0, 0)
clearAt = d.getTime()
}
function openEmojiPicker (ev: MouseEvent): void {
const target = ev.currentTarget as HTMLElement
showPopup(EmojiPopup, { selected: emojiText }, target, (result: { text?: string, image?: unknown } | undefined) => {
if (result?.text === undefined) return
if (result.image !== undefined) return
emojiText = result.text
})
}
async function save (): Promise<void> {
const trimmed = trimWorkspaceMemberStatusMessage(message)
const chosenEmoji = emojiText.trim() !== '' ? emojiText.trim() : presetEmoji(preset)
const finalMessage = `${chosenEmoji} ${trimmed}`.trim()
const empty =
trimmed === '' && chosenEmoji === presetEmoji('custom') && (clearAt === undefined || clearAt <= Date.now())
if (empty) {
if (existing !== undefined) {
await client.remove(existing)
}
finishPopup()
return
}
if (existing !== undefined) {
await client.update(existing, {
message: finalMessage,
...(clearAt !== undefined ? { clearAt } : { $unset: { clearAt: true } })
})
} else {
await client.createDoc(contact.class.WorkspaceMemberStatus, core.space.Workspace, {
space: core.space.Workspace,
user: account.uuid,
message: finalMessage,
...(clearAt !== undefined ? { clearAt } : {})
} as any)
}
finishPopup()
}
async function clearAll (): Promise<void> {
if (existing !== undefined) {
await client.remove(existing)
}
message = ''
preset = 'custom'
emojiText = presetEmoji('custom')
clearAt = undefined
existing = undefined
finishPopup()
}
</script>
<div class="status-modal antiPopup antiPopup-withHeader clear-mins">
<div class="status-modal__header ap-spaceHeader">
<Header type={'type-popup'} adaptive={'disabled'} on:close={finishPopup}>
<div class="status-modal__title fs-title">
<Label label={contactPlugin.string.WorkspaceStatusMenu} />
</div>
</Header>
</div>
<div class="status-modal__body ap-spaceContent flex-col flex-gap-3 p-4">
<div class="status-input-row">
<div class="status-input-row__emoji">
<Button
kind={'ghost'}
size={'large'}
padding={'0 0.25rem'}
minWidth={'2.5rem'}
justify={'center'}
on:click={openEmojiPicker}
>
<svelte:fragment slot="content">
<span class="text-lg leading-none">{emojiText}</span>
</svelte:fragment>
</Button>
</div>
<div class="status-input-grow">
<EditBox
bind:value={message}
placeholder={contactPlugin.string.WorkspaceStatusMessage}
on:change={markCustom}
/>
</div>
</div>
<div class="status-modal__ghost-bordered flex-col flex-gap-2 w-full">
{#each presets as item}
<Button
kind={'ghost'}
size={'large'}
width={'100%'}
justify={'left'}
on:click={() => {
void applyTemplate(item.id)
}}
>
<svelte:fragment slot="content">
<span class="flex-row-center flex-gap-3 min-w-0">
<span class="text-lg leading-none shrink-0" aria-hidden="true">{item.emoji}</span>
<span class="overflow-label"><Label label={item.label} /></span>
</span>
</svelte:fragment>
</Button>
{/each}
</div>
<div class="status-modal__clearAtTitle text-sm content-dark-color">
<Label label={contactPlugin.string.WorkspaceStatusUntil} />
</div>
<div class="status-modal__ghost-bordered w-full">
<Button
kind={'ghost'}
size={'large'}
width={'100%'}
justify={'left'}
on:click={(ev) => {
void openClearModeMenu(ev)
}}
>
<svelte:fragment slot="content">
<span class="overflow-label"><Label label={clearModeLabel} /></span>
</svelte:fragment>
</Button>
</div>
{#if clearAt !== undefined && clearMode === 'pickDate'}
<div class="flex-row-center flex-gap-2 w-full">
<div class="status-modal__ghost-bordered flex-grow min-w-0">
<Button kind={'ghost'} size={'large'} width={'100%'} justify={'left'} on:click={openUntilDatePicker}>
<svelte:fragment slot="content">
<span class="text-sm content-dark-color">{untilPickerDateLabel}</span>
</svelte:fragment>
</Button>
</div>
{#key clearAt}
<input
class="until-row__time ml-auto content-color"
type="time"
value={untilPickerTimeInputValue}
on:change={onTimeChange}
/>
{/key}
</div>
{/if}
</div>
<div class="status-modal__footer ap-spaceContent flex-row-center flex-gap-2 flex-reverse px-4">
<Button
label={contactPlugin.string.WorkspaceStatusSave}
kind={'primary'}
size={'large'}
on:click={() => {
save().catch(() => {})
}}
/>
<div class="status-modal__ghost-bordered">
<Button
label={contactPlugin.string.WorkspaceStatusClear}
kind={'ghost'}
size={'large'}
on:click={() => {
clearAll().catch(() => {})
}}
/>
</div>
</div>
</div>
<style lang="scss">
@media (min-width: 480px) {
.status-modal {
min-width: 22rem;
}
}
.status-modal__header.ap-spaceHeader {
border-bottom: none;
box-shadow: none;
}
.status-modal__header {
padding-bottom: 0;
background: transparent;
}
.status-modal__title {
line-height: 1.25;
}
.status-input-row {
display: flex;
align-items: stretch;
width: 100%;
border: 1px solid var(--theme-divider-color);
border-radius: var(--medium-BorderRadius);
background-color: transparent;
min-height: 2.25rem;
overflow: visible;
transition: border-color 0.15s ease;
}
/* Match default EditBox focus (see packages/theme/styles/editors.scss .default:focus-within) */
.status-input-row:focus-within {
border-color: var(--primary-button-default);
box-shadow: 0 0 0 1px var(--theme-editbox-focus-border);
}
.status-input-row :global(> .antiButton) {
border: none;
border-radius: 0;
box-shadow: none;
align-self: stretch;
height: auto;
}
.status-modal__ghost-bordered :global(.antiButton.ghost) {
border: 1px solid var(--theme-divider-color);
background: transparent;
}
.status-modal__clearAtTitle {
margin-bottom: -0.25rem;
}
.status-input-grow {
flex: 1;
display: flex;
align-items: center;
min-width: 0;
}
.status-input-grow :global(.antiEditBox > .editbox) {
border: none;
background: transparent;
box-shadow: none;
}
.status-input-grow :global(.antiEditBox .antiEditBoxInput) {
outline: none;
}
.status-input-grow :global(.antiEditBox .antiEditBoxInput:focus),
.status-input-grow :global(.antiEditBox .antiEditBoxInput:focus-visible) {
outline: none;
box-shadow: none;
}
.until-row__time {
min-width: 7rem;
min-height: 2.25rem;
border: 1px solid var(--theme-divider-color);
border-radius: var(--small-BorderRadius);
background: transparent;
padding: 0.3rem 0.45rem;
}
:global(html.theme-dark) .until-row__time {
color-scheme: dark;
}
:global(html.theme-light) .until-row__time {
color-scheme: light;
}
.status-modal__footer {
border-top: none;
padding-top: 0.5rem;
padding-bottom: 1rem;
background: transparent;
}
</style>
@@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Employee, Person } from '@hcengineering/contact'
import { Employee, Person, getWorkspaceMemberStatusSubtitle } from '@hcengineering/contact'
import { AccountUuid, Class, Doc, Ref } from '@hcengineering/core'
import { ComponentExtensions, createQuery, getClient, hasResource } from '@hcengineering/presentation'
import { ButtonIcon, Component, navigate } from '@hcengineering/ui'
@@ -24,6 +24,7 @@
import { EmployeePresenter, getPersonByPersonRefStore } from '../../index'
import contact from '../../plugin'
import { employeeByIdStore } from '../../utils'
import { workspaceMemberStatusByAccountStore } from '../../workspaceMemberStatus'
import Avatar from '../Avatar.svelte'
import DeactivatedHeader from './DeactivatedHeader.svelte'
import ModernProfilePopup from './ModernProfilePopup.svelte'
@@ -73,6 +74,11 @@
timezone = await getPersonTimezone(person?.personUuid as AccountUuid)
}
}
$: statusSubtitle =
employee?.personUuid !== undefined
? getWorkspaceMemberStatusSubtitle($workspaceMemberStatusByAccountStore.get(employee.personUuid as AccountUuid))
: undefined
</script>
<ModernProfilePopup {disabled}>
@@ -108,8 +114,21 @@
on:click={viewProfile}
/>
<div class="flex-col flex-gap-0-5 pl-1">
<div class="status-container" />
<EmployeePresenter value={employee} shouldShowAvatar={false} showPopup={false} compact accent />
{#if statusSubtitle}
<div class="status-container max-w-60">
<div class="status-container__text text-normal font-normal content-color overflow-label">
{statusSubtitle}
</div>
</div>
{/if}
<EmployeePresenter
value={employee}
shouldShowAvatar={false}
showPopup={false}
compact
accent
showWorkspaceStatusEmoji={false}
/>
{#if hasRating}
<div class="flex-row-center text-sm">
<Component
@@ -176,7 +195,16 @@
background-color: var(--theme-button-container-color);
}
.status-container {
display: flex;
min-height: 1rem;
display: inline-flex;
align-items: center;
min-height: 1.25rem;
padding: 0.125rem 0.5rem;
border-radius: var(--medium-BorderRadius);
background: color-mix(in srgb, var(--theme-popup-color) 72%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--theme-popup-color) 55%, var(--theme-divider-color));
}
.status-container__text {
line-height: 1.25;
}
</style>
+5 -1
View File
@@ -110,6 +110,7 @@ import SelectAvatars from './components/SelectAvatars.svelte'
import SelectUsersPopup from './components/SelectUsersPopup.svelte'
import SocialEditor from './components/SocialEditor.svelte'
import SpaceMembers from './components/SpaceMembers.svelte'
import WorkspaceMemberStatusEditor from './components/WorkspaceMemberStatusEditor.svelte'
import SpaceMembersEditor from './components/SpaceMembersEditor.svelte'
import SystemAvatar from './components/SystemAvatar.svelte'
import UserBox from './components/UserBox.svelte'
@@ -155,6 +156,7 @@ import {
} from './utils'
export * from './utils'
export * from './workspaceMemberStatus'
export { employeeByIdStore } from './utils'
export * from './assignee'
export * from './translation'
@@ -199,6 +201,7 @@ export {
SelectAvatars,
SelectUsersPopup,
SpaceMembers,
WorkspaceMemberStatusEditor,
SystemAvatar,
UserBox,
UserBoxItems,
@@ -403,7 +406,8 @@ export default async (): Promise<Resources> => ({
PersonIdFilter,
AssigneePopup,
TranslationSettings,
SocialIdentityPresenter
SocialIdentityPresenter,
WorkspaceMemberStatusEditor
},
completion: {
EmployeeQuery: async (
+20 -1
View File
@@ -90,7 +90,26 @@ export default mergeIds(contactId, contact, {
TranslateTo: '' as IntlString,
DontTranslate: '' as IntlString,
SelectLanguage: '' as IntlString,
SelectLanguages: '' as IntlString
SelectLanguages: '' as IntlString,
WorkspaceStatusUntil: '' as IntlString,
WorkspaceStatusMenu: '' as IntlString,
WorkspaceStatusMessage: '' as IntlString,
WorkspaceStatusSetYour: '' as IntlString,
WorkspaceStatusUpdateYour: '' as IntlString,
WorkspaceStatusAway: '' as IntlString,
WorkspaceStatusVacation: '' as IntlString,
WorkspaceStatusOutSick: '' as IntlString,
WorkspaceStatusClear: '' as IntlString,
WorkspaceStatusSave: '' as IntlString,
WorkspaceStatusQuickPick: '' as IntlString,
WorkspaceStatusComposeHint: '' as IntlString,
WorkspaceStatusClearUntil: '' as IntlString,
WorkspaceStatusDoNotClear: '' as IntlString,
WorkspaceStatusIn30Min: '' as IntlString,
WorkspaceStatusIn1Hour: '' as IntlString,
WorkspaceStatusIn4Hours: '' as IntlString,
WorkspaceStatusEndOfDay: '' as IntlString,
WorkspaceStatusPickDate: '' as IntlString
},
function: {
GetContactLink: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
+4
View File
@@ -14,6 +14,8 @@
// limitations under the License.
//
import { loadWorkspaceMemberStatuses } from './workspaceMemberStatus'
import { type AccountClient, getClient as getAccountClientRaw } from '@hcengineering/account-client'
import {
addEmployeeListenrer,
@@ -411,6 +413,8 @@ onClient(() => {
channelProviders.set(res)
})
loadWorkspaceMemberStatuses()
employeesQuery.query(
contact.mixin.Employee,
{},
@@ -0,0 +1,33 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import contact, { type WorkspaceMemberStatus } from '@hcengineering/contact'
import type { AccountUuid } from '@hcengineering/core'
import { writable } from 'svelte/store'
import { createQuery } from '@hcengineering/presentation'
export const workspaceMemberStatusByAccountStore = writable<Map<AccountUuid, WorkspaceMemberStatus>>(new Map())
const workspaceMemberStatusQuery = createQuery(true)
let workspaceStatusesLoaded = false
export function loadWorkspaceMemberStatuses (): void {
if (workspaceStatusesLoaded) return
workspaceStatusesLoaded = true
workspaceMemberStatusQuery.query(contact.class.WorkspaceMemberStatus, {}, (res) => {
workspaceMemberStatusByAccountStore.set(new Map(res.map((it) => [it.user, it])))
})
}
@@ -0,0 +1,47 @@
import {
WORKSPACE_MEMBER_STATUS_MESSAGE_MAX,
extractLeadingStatusEmoji,
getWorkspaceMemberStatusSubtitle,
isWorkspaceMemberStatusVisible,
trimWorkspaceMemberStatusMessage
} from '../workspaceMemberStatusUtils'
describe('workspaceMemberStatusUtils', () => {
it('trimWorkspaceMemberStatusMessage enforces max length', () => {
const long = 'x'.repeat(WORKSPACE_MEMBER_STATUS_MESSAGE_MAX + 50)
expect(trimWorkspaceMemberStatusMessage(long).length).toBe(WORKSPACE_MEMBER_STATUS_MESSAGE_MAX)
expect(trimWorkspaceMemberStatusMessage(' hi ')).toBe('hi')
})
it('trimWorkspaceMemberStatusMessage keeps emoji intact at boundary', () => {
const max = WORKSPACE_MEMBER_STATUS_MESSAGE_MAX
const input = 'a'.repeat(max - 1) + '😀' + 'z'
const out = trimWorkspaceMemberStatusMessage(input)
expect(Array.from(out).length).toBe(max)
expect(out.endsWith('😀')).toBe(true)
})
it('isWorkspaceMemberStatusVisible respects clearAt and empty message', () => {
const now = 1_000_000
expect(isWorkspaceMemberStatusVisible(undefined, now)).toBe(false)
expect(isWorkspaceMemberStatusVisible({ message: '', clearAt: now - 1 }, now)).toBe(false)
expect(isWorkspaceMemberStatusVisible({ message: '' }, now)).toBe(false)
expect(isWorkspaceMemberStatusVisible({ message: 'Hi' }, now)).toBe(true)
expect(isWorkspaceMemberStatusVisible({ message: '🏖️' }, now)).toBe(true)
})
it('getWorkspaceMemberStatusSubtitle returns trimmed message when visible', () => {
const now = 10_000_000
expect(getWorkspaceMemberStatusSubtitle({ message: '🏖️ Beach', clearAt: now + 1000 }, now)).toBe('🏖️ Beach')
expect(getWorkspaceMemberStatusSubtitle({ message: 'Only text' }, now)).toBe('Only text')
expect(getWorkspaceMemberStatusSubtitle({ message: '' }, now)).toBe(undefined)
})
it('extractLeadingStatusEmoji takes first emoji sequence', () => {
expect(extractLeadingStatusEmoji('🏖️ Holiday')).toBe('🏖️')
expect(extractLeadingStatusEmoji('⏳ In a meeting')).toBe('⏳')
expect(extractLeadingStatusEmoji('No emoji')).toBe(undefined)
expect(extractLeadingStatusEmoji('')).toBe(undefined)
expect(extractLeadingStatusEmoji('👋')).toBe('👋')
})
})
+24 -3
View File
@@ -217,6 +217,17 @@ export interface Translation extends Preference {
dontTranslate: string[]
}
/**
* Persistent user status in workspace (e.g. vacation, short note).
* @public
*/
export interface WorkspaceMemberStatus extends Doc {
space: Ref<Space>
user: AccountUuid
message: string
clearAt?: Timestamp
}
/**
* @public
*/
@@ -236,7 +247,8 @@ export const contactPlugin = plugin(contactId, {
SocialIdentity: '' as Ref<Class<SocialIdentity>>,
UserProfile: '' as Ref<MasterTag>,
UserRole: '' as Ref<Class<UserRole>>,
Translation: '' as Ref<Class<Translation>>
Translation: '' as Ref<Class<Translation>>,
WorkspaceMemberStatus: '' as Ref<Class<WorkspaceMemberStatus>>
},
mixin: {
Employee: '' as Ref<Class<Employee>>
@@ -264,7 +276,8 @@ export const contactPlugin = plugin(contactId, {
PersonFilterValuePresenter: '' as AnyComponent,
PersonIdFilter: '' as AnyComponent,
AssigneePopup: '' as AnyComponent,
EmployeePresenter: '' as AnyComponent
EmployeePresenter: '' as AnyComponent,
WorkspaceMemberStatusEditor: '' as AnyComponent
},
channelProvider: {
Email: '' as Ref<ChannelProvider>,
@@ -378,7 +391,8 @@ export const contactPlugin = plugin(contactId, {
HereDescription: '' as IntlString,
Guest: '' as IntlString,
Deleted: '' as IntlString,
Email: '' as IntlString
Email: '' as IntlString,
WorkspaceStatusNote: '' as IntlString
},
viewlet: {
TableMember: '' as Ref<Viewlet>,
@@ -429,3 +443,10 @@ export * from './types'
export * from './utils'
export * from './analytics'
export * from './avatar'
export {
WORKSPACE_MEMBER_STATUS_MESSAGE_MAX,
trimWorkspaceMemberStatusMessage,
isWorkspaceMemberStatusVisible,
getWorkspaceMemberStatusSubtitle,
extractLeadingStatusEmoji
} from './workspaceMemberStatusUtils'
@@ -0,0 +1,94 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
/** @public */
export const WORKSPACE_MEMBER_STATUS_MESSAGE_MAX = 200
/**
* Minimal shape for status helpers (see {@link WorkspaceMemberStatus}).
*/
interface WorkspaceMemberStatusLike {
message: string
clearAt?: number
}
/** @public */
export function trimWorkspaceMemberStatusMessage (message: string): string {
const trimmed = message.trim()
return Array.from(trimmed).slice(0, WORKSPACE_MEMBER_STATUS_MESSAGE_MAX).join('')
}
/** @public */
export function isWorkspaceMemberStatusVisible (
s: WorkspaceMemberStatusLike | undefined,
now: number = Date.now()
): boolean {
if (s === undefined) return false
if (s.clearAt !== undefined && s.clearAt <= now) return false
const m = s.message?.trim() ?? ''
return m !== ''
}
/**
* Plain-text line for member lists / tooltips (full stored message, including any leading emoji).
* @public
*/
export function getWorkspaceMemberStatusSubtitle (
s: WorkspaceMemberStatusLike | undefined,
now: number = Date.now()
): string | undefined {
if (!isWorkspaceMemberStatusVisible(s, now)) return undefined
return s?.message?.trim() ?? ''
}
const EMOJI_HEAD = /^\p{Extended_Pictographic}/u
/**
* Returns the first emoji sequence at the start of the status message (after trim), or undefined.
* Handles variation selectors, skin tones, and ZWJ sequences (e.g. family emoji).
* @public
*/
export function extractLeadingStatusEmoji (raw: string | undefined): string | undefined {
const text = raw?.trim() ?? ''
if (text === '') return undefined
try {
const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
const segments = [...seg.segment(text)].map((x) => x.segment)
if (segments.length === 0) return undefined
if (!EMOJI_HEAD.test(segments[0])) return undefined
let out = segments[0]
let i = 1
while (i < segments.length) {
const s = segments[i]
if (s === '\u200D' && i + 1 < segments.length && EMOJI_HEAD.test(segments[i + 1])) {
out += s + segments[i + 1]
i += 2
continue
}
if (s === '\uFE0F' || /^[\u{1F3FB}-\u{1F3FF}]$/u.test(s)) {
out += s
i++
continue
}
break
}
return out
} catch {
const m = text.match(/^(\p{Extended_Pictographic})/u)
return m?.[1]
}
}
@@ -13,8 +13,13 @@
// limitations under the License.
-->
<script lang="ts">
import contact, { formatName, getCurrentEmployee } from '@hcengineering/contact'
import { myEmployeeStore } from '@hcengineering/contact-resources'
import contact, { formatName, getCurrentEmployee, isWorkspaceMemberStatusVisible } from '@hcengineering/contact'
import {
myEmployeeStore,
loadWorkspaceMemberStatuses,
workspaceMemberStatusByAccountStore
} from '@hcengineering/contact-resources'
import contactResources from '@hcengineering/contact-resources/src/plugin'
import core, { AccountRole, getCurrentAccount, hasAccountRole } from '@hcengineering/core'
import rating, { type PersonRating } from '@hcengineering/rating'
import login, { loginId } from '@hcengineering/login'
@@ -60,6 +65,7 @@
)
onMount(() => {
loadWorkspaceMemberStatuses()
void getResource(setting.function.HasRoleCapability).then((checkCapability) => {
if (checkCapability != null) {
void checkCapability(RoleCapability.GenerateInviteLink).then((v: boolean) => {
@@ -70,6 +76,10 @@
})
$: person = $myEmployeeStore
$: ownWorkspaceStatus = $workspaceMemberStatusByAccountStore.get(account.uuid)
$: workspaceStatusActionLabel = isWorkspaceMemberStatusVisible(ownWorkspaceStatus)
? contactResources.string.WorkspaceStatusUpdateYour
: contactResources.string.WorkspaceStatusSetYour
function selectCategory (sp?: SettingsCategory): void {
closePopup()
@@ -142,6 +152,16 @@
}
})
}
if (hasAccountRole(account, AccountRole.User)) {
actions.push({
icon: contact.icon.User,
label: workspaceStatusActionLabel,
action: async () => {
closePopup()
showPopup(contact.component.WorkspaceMemberStatusEditor, {}, 'middle')
}
})
}
actions.push(...getMenu(items, ['main']))
if (hasAccountRole(account, AccountRole.User) && !isDisabled('invites') && canGenerateInviteLink) {
actions.push({