Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artem Savchenko
2026-04-17 14:48:21 +07:00
55 changed files with 1740 additions and 97 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
@@ -593,7 +593,7 @@ export interface ModulePermissionGroup extends Doc {
role: AccountRole
permissions: Ref<Permission>[]
disabledPermissions?: Ref<Permission>[]
spaceClass: Ref<Class<Space>>
spaceClass?: Ref<Class<Space>>
enabled: boolean
order?: number
}
+2 -2
View File
@@ -76,7 +76,7 @@ export function createModel (builder: Builder): void {
application: chunter.app.Chunter,
role: AccountRole.Guest,
permissions: [],
spaceClass: chunter.class.ChunterSpace,
spaceClass: chunter.class.Channel,
enabled: true,
order: 30
},
@@ -90,7 +90,7 @@ export function createModel (builder: Builder): void {
application: chunter.app.Chunter,
role: AccountRole.ReadOnlyGuest,
permissions: [],
spaceClass: chunter.class.ChunterSpace,
spaceClass: chunter.class.Channel,
enabled: true,
order: 15
},
+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 }]
+10 -3
View File
@@ -96,12 +96,19 @@ function defineRelationMetadata (builder: Builder): void {
rel(documents.class.Document, 'category', documents.class.DocumentCategory, 'forward')
rel(documents.class.Document, 'template', documents.class.Document, 'forward')
rel(documents.class.Document, 'meta', documents.class.ProjectMeta, 'inverse')
rel(documents.class.Document, 'document', documents.class.ProjectDocument, 'inverse')
rel(documents.class.HierarchyDocument, 'attachedTo', documents.class.DocumentMeta, 'forward')
rel(documents.class.ControlledDocument, 'changeControl', documents.class.ChangeControl, 'forward')
rel(documents.class.ProjectMeta, 'meta', documents.class.ProjectMeta, 'inverse')
rel(documents.class.ProjectMeta, 'document', documents.class.ProjectDocument, 'inverse')
rel(documents.class.DocumentMeta, 'meta', documents.class.ProjectMeta, 'inverse')
rel(documents.class.Project, 'project', documents.class.ProjectMeta, 'inverse')
rel(documents.class.ProjectMeta, 'meta', documents.class.DocumentMeta, 'forward')
rel(documents.class.ProjectMeta, 'parent', documents.class.DocumentMeta, 'forward')
rel(documents.class.ProjectMeta, 'path', documents.class.DocumentMeta, 'forward')
rel(documents.class.ProjectMeta, 'project', documents.class.Project, 'forward')
rel(documents.class.ProjectDocument, 'project', documents.class.Project, 'forward')
rel(documents.class.ProjectDocument, 'initial', documents.class.Project, 'forward')
rel(documents.class.ProjectDocument, 'attachedTo', documents.class.ProjectMeta, 'forward')
rel(documents.class.ProjectDocument, 'document', documents.class.Document, 'forward')
}
export function createModel (builder: Builder): void {
-2
View File
@@ -273,7 +273,6 @@ export function createModel (builder: Builder): void {
application: love.app.Love,
role: AccountRole.Guest,
permissions: [],
spaceClass: love.class.Office,
enabled: true,
order: 50
},
@@ -287,7 +286,6 @@ export function createModel (builder: Builder): void {
application: love.app.Love,
role: AccountRole.ReadOnlyGuest,
permissions: [],
spaceClass: love.class.Office,
enabled: false,
order: 50
},
+2 -2
View File
@@ -220,7 +220,7 @@ export function createModel (builder: Builder): void {
application: time.app.Me,
role: AccountRole.Guest,
permissions: [],
spaceClass: core.class.TypedSpace,
spaceClass: tracker.class.Project,
enabled: true,
order: 55
},
@@ -234,7 +234,7 @@ export function createModel (builder: Builder): void {
application: time.app.Me,
role: AccountRole.ReadOnlyGuest,
permissions: [],
spaceClass: core.class.TypedSpace,
spaceClass: tracker.class.Project,
enabled: false,
order: 55
},
-2
View File
@@ -917,7 +917,6 @@ function defineSettings (builder: Builder): void {
application: training.app.Training,
role: AccountRole.Guest,
permissions: [training.ids.GuestTrainingAttemptClassPermission],
spaceClass: core.class.TypedSpace,
enabled: true,
order: 25
},
@@ -931,7 +930,6 @@ function defineSettings (builder: Builder): void {
application: training.app.Training,
role: AccountRole.ReadOnlyGuest,
permissions: [],
spaceClass: core.class.TypedSpace,
enabled: false,
order: 25
},
@@ -80,9 +80,9 @@
</div>
<img class="image" src={getMetadata(calendar.image.Permissions)} alt="" />
<div class="footer">
<Button label={calendar.string.Connect} kind={'primary'} disabled={connecting} on:click={sendRequest} />
</div>
</div>
<div class="footer">
<Button label={calendar.string.Connect} kind={'primary'} disabled={connecting} on:click={sendRequest} />
</div>
</div>
@@ -91,9 +91,12 @@
position: relative;
display: flex;
flex-direction: column;
width: 40rem;
min-width: 40rem;
max-width: 40rem;
width: min(40rem, calc(100vw - 2rem));
min-width: 0;
max-width: min(40rem, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
max-height: calc(100dvh - 2rem);
overflow: hidden;
background-color: var(--popup-bg-hover);
border-radius: 0.75rem;
box-shadow: var(--popup-shadow);
@@ -114,10 +117,14 @@
}
.content {
flex-shrink: 0;
flex-grow: 1;
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
color: var(--theme-content-color);
margin: 0 1.75rem 0.5rem;
margin: 0 1.75rem;
padding-bottom: 0.5rem;
}
.image {
@@ -127,11 +134,14 @@
}
.footer {
flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
padding: 1rem 1.75rem 1.25rem;
margin-top: auto;
background-color: var(--popup-bg-hover);
}
}
</style>
+1 -1
View File
@@ -777,7 +777,7 @@ class Connection implements ClientConnection {
params: [_class, query, options],
measure: (time, result, serverTime, queue, toReceive) => {
if (typeof window !== 'undefined' && (time > 1000 || serverTime > 500)) {
console.error(
console.warn(
'measure slow findAll',
time,
serverTime,
+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]
}
}
+2
View File
@@ -66,6 +66,8 @@ export interface TransformConfig {
}
export interface RelationDefinition {
/** When set, this relation applies only to documents of this class (or its subclasses). */
sourceClass?: Ref<Class<Doc>>
field: string
class: Ref<Class<Doc>>
direction?: 'forward' | 'inverse'
+2
View File
@@ -174,6 +174,8 @@
"GuestSignUpDescription": "Umožňuje anonymním uživatelům připojit se k vašemu pracovnímu prostoru jako hosté s omezenými editačními právy",
"GuestChannelsDescription": "Kanály, kde hosté mohou psát zprávy po připojení",
"GuestChannelsArrayLabel": "Vybrat kanály",
"GuestAutoJoinAvailableSpaces": "Dostupné prostory",
"GuestAutoJoinAvailableSpacesHint": "Každá karta aplikace má vlastní řádek „Dostupné prostory“ pro automatické připojení hostů; změny se projeví okamžitě.",
"ManageIdentities": "Spravovat identity",
"Release": "Uvolnění",
"ReleaseSocialId": "Uvolnit sociální ID",
+2
View File
@@ -174,6 +174,8 @@
"GuestSignUpDescription": "Ermöglicht anonymen Benutzern den Beitritt zu Ihrem Arbeitsbereich als Gäste mit eingeschränkten Bearbeitungsrechten",
"GuestChannelsDescription": "Kanäle, in denen Gäste nach dem Beitritt Nachrichten schreiben können",
"GuestChannelsArrayLabel": "Kanäle auswählen",
"GuestAutoJoinAvailableSpaces": "Verfügbare Bereiche",
"GuestAutoJoinAvailableSpacesHint": "Jede Anwendungskarte hat eine eigene Zeile „Verfügbare Bereiche“ für den automatischen Gästebeitritt; Änderungen gelten sofort.",
"ManageIdentities": "Identitäten verwalten",
"Release": "Freigeben",
"ReleaseSocialId": "Social ID freigeben",
+2
View File
@@ -174,6 +174,8 @@
"GuestSignUpDescription": "Allows anonymous users to join your workspace as guests with limited editing rights",
"GuestChannelsDescription": "Channels where guests can write messages after joining",
"GuestChannelsArrayLabel": "Select channels",
"GuestAutoJoinAvailableSpaces": "Available spaces",
"GuestAutoJoinAvailableSpacesHint": "Each application card has its own “Available spaces” row to pick where workspace guests are added on activation. Changes apply immediately.",
"ManageIdentities": "Manage identities",
"Release": "Release",
"ReleaseSocialId": "Release social ID",
+2
View File
@@ -165,6 +165,8 @@
"GuestSignUpDescription": "Permite a usuarios anónimos unirse a su espacio de trabajo como invitados con derechos de edición limitados",
"GuestChannelsDescription": "Canales donde los invitados pueden escribir mensajes después de unirse",
"GuestChannelsArrayLabel": "Seleccionar canales",
"GuestAutoJoinAvailableSpaces": "Espacios disponibles",
"GuestAutoJoinAvailableSpacesHint": "Cada tarjeta de aplicación tiene su propia fila « Espacios disponibles » para la adhesión automática de invitados; los cambios son inmediatos.",
"ManageIdentities": "Gestionar identidades",
"Release": "Liberar",
"ReleaseSocialId": "Liberar ID social",
+2
View File
@@ -174,6 +174,8 @@
"GuestSignUpDescription": "Permet aux utilisateurs anonymes de rejoindre votre espace de travail en tant qu'invités avec des droits d'édition limités",
"GuestChannelsDescription": "Canaux où les invités peuvent écrire des messages après leur adhésion",
"GuestChannelsArrayLabel": "Sélectionner des canaux",
"GuestAutoJoinAvailableSpaces": "Espaces disponibles",
"GuestAutoJoinAvailableSpacesHint": "Chaque carte dapplication a sa propre ligne « Espaces disponibles » pour ladhésion automatique des invités ; les changements sont immédiats.",
"ManageIdentities": "Gérer les identités",
"Release": "Libération",
"ReleaseSocialId": "Libérer l'ID social",
+2
View File
@@ -174,6 +174,8 @@
"GuestSignUpDescription": "Consente agli utenti anonimi di unirsi al tuo spazio di lavoro come ospiti con diritti di modifica limitati",
"GuestChannelsDescription": "Canali in cui gli ospiti possono scrivere messaggi dopo essersi uniti",
"GuestChannelsArrayLabel": "Seleziona canali",
"GuestAutoJoinAvailableSpaces": "Spazi disponibili",
"GuestAutoJoinAvailableSpacesHint": "Ogni scheda applicazione ha la propria riga « Spazi disponibili » per lingresso automatico degli ospiti; le modifiche sono immediate.",
"ManageIdentities": "Gestisci identità",
"Release": "Rilascio",
"ReleaseSocialId": "Rilascia ID social",
+2
View File
@@ -174,6 +174,8 @@
"GuestSignUpDescription": "匿名ユーザーがゲストとしてワークスペースに参加し、制限された編集権限を得られるようにします",
"GuestChannelsDescription": "参加後、ゲストがメッセージを書き込めるチャンネル",
"GuestChannelsArrayLabel": "チャンネルを選択",
"GuestAutoJoinAvailableSpaces": "利用可能なスペース",
"GuestAutoJoinAvailableSpacesHint": "各アプリのカードにゲスト自動参加用の「利用可能なスペース」行があります。変更はすぐに反映されます。",
"ManageIdentities": "IDを管理",
"Release": "リリース",
"ReleaseSocialId": "ソーシャルIDをリリース",
+2
View File
@@ -165,6 +165,8 @@
"GuestSignUpDescription": "Permite que usuários anônimos entrem em seu espaço de trabalho como convidados com direitos de edição limitados",
"GuestChannelsDescription": "Canais onde convidados podem escrever mensagens após entrar",
"GuestChannelsArrayLabel": "Selecionar canais",
"GuestAutoJoinAvailableSpaces": "Espaços disponíveis",
"GuestAutoJoinAvailableSpacesHint": "Cada cartão de app tem sua própria linha « Espaços disponíveis » para adesão automática de convidados; as alterações são aplicadas na hora.",
"ManageIdentities": "Gerenciar identidades",
"Release": "Liberação",
"ReleaseSocialId": "Liberar ID social",
+2
View File
@@ -165,6 +165,8 @@
"GuestSignUpDescription": "Permite que usuários anônimos entrem em seu espaço de trabalho como convidados com direitos de edição limitados",
"GuestChannelsDescription": "Canais onde convidados podem escrever mensagens após ingressar",
"GuestChannelsArrayLabel": "Selecionar canais",
"GuestAutoJoinAvailableSpaces": "Espaços disponíveis",
"GuestAutoJoinAvailableSpacesHint": "Cada cartão de aplicação tem a sua própria linha « Espaços disponíveis » para adesão automática de convidados; as alterações aplicam-se de imediato.",
"ManageIdentities": "Gerenciar identidades",
"Release": "Libertação",
"ReleaseSocialId": "Liberar ID social",
+2
View File
@@ -179,6 +179,8 @@
"GuestSignUpDescription": "Позволяет анонимным пользователям присоединяться к вашему рабочему пространству в качестве гостей с ограниченными правами редактирования",
"GuestChannelsDescription": "Каналы, в которых гости могут писать сообщения после присоединения",
"GuestChannelsArrayLabel": "Выбрать каналы",
"GuestAutoJoinAvailableSpaces": "Доступные пространства",
"GuestAutoJoinAvailableSpacesHint": "У каждого приложения ниже своя строка «Доступные пространства»: выберите, куда при активации добавлять гостей воркспейса. Изменения применяются сразу.",
"ManageIdentities": "Управление идентификаторами",
"Release": "Освободить",
"ReleaseSocialId": "Освободить социальный ID",
+2
View File
@@ -174,6 +174,8 @@
"GuestSignUpDescription": "Anonim kullanıcıların çalışma alanınıza sınırlı düzenleme haklarıyla misafir olarak katılmasına izin verir",
"GuestChannelsDescription": "Misafirlerin katıldıktan sonra mesaj yazabileceği kanallar",
"GuestChannelsArrayLabel": "Kanalları seç",
"GuestAutoJoinAvailableSpaces": "Kullanılabilir alanlar",
"GuestAutoJoinAvailableSpacesHint": "Her uygulama kartının misafir otomatik katılımı için kendi « Kullanılabilir alanlar » satırı vardır; değişiklikler hemen uygulanır.",
"ManageIdentities": "Kimlikleri yönet",
"Release": "Serbest bırak",
"ReleaseSocialId": "Sosyal ID'yi serbest bırak",
+2
View File
@@ -174,6 +174,8 @@
"GuestSignUpDescription": "允许匿名用户以访客身份加入您的工作区,获得有限的编辑权限",
"GuestChannelsDescription": "访客加入后可以发送消息的频道",
"GuestChannelsArrayLabel": "选择频道",
"GuestAutoJoinAvailableSpaces": "可用空间",
"GuestAutoJoinAvailableSpacesHint": "每个应用卡片都有各自的「可用空间」行,用于选择在激活时添加工作区访客的位置;更改会立即生效。",
"ManageIdentities": "管理身份",
"Release": "释放",
"ReleaseSocialId": "释放社交ID",
@@ -0,0 +1,143 @@
<!--
// 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 { Analytics } from '@hcengineering/analytics'
import {
AccountRole,
setWorkspaceGuestAutoJoinRoles,
type ModulePermissionGroup,
type Space
} from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { DropdownLabels, type DropdownTextItem } from '@hcengineering/ui'
import settingsRes from '../plugin'
export let group: ModulePermissionGroup
export let disabled = false
const client = getClient()
let loading = true
let saving = false
let loadedGroupId: string | undefined
let dropdownItems: DropdownTextItem[] = []
let selectedKeys: string[] = []
let persistedSelectedKeys: string[] = []
let docById: Map<string, Space> = new Map<string, Space>()
function normalizeAutoJoinForRoles (roles: AccountRole[]): AccountRole[] | undefined {
return roles.length > 0 ? [...roles] : undefined
}
async function loadSpaces (): Promise<void> {
if (group.spaceClass === undefined) {
dropdownItems = []
selectedKeys = []
persistedSelectedKeys = []
docById = new Map<string, Space>()
loading = false
return
}
loading = true
try {
const res = await client.findAll(group.spaceClass, { archived: false })
docById = new Map(res.map((d) => [d._id, d as Space]))
const docs = [...docById.values()]
dropdownItems = docs.map((d) => ({ id: d._id, label: d.name })).sort((a, b) => a.label.localeCompare(b.label))
selectedKeys = docs
.filter((d) => d.autoJoinForRoles?.includes(AccountRole.Guest) === true)
.map((d) => d._id)
.sort()
persistedSelectedKeys = [...selectedKeys]
} catch (err) {
Analytics.handleError(err as Error)
dropdownItems = []
selectedKeys = []
persistedSelectedKeys = []
docById = new Map<string, Space>()
} finally {
loading = false
}
}
async function handleSelected (e: CustomEvent<string[]>): Promise<void> {
const nextKeys = new Set(e.detail)
const prevKeys = new Set(persistedSelectedKeys)
const toEnable = [...nextKeys].filter((k) => !prevKeys.has(k))
const toDisable = [...prevKeys].filter((k) => !nextKeys.has(k))
if (toEnable.length === 0 && toDisable.length === 0) {
return
}
saving = true
try {
const ops: Array<Promise<unknown>> = []
for (const key of toEnable) {
const doc = docById.get(key)
if (doc === undefined) continue
const roles = setWorkspaceGuestAutoJoinRoles(doc.autoJoinForRoles, true)
const autoJoinForRoles = normalizeAutoJoinForRoles(roles)
if (autoJoinForRoles === undefined) {
ops.push(client.update(doc, { $unset: { autoJoinForRoles: true } } as any))
} else {
ops.push(client.diffUpdate(doc, { autoJoinForRoles }))
}
}
for (const key of toDisable) {
const doc = docById.get(key)
if (doc === undefined) continue
const roles = setWorkspaceGuestAutoJoinRoles(doc.autoJoinForRoles, false)
const autoJoinForRoles = normalizeAutoJoinForRoles(roles)
if (autoJoinForRoles === undefined) {
ops.push(client.update(doc, { $unset: { autoJoinForRoles: true } } as any))
} else {
ops.push(client.diffUpdate(doc, { autoJoinForRoles }))
}
}
await Promise.all(ops)
selectedKeys = [...nextKeys].sort()
persistedSelectedKeys = [...selectedKeys]
} catch (err) {
Analytics.handleError(err as Error)
selectedKeys = [...persistedSelectedKeys]
await loadSpaces()
} finally {
saving = false
}
}
$: if (group._id !== loadedGroupId) {
loadedGroupId = group._id
void loadSpaces()
}
</script>
<DropdownLabels
multiselect
autoSelect={false}
items={dropdownItems}
selected={[...(selectedKeys ?? [])]}
label={settingsRes.string.GuestAutoJoinAvailableSpaces}
showDropdownIcon
kind={'no-border'}
size={'large'}
width={'min(18rem, 100%)'}
disabled={disabled || loading || saving || dropdownItems.length === 0}
loading={saving}
on:selected={handleSelected}
/>
@@ -48,6 +48,7 @@
} from '@hcengineering/ui'
import setting from '@hcengineering/setting'
import { onMount } from 'svelte'
import AvailableSpacesInput from './AvailableSpacesInput.svelte'
import settingsRes from '../plugin'
let loadingSettings = true
@@ -244,6 +245,14 @@
allowGuestSignUp = e.detail
}
function onReadonlyGuestsToggle (e: CustomEvent<boolean>): void {
void handleToggleReadonlyAccess(e)
}
function onGuestSignUpToggle (e: CustomEvent<boolean>): void {
void handleToggleGuestSignUp(e)
}
async function onAllowedCardsChange (value: Ref<Card>[]): Promise<void> {
if (existingGuestChatSettings === undefined) {
await client.createDoc(communication.class.GuestCommunicationSettings, core.space.Workspace, {
@@ -317,12 +326,7 @@
<Label label={settingsRes.string.GuestAccessDescription} />
</div>
<div class="guestAccessRow-toggleCell">
<Toggle
on={allowReadOnlyGuests}
on:change={(e) => {
void handleToggleReadonlyAccess(e)
}}
/>
<Toggle on={allowReadOnlyGuests} on:change={onReadonlyGuestsToggle} />
</div>
</div>
<div class="guestAccessRow">
@@ -330,13 +334,7 @@
<Label label={settingsRes.string.GuestSignUpDescription} />
</div>
<div class="guestAccessRow-toggleCell">
<Toggle
disabled={!allowReadOnlyGuests}
on={allowGuestSignUp}
on:change={(e) => {
void handleToggleGuestSignUp(e)
}}
/>
<Toggle disabled={!allowReadOnlyGuests} on={allowGuestSignUp} on:change={onGuestSignUpToggle} />
</div>
</div>
{#if communicationApiEnabled}
@@ -376,6 +374,14 @@
<Label label={setting.string.GuestPermissionsApplicationPermissionsHint} />
{/if}
</div>
{#if guestPermissionsTab === 'guest'}
<div class="sectionHint">
<Label label={core.string.AutoJoinGuestsDescr} />
</div>
<div class="sectionHint">
<Label label={settingsRes.string.GuestAutoJoinAvailableSpacesHint} />
</div>
{/if}
</div>
<div class="cardStack" class:cardStack-readonly={anonymousModulePermissionsReadOnly}>
@@ -383,10 +389,15 @@
{@const app = getApplication(group.application)}
{@const moduleOn = isModuleEnabled(group)}
{@const permissionCount = (group.permissions ?? []).length}
{@const hasGuestAutoJoinRow =
guestPermissionsTab === 'guest' &&
group.role === AccountRole.Guest &&
group.spaceClass !== undefined}
{@const hasPermissionRowsBlock = permissionCount > 0 || hasGuestAutoJoinRow}
<div class="permissionModuleCard" class:permissionModuleCard-off={!moduleOn}>
<div
class="permissionModuleCard-header"
class:permissionModuleCard-headerOnly={permissionCount === 0}
class:permissionModuleCard-headerOnly={!hasPermissionRowsBlock}
>
<div class="permissionModuleCard-headerMain">
{#if app}
@@ -411,22 +422,37 @@
</div>
</div>
{#if permissionCount > 0}
{#if hasPermissionRowsBlock}
<div class="permissionRows">
{#each group.permissions ?? [] as permissionId}
<div class="permissionRow">
<div class="permissionRow-label">
<Label label={getPermissionLabel(permissionId)} />
{#if permissionCount > 0}
{#each group.permissions ?? [] as permissionId}
<div class="permissionRow">
<div class="permissionRow-label">
<Label label={getPermissionLabel(permissionId)} />
</div>
<div class="permissionRow-toggleCell">
<Toggle
disabled={!moduleOn || anonymousModulePermissionsReadOnly}
on={isPermissionActive(group, permissionId)}
on:change={handlePermissionToggle(group, permissionId)}
/>
</div>
</div>
<div class="permissionRow-toggleCell">
<Toggle
{/each}
{/if}
{#if hasGuestAutoJoinRow}
<div class="permissionRow permissionRow--guestSpaces">
<div class="permissionRow-label">
<Label label={settingsRes.string.GuestAutoJoinAvailableSpaces} />
</div>
<div class="permissionRow-editorCell">
<AvailableSpacesInput
{group}
disabled={!moduleOn || anonymousModulePermissionsReadOnly}
on={isPermissionActive(group, permissionId)}
on:change={handlePermissionToggle(group, permissionId)}
/>
</div>
</div>
{/each}
{/if}
</div>
{/if}
</div>
@@ -644,6 +670,24 @@
color: var(--theme-content-color);
}
.permissionRow--guestSpaces {
align-items: start;
min-height: auto;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
grid-template-columns: minmax(0, 1fr) minmax(8rem, max-content);
}
.permissionRow--guestSpaces .permissionRow-editorCell {
grid-column: 2;
justify-self: end;
align-self: start;
width: max-content;
max-width: min(18rem, calc(100vw - 4rem));
min-width: 0;
overflow: visible;
}
.emptyState {
font-size: 0.875rem;
color: var(--theme-halfcontent-color);
+2
View File
@@ -137,6 +137,8 @@ export default mergeIds(settingId, setting, {
GuestSignUpDescription: '' as IntlString,
GuestChannelsDescription: '' as IntlString,
GuestChannelsArrayLabel: '' as IntlString,
GuestAutoJoinAvailableSpaces: '' as IntlString,
GuestAutoJoinAvailableSpacesHint: '' as IntlString,
ApiAccess: '' as IntlString,
ApiToken: '' as IntlString,
GenerateApiToken: '' as IntlString,
@@ -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({
@@ -0,0 +1,250 @@
/* eslint-disable @typescript-eslint/unbound-method */
//
// 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 {
Class,
type Doc,
type Hierarchy,
type LowLevelStorage,
MeasureMetricsContext,
type Ref,
type Space
} from '@hcengineering/core'
import { RelationExporter } from '../workspace/relation-exporter'
import type { ExportState, RelationDefinition } from '../workspace/types'
const spaceId = '69286daacb49b698d3ea2c51' as Ref<Space>
const classParent = 'test:class:ParentDoc' as Ref<Class<Doc>>
const classChild = 'test:class:ChildDoc' as Ref<Class<Doc>>
const classUnrelated = 'test:class:UnrelatedDoc' as Ref<Class<Doc>>
const classTarget = 'test:class:TargetDoc' as Ref<Class<Doc>>
function doc (id: string, _class: Ref<Class<Doc>>, extra: Record<string, any> = {}): Doc {
return {
_id: id as Ref<Doc>,
_class,
space: spaceId,
modifiedOn: 0,
modifiedBy: 'test:account:user' as any,
...extra
}
}
function hierarchyWithDerivation (childDerivesParent: boolean): Hierarchy {
return {
findDomain: jest.fn((classRef: Ref<Class<Doc>>) => `domain:${String(classRef)}`),
isDerived: jest.fn((cls: Ref<Class<Doc>>, base: Ref<Class<Doc>>) => {
if (cls === base) return true
if (childDerivesParent && cls === classChild && base === classParent) return true
return false
}),
getAllAttributes: jest.fn(() => new Map()),
isMixin: jest.fn(() => false),
hasMixin: jest.fn(),
getClass: jest.fn(() => ({ label: 'Test' }))
} as unknown as Hierarchy
}
function lowLevelForDocs (...docs: Doc[]): LowLevelStorage {
const byDomain = new Map<string, Doc[]>()
for (const d of docs) {
const domain = `domain:${String(d._class)}`
const list = byDomain.get(domain) ?? []
list.push(d)
byDomain.set(domain, list)
}
return {
rawFindAll: jest.fn(async <T extends Doc>(_domain: string, query: any): Promise<T[]> => {
const domainDocs = byDomain.get(_domain) ?? []
return domainDocs.filter((d) => {
if (query._id !== undefined && d._id !== query._id) return false
for (const [key, val] of Object.entries(query)) {
if (key.startsWith('_')) continue
if ((d as any)[key] !== val) return false
}
return true
}) as T[]
})
} as unknown as LowLevelStorage
}
function createExporter (exportDocument: jest.Mock): { exporter: RelationExporter, state: ExportState } {
const state: ExportState = {
idMapping: new Map(),
spaceMapping: new Map(),
processingDocs: new Set(),
uniqueFieldValues: new Map()
}
const ctx = new MeasureMetricsContext('relation-exporter-test', {})
const exporter = new RelationExporter(ctx, state, exportDocument as any)
return { exporter, state }
}
describe('RelationExporter sourceClass', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('runs forward relation when doc class is derived from sourceClass', async () => {
const parentId = '69286dc0cb49b698d3ea2c95' as Ref<Doc>
const parent = doc(parentId, classTarget, { name: 'parent' })
const child = doc('69286dc1cb49b698d3ea2c98', classChild, { folder: parentId })
const exportDocument = jest.fn().mockResolvedValue(true)
const { exporter } = createExporter(exportDocument)
const hierarchy = hierarchyWithDerivation(true)
const lowLevel = lowLevelForDocs(parent)
const relations: RelationDefinition[] = [
{
sourceClass: classParent,
field: 'folder',
class: classTarget,
direction: 'forward'
}
]
await exporter.exportForwardRelations(child, relations, 'duplicate', true, hierarchy, lowLevel)
expect(exportDocument).toHaveBeenCalledTimes(1)
expect(exportDocument).toHaveBeenCalledWith(
parent,
'duplicate',
true,
hierarchy,
lowLevel,
expect.any(Map),
relations
)
})
it('skips forward relation when doc class is not derived from sourceClass', async () => {
const parentId = '69286dc0cb49b698d3ea2c95' as Ref<Doc>
const parent = doc(parentId, classTarget, {})
const unrelated = doc('69286dc1cb49b698d3ea2c98', classUnrelated, { folder: parentId })
const exportDocument = jest.fn().mockResolvedValue(true)
const { exporter } = createExporter(exportDocument)
const hierarchy = hierarchyWithDerivation(true)
const lowLevel = lowLevelForDocs(parent)
const relations: RelationDefinition[] = [
{
sourceClass: classParent,
field: 'folder',
class: classTarget,
direction: 'forward'
}
]
await exporter.exportForwardRelations(unrelated, relations, 'duplicate', true, hierarchy, lowLevel)
expect(exportDocument).not.toHaveBeenCalled()
})
it('runs forward relation without sourceClass for any doc (backward compatible)', async () => {
const refId = '69286dc0cb49b698d3ea2c95' as Ref<Doc>
const target = doc(refId, classTarget, {})
const anyDoc = doc('69286dc1cb49b698d3ea2c98', classUnrelated, { link: refId })
const exportDocument = jest.fn().mockResolvedValue(true)
const { exporter } = createExporter(exportDocument)
const hierarchy = hierarchyWithDerivation(false)
jest.mocked(hierarchy.findDomain).mockImplementation((c) => `domain:${String(c)}` as any)
const lowLevel = lowLevelForDocs(target)
const relations: RelationDefinition[] = [{ field: 'link', class: classTarget, direction: 'forward' }]
await exporter.exportForwardRelations(anyDoc, relations, 'duplicate', true, hierarchy, lowLevel)
expect(exportDocument).toHaveBeenCalledTimes(1)
expect(exportDocument).toHaveBeenCalledWith(
target,
'duplicate',
true,
hierarchy,
lowLevel,
expect.any(Map),
relations
)
})
it('runs inverse relation when doc class is derived from sourceClass', async () => {
const anchorId = '69286dc0cb49b698d3ea2c95' as Ref<Doc>
const anchor = doc(anchorId, classChild, {})
const pointing = doc('69286dc1cb49b698d3ea2c98', classTarget, { owner: anchorId })
const exportDocument = jest.fn().mockResolvedValue(true)
const { exporter } = createExporter(exportDocument)
const hierarchy = hierarchyWithDerivation(true)
jest.mocked(hierarchy.findDomain).mockReturnValue(`domain:${String(classTarget)}` as any)
jest.mocked(hierarchy.isDerived).mockImplementation((cls, base) => {
if (cls === base) return true
if (cls === classChild && base === classParent) return true
if (cls === classTarget && base === classTarget) return true
return false
})
const lowLevel = lowLevelForDocs(pointing)
const relations: RelationDefinition[] = [
{
sourceClass: classParent,
field: 'owner',
class: classTarget,
direction: 'inverse'
}
]
await exporter.exportInverseRelations(anchor, relations, 'duplicate', true, hierarchy, lowLevel)
expect(exportDocument).toHaveBeenCalledTimes(1)
expect(exportDocument).toHaveBeenCalledWith(
pointing,
'duplicate',
true,
hierarchy,
lowLevel,
expect.any(Map),
relations
)
})
it('skips inverse relation when doc class is not derived from sourceClass', async () => {
const anchor = doc('69286dc0cb49b698d3ea2c95', classUnrelated, {})
const pointing = doc('69286dc1cb49b698d3ea2c98', classTarget, { owner: anchor._id })
const exportDocument = jest.fn().mockResolvedValue(true)
const { exporter } = createExporter(exportDocument)
const hierarchy = hierarchyWithDerivation(true)
jest.mocked(hierarchy.findDomain).mockReturnValue(`domain:${String(classTarget)}` as any)
const lowLevel = lowLevelForDocs(pointing)
const relations: RelationDefinition[] = [
{
sourceClass: classParent,
field: 'owner',
class: classTarget,
direction: 'inverse'
}
]
await exporter.exportInverseRelations(anchor, relations, 'duplicate', true, hierarchy, lowLevel)
expect(exportDocument).not.toHaveBeenCalled()
})
})
+8 -1
View File
@@ -126,6 +126,7 @@ const extractQueryToken = (queryParams: any): string | null => {
}
interface RelationPayloadEntry {
sourceClass?: Ref<Class<Doc>>
field?: string
class?: Ref<Class<Doc>>
direction?: 'forward' | 'inverse'
@@ -154,6 +155,7 @@ const normalizeRelations = (input: unknown): RelationDefinition[] | undefined =>
}
result.push({
...(item.sourceClass !== undefined ? { sourceClass: item.sourceClass } : {}),
field: item.field,
class: item.class,
direction: item.direction ?? 'forward'
@@ -180,7 +182,12 @@ const normalizeRelations = (input: unknown): RelationDefinition[] | undefined =>
}
const field = typeof value.field === 'string' ? value.field : key
result.push({ field, class: value.class, direction: value.direction ?? 'forward' })
result.push({
...(value.sourceClass !== undefined ? { sourceClass: value.sourceClass } : {}),
field,
class: value.class,
direction: value.direction ?? 'forward'
})
}
return result.length > 0 ? result : undefined
@@ -154,6 +154,10 @@ export class RelationExporter {
sourceLowLevel: LowLevelStorage,
relations: RelationDefinition[]
): Promise<void> {
if (relation.sourceClass !== undefined && !sourceHierarchy.isDerived(doc._class, relation.sourceClass)) {
return
}
const value = (doc as any)[relation.field]
if (value === undefined || value === null) {
return
@@ -195,6 +199,10 @@ export class RelationExporter {
sourceLowLevel: LowLevelStorage,
relations: RelationDefinition[]
): Promise<void> {
if (relation.sourceClass !== undefined && !sourceHierarchy.isDerived(doc._class, relation.sourceClass)) {
return
}
const domain = sourceHierarchy.findDomain(relation.class)
if (domain === undefined) {
this.context.warn(`Domain not found for relation class ${relation.class}`)
@@ -27,6 +27,8 @@ import { type Pipeline } from '@hcengineering/server-core'
export type PipelineFactory = (ctx: MeasureContext, workspace: WorkspaceIds) => Promise<Pipeline>
export interface RelationDefinition {
/** When set, this relation applies only to documents of this class (or its subclasses). */
sourceClass?: Ref<Class<Doc>>
field: string
class: Ref<Class<Doc>>
direction?: 'forward' | 'inverse'
@@ -184,6 +184,7 @@ export class CrossWorkspaceExporter {
if (resolvedRelations.length === 0) {
const relations = await sourcePipeline.findAll(this.context, core.class.RelationMetadata, {})
resolvedRelations = relations.map((doc) => ({
sourceClass: doc.sourceClass,
field: doc.field,
class: doc.targetClass,
direction: (doc.direction ?? 'forward') as 'forward' | 'inverse'