@dxos/plugin-thread 0.6.8-main.046e6cf
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +15 -0
- package/dist/lib/browser/ThreadArticle-J4VCLPMS.mjs +18 -0
- package/dist/lib/browser/ThreadArticle-J4VCLPMS.mjs.map +7 -0
- package/dist/lib/browser/ThreadMain-VV7MGQZJ.mjs +26 -0
- package/dist/lib/browser/ThreadMain-VV7MGQZJ.mjs.map +7 -0
- package/dist/lib/browser/chunk-5IBJO53M.mjs +21 -0
- package/dist/lib/browser/chunk-5IBJO53M.mjs.map +7 -0
- package/dist/lib/browser/chunk-CQ2RYETH.mjs +410 -0
- package/dist/lib/browser/chunk-CQ2RYETH.mjs.map +7 -0
- package/dist/lib/browser/chunk-TL3JITN7.mjs +19 -0
- package/dist/lib/browser/chunk-TL3JITN7.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +1062 -0
- package/dist/lib/browser/index.mjs.map +7 -0
- package/dist/lib/browser/meta.json +1 -0
- package/dist/lib/browser/meta.mjs +11 -0
- package/dist/lib/browser/meta.mjs.map +7 -0
- package/dist/lib/browser/types.mjs +8 -0
- package/dist/lib/browser/types.mjs.map +7 -0
- package/dist/lib/node/ThreadArticle-CSENFNNT.cjs +44 -0
- package/dist/lib/node/ThreadArticle-CSENFNNT.cjs.map +7 -0
- package/dist/lib/node/ThreadMain-XP4YSF7U.cjs +52 -0
- package/dist/lib/node/ThreadMain-XP4YSF7U.cjs.map +7 -0
- package/dist/lib/node/chunk-3VAAUEU5.cjs +431 -0
- package/dist/lib/node/chunk-3VAAUEU5.cjs.map +7 -0
- package/dist/lib/node/chunk-BWBONZGU.cjs +55 -0
- package/dist/lib/node/chunk-BWBONZGU.cjs.map +7 -0
- package/dist/lib/node/chunk-WPPLQNMJ.cjs +38 -0
- package/dist/lib/node/chunk-WPPLQNMJ.cjs.map +7 -0
- package/dist/lib/node/index.cjs +1067 -0
- package/dist/lib/node/index.cjs.map +7 -0
- package/dist/lib/node/meta.cjs +32 -0
- package/dist/lib/node/meta.cjs.map +7 -0
- package/dist/lib/node/meta.json +1 -0
- package/dist/lib/node/types.cjs +30 -0
- package/dist/lib/node/types.cjs.map +7 -0
- package/dist/types/src/ThreadPlugin.d.ts +4 -0
- package/dist/types/src/ThreadPlugin.d.ts.map +1 -0
- package/dist/types/src/components/Chat.stories.d.ts +46 -0
- package/dist/types/src/components/Chat.stories.d.ts.map +1 -0
- package/dist/types/src/components/ChatContainer.d.ts +7 -0
- package/dist/types/src/components/ChatContainer.d.ts.map +1 -0
- package/dist/types/src/components/CommentContainer.d.ts +4 -0
- package/dist/types/src/components/CommentContainer.d.ts.map +1 -0
- package/dist/types/src/components/Comments.stories.d.ts +44 -0
- package/dist/types/src/components/Comments.stories.d.ts.map +1 -0
- package/dist/types/src/components/CommentsContainer.d.ts +26 -0
- package/dist/types/src/components/CommentsContainer.d.ts.map +1 -0
- package/dist/types/src/components/MessageContainer.d.ts +9 -0
- package/dist/types/src/components/MessageContainer.d.ts.map +1 -0
- package/dist/types/src/components/ThreadArticle.d.ts +5 -0
- package/dist/types/src/components/ThreadArticle.d.ts.map +1 -0
- package/dist/types/src/components/ThreadMain.d.ts +7 -0
- package/dist/types/src/components/ThreadMain.d.ts.map +1 -0
- package/dist/types/src/components/ThreadSettings.d.ts +6 -0
- package/dist/types/src/components/ThreadSettings.d.ts.map +1 -0
- package/dist/types/src/components/command-extension.d.ts +3 -0
- package/dist/types/src/components/command-extension.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +22 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/components/testing.d.ts +67 -0
- package/dist/types/src/components/testing.d.ts.map +1 -0
- package/dist/types/src/components/types.d.ts +28 -0
- package/dist/types/src/components/types.d.ts.map +1 -0
- package/dist/types/src/hooks/analytics.d.ts +4 -0
- package/dist/types/src/hooks/analytics.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +3 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useStatus.d.ts +3 -0
- package/dist/types/src/hooks/useStatus.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +6 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/meta.d.ts +16 -0
- package/dist/types/src/meta.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +34 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +19 -0
- package/dist/types/src/types.d.ts.map +1 -0
- package/dist/types/src/types.test.d.ts +2 -0
- package/dist/types/src/types.test.d.ts.map +1 -0
- package/dist/types/src/util.d.ts +6 -0
- package/dist/types/src/util.d.ts.map +1 -0
- package/package.json +99 -0
- package/src/ThreadPlugin.tsx +744 -0
- package/src/components/Chat.stories.tsx +76 -0
- package/src/components/ChatContainer.tsx +127 -0
- package/src/components/CommentContainer.tsx +206 -0
- package/src/components/Comments.stories.tsx +66 -0
- package/src/components/CommentsContainer.tsx +114 -0
- package/src/components/MessageContainer.tsx +200 -0
- package/src/components/ThreadArticle.tsx +18 -0
- package/src/components/ThreadMain.tsx +27 -0
- package/src/components/ThreadSettings.tsx +23 -0
- package/src/components/command-extension.ts +36 -0
- package/src/components/index.ts +16 -0
- package/src/components/testing.ts +46 -0
- package/src/components/types.ts +32 -0
- package/src/hooks/analytics.ts +39 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/useStatus.ts +39 -0
- package/src/index.ts +12 -0
- package/src/meta.tsx +20 -0
- package/src/translations.ts +41 -0
- package/src/types.test.ts +13 -0
- package/src/types.ts +41 -0
- package/src/util.ts +26 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxosTheme';
|
|
6
|
+
|
|
7
|
+
import React, { useEffect, useState } from 'react';
|
|
8
|
+
|
|
9
|
+
import { SurfaceProvider } from '@dxos/app-framework';
|
|
10
|
+
import { ThreadType } from '@dxos/plugin-space/types';
|
|
11
|
+
import { faker } from '@dxos/random';
|
|
12
|
+
import { useClient } from '@dxos/react-client';
|
|
13
|
+
import { type Space } from '@dxos/react-client/echo';
|
|
14
|
+
import { useIdentity } from '@dxos/react-client/halo';
|
|
15
|
+
import { ClientRepeater } from '@dxos/react-client/testing';
|
|
16
|
+
import { Mosaic } from '@dxos/react-ui-mosaic';
|
|
17
|
+
import { Thread } from '@dxos/react-ui-thread';
|
|
18
|
+
import { withTheme } from '@dxos/storybook-utils';
|
|
19
|
+
|
|
20
|
+
import { ChatContainer } from './ChatContainer';
|
|
21
|
+
import { createChatThread } from './testing';
|
|
22
|
+
import translations from '../translations';
|
|
23
|
+
|
|
24
|
+
faker.seed(1);
|
|
25
|
+
|
|
26
|
+
const Story = () => {
|
|
27
|
+
const client = useClient();
|
|
28
|
+
const identity = useIdentity();
|
|
29
|
+
const [space, setSpace] = useState<Space>();
|
|
30
|
+
const [thread, setThread] = useState<ThreadType | null>();
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (identity) {
|
|
34
|
+
setTimeout(async () => {
|
|
35
|
+
const space = await client.spaces.create();
|
|
36
|
+
const thread = space.db.add(createChatThread(identity));
|
|
37
|
+
setSpace(space);
|
|
38
|
+
setThread(thread);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}, [identity]);
|
|
42
|
+
|
|
43
|
+
if (!identity || !thread) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<SurfaceProvider
|
|
49
|
+
value={{
|
|
50
|
+
components: {
|
|
51
|
+
ObjectMessage: ({ role }) => {
|
|
52
|
+
return <span>{JSON.stringify({ role })}</span>;
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<Mosaic.Root debug>
|
|
58
|
+
<main className='max-is-prose mli-auto bs-dvh overflow-hidden'>
|
|
59
|
+
{space && <ChatContainer thread={thread} />}
|
|
60
|
+
</main>
|
|
61
|
+
<Mosaic.DragOverlay />
|
|
62
|
+
</Mosaic.Root>
|
|
63
|
+
</SurfaceProvider>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export default {
|
|
68
|
+
title: 'plugin-thread/Chat',
|
|
69
|
+
component: Thread,
|
|
70
|
+
// TODO(wittjosiah): Register schemas.
|
|
71
|
+
render: () => <ClientRepeater component={Story} createIdentity createSpace types={[ThreadType]} />,
|
|
72
|
+
decorators: [withTheme],
|
|
73
|
+
parameters: { translations, layout: 'fullscreen' },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const Default = {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Chat } from '@phosphor-icons/react';
|
|
6
|
+
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import { create } from '@dxos/echo-schema';
|
|
9
|
+
import { MessageType } from '@dxos/plugin-space/types';
|
|
10
|
+
import { getSpace, useMembers } from '@dxos/react-client/echo';
|
|
11
|
+
import { useIdentity } from '@dxos/react-client/halo';
|
|
12
|
+
import { ScrollArea, useThemeContext, useTranslation } from '@dxos/react-ui';
|
|
13
|
+
import { PlankHeading, plankHeadingIconProps } from '@dxos/react-ui-deck';
|
|
14
|
+
import { createBasicExtensions, createThemeExtensions, listener } from '@dxos/react-ui-editor';
|
|
15
|
+
import { mx } from '@dxos/react-ui-theme';
|
|
16
|
+
import { MessageTextbox, type MessageTextboxProps, Thread, ThreadFooter, threadLayout } from '@dxos/react-ui-thread';
|
|
17
|
+
import { nonNullable } from '@dxos/util';
|
|
18
|
+
|
|
19
|
+
import { MessageContainer } from './MessageContainer';
|
|
20
|
+
import { command } from './command-extension';
|
|
21
|
+
import { type ThreadContainerProps } from './types';
|
|
22
|
+
import { useStatus } from '../hooks';
|
|
23
|
+
import { THREAD_PLUGIN } from '../meta';
|
|
24
|
+
import { getMessageMetadata } from '../util';
|
|
25
|
+
|
|
26
|
+
export const ChatHeading = ({ attendableId }: { attendableId?: string }) => {
|
|
27
|
+
const { t } = useTranslation(THREAD_PLUGIN);
|
|
28
|
+
return (
|
|
29
|
+
<div role='none' className='flex items-center'>
|
|
30
|
+
<PlankHeading.Button attendableId={attendableId}>
|
|
31
|
+
<Chat {...plankHeadingIconProps} />
|
|
32
|
+
</PlankHeading.Button>
|
|
33
|
+
<PlankHeading.Label attendableId={attendableId}>{t('chat heading')}</PlankHeading.Label>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const ChatContainer = ({ thread, context, current, autoFocusTextbox }: ThreadContainerProps) => {
|
|
39
|
+
const identity = useIdentity()!;
|
|
40
|
+
const space = getSpace(thread);
|
|
41
|
+
const members = useMembers(space?.key);
|
|
42
|
+
const activity = useStatus(space, thread.id);
|
|
43
|
+
const { t } = useTranslation(THREAD_PLUGIN);
|
|
44
|
+
// TODO(wittjosiah): This is a hack to reset the editor after a message is sent.
|
|
45
|
+
const [_count, _setCount] = useState(0);
|
|
46
|
+
const rerenderEditor = () => _setCount((count) => count + 1);
|
|
47
|
+
const [autoFocus, setAutoFocus] = useState(autoFocusTextbox);
|
|
48
|
+
const threadScrollRef = useRef<HTMLDivElement | null>(null);
|
|
49
|
+
const { themeMode } = useThemeContext();
|
|
50
|
+
|
|
51
|
+
const textboxMetadata = getMessageMetadata(thread.id, identity);
|
|
52
|
+
const messageRef = useRef('');
|
|
53
|
+
const extensions = useMemo(
|
|
54
|
+
() => [
|
|
55
|
+
createBasicExtensions({ placeholder: t('message placeholder') }),
|
|
56
|
+
createThemeExtensions({ themeMode }),
|
|
57
|
+
listener({ onChange: (text) => (messageRef.current = text) }),
|
|
58
|
+
command,
|
|
59
|
+
],
|
|
60
|
+
[themeMode, _count],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// TODO(thure): Factor out.
|
|
64
|
+
// TODO(thure): `flex-col-reverse` does not work to start the container scrolled to the end while also using
|
|
65
|
+
// `ScrollArea`. This is the least-bad way I found to scroll to the end on mount. Note that 0ms was insufficient
|
|
66
|
+
// for the desired effect; this is likely hardware-dependent and should be reevaluated.
|
|
67
|
+
const scrollToEnd = (behavior: ScrollBehavior) =>
|
|
68
|
+
setTimeout(() => threadScrollRef.current?.scrollIntoView({ behavior, block: 'end' }), 10);
|
|
69
|
+
|
|
70
|
+
useLayoutEffect(() => {
|
|
71
|
+
scrollToEnd('instant');
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
// TODO(burdon): Change to model.
|
|
75
|
+
const handleCreate: MessageTextboxProps['onSend'] = () => {
|
|
76
|
+
if (!messageRef.current?.length) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
thread.messages.push(
|
|
81
|
+
create(MessageType, {
|
|
82
|
+
sender: { identityKey: identity.identityKey.toHex() },
|
|
83
|
+
timestamp: new Date().toISOString(),
|
|
84
|
+
text: messageRef.current,
|
|
85
|
+
context,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
messageRef.current = '';
|
|
90
|
+
setAutoFocus(true);
|
|
91
|
+
scrollToEnd('smooth');
|
|
92
|
+
rerenderEditor();
|
|
93
|
+
return true;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleDelete = (id: string) => {
|
|
97
|
+
const messageIndex = thread.messages.filter(nonNullable).findIndex((message) => message.id === id);
|
|
98
|
+
if (messageIndex !== -1) {
|
|
99
|
+
thread.messages.splice(messageIndex, 1);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Thread
|
|
105
|
+
current={current}
|
|
106
|
+
id={thread.id}
|
|
107
|
+
classNames='bs-full grid-rows-[1fr_min-content_min-content] overflow-hidden transition-[padding-block-end] [[data-sidebar-inline-start-state=open]_&]:lg:pbe-0'
|
|
108
|
+
>
|
|
109
|
+
<ScrollArea.Root classNames='col-span-2'>
|
|
110
|
+
<ScrollArea.Viewport classNames='overflow-anchored after:overflow-anchor after:block after:bs-px after:-mbs-px [&>div]:min-bs-full [&>div]:!grid [&>div]:grid-rows-[1fr_0]'>
|
|
111
|
+
<div role='none' className={mx(threadLayout, 'place-self-end')}>
|
|
112
|
+
{thread.messages.filter(nonNullable).map((message) => (
|
|
113
|
+
<MessageContainer key={message.id} message={message} members={members} onDelete={handleDelete} />
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
{/* NOTE(thure): This can’t also be the `overflow-anchor` because `ScrollArea` injects an interceding node that contains this necessary ref’d element. */}
|
|
117
|
+
<div role='none' className='bs-px -mbs-px' ref={threadScrollRef} />
|
|
118
|
+
<ScrollArea.Scrollbar>
|
|
119
|
+
<ScrollArea.Thumb />
|
|
120
|
+
</ScrollArea.Scrollbar>
|
|
121
|
+
</ScrollArea.Viewport>
|
|
122
|
+
</ScrollArea.Root>
|
|
123
|
+
<MessageTextbox extensions={extensions} autoFocus={autoFocus} onSend={handleCreate} {...textboxMetadata} />
|
|
124
|
+
<ThreadFooter activity={activity}>{t('activity message')}</ThreadFooter>
|
|
125
|
+
</Thread>
|
|
126
|
+
);
|
|
127
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { CheckCircle, X } from '@phosphor-icons/react';
|
|
6
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import { create } from '@dxos/echo-schema';
|
|
9
|
+
import { MessageType } from '@dxos/plugin-space/types';
|
|
10
|
+
import { getSpace, useMembers } from '@dxos/react-client/echo';
|
|
11
|
+
import { useIdentity } from '@dxos/react-client/halo';
|
|
12
|
+
import { Button, Tag, Tooltip, useThemeContext, useTranslation } from '@dxos/react-ui';
|
|
13
|
+
import { createBasicExtensions, createThemeExtensions, listener } from '@dxos/react-ui-editor';
|
|
14
|
+
import {
|
|
15
|
+
getSize,
|
|
16
|
+
hoverableControlItem,
|
|
17
|
+
hoverableControls,
|
|
18
|
+
hoverableFocusedWithinControls,
|
|
19
|
+
mx,
|
|
20
|
+
} from '@dxos/react-ui-theme';
|
|
21
|
+
import { MessageTextbox, type MessageTextboxProps, Thread, ThreadFooter, ThreadHeading } from '@dxos/react-ui-thread';
|
|
22
|
+
import { nonNullable } from '@dxos/util';
|
|
23
|
+
|
|
24
|
+
import { MessageContainer } from './MessageContainer';
|
|
25
|
+
import { command } from './command-extension';
|
|
26
|
+
import { type ThreadContainerProps } from './types';
|
|
27
|
+
import { useStatus } from '../hooks';
|
|
28
|
+
import { THREAD_PLUGIN } from '../meta';
|
|
29
|
+
import { getMessageMetadata } from '../util';
|
|
30
|
+
|
|
31
|
+
const sizeClass = getSize(4);
|
|
32
|
+
|
|
33
|
+
const ToggleResolvedButton = ({
|
|
34
|
+
isResolved,
|
|
35
|
+
onResolve,
|
|
36
|
+
}: {
|
|
37
|
+
isResolved: boolean | undefined;
|
|
38
|
+
onResolve: () => void;
|
|
39
|
+
}) => {
|
|
40
|
+
const { t } = useTranslation(THREAD_PLUGIN);
|
|
41
|
+
const label = t(isResolved ? 'mark as unresolved label' : 'mark as resolved label');
|
|
42
|
+
return (
|
|
43
|
+
<Tooltip.Root>
|
|
44
|
+
<Tooltip.Trigger asChild>
|
|
45
|
+
<Button
|
|
46
|
+
variant='ghost'
|
|
47
|
+
data-testid='thread.toggle-resolved'
|
|
48
|
+
onClick={onResolve}
|
|
49
|
+
classNames={['min-bs-0 p-1', !isResolved && hoverableControlItem]}
|
|
50
|
+
>
|
|
51
|
+
<span className='sr-only'>{label}</span>
|
|
52
|
+
{isResolved ? <CheckCircle className={sizeClass} weight='fill' /> : <CheckCircle className={sizeClass} />}
|
|
53
|
+
</Button>
|
|
54
|
+
</Tooltip.Trigger>
|
|
55
|
+
<Tooltip.Portal>
|
|
56
|
+
<Tooltip.Content classNames={'z-[21]'}>
|
|
57
|
+
{label}
|
|
58
|
+
<Tooltip.Arrow />
|
|
59
|
+
</Tooltip.Content>
|
|
60
|
+
</Tooltip.Portal>
|
|
61
|
+
</Tooltip.Root>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const DeleteThreadButton = ({ onDelete }: { onDelete: () => void }) => {
|
|
66
|
+
const { t } = useTranslation(THREAD_PLUGIN);
|
|
67
|
+
const label = t('delete thread label');
|
|
68
|
+
return (
|
|
69
|
+
<Tooltip.Root>
|
|
70
|
+
<Tooltip.Trigger asChild>
|
|
71
|
+
<Button
|
|
72
|
+
variant='ghost'
|
|
73
|
+
data-testid='thread.delete'
|
|
74
|
+
onClick={onDelete}
|
|
75
|
+
classNames={['min-bs-0 p-1', hoverableControlItem]}
|
|
76
|
+
>
|
|
77
|
+
<span className='sr-only'>{label}</span>
|
|
78
|
+
<X className={sizeClass} />
|
|
79
|
+
</Button>
|
|
80
|
+
</Tooltip.Trigger>
|
|
81
|
+
<Tooltip.Portal>
|
|
82
|
+
<Tooltip.Content classNames='z-[21]'>
|
|
83
|
+
{label}
|
|
84
|
+
<Tooltip.Arrow />
|
|
85
|
+
</Tooltip.Content>
|
|
86
|
+
</Tooltip.Portal>
|
|
87
|
+
</Tooltip.Root>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const CommentContainer = ({
|
|
92
|
+
thread,
|
|
93
|
+
detached,
|
|
94
|
+
context,
|
|
95
|
+
current,
|
|
96
|
+
autoFocusTextbox,
|
|
97
|
+
onAttend,
|
|
98
|
+
onThreadDelete,
|
|
99
|
+
onMessageDelete,
|
|
100
|
+
onResolve,
|
|
101
|
+
onComment,
|
|
102
|
+
}: ThreadContainerProps) => {
|
|
103
|
+
const identity = useIdentity()!;
|
|
104
|
+
const space = getSpace(thread);
|
|
105
|
+
const members = useMembers(space?.key);
|
|
106
|
+
const activity = useStatus(space, thread.id);
|
|
107
|
+
const { t } = useTranslation(THREAD_PLUGIN);
|
|
108
|
+
const threadScrollRef = useRef<HTMLDivElement | null>(null);
|
|
109
|
+
const [autoFocus, setAutoFocus] = useState(!!autoFocusTextbox);
|
|
110
|
+
const { themeMode } = useThemeContext();
|
|
111
|
+
|
|
112
|
+
const textboxMetadata = getMessageMetadata(thread.id, identity);
|
|
113
|
+
// TODO(wittjosiah): This is a hack to reset the editor after a message is sent.
|
|
114
|
+
const [_count, _setCount] = useState(0);
|
|
115
|
+
const rerenderEditor = () => _setCount((count) => count + 1);
|
|
116
|
+
const messageRef = useRef('');
|
|
117
|
+
const extensions = useMemo(
|
|
118
|
+
() => [
|
|
119
|
+
createBasicExtensions({ placeholder: t('message placeholder') }),
|
|
120
|
+
createThemeExtensions({ themeMode }),
|
|
121
|
+
listener({ onChange: (text) => (messageRef.current = text) }),
|
|
122
|
+
command,
|
|
123
|
+
],
|
|
124
|
+
[_count],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// TODO(thure): Because of the way the `autoFocus` property is handled by TextEditor,
|
|
128
|
+
// this is the least-bad way of moving focus at the right time, though it is an anti-pattern.
|
|
129
|
+
// Refactor to behave more like <input/>’s `autoFocus` or `autofocus` (yes, they’re different).
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
setAutoFocus(!!autoFocusTextbox);
|
|
132
|
+
}, [autoFocusTextbox]);
|
|
133
|
+
|
|
134
|
+
// TODO(thure): Factor out.
|
|
135
|
+
const scrollToEnd = (behavior: ScrollBehavior) =>
|
|
136
|
+
setTimeout(() => threadScrollRef.current?.scrollIntoView({ behavior, block: 'end' }), 10);
|
|
137
|
+
|
|
138
|
+
const handleCreate: MessageTextboxProps['onSend'] = useCallback(() => {
|
|
139
|
+
if (!messageRef.current) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
thread.messages.push(
|
|
144
|
+
create(MessageType, {
|
|
145
|
+
sender: { identityKey: identity.identityKey.toHex() },
|
|
146
|
+
timestamp: new Date().toISOString(),
|
|
147
|
+
text: messageRef.current,
|
|
148
|
+
context,
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
onComment?.(thread);
|
|
152
|
+
|
|
153
|
+
messageRef.current = '';
|
|
154
|
+
setAutoFocus(true);
|
|
155
|
+
scrollToEnd('instant');
|
|
156
|
+
rerenderEditor();
|
|
157
|
+
|
|
158
|
+
// TODO(burdon): Scroll to bottom.
|
|
159
|
+
return true;
|
|
160
|
+
}, [thread, identity]);
|
|
161
|
+
|
|
162
|
+
const handleDeleteMessage = (id: string) => onMessageDelete?.(id);
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<Thread onClickCapture={onAttend} onFocusCapture={onAttend} current={current} id={thread.id}>
|
|
166
|
+
<div
|
|
167
|
+
role='none'
|
|
168
|
+
className={mx(
|
|
169
|
+
'col-span-2 grid grid-cols-[var(--rail-size)_1fr_min-content]',
|
|
170
|
+
hoverableControls,
|
|
171
|
+
hoverableFocusedWithinControls,
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
{detached ? (
|
|
175
|
+
<Tooltip.Root>
|
|
176
|
+
<Tooltip.Trigger asChild>
|
|
177
|
+
<ThreadHeading detached>{thread.name ?? t('thread title placeholder')}</ThreadHeading>
|
|
178
|
+
</Tooltip.Trigger>
|
|
179
|
+
<Tooltip.Portal>
|
|
180
|
+
<Tooltip.Content classNames='z-[21]' side='top'>
|
|
181
|
+
{t('detached thread label')}
|
|
182
|
+
<Tooltip.Arrow />
|
|
183
|
+
</Tooltip.Content>
|
|
184
|
+
</Tooltip.Portal>
|
|
185
|
+
</Tooltip.Root>
|
|
186
|
+
) : (
|
|
187
|
+
<ThreadHeading>{thread.name ?? t('thread title placeholder')}</ThreadHeading>
|
|
188
|
+
)}
|
|
189
|
+
<div className='flex flex-row items-center pli-1'>
|
|
190
|
+
{thread.status === 'staged' && <Tag palette='neutral'>{t('draft button')}</Tag>}
|
|
191
|
+
{onResolve && !(thread?.status === 'staged') && (
|
|
192
|
+
<ToggleResolvedButton isResolved={thread?.status === 'resolved'} onResolve={onResolve} />
|
|
193
|
+
)}
|
|
194
|
+
{onThreadDelete && <DeleteThreadButton onDelete={onThreadDelete} />}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
{thread.messages.filter(nonNullable).map((message) => (
|
|
198
|
+
<MessageContainer key={message.id} message={message} members={members} onDelete={handleDeleteMessage} />
|
|
199
|
+
))}
|
|
200
|
+
<MessageTextbox extensions={extensions} autoFocus={autoFocus} onSend={handleCreate} {...textboxMetadata} />
|
|
201
|
+
<ThreadFooter activity={activity}>{t('activity message')}</ThreadFooter>
|
|
202
|
+
{/* NOTE(thure): This can’t also be the `overflow-anchor` because `ScrollArea` injects an interceding node that contains this necessary ref’d element. */}
|
|
203
|
+
<div role='none' className='bs-px -mbs-px' ref={threadScrollRef} />
|
|
204
|
+
</Thread>
|
|
205
|
+
);
|
|
206
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxosTheme';
|
|
6
|
+
|
|
7
|
+
import React, { useEffect, useState } from 'react';
|
|
8
|
+
|
|
9
|
+
import { MessageType, ThreadType } from '@dxos/plugin-space/types';
|
|
10
|
+
import { faker } from '@dxos/random';
|
|
11
|
+
import { type PublicKey } from '@dxos/react-client';
|
|
12
|
+
import { Filter, useQuery, useSpace } from '@dxos/react-client/echo';
|
|
13
|
+
import { useIdentity } from '@dxos/react-client/halo';
|
|
14
|
+
import { ClientRepeater } from '@dxos/react-client/testing';
|
|
15
|
+
import { Tooltip } from '@dxos/react-ui';
|
|
16
|
+
import { withFullscreen, withTheme } from '@dxos/storybook-utils';
|
|
17
|
+
|
|
18
|
+
import { CommentsContainer } from './CommentsContainer';
|
|
19
|
+
import { createCommentThread } from './testing';
|
|
20
|
+
import translations from '../translations';
|
|
21
|
+
|
|
22
|
+
faker.seed(1);
|
|
23
|
+
|
|
24
|
+
const Story = ({ spaceKey }: { spaceKey: PublicKey }) => {
|
|
25
|
+
const identity = useIdentity();
|
|
26
|
+
const space = useSpace(spaceKey);
|
|
27
|
+
const threads = useQuery(space, Filter.schema(ThreadType));
|
|
28
|
+
const [detached, setDetached] = useState<string[]>([]);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (identity && space) {
|
|
32
|
+
const t = setTimeout(async () => {
|
|
33
|
+
space.db.add(createCommentThread(identity));
|
|
34
|
+
const thread = space.db.add(createCommentThread(identity));
|
|
35
|
+
setDetached([thread.id]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return () => clearTimeout(t);
|
|
39
|
+
}
|
|
40
|
+
}, [identity, space]);
|
|
41
|
+
|
|
42
|
+
if (!identity || !space || !threads) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// TODO(wittjosiah): Include Tooltip.Provider in `withTheme` decorator?
|
|
47
|
+
return (
|
|
48
|
+
<Tooltip.Provider>
|
|
49
|
+
<div className='flex justify-center overflow-y-auto bg-white dark:bg-black'>
|
|
50
|
+
<div className='flex flex-col w-[400px]'>
|
|
51
|
+
<CommentsContainer threads={threads} detached={detached} onThreadDelete={console.log} />
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</Tooltip.Provider>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export default {
|
|
59
|
+
title: 'plugin-thread/Comments',
|
|
60
|
+
// TODO(wittjosiah): Register schemas.
|
|
61
|
+
render: () => <ClientRepeater component={Story} createIdentity createSpace types={[ThreadType, MessageType]} />,
|
|
62
|
+
decorators: [withTheme, withFullscreen()],
|
|
63
|
+
parameters: { translations },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const Default = {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { ChatText, Quotes } from '@phosphor-icons/react';
|
|
6
|
+
import React, { useEffect } from 'react';
|
|
7
|
+
|
|
8
|
+
import { type ThreadType } from '@dxos/plugin-space/types';
|
|
9
|
+
import { useTranslation, Trans } from '@dxos/react-ui';
|
|
10
|
+
import { PlankHeading, plankHeadingIconProps } from '@dxos/react-ui-deck';
|
|
11
|
+
import { descriptionText, mx } from '@dxos/react-ui-theme';
|
|
12
|
+
|
|
13
|
+
import { CommentContainer } from './CommentContainer';
|
|
14
|
+
import { type ThreadContainerProps } from './types';
|
|
15
|
+
import { THREAD_PLUGIN } from '../meta';
|
|
16
|
+
|
|
17
|
+
export type ThreadsContainerProps = Omit<
|
|
18
|
+
ThreadContainerProps,
|
|
19
|
+
'thread' | 'detached' | 'onAttend' | 'onThreadDelete' | 'onMessageDelete' | 'current' | 'autoFocus'
|
|
20
|
+
> & {
|
|
21
|
+
threads: ThreadType[];
|
|
22
|
+
/**
|
|
23
|
+
* Threads that are no longer anchored to a position in the object.
|
|
24
|
+
*/
|
|
25
|
+
detached?: string[];
|
|
26
|
+
currentId?: string;
|
|
27
|
+
autoFocusCurrentTextbox?: boolean;
|
|
28
|
+
showResolvedThreads?: boolean;
|
|
29
|
+
onThreadAttend?: (thread: ThreadType) => void;
|
|
30
|
+
onThreadDelete?: (thread: ThreadType) => void;
|
|
31
|
+
onMessageDelete?: (thread: ThreadType, messageId: string) => void;
|
|
32
|
+
onThreadToggleResolved?: (thread: ThreadType) => void;
|
|
33
|
+
onComment?: (thread: ThreadType) => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const CommentsHeading = ({ attendableId }: { attendableId?: string }) => {
|
|
37
|
+
const { t } = useTranslation(THREAD_PLUGIN);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div role='none' className='flex items-center'>
|
|
41
|
+
<PlankHeading.Button attendableId={attendableId}>
|
|
42
|
+
<Quotes {...plankHeadingIconProps} />
|
|
43
|
+
</PlankHeading.Button>
|
|
44
|
+
<PlankHeading.Label attendableId={attendableId}>{t('comments heading')}</PlankHeading.Label>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Comment threads.
|
|
51
|
+
*/
|
|
52
|
+
export const CommentsContainer = ({
|
|
53
|
+
threads,
|
|
54
|
+
detached = [],
|
|
55
|
+
currentId,
|
|
56
|
+
autoFocusCurrentTextbox,
|
|
57
|
+
showResolvedThreads,
|
|
58
|
+
onThreadAttend,
|
|
59
|
+
onThreadDelete,
|
|
60
|
+
onMessageDelete,
|
|
61
|
+
onThreadToggleResolved,
|
|
62
|
+
...props
|
|
63
|
+
}: ThreadsContainerProps) => {
|
|
64
|
+
const { t } = useTranslation(THREAD_PLUGIN);
|
|
65
|
+
const filteredThreads = showResolvedThreads ? threads : threads.filter((thread) => !(thread?.status === 'resolved'));
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (currentId) {
|
|
69
|
+
document.getElementById(currentId)?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
70
|
+
}
|
|
71
|
+
}, [currentId]);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
{filteredThreads.length > 0 ? (
|
|
76
|
+
filteredThreads.map((thread) => (
|
|
77
|
+
<CommentContainer
|
|
78
|
+
key={thread.id}
|
|
79
|
+
thread={thread}
|
|
80
|
+
current={currentId === thread.id}
|
|
81
|
+
detached={detached.includes(thread.id)}
|
|
82
|
+
autoFocusTextbox={autoFocusCurrentTextbox && currentId === thread.id}
|
|
83
|
+
{...(onThreadAttend && { onAttend: () => onThreadAttend(thread) })}
|
|
84
|
+
{...(onThreadDelete && { onThreadDelete: () => onThreadDelete(thread) })}
|
|
85
|
+
{...(onMessageDelete && { onMessageDelete: (messageId: string) => onMessageDelete(thread, messageId) })}
|
|
86
|
+
{...(onThreadToggleResolved && { onResolve: () => onThreadToggleResolved(thread) })}
|
|
87
|
+
{...props}
|
|
88
|
+
/>
|
|
89
|
+
))
|
|
90
|
+
) : (
|
|
91
|
+
<div
|
|
92
|
+
role='alert'
|
|
93
|
+
className={mx(
|
|
94
|
+
descriptionText,
|
|
95
|
+
'place-self-center border border-dashed border-neutral-400/50 rounded-lg text-center p-4 m-4',
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<h2 className='mbe-2 font-medium text-base'>{t('no comments title')}</h2>
|
|
99
|
+
<p>
|
|
100
|
+
<Trans
|
|
101
|
+
{...{
|
|
102
|
+
t,
|
|
103
|
+
i18nKey: 'no comments message',
|
|
104
|
+
components: {
|
|
105
|
+
commentIcon: <ChatText className='inline-block' />,
|
|
106
|
+
},
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
</p>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
</>
|
|
113
|
+
);
|
|
114
|
+
};
|