Combine into one application chat, inbox and home (#9865)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2025-09-16 13:15:56 +07:00
committed by GitHub
parent ed424c1ba8
commit 38bc85bf6c
158 changed files with 424 additions and 2534 deletions
@@ -1,147 +0,0 @@
<!--
// Copyright © 2025 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 cardPlugin, { Card } from '@hcengineering/card'
import {
defineSeparators,
Separator,
deviceOptionsStore as deviceInfo,
resolvedLocationStore,
Location,
restoreLocation,
Component,
closePanel,
getCurrentLocation
} from '@hcengineering/ui'
import { onDestroy } from 'svelte'
import { getClient } from '@hcengineering/presentation'
import { inboxId } from '@hcengineering/inbox'
import view from '@hcengineering/view'
import { NotificationContext } from '@hcengineering/communication-types'
import InboxNavigation from './InboxNavigation.svelte'
import { getCardIdFromLocation, navigateToCard } from '../location'
import InboxHeader from './InboxHeader.svelte'
const client = getClient()
let replacedPanelElement: HTMLElement
let card: Card | undefined = undefined
let needRestoreLoc = true
async function syncLocation (loc: Location): Promise<void> {
if (loc.path[2] !== inboxId) {
return
}
const cardId = getCardIdFromLocation(loc)
if (cardId == null || cardId === '') {
card = undefined
if (needRestoreLoc) {
needRestoreLoc = false
restoreLocation(loc, inboxId)
}
return
}
needRestoreLoc = false
if (cardId !== card?._id) {
card = await client.findOne(cardPlugin.class.Card, { _id: cardId })
}
}
function selectCard (event: CustomEvent<{ context: NotificationContext, card: Card }>): void {
closePanel()
const loc = getCurrentLocation()
if (card?._id === event.detail.card._id && loc.path[2] === inboxId) return
card = event.detail.card
navigateToCard(card._id)
}
function handleClose (): void {
navigateToCard(undefined)
}
onDestroy(
resolvedLocationStore.subscribe((loc) => {
void syncLocation(loc)
})
)
defineSeparators('new-inbox', [
{ minSize: 15, maxSize: 60, size: 30, float: 'navigator' },
{ size: 'auto', minSize: 20, maxSize: 'auto' }
])
$: $deviceInfo.replacedPanel = replacedPanelElement
onDestroy(() => ($deviceInfo.replacedPanel = undefined))
</script>
<div class="hulyPanels-container inbox">
{#if $deviceInfo.navigator.visible}
<div
class="antiPanel-navigator {$deviceInfo.navigator.direction === 'horizontal'
? 'portrait'
: 'landscape'} border-left inbox__navigator"
class:fly={$deviceInfo.navigator.float}
>
<div class="antiPanel-wrap__content hulyNavPanel-container">
<InboxHeader />
<div class="antiPanel-wrap__content hulyNavPanel-container">
<InboxNavigation {card} on:select={selectCard} />
</div>
</div>
{#if !($deviceInfo.isMobile && $deviceInfo.isPortrait && $deviceInfo.minWidth)}
<Separator name="new-inbox" float={$deviceInfo.navigator.float ? 'navigator' : true} index={0} />
{/if}
</div>
<Separator
name="new-inbox"
float={$deviceInfo.navigator.float}
index={0}
color={'transparent'}
separatorSize={0}
short
/>
{/if}
<div bind:this={replacedPanelElement} class="hulyComponent inbox__panel">
{#if card}
{@const panel = client.getHierarchy().classHierarchyMixin(card._class, view.mixin.ObjectPanel)}
<Component
is={panel?.component ?? view.component.EditDoc}
props={{
_id: card._id,
embedded: true
}}
on:close={handleClose}
/>
{/if}
</div>
</div>
<style lang="scss">
.inbox {
&__navigator {
position: relative;
}
&__panel {
position: relative;
}
}
</style>
@@ -1,181 +0,0 @@
<!--
// Copyright © 2025 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 { NotificationContext } from '@hcengineering/communication-types'
import { Card } from '@hcengineering/card'
import { createEventDispatcher } from 'svelte'
import { createNotificationsQuery, getCommunicationClient } from '@hcengineering/presentation'
import { CheckBox, Spinner } from '@hcengineering/ui'
import { AccountRole, getCurrentAccount } from '@hcengineering/core'
import InboxNotification from './InboxNotification.svelte'
import InboxCardIcon from './InboxCardIcon.svelte'
export let context: NotificationContext
export let card: Card
export let selected: boolean = false
const account = getCurrentAccount()
const dispatch = createEventDispatcher()
const communicationClient = getCommunicationClient()
const notificationsQuery = createNotificationsQuery()
let total = 0
notificationsQuery.query({ limit: 1, total: true, read: false, strict: true, context: context.id }, (res) => {
total = res.getTotal()
})
let isRemoving = false
async function handleToggle (): Promise<void> {
isRemoving = true
try {
await communicationClient.removeNotificationContext(context.id)
} finally {
isRemoving = false
}
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="inbox-card" class:selected on:click={() => dispatch('select', { context, card })}>
<div class="inbox-card__header">
<InboxCardIcon {card} count={total ?? 0} />
<div class="inbox-card__labels">
<span class="inbox-card__title overflow-label clear-mins" title={card.title}>
{card.title}
</span>
</div>
{#if account.role !== AccountRole.ReadOnlyGuest}
<div class="inbox-card__remove">
{#if isRemoving}
<Spinner size="small" />
{:else}
<CheckBox kind="todo" size="medium" on:value={handleToggle} />
{/if}
</div>
{/if}
</div>
<div class="inbox-card__content">
<div class="inbox-card__notifications">
{#each context.notifications ?? [] as notification}
<div class="inbox-card__notification">
<div class="inbox-card__marker" />
<InboxNotification {notification} {card} />
</div>
{/each}
</div>
</div>
</div>
<style lang="scss">
.inbox-card {
display: flex;
flex-direction: column;
position: relative;
cursor: pointer;
padding: 0.5rem;
border-bottom: 1px solid var(--global-ui-BorderColor);
min-height: 5.625rem;
&.selected {
background-color: var(--global-ui-highlight-BackgroundColor);
}
&:hover:not(.selected) {
background-color: var(--global-ui-highlight-BackgroundColor);
}
&__header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: var(--spacing-0_5);
}
&__labels {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
color: var(--global-primary-TextColor);
font-size: 0.875rem;
overflow: hidden;
}
&__title {
font-weight: 400;
color: var(--global-primary-TextColor);
overflow: hidden;
text-overflow: ellipsis;
}
&__remove {
display: flex;
align-items: center;
margin-left: auto;
min-width: 1.5rem;
}
&__content {
display: flex;
width: 100%;
padding-left: 0.5rem;
}
&__notifications {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
margin-top: var(--spacing-1);
}
&__notification {
position: relative;
display: flex;
flex-direction: column;
cursor: pointer;
user-select: none;
&:first-child .inbox-card__marker {
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
&:last-child .inbox-card__marker {
border-bottom-left-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
}
&:hover .inbox-card__marker {
border-radius: 0.5rem;
background: var(--global-primary-LinkColor);
}
}
&__marker {
position: absolute;
width: 0.25rem;
height: 100%;
background: var(--global-ui-highlight-BackgroundColor);
border-radius: 0;
}
}
</style>
@@ -1,68 +0,0 @@
<!--
// Copyright © 2025 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 cardPlugin, { Card } from '@hcengineering/card'
import { Component } from '@hcengineering/ui'
import { getClient } from '@hcengineering/presentation'
import communication from '@hcengineering/communication'
import NotifyMarker from './NotifyMarker.svelte'
export let card: Card
export let count: number = 0
const client = getClient()
const hierarchy = client.getHierarchy()
$: size = hierarchy.isDerived(card._class, communication.type.Direct) ? 'large' : 'medium'
</script>
<div class="card-icon">
<Component is={cardPlugin.component.CardIcon} props={{ value: card, size }} />
{#if count > 0}
<div class="card-icon__marker">
<NotifyMarker {count} size="small" />
</div>
{/if}
</div>
<style lang="scss">
.card-icon {
display: inline-flex;
position: relative;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
min-width: 2.5rem;
min-height: 2.5rem;
color: var(--global-secondary-TextColor);
background-color: var(--global-ui-BackgroundColor);
border: 1px solid var(--global-subtle-ui-BorderColor);
border-radius: var(--medium-BorderRadius);
fill: var(--global-secondary-TextColor);
&__marker {
position: absolute;
top: -0.375rem;
right: 0;
transform: translateX(calc(100% - 0.875rem));
display: flex;
align-items: center;
justify-content: center;
}
}
</style>
@@ -1,61 +0,0 @@
<!-- Copyright © 2025 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 { SortingOrder } from '@hcengineering/core'
import { IconDelete, IconSettings, Label, ModernButton, showPopup, eventToHTMLElement } from '@hcengineering/ui'
import { getCommunicationClient } from '@hcengineering/presentation'
import inbox from '../plugin'
import InboxViewSettings from './InboxViewSettings.svelte'
const communicationClient = getCommunicationClient()
let clearing = false
async function clearInbox (): Promise<void> {
if (clearing) return
try {
clearing = true
const contexts = await communicationClient.findNotificationContexts({ order: SortingOrder.Ascending })
await Promise.all(contexts.map((context) => communicationClient.removeNotificationContext(context.id)))
clearing = false
} catch (e) {
clearing = false
console.error(e)
}
}
function click (e: MouseEvent): void {
showPopup(InboxViewSettings, {}, eventToHTMLElement(e))
}
</script>
<div class="hulyNavPanel-header withButton small inbox-header">
<span class="overflow-label"><Label label={inbox.string.Inbox} /></span>
<div class="flex-row-center flex-gap-2">
<ModernButton
label={inbox.string.ClearAll}
icon={IconDelete}
size="small"
iconSize="small"
on:click={clearInbox}
loading={clearing}
/>
<ModernButton icon={IconSettings} on:click={click} size="small" iconSize="small" />
</div>
</div>
<style lang="scss">
.inbox-header {
border-bottom: 1px solid var(--theme-navpanel-border);
}
</style>
@@ -1,201 +0,0 @@
<!--
// Copyright © 2025 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 cardPlugin, { Card } from '@hcengineering/card'
import { Label, ListView, Loading, Scroller } from '@hcengineering/ui'
import { SortingOrder } from '@hcengineering/core'
import { createNotificationContextsQuery, createQuery, getCommunicationClient } from '@hcengineering/presentation'
import { NotificationContext, Window } from '@hcengineering/communication-types'
import { createEventDispatcher } from 'svelte'
import InboxCard from './InboxCard.svelte'
import inbox from '../plugin'
export let card: Card | undefined = undefined
const dispatch = createEventDispatcher()
const limit = 20
let cards: Card[] = []
let contexts: NotificationContext[] = []
const communicationClient = getCommunicationClient()
const cardsQuery = createQuery()
const query = createNotificationContextsQuery()
let divScroll: HTMLElement | undefined | null = undefined
let contentDiv: HTMLElement | undefined | null = undefined
let window: Window<NotificationContext> | undefined = undefined
let list: ListView
let listSelection = 0
let isLoading = true
query.query(
{
notifications: {
message: true,
order: SortingOrder.Descending,
limit: 3
},
order: SortingOrder.Descending,
limit
},
(res) => {
window = res
contexts = res.getResult().filter((it) => (it.notifications?.length ?? 0) > 0)
isLoading = false
if (contexts.length < limit && window.hasPrevPage()) {
void window.loadPrevPage()
}
}
)
$: cardsQuery.query(cardPlugin.class.Card, { _id: { $in: contexts.map((c) => c.cardId) } }, (res) => {
cards = res
})
function handleScroll (): void {
if (divScroll != null && window != null && window.hasPrevPage()) {
const isAtBottom = divScroll.scrollTop + divScroll.clientHeight >= divScroll.scrollHeight - 10
if (isAtBottom) {
void window.loadPrevPage()
}
}
}
async function onKeydown (key: KeyboardEvent): Promise<void> {
if (key.code === 'ArrowUp') {
key.stopPropagation()
key.preventDefault()
list.select(listSelection - 1)
}
if (key.code === 'ArrowDown') {
key.stopPropagation()
key.preventDefault()
list.select(listSelection + 1)
}
if (key.code === 'Backspace') {
key.preventDefault()
key.stopPropagation()
const context = contexts[listSelection]
if (context != null) {
await communicationClient.removeNotificationContext(context.id)
}
}
if (key.code === 'Enter') {
key.preventDefault()
key.stopPropagation()
const context = contexts[listSelection]
const card = cards.find((c) => c._id === context.cardId)
if (card && context) {
dispatch('select', { context, card })
}
}
}
$: if (contentDiv != null) {
contentDiv.focus()
}
function getContextKey (index: number): string {
const contextId = contexts[index].id
return contextId ?? index.toString()
}
</script>
{#if contexts.length > 0}
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<Scroller bind:divScroll padding="0" shrink onScroll={handleScroll}>
<div class="inbox-list" bind:this={contentDiv} tabindex="0" on:keydown={onKeydown}>
<ListView
bind:this={list}
bind:selection={listSelection}
count={contexts.length}
items={contexts}
highlightIndex={contexts.findIndex((it) => it.cardId === card?._id)}
noScroll
kind="full-size"
colorsSchema="lumia"
lazy={true}
getKey={getContextKey}
>
<svelte:fragment slot="item" let:item={itemIndex}>
{@const context = contexts[itemIndex]}
{@const contextCard = cards.find((c) => c._id === context.cardId)}
{#if context && contextCard}
<InboxCard
{context}
card={contextCard}
on:select={(e) => {
dispatch('select', e.detail)
listSelection = itemIndex
}}
/>
{/if}
</svelte:fragment>
</ListView>
</div>
</Scroller>
{:else if isLoading}
<div class="placeholder">
<Loading />
</div>
{:else}
<div class="placeholder">
<div class="placeholder__header">
<Label label={inbox.string.InboxIsClear} />
</div>
<span class="mb-4">
<Label label={inbox.string.YouDontHaveAnyNewMessages} />
</span>
</div>
{/if}
<style lang="scss">
.inbox-list {
display: flex;
flex-direction: column;
width: 100%;
&:focus {
outline: 0;
}
:global(.list-item) {
border-radius: 0;
}
}
.placeholder {
display: flex;
align-self: center;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
height: inherit;
width: 100%;
gap: 0.5rem;
&__header {
font-weight: 600;
}
}
</style>
@@ -1,31 +0,0 @@
<!--
// Copyright © 2025 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 { Notification, NotificationType } from '@hcengineering/communication-types'
import { Card } from '@hcengineering/card'
import MessageNotification from './MessageNotification.svelte'
import ReactionNotification from './ReactionNotification.svelte'
export let notification: Notification
export let card: Card
</script>
{#if notification.type === NotificationType.Message}
<MessageNotification {notification} {card} />
{:else if notification.type === NotificationType.Reaction}
<ReactionNotification {notification} {card} />
{/if}
@@ -1,120 +0,0 @@
<!--
// Copyright © 2023 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 { createEventDispatcher } from 'svelte'
import { createFocusManager, FocusHandler, Label, ListView, ModernToggle, resizeObserver } from '@hcengineering/ui'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { updateViewSetting, isViewSettingEnabled, viewSettingsStore, hideUserNamesSettingId } from '../settings'
const dispatch = createEventDispatcher()
let popupElement: HTMLDivElement | undefined = undefined
const items = [
{
id: hideUserNamesSettingId,
label: getEmbeddedLabel('Hide user names')
}
]
let selection = 0
let list: ListView
export function onKeydown (key: KeyboardEvent): boolean {
if (key.code === 'Tab') {
dispatch('close')
key.preventDefault()
key.stopPropagation()
return true
}
if (key.code === 'ArrowUp') {
key.stopPropagation()
key.preventDefault()
list.select(selection - 1)
return true
}
if (key.code === 'ArrowDown') {
key.stopPropagation()
key.preventDefault()
list.select(selection + 1)
return true
}
if (key.code === 'Enter') {
key.preventDefault()
key.stopPropagation()
const item = items[selection]
if (item != null) {
onToggle(item.id)
}
return true
}
return false
}
const manager = createFocusManager()
$: if (popupElement) {
popupElement.focus()
}
function onToggle (id: string): void {
updateViewSetting(id, !isViewSettingEnabled($viewSettingsStore, id))
}
</script>
<FocusHandler {manager} />
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="selectPopup"
bind:this={popupElement}
tabindex="0"
use:resizeObserver={() => {
dispatch('changeContent')
}}
on:keydown={onKeydown}
>
<div class="menu-space" />
<div class="scroll">
<div class="box">
<ListView bind:this={list} count={items.length} bind:selection on:changeContent={() => dispatch('changeContent')}>
<svelte:fragment slot="item" let:item={itemId}>
{@const item = items[itemId]}
<div class="menu-item withList w-full container">
<Label label={item.label} />
<ModernToggle
checked={isViewSettingEnabled($viewSettingsStore, item.id)}
size="small"
on:change={() => {
onToggle(item.id)
}}
/>
</div>
</svelte:fragment>
</ListView>
</div>
</div>
<div class="menu-space" />
</div>
<style lang="scss">
.container {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
@@ -1,27 +0,0 @@
<!--
// Copyright © 2025 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 { Notification } from '@hcengineering/communication-types'
import { Card } from '@hcengineering/card'
import NotificationPreview from './preview/NotificationPreview.svelte'
export let notification: Notification
export let card: Card
</script>
{#if notification.message}
<NotificationPreview {card} message={notification.message} date={notification.created} />
{/if}
@@ -1,69 +0,0 @@
<!--
// Copyright © 2024 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">
export let count: number = 0
export let kind: 'primary' | 'simple' = 'primary'
export let size: 'xx-small' | 'x-small' | 'small' | 'medium' = 'small'
</script>
{#if kind === 'primary' && count > 0}
<div class="notifyMarker {size} {kind}">
{count}
</div>
{/if}
{#if kind === 'simple'}
<div class="notifyMarker {size} {kind}" />
{/if}
<style lang="scss">
.notifyMarker {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.5rem;
font-weight: 500;
padding: 0 0.25rem;
&.simple,
&.primary {
background-color: var(--global-higlight-Color);
color: var(--global-on-accent-TextColor);
}
&.xx-small {
min-width: 0.5rem;
height: 0.5rem;
}
&.x-small {
min-width: 0.75rem;
height: 0.75rem;
}
&.small {
min-width: 1rem;
height: 1rem;
font-size: 0.5rem;
}
&.medium {
min-width: 1.25rem;
height: 1.25rem;
font-size: 0.625rem;
}
}
</style>
@@ -1,95 +0,0 @@
<!--
// Copyright © 2025 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 { Notification, ReactionNotificationContent, SocialID } from '@hcengineering/communication-types'
import { EmojiPresenter } from '@hcengineering/emoji-resources'
import { Card } from '@hcengineering/card'
import { Label } from '@hcengineering/ui'
import { Person } from '@hcengineering/contact'
import { employeeByPersonIdStore, getPersonByPersonId } from '@hcengineering/contact-resources'
import NotificationPreview from './preview/NotificationPreview.svelte'
import PreviewTemplate from './preview/PreviewTemplate.svelte'
import inbox from '../plugin'
export let notification: Notification
export let card: Card
let content = notification.content as ReactionNotificationContent
$: content = notification.content as ReactionNotificationContent
let author: Person | undefined
$: void updateAuthor(content.creator)
async function updateAuthor (socialId: SocialID): Promise<void> {
author = $employeeByPersonIdStore.get(socialId)
if (author === undefined) {
author = (await getPersonByPersonId(socialId)) ?? undefined
}
}
</script>
{#if notification.message}
<div class="reaction-notification">
<PreviewTemplate socialId={content.creator} date={notification.created} color="secondary" showSeparator={false}>
<svelte:fragment slot="content">
<span class="ml-1-5" />
<Label label={inbox.string.ReactedToYourMessage} />
</svelte:fragment>
</PreviewTemplate>
<div class="reaction-notification__body">
<div class="reaction-notification__emoji">
<EmojiPresenter emoji={content.emoji} fitSize center />
</div>
<NotificationPreview
{card}
message={notification.message}
date={notification.created}
kind="column"
padding="0"
/>
</div>
</div>
{/if}
<style lang="scss">
.reaction-notification {
display: flex;
flex-direction: column;
color: var(--global-secondary-TextColor);
white-space: nowrap;
&__body {
display: flex;
align-items: center;
gap: 0.25rem;
padding-right: var(--spacing-0_75);
padding-left: var(--spacing-1_25);
}
&__emoji {
display: flex;
align-items: center;
font-size: 2rem;
width: 2.5rem;
min-width: 2.5rem;
min-height: 2.5rem;
height: 2.5rem;
overflow: hidden;
}
}
</style>
@@ -1,75 +0,0 @@
<!-- Copyright © 2025 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 { LiteMessageViewer } from '@hcengineering/presentation'
import { Card } from '@hcengineering/card'
import { type WithLookup } from '@hcengineering/core'
import { Message, MessageType, SocialID } from '@hcengineering/communication-types'
import { Person } from '@hcengineering/contact'
import { getEmbeddedLabel, IntlString } from '@hcengineering/platform'
import { employeeByPersonIdStore, getPersonByPersonId } from '@hcengineering/contact-resources'
import { markdownToMarkup } from '@hcengineering/text-markdown'
import { jsonToMarkup, markupToText } from '@hcengineering/text'
import { ActivityMessageViewer, isActivityMessage, AttachmentsPreview } from '@hcengineering/communication-resources'
import PreviewTemplate from './PreviewTemplate.svelte'
export let card: Card
export let message: Message
export let date: Date
export let color: 'primary' | 'secondary' = 'primary'
export let kind: 'default' | 'column' = 'default'
export let padding: string | undefined = undefined
const tooltipLimit = 512
let person: WithLookup<Person> | undefined = undefined
$: void updatePerson(message.creator)
function getTooltipLabel (message: Message): IntlString {
const text = markupToText(jsonToMarkup(markdownToMarkup(message.content)))
if (text.length > tooltipLimit) {
return getEmbeddedLabel(text.substring(0, tooltipLimit) + '...')
}
return getEmbeddedLabel(text)
}
async function updatePerson (socialId: SocialID): Promise<void> {
person = $employeeByPersonIdStore.get(socialId) ?? (await getPersonByPersonId(socialId)) ?? undefined
}
</script>
<PreviewTemplate
{kind}
{color}
{person}
{padding}
socialId={message.creator}
{date}
fixHeight={message.type !== MessageType.Activity}
tooltipLabel={getTooltipLabel(message)}
>
<svelte:fragment slot="content">
{#if isActivityMessage(message)}
<ActivityMessageViewer {message} {card} author={person} />
{:else}
<LiteMessageViewer message={markdownToMarkup(message.content)} />
{/if}
</svelte:fragment>
<svelte:fragment slot="after">
<AttachmentsPreview {message} />
</svelte:fragment>
</PreviewTemplate>
@@ -1,228 +0,0 @@
<!-- Copyright © 2025 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 { Person, formatName } from '@hcengineering/contact'
import { IntlString } from '@hcengineering/platform'
import { resizeObserver, TimeSince, tooltip } from '@hcengineering/ui'
import {
Avatar,
PersonPreviewProvider,
SystemAvatar,
employeeByPersonIdStore,
getPersonByPersonId
} from '@hcengineering/contact-resources'
import { SocialID } from '@hcengineering/communication-types'
import { isViewSettingEnabled, hideUserNamesSettingId, viewSettingsStore } from '../../settings'
export let tooltipLabel: IntlString | undefined = undefined
export let person: Person | undefined = undefined
export let socialId: SocialID
export let date: Date
export let color: 'primary' | 'secondary' = 'primary'
export let kind: 'default' | 'column' = 'default'
export let padding: string | undefined = undefined
export let fixHeight: boolean = false
export let showSeparator: boolean = true
let clientWidth: number
$: showPersonName = clientWidth > 300 && !isViewSettingEnabled($viewSettingsStore, hideUserNamesSettingId)
let _person: Person | undefined
$: void updatePerson(socialId, person)
async function updatePerson (socialId: SocialID, person: Person | undefined): Promise<void> {
_person = person ?? $employeeByPersonIdStore.get(socialId)
if (!_person) {
_person = (await getPersonByPersonId(socialId)) ?? undefined
}
}
</script>
{#if kind === 'column'}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="message-preview-template clear-mins {color} {kind}"
style:padding
use:resizeObserver={(element) => (clientWidth = element.clientWidth)}
>
<span class="message-preview-template__column-header">
{#if _person}
<span class="overflow-label clear-mins">
<PersonPreviewProvider value={_person} inline>
{formatName(_person?.name ?? '')}
</PersonPreviewProvider>
</span>
{/if}
</span>
<span class="message-preview-template__left">
<span
class="message-preview-template__content overflow-label {color}"
class:fixHeight
use:tooltip={tooltipLabel ? { label: tooltipLabel } : undefined}
>
<slot />
</span>
<slot name="after" />
</span>
</div>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="message-preview-template clear-mins {color}"
use:resizeObserver={(element) => (clientWidth = element.clientWidth)}
>
<span class="message-preview-template__left">
<PersonPreviewProvider value={_person} inline>
<span class="message-preview-template__person">
<span class="message-preview-template__avatar">
{#if _person}
<Avatar size="card" person={_person} name={_person.name} />
{:else}
<SystemAvatar size="card" />
{/if}
</span>
{#if showPersonName && _person}
<span class="message-preview-template__name overflow-label {color}">{formatName(_person?.name ?? '')}</span>
{/if}
{#if showSeparator}
{#if showPersonName}
<span class="message-preview-template__separator"></span>
{:else}
<span class="mr-0-5" />
{/if}
{/if}
</span>
</PersonPreviewProvider>
{#if $$slots.content}
<span
class="message-preview-template__content overflow-label {color}"
class:fixHeight
use:tooltip={tooltipLabel ? { label: tooltipLabel, textAlign: 'left' } : undefined}
>
<slot name="content" />
</span>
{/if}
<slot name="after" />
</span>
<span class="message-preview-template__time clear-mins">
<TimeSince value={date.getTime()} />
</span>
</div>
{/if}
<style lang="scss">
.message-preview-template {
display: flex;
align-items: center;
width: 100%;
max-width: 100%;
min-height: 2rem;
color: var(--global-secondary-TextColor);
padding: 0.25rem var(--spacing-1_25) 0.25rem var(--spacing-1_25);
overflow: hidden;
font-size: 0.875rem;
gap: 0.25rem;
&.column {
flex-direction: column;
align-items: flex-start;
}
&__left {
display: flex;
align-items: center;
gap: 0.25rem;
min-width: 2.75rem;
overflow: hidden;
max-width: 100%;
flex: 1;
}
&__person {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--global-primary-TextColor);
font-size: 0.875rem;
}
&__avatar {
width: 1.325rem;
min-width: 1.325rem;
height: 1.325rem;
display: flex;
align-items: center;
justify-content: center;
margin-right: 0.25rem;
}
&__content {
display: inline;
color: var(--global-primary-TextColor);
max-width: 100%;
&.fixHeight {
max-height: 1.375rem;
}
&.secondary {
color: var(--global-secondary-TextColor);
}
}
&__separator {
font-size: 0.875rem;
min-width: 0.375rem;
flex: 0 0 auto;
color: var(--global-primary-TextColor);
margin: 0 0.25rem;
}
&__name {
font-weight: 500;
flex: 0 0 auto;
&.secondary {
color: var(--global-secondary-TextColor);
}
}
&__time {
display: flex;
align-items: center;
font-size: 0.75rem;
color: var(--global-tertiary-TextColor);
white-space: nowrap;
margin-left: auto;
max-width: 100%;
min-width: 0;
width: fit-content;
}
&__column-header {
display: flex;
align-items: center;
max-width: 100%;
gap: var(--spacing-0_5);
font-weight: 500;
color: var(--global-primary-TextColor);
}
}
</style>