UBERF-4360: rewrite chat (#4265)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina
2024-01-11 21:46:11 +07:00
committed by GitHub
parent 06f6f6a222
commit 7f6f5e28a6
183 changed files with 6360 additions and 5051 deletions
@@ -24,5 +24,5 @@
</script>
{#if extension}
<Component is={extension.components[kind]} {props} on:close on:open />
<Component is={extension.components[kind]} {props} on:close on:open on:submit />
{/if}
@@ -30,12 +30,17 @@
export let object: Doc
export let isNewestFirst = false
let filtered: ActivityMessage[]
const allId = activity.ids.AllFilter
const client = getClient()
let filtered: ActivityMessage[]
let filters: ActivityMessagesFilter[] = []
const saved = localStorage.getItem('activity-filter')
let selectedFiltersRefs: Ref<Doc>[] | 'All' =
saved !== null && saved !== undefined ? (JSON.parse(saved) as Ref<Doc>[] | 'All') : 'All'
let selectedFiltersRefs: Ref<ActivityMessagesFilter>[] | Ref<ActivityMessagesFilter> = saved
? (JSON.parse(saved) as Ref<ActivityMessagesFilter>[])
: allId
let selectedFilters: ActivityMessagesFilter[] = []
$: localStorage.setItem('activity-filter', JSON.stringify(selectedFiltersRefs))
@@ -45,14 +50,14 @@
filters = res
if (saved !== null && saved !== undefined) {
const temp: Ref<Doc>[] | 'All' = JSON.parse(saved)
if (temp !== 'All' && Array.isArray(temp)) {
const temp: Ref<ActivityMessagesFilter>[] | Ref<ActivityMessagesFilter> = JSON.parse(saved)
if (temp !== allId && Array.isArray(temp)) {
selectedFiltersRefs = temp.filter((it) => filters.findIndex((f) => it === f._id) > -1)
if (selectedFiltersRefs.length === 0) {
selectedFiltersRefs = 'All'
selectedFiltersRefs = allId
}
} else {
selectedFiltersRefs = 'All'
selectedFiltersRefs = allId
}
}
})
@@ -69,9 +74,9 @@
isNewestFirst = res.value
return
}
const selected = res.value as Ref<Doc>[]
const selected = res.value as Ref<ActivityMessagesFilter>[]
const isAll = selected.length === filters.length || selected.length === 0
if (res.action === 'select') selectedFiltersRefs = isAll ? 'All' : selected
if (res.action === 'select') selectedFiltersRefs = isAll ? allId : selected
}
)
}
@@ -81,14 +86,14 @@
async function updateFilterActions (
messages: ActivityMessage[],
filters: ActivityMessagesFilter[],
selected: Ref<Doc>[] | 'All',
selected: Ref<Doc>[] | Ref<ActivityMessagesFilter>,
sortOrder: SortingOrder
): Promise<void> {
const sortedMessages = sortActivityMessages(messages, sortOrder).sort(({ isPinned }) =>
isPinned && sortOrder === SortingOrder.Ascending ? -1 : 1
)
if (selected === 'All') {
if (selected === allId) {
filtered = sortedMessages
dispatch('update', filtered)
@@ -126,9 +131,9 @@
<div
class="tag-icon"
on:click={() => {
if (selectedFiltersRefs !== 'All') {
if (selectedFiltersRefs !== allId && Array.isArray(selectedFiltersRefs)) {
const ids = selectedFiltersRefs.filter((it) => it !== filter._id)
selectedFiltersRefs = ids.length > 0 ? ids : 'All'
selectedFiltersRefs = ids.length > 0 ? ids : allId
}
}}
>
@@ -0,0 +1,356 @@
<!--
// 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 { Class, Doc, Ref, SortingOrder, isOtherDay, Timestamp } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import activity, {
ActivityExtension,
ActivityMessage,
ActivityMessagesFilter,
DisplayActivityMessage,
type DisplayDocUpdateMessage
} from '@hcengineering/activity'
import notification, { InboxNotificationsClient } from '@hcengineering/notification'
import { getResource } from '@hcengineering/platform'
import { get } from 'svelte/store'
import ActivityMessagePresenter from './activity-message/ActivityMessagePresenter.svelte'
import { combineActivityMessages, filterActivityMessages, getClosestDateSelectorDate } from '../activityMessagesUtils'
import ActivityMessagesSeparator from './activity-message/ActivityMessagesSeparator.svelte'
import JumpToDateSelector from './JumpToDateSelector.svelte'
import ActivityExtensionComponent from './ActivityExtension.svelte'
export let _class: Ref<Class<ActivityMessage>> = activity.class.ActivityMessage
export let object: Doc
export let isLoading = false
export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
export let scrollElement: HTMLDivElement | undefined = undefined
export let startFromBottom = false
export let filter: Ref<ActivityMessagesFilter> | undefined = undefined
export let withDates: boolean = true
export let collection: string | undefined = undefined
export let showEmbedded = false
export let skipLabels = false
export let lastViewedTimestamp: Timestamp | undefined = undefined
const dateSelectorHeight = 30
const headerHeight = 50
const client = getClient()
const messagesQuery = createQuery()
let prevMessagesLength = 0
let messages: DisplayActivityMessage[] = []
let displayMessages: DisplayActivityMessage[] = []
let filters: ActivityMessagesFilter[] = []
let extensions: ActivityExtension[] = []
let separatorElement: HTMLDivElement | undefined = undefined
let separatorPosition: number | undefined = undefined
let showDateSelector = false
let selectedDate: Timestamp | undefined = undefined
let isViewportInitialized = false
let inboxClient: InboxNotificationsClient | undefined = undefined
getResource(notification.function.GetInboxNotificationsClient).then((getClientFn) => {
inboxClient = getClientFn()
})
$: client.findAll(activity.class.ActivityExtension, { ofClass: object._class }).then((res) => {
extensions = res
})
client.findAll(activity.class.ActivityMessagesFilter, {}).then((res) => {
filters = res
})
$: messagesQuery.query(
_class,
{ attachedTo: object._id },
(result: ActivityMessage[]) => {
prevMessagesLength = messages.length
messages = combineActivityMessages(result)
isLoading = false
},
{
sort: {
createdOn: SortingOrder.Ascending
}
}
)
function scrollToBottom (afterScrollFn?: () => void) {
setTimeout(() => {
if (scrollElement !== undefined) {
scrollElement?.scrollTo(0, scrollElement.scrollHeight)
afterScrollFn?.()
}
}, 100)
}
function scrollToSeparator (afterScrollFn?: () => void) {
setTimeout(() => {
if (separatorElement) {
separatorElement.scrollIntoView()
afterScrollFn?.()
}
}, 100)
}
function handleJumpToDate (e: CustomEvent) {
const date = e.detail.date
if (!date || !scrollElement) {
return
}
let closestDate = getClosestDateSelectorDate(date, scrollElement)
if (!closestDate) {
return
}
if (closestDate < date) {
closestDate = undefined
} else {
scrollToDate(closestDate)
}
}
function scrollToDate (date: Timestamp) {
let offset = date && document.getElementById(date.toString())?.offsetTop
if (!offset || !scrollElement) {
return
}
offset = offset - headerHeight - dateSelectorHeight / 2
scrollElement.scrollTo({ left: 0, top: offset })
}
function handleScroll () {
updateSelectedDate()
readViewportMessages()
}
function readViewportMessages () {
if (!isViewportInitialized) {
return
}
const containerRect = scrollElement?.getBoundingClientRect()
if (containerRect === undefined) {
return
}
const messagesToRead: DisplayActivityMessage[] = []
for (const message of displayMessages) {
const msgElement = document.getElementById(message._id)
if (!msgElement) {
continue
}
const messageRect = msgElement.getBoundingClientRect()
if (messageRect.top >= containerRect.top && messageRect.bottom - messageRect.height / 2 <= containerRect.bottom) {
messagesToRead.push(message)
}
}
readMessage(messagesToRead)
}
function readMessage (messages: DisplayActivityMessage[]) {
if (inboxClient === undefined || messages.length === 0) {
return
}
const allIds = messages
.map((message) => {
const combined =
message._class === activity.class.DocUpdateMessage
? (message as DisplayDocUpdateMessage)?.combinedMessagesIds
: undefined
return [message._id, ...(combined ?? [])]
})
.flat()
inboxClient.readMessages(allIds)
const notifyContext = get(inboxClient.docNotifyContextByDoc).get(object._id)
const lastTimestamp = messages[messages.length - 1].createdOn ?? 0
if (notifyContext !== undefined && (notifyContext.lastViewedTimestamp ?? 0) < lastTimestamp) {
client.update(notifyContext, { lastViewedTimestamp: lastTimestamp })
}
}
function updateSelectedDate () {
if (!scrollElement) {
return
}
const clientRect = scrollElement.getBoundingClientRect()
const dateSelectors = scrollElement.getElementsByClassName('dateSelector')
const firstVisibleDateElement = Array.from(dateSelectors)
.reverse()
.find((child) => {
if (child?.nodeType === Node.ELEMENT_NODE) {
const rect = child?.getBoundingClientRect()
if (rect.top - dateSelectorHeight / 2 <= clientRect.top + dateSelectorHeight) {
return true
}
}
return false
})
if (!firstVisibleDateElement) {
return
}
showDateSelector = clientRect.top - firstVisibleDateElement.getBoundingClientRect().top > -dateSelectorHeight / 2
selectedDate = parseInt(firstVisibleDateElement.id)
}
$: filterActivityMessages(messages, filters, object._class, filter).then((filteredMessages) => {
displayMessages = filteredMessages
})
function getNewPosition (displayMessages: ActivityMessage[], lastViewedTimestamp?: Timestamp): number | undefined {
if (displayMessages.length === 0) {
return undefined
}
if (separatorPosition !== undefined) {
return separatorPosition
}
if (lastViewedTimestamp === undefined) {
return -1
}
if (lastViewedTimestamp === 0) {
return 0
}
const lastViewedMessageIdx = displayMessages.findIndex((message, index) => {
const createdOn = message.createdOn ?? 0
const nextCreatedOn = displayMessages[index + 1]?.createdOn ?? 0
return lastViewedTimestamp >= createdOn && lastViewedTimestamp < nextCreatedOn
})
return lastViewedMessageIdx !== -1 ? lastViewedMessageIdx + 1 : -1
}
$: separatorPosition = getNewPosition(displayMessages, lastViewedTimestamp)
$: initializeViewport(scrollElement, separatorElement, separatorPosition)
function initializeViewport (
scrollElement?: HTMLDivElement,
separatorElement?: HTMLDivElement,
separatorPosition?: number
) {
if (separatorPosition === undefined) {
return
}
if (separatorPosition < 0 && scrollElement) {
scrollToBottom(markViewportInitialized)
} else if (separatorElement) {
scrollToSeparator(markViewportInitialized)
}
}
function markViewportInitialized () {
// We should mark viewport as initialized when scroll is finished
setTimeout(() => {
isViewportInitialized = true
readViewportMessages()
}, 100)
}
function handleMessageSent () {
scrollToBottom(markViewportInitialized)
}
$: if (isViewportInitialized && messages.length > prevMessagesLength) {
setTimeout(() => {
readViewportMessages()
}, 100)
}
</script>
{#if !isLoading}
<div class="flex-col vScroll" bind:this={scrollElement} on:scroll={handleScroll}>
{#if startFromBottom}
<div class="grower" />
{/if}
{#if showDateSelector}
<div class="ml-2 pr-2 fixed">
<JumpToDateSelector {selectedDate} fixed on:jumpToDate={handleJumpToDate} />
</div>
{/if}
{#each displayMessages as message, index}
{#if index === separatorPosition}
<ActivityMessagesSeparator bind:element={separatorElement} title={activity.string.New} line reverse isNew />
{/if}
{#if withDates && (index === 0 || isOtherDay(message.createdOn ?? 0, displayMessages[index - 1].createdOn ?? 0))}
<JumpToDateSelector selectedDate={message.createdOn} on:jumpToDate={handleJumpToDate} />
{/if}
<ActivityMessagePresenter
value={message}
skipLabel={skipLabels}
{showEmbedded}
isHighlighted={message._id === selectedMessageId}
shouldScroll={selectedMessageId === message._id}
/>
{/each}
</div>
{/if}
<div class="ref-input">
<ActivityExtensionComponent
kind="input"
{extensions}
props={{ object, boundary: scrollElement, collection }}
on:submit={handleMessageSent}
/>
</div>
<style lang="scss">
.grower {
flex-grow: 10;
flex-shrink: 5;
}
.fixed {
position: absolute;
align-self: center;
z-index: 1;
}
.ref-input {
margin: 1.25rem 1rem;
}
</style>
@@ -15,15 +15,17 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'
import { IntlString } from '@hcengineering/platform'
import { Label, resizeObserver, CheckBox, MiniToggle } from '@hcengineering/ui'
import { Doc, Ref } from '@hcengineering/core'
import { CheckBox, Label, MiniToggle, resizeObserver } from '@hcengineering/ui'
import { Ref } from '@hcengineering/core'
import { ActivityMessagesFilter } from '@hcengineering/activity'
import activity from '../plugin'
export let selectedFiltersRefs: Ref<Doc>[] | 'All' = 'All'
export let selectedFiltersRefs: Ref<ActivityMessagesFilter>[] | Ref<ActivityMessagesFilter> = activity.ids.AllFilter
export let filters: ActivityMessagesFilter[] = []
const allId = activity.ids.AllFilter
const dispatch = createEventDispatcher()
let activityOrderNewestFirst = JSON.parse(localStorage.getItem('activity-newest-first') ?? 'false')
@@ -31,20 +33,19 @@
interface ActionMenu {
label: IntlString
checked: boolean
value: Ref<Doc> | 'All'
value: Ref<ActivityMessagesFilter>
}
let menu: ActionMenu[] = [
{
label: activity.string.All,
checked: true,
value: 'All'
}
]
filters.map((fl) => menu.push({ label: fl.label, checked: false, value: fl._id }))
if (selectedFiltersRefs !== 'All') {
selectedFiltersRefs.forEach((fl) => {
const index = menu.findIndex((el) => el.value === fl)
if (index !== -1) menu[index].checked = true
let menu: ActionMenu[] = []
filters.map(({ label, _id }) => menu.push({ label, checked: _id === allId, value: _id }))
if (Array.isArray(selectedFiltersRefs)) {
selectedFiltersRefs.forEach((filterId) => {
const index = menu.findIndex(({ value }) => value === filterId)
if (index !== -1) {
menu[index].checked = true
}
})
}
@@ -79,31 +80,31 @@
const checkAll = () => {
menu.forEach((el, i) => (el.checked = i === 0))
selectedFiltersRefs = 'All'
selectedFiltersRefs = allId
}
const uncheckAll = () => {
menu.forEach((el) => (el.checked = true))
const temp = filters.map((fl) => fl._id as Ref<Doc>)
selectedFiltersRefs = temp
selectedFiltersRefs = filters.map(({ _id }) => _id)
}
const selectRow = (n: number) => {
if (n === 0) {
if (selectedFiltersRefs === 'All') uncheckAll()
if (selectedFiltersRefs === allId) uncheckAll()
else checkAll()
} else {
if (selectedFiltersRefs === 'All') {
if (selectedFiltersRefs === allId) {
menu[n].checked = true
selectedFiltersRefs = [menu[n].value as Ref<Doc>]
selectedFiltersRefs = [menu[n].value]
} else if (menu[n].checked) {
if (menu.filter((el) => el.checked).length === 2) checkAll()
else {
else if (Array.isArray(selectedFiltersRefs)) {
menu[n].checked = false
selectedFiltersRefs = selectedFiltersRefs.filter((fl) => fl !== menu[n].value)
}
} else {
} else if (Array.isArray(selectedFiltersRefs)) {
menu[n].checked = true
selectedFiltersRefs.push(menu[n].value as Ref<Doc>)
selectedFiltersRefs.push(menu[n].value)
}
}
menu = menu
@@ -152,7 +153,7 @@
}}
>
<div class="flex-center justify-end mr-3 pointer-events-none">
<CheckBox checked={item.checked} symbol={selectedFiltersRefs !== 'All' && i === 0 ? 'minus' : 'check'} />
<CheckBox checked={item.checked} symbol={selectedFiltersRefs !== allId && i === 0 ? 'minus' : 'check'} />
</div>
<span class="overflow-label">
<Label label={item.label} />
@@ -0,0 +1,79 @@
<!--
// 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 { getDay, Timestamp } from '@hcengineering/core'
import { DateRangePopup, showPopup } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
export let selectedDate: Timestamp | undefined
export let fixed: boolean = false
let div: HTMLDivElement | undefined
const dispatch = createEventDispatcher()
$: time = selectedDate ? getDay(selectedDate) : undefined
$: isCurrentYear = time ? new Date(time).getFullYear() === new Date().getFullYear() : undefined
</script>
<div id={fixed ? '' : time?.toString()} class="flex-center clear-mins dateSelector">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
bind:this={div}
class="border-radius-4 over-underline dateSelectorButton clear-mins"
on:click={() => {
showPopup(DateRangePopup, {}, div, (v) => {
if (v) {
v.setHours(0, 0, 0, 0)
dispatch('jumpToDate', { date: v.getTime() })
}
})
}}
>
{#if time}
{new Date(time).toLocaleDateString('default', {
weekday: 'short',
month: 'long',
day: 'numeric',
year: isCurrentYear ? undefined : 'numeric'
})}
{/if}
</div>
</div>
<style lang="scss">
.dateSelector {
position: relative;
flex-shrink: 0;
margin: 0.25rem 0;
&:not(:first-child)::after {
position: absolute;
content: '';
top: 50%;
left: 0;
width: 100%;
height: 1px;
background-color: var(--theme-divider-color);
}
.dateSelectorButton {
padding: 0.25rem 0.5rem;
height: max-content;
background-color: var(--theme-list-row-color);
border: 1px solid var(--theme-divider-color);
z-index: 10;
}
}
</style>
@@ -0,0 +1,167 @@
<!--
// 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 { Person } from '@hcengineering/contact'
import { personByIdStore, Avatar } from '@hcengineering/contact-resources'
import { Doc, IdMap, Ref, WithLookup } from '@hcengineering/core'
import { getLocation, Label, navigate, TimeSince } from '@hcengineering/ui'
import { ActivityMessage } from '@hcengineering/activity'
import notification, {
ActivityInboxNotification,
DocNotifyContext,
InboxNotification,
InboxNotificationsClient
} from '@hcengineering/notification'
import { getResource } from '@hcengineering/platform'
import activity from '../plugin'
export let message: ActivityMessage
export let onReply: (() => void) | undefined = undefined
const maxDisplayPersons = 5
$: lastReply = message.lastReply ?? new Date().getTime()
$: persons = new Set(message.repliedPersons)
let inboxClient: InboxNotificationsClient | undefined = undefined
getResource(notification.function.GetInboxNotificationsClient).then((getClientFn) => {
inboxClient = getClientFn()
})
let displayPersons: Person[] = []
$: docNotifyContextByDocStore = inboxClient?.docNotifyContextByDoc
$: inboxNotificationsByContextStore = inboxClient?.inboxNotificationsByContext
$: hasNew = hasNewReplies(message, $docNotifyContextByDocStore, $inboxNotificationsByContextStore)
$: updateQuery(persons, $personByIdStore)
function hasNewReplies (
message: ActivityMessage,
notifyContexts?: Map<Ref<Doc>, DocNotifyContext>,
inboxNotificationsByContext?: Map<Ref<DocNotifyContext>, WithLookup<InboxNotification>[]>
): boolean {
const context: DocNotifyContext | undefined = notifyContexts?.get(message._id)
if (context === undefined) {
return false
}
return (inboxNotificationsByContext?.get(context._id) ?? [])
.filter((notification) => {
const activityNotifications = notification as ActivityInboxNotification
return activityNotifications.attachedToClass !== activity.class.DocUpdateMessage
})
.some(({ isViewed }) => !isViewed)
}
function updateQuery (personIds: Set<Ref<Person>>, personById: IdMap<Person>) {
displayPersons = Array.from(personIds)
.map((id) => personById.get(id))
.filter((person): person is Person => person !== undefined)
.slice(0, maxDisplayPersons - 1)
}
function handleReply (e: any) {
e.stopPropagation()
e.preventDefault()
if (onReply) {
onReply()
return
}
const loc = getLocation()
loc.path[4] = message._id
navigate(loc)
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="flex-row-center container cursor-pointer" on:click={handleReply}>
<div class="flex-row-center">
<div class="avatars">
{#each displayPersons as person}
<Avatar size="x-small" avatar={person.avatar} name={person.name} />
{/each}
</div>
{#if persons.size > maxDisplayPersons}
<div class="plus">
+{persons.size - maxDisplayPersons}
</div>
{/if}
</div>
<div class="whitespace-nowrap ml-2 mr-2 over-underline repliesCount">
<Label label={activity.string.RepliesCount} params={{ replies: message.replies ?? 0 }} />
</div>
{#if hasNew}
<div class="notifyMarker" />
{/if}
<div class="lastReply">
<Label label={activity.string.LastReply} />
</div>
<div class="time">
<TimeSince value={lastReply} />
</div>
</div>
<style lang="scss">
.container {
border: 1px solid transparent;
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
width: fit-content;
margin-left: -0.5rem;
.plus {
margin-left: 0.25rem;
}
.repliesCount {
color: var(--theme-link-color);
font-weight: 500;
}
.time {
font-size: 0.75rem;
}
.lastReply {
font-size: 0.75rem;
margin-right: 0.25rem;
}
.notifyMarker {
margin-right: 0.25rem;
width: 0.425rem;
height: 0.425rem;
border-radius: 50%;
background-color: var(--highlight-red);
}
&:hover {
border: 1px solid var(--button-border-hover);
background-color: var(--theme-bg-color);
}
}
.avatars {
display: flex;
gap: 0.25rem;
}
</style>
@@ -0,0 +1,51 @@
<!--
// 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 { ActionIcon } from '@hcengineering/ui'
import { createQuery, getClient } from '@hcengineering/presentation'
import activity, { ActivityMessage, SavedMessage } from '@hcengineering/activity'
import preference from '@hcengineering/preference'
import Bookmark from './icons/Bookmark.svelte'
export let object: ActivityMessage
const client = getClient()
const query = createQuery()
let savedMessage: SavedMessage | undefined = undefined
$: query.query(activity.class.SavedMessage, { attachedTo: object._id }, (res) => {
savedMessage = res[0]
})
async function toggleSaveMessage (): Promise<void> {
if (savedMessage !== undefined) {
await client.remove(savedMessage)
savedMessage = undefined
} else {
await client.createDoc(activity.class.SavedMessage, preference.space.Preference, {
attachedTo: object._id
})
}
}
</script>
<ActionIcon
icon={Bookmark}
iconProps={savedMessage ? { fill: '#3265cb' } : undefined}
size="medium"
action={toggleSaveMessage}
/>
@@ -36,7 +36,7 @@
export let isSelected: boolean = false
export let shouldScroll: boolean = false
export let embedded: boolean = false
export let hasActionsMenu: boolean = true
export let withActions: boolean = true
export let onClick: (() => void) | undefined = undefined
$: personAccount = $personAccountByIdStore.get((value.createdBy ?? value.modifiedBy) as Ref<PersonAccount>)
@@ -65,7 +65,7 @@
{isSelected}
{shouldScroll}
{embedded}
{hasActionsMenu}
{withActions}
viewlet={undefined}
{onClick}
>
@@ -16,7 +16,7 @@
import { DisplayActivityMessage } from '@hcengineering/activity'
import view from '@hcengineering/view'
import { getClient } from '@hcengineering/presentation'
import { Component } from '@hcengineering/ui'
import { Action, Component } from '@hcengineering/ui'
import { Class, Doc, Ref } from '@hcengineering/core'
export let value: DisplayActivityMessage
@@ -25,8 +25,14 @@
export let isSelected: boolean = false
export let shouldScroll: boolean = false
export let embedded: boolean = false
export let hasActionsMenu: boolean = true
export let withActions: boolean = true
export let showEmbedded = false
export let hideReplies = false
export let skipLabel = false
export let actions: Action[] = []
export let onClick: (() => void) | undefined = undefined
export let onReply: (() => void) | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
@@ -40,12 +46,17 @@
props={{
value,
showNotify,
skipLabel,
isHighlighted,
isSelected,
shouldScroll,
embedded,
hasActionsMenu,
onClick
withActions,
showEmbedded,
hideReplies,
actions,
onClick,
onReply
}}
/>
{/if}
@@ -30,6 +30,8 @@
import ActivityMessageExtensionComponent from './ActivityMessageExtension.svelte'
import ActivityMessagePresenter from './ActivityMessagePresenter.svelte'
import PinMessageAction from './PinMessageAction.svelte'
import Replies from '../Replies.svelte'
import SaveMessageAction from '../SaveMessageAction.svelte'
export let message: DisplayActivityMessage
export let parentMessage: DisplayActivityMessage | undefined
@@ -43,8 +45,11 @@
export let isSelected: boolean = false
export let shouldScroll: boolean = false
export let embedded: boolean = false
export let hasActionsMenu: boolean = true
export let withActions: boolean = true
export let showEmbedded = false
export let hideReplies = false
export let onClick: (() => void) | undefined = undefined
export let onReply: (() => void) | undefined = undefined
const client = getClient()
let allActionIds: string[] = []
@@ -53,9 +58,10 @@
let extensions: ActivityMessageExtension[] = []
let isActionMenuOpened = false
$: void getActions(client, message, activity.class.ActivityMessage).then((res) => {
allActionIds = res.map(({ _id }) => _id)
})
$: withActions &&
getActions(client, message, activity.class.ActivityMessage).then((res) => {
allActionIds = res.map(({ _id }) => _id)
})
function scrollToMessage (): void {
if (element != null && shouldScroll) {
@@ -101,7 +107,7 @@
$: isHidden = !!viewlet?.onlyWithParent && parentMessage === undefined
$: withActionMenu =
!embedded && hasActionsMenu && (actions.length > 0 || allActionIds.some((id) => !excludedActions.includes(id)))
withActions && !embedded && (actions.length > 0 || allActionIds.some((id) => !excludedActions.includes(id)))
</script>
{#if !isHidden}
@@ -151,32 +157,38 @@
<slot name="content" />
{#if !hideReplies && message.replies && message.replies > 0}
<div class="mt-2" />
<Replies {message} {onReply} />
{/if}
<ActivityMessageExtensionComponent kind="footer" {extensions} props={{ object: message }} />
<ReactionsPresenter object={message} />
{#if parentMessage}
{#if parentMessage && showEmbedded}
<div class="mt-2" />
<ActivityMessagePresenter value={parentMessage} embedded />
<ActivityMessagePresenter value={parentMessage} embedded hideReplies withActions={false} />
{/if}
</div>
<div
class="actions clear-mins flex flex-gap-2 items-center"
class:menuShowed={isActionMenuOpened || message.isPinned}
class:opened={isActionMenuOpened || message.isPinned}
>
<AddReactionAction object={message} />
<PinMessageAction object={message} />
{#if withActions}
<AddReactionAction object={message} />
<PinMessageAction object={message} />
<SaveMessageAction object={message} />
<ActivityMessageExtensionComponent
kind="action"
{extensions}
props={{ object: message }}
on:close={handleActionMenuClosed}
on:open={handleActionMenuOpened}
/>
{#if withActionMenu}
<ActionIcon icon={IconMoreH} size="small" action={showMenu} />
<ActivityMessageExtensionComponent
kind="action"
{extensions}
props={{ object: message }}
on:close={handleActionMenuClosed}
on:open={handleActionMenuOpened}
/>
{#if withActionMenu}
<ActionIcon icon={IconMoreH} size="small" action={showMenu} />
{/if}
{/if}
</div>
</div>
@@ -195,8 +207,8 @@
display: flex;
flex-shrink: 0;
padding: 0.75rem 0.75rem 0.75rem 1.25rem;
border-radius: 8px;
gap: 1rem;
overflow: hidden;
border: 1px solid transparent;
border-radius: 0.25rem;
@@ -228,7 +240,7 @@
right: 0.75rem;
color: var(--theme-halfcontent-color);
&.menuShowed {
&.opened {
visibility: visible;
}
}
@@ -0,0 +1,51 @@
<!--
// 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 type { IntlString } from '@hcengineering/platform'
import { Label } from '@hcengineering/ui'
export let title: IntlString
export let line: boolean = false
export let params: any = undefined
export let reverse: boolean = false
export let isNew: boolean = false
export let element: HTMLDivElement | undefined = undefined
</script>
<div
class="w-full text-sm flex-center whitespace-nowrap"
class:flex-reverse={reverse}
class:new={isNew}
bind:this={element}
>
<div class:ml-8={!reverse} class:mr-4={reverse}><Label label={title} {params} /></div>
<div class:ml-4={!reverse} class:mr-4={reverse} class:line />
</div>
<style lang="scss">
.line {
position: relative;
width: 100%;
height: 1px;
background-color: var(--divider-color);
}
.new {
.line {
background-color: var(--highlight-red);
}
color: var(--highlight-red);
}
</style>
@@ -25,7 +25,7 @@
import core, { Account, AttachedDoc, Class, Collection, Doc, Ref } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Component, ShowMore } from '@hcengineering/ui'
import { Component, ShowMore, Action } from '@hcengineering/ui'
import { AttributeModel } from '@hcengineering/view'
import ActivityMessageTemplate from '../activity-message/ActivityMessageTemplate.svelte'
@@ -42,8 +42,12 @@
export let isSelected: boolean = false
export let shouldScroll: boolean = false
export let embedded: boolean = false
export let hasActionsMenu: boolean = true
export let withActions: boolean = true
export let showEmbedded = false
export let hideReplies = false
export let actions: Action[] = []
export let onClick: (() => void) | undefined = undefined
export let onReply: (() => void) | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
@@ -146,9 +150,13 @@
{isSelected}
{shouldScroll}
{embedded}
{hasActionsMenu}
{withActions}
{viewlet}
{showEmbedded}
{hideReplies}
{actions}
{onClick}
{onReply}
>
<svelte:fragment slot="header">
{#if viewlet?.labelComponent}
@@ -0,0 +1,24 @@
<!--
// 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">
export let size: 'small' | 'medium' | 'large'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path
d="M16.2,18c-0.1,0-0.2,0-0.3-0.1l-6-4l-6,4c-0.2,0.1-0.4,0.1-0.5,0c-0.2-0.1-0.3-0.3-0.3-0.4V4.2 C3.2,3,4.3,2,5.5,2h8.9c1.3,0,2.3,1,2.3,2.2v13.3c0,0.2-0.1,0.4-0.3,0.4C16.4,18,16.3,18,16.2,18z M10,12.8c0.1,0,0.2,0,0.3,0.1 l5.5,3.6V4.2c0-0.6-0.6-1.2-1.3-1.2H5.5C4.8,3,4.2,3.5,4.2,4.2v12.4l5.5-3.6C9.8,12.9,9.9,12.8,10,12.8z"
/>
</svg>
@@ -16,12 +16,11 @@
import { createEventDispatcher } from 'svelte'
import { ActionIcon, EmojiPopup, IconEmoji, showPopup } from '@hcengineering/ui'
import { createQuery, getClient } from '@hcengineering/presentation'
import activity, { Reaction } from '@hcengineering/activity'
import { Doc } from '@hcengineering/core'
import activity, { ActivityMessage, Reaction } from '@hcengineering/activity'
import { updateDocReactions } from '../../utils'
export let object: Doc | undefined = undefined
export let object: ActivityMessage | undefined = undefined
const client = getClient()
@@ -38,16 +37,10 @@
function openEmojiPalette (ev: Event) {
dispatch('open')
showPopup(
EmojiPopup,
{},
ev.target as HTMLElement,
(emoji: string) => {
updateDocReactions(client, reactions, object, emoji)
dispatch('close')
},
() => {}
)
showPopup(EmojiPopup, {}, ev.target as HTMLElement, (emoji: string) => {
updateDocReactions(client, reactions, object, emoji)
dispatch('close')
})
}
</script>
@@ -14,13 +14,12 @@
-->
<script lang="ts">
import { createQuery, getClient } from '@hcengineering/presentation'
import activity, { Reaction } from '@hcengineering/activity'
import { Doc } from '@hcengineering/core'
import activity, { ActivityMessage, Reaction } from '@hcengineering/activity'
import Reactions from './Reactions.svelte'
import { updateDocReactions } from '../../utils'
export let object: Doc | undefined
export let object: ActivityMessage | undefined
const client = getClient()
const reactionsQuery = createQuery()
@@ -38,7 +37,7 @@
}
</script>
{#if reactions.length}
{#if object !== undefined && reactions.length > 0}
<div class="footer flex-col p-inline contrast mt-2">
<Reactions {reactions} {object} on:click={handleClick} />
</div>