Chunter last views (#1273)

Signed-off-by: Denis Bykhov <80476319+BykhovDenis@users.noreply.github.com>
This commit is contained in:
Denis Bykhov 2022-04-05 12:19:18 +06:00 committed by GitHub
parent f13353c9ec
commit ed1c29573f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 317 additions and 108 deletions

View File

@ -33,4 +33,8 @@ export function createModel (builder: Builder): void {
builder.mixin<Class<Doc>, ObjectDDParticipant>(chunter.class.Comment, core.class.Class, serverCore.mixin.ObjectDDParticipant, {
collectDocs: serverChunter.function.CommentRemove
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverChunter.trigger.CommentCreate
})
}

View File

@ -304,6 +304,7 @@ p:last-child { margin-block-end: 0; }
.mb-2 { margin-bottom: .5rem; }
.mb-3 { margin-bottom: .75rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mx-1 { margin: 0 .25rem; }
.mx-2 { margin: 0 .5rem; }
.mx-3 { margin: 0 .75rem; }

View File

@ -24,6 +24,7 @@
"Replies": "Replies",
"LastReply": "Last reply",
"RepliesCount": "{replies, plural, =1 {# reply} other {# replies}}",
"Thread": "Thread"
"Thread": "Thread",
"New": "New"
}
}

View File

@ -24,6 +24,7 @@
"Replies": "Ответы",
"LastReply": "Последний ответ",
"RepliesCount": "{replies, plural, =1 {# ответ} =2 {# ответа} =3 {# ответа} =4 {# ответа} other {# ответов}}",
"Thread": "Обсуждение"
"Thread": "Обсуждение",
"New": "Новое"
}
}

View File

@ -41,6 +41,7 @@
"@anticrm/text-editor": "~0.6.0",
"@anticrm/contact": "~0.6.5",
"@anticrm/contact-resources": "~0.6.0",
"@anticrm/notification-resources": "~0.6.0",
"@anticrm/attachment": "~0.6.1",
"@anticrm/attachment-resources": "~0.6.0",
"@anticrm/view-resources": "~0.6.0",

View File

