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
@@ -0,0 +1,172 @@
<!-- 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 { createQuery, getClient } from '@hcengineering/presentation'
import { Card, type MasterTag } from '@hcengineering/card'
import core, { DocumentQuery, type Ref, SortingOrder } from '@hcengineering/core'
import ui, { Label, Scroller, Loading } from '@hcengineering/ui'
import card from '@hcengineering/card'
import HomeCardPresenter from './FeedCardPresenter.svelte'
import NewCardForm from './NewCardForm.svelte'
const cardsQuery = createQuery()
const limitStep = 50
const client = getClient()
export let _class: Ref<MasterTag> | undefined = undefined
export let query: DocumentQuery<Card>
let divScroll: HTMLDivElement
let limit = limitStep
let cards: Card[] = []
let total = -1
let isLoading = true
let classQuery: Record<string, unknown> = {}
// Get descendants for _class using model hierarchy
$: if (_class !== undefined) {
const hierarchy = client.getHierarchy()
const descendants = hierarchy.getDescendants(_class)
const allClasses = [_class, ...descendants]
classQuery = allClasses.length > 1 ? { _class: { $in: allClasses } } : { _class }
} else {
classQuery = {}
}
$: resultQuery = { ...query, ...classQuery }
$: cardsQuery.query(
card.class.Card,
resultQuery,
(res) => {
cards = res
total = res.total
isLoading = false
},
{
sort: { modifiedOn: SortingOrder.Descending },
limit,
total: true,
lookup: {
space: core.class.Space
}
}
)
$: hasNextPage = total > cards.length
function onScroll (): void {
if (divScroll != null && hasNextPage && !isLoading) {
const isAtBottom = divScroll.scrollTop + divScroll.clientHeight >= divScroll.scrollHeight - 400
if (isAtBottom) {
isLoading = true
limit += limitStep
}
}
}
function getFormatDateId (timestamp: number): string {
const now = new Date()
const date = new Date(timestamp)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const yesterdayStart = todayStart - 24 * 60 * 60 * 1000
const thisWeekStart = new Date(now)
const day = thisWeekStart.getDay() === 0 ? 7 : thisWeekStart.getDay()
thisWeekStart.setDate(thisWeekStart.getDate() - (day - 1))
thisWeekStart.setHours(0, 0, 0, 0)
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime()
const thisYearStart = new Date(now.getFullYear(), 0, 1).getTime()
if (date.getTime() >= todayStart) {
return 'today'
} else if (date.getTime() >= yesterdayStart) {
return 'yesterday'
} else if (date.getTime() >= thisWeekStart.getTime()) {
return 'thisWeek'
} else if (date.getTime() >= thisMonthStart) {
return 'thisMonth'
} else if (date.getTime() >= thisYearStart) {
return 'thisYear'
} else {
return date.getFullYear().toString()
}
}
</script>
<Scroller bind:divScroll {onScroll} padding="2rem 4rem">
<div class="home">
<NewCardForm type={_class} />
<div class="body flex-gap-2">
{#each cards as card, index}
{@const previousCard = cards[index - 1]}
{@const currentDate = card.modifiedOn}
{@const previousDate = previousCard?.modifiedOn}
{@const currentDateId = getFormatDateId(currentDate)}
{@const previousDateId = previousDate != null ? getFormatDateId(previousDate) : undefined}
{#if currentDateId !== previousDateId}
<div class="date">
{#if currentDateId === 'today'}
<Label label={ui.string.Today} />
{:else if currentDateId === 'yesterday'}
<Label label={ui.string.Yesterday} />
{:else if currentDateId === 'thisWeek'}
<Label label={ui.string.ThisWeek} />
{:else if currentDateId === 'thisMonth'}
<Label label={ui.string.ThisMonth} />
{:else if currentDateId === 'thisYear'}
<Label label={ui.string.ThisYear} />
{:else}
{currentDateId}
{/if}
</div>
{/if}
<HomeCardPresenter {card} />
{/each}
{#if isLoading}
<div class="flex-center pb-2">
<Loading />
</div>
{/if}
</div>
</div>
</Scroller>
<style lang="scss">
.home {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
}
.body {
display: flex;
flex-direction: column;
overflow-x: hidden;
flex: 1;
width: 100%;
}
.date {
display: flex;
text-transform: uppercase;
font-size: 0.875rem;
font-weight: 500;
color: var(--global-secondary-TextColor);
}
</style>
@@ -0,0 +1,249 @@
<!-- 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 { createMessagesQuery, IconForward } from '@hcengineering/presentation'
import core, { PersonId, SortingOrder, WithLookup } from '@hcengineering/core'
import { CardID, Message, Label as CardLabel } from '@hcengineering/communication-types'
import { Avatar, getPersonByPersonIdStore, PersonPreviewProvider } from '@hcengineering/contact-resources'
import { Person } from '@hcengineering/contact'
import { MessagePresenter, labelsStore, MessagePreview } from '@hcengineering/communication-resources'
import { Button, IconMoreH, tooltip } from '@hcengineering/ui'
import { showMenu } from '@hcengineering/view-resources'
import { getEmbeddedLabel } from '@hcengineering/platform'
import CardTagsColored from './CardTagsColored.svelte'
import CardPathPresenter from './CardPathPresenter.svelte'
import CardTimestamp from './CardTimestamp.svelte'
import { openCardInSidebar } from '../utils'
import SystemAvatar from '@hcengineering/contact-resources/src/components/SystemAvatar.svelte'
export let card: WithLookup<Card>
export let isCompact = false
export let isComfortable2 = false
const messagesQuery = createMessagesQuery()
let message: Message | undefined = undefined
let socialId: PersonId | undefined = undefined
let person: Person | undefined = undefined
$: messagesQuery.query(
{ card: card._id, strict: true, attachments: true, reactions: true, limit: 1, order: SortingOrder.Descending },
(res) => {
const msgs = res.getResult().reverse()
message = msgs[msgs.length - 1]
}
)
$: socialId = message?.creator ?? card.modifiedBy
$: personStore = getPersonByPersonIdStore([socialId])
$: person = $personStore.get(socialId)
function hasNewMessages (labels: CardLabel[], cardId: CardID): boolean {
return labels.some((it) => (it.labelId as string) === cardPlugin.label.NewMessages && it.cardId === cardId)
}
let isActionsOpened = false
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="card" on:click|stopPropagation|preventDefault={() => openCardInSidebar(card._id, card)}>
<div class="card__avatar">
{#if socialId !== core.account.System}
<PersonPreviewProvider value={person}>
<Avatar name={person?.name} {person} size="medium" />
</PersonPreviewProvider>
{:else}
<SystemAvatar size="medium" />
{/if}
</div>
<div class="card__body">
<div class="card__header">
<div class="flex-presenter">
{#if hasNewMessages($labelsStore, card._id)}
<div class="flex pr-1">
<span class="notifyMarker" />
</div>
{/if}
<span
class="card__title overflow-label"
use:tooltip={{ label: getEmbeddedLabel(card.title), textAlign: 'left' }}
>
{card.title}
</span>
</div>
<CardTimestamp date={card.modifiedOn} />
{#if !isComfortable2}
<div class="flex-presenter flex-gap-0-5 tags-container">
<CardPathPresenter {card} />
<IconForward size={'small'} />
<div class="card__tags">
<CardTagsColored value={card} collapsable fullWidth />
</div>
</div>
{/if}
</div>
<div class="card__message">
{#if message}
{#if isCompact}
<MessagePreview {card} {message} colorInherit />
{:else}
<MessagePresenter {card} {message} hideHeader hideAvatar readonly padding="0" thread={false} />
{/if}
{/if}
</div>
<div class="card__parent" class:wrap={isComfortable2}>
{#if isComfortable2}
<div class="flex-presenter flex-gap-0-5 tags-container">
<CardPathPresenter {card} />
<IconForward size={'small'} />
<div class="card__tags mr-2">
<CardTagsColored value={card} collapsable fullWidth />
</div>
</div>
{/if}
</div>
</div>
{#if !isCompact}
<div class="card__actions" class:opened={isActionsOpened}>
<Button
icon={IconMoreH}
iconProps={{ size: 'medium' }}
kind="icon"
dataId="btnMoreActions"
on:click={(e) => {
isActionsOpened = true
showMenu(e, { object: card }, () => {
isActionsOpened = false
})
}}
/>
</div>
{/if}
</div>
<style lang="scss">
.card {
display: flex;
cursor: pointer;
border-radius: 0.5rem;
padding: 0.375rem 1rem;
gap: 0.75rem;
min-height: 4.75rem;
width: 100%;
height: fit-content;
&:hover {
background-color: var(--global-ui-hover-BackgroundColor);
.card__actions {
visibility: visible;
}
}
&__body {
display: flex;
flex-direction: column;
gap: 0.25rem;
overflow: hidden;
width: 100%;
}
&__header {
display: flex;
flex-direction: row;
overflow: hidden;
align-items: center;
height: 2rem;
gap: 0.5rem;
width: 100%;
}
&__tags {
display: flex;
flex-direction: row;
height: 2rem;
min-width: 0;
flex-grow: 1;
}
&__title {
color: var(--global-primary-TextColor);
font-weight: 500;
font-size: 0.875rem;
white-space: nowrap;
}
&__avatar {
display: flex;
align-items: flex-start;
justify-content: center;
margin-top: 0.625rem;
}
&__message {
display: flex;
min-height: 1.375rem;
color: var(--global-secondary-TextColor);
}
&__parent {
display: flex;
align-items: center;
margin-top: 0.5rem;
&.wrap {
flex-wrap: wrap;
}
}
&__actions {
display: flex;
flex-direction: column;
margin-left: auto;
padding-left: 0.5rem;
visibility: hidden;
&.opened {
visibility: visible;
}
&:hover {
visibility: visible;
}
}
.notifyMarker {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 50%;
background-color: var(--global-higlight-Color);
min-width: 0.5rem;
height: 0.5rem;
}
.tags-container {
min-width: 14rem;
max-width: none;
flex-grow: 1;
}
}
</style>
@@ -39,9 +39,10 @@
const threadMasterTag = chat.masterTag.Thread
export let type: Ref<MasterTag> = threadMasterTag
let title: string = ''
let space: Ref<CardSpace> | undefined = undefined
let type: Ref<MasterTag> = threadMasterTag
let description = EmptyMarkup
let _id = generateId<Card>()
let isExpanded = false
@@ -17,13 +17,27 @@
import { ObjectBox } from '@hcengineering/view-resources'
import view, { ViewletDescriptor } from '@hcengineering/view'
import { IntlString } from '@hcengineering/platform'
import card from '../../../plugin'
export let value: Ref<ViewletDescriptor> | undefined = undefined
export let withSingleViews: boolean = false
export let label: IntlString
$: supportedTypes = withSingleViews
? [view.viewlet.Table, view.viewlet.List, view.viewlet.Tree, view.viewlet.MasterDetail, view.viewlet.Document]
: [view.viewlet.Table, view.viewlet.List, view.viewlet.Tree, view.viewlet.MasterDetail]
? [
view.viewlet.Table,
view.viewlet.List,
view.viewlet.Tree,
view.viewlet.MasterDetail,
card.viewlet.CardFeedDescriptor,
view.viewlet.Document
]
: [
view.viewlet.Table,
view.viewlet.List,
view.viewlet.Tree,
view.viewlet.MasterDetail,
card.viewlet.CardFeedDescriptor
]
</script>
<ObjectBox