UBERF-4360: rewrite chat (#4265)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-01-11 18:46:11 +04:00 committed by GitHub
parent 06f6f6a222
commit 7f6f5e28a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
183 changed files with 6360 additions and 5051 deletions

View File

@ -27,11 +27,15 @@
"dependencies": {
"@hcengineering/activity": "^0.6.0",
"@hcengineering/activity-resources": "^0.6.1",
"@hcengineering/contact": "^0.6.20",
"@hcengineering/core": "^0.6.28",
"@hcengineering/model": "^0.6.7",
"@hcengineering/model-core": "^0.6.0",
"@hcengineering/model-preference": "^0.6.0",
"@hcengineering/model-view": "^0.6.0",
"@hcengineering/notification": "^0.6.16",
"@hcengineering/platform": "^0.6.9",
"@hcengineering/ui": "^0.6.11",
"@hcengineering/model-view": "^0.6.0"
"@hcengineering/view": "^0.6.9"
}
}

View File

@ -31,6 +31,7 @@ import {
type Reaction,
type TxViewlet,
type ActivityMessageControl,
type SavedMessage,
type IgnoreActivity
} from '@hcengineering/activity'
import core, {
@ -43,7 +44,8 @@ import core, {
IndexKind,
type TxCUD,
type Domain,
type Account
type Account,
type Timestamp
} from '@hcengineering/core'
import {
Model,
@ -55,11 +57,16 @@ import {
Mixin,
Collection,
TypeBoolean,
TypeIntlString
TypeIntlString,
ArrOf,
TypeTimestamp
} from '@hcengineering/model'
import { TAttachedDoc, TClass, TDoc } from '@hcengineering/model-core'
import type { Asset, IntlString, Resource } from '@hcengineering/platform'
import { type AnyComponent } from '@hcengineering/ui/src/types'
import contact, { type Person } from '@hcengineering/contact'
import preference, { TPreference } from '@hcengineering/model-preference'
import notification from '@hcengineering/notification'
import view from '@hcengineering/model-view'
import activity from './plugin'
@ -102,8 +109,18 @@ export class TActivityMessage extends TAttachedDoc implements ActivityMessage {
@Prop(TypeBoolean(), activity.string.Pinned)
isPinned?: boolean
@Prop(ArrOf(TypeRef(contact.class.Person)), contact.string.Person)
repliedPersons?: Ref<Person>[]
@Prop(TypeTimestamp(), activity.string.LastReply)
@Index(IndexKind.Indexed)
lastReply?: Timestamp
@Prop(Collection(activity.class.Reaction), activity.string.Reactions)
reactions?: number
@Prop(Collection(activity.class.ActivityMessage), activity.string.Replies)
replies?: number
}
@Model(activity.class.DocUpdateMessage, activity.class.ActivityMessage)
@ -186,11 +203,20 @@ export class TActivityExtension extends TDoc implements ActivityExtension {
@Model(activity.class.ActivityMessagesFilter, core.class.Doc, DOMAIN_MODEL)
export class TActivityMessagesFilter extends TDoc implements ActivityMessagesFilter {
label!: IntlString
position!: number
filter!: Resource<(message: ActivityMessage, _class?: Ref<Doc>) => boolean>
}
@Model(activity.class.Reaction, core.class.AttachedDoc, DOMAIN_ACTIVITY)
export class TReaction extends TAttachedDoc implements Reaction {
@Prop(TypeRef(activity.class.ActivityMessage), core.string.AttachedTo)
@Index(IndexKind.Indexed)
declare attachedTo: Ref<ActivityMessage>
@Prop(TypeRef(activity.class.ActivityMessage), core.string.AttachedToClass)
@Index(IndexKind.Indexed)
declare attachedToClass: Ref<Class<ActivityMessage>>
@Prop(TypeString(), activity.string.Emoji)
emoji!: string
@ -198,6 +224,11 @@ export class TReaction extends TAttachedDoc implements Reaction {
createBy!: Ref<Account>
}
@Model(activity.class.SavedMessage, preference.class.Preference)
export class TSavedMessage extends TPreference implements SavedMessage {
@Prop(TypeRef(activity.class.ActivityMessage), view.string.Save)
declare attachedTo: Ref<ActivityMessage>
}
export function createModel (builder: Builder): void {
builder.createModel(
TTxViewlet,
@ -212,6 +243,7 @@ export function createModel (builder: Builder): void {
TActivityAttributeUpdatesPresenter,
TActivityInfoMessage,
TActivityMessageControl,
TSavedMessage,
TIgnoreActivity
)
@ -225,13 +257,30 @@ export function createModel (builder: Builder): void {
presenter: activity.component.ActivityInfoMessagePresenter
})
builder.mixin(activity.class.DocUpdateMessage, core.class.Class, view.mixin.LinkProvider, {
encode: activity.function.GetFragment
})
builder.createDoc(
activity.class.ActivityMessagesFilter,
core.space.Model,
{
label: activity.string.All,
position: 10,
filter: activity.filter.AllFilter
},
activity.ids.AllFilter
)
builder.createDoc(activity.class.ActivityMessagesFilter, core.space.Model, {
label: activity.string.Attributes,
position: 10,
filter: activity.filter.AttributesFilter
})
builder.createDoc(activity.class.ActivityMessagesFilter, core.space.Model, {
label: activity.string.Pinned,
position: 20,
filter: activity.filter.PinnedFilter
})
@ -259,6 +308,31 @@ export function createModel (builder: Builder): void {
},
activity.ids.ReactionRemovedActivityViewlet
)
builder.mixin(activity.class.ActivityMessage, core.class.Class, notification.mixin.ClassCollaborators, {
fields: ['createdBy', 'repliedPersons']
})
builder.mixin(activity.class.DocUpdateMessage, core.class.Class, notification.mixin.ClassCollaborators, {
fields: ['createdBy', 'repliedPersons']
})
builder.createDoc(
notification.class.NotificationType,
core.space.Model,
{
hidden: false,
generated: false,
label: activity.string.Reactions,
group: activity.ids.ActivityNotificationGroup,
txClasses: [core.class.TxCreateDoc],
objectClass: activity.class.Reaction,
providers: {
[notification.providers.PlatformNotification]: true
}
},
activity.ids.AddReactionNotification
)
}
export default activity

View File

@ -16,20 +16,33 @@ import { activityId, type ActivityMessage, type DocUpdateMessageViewlet } from '
import activity from '@hcengineering/activity-resources/src/plugin'
import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform'
import { type Doc, type Ref } from '@hcengineering/core'
import type { Location } from '@hcengineering/ui'
import { type ActionCategory } from '@hcengineering/view'
import { type NotificationGroup, type NotificationType } from '@hcengineering/notification'
export default mergeIds(activityId, activity, {
string: {
Attributes: '' as IntlString,
Pinned: '' as IntlString,
Emoji: '' as IntlString,
Reacted: '' as IntlString
Reacted: '' as IntlString,
Replies: '' as IntlString
},
filter: {
AttributesFilter: '' as Resource<(message: ActivityMessage, _class?: Ref<Doc>) => boolean>,
PinnedFilter: '' as Resource<(message: ActivityMessage, _class?: Ref<Doc>) => boolean>
PinnedFilter: '' as Resource<(message: ActivityMessage, _class?: Ref<Doc>) => boolean>,
AllFilter: '' as Resource<(message: ActivityMessage, _class?: Ref<Doc>) => boolean>
},
ids: {
ReactionAddedActivityViewlet: '' as Ref<DocUpdateMessageViewlet>,
ReactionRemovedActivityViewlet: '' as Ref<DocUpdateMessageViewlet>
ReactionRemovedActivityViewlet: '' as Ref<DocUpdateMessageViewlet>,
ActivityNotificationGroup: '' as Ref<NotificationGroup>,
AddReactionNotification: '' as Ref<NotificationType>
},
function: {
GetFragment: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>
},
category: {
Activity: '' as Ref<ActionCategory>
}
})

View File

@ -55,12 +55,13 @@ export const migrateOperations: [string, MigrateOperation][] = [
['view', viewOperation],
['contact', contactOperation],
['tags', tagsOperation],
['notification', notificationOperation],
['setting', settingOperation],
['tracker', trackerOperation],
['board', boardOperation],
['hr', hrOperation],
['bitrix', bitrixOperation],
['inventiry', inventoryOperation],
['activityServer', activityServerOperation]
['activityServer', activityServerOperation],
// We should call it after activityServer and chunter
['notification', notificationOperation]
]

View File

@ -31,6 +31,7 @@ import {
import core, { TAttachedDoc } from '@hcengineering/model-core'
import preference, { TPreference } from '@hcengineering/model-preference'
import view, { createAction } from '@hcengineering/model-view'
import attachment from './plugin'
export { attachmentId } from '@hcengineering/attachment'
@ -150,6 +151,7 @@ export function createModel (builder: Builder): void {
builder.createDoc(activity.class.ActivityMessagesFilter, core.space.Model, {
label: attachment.string.FilterAttachments,
position: 50,
filter: attachment.filter.AttachmentsFilter
})

View File

@ -24,8 +24,7 @@ import type { ActionCategory } from '@hcengineering/view'
export default mergeIds(attachmentId, attachment, {
component: {
AttachmentPresenter: '' as AnyComponent,
FileBrowser: '' as AnyComponent
AttachmentPresenter: '' as AnyComponent
},
string: {
AddAttachment: '' as IntlString,

View File

@ -36,7 +36,6 @@
"@hcengineering/model-activity": "^0.6.0",
"@hcengineering/model-core": "^0.6.0",
"@hcengineering/model-notification": "^0.6.0",
"@hcengineering/model-preference": "^0.6.0",
"@hcengineering/model-view": "^0.6.0",
"@hcengineering/model-workbench": "^0.6.1",
"@hcengineering/notification": "^0.6.16",

View File

@ -13,22 +13,22 @@
// limitations under the License.
//
import activity from '@hcengineering/activity'
import activity, { type ActivityMessage } from '@hcengineering/activity'
import {
type Backlink,
type Channel,
chunterId,
type ChunterMessage,
type ChunterMessageExtension,
type ChunterSpace,
type Comment,
type DirectMessage,
type Message,
type SavedMessages,
type ThreadMessage,
type DirectMessageInput,
type ChatMessage,
type ChatMessageViewlet
type ChatMessageViewlet,
type ChunterSpace,
type ObjectChatPanel,
type ThreadMessage
} from '@hcengineering/chunter'
import contact, { type Person } from '@hcengineering/contact'
import {
@ -61,14 +61,14 @@ import {
import attachment from '@hcengineering/model-attachment'
import core, { TAttachedDoc, TClass, TDoc, TSpace } from '@hcengineering/model-core'
import notification from '@hcengineering/model-notification'
import preference, { TPreference } from '@hcengineering/model-preference'
import view, { createAction, actionTemplates as viewTemplates } from '@hcengineering/model-view'
import workbench from '@hcengineering/model-workbench'
import chunter from './plugin'
import { type AnyComponent } from '@hcengineering/ui/src/types'
import { TypeBoolean } from '@hcengineering/model'
import type { IntlString } from '@hcengineering/platform'
import type { IntlString, Resource } from '@hcengineering/platform'
import { TActivityMessage } from '@hcengineering/model-activity'
export { chunterId } from '@hcengineering/chunter'
export { chunterOperation } from './migration'
@ -119,14 +119,6 @@ export class TChunterMessage extends TAttachedDoc implements ChunterMessage {
@Mixin(chunter.mixin.ChunterMessageExtension, chunter.class.ChunterMessage)
export class TChunterMessageExtension extends TChunterMessage implements ChunterMessageExtension {}
@Model(chunter.class.ThreadMessage, chunter.class.ChunterMessage)
@UX(chunter.string.ThreadMessage, undefined, 'TMSG')
export class TThreadMessage extends TChunterMessage implements ThreadMessage {
declare attachedTo: Ref<Message>
declare attachedToClass: Ref<Class<Message>>
}
@Model(chunter.class.Message, chunter.class.ChunterMessage)
@UX(chunter.string.Message, undefined, 'MSG')
export class TMessage extends TChunterMessage implements Message {
@ -139,7 +131,7 @@ export class TMessage extends TChunterMessage implements Message {
repliesCount?: number
@Prop(TypeTimestamp(), chunter.string.LastReply)
@Prop(TypeTimestamp(), activity.string.LastReply)
lastReply?: Timestamp
}
@ -167,12 +159,6 @@ export class TBacklink extends TComment implements Backlink {
backlinkClass!: Ref<Class<Doc>>
}
@Model(chunter.class.SavedMessages, preference.class.Preference)
export class TSavedMessages extends TPreference implements SavedMessages {
@Prop(TypeRef(chunter.class.ChunterMessage), chunter.string.SavedMessages)
declare attachedTo: Ref<ChunterMessage>
}
@Mixin(chunter.mixin.DirectMessageInput, core.class.Class)
export class TDirectMessageInput extends TClass implements DirectMessageInput {
component!: AnyComponent
@ -184,13 +170,31 @@ export class TChatMessage extends TActivityMessage implements ChatMessage {
@Index(IndexKind.FullText)
message!: string
@Prop(TypeTimestamp(), chunter.string.Edit)
@Index(IndexKind.Indexed)
editedOn?: Timestamp
@Prop(PropCollection(attachment.class.Attachment), attachment.string.Attachments, {
shortLabel: attachment.string.Files
})
attachments?: number
}
@Prop(TypeBoolean(), core.string.Boolean)
isEdited?: boolean
@Model(chunter.class.ThreadMessage, chunter.class.ChatMessage)
export class TThreadMessage extends TChatMessage implements ThreadMessage {
@Prop(TypeRef(activity.class.ActivityMessage), core.string.AttachedTo)
declare attachedTo: Ref<ActivityMessage>
@Prop(TypeRef(activity.class.ActivityMessage), core.string.AttachedToClass)
declare attachedToClass: Ref<Class<ActivityMessage>>
@Prop(TypeRef(core.class.Doc), core.string.Object)
@Index(IndexKind.Indexed)
objectId!: Ref<Doc>
@Prop(TypeRef(core.class.Class), core.string.Class)
@Index(IndexKind.Indexed)
objectClass!: Ref<Class<Doc>>
}
@Model(chunter.class.ChatMessageViewlet, core.class.Doc, DOMAIN_MODEL)
@ -199,31 +203,45 @@ export class TChatMessageViewlet extends TDoc implements ChatMessageViewlet {
@Index(IndexKind.Indexed)
objectClass!: Ref<Class<Doc>>
@Prop(TypeRef(core.class.Doc), core.string.Class)
@Index(IndexKind.Indexed)
messageClass!: Ref<Class<Doc>>
label?: IntlString
onlyWithParent?: boolean
}
@Mixin(chunter.mixin.ObjectChatPanel, core.class.Class)
export class TObjectChatPanel extends TClass implements ObjectChatPanel {
ignoreKeys!: string[]
titleProvider!: Resource<(object: Doc) => string>
}
export function createModel (builder: Builder, options = { addApplication: true }): void {
builder.createModel(
TChunterSpace,
TChannel,
TMessage,
TThreadMessage,
TChunterMessage,
TChunterMessageExtension,
TComment,
TBacklink,
TDirectMessage,
TSavedMessages,
TDirectMessageInput,
TChatMessage,
TChatMessageViewlet
TThreadMessage,
TChatMessageViewlet,
TObjectChatPanel
)
const spaceClasses = [chunter.class.Channel, chunter.class.DirectMessage]
spaceClasses.forEach((spaceClass) => {
builder.mixin(spaceClass, core.class.Class, activity.mixin.ActivityDoc, {})
builder.mixin(spaceClass, core.class.Class, view.mixin.LinkProvider, {
encode: chunter.function.GetChunterSpaceLinkFragment
})
builder.mixin(spaceClass, core.class.Class, workbench.mixin.SpaceView, {
view: {
class: chunter.class.Message
@ -239,14 +257,14 @@ export function createModel (builder: Builder, options = { addApplication: true
})
})
builder.mixin(chunter.class.DirectMessage, core.class.Class, view.mixin.SpaceName, {
getName: chunter.function.GetDmName
})
builder.mixin(chunter.class.Message, core.class.Class, notification.mixin.ClassCollaborators, {
fields: ['createdBy', 'replies']
})
builder.mixin(chunter.class.DirectMessage, core.class.Class, view.mixin.ObjectTitle, {
titleProvider: chunter.function.DirectMessageTitleProvider
})
builder.mixin(chunter.class.DirectMessage, core.class.Class, notification.mixin.ClassCollaborators, {
fields: ['members']
})
@ -279,10 +297,6 @@ export function createModel (builder: Builder, options = { addApplication: true
presenter: chunter.component.ThreadParentPresenter
})
builder.mixin(chunter.class.ThreadMessage, core.class.Class, view.mixin.ObjectPresenter, {
presenter: chunter.component.MessagePresenter
})
builder.mixin(chunter.class.Message, core.class.Class, view.mixin.ObjectPanel, {
component: chunter.component.ThreadViewPanel
})
@ -303,23 +317,6 @@ export function createModel (builder: Builder, options = { addApplication: true
header: chunter.component.ChannelHeader
})
builder.createDoc(
view.class.ViewletDescriptor,
core.space.Model,
{
label: chunter.string.Chat,
icon: view.icon.Table,
component: chunter.component.ChannelView
},
chunter.viewlet.Chat
)
builder.createDoc(view.class.Viewlet, core.space.Model, {
attachTo: chunter.class.Message,
descriptor: chunter.viewlet.Chat,
config: []
})
builder.createDoc(
view.class.ActionCategory,
core.space.Model,
@ -327,38 +324,6 @@ export function createModel (builder: Builder, options = { addApplication: true
chunter.category.Chunter
)
createAction(
builder,
{
action: chunter.actionImpl.MarkUnread,
label: chunter.string.MarkUnread,
input: 'focus',
category: chunter.category.Chunter,
target: chunter.class.Message,
context: {
mode: 'context',
group: 'edit'
}
},
chunter.action.MarkUnread
)
createAction(
builder,
{
label: chunter.string.MarkUnread,
action: chunter.actionImpl.MarkCommentUnread,
input: 'focus',
category: chunter.category.Chunter,
target: chunter.class.ThreadMessage,
context: {
mode: 'context',
group: 'edit'
}
},
chunter.action.MarkCommentUnread
)
createAction(
builder,
{
@ -423,164 +388,20 @@ export function createModel (builder: Builder, options = { addApplication: true
{
label: chunter.string.ApplicationLabelChunter,
icon: chunter.icon.Chunter,
locationResolver: chunter.resolver.Location,
alias: chunterId,
hidden: false,
navigatorModel: {
specials: [
{
id: 'spaceBrowser',
component: workbench.component.SpaceBrowser,
icon: chunter.icon.ChannelBrowser,
label: chunter.string.ChannelBrowser,
position: 'top',
spaceClass: chunter.class.Channel,
componentProps: {
_class: chunter.class.Channel,
label: chunter.string.ChannelBrowser,
createItemDialog: chunter.component.CreateChannel,
createItemLabel: chunter.string.CreateChannel
}
},
{
id: 'archive',
component: workbench.component.Archive,
icon: view.icon.Archive,
label: workbench.string.Archive,
position: 'top',
visibleIf: workbench.function.HasArchiveSpaces,
spaceClass: chunter.class.Channel
},
{
id: 'threads',
label: chunter.string.Threads,
icon: chunter.icon.Thread,
component: chunter.component.Threads,
position: 'top'
},
{
id: 'savedItems',
label: chunter.string.SavedItems,
icon: chunter.icon.Bookmark,
component: chunter.component.SavedMessages
},
{
id: 'fileBrowser',
label: attachment.string.FileBrowser,
icon: attachment.icon.FileBrowser,
component: attachment.component.FileBrowser,
componentProps: {
requestedSpaceClasses: [chunter.class.Channel, chunter.class.DirectMessage]
}
},
{
id: 'chunterBrowser',
label: chunter.string.ChunterBrowser,
icon: workbench.icon.Search,
component: chunter.component.ChunterBrowser,
visibleIf: chunter.function.ChunterBrowserVisible
}
],
spaces: [
{
id: 'channels',
label: chunter.string.Channels,
spaceClass: chunter.class.Channel,
addSpaceLabel: chunter.string.CreateChannel,
createComponent: chunter.component.CreateChannel
},
{
id: 'directMessages',
label: chunter.string.DirectMessages,
spaceClass: chunter.class.DirectMessage,
addSpaceLabel: chunter.string.NewDirectMessage,
createComponent: chunter.component.CreateDirectMessage
}
],
aside: chunter.component.ThreadView
}
component: chunter.component.Chat,
aside: chunter.component.ThreadView,
shouldNotify: chunter.function.ShouldNotify
},
chunter.app.Chunter
)
}
builder.createDoc(
activity.class.TxViewlet,
core.space.Model,
{
objectClass: chunter.class.Comment,
icon: chunter.icon.Chunter,
txClass: core.class.TxCreateDoc,
component: chunter.activity.TxCommentCreate,
label: chunter.string.LeftComment,
display: 'content',
editable: true,
hideOnRemove: true
},
chunter.ids.TxCommentCreate
)
builder.createDoc(
activity.class.TxViewlet,
core.space.Model,
{
objectClass: chunter.class.ChatMessage,
icon: chunter.icon.Chunter,
txClass: core.class.TxCreateDoc,
component: chunter.activity.TxCommentCreate,
label: chunter.string.LeftComment,
display: 'content',
editable: true,
hideOnRemove: true
},
chunter.ids.TxChatMessageCreate
)
createAction(
builder,
{
action: view.actionImpl.CopyTextToClipboard,
actionProps: {
textProvider: chunter.function.GetLink
},
label: chunter.string.CopyLink,
icon: chunter.icon.Thread,
keyBinding: [],
input: 'none',
category: chunter.category.Chunter,
target: chunter.class.Comment,
context: {
mode: ['context', 'browser'],
group: 'copy'
}
},
chunter.action.CopyCommentLink
)
builder.mixin(chunter.class.Comment, core.class.Class, view.mixin.IgnoreActions, {
actions: [view.action.Open]
})
builder.mixin(chunter.class.Message, core.class.Class, view.mixin.IgnoreActions, {
actions: [view.action.Open]
})
builder.mixin(chunter.class.ThreadMessage, core.class.Class, view.mixin.IgnoreActions, {
actions: [view.action.Open]
})
builder.mixin(chunter.class.Comment, core.class.Class, view.mixin.LinkProvider, {
encode: chunter.function.GetFragment
})
builder.mixin(chunter.class.ThreadMessage, core.class.Class, view.mixin.LinkProvider, {
encode: chunter.function.GetFragment
})
builder.mixin(chunter.class.Message, core.class.Class, view.mixin.LinkProvider, {
encode: chunter.function.GetFragment
})
createAction(
builder,
{
@ -593,114 +414,19 @@ export function createModel (builder: Builder, options = { addApplication: true
keyBinding: [],
input: 'none',
category: chunter.category.Chunter,
target: chunter.class.Message,
target: activity.class.ActivityMessage,
context: {
mode: ['context', 'browser'],
application: chunter.app.Chunter,
group: 'copy'
}
},
chunter.action.CopyMessageLink
)
createAction(
builder,
{
action: view.actionImpl.CopyTextToClipboard,
actionProps: {
textProvider: chunter.function.GetLink
},
label: chunter.string.CopyLink,
icon: chunter.icon.Thread,
keyBinding: [],
input: 'none',
category: chunter.category.Chunter,
target: chunter.class.ThreadMessage,
context: {
mode: ['context', 'browser'],
application: chunter.app.Chunter,
group: 'copy'
}
},
chunter.action.CopyThreadMessageLink
)
// We need to define this one, to hide default attached object removed case
builder.createDoc(
activity.class.TxViewlet,
core.space.Model,
{
objectClass: chunter.class.Comment,
icon: chunter.icon.Chunter,
txClass: core.class.TxRemoveDoc,
display: 'inline',
hideOnRemove: true
},
chunter.ids.TxCommentRemove
)
builder.createDoc(
activity.class.TxViewlet,
core.space.Model,
{
objectClass: chunter.class.ChatMessage,
icon: chunter.icon.Chunter,
txClass: core.class.TxRemoveDoc,
display: 'inline',
hideOnRemove: true
},
chunter.ids.TxChatMessageRemove
)
builder.createDoc(
activity.class.TxViewlet,
core.space.Model,
{
objectClass: chunter.class.Message,
icon: chunter.icon.Chunter,
txClass: core.class.TxCreateDoc,
component: chunter.activity.TxMessageCreate,
label: chunter.string.DMNotification,
display: 'content',
editable: true,
hideOnRemove: true
},
chunter.ids.TxMessageCreate
)
builder.createDoc(
activity.class.TxViewlet,
core.space.Model,
{
objectClass: chunter.class.Backlink,
icon: chunter.icon.Chunter,
txClass: core.class.TxCreateDoc,
component: chunter.component.BacklinkContent,
label: chunter.string.MentionedIn,
labelComponent: chunter.component.BacklinkReference,
display: 'emphasized',
editable: false,
hideOnRemove: true
},
chunter.ids.TxBacklinkCreate
)
// We need to define this one, to hide default attached object removed case
builder.createDoc(
activity.class.TxViewlet,
core.space.Model,
{
objectClass: chunter.class.Backlink,
icon: chunter.icon.Chunter,
txClass: core.class.TxRemoveDoc,
display: 'inline',
hideOnRemove: true
},
chunter.ids.TxBacklinkRemove
chunter.action.CopyChatMessageLink
)
builder.createDoc(activity.class.ActivityMessagesFilter, core.space.Model, {
label: chunter.string.FilterBacklinks,
position: 60,
filter: chunter.filter.BacklinksFilter
})
@ -753,7 +479,7 @@ export function createModel (builder: Builder, options = { addApplication: true
generated: false,
hidden: false,
txClasses: [core.class.TxCreateDoc],
objectClass: chunter.class.Message,
objectClass: chunter.class.ChatMessage,
providers: {
[notification.providers.EmailNotification]: false,
[notification.providers.PlatformNotification]: true
@ -776,7 +502,7 @@ export function createModel (builder: Builder, options = { addApplication: true
generated: false,
hidden: false,
txClasses: [core.class.TxCreateDoc],
objectClass: chunter.class.Message,
objectClass: chunter.class.ChatMessage,
providers: {
[notification.providers.PlatformNotification]: true
},
@ -841,10 +567,16 @@ export function createModel (builder: Builder, options = { addApplication: true
builder.createDoc(activity.class.ActivityMessagesFilter, core.space.Model, {
label: chunter.string.Comments,
position: 60,
filter: chunter.filter.ChatMessagesFilter
})
builder.mixin(chunter.class.ChatMessage, core.class.Class, activity.mixin.ActivityDoc, {})
builder.mixin(chunter.class.DirectMessage, core.class.Class, view.mixin.ObjectIdentifier, {
provider: chunter.function.DmIdentifierProvider
})
builder.mixin(chunter.class.ChatMessage, core.class.Class, view.mixin.CollectionPresenter, {
presenter: chunter.component.ChatMessagesPresenter
})
@ -853,6 +585,21 @@ export function createModel (builder: Builder, options = { addApplication: true
presenter: chunter.component.ChatMessagePresenter
})
builder.mixin(chunter.class.ThreadMessage, core.class.Class, view.mixin.ObjectPresenter, {
presenter: chunter.component.ThreadMessagePresenter
})
builder.createDoc(
chunter.class.ChatMessageViewlet,
core.space.Model,
{
messageClass: chunter.class.ThreadMessage,
objectClass: chunter.class.ChatMessage,
label: chunter.string.RepliedToThread
},
chunter.ids.ThreadMessageViewlet
)
createAction(
builder,
{
@ -863,10 +610,59 @@ export function createModel (builder: Builder, options = { addApplication: true
keyBinding: ['Backspace'],
category: chunter.category.Chunter,
target: chunter.class.ChatMessage,
context: { mode: ['context', 'browser'], group: 'edit' }
visibilityTester: chunter.function.CanDeleteMessage,
context: { mode: ['context', 'browser'], group: 'remove' }
},
chunter.action.DeleteChatMessage
)
createAction(
builder,
{
action: chunter.actionImpl.ReplyToThread,
label: chunter.string.ReplyToThread,
icon: chunter.icon.Thread,
input: 'focus',
category: chunter.category.Chunter,
target: activity.class.ActivityMessage,
context: { mode: 'context', application: notification.app.Notification, group: 'edit' }
},
chunter.action.ReplyToThread
)
createAction(
builder,
{
...viewTemplates.open,
target: notification.class.DocNotifyContext,
context: {
mode: ['browser', 'context'],
group: 'create'
},
action: chunter.actionImpl.OpenChannel
},
chunter.action.OpenChannel
)
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: chunter.class.Channel,
components: { input: chunter.component.ChatMessageInput }
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: chunter.class.DirectMessage,
components: { input: chunter.component.ChatMessageInput }
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: activity.class.DocUpdateMessage,
components: { input: chunter.component.ChatMessageInput }
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: chunter.class.ChatMessage,
components: { input: chunter.component.ChatMessageInput }
})
}
export default chunter

View File

@ -13,7 +13,7 @@
// limitations under the License.
//
import core, { TxOperations } from '@hcengineering/core'
import core, { type Class, type Doc, type Ref, TxOperations } from '@hcengineering/core'
import {
type MigrateOperation,
type MigrationClient,
@ -22,14 +22,44 @@ import {
} from '@hcengineering/model'
import { chunterId } from '@hcengineering/chunter'
import { DOMAIN_ACTIVITY } from '@hcengineering/model-activity'
import notification from '@hcengineering/notification'
import { DOMAIN_COMMENT } from './index'
import chunter from './plugin'
export async function createGeneral (tx: TxOperations): Promise<void> {
export async function createDocNotifyContexts (
client: MigrationUpgradeClient,
tx: TxOperations,
attachedTo: Ref<Doc>,
attachedToClass: Ref<Class<Doc>>
): Promise<void> {
const users = await client.findAll(core.class.Account, {})
for (const user of users) {
if (user._id === core.account.System) {
continue
}
const docNotifyContext = await client.findOne(notification.class.DocNotifyContext, {
user: user._id,
attachedTo,
attachedToClass
})
if (docNotifyContext === undefined) {
await tx.createDoc(notification.class.DocNotifyContext, core.space.Space, {
user: user._id,
attachedTo,
attachedToClass,
hidden: false
})
}
}
}
export async function createGeneral (client: MigrationUpgradeClient, tx: TxOperations): Promise<void> {
const createTx = await tx.findOne(core.class.TxCreateDoc, {
objectId: chunter.space.General
})
if (createTx === undefined) {
await tx.createDoc(
chunter.class.Channel,
@ -45,12 +75,15 @@ export async function createGeneral (tx: TxOperations): Promise<void> {
chunter.space.General
)
}
await createDocNotifyContexts(client, tx, chunter.space.General, chunter.class.Channel)
}
export async function createRandom (tx: TxOperations): Promise<void> {
export async function createRandom (client: MigrationUpgradeClient, tx: TxOperations): Promise<void> {
const createTx = await tx.findOne(core.class.TxCreateDoc, {
objectId: chunter.space.Random
})
if (createTx === undefined) {
await tx.createDoc(
chunter.class.Channel,
@ -66,6 +99,8 @@ export async function createRandom (tx: TxOperations): Promise<void> {
chunter.space.Random
)
}
await createDocNotifyContexts(client, tx, chunter.space.Random, chunter.class.Channel)
}
async function createBacklink (tx: TxOperations): Promise<void> {
@ -104,8 +139,8 @@ export const chunterOperation: MigrateOperation = {
},
async upgrade (client: MigrationUpgradeClient): Promise<void> {
const tx = new TxOperations(client, core.account.System)
await createGeneral(tx)
await createRandom(tx)
await createGeneral(client, tx)
await createRandom(client, tx)
await createBacklink(tx)
}
}

View File

@ -16,8 +16,8 @@
import type { ActivityMessage, DocUpdateMessageViewlet, TxViewlet } from '@hcengineering/activity'
import { chunterId, type Channel } from '@hcengineering/chunter'
import chunter from '@hcengineering/chunter-resources/src/plugin'
import type { Doc, Ref, Space } from '@hcengineering/core'
import { type NotificationGroup } from '@hcengineering/notification'
import { type Client, type Doc, type Ref } from '@hcengineering/core'
import { type DocNotifyContext, type NotificationGroup } from '@hcengineering/notification'
import type { IntlString, Resource } from '@hcengineering/platform'
import { mergeIds } from '@hcengineering/platform'
import type { AnyComponent, Location } from '@hcengineering/ui/src/types'
@ -29,11 +29,10 @@ export default mergeIds(chunterId, chunter, {
DirectMessagePresenter: '' as AnyComponent,
MessagePresenter: '' as AnyComponent,
DmPresenter: '' as AnyComponent,
Threads: '' as AnyComponent,
SavedMessages: '' as AnyComponent,
ChunterBrowser: '' as AnyComponent,
BacklinkContent: '' as AnyComponent,
BacklinkReference: '' as AnyComponent
BacklinkReference: '' as AnyComponent,
ChannelsPanel: '' as AnyComponent,
Chat: '' as AnyComponent
},
action: {
MarkCommentUnread: '' as Ref<Action>,
@ -41,17 +40,15 @@ export default mergeIds(chunterId, chunter, {
ArchiveChannel: '' as Ref<Action>,
UnarchiveChannel: '' as Ref<Action>,
ConvertToPrivate: '' as Ref<Action>,
CopyCommentLink: '' as Ref<Action<Doc, any>>,
CopyThreadMessageLink: '' as Ref<Action<Doc, any>>,
CopyMessageLink: '' as Ref<Action<Doc, any>>
CopyChatMessageLink: '' as Ref<Action<Doc, any>>,
OpenChannel: '' as Ref<Action>
},
actionImpl: {
MarkUnread: '' as ViewAction,
MarkCommentUnread: '' as ViewAction,
ArchiveChannel: '' as ViewAction,
UnarchiveChannel: '' as ViewAction,
ConvertDmToPrivateChannel: '' as ViewAction,
DeleteChatMessage: '' as ViewAction
DeleteChatMessage: '' as ViewAction,
ReplyToThread: '' as ViewAction
},
category: {
Chunter: '' as Ref<ActionCategory>
@ -62,7 +59,6 @@ export default mergeIds(chunterId, chunter, {
Content: '' as IntlString,
Comment: '' as IntlString,
Reference: '' as IntlString,
Chat: '' as IntlString,
CreateBy: '' as IntlString,
Create: '' as IntlString,
Edit: '' as IntlString,
@ -71,14 +67,15 @@ export default mergeIds(chunterId, chunter, {
MentionNotification: '' as IntlString,
PinnedMessages: '' as IntlString,
SavedMessages: '' as IntlString,
ThreadMessage: '' as IntlString,
Emoji: '' as IntlString,
FilterBacklinks: '' as IntlString,
DM: '' as IntlString,
DMNotification: '' as IntlString,
ConfigLabel: '' as IntlString,
ConfigDescription: '' as IntlString,
Reacted: '' as IntlString
Reacted: '' as IntlString,
Saved: '' as IntlString,
RepliedToThread: '' as IntlString
},
viewlet: {
Chat: '' as Ref<ViewletDescriptor>
@ -105,9 +102,12 @@ export default mergeIds(chunterId, chunter, {
Random: '' as Ref<Channel>
},
function: {
ChunterBrowserVisible: '' as Resource<(spaces: Space[]) => Promise<boolean>>,
GetLink: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<string>>,
GetFragment: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>
GetFragment: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
ShouldNotify: '' as Resource<(docNotifyContexts: DocNotifyContext[]) => Promise<boolean>>,
DmIdentifierProvider: '' as Resource<<T extends Doc>(client: Client, ref: Ref<T>, doc?: T) => Promise<string>>,
CanDeleteMessage: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
GetChunterSpaceLinkFragment: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>
},
filter: {
BacklinksFilter: '' as Resource<(message: ActivityMessage, _class?: Ref<Doc>) => boolean>,

View File

@ -243,6 +243,8 @@ export function createModel (builder: Builder): void {
]
})
builder.mixin(contact.class.Channel, core.class.Class, activity.mixin.ActivityDoc, {})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: contact.class.Contact,
components: { input: chunter.component.ChatMessageInput }
@ -280,6 +282,7 @@ export function createModel (builder: Builder): void {
hidden: false,
// component: contact.component.ContactsTabs,
locationResolver: contact.resolver.Location,
aside: chunter.component.ThreadView,
navigatorModel: {
spaces: [],
specials: [
@ -988,6 +991,7 @@ export function createModel (builder: Builder): void {
chunter.class.ChatMessageViewlet,
core.space.Model,
{
messageClass: chunter.class.ChatMessage,
objectClass: contact.class.Person,
label: chunter.string.LeftComment
},
@ -998,6 +1002,7 @@ export function createModel (builder: Builder): void {
chunter.class.ChatMessageViewlet,
core.space.Model,
{
messageClass: chunter.class.ChatMessage,
objectClass: contact.mixin.Employee,
label: chunter.string.LeftComment
},
@ -1008,6 +1013,7 @@ export function createModel (builder: Builder): void {
chunter.class.ChatMessageViewlet,
core.space.Model,
{
messageClass: chunter.class.ChatMessage,
objectClass: contact.class.Organization,
label: chunter.string.LeftComment
},

View File

@ -148,6 +148,7 @@ export function createModel (builder: Builder): void {
icon: inventory.icon.InventoryApplication,
alias: inventoryId,
hidden: false,
aside: chunter.component.ThreadView,
navigatorModel: {
specials: [
{
@ -187,6 +188,7 @@ export function createModel (builder: Builder): void {
chunter.class.ChatMessageViewlet,
core.space.Model,
{
messageClass: chunter.class.ChatMessage,
objectClass: inventory.class.Product,
label: chunter.string.LeftComment
},
@ -197,6 +199,7 @@ export function createModel (builder: Builder): void {
chunter.class.ChatMessageViewlet,
core.space.Model,
{
messageClass: chunter.class.ChatMessage,
objectClass: inventory.class.Category,
label: chunter.string.LeftComment
},

View File

@ -155,6 +155,7 @@ export function createModel (builder: Builder): void {
icon: lead.icon.LeadApplication,
alias: leadId,
hidden: false,
aside: chunter.component.ThreadView,
navigatorModel: {
specials: [
{
@ -542,6 +543,7 @@ export function createModel (builder: Builder): void {
chunter.class.ChatMessageViewlet,
core.space.Model,
{
messageClass: chunter.class.ChatMessage,
objectClass: lead.class.Lead,
label: chunter.string.LeftComment
},

View File

@ -43,7 +43,8 @@ import {
TypeString,
UX,
TypeBoolean,
TypeDate
TypeDate,
TypeIntlString
} from '@hcengineering/model'
import core, { TAttachedDoc, TClass, TDoc } from '@hcengineering/model-core'
import preference, { TPreference } from '@hcengineering/model-preference'
@ -65,7 +66,9 @@ import {
type NotificationTemplate,
type NotificationType,
notificationId,
type NotificationObjectPresenter
type NotificationObjectPresenter,
type ActivityInboxNotification,
type CommonInboxNotification
} from '@hcengineering/notification'
import { type Asset, type IntlString } from '@hcengineering/platform'
import setting from '@hcengineering/setting'
@ -197,18 +200,13 @@ export class TDocNotifyContext extends TDoc implements DocNotifyContext {
@Prop(TypeDate(), core.string.Date)
@Index(IndexKind.Indexed)
lastUpdateTimestamp?: Timestamp
@Prop(TypeBoolean(), notification.string.Pinned)
isPinned?: boolean
}
@Model(notification.class.InboxNotification, core.class.Doc, DOMAIN_NOTIFICATION)
export class TInboxNotification extends TDoc implements InboxNotification {
@Prop(TypeRef(activity.class.ActivityMessage), core.string.AttachedTo)
@Index(IndexKind.Indexed)
attachedTo!: Ref<ActivityMessage>
@Prop(TypeRef(activity.class.ActivityMessage), core.string.AttachedToClass)
@Index(IndexKind.Indexed)
attachedToClass!: Ref<Class<ActivityMessage>>
@Prop(TypeRef(notification.class.DocNotifyContext), core.string.AttachedTo)
@Index(IndexKind.Indexed)
docNotifyContext!: Ref<DocNotifyContext>
@ -220,6 +218,32 @@ export class TInboxNotification extends TDoc implements InboxNotification {
@Prop(TypeBoolean(), core.string.Boolean)
@Index(IndexKind.Indexed)
isViewed!: boolean
title?: IntlString
body?: IntlString
intlParams?: Record<string, string | number>
intlParamsNotLocalized?: Record<string, IntlString>
}
@Model(notification.class.ActivityInboxNotification, notification.class.InboxNotification)
export class TActivityInboxNotification extends TInboxNotification implements ActivityInboxNotification {
@Prop(TypeRef(activity.class.ActivityMessage), core.string.AttachedTo)
@Index(IndexKind.Indexed)
attachedTo!: Ref<ActivityMessage>
@Prop(TypeRef(activity.class.ActivityMessage), core.string.AttachedToClass)
@Index(IndexKind.Indexed)
attachedToClass!: Ref<Class<ActivityMessage>>
}
@Model(notification.class.CommonInboxNotification, notification.class.InboxNotification)
export class TCommonInboxNotification extends TInboxNotification implements CommonInboxNotification {
@Prop(TypeIntlString(), notification.string.Message)
message!: IntlString
props!: Record<string, any>
icon!: Asset
iconProps!: Record<string, any>
}
export function createModel (builder: Builder): void {
@ -236,7 +260,9 @@ export function createModel (builder: Builder): void {
TNotificationObjectPresenter,
TNotificationPreview,
TDocNotifyContext,
TInboxNotification
TInboxNotification,
TActivityInboxNotification,
TCommonInboxNotification
)
// Temporarily disabled, we should think about it
@ -283,20 +309,6 @@ export function createModel (builder: Builder): void {
notification.ids.NotificationSettings
)
builder.createDoc(
workbench.class.Application,
core.space.Model,
{
label: notification.string.Inbox,
icon: notification.icon.Notifications,
alias: notificationId,
hidden: true,
component: notification.component.Inbox,
aside: chunter.component.ThreadView
},
notification.app.Notification
)
builder.createDoc(
workbench.class.Application,
core.space.Model,
@ -312,7 +324,7 @@ export function createModel (builder: Builder): void {
specials: [
{
id: 'all',
component: notification.component.NewInbox,
component: notification.component.Inbox,
icon: activity.icon.Activity,
label: activity.string.AllActivity,
componentProps: {
@ -322,7 +334,7 @@ export function createModel (builder: Builder): void {
},
{
id: 'reactions',
component: notification.component.NewInbox,
component: notification.component.Inbox,
icon: activity.icon.Emoji,
label: activity.string.Reactions,
componentProps: {
@ -336,57 +348,6 @@ export function createModel (builder: Builder): void {
notification.app.Inbox
)
createAction(
builder,
{
action: notification.actionImpl.MarkAsUnread,
actionProps: {},
label: notification.string.MarkAsUnread,
icon: notification.icon.Track,
input: 'focus',
visibilityTester: notification.function.HasntNotifications,
category: notification.category.Notification,
target: notification.class.DocUpdates,
context: { mode: 'context', application: notification.app.Notification, group: 'edit' }
},
notification.action.MarkAsUnread
)
createAction(
builder,
{
action: notification.actionImpl.Hide,
actionProps: {},
label: notification.string.Archive,
icon: view.icon.Archive,
input: 'focus',
keyBinding: ['Backspace'],
category: notification.category.Notification,
target: notification.class.DocUpdates,
context: { mode: ['context', 'browser'], group: 'edit' }
},
notification.action.Hide
)
createAction(
builder,
{
action: notification.actionImpl.Unsubscribe,
actionProps: {},
label: notification.string.DontTrack,
icon: notification.icon.Hide,
input: 'focus',
category: notification.category.Notification,
target: notification.class.DocUpdates,
context: { mode: 'context', application: notification.app.Notification, group: 'edit' }
},
notification.action.Unsubscribe
)
builder.mixin(notification.class.DocUpdates, core.class.Class, view.mixin.IgnoreActions, {
actions: [view.action.Delete, view.action.Open]
})
createAction(builder, {
action: workbench.actionImpl.Navigate,
actionProps: {
@ -486,7 +447,7 @@ export function createModel (builder: Builder): void {
input: 'focus',
visibilityTester: notification.function.HasMarkAsReadAction,
category: notification.category.Notification,
target: activity.class.ActivityMessage,
target: notification.class.InboxNotification,
context: { mode: 'context', application: notification.app.Notification, group: 'edit' }
},
notification.action.MarkAsReadInboxNotification
@ -501,7 +462,7 @@ export function createModel (builder: Builder): void {
input: 'focus',
visibilityTester: notification.function.HasMarkAsUnreadAction,
category: notification.category.Notification,
target: activity.class.ActivityMessage,
target: notification.class.InboxNotification,
context: { mode: 'context', application: notification.app.Notification, group: 'edit' }
},
notification.action.MarkAsUnreadInboxNotification
@ -516,12 +477,89 @@ export function createModel (builder: Builder): void {
input: 'focus',
keyBinding: ['Backspace'],
category: notification.category.Notification,
visibilityTester: notification.function.HasDeleteNotificationAction,
target: activity.class.ActivityMessage,
target: notification.class.InboxNotification,
context: { mode: ['context', 'browser'], group: 'edit' }
},
notification.action.DeleteInboxNotification
)
createAction(
builder,
{
action: notification.actionImpl.HideDocNotifyContext,
label: view.string.Archive,
icon: view.icon.Archive,
input: 'focus',
category: view.category.General,
target: notification.class.DocNotifyContext,
context: {
mode: ['browser', 'context'],
group: 'remove'
},
visibilityTester: notification.function.IsDocNotifyContextVisible
},
notification.action.HideDocNotifyContext
)
createAction(
builder,
{
action: notification.actionImpl.UnHideDocNotifyContext,
label: view.string.UnArchive,
icon: view.icon.Archive,
input: 'focus',
category: view.category.General,
target: notification.class.DocNotifyContext,
context: {
mode: ['browser', 'context'],
group: 'remove'
},
visibilityTester: notification.function.IsDocNotifyContextHidden
},
notification.action.UnHideDocNotifyContext
)
createAction(
builder,
{
action: notification.actionImpl.PinDocNotifyContext,
label: view.string.Pin,
icon: notification.icon.Track,
input: 'focus',
category: notification.category.Notification,
target: notification.class.DocNotifyContext,
visibilityTester: notification.function.HasDocNotifyContextPinAction,
context: { mode: ['context', 'browser'], group: 'edit' }
},
notification.action.PinDocNotifyContext
)
createAction(
builder,
{
action: notification.actionImpl.UnpinDocNotifyContext,
label: view.string.Unpin,
icon: notification.icon.Track,
input: 'focus',
category: notification.category.Notification,
target: notification.class.DocNotifyContext,
visibilityTester: notification.function.HasDocNotifyContextUnpinAction,
context: { mode: ['context', 'browser'], group: 'edit' }
},
notification.action.UnpinDocNotifyContext
)
builder.mixin(notification.class.DocNotifyContext, core.class.Class, view.mixin.ObjectPresenter, {
presenter: notification.component.DocNotifyContextPresenter
})
builder.mixin(notification.class.ActivityInboxNotification, core.class.Class, view.mixin.ObjectPresenter, {
presenter: notification.component.ActivityInboxNotificationPresenter
})
builder.mixin(notification.class.CommonInboxNotification, core.class.Class, view.mixin.ObjectPresenter, {
presenter: notification.component.CommonInboxNotificationPresenter
})
}
export function generateClassNotificationTypes (

View File

@ -13,9 +13,41 @@
// limitations under the License.
//
import core, { TxOperations } from '@hcengineering/core'
import { type MigrateOperation, type MigrationClient, type MigrationUpgradeClient } from '@hcengineering/model'
import notification from '@hcengineering/notification'
import core, {
type AttachedDoc,
type Doc,
type Domain,
DOMAIN_TX,
generateId,
type Ref,
type TxCollectionCUD,
type TxCUD,
TxOperations,
TxProcessor
} from '@hcengineering/core'
import {
type MigrateOperation,
type MigrationClient,
type MigrationUpgradeClient,
tryMigrate
} from '@hcengineering/model'
import notification, {
type DocNotifyContext,
type DocUpdates,
type DocUpdateTx,
type InboxNotification,
notificationId
} from '@hcengineering/notification'
import activity, { type ActivityMessage, type DocUpdateMessage } from '@hcengineering/activity'
import { DOMAIN_NOTIFICATION } from './index'
interface InboxData {
context: DocNotifyContext
notifications: InboxNotification[]
}
const DOMAIN_ACTIVITY = 'activity' as Domain
async function createSpace (client: MigrationUpgradeClient): Promise<void> {
const txop = new TxOperations(client, core.account.System)
@ -38,8 +70,140 @@ async function createSpace (client: MigrationUpgradeClient): Promise<void> {
}
}
async function getActivityMessages (
client: MigrationClient,
tx: DocUpdateTx,
context: DocNotifyContext
): Promise<ActivityMessage[]> {
const docUpdateMessages = await client.find<DocUpdateMessage>(DOMAIN_ACTIVITY, {
_class: activity.class.DocUpdateMessage,
txId: tx._id,
attachedTo: context.attachedTo
})
if (docUpdateMessages.length > 0) {
return docUpdateMessages
}
const originTx = (await client.find<TxCUD<Doc>>(DOMAIN_TX, { _id: tx._id }))[0]
if (originTx === undefined) {
return []
}
const innerTx = TxProcessor.extractTx(originTx as TxCollectionCUD<Doc, AttachedDoc>) as TxCUD<Doc>
return (
await client.find<ActivityMessage>(DOMAIN_ACTIVITY, {
_id: innerTx.objectId as Ref<ActivityMessage>,
attachedTo: context.attachedTo
})
).filter(({ _class }) => client.hierarchy.isDerived(_class, activity.class.ActivityMessage))
}
async function getInboxNotifications (
client: MigrationClient,
tx: DocUpdateTx,
context: DocNotifyContext
): Promise<InboxNotification[]> {
const messages = await getActivityMessages(client, tx, context)
if (messages.length === 0) {
return []
}
return messages.map((message) => ({
_id: generateId(),
_class: notification.class.InboxNotification,
space: context.space,
user: context.user,
isViewed: !tx.isNew,
attachedTo: message._id,
attachedToClass: message._class,
docNotifyContext: context._id,
title: tx.title,
body: tx.body,
intlParams: tx.intlParams,
intlParamsNotLocalized: tx.intlParamsNotLocalized,
modifiedOn: tx.modifiedOn,
modifiedBy: tx.modifiedBy,
createdOn: tx.modifiedOn,
createdBy: tx.modifiedBy
}))
}
async function getInboxData (client: MigrationClient, docUpdate: DocUpdates): Promise<InboxData | undefined> {
if (!client.hierarchy.hasClass(docUpdate.attachedToClass)) {
console.log('cannot find class: ', docUpdate.attachedToClass)
return
}
const { txes } = docUpdate
const newTxIndex = txes.findIndex(({ isNew }) => isNew)
const context: DocNotifyContext = {
_id: docUpdate._id,
_class: notification.class.DocNotifyContext,
space: docUpdate.space,
user: docUpdate.user,
attachedTo: docUpdate.attachedTo,
attachedToClass: docUpdate.attachedToClass,
hidden: docUpdate.hidden,
lastViewedTimestamp: newTxIndex !== -1 ? txes[newTxIndex - 1]?.modifiedOn : docUpdate.lastTxTime,
lastUpdateTimestamp: docUpdate.lastTxTime,
modifiedBy: docUpdate.modifiedBy,
modifiedOn: docUpdate.modifiedOn,
createdBy: docUpdate.createdBy,
createdOn: docUpdate.createdOn
}
const notifications = (await Promise.all(txes.map((tx) => getInboxNotifications(client, tx, context)))).flat()
return {
context,
notifications
}
}
async function migrateInboxNotifications (client: MigrationClient): Promise<void> {
while (true) {
const docUpdates = await client.find<DocUpdates>(
DOMAIN_NOTIFICATION,
{
_class: notification.class.DocUpdates
},
{ limit: 500 }
)
if (docUpdates.length === 0) {
return
}
const data: InboxData[] = (
await Promise.all(docUpdates.map((docUpdate) => getInboxData(client, docUpdate)))
).filter((data): data is InboxData => data !== undefined)
await client.deleteMany(DOMAIN_NOTIFICATION, { _id: { $in: docUpdates.map(({ _id }) => _id) } })
await client.create(
DOMAIN_NOTIFICATION,
data.map(({ context }) => context)
)
await client.create(
DOMAIN_NOTIFICATION,
data.flatMap(({ notifications }) => notifications)
)
}
}
export const notificationOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {},
async migrate (client: MigrationClient): Promise<void> {
await tryMigrate(client, notificationId, [
{
state: 'inbox-notifications',
func: migrateInboxNotifications
}
])
},
async upgrade (client: MigrationUpgradeClient): Promise<void> {
await createSpace(client)
}

View File

@ -31,7 +31,8 @@ export default mergeIds(notificationId, notification, {
Archive: '' as IntlString,
MarkAsUnread: '' as IntlString,
MarkAsRead: '' as IntlString,
ChangeCollaborators: '' as IntlString
ChangeCollaborators: '' as IntlString,
Message: '' as IntlString
},
app: {
Notification: '' as Ref<Application>,
@ -48,31 +49,30 @@ export default mergeIds(notificationId, notification, {
component: {
NotificationSettings: '' as AnyComponent,
InboxAside: '' as AnyComponent,
ChatMessagePresenter: '' as AnyComponent,
DocUpdateMessagePresenter: '' as AnyComponent,
PinMessageAction: '' as AnyComponent
ActivityInboxNotificationPresenter: '' as AnyComponent,
CommonInboxNotificationPresenter: '' as AnyComponent
},
function: {
HasntNotifications: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
HasMarkAsUnreadAction: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
HasMarkAsReadAction: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
HasDeleteNotificationAction: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>
HasDocNotifyContextPinAction: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
HasDocNotifyContextUnpinAction: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>
},
category: {
Notification: '' as Ref<ActionCategory>
},
groups: {},
action: {
Unsubscribe: '' as Ref<Action>,
Hide: '' as Ref<Action>,
MarkAsUnread: '' as Ref<Action>
Unsubscribe: '' as Ref<Action>
},
actionImpl: {
Unsubscribe: '' as ViewAction,
Hide: '' as ViewAction,
MarkAsUnread: '' as ViewAction,
MarkAsUnreadInboxNotification: '' as ViewAction,
MarkAsReadInboxNotification: '' as ViewAction,
DeleteInboxNotification: '' as ViewAction
DeleteInboxNotification: '' as ViewAction,
UnpinDocNotifyContext: '' as ViewAction,
PinDocNotifyContext: '' as ViewAction,
HideDocNotifyContext: '' as ViewAction,
UnHideDocNotifyContext: '' as ViewAction
}
})

View File

@ -298,6 +298,7 @@ export function createModel (builder: Builder): void {
locationResolver: recruit.resolver.Location,
alias: recruitId,
hidden: false,
aside: chunter.component.ThreadView,
navigatorModel: {
spaces: [],
specials: [
@ -1619,6 +1620,7 @@ export function createModel (builder: Builder): void {
chunter.class.ChatMessageViewlet,
core.space.Model,
{
messageClass: chunter.class.ChatMessage,
objectClass: recruit.class.Vacancy,
label: chunter.string.LeftComment
},
@ -1629,6 +1631,7 @@ export function createModel (builder: Builder): void {
chunter.class.ChatMessageViewlet,
core.space.Model,
{
messageClass: chunter.class.ChatMessage,
objectClass: recruit.class.Applicant,
label: chunter.string.LeftComment
},
@ -1639,6 +1642,7 @@ export function createModel (builder: Builder): void {
chunter.class.ChatMessageViewlet,
core.space.Model,
{
messageClass: chunter.class.ChatMessage,
objectClass: recruit.class.Review,
label: chunter.string.LeftComment
},

View File

@ -28,6 +28,7 @@
"@hcengineering/activity": "^0.6.0",
"@hcengineering/core": "^0.6.28",
"@hcengineering/model": "^0.6.7",
"@hcengineering/model-activity": "^0.6.0",
"@hcengineering/platform": "^0.6.9",
"@hcengineering/server-activity": "^0.6.0",
"@hcengineering/server-activity-resources": "^0.6.0",

View File

@ -22,15 +22,13 @@ export { activityServerOperation } from './migration'
export { serverActivityId } from '@hcengineering/server-activity'
export function createModel (builder: Builder): void {
// NOTE: temporarily disabled
// builder.createDoc(serverCore.class.Trigger, core.space.Model, {
// trigger: serverNotification.trigger.OnReactionChanged,
// txMatch: {
// collection: 'reactions',
// objectClass: activity.class.ActivityMessage,
// _class: core.class.TxCollectionCUD
// }
// })
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverActivity.trigger.OnReactionChanged,
txMatch: {
collection: 'reactions',
_class: core.class.TxCollectionCUD
}
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverActivity.trigger.ActivityMessagesHandler

View File

@ -36,6 +36,7 @@ import {
serverActivityId
} from '@hcengineering/server-activity'
import { generateDocUpdateMessages } from '@hcengineering/server-activity-resources'
import { DOMAIN_ACTIVITY } from '@hcengineering/model-activity'
function getActivityControl (client: MigrationClient): ActivityControl {
const txFactory = new TxFactory(core.account.System, false)
@ -55,6 +56,16 @@ async function generateDocUpdateMessageByTx (
client: MigrationClient,
objectCache?: DocObjectCache
): Promise<void> {
const existsMessages = await client.find<DocUpdateMessage>(
DOMAIN_ACTIVITY,
{ _class: activity.class.DocUpdateMessage, txId: tx._id },
{ projection: { _id: 1 } }
)
if (existsMessages.length > 0) {
return
}
const createCollectionCUDTxes = await generateDocUpdateMessages(tx, control, undefined, undefined, objectCache)
for (const collectionTx of createCollectionCUDTxes) {
@ -206,8 +217,14 @@ export const activityServerOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await tryMigrate(client, serverActivityId, [
{
state: 'activity-messages',
func: createDocUpdateMessages
state: 'doc-update-messages',
func: async (client) => {
// Recreate activity to avoid duplicates
await client.deleteMany(DOMAIN_ACTIVITY, {
_class: activity.class.DocUpdateMessage
})
await createDocUpdateMessages(client)
}
}
])
},

View File

@ -21,6 +21,7 @@ import serverNotification from '@hcengineering/server-notification'
import serverCore, { type ObjectDDParticipant } from '@hcengineering/server-core'
import serverChunter from '@hcengineering/server-chunter'
import notification from '@hcengineering/notification'
export { serverChunterId } from '@hcengineering/server-chunter'
export function createModel (builder: Builder): void {
@ -45,7 +46,7 @@ export function createModel (builder: Builder): void {
presenter: serverChunter.function.ChunterNotificationContentProvider
})
builder.mixin(chunter.class.Message, core.class.Class, serverNotification.mixin.NotificationPresenter, {
builder.mixin(chunter.class.ChatMessage, core.class.Class, serverNotification.mixin.NotificationPresenter, {
presenter: serverChunter.function.ChunterNotificationContentProvider
})
@ -58,11 +59,22 @@ export function createModel (builder: Builder): void {
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverChunter.trigger.OnMessageSent,
trigger: serverChunter.trigger.OnDirectMessageSent,
txMatch: {
objectClass: chunter.class.DirectMessage,
_class: core.class.TxCollectionCUD,
collection: 'messages'
objectClass: chunter.class.DirectMessage,
collection: 'messages',
'tx._class': core.class.TxCreateDoc,
'tx.objectClass': chunter.class.ChatMessage
}
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverChunter.trigger.OnChatMessageRemoved,
txMatch: {
_class: core.class.TxCollectionCUD,
'tx._class': core.class.TxRemoveDoc,
'tx.objectClass': chunter.class.ChatMessage
}
})

View File

@ -31,6 +31,7 @@ import serverNotification, {
type NotificationContentProvider
} from '@hcengineering/server-notification'
import chunter from '@hcengineering/model-chunter'
import activity from '@hcengineering/activity'
export { serverNotificationId } from '@hcengineering/server-notification'
@ -60,17 +61,36 @@ export function createModel (builder: Builder): void {
builder.createModel(THTMLPresenter, TTextPresenter, TTypeMatch, TNotificationPresenter)
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverNotification.trigger.OnBacklinkCreate
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverNotification.trigger.NotificationMessagesHandler
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverNotification.trigger.OnChatMessageSent,
trigger: serverNotification.trigger.ActivityNotificationsHandler,
txMatch: {
objectClass: chunter.class.ChatMessage
_class: core.class.TxCollectionCUD,
'tx._class': core.class.TxCreateDoc,
'tx.objectClass': activity.class.DocUpdateMessage
}
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverNotification.trigger.OnActivityNotificationViewed,
txMatch: {
_class: core.class.TxUpdateDoc,
objectClass: notification.class.ActivityInboxNotification
}
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverNotification.trigger.OnChatMessageCreate,
txMatch: {
_class: core.class.TxCollectionCUD,
'tx._class': core.class.TxCreateDoc,
'tx.objectClass': chunter.class.ChatMessage
}
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverNotification.trigger.OnBacklinkCreate,
txMatch: {
_class: core.class.TxCollectionCUD,
'tx._class': core.class.TxCreateDoc
}
})

View File

@ -161,6 +161,24 @@ function defineFilters (builder: Builder): void {
getVisibleFilters: tracker.function.GetVisibleFilters
})
builder.mixin(tracker.class.Issue, core.class.Class, view.mixin.ObjectIdentifier, {
provider: tracker.function.IssueTitleProvider
})
builder.mixin(tracker.class.Issue, core.class.Class, chunter.mixin.ObjectChatPanel, {
ignoreKeys: [
'number',
'createdBy',
'attachedTo',
'title',
'collaborators',
'description',
'remainingTime',
'reportedTime'
],
titleProvider: tracker.function.IssueChatTitleProvider
})
//
// Issue Status
//
@ -256,6 +274,7 @@ function defineApplication (
alias: trackerId,
hidden: false,
locationResolver: tracker.resolver.Location,
aside: chunter.component.ThreadView,
navigatorModel: {
specials: [
{
@ -607,6 +626,7 @@ export function createModel (builder: Builder): void {
chunter.class.ChatMessageViewlet,
core.space.Model,
{
messageClass: chunter.class.ChatMessage,
objectClass: tracker.class.Issue,
label: chunter.string.LeftComment
},
@ -617,6 +637,7 @@ export function createModel (builder: Builder): void {
chunter.class.ChatMessageViewlet,
core.space.Model,
{
messageClass: chunter.class.ChatMessage,
objectClass: tracker.class.IssueTemplate,
label: chunter.string.LeftComment
},
@ -627,6 +648,7 @@ export function createModel (builder: Builder): void {
chunter.class.ChatMessageViewlet,
core.space.Model,
{
messageClass: chunter.class.ChatMessage,
objectClass: tracker.class.Component,
label: chunter.string.LeftComment
},
@ -637,6 +659,7 @@ export function createModel (builder: Builder): void {
chunter.class.ChatMessageViewlet,
core.space.Model,
{
messageClass: chunter.class.ChatMessage,
objectClass: tracker.class.Milestone,
label: chunter.string.LeftComment
},

View File

@ -99,7 +99,9 @@ export default mergeIds(viewId, view, {
General: '' as IntlString,
Navigation: '' as IntlString,
Editor: '' as IntlString,
MarkdownFormatting: '' as IntlString
MarkdownFormatting: '' as IntlString,
Pin: '' as IntlString,
Unpin: '' as IntlString
},
function: {
FilterArrayAllResult: '' as FilterFunction,

View File

@ -22,8 +22,6 @@ import workbench from '@hcengineering/workbench-resources/src/plugin'
export default mergeIds(workbenchId, workbench, {
component: {
ApplicationPresenter: '' as AnyComponent,
Archive: '' as AnyComponent,
SpaceBrowser: '' as AnyComponent,
SpecialView: '' as AnyComponent,
ServerManager: '' as AnyComponent
},

View File

@ -1,11 +1,23 @@
function isToday (time: number): boolean {
const current = new Date()
const target = new Date(time)
return (
current.getDate() === target.getDate() &&
current.getMonth() === target.getMonth() &&
current.getFullYear() === target.getFullYear()
)
//
// 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.
//
import { Timestamp } from './classes'
export function getDay (time: Timestamp): Timestamp {
const date: Date = new Date(time)
date.setHours(0, 0, 0, 0)
return date.getTime()
}
export function getDisplayTime (time: number): string {
@ -20,3 +32,17 @@ export function getDisplayTime (time: number): string {
return new Date(time).toLocaleString('default', options)
}
export function isOtherDay (time1: Timestamp, time2: Timestamp): boolean {
return getDay(time1) !== getDay(time2)
}
function isToday (time: number): boolean {
const current = new Date()
const target = new Date(time)
return (
current.getDate() === target.getDate() &&
current.getMonth() === target.getMonth() &&
current.getFullYear() === target.getFullYear()
)
}

View File

@ -94,7 +94,7 @@ export interface MigrationClient {
create: <T extends Doc>(domain: Domain, doc: T | T[]) => Promise<void>
delete: <T extends Doc>(domain: Domain, _id: Ref<T>) => Promise<void>
deleteMany: <T extends Doc>(domain: Domain, ids: Ref<T>[]) => Promise<void>
deleteMany: <T extends Doc>(domain: Domain, query: DocumentQuery<T>) => Promise<void>
hierarchy: Hierarchy
model: ModelDb

View File

@ -48,6 +48,7 @@
dispatch('close')
}}
/>
<div class="mr-4" />
<Button
label={presentation.string.Cancel}
on:click={() => {

View File

@ -64,6 +64,7 @@
on:action
on:valid
on:validate
on:submit
>
<slot />
</Ctor>
@ -81,6 +82,7 @@
on:action
on:valid
on:validate
on:submit
/>
{/if}
</ErrorBoundary>

View File

@ -9,4 +9,7 @@
d="M10 2C14.4183 2 18 5.58172 18 10C18 14.4183 14.4183 18 10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2ZM10 3C6.13401 3 3 6.13401 3 10C3 13.866 6.13401 17 10 17C13.866 17 17 13.866 17 10C17 6.13401 13.866 3 10 3ZM7.15467 12.4273C8.66416 13.9463 11.0877 14.0045 12.6671 12.5961L12.8453 12.4273C13.04 12.2314 13.3566 12.2304 13.5524 12.4251C13.7265 12.5981 13.7467 12.8674 13.6123 13.0627L13.5547 13.1322L13.5323 13.1545C11.5691 15.1054 8.39616 15.0953 6.44533 13.1322C6.25069 12.9363 6.25169 12.6197 6.44757 12.4251C6.64344 12.2304 6.96002 12.2314 7.15467 12.4273ZM12.5 7.5C13.0523 7.5 13.5 7.94772 13.5 8.5C13.5 9.05228 13.0523 9.5 12.5 9.5C11.9477 9.5 11.5 9.05228 11.5 8.5C11.5 7.94772 11.9477 7.5 12.5 7.5ZM7.5 7.5C8.05228 7.5 8.5 7.94772 8.5 8.5C8.5 9.05228 8.05228 9.5 7.5 9.5C6.94772 9.5 6.5 9.05228 6.5 8.5C6.5 7.94772 6.94772 7.5 7.5 7.5Z"
/>
</symbol>
<symbol id="bookmark" viewBox="0 0 24 24">
<path d="M20,4.2h-2.2V2c0-0.4-0.3-0.8-0.8-0.8H4C3.6,1.2,3.2,1.6,3.2,2v17c0,0.3,0.1,0.5,0.4,0.6c0.2,0.1,0.5,0.1,0.7,0 l1.9-1V22c0,0.3,0.1,0.5,0.4,0.6c0.2,0.1,0.5,0.2,0.7,0l6.2-3l6.2,3c0.2,0.1,0.5,0.1,0.7,0c0.2-0.1,0.4-0.4,0.4-0.6V5 C20.8,4.6,20.4,4.2,20,4.2z M4.8,17.8v-15h11.5v1.5H7C6.6,4.2,6.2,4.6,6.2,5v12L4.8,17.8z M19.2,20.8l-5.4-2.6 c-0.2-0.1-0.4-0.1-0.7,0l-5.4,2.6V5.8h11.5V20.8z" />
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -17,12 +17,15 @@
"For": "For",
"From": "from",
"In": "In",
"LastReply": "Last reply",
"New": "New",
"NewestFirst": "Newest first",
"Pinned": "Pinned",
"Reacted": "Reacted",
"Reactions": "Reactions",
"Removed": "removed",
"Replies": "Replies",
"RepliesCount": "{replies, plural, =1 {# reply} other {# replies}}",
"Set": "set",
"To": "to",
"Unset": "Unset",

View File

@ -17,12 +17,15 @@
"For": "Для",
"From": "из",
"In": "В",
"LastReply": "Последний ответ",
"New": "Новые",
"NewestFirst": "Сначала новые",
"Pinned": "Закрепленные",
"Reacted": "Отреагировал(а)",
"Reactions": "Реакции",
"Removed": "Удалил(а)",
"Replies": "Ответы",
"RepliesCount": "{replies, plural, one {# ответ} few {# ответа} other {# ответов}}",
"Set": "установлен",
"To": "на",
"Unset": "Cбросил",

View File

@ -19,5 +19,6 @@ import { loadMetadata } from '@hcengineering/platform'
const icons = require('../assets/icons.svg') as string // eslint-disable-line
loadMetadata(activity.icon, {
Activity: `${icons}#activity`,
Emoji: `${icons}#emoji`
Emoji: `${icons}#emoji`,
Bookmark: `${icons}#bookmark`
})

View File

@ -39,7 +39,9 @@
"@hcengineering/contact": "^0.6.20",
"@hcengineering/contact-resources": "^0.6.0",
"@hcengineering/core": "^0.6.28",
"@hcengineering/notification": "^0.6.16",
"@hcengineering/platform": "^0.6.9",
"@hcengineering/preference": "^0.6.9",
"@hcengineering/presentation": "^0.6.2",
"@hcengineering/ui": "^0.6.11",
"@hcengineering/view": "^0.6.9",

View File

@ -22,18 +22,20 @@ import core, {
groupByArray,
type Hierarchy,
type Ref,
SortingOrder
SortingOrder,
type Timestamp
} from '@hcengineering/core'
import view, { type AttributeModel } from '@hcengineering/view'
import { getClient, getFiltredKeys } from '@hcengineering/presentation'
import { buildRemovedDoc, getAttributePresenter, getDocLinkTitle } from '@hcengineering/view-resources'
import { type Person } from '@hcengineering/contact'
import { type IntlString } from '@hcengineering/platform'
import { getResource, type IntlString } from '@hcengineering/platform'
import { type AnyComponent } from '@hcengineering/ui'
import { get } from 'svelte/store'
import { personAccountByIdStore } from '@hcengineering/contact-resources'
import activity, {
type ActivityMessage,
type ActivityMessagesFilter,
type DisplayActivityMessage,
type DisplayDocUpdateMessage,
type DocAttributeUpdates,
@ -381,6 +383,10 @@ export function pinnedFilter (message: ActivityMessage, _class?: Ref<Doc>): bool
return message.isPinned === true
}
export function allFilter (): boolean {
return true
}
export interface LinkData {
title?: string
preposition: IntlString
@ -427,3 +433,57 @@ export async function getLinkData (
object: linkObject
}
}
export async function getMessageFragment (doc: Doc): Promise<string> {
const client = getClient()
const hierarchy = client.getHierarchy()
let clazz = hierarchy.getClass(doc._class)
let label = clazz.shortLabel
while (label === undefined && clazz.extends !== undefined) {
clazz = hierarchy.getClass(clazz.extends)
label = clazz.shortLabel
}
label = label ?? doc._class
return `${label}-${doc._id}`
}
export async function filterActivityMessages (
messages: DisplayActivityMessage[],
filters: ActivityMessagesFilter[],
objectClass: Ref<Class<Doc>>,
filterId?: Ref<ActivityMessagesFilter>
): Promise<DisplayActivityMessage[]> {
if (filterId === undefined || filterId === activity.ids.AllFilter) {
return messages
}
const filter = filters.find(({ _id }) => _id === filterId)
if (filter === undefined) {
return messages
}
const filterFn = await getResource(filter.filter)
return messages.filter((message) => filterFn(message, objectClass))
}
export function getClosestDateSelectorDate (date: Timestamp, scrollElement: HTMLDivElement): Timestamp | undefined {
const dateSelectors = scrollElement.getElementsByClassName('dateSelector')
if (dateSelectors === undefined || dateSelectors.length === 0) {
return
}
let closestDate: Timestamp | undefined = parseInt(dateSelectors[dateSelectors.length - 1].id)
for (const elem of Array.from(dateSelectors).reverse()) {
const curDate = parseInt(elem.id)
if (curDate < date) break
else if (curDate - date < closestDate - date) {
closestDate = curDate
}
}
return closestDate
}

View File

@ -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}

View File

@ -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
}
}}
>

View File

@ -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>

View File

@ -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} />

View File

@ -1,8 +1,21 @@
<!--
// 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 { Timestamp } from '@hcengineering/core'
import { getDay, Timestamp } from '@hcengineering/core'
import { DateRangePopup, showPopup } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { getDay } from '../utils'
export let selectedDate: Timestamp | undefined
export let fixed: boolean = false
@ -17,7 +30,6 @@
<div id={fixed ? '' : time?.toString()} class="flex-center clear-mins dateSelector">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={div}
class="border-radius-4 over-underline dateSelectorButton clear-mins"

View File

@ -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>

View File

@ -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}
/>

View File

@ -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}
>

View File

@ -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}

View File

@ -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;
}
}

View File

@ -1,5 +1,5 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
// 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
@ -21,9 +21,15 @@
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}>
<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>
@ -35,6 +41,7 @@
height: 1px;
background-color: var(--divider-color);
}
.new {
.line {
background-color: var(--highlight-red);

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -21,7 +21,7 @@ import DocUpdateMessagePresenter from './components/doc-update-message/DocUpdate
import ActivityInfoMessagePresenter from './components/activity-message/ActivityInfoMessagePresenter.svelte'
import ReactionAddedMessage from './components/reactions/ReactionAddedMessage.svelte'
import { attributesFilter, pinnedFilter } from './activityMessagesUtils'
import { getMessageFragment, attributesFilter, pinnedFilter, allFilter } from './activityMessagesUtils'
export * from './activity'
export * from './utils'
@ -31,6 +31,7 @@ export { default as Reactions } from './components/reactions/Reactions.svelte'
export { default as ActivityMessageTemplate } from './components/activity-message/ActivityMessageTemplate.svelte'
export { default as ActivityMessagePresenter } from './components/activity-message/ActivityMessagePresenter.svelte'
export { default as ActivityExtension } from './components/ActivityExtension.svelte'
export { default as ActivityScrolledView } from './components/ActivityScrolledView.svelte'
export { default as ActivityMessageHeader } from './components/activity-message/ActivityMessageHeader.svelte'
export default async (): Promise<Resources> => ({
@ -43,6 +44,10 @@ export default async (): Promise<Resources> => ({
},
filter: {
AttributesFilter: attributesFilter,
PinnedFilter: pinnedFilter
PinnedFilter: pinnedFilter,
AllFilter: allFilter
},
function: {
GetFragment: getMessageFragment
}
})

View File

@ -29,8 +29,11 @@
"typescript": "^5.2.2"
},
"dependencies": {
"@hcengineering/contact": "^0.6.20",
"@hcengineering/core": "^0.6.28",
"@hcengineering/platform": "^0.6.9",
"@hcengineering/ui": "^0.6.11"
"@hcengineering/preference": "^0.6.9",
"@hcengineering/ui": "^0.6.11",
"@hcengineering/view": "^0.6.9"
}
}

View File

@ -13,6 +13,7 @@
// limitations under the License.
//
import { Person } from '@hcengineering/contact'
import {
Account,
AttachedDoc,
@ -32,6 +33,7 @@ import {
} from '@hcengineering/core'
import type { Asset, IntlString, Plugin, Resource } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import { Preference } from '@hcengineering/preference'
import type { AnyComponent } from '@hcengineering/ui'
// TODO: remove TxViewlet
@ -110,10 +112,14 @@ export interface ActivityMessage extends AttachedDoc {
isPinned?: boolean
repliedPersons?: Ref<Person>[]
lastReply?: Timestamp
replies?: number
reactions?: number
}
export type DisplayActivityMessage = DisplayDocUpdateMessage | ActivityMessage | ActivityInfoMessage
export type DisplayActivityMessage = DisplayDocUpdateMessage | ActivityMessage
export interface DisplayDocUpdateMessage extends DocUpdateMessage {
previousMessages?: DocUpdateMessage[]
@ -228,6 +234,7 @@ export const activityId = 'activity' as Plugin
*/
export interface ActivityMessagesFilter extends Doc {
label: IntlString
position: number
filter: Resource<(message: ActivityMessage, _class?: Ref<Doc>) => boolean>
}
@ -262,10 +269,19 @@ export interface ActivityExtension extends Doc {
* @public
*/
export interface Reaction extends AttachedDoc {
attachedTo: Ref<ActivityMessage>
attachedToClass: Ref<Class<ActivityMessage>>
emoji: string
createBy: Ref<Account>
}
/**
* @public
*/
export interface SavedMessage extends Preference {
attachedTo: Ref<ActivityMessage>
}
/**
* @public
*/
@ -287,11 +303,13 @@ export default plugin(activityId, {
ActivityMessageExtension: '' as Ref<Class<ActivityMessageExtension>>,
ActivityMessagesFilter: '' as Ref<Class<ActivityMessagesFilter>>,
ActivityExtension: '' as Ref<Class<ActivityExtension>>,
Reaction: '' as Ref<Class<Reaction>>
Reaction: '' as Ref<Class<Reaction>>,
SavedMessage: '' as Ref<Class<SavedMessage>>
},
icon: {
Activity: '' as Asset,
Emoji: '' as Asset
Emoji: '' as Asset,
Bookmark: '' as Asset
},
string: {
Activity: '' as IntlString,
@ -313,7 +331,9 @@ export default plugin(activityId, {
Update: '' as IntlString,
For: '' as IntlString,
AllActivity: '' as IntlString,
Reactions: '' as IntlString
Reactions: '' as IntlString,
LastReply: '' as IntlString,
RepliesCount: '' as IntlString
},
component: {
Activity: '' as AnyComponent,
@ -321,5 +341,8 @@ export default plugin(activityId, {
DocUpdateMessagePresenter: '' as AnyComponent,
ActivityInfoMessagePresenter: '' as AnyComponent,
ReactionAddedMessage: '' as AnyComponent
},
ids: {
AllFilter: '' as Ref<ActivityMessagesFilter>
}
})

View File

@ -14,14 +14,18 @@
-->
<script lang="ts">
import { Attachment } from '@hcengineering/attachment'
import type { Doc } from '@hcengineering/core'
import type { Doc, Ref } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import attachment from '../plugin'
import AttachmentList from './AttachmentList.svelte'
export let value: Doc & { attachments?: number }
const query = createQuery()
const savedAttachmentsQuery = createQuery()
let savedAttachmentsIds: Ref<Attachment>[] = []
let attachments: Attachment[] = []
$: updateQuery(value)
@ -41,6 +45,10 @@
attachments = []
}
}
savedAttachmentsQuery.query(attachment.class.SavedAttachments, {}, (res) => {
savedAttachmentsIds = res.map(({ attachedTo }) => attachedTo)
})
</script>
<AttachmentList {attachments} />
<AttachmentList {attachments} {savedAttachmentsIds} />

View File

@ -24,7 +24,6 @@ export default mergeIds(attachmentId, attachment, {
NoAttachments: '' as IntlString,
UploadDropFilesHere: '' as IntlString,
Photos: '' as IntlString,
FileBrowser: '' as IntlString,
FileBrowserFileCounter: '' as IntlString,
FileBrowserListView: '' as IntlString,
FileBrowserGridView: '' as IntlString,

View File

@ -56,7 +56,8 @@ export default plugin(attachmentId, {
component: {
Attachments: '' as AnyComponent,
Photos: '' as AnyComponent,
AttachmentsPresenter: '' as AnyComponent
AttachmentsPresenter: '' as AnyComponent,
FileBrowser: '' as AnyComponent
},
icon: {
Attachment: '' as Asset,
@ -89,6 +90,7 @@ export default plugin(attachmentId, {
FileBrowserTypeFilterVideos: '' as IntlString,
FileBrowserTypeFilterPDFs: '' as IntlString,
DeleteFile: '' as IntlString,
Attachments: '' as IntlString
Attachments: '' as IntlString,
FileBrowser: '' as IntlString
}
})

View File

@ -15,14 +15,14 @@
<script lang="ts">
import { Doc } from '@hcengineering/core'
import notification from '@hcengineering/notification'
import { NotificationClientImpl } from '@hcengineering/notification-resources'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { Icon, IconSize } from '@hcengineering/ui'
export let object: Doc
export let size: IconSize = 'small'
const notificationClient = NotificationClientImpl.getClient()
const store = notificationClient.docUpdatesStore
const inboxClient = InboxNotificationsClientImpl.getClient()
const store = inboxClient.docNotifyContextByDoc
$: subscribed = $store.get(object._id) !== undefined
</script>

View File

@ -11,9 +11,6 @@
<symbol id="thread" viewBox="0 0 24 24">
<path d="M22.8,10.2c0-5-4.3-9-9.6-9s-9.6,4-9.6,9c0,0.1,0,0.3,0,0.4c-1.5,1.1-2.4,2.8-2.4,4.6c0,1.7,0.8,3.3,2.2,4.4 l-0.2,1.5c-0.1,0.5,0.2,1,0.6,1.3c0.2,0.1,0.5,0.2,0.7,0.2c0.2,0,0.5-0.1,0.7-0.2l2.4-1.4c1.7-0.1,3.3-0.8,4.4-1.9 c0.3,0,0.6,0.1,0.8,0.1l4,2.4c0.3,0.2,0.6,0.3,0.9,0.3c0.3,0,0.7-0.1,0.9-0.3c0.6-0.4,0.9-1,0.8-1.7l-0.3-2.7 C21.5,15.5,22.8,13,22.8,10.2z M7.5,19.6c-0.1,0-0.3,0-0.4,0.1l-2.4,1.4l0.2-1.7c0-0.3-0.1-0.6-0.3-0.7c-1.2-0.8-1.9-2.1-1.9-3.5 c0-1.4,0.8-2.8,2-3.6c0.8-0.5,1.7-0.8,2.7-0.8c2.3,0,4.2,1.5,4.7,3.5c0.1,0.3,0.1,0.6,0.1,0.9c0,0.2,0,0.5-0.1,0.7c0,0,0,0.1,0,0.1 c-0.1,0.7-0.4,1.3-0.9,1.9C10.4,19,9,19.6,7.5,19.6z M18,16.2c-0.2,0.2-0.3,0.4-0.3,0.7l0.4,3.2c0,0.1-0.1,0.2-0.1,0.2 c-0.1,0-0.1,0.1-0.3,0l-4.2-2.5c-0.1-0.1-0.3-0.1-0.4-0.1c0.1-0.2,0.2-0.3,0.2-0.5c0,0,0,0,0-0.1c0.1-0.3,0.2-0.6,0.2-0.8 c0-0.1,0-0.1,0-0.2c0-0.3,0.1-0.6,0.1-0.9c0-0.2,0-0.4,0-0.6c-0.3-3-3-5.3-6.2-5.3c-0.3,0-0.6,0-0.8,0.1c-0.1,0-0.1,0-0.2,0 c-0.3,0-0.5,0.1-0.8,0.2c0,0,0,0-0.1,0C5.4,9.7,5.3,9.7,5.1,9.8c0.3-3.9,3.8-7,8.1-7c4.5,0,8.1,3.4,8.1,7.5 C21.2,12.6,20.1,14.8,18,16.2z" />
</symbol>
<symbol id="bookmark" viewBox="0 0 24 24">
<path d="M20,4.2h-2.2V2c0-0.4-0.3-0.8-0.8-0.8H4C3.6,1.2,3.2,1.6,3.2,2v17c0,0.3,0.1,0.5,0.4,0.6c0.2,0.1,0.5,0.1,0.7,0 l1.9-1V22c0,0.3,0.1,0.5,0.4,0.6c0.2,0.1,0.5,0.2,0.7,0l6.2-3l6.2,3c0.2,0.1,0.5,0.1,0.7,0c0.2-0.1,0.4-0.4,0.4-0.6V5 C20.8,4.6,20.4,4.2,20,4.2z M4.8,17.8v-15h11.5v1.5H7C6.6,4.2,6.2,4.6,6.2,5v12L4.8,17.8z M19.2,20.8l-5.4-2.6 c-0.2-0.1-0.4-0.1-0.7,0l-5.4,2.6V5.8h11.5V20.8z" />
</symbol>
<symbol id="channelbrowser" viewBox="0 0 24 24">
<path d="M12.4,20.2H8.6c-4.3,0-5.9-1.6-5.9-5.8V8.7c0-4.4,1.5-6,5.9-6h5.7c4.4,0,6,1.6,6,6v3.7c0,0.4,0.3,0.7,0.8,0.7 s0.8-0.3,0.8-0.7V8.7c0-5.2-2.2-7.5-7.5-7.5H8.6c-5.2,0-7.4,2.2-7.4,7.5v5.7c0,5.1,2.3,7.3,7.4,7.3h3.8c0.4,0,0.7-0.3,0.7-0.8 S12.8,20.2,12.4,20.2z" />
<path d="M22.2,21.4l-1-1c0,0,0,0-0.1,0c0.4-0.6,0.7-1.4,0.7-2.2c0-2.2-1.7-3.9-3.9-3.9s-4,1.7-4,3.9c0,2.1,1.8,4,4,4 c0.8,0,1.6-0.2,2.2-0.7c0,0,0,0,0,0.1l1,1c0.1,0.1,0.3,0.2,0.5,0.2s0.4-0.1,0.5-0.2C22.5,22.1,22.5,21.7,22.2,21.4z M17.9,20.5 c-1.3,0-2.5-1.2-2.5-2.5c0-1.4,1.1-2.4,2.5-2.4s2.4,1.1,2.4,2.4S19.3,20.5,17.9,20.5z" />

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -29,9 +29,6 @@
"Chat": "Chat",
"In": "In",
"MentionNotification": "Mentioned",
"Replies": "Replies",
"LastReply": "Last reply",
"RepliesCount": "{replies, plural, =1 {# reply} other {# replies}}",
"Topic": "Topic",
"Thread": "Thread",
"Threads": "Threads",
@ -80,6 +77,13 @@
"DirectNotificationTitle": "{senderName}",
"DirectNotificationBody": "{message}",
"AddCommentPlaceholder": "Add comment...",
"Reacted": "Reacted"
"Reacted": "Reacted",
"Saved": "Saved",
"Docs": "Documents",
"NewestFirst": "Newest first",
"ReplyToThread": "Reply to thread",
"SentMessage": "Sent message",
"Direct": "direct",
"RepliedToThread": "Replied to thread"
}
}

View File

@ -29,9 +29,6 @@
"Reference": "Ссылка",
"Chat": "Чат",
"In": "в",
"Replies": "Ответы",
"LastReply": "Последний ответ",
"RepliesCount": "{replies, plural, one {# ответ} few {# ответа} other {# ответов}}",
"Topic": "Топик",
"Thread": "Обсуждение",
"Threads": "Обсуждения",
@ -80,6 +77,13 @@
"DirectNotificationTitle": "{senderName}",
"DirectNotificationBody": "{message}",
"AddCommentPlaceholder": "Добавить комментарий...",
"Reacted": "Отреагировал(а)"
"Reacted": "Отреагировал(а)",
"Saved": "Сохранено",
"Docs": "Documents",
"NewestFirst": "Сначала новые",
"ReplyToThread": "Ответить в канале",
"SentMessage": "Отправил(а) сообщение",
"Direct": "личные сообщения",
"RepliedToThread": "Ответил(а) в канале"
}
}

View File

@ -22,6 +22,5 @@ loadMetadata(chunter.icon, {
Hashtag: `${icons}#hashtag`,
Thread: `${icons}#thread`,
Lock: `${icons}#lock`,
Bookmark: `${icons}#bookmark`,
ChannelBrowser: `${icons}#channelbrowser`
})

View File

@ -1,5 +1,5 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
// 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
@ -13,228 +13,39 @@
// limitations under the License.
-->
<script lang="ts">
import attachment, { Attachment } from '@hcengineering/attachment'
import type { ChunterMessage, Message } from '@hcengineering/chunter'
import core, { Doc, Ref, Space, Timestamp, WithLookup } from '@hcengineering/core'
import { DocUpdates } from '@hcengineering/notification'
import { NotificationClientImpl } from '@hcengineering/notification-resources'
import { createQuery } from '@hcengineering/presentation'
import { Doc, Ref } from '@hcengineering/core'
import { DocNotifyContext } from '@hcengineering/notification'
import { location as locationStore } from '@hcengineering/ui'
import { afterUpdate, beforeUpdate, onDestroy } from 'svelte'
import activity from '@hcengineering/activity'
import { onDestroy } from 'svelte'
import activity, { ActivityMessage, ActivityMessagesFilter } from '@hcengineering/activity'
import { ActivityScrolledView } from '@hcengineering/activity-resources'
import chunter from '../plugin'
import { getDay, isMessageHighlighted, messageIdForScroll, scrollAndHighLight, shouldScrollToMessage } from '../utils'
import ChannelSeparator from './ChannelSeparator.svelte'
import JumpToDateSelector from './JumpToDateSelector.svelte'
import MessageComponent from './Message.svelte'
export let space: Ref<Space> | undefined
export let pinnedIds: Ref<ChunterMessage>[]
export let savedMessagesIds: Ref<ChunterMessage>[]
export let savedAttachmentsIds: Ref<Attachment>[]
export let isScrollForced = false
export let content: HTMLElement | undefined = undefined
export let notifyContext: DocNotifyContext
export let object: Doc
export let filterId: Ref<ActivityMessagesFilter> = activity.ids.AllFilter
let autoscroll: boolean = false
let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
const unsubscribe = locationStore.subscribe((newLocation) => {
const messageId = newLocation.fragment
if (!messageId) {
messageIdForScroll.set('')
return
}
if (messageId === $messageIdForScroll) {
return
}
messageIdForScroll.set(messageId)
shouldScrollToMessage.set(true)
scrollAndHighLight()
selectedMessageId = newLocation.fragment as Ref<ActivityMessage>
})
onDestroy(unsubscribe)
beforeUpdate(() => {
autoscroll = content !== undefined && content.offsetHeight + content.scrollTop > content.scrollHeight - 20
})
afterUpdate(() => {
if ($shouldScrollToMessage && !$isMessageHighlighted) {
scrollAndHighLight()
return
}
if (content && (autoscroll || isScrollForced)) {
content.scrollTo(0, content.scrollHeight)
isScrollForced = false
}
})
let messages: WithLookup<Message>[] = []
const query = createQuery()
const notificationClient = NotificationClientImpl.getClient()
const docUpdates = notificationClient.docUpdatesStore
$: updateQuery(space)
function updateQuery (space: Ref<Space> | undefined) {
if (space === undefined) {
query.unsubscribe()
messages = []
return
}
query.query(
chunter.class.Message,
{
space
},
(res) => {
messages = res
newMessagesPos = newMessagesStart(messages, $docUpdates)
notificationClient.read(space)
},
{
lookup: {
_id: { attachments: attachment.class.Attachment, reactions: activity.class.Reaction },
createBy: core.class.Account
}
}
)
}
function newMessagesStart (messages: Message[], docUpdates: Map<Ref<Doc>, DocUpdates>): number {
if (space === undefined) return -1
const docUpdate = docUpdates.get(space)
const lastView = docUpdate?.txes?.findLast((tx) => !tx.isNew)
if (!docUpdate?.txes.some((tx) => tx.isNew)) return -1
if (docUpdate === undefined || lastView === undefined) return -1
for (let index = 0; index < messages.length; index++) {
const message = messages[index]
if ((message.createdOn ?? 0) >= lastView.modifiedOn) return index
}
return -1
}
$: markUnread(messages, $docUpdates)
function markUnread (messages: Message[], docUpdates: Map<Ref<Doc>, DocUpdates>) {
const newPos = newMessagesStart(messages, docUpdates)
if (newPos !== -1) {
newMessagesPos = newPos
}
}
let newMessagesPos: number = -1
function isOtherDay (time1: Timestamp, time2: Timestamp) {
return getDay(time1) !== getDay(time2)
}
function handleJumpToDate (e: CustomEvent<any>) {
const date = e.detail.date
if (!date) {
return
}
const dateSelectors = content?.getElementsByClassName('dateSelector')
if (!dateSelectors) return
let closestDate: Timestamp | undefined = parseInt(dateSelectors[dateSelectors.length - 1].id)
for (const elem of Array.from(dateSelectors).reverse()) {
const curDate = parseInt(elem.id)
if (curDate < date) break
else if (curDate - date < closestDate - date) {
closestDate = curDate
}
}
if (closestDate && closestDate < date) closestDate = undefined
if (closestDate) {
scrollToDate(closestDate)
}
}
const pinnedHeight = 30
const headerHeight = 50
function scrollToDate (date: Timestamp) {
let offset = date && document.getElementById(date.toString())?.offsetTop
if (offset) {
offset = offset - headerHeight - dateSelectorHeight / 2
if (pinnedIds.length > 0) offset = offset - pinnedHeight
content?.scrollTo({ left: 0, top: offset })
}
}
let showFixed: boolean | undefined
let selectedDate: Timestamp | undefined = undefined
function handleScroll () {
const upperVisible = getFirstVisible()
if (upperVisible) {
selectedDate = parseInt(upperVisible.id)
}
}
const dateSelectorHeight = 30
function getFirstVisible (): Element | undefined {
if (!content) return
const clientRect = content.getBoundingClientRect()
const dateSelectors = content.getElementsByClassName('dateSelector')
const firstVisible = 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 (firstVisible) {
showFixed = clientRect.top - firstVisible.getBoundingClientRect().top > -dateSelectorHeight / 2
}
return firstVisible
}
$: isDocChannel = ![chunter.class.DirectMessage, chunter.class.Channel].includes(notifyContext.attachedToClass)
$: messagesClass = isDocChannel ? activity.class.ActivityMessage : chunter.class.ChatMessage
$: collection = isDocChannel ? 'comments' : 'messages'
</script>
<div class="flex-col vScroll" bind:this={content} on:scroll={handleScroll}>
<div class="grower" />
{#if showFixed}
<div class="ml-2 pr-2 fixed">
<JumpToDateSelector {selectedDate} fixed on:jumpToDate={handleJumpToDate} />
</div>
{/if}
{#if messages}
{#each messages as message, i (message._id)}
{#if newMessagesPos === i}
<ChannelSeparator title={chunter.string.New} line reverse isNew />
{/if}
{#if i === 0 || isOtherDay(message.createdOn ?? 0, messages[i - 1].createdOn ?? 0)}
<JumpToDateSelector selectedDate={message.createdOn} on:jumpToDate={handleJumpToDate} />
{/if}
<MessageComponent
isHighlighted={$messageIdForScroll === message._id && $isMessageHighlighted}
{message}
on:openThread
isPinned={pinnedIds.includes(message._id)}
isSaved={savedMessagesIds.includes(message._id)}
{savedAttachmentsIds}
/>
{/each}
{/if}
</div>
<style lang="scss">
.grower {
flex-grow: 10;
flex-shrink: 5;
}
.fixed {
position: absolute;
align-self: center;
z-index: 1;
}
</style>
<ActivityScrolledView
_class={messagesClass}
{object}
skipLabels={!isDocChannel}
filter={filterId}
startFromBottom
{selectedMessageId}
{collection}
lastViewedTimestamp={notifyContext.lastViewedTimestamp}
/>

View File

@ -1,5 +1,5 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
// 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
@ -13,38 +13,38 @@
// limitations under the License.
-->
<script lang="ts">
import { Doc } from '@hcengineering/core'
import { getDocLinkTitle, getDocTitle } from '@hcengineering/view-resources'
import { getClient } from '@hcengineering/presentation'
import { Channel } from '@hcengineering/chunter'
import type { Ref } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { openDoc } from '@hcengineering/view-resources'
import chunter from '../plugin'
import { classIcon } from '../utils'
import Header from './Header.svelte'
import Lock from './icons/Lock.svelte'
export let spaceId: Ref<Channel> | undefined
import Header from './Header.svelte'
import chunter from '../plugin'
import { getChannelIcon } from '../utils'
export let object: Doc
const client = getClient()
const query = createQuery()
let channel: Channel | undefined
const hierarchy = client.getHierarchy()
$: query.query(chunter.class.Channel, { _id: spaceId }, (result) => {
channel = result[0]
})
$: topic = hierarchy.isDerived(object._class, chunter.class.Channel) ? (object as Channel).topic : undefined
async function onSpaceEdit (): Promise<void> {
if (channel === undefined) return
openDoc(client.getHierarchy(), channel)
async function getTitle (object: Doc) {
if (object._class === chunter.class.DirectMessage) {
return await getDocTitle(client, object._id, object._class, object)
}
return await getDocLinkTitle(client, object._id, object._class, object)
}
</script>
<div class="ac-header divide full caption-height">
{#if channel}
{#await getTitle(object) then title}
<Header
icon={channel.private ? Lock : classIcon(client, channel._class)}
label={channel.name}
description={channel.topic}
on:click={onSpaceEdit}
icon={getChannelIcon(object)}
iconProps={{ value: object }}
label={title}
intlLabel={title ? undefined : chunter.string.Channel}
description={topic}
/>
{/if}
{/await}
</div>

View File

@ -15,22 +15,20 @@
<script lang="ts">
import { SortingOrder } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import chunter, { ChunterMessage, DirectMessage } from '@hcengineering/chunter'
import attachment from '@hcengineering/attachment'
import chunter, { ChatMessage, DirectMessage } from '@hcengineering/chunter'
import { Label } from '@hcengineering/ui'
import { ActivityMessagePresenter } from '@hcengineering/activity-resources'
import chunterResources from '../plugin'
import MessagePreview from './MessagePreview.svelte'
export let object: DirectMessage
export let newTxes: number
const NUM_OF_RECENT_MESSAGES = 5 as const
let messages: ChunterMessage[] = []
let messages: ChatMessage[] = []
const messagesQuery = createQuery()
$: messagesQuery.query(
chunter.class.ChunterMessage,
chunter.class.ChatMessage,
{ attachedTo: object._id },
(res) => {
if (res !== undefined) {
@ -38,12 +36,9 @@
}
},
{
limit: newTxes + NUM_OF_RECENT_MESSAGES,
limit: NUM_OF_RECENT_MESSAGES,
sort: {
createdOn: SortingOrder.Descending
},
lookup: {
_id: { attachments: attachment.class.Attachment }
}
}
)
@ -52,7 +47,7 @@
<div class="flex-col flex-gap-3 preview-container">
{#if messages.length}
{#each messages as message}
<MessagePreview value={message} />
<ActivityMessagePresenter value={message} skipLabel />
{/each}
{:else}
<Label label={chunterResources.string.NoMessages} />

View File

@ -1,5 +1,5 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
// 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
@ -13,97 +13,34 @@
// limitations under the License.
-->
<script lang="ts">
import attachment, { Attachment } from '@hcengineering/attachment'
import { AttachmentRefInput } from '@hcengineering/attachment-resources'
import { ChunterMessage, ChunterSpace, Message } from '@hcengineering/chunter'
import { AttachedDoc, Ref, Space, generateId, getCurrentAccount } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Ref, Doc } from '@hcengineering/core'
import { getLocation, navigate } from '@hcengineering/ui'
import chunter from '../plugin'
import { DocNotifyContext } from '@hcengineering/notification'
import activity, { ActivityMessagesFilter } from '@hcengineering/activity'
import { ChatMessage } from '@hcengineering/chunter'
import Channel from './Channel.svelte'
import PinnedMessages from './PinnedMessages.svelte'
import ChannelHeader from './ChannelHeader.svelte'
export let space: Ref<Space>
let chunterSpace: ChunterSpace
let isScrollForced = false
export let notifyContext: DocNotifyContext
export let object: Doc
export let filterId: Ref<ActivityMessagesFilter> = activity.ids.AllFilter
const client = getClient()
const _class = chunter.class.Message
let _id = generateId<AttachedDoc>()
async function onMessage (event: CustomEvent) {
const { message, attachments } = event.detail
const me = getCurrentAccount()._id
await client.addCollection(
_class,
space,
space,
chunterSpace?._class ?? chunter.class.ChunterSpace,
'messages',
{
content: message,
createBy: me,
attachments
},
_id
)
_id = generateId()
isScrollForced = true
loading = false
}
function openThread (_id: Ref<Message>) {
function openThread (_id: Ref<ChatMessage>) {
const loc = getLocation()
loc.path[4] = _id
navigate(loc)
}
const pinnedQuery = createQuery()
let pinnedIds: Ref<ChunterMessage>[] = []
pinnedQuery.query(
chunter.class.ChunterSpace,
{ _id: space },
(res) => {
pinnedIds = res[0]?.pinned ?? []
chunterSpace = res[0]
},
{ limit: 1 }
)
const savedMessagesQuery = createQuery()
let savedMessagesIds: Ref<ChunterMessage>[] = []
savedMessagesQuery.query(chunter.class.SavedMessages, {}, (res) => {
savedMessagesIds = res.map((r) => r.attachedTo)
})
const savedAttachmentsQuery = createQuery()
let savedAttachmentsIds: Ref<Attachment>[] = []
savedAttachmentsQuery.query(attachment.class.SavedAttachments, {}, (res) => {
savedAttachmentsIds = res.map((r) => r.attachedTo)
})
let loading = false
let content: HTMLElement
</script>
<PinnedMessages {space} {pinnedIds} />
<ChannelHeader {object} />
<PinnedMessages {notifyContext} />
<Channel
bind:isScrollForced
bind:content
{space}
{notifyContext}
{object}
{filterId}
on:openThread={(e) => {
openThread(e.detail)
}}
{pinnedIds}
{savedMessagesIds}
{savedAttachmentsIds}
/>
<div class="reference">
<AttachmentRefInput bind:loading {space} {_class} objectId={_id} boundary={content} on:message={onMessage} />
</div>
<style lang="scss">
.reference {
margin: 1.25rem 1rem;
}
</style>

View File

@ -13,14 +13,32 @@
// limitations under the License.
-->
<script lang="ts">
import { Ref, Space } from '@hcengineering/core'
import ChannelView from './ChannelView.svelte'
import SpaceHeader from './SpaceHeader.svelte'
import { Doc, Ref } from '@hcengineering/core'
import notification, { DocNotifyContext } from '@hcengineering/notification'
import { createQuery } from '@hcengineering/presentation'
export let _id: Ref<Space>
import ChannelPresenter from './ChannelView.svelte'
export let _id: Ref<DocNotifyContext>
const objectQuery = createQuery()
const contextQuery = createQuery()
let notifyContext: DocNotifyContext | undefined = undefined
let object: Doc | undefined = undefined
$: contextQuery.query(notification.class.DocNotifyContext, { _id }, (res) => {
notifyContext = res[0]
})
$: notifyContext &&
objectQuery.query(notifyContext.attachedToClass, { _id: notifyContext.attachedTo }, (res) => {
object = res[0]
})
</script>
<div class="antiComponent">
<SpaceHeader spaceId={_id} withSearch={false} />
<ChannelView space={_id} />
</div>
{#if notifyContext && object}
<div class="antiComponent">
<ChannelPresenter {notifyContext} {object} />
</div>
{/if}

View File

@ -0,0 +1,39 @@
<!--
// 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 { DirectMessage } from '@hcengineering/chunter'
import { getClient } from '@hcengineering/presentation'
import { Person } from '@hcengineering/contact'
import { Avatar } from '@hcengineering/contact-resources'
import { getDmPersons } from '../utils'
export let value: DirectMessage
const client = getClient()
let persons: Person[] = []
$: getDmPersons(client, value).then((res) => {
persons = res
})
</script>
{#each persons as person}
{#if person}
<div class="icon">
<Avatar size="x-small" avatar={person.avatar} name={person.name} />
</div>
{/if}
{/each}

View File

@ -15,28 +15,22 @@
<script lang="ts">
import { chunterId, DirectMessage } from '@hcengineering/chunter'
import { getClient } from '@hcengineering/presentation'
import { Icon } from '@hcengineering/ui'
import { NavLink } from '@hcengineering/view-resources'
import { getDmName } from '../utils'
import DmIconPresenter from './DmIconPresenter.svelte'
export let value: DirectMessage
export let disabled = false
const client = getClient()
$: icon = client.getHierarchy().getClass(value._class).icon
</script>
{#if value}
{#await getDmName(client, value) then name}
<NavLink app={chunterId} space={value._id} {disabled}>
<div class="flex-presenter">
<div class="icon">
{#if icon}
<Icon {icon} size={'small'} />
{/if}
</div>
<DmIconPresenter {value} />
<span class="label">{name}</span>
</div>
</NavLink>

View File

@ -19,6 +19,7 @@
import { navigateToSpecial } from '../utils'
export let icon: Asset | AnySvelteComponent | undefined = undefined
export let iconProps: Record<string, any> | undefined = undefined
export let label: string | undefined = undefined
export let intlLabel: IntlString | undefined = undefined
export let description: string | undefined = undefined
@ -31,7 +32,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="ac-header__wrap-title" on:click>
{#if icon}<div class="ac-header__icon"><Icon {icon} size={'small'} /></div>{/if}
{#if icon}<div class="ac-header__icon"><Icon {icon} size={'small'} {iconProps} /></div>{/if}
{#if label}
<span class="ac-header__title">{label}</span>
{:else if intlLabel}

View File

@ -1,347 +0,0 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
//
// 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 { Attachment } from '@hcengineering/attachment'
import { AttachmentList, AttachmentRefInput } from '@hcengineering/attachment-resources'
import type { ChunterMessage, ChunterMessageExtension, Message } from '@hcengineering/chunter'
import { PersonAccount } from '@hcengineering/contact'
import { Avatar, personByIdStore, EmployeePresenter } from '@hcengineering/contact-resources'
import { getCurrentAccount, getDisplayTime, Mixin, Ref, WithLookup } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { getClient, MessageViewer } from '@hcengineering/presentation'
import ui, { ActionIcon, Button, EmojiPopup, IconMoreV, Label, showPopup, tooltip } from '@hcengineering/ui'
import { Action } from '@hcengineering/view'
import { LinkPresenter, Menu, ObjectPresenter } from '@hcengineering/view-resources'
import notification, { Collaborators } from '@hcengineering/notification'
import { Reactions, updateDocReactions } from '@hcengineering/activity-resources'
import { Reaction } from '@hcengineering/activity'
import Bookmark from './icons/Bookmark.svelte'
import Emoji from './icons/Emoji.svelte'
import Thread from './icons/Thread.svelte'
import Replies from './Replies.svelte'
import { AddMessageToSaved, DeleteMessageFromSaved, UnpinMessage } from '../index'
import chunter from '../plugin'
import { getLinks } from '../utils'
export let message: WithLookup<ChunterMessage>
export let savedAttachmentsIds: Ref<Attachment>[]
export let thread: boolean = false
export let isPinned: boolean = false
export let isSaved: boolean = false
export let isHighlighted = false
export let readOnly = false
let refInput: AttachmentRefInput
$: empRef = (message.$lookup?.createBy as PersonAccount)?.person
$: employee = empRef !== undefined ? $personByIdStore.get(empRef) : undefined
$: attachments = (message.$lookup?.attachments ?? []) as Attachment[]
const client = getClient()
const hierarchy = client.getHierarchy()
const dispatch = createEventDispatcher()
const me = getCurrentAccount()._id
$: reactions = message.$lookup?.reactions as Reaction[] | undefined
$: subscribed = (
hierarchy.as(message, notification.mixin.Collaborators) as any as Collaborators
).collaborators?.includes(me)
$: subscribeAction = subscribed
? ({
label: chunter.string.TurnOffReplies,
action: chunter.actionImpl.UnsubscribeMessage
} as unknown as Action)
: ({
label: chunter.string.GetNewReplies,
action: chunter.actionImpl.SubscribeMessage
} as unknown as Action)
$: pinActions = isPinned
? ({
label: chunter.string.UnpinMessage,
action: chunter.actionImpl.UnpinMessage
} as unknown as Action)
: ({
label: chunter.string.PinMessage,
action: chunter.actionImpl.PinMessage
} as unknown as Action)
$: isEditing = false
let extensions: Ref<Mixin<ChunterMessageExtension>>[] = []
$: if (message) {
extensions = []
for (const extension of hierarchy.getDescendants(chunter.mixin.ChunterMessageExtension)) {
if (hierarchy.hasMixin(message, extension)) {
extensions.push(extension as Ref<Mixin<ChunterMessageExtension>>)
}
}
}
const editAction = {
label: chunter.string.EditMessage,
action: () => (isEditing = true)
}
const deleteAction = {
label: chunter.string.DeleteMessage,
action: async () => {
;(await client.findAll(chunter.class.ThreadMessage, { attachedTo: message._id as Ref<Message> })).forEach((c) => {
UnpinMessage(c)
DeleteMessageFromSaved(c)
})
UnpinMessage(message)
await client.removeDoc(message._class, message.space, message._id)
}
}
let menuShowed = false
const showMenu = async (ev: Event): Promise<void> => {
const actions = [pinActions]
if (message._class === chunter.class.Message) {
actions.push(subscribeAction)
}
menuShowed = true
showPopup(
Menu,
{
object: message,
baseMenuClass: message._class,
actions: [
...actions.map((a) => ({
label: a.label,
icon: a.icon,
action: async (ctx: any, evt: MouseEvent) => {
const impl = await getResource(a.action)
await impl(message, evt)
}
})),
...(getCurrentAccount()._id === message.createBy ? [editAction, deleteAction] : [])
]
},
ev.target as HTMLElement,
() => {
menuShowed = false
}
)
}
async function onMessageEdit (event: CustomEvent) {
const { message: newContent, attachments: newAttachments } = event.detail
await client.update(message, {
content: newContent,
attachments: newAttachments
})
isEditing = false
loading = false
}
function openThread () {
dispatch('openThread', message._id)
}
function addToSaved () {
if (isSaved) DeleteMessageFromSaved(message)
else AddMessageToSaved(message)
}
function updateReactions (emoji?: string) {
updateDocReactions(client, reactions || [], message, emoji)
}
function openEmojiPalette (ev: Event) {
showPopup(EmojiPopup, {}, ev.target as HTMLElement, updateReactions, () => {})
}
$: parentMessage = message as Message
$: hasReplies = (parentMessage?.replies?.length ?? 0) > 0
$: links = getLinks(message.content)
let loading = false
</script>
<div class="container clear-mins" class:highlighted={isHighlighted} id={message._id}>
<div class="min-w-6">
<Avatar size="x-small" avatar={employee?.avatar} name={employee?.name} />
</div>
<div class="message ml-2 w-full clear-mins">
<div class="header clear-mins">
{#if employee}
<EmployeePresenter value={employee} shouldShowAvatar={false} />
{/if}
<span class="text-sm">{getDisplayTime(message.createdOn ?? 0)}</span>
{#if message.editedOn}
<span use:tooltip={{ label: ui.string.TimeTooltip, props: { value: getDisplayTime(message.editedOn) } }}>
<Label label={chunter.string.Edited} />
</span>
{/if}
</div>
{#if isEditing}
<AttachmentRefInput
bind:this={refInput}
space={message.space}
_class={message._class}
objectId={message._id}
content={message.content}
showSend={false}
on:message={onMessageEdit}
bind:loading
/>
<div class="flex-row-reverse gap-2 reverse">
<Button label={chunter.string.EditCancel} on:click={() => (isEditing = false)} />
<Button label={chunter.string.EditUpdate} on:click={() => refInput.submit()} />
</div>
{:else}
<div class="text"><MessageViewer message={message.content} /></div>
{#if message.attachments}
<div class="attachments">
<AttachmentList {attachments} {savedAttachmentsIds} />
</div>
{/if}
{#each links as link}
<LinkPresenter {link} />
{/each}
{/if}
{#if reactions?.length || (!thread && hasReplies)}
<div class="footer flex-col">
{#if reactions?.length}
<Reactions
{reactions}
on:click={(ev) => {
updateReactions(ev.detail)
}}
/>
{/if}
{#if !thread && hasReplies}
<Replies message={parentMessage} on:click={openThread} />
{/if}
</div>
{/if}
</div>
<div class="buttons clear-mins flex flex-gap-1 items-center" class:menuShowed>
{#each extensions as mixinClass}
<ObjectPresenter _class={mixinClass} value={hierarchy.as(message, mixinClass)} exact />
{/each}
{#if !readOnly}
<ActionIcon icon={Emoji} size={'medium'} action={openEmojiPalette} />
<div class="book">
<ActionIcon
icon={Bookmark}
size={'medium'}
action={addToSaved}
label={isSaved ? chunter.string.RemoveFromSaved : chunter.string.AddToSaved}
/>
</div>
{#if !thread}
<ActionIcon icon={Thread} size={'medium'} action={openThread} />
{/if}
<ActionIcon
icon={IconMoreV}
size={'medium'}
action={(e) => {
showMenu(e)
}}
/>
{/if}
</div>
</div>
<style lang="scss">
@keyframes highlight {
50% {
background-color: var(--theme-warning-color);
}
}
.container {
position: relative;
display: flex;
flex-shrink: 0;
padding: 0.5rem 1rem;
&.highlighted {
animation: highlight 2000ms ease-in-out;
}
.message {
display: flex;
flex-direction: column;
.header {
display: flex;
align-items: baseline;
font-weight: 500;
font-size: 0.875rem;
line-height: 150%;
color: var(--theme-caption-color);
margin-bottom: 0.25rem;
span {
margin-left: 0.25rem;
font-weight: 400;
line-height: 1.25rem;
opacity: 0.4;
}
}
.text {
line-height: 150%;
user-select: contain;
}
.attachments {
margin-top: 1rem;
}
.footer {
align-items: flex-start;
margin-top: 0.5rem;
user-select: none;
div + div {
margin-top: 0.5rem;
}
}
}
.buttons {
position: absolute;
visibility: hidden;
top: 0.5rem;
right: 1rem;
user-select: none;
color: var(--theme-halfcontent-color);
&.menuShowed {
visibility: visible;
}
}
&:hover > .buttons {
visibility: visible;
}
&:hover {
background-color: var(--highlight-hover);
}
}
</style>

View File

@ -1,62 +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 chunter, { Message } from '@hcengineering/chunter'
import { Doc } from '@hcengineering/core'
import { MessageViewer, createQuery, getClient } from '@hcengineering/presentation'
import { Icon, Label } from '@hcengineering/ui'
import { AttributeModel } from '@hcengineering/view'
import { getObjectPresenter } from '@hcengineering/view-resources'
import chunterResources from '../plugin'
export let value: Message
export let inline: boolean = false
export let embedded = false
export let disabled = false
const client = getClient()
const isThreadMessage = client.getHierarchy().isDerived(value._class, chunter.class.ThreadMessage)
let presenter: AttributeModel | undefined
getObjectPresenter(client, value.attachedToClass, { key: '' }).then((p) => {
presenter = p
})
let doc: Doc | undefined = undefined
const docQuery = createQuery()
$: docQuery.query(value.attachedToClass, { _id: value.attachedTo }, (res) => {
;[doc] = res
})
</script>
{#if inline || embedded}
{#if presenter && doc}
<div class="flex-presenter">
{#if isThreadMessage}
<div class="icon">
<Icon icon={chunter.icon.Thread} size="small" />
</div>
<span class="labels-row" style:text-transform="lowercase">
<Label label={chunterResources.string.On} />
</span>
&nbsp;
{/if}
<svelte:component this={presenter.presenter} value={doc} {inline} {disabled} shouldShowAvatarr={false} />
</div>
{/if}
{:else}
<div><MessageViewer message={value.content} /></div>
{/if}

View File

@ -1,119 +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 { ChunterMessage } from '@hcengineering/chunter'
import { MessageViewer } from '@hcengineering/presentation'
import ui, { Label, tooltip } from '@hcengineering/ui'
import { LinkPresenter } from '@hcengineering/view-resources'
import { AttachmentList } from '@hcengineering/attachment-resources'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { Ref, WithLookup, getCurrentAccount, getDisplayTime } from '@hcengineering/core'
import { Attachment } from '@hcengineering/attachment'
import { EmployeePresenter, personAccountByIdStore, personByIdStore } from '@hcengineering/contact-resources'
import { PersonAccount } from '@hcengineering/contact'
import chunter from '../plugin'
import { getLinks } from '../utils'
export let value: WithLookup<ChunterMessage>
$: attachments = (value.$lookup?.attachments ?? []) as Attachment[]
$: links = getLinks(value.content)
const me = getCurrentAccount()._id as Ref<PersonAccount>
let account: PersonAccount | undefined
$: account = $personAccountByIdStore.get(value.createdBy as Ref<PersonAccount>)
$: employee = account && $personByIdStore.get(account.person)
</script>
<div class="container clear-mins" class:highlighted={false} id={value._id}>
<div class="message clear-mins">
<div class="flex-row-center header clear-mins">
{#if employee && account}
{#if account._id !== me}
<EmployeePresenter value={employee} shouldShowAvatar={true} disabled />
{:else}
<Label label={chunter.string.You} />
{/if}
{/if}
<span>{getDisplayTime(value.createdOn ?? 0)}</span>
{#if value.editedOn}
<span use:tooltip={{ label: ui.string.TimeTooltip, props: { value: getDisplayTime(value.editedOn) } }}>
<Label label={getEmbeddedLabel('Edited')} />
</span>
{/if}
</div>
<div class="text"><MessageViewer message={value.content} /></div>
{#if value.attachments}
<div class="attachments">
<AttachmentList {attachments} />
</div>
{/if}
{#each links as link}
<LinkPresenter {link} />
{/each}
</div>
</div>
<style lang="scss">
@keyframes highlight {
50% {
background-color: var(--theme-warning-color);
}
}
.container {
position: relative;
display: flex;
flex-shrink: 0;
padding: 0.5rem 0.15rem;
&.highlighted {
animation: highlight 2000ms ease-in-out;
}
.message {
display: flex;
flex-direction: column;
width: 100%;
margin-left: 1rem;
.header {
display: flex;
font-weight: 500;
line-height: 150%;
color: var(--theme-caption-color);
margin-bottom: 0.25rem;
span {
margin-left: 0.5rem;
font-weight: 400;
line-height: 1.125rem;
opacity: 0.4;
}
}
.text {
line-height: 150%;
user-select: contain;
}
.attachments {
margin-top: 0.25rem;
}
}
}
</style>

View File

@ -1,117 +0,0 @@
<script lang="ts">
import attachment, { Attachment } from '@hcengineering/attachment'
import chunter, { ChunterMessage } from '@hcengineering/chunter'
import core, { DocumentQuery, Ref, SortingOrder } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Label, Scroller, SearchEdit } from '@hcengineering/ui'
import { FilterBar } from '@hcengineering/view-resources'
import plugin from '../plugin'
import { openMessageFromSpecial } from '../utils'
import MessageComponent from './Message.svelte'
export let withHeader: boolean = true
export let filterClass = chunter.class.ChunterMessage
export let search: string = ''
let searchQuery: DocumentQuery<ChunterMessage> = { $search: search }
function updateSearchQuery (search: string): void {
searchQuery = { $search: search }
}
$: updateSearchQuery(search)
const client = getClient()
let messages: ChunterMessage[] = []
let resultQuery: DocumentQuery<ChunterMessage> = { ...searchQuery }
async function updateMessages (resultQuery: DocumentQuery<ChunterMessage>) {
messages = await client.findAll(
filterClass,
{
...resultQuery
},
{
sort: { createdOn: SortingOrder.Descending },
limit: 100,
lookup: {
_id: { attachments: attachment.class.Attachment },
createBy: core.class.Account
}
}
)
}
$: updateMessages(resultQuery)
const pinnedQuery = createQuery()
const pinnedIds: Ref<ChunterMessage>[] = []
pinnedQuery.query(
chunter.class.Channel,
{},
(res) => {
res.forEach((ch) => {
if (ch.pinned) {
pinnedIds.push(...ch.pinned)
}
})
},
{}
)
let savedMessagesIds: Ref<ChunterMessage>[] = []
let savedAttachmentsIds: Ref<Attachment>[] = []
const savedMessagesQuery = createQuery()
const savedAttachmentsQuery = createQuery()
savedMessagesQuery.query(chunter.class.SavedMessages, {}, (res) => {
savedMessagesIds = res.map((r) => r.attachedTo)
})
savedAttachmentsQuery.query(attachment.class.SavedAttachments, {}, (res) => {
savedAttachmentsIds = res.map((r) => r.attachedTo)
})
</script>
{#if withHeader}
<div class="ac-header full divide">
<div class="ac-header__wrap-title">
<span class="ac-header__title"><Label label={plugin.string.MessagesBrowser} /></span>
</div>
<SearchEdit
value={search}
on:change={() => {
updateSearchQuery(search)
updateMessages(resultQuery)
}}
/>
</div>
{/if}
<FilterBar _class={filterClass} space={undefined} query={searchQuery} on:change={(e) => (resultQuery = e.detail)} />
{#if messages.length > 0}
<Scroller>
{#each messages as message}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
on:click={() => {
openMessageFromSpecial(message)
}}
>
<MessageComponent
{message}
on:openThread
isPinned={pinnedIds.includes(message._id)}
isSaved={savedMessagesIds.includes(message._id)}
{savedAttachmentsIds}
/>
</div>
{/each}
</Scroller>
{:else}
<div class="flex-center h-full text-lg">
<Label label={plugin.string.NoResults} />
</div>
{/if}

View File

@ -1,29 +1,55 @@
<!--
// 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 { ChunterMessage } from '@hcengineering/chunter'
import { Ref, Space } from '@hcengineering/core'
import { eventToHTMLElement, Label, showPopup } from '@hcengineering/ui'
import PinnedMessagesPopup from './PinnedMessagesPopup.svelte'
import { createQuery } from '@hcengineering/presentation'
import { DocNotifyContext } from '@hcengineering/notification'
import activity, { ActivityMessage } from '@hcengineering/activity'
import chunter from '../plugin'
export let space: Ref<Space>
export let pinnedIds: Ref<ChunterMessage>[]
export let notifyContext: DocNotifyContext
function showMessages (ev: MouseEvent & { currentTarget: EventTarget & HTMLDivElement }) {
showPopup(PinnedMessagesPopup, { space }, eventToHTMLElement(ev))
const pinnedQuery = createQuery()
let pinnedMessagesCount = 0
$: pinnedQuery.query(
activity.class.ActivityMessage,
{ attachedTo: notifyContext.attachedTo, isPinned: true },
(res: ActivityMessage[]) => {
pinnedMessagesCount = res.length
}
)
function openMessagesPopup (ev: MouseEvent & { currentTarget: EventTarget & HTMLDivElement }) {
showPopup(
PinnedMessagesPopup,
{ attachedTo: notifyContext.attachedTo, attachedToClass: notifyContext.attachedToClass },
eventToHTMLElement(ev)
)
}
</script>
{#if pinnedIds.length > 0}
{#if pinnedMessagesCount > 0}
<div class="bottom-divider over-underline pt-2 pb-2 container">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
on:click={(ev) => {
showMessages(ev)
}}
>
<div on:click={openMessagesPopup}>
<Label label={chunter.string.Pinned} />
{pinnedIds.length}
{pinnedMessagesCount}
</div>
</div>
{/if}

View File

@ -1,76 +1,37 @@
<!--
// 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 chunter, { ChunterMessage } from '@hcengineering/chunter'
import { Person, PersonAccount, getName } from '@hcengineering/contact'
import { Avatar, personAccountByIdStore, personByIdStore } from '@hcengineering/contact-resources'
import { getDisplayTime, IdMap, Ref, Space } from '@hcengineering/core'
import { MessageViewer, createQuery, getClient } from '@hcengineering/presentation'
import { IconClose } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { UnpinMessage } from '../index'
import { Doc, Ref } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import activity, { ActivityMessage, DisplayActivityMessage } from '@hcengineering/activity'
import { ActivityMessagePresenter } from '@hcengineering/activity-resources'
export let space: Ref<Space>
const client = getClient()
const pinnedQuery = createQuery()
let pinnedIds: Ref<ChunterMessage>[] = []
pinnedQuery.query(
chunter.class.ChunterSpace,
{ _id: space },
(res) => {
pinnedIds = res[0]?.pinned ?? []
},
{ limit: 1 }
)
export let attachedTo: Ref<Doc>
const messagesQuery = createQuery()
let pinnedMessages: ChunterMessage[] = []
$: pinnedIds &&
messagesQuery.query(chunter.class.ChunterMessage, { _id: { $in: pinnedIds } }, (res) => {
pinnedMessages = res
})
let pinnedMessages: DisplayActivityMessage[] = []
const dispatch = createEventDispatcher()
function getEmployee (
message: ChunterMessage,
employeeAccounts: IdMap<PersonAccount>,
employees: IdMap<Person>
): Person | undefined {
const acc = employeeAccounts.get(message.createBy as Ref<PersonAccount>)
if (acc) {
return employees.get(acc.person)
}
}
$: messagesQuery.query(activity.class.ActivityMessage, { attachedTo, isPinned: true }, (res: ActivityMessage[]) => {
pinnedMessages = res as DisplayActivityMessage[]
})
</script>
<div class="antiPopup vScroll popup">
{#each pinnedMessages as message}
{@const employee = getEmployee(message, $personAccountByIdStore, $personByIdStore)}
<div class="message">
<div class="header">
<div class="avatar">
<Avatar size={'medium'} avatar={employee?.avatar} name={employee?.name} />
</div>
<span class="name">
{employee ? getName(client.getHierarchy(), employee) : ''}
</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="cross"
on:click={async () => {
if (pinnedIds.length === 1) dispatch('close')
UnpinMessage(message)
}}
>
<IconClose size="small" />
</div>
</div>
<MessageViewer message={message.content} />
<span class="time">{getDisplayTime(message.createdOn ?? 0)}</span>
</div>
<ActivityMessagePresenter value={message} withActions={false} />
{/each}
</div>
@ -80,34 +41,4 @@
max-height: 20rem;
color: var(--caption-color);
}
.message {
padding: 0.75rem 1rem 0.75rem;
margin-bottom: 1rem;
box-shadow: inherit;
border-radius: inherit;
}
.header {
display: flex;
flex-direction: row;
align-items: center;
.name {
font-weight: 500;
margin-left: 1rem;
flex-grow: 2;
}
.cross {
opacity: 0.4;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
.time {
font-size: 0.75rem;
}
</style>

View File

@ -1,128 +0,0 @@
<!--
// Copyright © 2022 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 { Message } from '@hcengineering/chunter'
import { Person } from '@hcengineering/contact'
import { personByIdStore, Avatar } from '@hcengineering/contact-resources'
import { Doc, IdMap, Ref } from '@hcengineering/core'
import { Label, TimeSince } from '@hcengineering/ui'
import { NotificationClientImpl } from '@hcengineering/notification-resources'
import { DocUpdates } from '@hcengineering/notification'
import chunter from '../plugin'
export let message: Message
$: lastReply = message.lastReply ?? new Date().getTime()
$: employees = new Set(message.replies)
const notificationClient = NotificationClientImpl.getClient()
const docUpdates = notificationClient.docUpdatesStore
const shown: number = 4
let showReplies: Person[] = []
$: hasNew = checkNewReplies(message, $docUpdates)
$: updateQuery(employees, $personByIdStore)
function checkNewReplies (message: Message, docUpdates: Map<Ref<Doc>, DocUpdates>): boolean {
const docUpdate = docUpdates.get(message._id)
if (docUpdate === undefined) return false
return docUpdate.txes.filter((tx) => tx.isNew).length > 0
}
function updateQuery (employees: Set<Ref<Person>>, map: IdMap<Person>) {
showReplies = []
for (const employee of employees) {
const emp = map.get(employee)
if (emp !== undefined) {
showReplies.push(emp)
}
}
showReplies = showReplies
}
</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>
<div class="flex-row-center">
{#each showReplies as reply}
<div class="reply"><Avatar size={'x-small'} avatar={reply.avatar} name={reply.name} /></div>
{/each}
{#if employees.size > shown}
<div class="reply"><span>+{employees.size - shown}</span></div>
{/if}
</div>
<div class="whitespace-nowrap ml-2 mr-2 over-underline">
<Label label={chunter.string.RepliesCount} params={{ replies: message.replies?.length ?? 0 }} />
</div>
{#if hasNew}
<div class="marker" />
{/if}
{#if (message.replies?.length ?? 0) > 1}
<div class="mr-1">
<Label label={chunter.string.LastReply} />
</div>
{/if}
<TimeSince value={lastReply} />
</div>
<style lang="scss">
.container {
user-select: none;
border: 1px solid transparent;
border-radius: 0.5rem;
padding: 0.25rem;
.reply {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--theme-bg-color);
border-radius: 50%;
span {
display: flex;
justify-content: center;
align-items: center;
width: 1.5rem;
height: 1.5rem;
font-size: 0.75rem;
font-weight: 500;
line-height: 0.5;
color: var(--caption-color);
background-color: var(--theme-bg-accent-color);
border-radius: 50%;
}
}
.reply + .reply {
margin-left: 0.25rem;
}
&:hover {
border: 1px solid var(--button-border-hover);
background-color: var(--theme-bg-color);
}
}
.marker {
margin: 0 0.25rem 0 -0.25rem;
width: 0.425rem;
height: 0.425rem;
border-radius: 50%;
background-color: var(--highlight-red);
}
</style>

View File

@ -1,175 +0,0 @@
<script lang="ts">
import attachment, { Attachment } from '@hcengineering/attachment'
import { AttachmentPreview } from '@hcengineering/attachment-resources'
import { ChunterMessage } from '@hcengineering/chunter'
import { Person, PersonAccount, getName as getContactName } from '@hcengineering/contact'
import { personAccountByIdStore, personByIdStore } from '@hcengineering/contact-resources'
import core, { getDisplayTime, IdMap, Ref, WithLookup } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Label, Scroller } from '@hcengineering/ui'
import chunter from '../plugin'
import { openMessageFromSpecial } from '../utils'
import Message from './Message.svelte'
import Bookmark from './icons/Bookmark.svelte'
const client = getClient()
let savedMessagesIds: Ref<ChunterMessage>[] = []
let savedMessages: WithLookup<ChunterMessage>[] = []
let savedAttachmentsIds: Ref<Attachment>[] = []
let savedAttachments: WithLookup<Attachment>[] = []
const messagesQuery = createQuery()
const attachmentsQuery = createQuery()
const savedMessagesQuery = createQuery()
const savedAttachmentsQuery = createQuery()
savedMessagesQuery.query(chunter.class.SavedMessages, {}, (res) => {
savedMessagesIds = res.map((r) => r.attachedTo)
})
savedAttachmentsQuery.query(attachment.class.SavedAttachments, {}, (res) => {
savedAttachmentsIds = res.map((r) => r.attachedTo)
})
$: savedMessagesIds &&
messagesQuery.query(
chunter.class.ChunterMessage,
{ _id: { $in: savedMessagesIds } },
(res) => {
savedMessages = res
},
{
lookup: {
_id: { attachments: attachment.class.Attachment },
createBy: core.class.Account
}
}
)
$: savedAttachmentsIds &&
attachmentsQuery.query(attachment.class.Attachment, { _id: { $in: savedAttachmentsIds } }, (res) => {
savedAttachments = res
})
const pinnedQuery = createQuery()
const pinnedIds: Ref<ChunterMessage>[] = []
pinnedQuery.query(
chunter.class.Channel,
{},
(res) => {
res.forEach((ch) => {
if (ch.pinned) {
pinnedIds.push(...ch.pinned)
}
})
},
{}
)
async function openAttachment (att: Attachment) {
const messageId: Ref<ChunterMessage> = att.attachedTo as Ref<ChunterMessage>
await client.findOne(chunter.class.ChunterMessage, { _id: messageId }).then((res) => {
if (res !== undefined) openMessageFromSpecial(res)
})
}
function getName (
a: Attachment,
personAccountByIdStore: IdMap<PersonAccount>,
personByIdStore: IdMap<Person>
): string | undefined {
const acc = personAccountByIdStore.get(a.modifiedBy as Ref<PersonAccount>)
if (acc !== undefined) {
const emp = personByIdStore.get(acc?.person)
if (emp !== undefined) {
return getContactName(client.getHierarchy(), emp)
}
}
}
</script>
<div class="ac-header full divide caption-height">
<div class="ac-header__wrap-title">
<span class="ac-header__title"><Label label={chunter.string.SavedItems} /></span>
</div>
</div>
<Scroller>
{#if savedMessages.length > 0 || savedAttachments.length > 0}
{#each savedMessages as message}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="clear-mins flex-no-shrink"
on:click={() => {
openMessageFromSpecial(message)
}}
>
<Message
{message}
on:openThread
thread
isPinned={pinnedIds.includes(message._id)}
isSaved={savedMessagesIds.includes(message._id)}
{savedAttachmentsIds}
/>
</div>
{/each}
{#each savedAttachments as att}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="attachmentContainer flex-no-shrink clear-mins" on:click={() => openAttachment(att)}>
<AttachmentPreview value={att} isSaved={true} />
<div class="label">
<Label
label={chunter.string.SharedBy}
params={{
name: getName(att, $personAccountByIdStore, $personByIdStore),
time: getDisplayTime(att.modifiedOn)
}}
/>
</div>
</div>
{/each}
{:else}
<div class="empty">
<Bookmark size={'large'} />
<div class="an-element__label header">
<Label label={chunter.string.EmptySavedHeader} />
</div>
<span class="an-element__label">
<Label label={chunter.string.EmptySavedText} />
</span>
</div>
{/if}
</Scroller>
<style lang="scss">
.empty {
display: flex;
align-self: center;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
height: inherit;
width: 30rem;
}
.header {
font-weight: 600;
margin: 1rem;
}
.attachmentContainer {
padding: 2rem;
&:hover {
background-color: var(--highlight-hover);
}
.label {
padding-top: 1rem;
}
}
</style>

View File

@ -1,42 +0,0 @@
<!--
// Copyright © 2022 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 { ChunterSpace } from '@hcengineering/chunter'
import type { Ref } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import chunter from '../plugin'
import DmHeader from './DmHeader.svelte'
import ChannelHeader from './ChannelHeader.svelte'
export let spaceId: Ref<ChunterSpace> | undefined
export let withSearch: boolean = true
const client = getClient()
const hierarchy = client.getHierarchy()
const query = createQuery()
let channel: ChunterSpace | undefined
$: query.query(chunter.class.ChunterSpace, { _id: spaceId }, (result) => {
channel = result[0]
})
</script>
{#if channel}
{#if hierarchy.isDerived(channel._class, chunter.class.DirectMessage)}
<DmHeader {spaceId} {withSearch} />
{:else}
<ChannelHeader {spaceId} />
{/if}
{/if}

View File

@ -1,230 +0,0 @@
<!--
// Copyright © 2022 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 attachment, { Attachment } from '@hcengineering/attachment'
import { AttachmentRefInput } from '@hcengineering/attachment-resources'
import chunter, { type ChunterSpace, type Message, type ThreadMessage } from '@hcengineering/chunter'
import contact, { Person, PersonAccount, getName } from '@hcengineering/contact'
import { Avatar, personByIdStore } from '@hcengineering/contact-resources'
import core, { FindOptions, IdMap, Ref, SortingOrder, generateId, getCurrentAccount } from '@hcengineering/core'
import { NotificationClientImpl } from '@hcengineering/notification-resources'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Label } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import activity from '@hcengineering/activity'
import plugin from '../plugin'
import ChannelPresenter from './ChannelPresenter.svelte'
import DmPresenter from './DmPresenter.svelte'
import MsgView from './Message.svelte'
const client = getClient()
const query = createQuery()
const messageQuery = createQuery()
const currentPerson = (getCurrentAccount() as PersonAccount)?.person
const currentEmployee = currentPerson !== undefined ? $personByIdStore.get(currentPerson) : undefined
export let savedAttachmentsIds: Ref<Attachment>[]
export let _id: Ref<Message>
export let showHeader = true
export let readOnly = false
let parent: Message | undefined
let commentId: Ref<ThreadMessage> = generateId()
const notificationClient = NotificationClientImpl.getClient()
const dispatch = createEventDispatcher()
const lookup = {
_id: { attachments: attachment.class.Attachment, reactions: activity.class.Reaction },
createBy: core.class.Account
}
let showAll = false
let total = 0
let showActions = false
let showSend = false
$: updateQuery(_id)
$: updateThreadQuery(_id, showAll)
function updateQuery (id: Ref<Message>) {
messageQuery.query(
plugin.class.Message,
{
_id: id
},
(res) => {
parent = res[0]
},
{
lookup
}
)
}
function updateThreadQuery (id: Ref<Message>, showAll: boolean) {
const options: FindOptions<ThreadMessage> = {
lookup,
sort: {
createdOn: SortingOrder.Descending
},
total: true
}
if (!showAll) {
options.limit = 4
}
query.query(
plugin.class.ThreadMessage,
{
attachedTo: id
},
(res) => {
total = res.total
if (!showAll && res.total > 4) {
comments = res.splice(0, 2).reverse()
} else {
comments = res.reverse()
}
notificationClient.read(id)
},
options
)
}
async function getParticipants (
comments: ThreadMessage[],
parent: Message | undefined,
employees: IdMap<Person>
): Promise<string[]> {
const refs = new Set(comments.map((p) => p.createBy))
if (parent !== undefined) {
refs.add(parent.createBy)
}
refs.delete(getCurrentAccount()._id)
const accounts = await client.findAll(contact.class.PersonAccount, {
_id: { $in: Array.from(refs) as Ref<PersonAccount>[] }
})
const res: string[] = []
for (const account of accounts) {
const employee = employees.get(account.person)
if (employee !== undefined) {
res.push(getName(client.getHierarchy(), employee))
}
}
return res
}
async function onMessage (event: CustomEvent) {
if (parent === undefined) return
const { message, attachments } = event.detail
const me = getCurrentAccount()._id
await client.addCollection(
chunter.class.ThreadMessage,
parent.space,
parent._id,
parent._class,
'repliesCount',
{
content: message,
createBy: me,
attachments
},
commentId
)
dispatch('added', commentId)
commentId = generateId()
loading = false
}
let comments: ThreadMessage[] = []
async function getChannel (_id: Ref<ChunterSpace>): Promise<ChunterSpace | undefined> {
return await client.findOne(plugin.class.ChunterSpace, { _id })
}
let loading = false
</script>
{#if showHeader && parent}
<div class="flex-col ml-4 mt-4 flex-no-shrink">
{#await getChannel(parent.space) then channel}
{#if channel?._class === plugin.class.Channel}
<ChannelPresenter value={channel} />
{:else if channel}
<DmPresenter value={channel} />
{/if}
{/await}
<div class="text-sm">
{#await getParticipants(comments, parent, $personByIdStore) then participants}
{participants.join(', ')}
<Label label={plugin.string.AndYou} params={{ participants: participants.length }} />
{/await}
</div>
</div>
{/if}
<div class="flex-col content mt-2 flex-no-shrink">
{#if parent}
<MsgView message={parent} thread {savedAttachmentsIds} {readOnly} />
{#if total > comments.length}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="label pb-2 pt-2 pl-8 over-underline clear-mins"
on:click={() => {
showAll = true
}}
>
<Label label={plugin.string.ShowMoreReplies} params={{ count: total - comments.length }} />
</div>
{/if}
{#each comments as comment (comment._id)}
<MsgView message={comment} thread {savedAttachmentsIds} {readOnly} />
{/each}
{#if !readOnly}
<div class="flex mr-4 ml-4 pb-4 mt-2 clear-mins">
<div class="min-w-6">
<Avatar size="x-small" avatar={currentEmployee?.avatar} name={currentEmployee?.name} />
</div>
<div class="ml-2 w-full">
<AttachmentRefInput
space={parent.space}
_class={plugin.class.ThreadMessage}
objectId={commentId}
placeholder={chunter.string.AddCommentPlaceholder}
{showActions}
{showSend}
on:message={onMessage}
on:focus={() => {
showSend = true
showActions = true
}}
bind:loading
/>
</div>
</div>
{/if}
{/if}
</div>
<style lang="scss">
.content {
overflow: hidden;
}
.label:hover {
background-color: var(--theme-button-hovered);
}
</style>

View File

@ -1,249 +0,0 @@
<!--
// Copyright © 2021 Anticrm Platform Contributors.
//
// 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 attachment, { Attachment } from '@hcengineering/attachment'
import { AttachmentRefInput } from '@hcengineering/attachment-resources'
import { type ChunterMessage, type Message, type ThreadMessage } from '@hcengineering/chunter'
import core, { AttachedDoc, Doc, Ref, Space, generateId, getCurrentAccount } from '@hcengineering/core'
import { DocUpdates } from '@hcengineering/notification'
import { NotificationClientImpl } from '@hcengineering/notification-resources'
import { createQuery, getClient } from '@hcengineering/presentation'
import { IconClose, Label, getCurrentResolvedLocation, navigate } from '@hcengineering/ui'
import { afterUpdate, beforeUpdate, createEventDispatcher } from 'svelte'
import activity from '@hcengineering/activity'
import chunter from '../plugin'
import { isMessageHighlighted, messageIdForScroll, scrollAndHighLight, shouldScrollToMessage } from '../utils'
import MsgView from './Message.svelte'
const client = getClient()
const query = createQuery()
const messageQuery = createQuery()
const dispatch = createEventDispatcher()
export let _id: Ref<Message>
export let currentSpace: Ref<Space>
let message: Message | undefined
let commentId: Ref<AttachedDoc> = generateId()
let div: HTMLDivElement | undefined
let autoscroll: boolean = false
let isScrollForced = false
beforeUpdate(() => {
autoscroll = div !== undefined && div.offsetHeight + div.scrollTop > div.scrollHeight - 20
})
afterUpdate(() => {
if ($shouldScrollToMessage && !$isMessageHighlighted) {
scrollAndHighLight()
return
}
if (div && (autoscroll || isScrollForced)) {
div.scrollTo(0, div.scrollHeight)
isScrollForced = false
}
})
const notificationClient = NotificationClientImpl.getClient()
const docUpdates = notificationClient.docUpdatesStore
const lookup = {
_id: { attachments: attachment.class.Attachment, reactions: activity.class.Reaction },
createBy: core.class.Account
}
const pinnedQuery = createQuery()
let pinnedIds: Ref<ChunterMessage>[] = []
$: updateQueries(_id)
function updateQueries (id: Ref<Message>) {
messageQuery.query(
chunter.class.Message,
{
_id: id
},
(res) => {
message = res[0]
if (!message) {
const loc = getCurrentResolvedLocation()
loc.path.length = 4
navigate(loc)
}
},
{
lookup: {
_id: { attachments: attachment.class.Attachment, reactions: activity.class.Reaction },
createBy: core.class.Account
}
}
)
query.query(
chunter.class.ThreadMessage,
{
attachedTo: id
},
(res) => {
comments = res
// newMessagesPos = newMessagesStart(comments, $docUpdates)
notificationClient.read(id)
},
{
lookup
}
)
pinnedQuery.query(
chunter.class.ChunterSpace,
{ _id: currentSpace },
(res) => {
pinnedIds = res[0]?.pinned ?? []
},
{ limit: 1 }
)
}
const savedMessagesQuery = createQuery()
let savedMessagesIds: Ref<ChunterMessage>[] = []
savedMessagesQuery.query(chunter.class.SavedMessages, {}, (res) => {
savedMessagesIds = res.map((r) => r.attachedTo)
})
const savedAttachmentsQuery = createQuery()
let savedAttachmentsIds: Ref<Attachment>[] = []
savedAttachmentsQuery.query(attachment.class.SavedAttachments, {}, (res) => {
savedAttachmentsIds = res.map((r) => r.attachedTo)
})
async function onMessage (event: CustomEvent) {
const { message, attachments } = event.detail
const me = getCurrentAccount()._id
await client.addCollection(
chunter.class.ThreadMessage,
currentSpace,
_id,
chunter.class.Message,
'repliesCount',
{
content: message,
createBy: me,
attachments
},
commentId
)
commentId = generateId()
isScrollForced = true
loading = false
}
let comments: ThreadMessage[] = []
function newMessagesStart (comments: ThreadMessage[], docUpdates: Map<Ref<Doc>, DocUpdates>): number {
const docUpdate = docUpdates.get(_id)
const lastView = docUpdate?.txes?.findLast((tx) => !tx.isNew)
if (!docUpdate?.txes.some((tx) => tx.isNew)) return -1
if (docUpdate === undefined || lastView === undefined) return -1
for (let index = 0; index < comments.length; index++) {
const comment = comments[index]
if ((comment.createdOn ?? 0) >= lastView.modifiedOn) return index
}
return -1
}
$: markUnread(comments, $docUpdates)
function markUnread (comments: ThreadMessage[], docUpdates: Map<Ref<Doc>, DocUpdates>) {
const newPos = newMessagesStart(comments, docUpdates)
if (newPos !== -1) {
// newMessagesPos = newPos
}
}
// let newMessagesPos: number = -1
let loading = false
</script>
<div class="header">
<div class="title"><Label label={chunter.string.Thread} /></div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="tool"
on:click={() => {
dispatch('close')
}}
>
<IconClose size="medium" />
</div>
</div>
<div class="flex-col vScroll content" bind:this={div}>
{#if message}
<MsgView {message} thread isSaved={savedMessagesIds.includes(message._id)} {savedAttachmentsIds} />
{#each comments as comment}
<MsgView
isHighlighted={$messageIdForScroll === comment._id && $isMessageHighlighted}
message={comment}
thread
isPinned={pinnedIds.includes(comment._id)}
isSaved={savedMessagesIds.includes(comment._id)}
{savedAttachmentsIds}
/>
{/each}
{/if}
</div>
<div class="ref-input">
<AttachmentRefInput
space={currentSpace}
_class={chunter.class.ThreadMessage}
objectId={commentId}
placeholder={chunter.string.AddCommentPlaceholder}
on:message={onMessage}
bind:loading
/>
</div>
<style lang="scss">
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1.75rem 0 1rem;
height: 4rem;
min-height: 4rem;
.title {
flex-grow: 1;
font-weight: 500;
font-size: 1.25rem;
color: var(--caption-color);
user-select: none;
}
.tool {
margin-left: 0.75rem;
opacity: 0.4;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
.ref-input {
margin: 1.25rem 1rem;
}
</style>

View File

@ -1,67 +0,0 @@
<!--
// Copyright © 2022 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 attachment, { Attachment } from '@hcengineering/attachment'
import type { Message } from '@hcengineering/chunter'
import { getCurrentAccount, Ref, SortingOrder } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { Label, Scroller } from '@hcengineering/ui'
import chunter from '../plugin'
import Thread from './Thread.svelte'
const query = createQuery()
const me = getCurrentAccount()._id
let threads: Ref<Message>[] = []
query.query(
chunter.class.ThreadMessage,
{
createBy: me
},
(res) => {
const ids = new Set(res.map((c) => c.attachedTo))
threads = Array.from(ids)
},
{
sort: {
createdOn: SortingOrder.Descending
}
}
)
const savedAttachmentsQuery = createQuery()
let savedAttachmentsIds: Ref<Attachment>[] = []
savedAttachmentsQuery.query(attachment.class.SavedAttachments, {}, (res) => {
savedAttachmentsIds = res.map((r) => r.attachedTo)
})
</script>
<div class="ac-header full divide caption-height">
<div class="ac-header__wrap-title">
<span class="ac-header__title"><Label label={chunter.string.Threads} /></span>
</div>
</div>
<Scroller>
{#each threads as thread, i (thread)}
<div class="ml-4 mr-4">
<Thread _id={thread} {savedAttachmentsIds} />
</div>
{#if i < threads.length - 1}
<div class="antiDivider" />
{/if}
{/each}
</Scroller>

View File

@ -1,116 +0,0 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
//
// 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 { Comment } from '@hcengineering/chunter'
import type { AttachedData, TxCreateDoc } from '@hcengineering/core'
import { getClient, MessageViewer } from '@hcengineering/presentation'
import { AttachmentDocList, AttachmentRefInput } from '@hcengineering/attachment-resources'
import { Button } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import chunter from '../../plugin'
import { LinkPresenter } from '@hcengineering/view-resources'
export let tx: TxCreateDoc<Comment>
export let value: Comment
export let edit: boolean = false
export let boundary: HTMLElement | undefined = undefined
const client = getClient()
const dispatch = createEventDispatcher()
const editing = false
async function onMessage (event: CustomEvent<AttachedData<Comment>>) {
loading = true
try {
const { message, attachments } = event.detail
await client.updateCollection(
tx.objectClass,
tx.objectSpace,
tx.objectId,
value.attachedTo,
value.attachedToClass,
value.collection,
{
message,
attachments
}
)
} finally {
loading = false
}
dispatch('close', false)
}
let refInput: AttachmentRefInput
let loading = false
$: links = getLinks(value.message)
function getLinks (content: string): HTMLLinkElement[] {
const parser = new DOMParser()
const parent = parser.parseFromString(content, 'text/html').firstChild?.childNodes[1] as HTMLElement
return parseLinks(parent.childNodes)
}
function parseLinks (nodes: NodeListOf<ChildNode>): HTMLLinkElement[] {
const res: HTMLLinkElement[] = []
nodes.forEach((p) => {
if (p.nodeType !== Node.TEXT_NODE) {
if (p.nodeName === 'A') {
res.push(p as HTMLLinkElement)
}
res.push(...parseLinks(p.childNodes))
}
})
return res
}
</script>
<div class:editing class="content-color">
{#if edit}
<AttachmentRefInput
bind:loading
bind:this={refInput}
_class={value._class}
objectId={value._id}
space={value.space}
content={value.message}
on:message={onMessage}
showSend={false}
{boundary}
/>
<div class="flex-row-center gap-2 justify-end mt-2">
<Button
label={chunter.string.EditCancel}
on:click={() => {
dispatch('close', false)
}}
/>
<Button label={chunter.string.EditUpdate} accent on:click={() => refInput.submit()} />
</div>
{:else}
<MessageViewer message={value.message} />
<AttachmentDocList {value} />
{#each links as link}
<LinkPresenter {link} />
{/each}
{/if}
</div>
<style lang="scss">
.editing {
border: 1px solid var(--primary-button-outline);
}
</style>

View File

@ -1,9 +0,0 @@
<script lang="ts">
import { TxCreateDoc } from '@hcengineering/core'
import { Message } from '@hcengineering/chunter'
import { MessageViewer } from '@hcengineering/presentation'
export let tx: TxCreateDoc<Message>
</script>
<MessageViewer message={tx.attributes.content} />

View File

@ -21,11 +21,14 @@
import { getLinkData, LinkData } from '@hcengineering/activity-resources'
import notification from '@hcengineering/notification'
import chunter from '../../plugin'
export let message: ChatMessage
export let person: Person | undefined
export let viewlet: ChatMessageViewlet | undefined
export let object: Doc | undefined
export let parentObject: Doc | undefined
export let skipLabel = false
let linkData: LinkData | undefined = undefined
@ -34,21 +37,21 @@
})
</script>
{#if viewlet?.label}
<span class="text-sm lower"> <Label label={viewlet.label} /></span>
{#if !skipLabel}
<span class="text-sm lower"> <Label label={viewlet?.label ?? chunter.string.SentMessage} /></span>
{#if linkData}
<span class="text-sm lower"><Label label={linkData.preposition} /></span>
<span class="text-sm">
<DocNavLink {object} component={linkData.panelComponent} shrink={0}>
<DocNavLink object={linkData.object} component={linkData.panelComponent} shrink={0}>
<span class="overflow-label select-text">{linkData.title}</span>
</DocNavLink>
</span>
{#if message.isEdited}
<span class="text-sm lower"><Label label={notification.string.Edited} /></span>
{/if}
{/if}
{/if}
{#if message.editedOn}
<span class="text-sm lower"><Label label={notification.string.Edited} /></span>
{/if}
<style lang="scss">
span {

View File

@ -15,24 +15,31 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { AttachmentRefInput } from '@hcengineering/attachment-resources'
import { Doc, generateId, getCurrentAccount } from '@hcengineering/core'
import { Class, Doc, generateId, getCurrentAccount, Ref } from '@hcengineering/core'
import { createQuery, DraftController, draftsStore, getClient } from '@hcengineering/presentation'
import chunter, { ChatMessage } from '@hcengineering/chunter'
import chunter, { ChatMessage, ThreadMessage } from '@hcengineering/chunter'
import { PersonAccount } from '@hcengineering/contact'
import activity, { ActivityMessage } from '@hcengineering/activity'
export let object: Doc
export let chatMessage: ChatMessage | undefined = undefined
export let shouldSaveDraft: boolean = true
export let focusIndex: number = -1
export let boundary: HTMLElement | undefined = undefined
export let loading = false
export let collection: string = 'comments'
type MessageDraft = Pick<ChatMessage, '_id' | 'message' | 'attachments'>
const dispatch = createEventDispatcher()
const client = getClient()
const _class = chunter.class.ChatMessage
const hierarchy = client.getHierarchy()
const _class: Ref<Class<ChatMessage>> = hierarchy.isDerived(object._class, activity.class.ActivityMessage)
? chunter.class.ThreadMessage
: chunter.class.ChatMessage
const createdMessageQuery = createQuery()
const account = getCurrentAccount()
const account = getCurrentAccount() as PersonAccount
const draftKey = `${object._id}_${_class}`
const draftController = new DraftController<MessageDraft>(draftKey)
@ -47,9 +54,8 @@
let currentMessage: MessageDraft = chatMessage ?? currentDraft ?? getDefault()
let _id = currentMessage._id
let inputContent = currentMessage.message
let loading = false
$: createdMessageQuery.query(chunter.class.ChatMessage, { _id }, (result: ChatMessage[]) => {
$: createdMessageQuery.query(_class, { _id }, (result: ChatMessage[]) => {
if (result.length > 0 && _id !== chatMessage?._id) {
// Ouch we have got comment with same id created already.
currentMessage = getDefault()
@ -108,17 +114,46 @@
async function createMessage (event: CustomEvent) {
const { message, attachments } = event.detail
await client.addCollection<Doc, ChatMessage>(
_class,
object.space,
object._id,
object._class,
'comments',
{ message, attachments },
_id,
Date.now(),
account._id
)
if (_class === chunter.class.ThreadMessage) {
const parentMessage = object as ActivityMessage
await client.addCollection<ActivityMessage, ThreadMessage>(
chunter.class.ThreadMessage,
parentMessage.space,
parentMessage._id,
parentMessage._class,
'replies',
{
message,
attachments,
objectClass: parentMessage.attachedToClass,
objectId: parentMessage.attachedTo
},
_id as Ref<ThreadMessage>,
Date.now(),
account._id
)
await client.update(parentMessage, { lastReply: Date.now() })
const hasPerson = !!parentMessage.repliedPersons?.includes(account.person)
if (!hasPerson) {
await client.update(parentMessage, { $push: { repliedPersons: account.person } })
}
} else {
await client.addCollection<Doc, ChatMessage>(
_class,
object.space,
object._id,
object._class,
collection,
{ message, attachments },
_id,
Date.now(),
account._id
)
}
}
async function editMessage (event: CustomEvent) {
@ -126,7 +161,7 @@
return
}
const { message, attachments } = event.detail
await client.update(chatMessage, { message, attachments, isEdited: true })
await client.update(chatMessage, { message, attachments, editedOn: Date.now() })
}
export function submit (): void {
inputRef.submit()

View File

@ -24,10 +24,10 @@
import view from '@hcengineering/view'
import activity, { DisplayActivityMessage } from '@hcengineering/activity'
import { ActivityMessageTemplate } from '@hcengineering/activity-resources'
import chunter, { ChatMessage, ChatMessageViewlet } from '@hcengineering/chunter'
import ChatMessageHeader from './ChatMessageHeader.svelte'
import ChatMessageInput from './ChatMessageInput.svelte'
import chunter, { ChatMessage, ChatMessageViewlet } from '@hcengineering/chunter'
export let value: ChatMessage | undefined
export let showNotify: boolean = false
@ -35,8 +35,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 skipLabel = false
export let onClick: (() => void) | undefined = undefined
export let onReply: (() => void) | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
@ -58,7 +62,7 @@
$: value &&
viewletQuery.query(
chunter.class.ChatMessageViewlet,
{ objectClass: value.attachedToClass },
{ objectClass: value.attachedToClass, messageClass: value._class },
(result: ChatMessageViewlet[]) => {
viewlet = result[0]
}
@ -131,13 +135,14 @@
{
label: activity.string.Edit,
icon: IconEdit,
group: 'edit',
action: handleEditAction
}
]
: [])
]
$: excludedActions = isOwn ? [] : [chunter.action.DeleteChatMessage]
$: excludedActions = []
let refInput: ChatMessageInput
</script>
@ -153,12 +158,15 @@
{isSelected}
{shouldScroll}
{embedded}
{hasActionsMenu}
{withActions}
{actions}
{showEmbedded}
{hideReplies}
{onClick}
{onReply}
>
<svelte:fragment slot="header">
<ChatMessageHeader {object} {parentObject} message={value} {viewlet} {person} />
<ChatMessageHeader {object} {parentObject} message={value} {viewlet} {person} {skipLabel} />
</svelte:fragment>
<svelte:fragment slot="content">
{#if !isEditing}

View File

@ -0,0 +1,146 @@
<!--
// 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 { Doc, Ref } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { Component, defineSeparators, getCurrentLocation, location, navigate, Separator } from '@hcengineering/ui'
import chunter from '@hcengineering/chunter'
import notification, { DocNotifyContext } from '@hcengineering/notification'
import { NavHeader } from '@hcengineering/workbench-resources'
import { NavigatorModel, SpecialNavModel } from '@hcengineering/workbench'
import { ActivityMessagesFilter } from '@hcengineering/activity'
import ChatNavigator from './navigator/ChatNavigator.svelte'
import ChannelView from '../ChannelView.svelte'
import DocChatPanel from './DocChatPanel.svelte'
import { chatSpecials } from './utils'
export let visibleNav: boolean = true
export let navFloat: boolean = false
export let appsDirection: 'vertical' | 'horizontal' = 'horizontal'
const notifyContextQuery = createQuery()
const objectQuery = createQuery()
const navigatorModel: NavigatorModel = {
spaces: [],
specials: chatSpecials
}
let selectedContextId: Ref<DocNotifyContext> | undefined = undefined
let selectedContext: DocNotifyContext | undefined = undefined
let filterId: Ref<ActivityMessagesFilter> | undefined = undefined
let object: Doc | undefined = undefined
let currentSpecial: SpecialNavModel | undefined
location.subscribe((loc) => {
updateSpecialComponent(loc.path[3])
updateSelectedContext(loc.path[3])
filterId = loc.query?.filter as Ref<ActivityMessagesFilter> | undefined
})
function updateSpecialComponent (id?: string): SpecialNavModel | undefined {
if (id === undefined) {
return
}
currentSpecial = navigatorModel?.specials?.find((special) => special.id === id)
}
function updateSelectedContext (id?: string) {
selectedContextId = id as Ref<DocNotifyContext> | undefined
if (selectedContext && selectedContextId !== selectedContext._id) {
selectedContext = undefined
}
}
defineSeparators('chat', [
{ minSize: 20, maxSize: 40, size: 30, float: 'navigator' },
{ size: 'auto', minSize: 30, maxSize: 'auto', float: undefined }
])
$: selectedContextId &&
notifyContextQuery.query(
notification.class.DocNotifyContext,
{ _id: selectedContextId },
(res: DocNotifyContext[]) => {
selectedContext = res[0]
}
)
$: selectedContext !== undefined &&
objectQuery.query(selectedContext.attachedToClass, { _id: selectedContext.attachedTo }, (res: Doc[]) => {
object = res[0]
})
$: if (selectedContext) {
console.log({ selectedContext: selectedContext.attachedToClass })
}
$: isDocChatOpened =
selectedContext !== undefined &&
![chunter.class.Channel, chunter.class.DirectMessage].includes(selectedContext.attachedToClass)
</script>
<div class="flex-row-top h-full">
{#if visibleNav}
<div
class="antiPanel-navigator {appsDirection === 'horizontal'
? 'portrait'
: 'landscape'} background-comp-header-color"
>
<div class="antiPanel-wrap__content">
{#if !isDocChatOpened}
<NavHeader label={chunter.string.Chat} />
<ChatNavigator {selectedContextId} {currentSpecial} />
{:else if object}
<DocChatPanel {object} {filterId} />
{/if}
</div>
<Separator name="chat" float={navFloat ? 'navigator' : true} index={0} />
</div>
<Separator name="chat" float={navFloat} index={0} />
{/if}
<div class="antiPanel-component filled w-full">
{#if currentSpecial}
<Component
is={currentSpecial.component}
props={{
model: navigatorModel,
...currentSpecial.componentProps,
visibleNav,
navFloat,
appsDirection
}}
on:action={(e) => {
if (e?.detail) {
const loc = getCurrentLocation()
loc.query = { ...loc.query, ...e.detail }
navigate(loc)
}
}}
/>
{/if}
{#if selectedContext && object}
{#key selectedContext._id}
<ChannelView notifyContext={selectedContext} {object} {filterId} />
{/key}
{/if}
</div>
</div>

View File

@ -0,0 +1,163 @@
<!--
// 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 { Doc, Mixin, Ref, SortingOrder } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { ActionIcon, getCurrentResolvedLocation, Label, navigate } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { DocAttributeBar, DocNavLink, getDocLinkTitle, getDocMixins, openDoc } from '@hcengineering/view-resources'
import { getResource } from '@hcengineering/platform'
import { SpecialElement } from '@hcengineering/workbench-resources'
import activity, { ActivityMessage, ActivityMessagesFilter } from '@hcengineering/activity'
import { combineActivityMessages } from '@hcengineering/activity-resources'
import chunter from '../../plugin'
export let object: Doc
export let filterId: Ref<ActivityMessagesFilter> = activity.ids.AllFilter
const client = getClient()
const hierarchy = client.getHierarchy()
const activityMessagesQuery = createQuery()
$: clazz = hierarchy.getClass(object._class)
$: objectChatPanel = hierarchy.classHierarchyMixin(object._class, chunter.mixin.ObjectChatPanel)
let mixins: Array<Mixin<Doc>> = []
let linkTitle: string | undefined = undefined
let activityMessages: ActivityMessage[] = []
let filters: ActivityMessagesFilter[] = []
$: mixins = getDocMixins(object)
$: getDocLinkTitle(client, object._id, object._class, object).then((res) => {
linkTitle = res
})
$: activityMessagesQuery.query(activity.class.ActivityMessage, { attachedTo: object._id }, (res) => {
activityMessages = combineActivityMessages(res)
})
client
.findAll(activity.class.ActivityMessagesFilter, {}, { sort: { position: SortingOrder.Ascending } })
.then((res) => {
filters = res
})
function handleFilterSelected (filter: ActivityMessagesFilter) {
const loc = getCurrentResolvedLocation()
loc.query = { filter: filter._id }
navigate(loc)
}
async function getMessagesCount (
filter: ActivityMessagesFilter,
activityMessages: ActivityMessage[]
): Promise<number> {
if (filter._id === activity.ids.AllFilter) {
return activityMessages.length
}
const filterFn = await getResource(filter.filter)
const filteredMessages = activityMessages.filter((message) => filterFn(message, object._class))
return filteredMessages.length
}
</script>
<div class="popupPanel panel">
<div class="actions">
<ActionIcon
icon={view.icon.Open}
size="medium"
action={() => {
openDoc(client.getHierarchy(), object)
}}
/>
</div>
<div class="header">
<div class="identifier">
<Label label={clazz.label} />
{#if linkTitle}
<DocNavLink {object}>
{linkTitle}
</DocNavLink>
{/if}
</div>
{#if objectChatPanel}
{#await getResource(objectChatPanel.titleProvider) then getTitle}
<div class="title overflow-label">
{getTitle(object)}
</div>
{/await}
{/if}
</div>
<DocAttributeBar {object} {mixins} ignoreKeys={objectChatPanel?.ignoreKeys ?? []} showHeader={false} />
{#each filters as filter}
{#key filter._id}
{#await getMessagesCount(filter, activityMessages) then count}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if filter._id === activity.ids.AllFilter || count > 0}
<div
on:click={() => {
handleFilterSelected(filter)
}}
>
<SpecialElement label={filter.label} selected={filterId === filter._id} notifications={count} />
</div>
{/if}
{/await}
{/key}
{/each}
</div>
<style lang="scss">
.header {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex-shrink: 0;
margin: 0.75rem;
padding: 0 0.75rem;
color: var(--theme-content-color);
margin-bottom: 0;
}
.identifier {
display: flex;
gap: 0.25rem;
color: var(--theme-halfcontent-color);
font-size: 0.75rem;
}
.title {
font-weight: 500;
font-size: 1.125rem;
}
.actions {
display: flex;
justify-content: end;
margin: 0.75rem;
padding: 0 0.75rem;
margin-bottom: 0;
}
</style>

View File

@ -1,5 +1,5 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
// 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
@ -15,36 +15,43 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { IconFolder, EditBox, ToggleWithLabel, Grid } from '@hcengineering/ui'
import workbench from '@hcengineering/workbench'
import presentation, { getClient, SpaceCreateCard } from '@hcengineering/presentation'
import { getResource } from '@hcengineering/platform'
import chunter from '../plugin'
import core, { getCurrentAccount } from '@hcengineering/core'
import notification from '@hcengineering/notification'
import chunter from '../../../plugin'
const dispatch = createEventDispatcher()
let isPrivate: boolean = false
let name: string = ''
export function canClose (): boolean {
return name === ''
}
const client = getClient()
async function createChannel () {
const accountId = getCurrentAccount()._id
const channelId = await client.createDoc(chunter.class.Channel, core.space.Space, {
name,
description: '',
private: isPrivate,
archived: false,
members: [getCurrentAccount()._id]
members: [accountId]
})
const notifyContextId = await client.createDoc(notification.class.DocNotifyContext, core.space.Space, {
user: accountId,
attachedTo: channelId,
attachedToClass: chunter.class.Channel,
hidden: false
})
const navigate = await getResource(workbench.actionImpl.Navigate)
await navigate([], undefined as any, {
mode: 'space',
space: channelId
})
const navigate = await getResource(chunter.actionImpl.OpenChannel)
await navigate(undefined, undefined, { _id: notifyContextId })
}
</script>

View File

@ -1,5 +1,5 @@
<!--
// Copyright © 2022 Anticrm Platform Contributors.
// 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
@ -16,14 +16,15 @@
import { createEventDispatcher } from 'svelte'
import { deepEqual } from 'fast-equals'
import { DirectMessage } from '@hcengineering/chunter'
import contact, { Employee } from '@hcengineering/contact'
import core, { getCurrentAccount, Ref } from '@hcengineering/core'
import { getClient, SpaceCreateCard } from '@hcengineering/presentation'
import workbench from '@hcengineering/workbench'
import { getResource } from '@hcengineering/platform'
import chunter from '../plugin'
import { UserBoxList } from '@hcengineering/contact-resources'
import notification, { DocNotifyContext } from '@hcengineering/notification'
import chunter from '../../../plugin'
const dispatch = createEventDispatcher()
const client = getClient()
@ -33,17 +34,26 @@
async function createDirectMessage () {
const employeeAccounts = await client.findAll(contact.class.PersonAccount, { person: { $in: employeeIds } })
const accIds = [myAccId, ...employeeAccounts.filter(({ _id }) => _id !== myAccId).map(({ _id }) => _id)].sort()
const accIds = [myAccId, ...employeeAccounts.filter((ea) => ea._id !== myAccId).map((ea) => ea._id)].sort()
const existingDms = await client.findAll(chunter.class.DirectMessage, {})
const navigate = await getResource(workbench.actionImpl.Navigate)
const existingContexts = await client.findAll<DocNotifyContext>(
notification.class.DocNotifyContext,
{
user: myAccId,
attachedToClass: chunter.class.DirectMessage
},
{ lookup: { attachedTo: chunter.class.DirectMessage } }
)
const navigate = await getResource(chunter.actionImpl.OpenChannel)
for (const context of existingContexts) {
if (deepEqual((context.$lookup?.attachedTo as DirectMessage)?.members.sort(), accIds)) {
if (context.hidden) {
await client.update(context, { hidden: false })
}
await navigate(context)
for (const dm of existingDms) {
if (deepEqual(dm.members.sort(), accIds)) {
await navigate([], undefined as any, {
mode: 'space',
space: dm._id
})
return
}
}
@ -56,10 +66,14 @@
members: accIds
})
await navigate([], undefined as any, {
mode: 'space',
space: dmId
const notifyContextId = await client.createDoc(notification.class.DocNotifyContext, core.space.Space, {
user: myAccId,
attachedTo: dmId,
attachedToClass: chunter.class.DirectMessage,
hidden: false
})
await navigate(undefined, undefined, { _id: notifyContextId })
}
</script>

View File

@ -0,0 +1,95 @@
<!--
// 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 type { Doc, Ref } from '@hcengineering/core'
import { getCurrentAccount } from '@hcengineering/core'
import notification, { DocNotifyContext } from '@hcengineering/notification'
import { getResource } from '@hcengineering/platform'
import { createQuery } from '@hcengineering/presentation'
import { Action, IconAdd, showPopup } from '@hcengineering/ui'
import { TreeNode } from '@hcengineering/view-resources'
import { getDocByNotifyContext } from '../utils'
import { ChatNavGroupModel } from '../types'
import ChatNavItem from './ChatNavItem.svelte'
export let model: ChatNavGroupModel
export let selectedContextId: Ref<DocNotifyContext> | undefined = undefined
const dispatch = createEventDispatcher()
const notifyContextsQuery = createQuery()
let notifyContexts: DocNotifyContext[] = []
let docByNotifyContext: Map<Ref<DocNotifyContext>, Doc> = new Map<Ref<DocNotifyContext>, Doc>()
notifyContextsQuery.query(
notification.class.DocNotifyContext,
{
...model.query,
hidden: false,
user: getCurrentAccount()._id
},
(res: DocNotifyContext[]) => {
notifyContexts = res
}
)
$: getDocByNotifyContext(notifyContexts).then((res) => {
docByNotifyContext = res
})
function getGroupActions (): Action[] {
const result: Action[] = []
if (model.addLabel !== undefined && model.addComponent !== undefined) {
result.push({
label: model.addLabel,
icon: IconAdd,
action: async (_id: Ref<Doc>): Promise<void> => {
dispatch('open')
if (model.addComponent !== undefined) {
showPopup(model.addComponent, {}, 'top')
}
}
})
}
const additionalActions = model.actions?.map(({ icon, label, action }) => ({
icon,
label,
action: async (ctx: any, evt: Event) => {
const impl = await getResource(action)
await impl(notifyContexts, evt)
}
}))
return additionalActions ? result.concat(additionalActions) : result
}
</script>
{#if !model.hideEmpty || notifyContexts.length > 0}
<TreeNode _id={`tree-${model.id}`} label={model.label} node actions={async () => getGroupActions()}>
{#each notifyContexts as docNotifyContext}
{@const doc = docByNotifyContext.get(docNotifyContext._id)}
{#if doc}
{#key docNotifyContext._id}
<ChatNavItem {doc} notifyContext={docNotifyContext} {selectedContextId} />
{/key}
{/if}
{/each}
</TreeNode>
{/if}

View File

@ -0,0 +1,121 @@
<!--
// 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 { Doc, Ref } from '@hcengineering/core'
import notification, { ActivityInboxNotification, DocNotifyContext } from '@hcengineering/notification'
import { getResource } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Action, IconEdit } from '@hcengineering/ui'
import {
getActions as getContributedActions,
getDocLinkTitle,
getDocTitle,
NavLink,
TreeItem
} from '@hcengineering/view-resources'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { ActivityMessage } from '@hcengineering/activity'
import { ThreadMessage } from '@hcengineering/chunter'
import chunter from '../../../plugin'
import { getChannelIcon } from '../../../utils'
export let doc: Doc
export let notifyContext: DocNotifyContext
export let selectedContextId: Ref<DocNotifyContext> | undefined = undefined
const client = getClient()
const notificationClient = InboxNotificationsClientImpl.getClient()
const threadMessagesQuery = createQuery()
let threadMessages: ThreadMessage[] = []
$: inboxNotificationsStore = notificationClient.activityInboxNotifications
$: threadMessagesQuery.query(
chunter.class.ThreadMessage,
{
objectId: doc._id
},
(res) => {
threadMessages = res
}
)
function hasNewMessages (
context: DocNotifyContext,
notifications: ActivityInboxNotification[],
threadMessages: ThreadMessage[]
) {
const { lastViewedTimestamp = 0, lastUpdateTimestamp = 0 } = context
if (lastViewedTimestamp < lastUpdateTimestamp) {
return true
}
const threadMessagesIds = threadMessages.map(({ _id }) => _id) as Ref<ActivityMessage>[]
return notifications.some(({ attachedTo, isViewed }) => threadMessagesIds.includes(attachedTo) && !isViewed)
}
async function getItemActions (docNotifyContext: DocNotifyContext): Promise<Action[]> {
const result = []
const actions = await getContributedActions(client, docNotifyContext, notification.class.DocNotifyContext)
for (const action of actions) {
const { visibilityTester } = action
const getIsVisible = visibilityTester && (await getResource(visibilityTester))
const isVisible = getIsVisible ? await getIsVisible(docNotifyContext) : true
if (!isVisible) {
continue
}
result.push({
icon: action.icon ?? IconEdit,
label: action.label,
action: async (ctx: any, evt: Event) => {
const impl = await getResource(action.action)
await impl(docNotifyContext, evt, action.actionProps)
}
})
}
return result
}
async function getChannelName (doc: Doc): Promise<string | undefined> {
if (doc._class === chunter.class.DirectMessage) {
return await getDocTitle(client, doc._id, doc._class, doc)
}
return await getDocLinkTitle(client, doc._id, doc._class, doc)
}
</script>
<NavLink space={notifyContext._id}>
{#await getChannelName(doc) then name}
<TreeItem
_id={notifyContext._id}
title={name ?? chunter.string.Channel}
selected={selectedContextId === notifyContext._id}
icon={getChannelIcon(doc)}
iconProps={{ value: doc }}
showNotify={hasNewMessages(notifyContext, $inboxNotificationsStore, threadMessages)}
actions={async () => await getItemActions(notifyContext)}
indent
/>
{/await}
</NavLink>

View File

@ -0,0 +1,75 @@
<!--
// 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 { Ref } from '@hcengineering/core'
import { Scroller } from '@hcengineering/ui'
import { DocNotifyContext } from '@hcengineering/notification'
import { SpecialNavModel } from '@hcengineering/workbench'
import { NavLink } from '@hcengineering/view-resources'
import { SpecialElement, TreeSeparator } from '@hcengineering/workbench-resources'
import { getResource } from '@hcengineering/platform'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import ChatNavGroup from './ChatNavGroup.svelte'
import { chatNavGroups, chatSpecials } from '../utils'
export let selectedContextId: Ref<DocNotifyContext> | undefined
export let currentSpecial: SpecialNavModel | undefined
const notificationClient = InboxNotificationsClientImpl.getClient()
let notifyContexts: DocNotifyContext[] = []
notificationClient.docNotifyContexts.subscribe((res) => {
notifyContexts = res
})
async function isSpecialVisible (special: SpecialNavModel, docNotifyContexts: DocNotifyContext[]) {
if (special.visibleIf === undefined) {
return true
}
const getIsVisible = await getResource(special.visibleIf)
return await getIsVisible(docNotifyContexts as any)
}
const menuSelection: boolean = false
</script>
<!--TODO: hasSpaceBrowser-->
<Scroller shrink>
{#each chatSpecials as special, row}
{#if row > 0 && chatSpecials[row].position !== chatSpecials[row - 1].position}
<TreeSeparator line />
{/if}
{#await isSpecialVisible(special, notifyContexts) then isVisible}
{#if isVisible}
<NavLink space={special.id}>
<SpecialElement
label={special.label}
icon={special.icon}
selected={menuSelection ? false : special.id === currentSpecial?.id}
/>
</NavLink>
{/if}
{/await}
{/each}
{#each chatNavGroups as model}
<ChatNavGroup {selectedContextId} {model} on:open />
{/each}
<div class="antiNav-space" />
</Scroller>

View File

@ -0,0 +1,262 @@
<!--
// 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,
DocumentQuery,
FindOptions,
getCurrentAccount,
Ref,
SortingOrder,
SortingQuery,
Space
} from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import {
AnyComponent,
Button,
getCurrentResolvedLocation,
Icon,
Label,
navigate,
Scroller,
SearchEdit,
showPopup
} from '@hcengineering/ui'
import { FilterBar, FilterButton, SpacePresenter } from '@hcengineering/view-resources'
import workbench from '@hcengineering/workbench'
import { getChannelIcon } from '../../../utils'
export let _class: Ref<Class<Space>>
export let label: IntlString
export let createItemDialog: AnyComponent | undefined = undefined
export let createItemLabel: IntlString = presentation.string.Create
export let withHeader: boolean = true
export let withFilterButton: boolean = true
export let search: string = ''
const me = getCurrentAccount()._id
const client = getClient()
const spaceQuery = createQuery()
const sort: SortingQuery<Space> = {
name: SortingOrder.Ascending
}
let searchQuery: DocumentQuery<Space>
let resultQuery: DocumentQuery<Space>
let spaces: Space[] = []
$: updateSearchQuery(search)
$: update(sort, resultQuery)
async function update (sort: SortingQuery<Space>, resultQuery: DocumentQuery<Space>): Promise<void> {
const options: FindOptions<Space> = {
sort
}
spaceQuery.query(
_class,
{
...resultQuery
},
(res) => {
spaces = res
},
options
)
}
function updateSearchQuery (search: string): void {
searchQuery = search.length ? { $search: search } : {}
}
function showCreateDialog (ev: Event) {
showPopup(createItemDialog as AnyComponent, {}, 'middle')
}
async function join (space: Space): Promise<void> {
if (space.members.includes(me)) return
await client.update(space, {
$push: {
members: me
}
})
}
async function leave (space: Space): Promise<void> {
if (!space.members.includes(me)) return
await client.update(space, {
$pull: {
members: me
}
})
}
async function view (space: Space): Promise<void> {
const loc = getCurrentResolvedLocation()
loc.path[3] = space._id
navigate(loc)
}
</script>
{#if withHeader}
<div class="ac-header full divide">
<div class="ac-header__wrap-title">
<span class="ac-header__title"><Label {label} /></span>
</div>
{#if createItemDialog}
<div class="mb-1 clear-mins">
<Button
label={createItemLabel}
kind={'primary'}
size={'medium'}
on:click={(ev) => {
showCreateDialog(ev)
}}
/>
</div>
{/if}
</div>
<div class="ac-header full divide search-start">
<div class="ac-header-full small-gap">
<SearchEdit
bind:value={search}
on:change={() => {
updateSearchQuery(search)
update(sort, resultQuery)
}}
/>
<!-- <ActionIcon icon={IconMoreH} size={'small'} /> -->
<div class="buttons-divider" />
{#if withFilterButton}
<FilterButton {_class} />
{/if}
</div>
</div>
{:else if withFilterButton}
<div class="ac-header full divide">
<div class="ac-header-full small-gap">
<FilterButton {_class} />
</div>
</div>
{/if}
<FilterBar {_class} query={searchQuery} space={undefined} on:change={(e) => (resultQuery = e.detail)} />
<Scroller padding={'2.5rem'}>
<div class="spaces-container">
{#each spaces as space (space._id)}
{@const icon = getChannelIcon(space)}
{@const joined = space.members.includes(me)}
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="item flex-between" tabindex="0">
<div class="flex-col clear-mins">
<div class="fs-title flex-row-center">
{#if icon}
<div class="icon"><Icon {icon} size={'small'} /></div>
{/if}
<SpacePresenter value={space} />
</div>
<div class="flex-row-center">
{#if joined}
<Label label={workbench.string.Joined} />
&#183
{/if}
{space.members.length}
&#183
{space.description}
</div>
</div>
<div class="tools flex-row-center gap-2">
{#if joined}
<Button
size={'x-large'}
label={workbench.string.Leave}
on:click={async () => {
await leave(space)
}}
/>
{:else}
<Button
size={'x-large'}
label={workbench.string.View}
on:click={async () => {
await view(space)
}}
/>
<Button
size={'x-large'}
kind={'primary'}
label={workbench.string.Join}
on:click={async () => {
await join(space)
}}
/>
{/if}
</div>
</div>
{/each}
</div>
{#if createItemDialog}
<div class="flex-center mt-10">
<Button
size={'x-large'}
kind={'primary'}
label={createItemLabel}
on:click={(ev) => {
showCreateDialog(ev)
}}
/>
</div>
{/if}
</Scroller>
<style lang="scss">
.spaces-container {
display: flex;
flex-direction: column;
border: 1px solid var(--theme-list-border-color);
border-radius: 0.25rem;
.item {
padding: 1rem 0.75rem;
color: var(--theme-caption-color);
cursor: pointer;
.icon {
margin-right: 0.375rem;
color: var(--theme-trans-color);
}
&:not(:last-child) {
border-bottom: 1px solid var(--theme-divider-color);
}
&:hover,
&:focus {
background-color: var(--highlight-hover);
.icon {
color: var(--theme-caption-color);
}
.tools {
visibility: visible;
}
}
.tools {
position: relative;
visibility: hidden;
}
}
}
</style>

View File

@ -1,19 +1,34 @@
<!--
// 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 attachment from '@hcengineering/attachment'
import { FileBrowser } from '@hcengineering/attachment-resources'
import { AnySvelteComponent, Button } from '@hcengineering/ui'
import workbench from '@hcengineering/workbench'
import { SpaceBrowser } from '@hcengineering/workbench-resources'
import Header from './Header.svelte'
import contact from '@hcengineering/contact-resources/src/plugin'
import { EmployeeBrowser } from '@hcengineering/contact-resources'
import { userSearch } from '../index'
import plugin from '../plugin'
import { SearchType } from '../utils'
import MessagesBrowser from './MessagesBrowser.svelte'
import { FilterButton } from '@hcengineering/view-resources'
import { Class, Doc, Ref } from '@hcengineering/core'
import { userSearch } from '../../../index'
import { SearchType } from '../../../utils'
import plugin from '../../../plugin'
import Header from '../../Header.svelte'
let userSearch_: string = ''
userSearch.subscribe((v) => (userSearch_ = v))
@ -25,7 +40,7 @@
filterClass?: Ref<Class<Doc>>
props?: Record<string, any>
}[] = [
{ searchType: SearchType.Messages, component: MessagesBrowser, filterClass: plugin.class.ChunterMessage },
{ searchType: SearchType.Messages, component: MessagesBrowser },
{
searchType: SearchType.Channels,
component: SpaceBrowser,

View File

@ -0,0 +1,93 @@
<!--
// 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 chunter, { ChatMessage } from '@hcengineering/chunter'
import { DocumentQuery, SortingOrder } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Label, Scroller, SearchEdit } from '@hcengineering/ui'
import { FilterBar } from '@hcengineering/view-resources'
import { ActivityMessagePresenter } from '@hcengineering/activity-resources'
import plugin from '../../../plugin'
import { openMessageFromSpecial } from '../../../utils'
export let withHeader: boolean = true
export let search: string = ''
let searchQuery: DocumentQuery<ChatMessage> = { $search: search }
function updateSearchQuery (search: string): void {
searchQuery = { $search: search }
}
$: updateSearchQuery(search)
const client = getClient()
let messages: ChatMessage[] = []
let resultQuery: DocumentQuery<ChatMessage> = { ...searchQuery }
async function updateMessages (resultQuery: DocumentQuery<ChatMessage>) {
messages = await client.findAll(
chunter.class.ChatMessage,
{
...resultQuery
},
{
sort: { createdOn: SortingOrder.Descending },
limit: 100
}
)
}
$: updateMessages(resultQuery)
</script>
{#if withHeader}
<div class="ac-header full divide">
<div class="ac-header__wrap-title">
<span class="ac-header__title"><Label label={plugin.string.MessagesBrowser} /></span>
</div>
<SearchEdit
value={search}
on:change={() => {
updateSearchQuery(search)
updateMessages(resultQuery)
}}
/>
</div>
{/if}
<FilterBar
_class={chunter.class.ChatMessage}
space={undefined}
query={searchQuery}
on:change={(e) => (resultQuery = e.detail)}
/>
{#if messages.length > 0}
<Scroller>
{#each messages as message}
<ActivityMessagePresenter
value={message}
onClick={() => {
openMessageFromSpecial(message)
}}
/>
{/each}
</Scroller>
{:else}
<div class="flex-center h-full text-lg">
<Label label={plugin.string.NoResults} />
</div>
{/if}

View File

@ -0,0 +1,164 @@
<!--
// 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 attachment, { Attachment, SavedAttachments } from '@hcengineering/attachment'
import { AttachmentPreview } from '@hcengineering/attachment-resources'
import { Person, PersonAccount, getName as getContactName } from '@hcengineering/contact'
import { personAccountByIdStore, personByIdStore } from '@hcengineering/contact-resources'
import { getDisplayTime, IdMap, Ref, WithLookup } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Icon, Label, Scroller } from '@hcengineering/ui'
import activity, { ActivityMessage, SavedMessage } from '@hcengineering/activity'
import { ActivityMessagePresenter } from '@hcengineering/activity-resources'
import chunter from '../../../plugin'
import { openMessageFromSpecial } from '../../../utils'
const client = getClient()
let savedMessages: WithLookup<SavedMessage>[] = []
let savedAttachments: WithLookup<SavedAttachments>[] = []
const savedMessagesQuery = createQuery()
const savedAttachmentsQuery = createQuery()
savedMessagesQuery.query(
activity.class.SavedMessage,
{},
(res) => {
savedMessages = res
},
{ lookup: { attachedTo: activity.class.ActivityMessage } }
)
savedAttachmentsQuery.query(
attachment.class.SavedAttachments,
{},
(res) => {
savedAttachments = res
},
{ lookup: { attachedTo: attachment.class.Attachment } }
)
async function openAttachment (attach?: Attachment) {
if (attach === undefined) {
return
}
const messageId: Ref<ActivityMessage> = attach.attachedTo as Ref<ActivityMessage>
await client.findOne(activity.class.ActivityMessage, { _id: messageId }).then((res) => {
if (res !== undefined) {
openMessageFromSpecial(res)
}
})
}
function getName (
a: Attachment,
personAccountByIdStore: IdMap<PersonAccount>,
personByIdStore: IdMap<Person>
): string | undefined {
const acc = personAccountByIdStore.get(a.modifiedBy as Ref<PersonAccount>)
if (acc !== undefined) {
const emp = personByIdStore.get(acc?.person)
if (emp !== undefined) {
return getContactName(client.getHierarchy(), emp)
}
}
}
</script>
<div class="ac-header full divide caption-height">
<div class="ac-header__wrap-title">
<span class="ac-header__title"><Label label={chunter.string.SavedItems} /></span>
</div>
</div>
<Scroller>
{#if savedMessages.length > 0 || savedAttachments.length > 0}
{#each savedMessages as message}
{#if message.$lookup?.attachedTo}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<ActivityMessagePresenter
value={message.$lookup?.attachedTo}
onClick={() => {
openMessageFromSpecial(message.$lookup?.attachedTo)
}}
/>
{/if}
{/each}
{#each savedAttachments as attach}
{#if attach.$lookup?.attachedTo}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="attachmentContainer flex-no-shrink clear-mins"
on:click={() => openAttachment(attach.$lookup?.attachedTo)}
>
<AttachmentPreview value={attach.$lookup.attachedTo} isSaved={true} />
<div class="label">
<Label
label={chunter.string.SharedBy}
params={{
name: getName(attach.$lookup.attachedTo, $personAccountByIdStore, $personByIdStore),
time: getDisplayTime(attach.modifiedOn)
}}
/>
</div>
</div>
{/if}
{/each}
{:else}
<div class="empty">
<Icon icon={activity.icon.Bookmark} size="large" />
<div class="an-element__label header">
<Label label={chunter.string.EmptySavedHeader} />
</div>
<span class="an-element__label">
<Label label={chunter.string.EmptySavedText} />
</span>
</div>
{/if}
</Scroller>
<style lang="scss">
.empty {
display: flex;
align-self: center;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
height: inherit;
width: 30rem;
}
.header {
font-weight: 600;
margin: 1rem;
}
.attachmentContainer {
padding: 2rem;
&:hover {
background-color: var(--highlight-hover);
}
.label {
padding-top: 1rem;
}
}
</style>

View File

@ -0,0 +1,55 @@
<!--
// 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 { getCurrentAccount } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { Label, Scroller } from '@hcengineering/ui'
import activity, { ActivityMessage } from '@hcengineering/activity'
import { PersonAccount } from '@hcengineering/contact'
import { ActivityMessagePresenter } from '@hcengineering/activity-resources'
import chunter from '../../../plugin'
const threadsQuery = createQuery()
const me = getCurrentAccount() as PersonAccount
let threads: ActivityMessage[] = []
$: threadsQuery.query(
activity.class.ActivityMessage,
{
replies: { $gte: 1 }
},
(res) => {
threads = res.filter(
({ createdBy, repliedPersons }) => createdBy === me._id || repliedPersons?.includes(me.person)
)
}
)
</script>
<div class="ac-header full divide caption-height">
<div class="ac-header__wrap-title">
<span class="ac-header__title"><Label label={chunter.string.Threads} /></span>
</div>
</div>
<Scroller>
{#each threads as thread}
<div class="ml-4 mr-4">
<ActivityMessagePresenter value={thread} />
</div>
{/each}
</Scroller>

View File

@ -0,0 +1,33 @@
//
// 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.
//
import { type Asset, type IntlString } from '@hcengineering/platform'
import { type DocumentQuery } from '@hcengineering/core'
import { type DocNotifyContext } from '@hcengineering/notification'
import { type ViewAction } from '@hcengineering/view'
import { type AnyComponent } from '@hcengineering/ui'
export interface ChatNavGroupModel {
id: string
label?: IntlString
addLabel?: IntlString
addComponent?: AnyComponent
query?: DocumentQuery<DocNotifyContext>
hideEmpty?: boolean
actions?: Array<{
icon: Asset
label: IntlString
action: ViewAction
}>
}

View File

@ -0,0 +1,178 @@
//
// 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.
//
import notification, { type DocNotifyContext } from '@hcengineering/notification'
import { type Doc, type Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import view from '@hcengineering/view'
import workbench, { type SpecialNavModel } from '@hcengineering/workbench'
import attachment from '@hcengineering/attachment'
import activity from '@hcengineering/activity'
import { type ChatNavGroupModel } from './types'
import chunter from '../../plugin'
export const chatSpecials: SpecialNavModel[] = [
{
id: 'channelBrowser',
component: workbench.component.SpaceBrowser,
icon: chunter.icon.ChannelBrowser,
label: chunter.string.ChannelBrowser,
position: 'top',
componentProps: {
_class: chunter.class.Channel,
label: chunter.string.ChannelBrowser,
createItemDialog: chunter.component.CreateChannel,
createItemLabel: chunter.string.CreateChannel
}
},
{
id: 'fileBrowser',
label: attachment.string.FileBrowser,
icon: attachment.icon.FileBrowser,
component: attachment.component.FileBrowser,
position: 'top',
componentProps: {
requestedSpaceClasses: [chunter.class.Channel, chunter.class.DirectMessage]
}
},
{
id: 'threads',
label: chunter.string.Threads,
icon: chunter.icon.Thread,
component: chunter.component.Threads,
position: 'top'
},
{
id: 'saved',
label: chunter.string.SavedItems,
icon: activity.icon.Bookmark,
position: 'bottom',
component: chunter.component.SavedMessages
},
{
id: 'archive',
component: workbench.component.Archive,
icon: view.icon.Archive,
label: workbench.string.Archive,
position: 'bottom',
componentProps: {
_class: notification.class.DocNotifyContext,
config: [
{ key: '', label: chunter.string.ChannelName },
{ key: 'attachedToClass', label: view.string.Type },
'modifiedOn'
],
baseMenuClass: notification.class.DocNotifyContext,
query: {
_class: notification.class.DocNotifyContext,
hidden: true
}
},
visibleIf: notification.function.HasHiddenDocNotifyContext
},
{
id: 'chunterBrowser',
label: chunter.string.ChunterBrowser,
icon: workbench.icon.Search,
component: chunter.component.ChunterBrowser,
visibleIf: chunter.function.ChunterBrowserVisible
}
]
export const chatNavGroups: ChatNavGroupModel[] = [
{
id: 'pinned',
label: notification.string.Pinned,
hideEmpty: true,
query: {
isPinned: true
},
actions: [
{
icon: view.icon.Delete,
label: view.string.Delete,
action: chunter.actionImpl.UnpinAllChannels
}
]
},
{
id: 'documents',
label: chunter.string.Docs,
query: {
attachedToClass: {
$nin: [
chunter.class.DirectMessage,
chunter.class.Channel,
activity.class.DocUpdateMessage,
chunter.class.ChatMessage,
chunter.class.ThreadMessage,
chunter.class.Backlink
]
},
isPinned: { $ne: true }
}
},
{
id: 'channels',
label: chunter.string.Channels,
query: {
attachedToClass: { $in: [chunter.class.Channel] },
isPinned: { $ne: true }
},
addLabel: chunter.string.CreateChannel,
addComponent: chunter.component.CreateChannel
},
{
id: 'direct',
label: chunter.string.DirectMessages,
query: {
attachedToClass: { $in: [chunter.class.DirectMessage] },
isPinned: { $ne: true }
},
addLabel: chunter.string.NewDirectMessage,
addComponent: chunter.component.CreateDirectMessage
}
]
export async function getDocByNotifyContext (
docNotifyContexts: DocNotifyContext[]
): Promise<Map<Ref<DocNotifyContext>, Doc>> {
const client = getClient()
const docs = await Promise.all(
docNotifyContexts.map(
async ({ attachedTo, attachedToClass }) => await client.findOne(attachedToClass, { _id: attachedTo })
)
)
const result: Map<Ref<DocNotifyContext>, Doc> = new Map<Ref<DocNotifyContext>, Doc>()
for (const doc of docs) {
if (doc === undefined) {
continue
}
const context = docNotifyContexts.find(({ attachedTo }) => attachedTo === doc._id)
if (context === undefined) {
continue
}
result.set(context._id, doc)
}
return result
}

Some files were not shown because too many files have changed in this diff Show More