@ -14,20 +14,37 @@
-->
<script lang="ts">
import attachment from '@anticrm/attachment'
import type { Message } from '@anticrm/chunter'
import contact,{ Employee } from '@anticrm/contact'
import { Ref,Space } from '@anticrm/core'
import core, { Doc,Ref,Space,Timestamp, WithLookup } from '@anticrm/core'
import { NotificationClientImpl } from '@anticrm/notification-resources'
import { createQuery } from '@anticrm/presentation'
import { afterUpdate, beforeUpdate } from 'svelte'
import chunter from '../plugin'
import ChannelSeparator from './ChannelSeparator.svelte'
import MessageComponent from './Message.svelte'
export let space: Ref<Space> | undefined
let messages: Message[] | undefined
let div: HTMLDivElement | undefined
let autoscroll: boolean = false
beforeUpdate(() => {
autoscroll = div !== undefined && (div.offsetHeight + div.scrollTop) > (div.scrollHeight - 20)
})
afterUpdate(() => {
if (div && autoscroll) div.scrollTo(0, div.scrollHeight)
})
let messages: WithLookup<Message>[] | undefined
let employees: Map<Ref<Employee>, Employee> = new Map<Ref<Employee>, Employee>()
const query = createQuery()
const employeeQuery = createQuery()
const notificationClient = NotificationClientImpl.getClient()
const lastViews = notificationClient.getLastViews()
employeeQuery.query(contact.class.Employee, { }, (res) => employees = new Map(res.map((r) => { return [r._id, r] })))
$: updateQuery(space)
@ -42,14 +59,37 @@
space
}, (res) => {
messages = res
newMessagesPos = newMessagesStart(messages)
notificationClient.updateLastView(space, chunter.class.Channel)
}, {
lookup: {
_id: { attachments: attachment.class.Attachment },
createBy: core.class.Account
}
})
}
function newMessagesStart (messages: Message[]): number {
if (space === undefined) return -1
const lastView = $lastViews.get(space)
if (lastView === undefined) return -1
for (let index = 0; index < messages.length; index++) {
const message = messages[index]
if (message.createOn > lastView) return index
}
return -1
}
let newMessagesPos: number = -1
</script>
<div class="flex-col container">
<div class="flex-col vScroll container" bind:this={div}>
{#if messages}
{#each messages as message}
{#each messages as message, i (message._id)}
{#if newMessagesPos === i}
<ChannelSeparator title={chunter.string.New} line reverse isNew />
{/if}
<MessageComponent {message} {employees} on:openThread />
{/each}
{/if}
@ -57,6 +97,7 @@
<style lang="scss">
.container {
flex-shrink: 0;
margin: 1rem 1rem 0;
padding: 1.5rem 1.5rem 0px;
}
</style>

View File

@ -20,12 +20,13 @@
export let title: IntlString
export let line: boolean = false
export let params: any = undefined
export let reverse: boolean = false
export let isNew: boolean = false
</script>
<div class="w-full flex-center whitespace-nowrap mb-4">
<div class="w-full text-sm flex-center whitespace-nowrap mb-6" class:flex-reverse={reverse} class:new={isNew} >
<Label label={title} {params} />
<div class="ml-4" class:line={line} ></div>
<div class:ml-4={!reverse} class:mr-4={reverse} class:line={line} ></div>
</div>
<style lang="scss">
@ -34,5 +35,12 @@
width: 100%;
height: 1px;
background-color: var(--theme-chat-divider);
}
.new {
.line {
background-color: var(--highlight-red);
}
color: var(--highlight-red);
}
</style>

View File

@ -17,6 +17,7 @@
import { AttachmentRefInput } from '@anticrm/attachment-resources'
import { Message } from '@anticrm/chunter'
import { generateId,getCurrentAccount,Ref,Space, TxFactory } from '@anticrm/core'
import { NotificationClientImpl } from '@anticrm/notification-resources'
import { getClient } from '@anticrm/presentation'
import { getCurrentLocation,navigate } from '@anticrm/ui'
import { createBacklinks } from '../backlinks'
@ -28,6 +29,7 @@
const client = getClient()
const _class = chunter.class.Message
let _id = generateId() as Ref<Message>
const notificationClient = NotificationClientImpl.getClient()
async function onMessage (event: CustomEvent) {
const { message, attachments } = event.detail
@ -40,10 +42,12 @@
attachments
}, _id)
tx.attributes.createOn = tx.modifiedOn
await notificationClient.updateLastView(space, chunter.class.Channel, tx.modifiedOn, true)
await client.tx(tx)
// Create an backlink to document
await createBacklinks(client, space, chunter.class.Channel, _id, message)
_id = generateId()
}
@ -55,22 +59,12 @@
</script>
<div class="msg-board">
<Channel {space} on:openThread={(e) => { openThread(e.detail) }} />
</div>
<Channel {space} on:openThread={(e) => { openThread(e.detail) }} />
<div class="reference">
<AttachmentRefInput {space} {_class} objectId={_id} on:message={onMessage}/>
</div>
<style lang="scss">
.msg-board {
display: flex;
flex-direction: column;
flex-grow: 1;
margin: 1rem 1rem 0;
padding: 1.5rem 1.5rem 0px;
overflow: auto;
}
.reference {
margin: 1.25rem 2.5rem;
}

View File

@ -0,0 +1,82 @@
<!--
// 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 { Timestamp } from "@anticrm/core"
export let value: Timestamp
export let line: boolean = false
const current = new Date()
const target = new Date(value)
let options: Intl.DateTimeFormatOptions = {
day: 'numeric',
month: 'long'
}
if (current.getFullYear() !== target.getFullYear()) {
options = {
...options,
year: '2-digit'
}
}
</script>
<div class="flex-center container" class:line={line}>
<div class="title">{new Intl.DateTimeFormat('default', options).format(value)}</div>
</div>
<style lang="scss">
.container {
width: 100%;
height: 1.75rem;
margin-bottom: 2rem;
.title {
position: relative;
padding: .375rem .75rem;
font-weight: 600;
font-size: .75rem;
letter-spacing: .5;
text-transform: uppercase;
color: var(--theme-content-trans-color);
z-index: 1;
&::before {
position: absolute;
content: '';
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 1.25rem;
background-color: var(--theme-chat-divider);
z-index: -1;
}
}
&.line {
position: relative;
width: 100%;
&::before {
position: absolute;
content: '';
top: 50%;
left: 0;
width: 100%;
height: 1px;
background-color: var(--theme-chat-divider);
}
}
}
</style>

View File

@ -14,10 +14,11 @@
-->
<script lang="ts">
import { AttachmentDocList } from '@anticrm/attachment-resources'
import { Attachment } from '@anticrm/attachment'
import { AttachmentList } from '@anticrm/attachment-resources'
import type { Message } from '@anticrm/chunter'
import contact,{ Employee,EmployeeAccount,formatName } from '@anticrm/contact'
import { Account,Ref } from '@anticrm/core'
import { Employee,EmployeeAccount,formatName } from '@anticrm/contact'
import { Ref,WithLookup } from '@anticrm/core'
import { getResource } from '@anticrm/platform'
import { Avatar,getClient,MessageViewer } from '@anticrm/presentation'
import { ActionIcon,IconMoreH,Menu,showPopup } from '@anticrm/ui'
@ -32,10 +33,13 @@
import Reactions from './Reactions.svelte'
import Replies from './Replies.svelte'
export let message: Message
export let message: WithLookup<Message>
export let employees: Map<Ref<Employee>, Employee>
export let thread: boolean = false
$: employee = getEmployee(message)
$: attachments = (message.$lookup?.attachments ?? []) as Attachment[]
const client = getClient()
const dispatch = createEventDispatcher()
@ -62,10 +66,11 @@
)
}
async function getEmployee (createdBy: Ref<Account>): Promise<Employee | undefined> {
const account = await client.findOne(contact.class.EmployeeAccount, { _id: createdBy as Ref<EmployeeAccount> })
if (account === undefined) return
return employees.get(account.employee)
function getEmployee (message: WithLookup<Message>): Employee | undefined {
const employee = (message.$lookup?.createBy as EmployeeAccount).employee
if (employee !== undefined) {
return employees.get(employee)
}
}
function openThread () {
@ -74,25 +79,23 @@
</script>
<div class="container">
{#await getEmployee(message.createBy) then employee}
<div class="avatar"><Avatar size={'medium'} avatar={employee?.avatar} /></div>
<div class="message">
<div class="header">
{#if employee}{formatName(employee.name)}{/if}
<span>{getTime(message.createOn)}</span>
</div>
<div class="text"><MessageViewer message={message.content}/></div>
{#if message.attachments}<div class="attachments"><AttachmentDocList value={message} /></div>{/if}
{#if (reactions || message.replies)}
<div class="footer flex-col">
<div>{#if reactions}<Reactions/>{/if}</div>
{#if !thread}
<div>{#if message.replies}<Replies replies={message.replies} lastReply={message.lastReply} on:click={openThread} />{/if}</div>
{/if}
</div>
{/if}
<div class="avatar"><Avatar size={'medium'} avatar={employee?.avatar} /></div>
<div class="message">
<div class="header">
{#if employee}{formatName(employee.name)}{/if}
<span>{getTime(message.createOn)}</span>
</div>
{/await}
<div class="text"><MessageViewer message={message.content}/></div>
{#if message.attachments}<div class="attachments"><AttachmentList {attachments} /></div>{/if}
{#if (reactions || message.replies)}
<div class="footer flex-col">
<div>{#if reactions}<Reactions/>{/if}</div>
{#if !thread}
<div>{#if message.replies}<Replies replies={message.replies} lastReply={message.lastReply} on:click={openThread} />{/if}</div>
{/if}
</div>
{/if}
</div>
<div class="buttons">
<div class="tool"><ActionIcon icon={IconMoreH} size={'medium'} action={(e) => { showMenu(e) }}/></div>
{#if !thread}

View File

@ -14,10 +14,11 @@
-->
<script lang="ts">
import { AttachmentDocList } from '@anticrm/attachment-resources'
import { Attachment } from '@anticrm/attachment'
import { AttachmentList } from '@anticrm/attachment-resources'
import type { Comment } from '@anticrm/chunter'
import contact,{ Employee,EmployeeAccount,formatName } from '@anticrm/contact'
import { Account,Ref } from '@anticrm/core'
import { Employee,EmployeeAccount,formatName } from '@anticrm/contact'
import { Ref,WithLookup } from '@anticrm/core'
import { getResource } from '@anticrm/platform'
import { Avatar,getClient,MessageViewer } from '@anticrm/presentation'
import { ActionIcon,IconMoreH,Menu,showPopup } from '@anticrm/ui'
@ -29,9 +30,11 @@
import Emoji from './icons/Emoji.svelte'
import Reactions from './Reactions.svelte'
export let comment: Comment
export let comment: WithLookup<Comment>
export let employees: Map<Ref<Employee>, Employee>
$: attachments = (comment.$lookup?.attachments ?? []) as Attachment[]
const client = getClient()
let reactions: boolean = false
@ -56,30 +59,31 @@
)
}
async function getEmployee (createdBy: Ref<Account>): Promise<Employee | undefined> {
const account = await client.findOne(contact.class.EmployeeAccount, { _id: createdBy as Ref<EmployeeAccount> })
if (account === undefined) return
return employees.get(account.employee)
$: employee = getEmployee(comment)
function getEmployee (comment: WithLookup<Comment>): Employee | undefined {
const employee = (comment.$lookup?.modifiedBy as EmployeeAccount)?.employee
if (employee !== undefined) {
return employees.get(employee)
}
}
</script>
<div class="container">
{#await getEmployee(comment.modifiedBy) then employee}
<div class="avatar"><Avatar size={'medium'} avatar={employee?.avatar} /></div>
<div class="message">
<div class="header">
{#if employee}{formatName(employee.name)}{/if}
<span>{getTime(comment.modifiedOn)}</span>
</div>
<div class="text"><MessageViewer message={comment.message}/></div>
{#if comment.attachments}<div class="attachments"><AttachmentDocList value={comment} /></div>{/if}
{#if reactions}
<div class="footer">
<div><Reactions/></div>
</div>
{/if}
<div class="avatar"><Avatar size={'medium'} avatar={employee?.avatar} /></div>
<div class="message">
<div class="header">
{#if employee}{formatName(employee.name)}{/if}
<span>{getTime(comment.modifiedOn)}</span>
</div>
{/await}
<div class="text"><MessageViewer message={comment.message}/></div>
{#if comment.attachments}<div class="attachments"><AttachmentList {attachments} /></div>{/if}
{#if reactions}
<div class="footer">
<div><Reactions/></div>
</div>
{/if}
</div>
<div class="buttons">
<div class="tool"><ActionIcon icon={IconMoreH} size={'medium'} action={(e) => { showMenu(e) }}/></div>
<div class="tool"><ActionIcon icon={Bookmark} size={'medium'}/></div>

View File

@ -16,11 +16,12 @@
import attachment from '@anticrm/attachment'
import { AttachmentRefInput } from '@anticrm/attachment-resources'
import type { Comment,Message } from '@anticrm/chunter'
import contact,{ Employee, EmployeeAccount } from '@anticrm/contact'
import { Class,generateId,getCurrentAccount,Lookup,Ref, Space } from '@anticrm/core'
import contact,{ Employee,EmployeeAccount } from '@anticrm/contact'
import core,{ generateId,getCurrentAccount,Ref,Space,TxFactory } from '@anticrm/core'
import { NotificationClientImpl } from '@anticrm/notification-resources'
import { createQuery,getClient } from '@anticrm/presentation'
import { IconClose,Label } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte'
import { afterUpdate,beforeUpdate,createEventDispatcher } from 'svelte'
import { createBacklinks } from '../backlinks'
import chunter from '../plugin'
import ChannelSeparator from './ChannelSeparator.svelte'
@ -37,8 +38,23 @@
let message: Message | undefined
let commentId = generateId()
let div: HTMLDivElement | undefined
let autoscroll: boolean = false
beforeUpdate(() => {
autoscroll = div !== undefined && (div.offsetHeight + div.scrollTop) > (div.scrollHeight - 20)
})
afterUpdate(() => {
if (div && autoscroll) div.scrollTo(0, div.scrollHeight)
})
const notificationClient = NotificationClientImpl.getClient()
const lastViews = notificationClient.getLastViews()
const lookup = {
_id: { attachments: attachment.class.Attachment }
_id: { attachments: attachment.class.Attachment },
modifiedBy: core.class.Account
}
$: updateQueries(_id)
@ -47,12 +63,19 @@
messageQuery.query(chunter.class.Message, {
_id: id
}, (res) => message = res[0], {
lookup
lookup: {
_id: { attachments: attachment.class.Attachment },
createBy: core.class.Account
}
})
query.query(chunter.class.Comment, {
attachedTo: id
}, (res) => comments = res, {
}, (res) => {
comments = res
newMessagesPos = newMessagesStart(comments)
notificationClient.updateLastView(id, chunter.class.Message)
}, {
lookup
})
}
@ -64,8 +87,9 @@
async function onMessage (event: CustomEvent) {
const { message, attachments } = event.detail
const employee = (getCurrentAccount() as EmployeeAccount).employee
await client.createDoc(chunter.class.Comment, space, {
const me = getCurrentAccount()._id
const txFactory = new TxFactory(me)
const tx = txFactory.createTxCreateDoc(chunter.class.Comment, space, {
attachedTo: _id,
attachedToClass: chunter.class.Message,
collection: 'replies',
@ -73,39 +97,49 @@
attachments
}, commentId)
await client.updateDoc(chunter.class.Message, space, _id, {
$push: { replies: employee },
lastReply: new Date().getTime()
})
await notificationClient.updateLastView(_id, chunter.class.Message, tx.modifiedOn, true)
await client.tx(tx)
// Create an backlink to document
await createBacklinks(client, space, chunter.class.Channel, commentId, message)
commentId = generateId()
}
let comments: Comment[] = []
function newMessagesStart (comments: Comment[]): number {
const lastView = $lastViews.get(_id)
if (lastView === undefined) return -1
for (let index = 0; index < comments.length; index++) {
const comment = comments[index]
if (comment.modifiedOn > lastView) return index
}
return -1
}
let newMessagesPos: number = -1
</script>
<div class="header">
<div class="title"><Label label={chunter.string.Thread} /></div>
<div class="tool" on:click={() => { dispatch('close') }}><IconClose size='medium' /></div>
</div>
<div class="h-full flex-col">
<div class="content">
{#if message}
<div class="flex-col">
<MsgView {message} {employees} thread />
{#if comments.length}
<ChannelSeparator title={chunter.string.RepliesCount} line params={{ replies: message.replies?.length }} />
{/if}
{#each comments as comment}
<ThreadComment {comment} {employees} />
{/each}
</div>
<div class="flex-col vScroll content" bind:this={div}>
{#if message}
<MsgView {message} {employees} thread />
{#if comments.length}
<ChannelSeparator title={chunter.string.RepliesCount} line params={{ replies: comments.length }} />
{/if}
</div>
<div class="ref-input">
<AttachmentRefInput {space} _class={chunter.class.Comment} objectId={commentId} on:message={onMessage}/>
</div>
{#each comments as comment, i (comment._id)}
{#if newMessagesPos === i}
<ChannelSeparator title={chunter.string.New} line reverse isNew />
{/if}
<ThreadComment {comment} {employees} />
{/each}
{/if}
</div>
<div class="ref-input">
<AttachmentRefInput {space} _class={chunter.class.Comment} objectId={commentId} on:message={onMessage}/>
</div>
<style lang="scss">
@ -134,9 +168,6 @@
}
}
.content {
display: flex;
flex-direction: column;
flex-grow: 1;
margin: 1rem 1rem 0px;
padding: 1.5rem 1.5rem 0px;
}

View File

@ -36,6 +36,7 @@ export default mergeIds(chunterId, chunter, {
Replies: '' as IntlString,
Thread: '' as IntlString,
RepliesCount: '' as IntlString,
LastReply: '' as IntlString
LastReply: '' as IntlString,
New: '' as IntlString
}
})

View File

@ -391,9 +391,7 @@
{/if}
</div>
{#if asideId && navigatorModel?.aside !== undefined}
<div class="antiPanel-component indent antiComponent filled">
<Component is={navigatorModel.aside} props={{ currentSpace, _id: asideId }} on:close={closeAside} />
</div>
<div class="antiPanel-component antiComponent border-left"><Component is={navigatorModel.aside} props={{ currentSpace, _id: asideId }} on:close={closeAside} /></div>
{/if}
</div>
<PanelInstance {contentPanel} />

View File

@ -29,6 +29,7 @@
"@anticrm/core": "~0.6.16",
"@anticrm/platform": "~0.6.5",
"@anticrm/server-core": "~0.6.1",
"@anticrm/contact": "~0.6.5",
"@anticrm/chunter": "~0.6.1",
"@anticrm/view": "~0.6.0",
"@anticrm/login": "~0.6.1",

View File

@ -13,10 +13,12 @@
// limitations under the License.
//
import chunter, { Comment, Channel } from '@anticrm/chunter'
import { Class, Doc, DocumentQuery, FindOptions, FindResult, Hierarchy, Ref } from '@anticrm/core'
import chunter, { Channel, Comment, Message } from '@anticrm/chunter'
import { EmployeeAccount } from '@anticrm/contact'
import core, { Class, Doc, DocumentQuery, FindOptions, FindResult, Hierarchy, Ref, Tx, TxCreateDoc, TxProcessor, TxUpdateDoc } from '@anticrm/core'
import login from '@anticrm/login'
import { getMetadata } from '@anticrm/platform'
import { TriggerControl } from '@anticrm/server-core'
import workbench from '@anticrm/workbench'
/**
@ -49,8 +51,40 @@ export async function CommentRemove (doc: Doc, hiearachy: Hierarchy, findAll: <T
return result
}
/**
* @public
*/
export async function CommentCreate (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const hierarchy = control.hierarchy
if (tx._class !== core.class.TxCreateDoc) return []
const doc = TxProcessor.createDoc2Doc(tx as TxCreateDoc<Doc>)
if (!hierarchy.isDerived(doc._class, chunter.class.Comment)) {
return []
}
const comment = doc as Comment
if (!hierarchy.isDerived(comment.attachedToClass, chunter.class.Message)) {
return []
}
const lastReplyTx = control.txFactory.createTxUpdateDoc<Message>(chunter.class.Message, comment.space, comment.attachedTo as Ref<Message>, {
lastReply: tx.modifiedOn
})
const employee = control.modelDb.getObject(tx.modifiedBy) as EmployeeAccount
const employeeTx = control.txFactory.createTxUpdateDoc<Message>(chunter.class.Message, comment.space, comment.attachedTo as Ref<Message>, {
$push: { replies: employee.employee }
})
const result: TxUpdateDoc<Message>[] = []
result.push(lastReplyTx)
result.push(employeeTx)
return result
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export default async () => ({
trigger: {
CommentCreate
},
function: {
CommentRemove,
ChannelHTMLPresenter: channelHTMLPresenter,

View File

@ -16,6 +16,7 @@
import { Class, Doc, DocumentQuery, FindOptions, FindResult, Hierarchy, Ref } from '@anticrm/core'
import type { Plugin, Resource } from '@anticrm/platform'
import { plugin } from '@anticrm/platform'
import { TriggerFunc } from '@anticrm/server-core'
/**
* @public
@ -26,6 +27,9 @@ export const serverChunterId = 'server-chunter' as Plugin
* @public
*/
export default plugin(serverChunterId, {
trigger: {
CommentCreate: '' as Resource<TriggerFunc>
},
function: {
CommentRemove: '' as Resource<(doc: Doc, hiearachy: Hierarchy, findAll: <T extends Doc> (clazz: Ref<Class<T>>, query: DocumentQuery<T>, options?: FindOptions<T>) => Promise<FindResult<T>>) => Promise<Doc[]>>,
ChannelHTMLPresenter: '' as Resource<(doc: Doc) => string>,