diff --git a/apps/tlon-mobile/cosmos.imports.ts b/apps/tlon-mobile/cosmos.imports.ts
index 6cd06d4c68..4fad2ab1c8 100644
--- a/apps/tlon-mobile/cosmos.imports.ts
+++ b/apps/tlon-mobile/cosmos.imports.ts
@@ -3,22 +3,23 @@
import { RendererConfig, UserModuleWrappers } from 'react-cosmos-core';
import * as fixture0 from './src/App.fixture';
-import * as fixture26 from './src/fixtures/ActionSheet.fixture';
-import * as fixture25 from './src/fixtures/AudioEmbed.fixture';
-import * as fixture24 from './src/fixtures/Button.fixture';
-import * as fixture23 from './src/fixtures/Channel.fixture';
-import * as fixture22 from './src/fixtures/ChannelDivider.fixture';
-import * as fixture21 from './src/fixtures/ChannelHeader.fixture';
-import * as fixture20 from './src/fixtures/ChannelSwitcherSheet.fixture';
-import * as fixture19 from './src/fixtures/ChatMessage.fixture';
-import * as fixture18 from './src/fixtures/ChatReference.fixture';
-import * as fixture17 from './src/fixtures/ContactList.fixture';
-import * as fixture16 from './src/fixtures/GalleryPost.fixture';
-import * as fixture15 from './src/fixtures/GroupList.fixture';
-import * as fixture14 from './src/fixtures/GroupListItem.fixture';
-import * as fixture13 from './src/fixtures/HeaderButton.fixture';
-import * as fixture12 from './src/fixtures/ImageViewer.fixture';
-import * as fixture11 from './src/fixtures/Input.fixture';
+import * as fixture27 from './src/fixtures/ActionSheet.fixture';
+import * as fixture26 from './src/fixtures/AudioEmbed.fixture';
+import * as fixture25 from './src/fixtures/Button.fixture';
+import * as fixture24 from './src/fixtures/Channel.fixture';
+import * as fixture23 from './src/fixtures/ChannelDivider.fixture';
+import * as fixture22 from './src/fixtures/ChannelHeader.fixture';
+import * as fixture21 from './src/fixtures/ChannelSwitcherSheet.fixture';
+import * as fixture20 from './src/fixtures/ChatMessage.fixture';
+import * as fixture19 from './src/fixtures/ChatReference.fixture';
+import * as fixture18 from './src/fixtures/ContactList.fixture';
+import * as fixture17 from './src/fixtures/GalleryPost.fixture';
+import * as fixture16 from './src/fixtures/GroupList.fixture';
+import * as fixture15 from './src/fixtures/GroupListItem.fixture';
+import * as fixture14 from './src/fixtures/HeaderButton.fixture';
+import * as fixture13 from './src/fixtures/ImageViewer.fixture';
+import * as fixture12 from './src/fixtures/Input.fixture';
+import * as fixture11 from './src/fixtures/InputToolbar.fixture';
import * as fixture10 from './src/fixtures/MessageActions.fixture';
import * as fixture9 from './src/fixtures/MessageInput.fixture';
import * as fixture8 from './src/fixtures/OutsideEmbed.fixture';
@@ -32,7 +33,7 @@ import * as fixture1 from './src/fixtures/VideoEmbed.fixture';
import * as decorator0 from './src/fixtures/cosmos.decorator';
export const rendererConfig: RendererConfig = {
- playgroundUrl: 'http://localhost:5001',
+ playgroundUrl: 'http://localhost:5000',
rendererUrl: null,
};
@@ -48,22 +49,23 @@ const fixtures = {
'src/fixtures/OutsideEmbed.fixture.tsx': { module: fixture8 },
'src/fixtures/MessageInput.fixture.tsx': { module: fixture9 },
'src/fixtures/MessageActions.fixture.tsx': { module: fixture10 },
- 'src/fixtures/Input.fixture.tsx': { module: fixture11 },
- 'src/fixtures/ImageViewer.fixture.tsx': { module: fixture12 },
- 'src/fixtures/HeaderButton.fixture.tsx': { module: fixture13 },
- 'src/fixtures/GroupListItem.fixture.tsx': { module: fixture14 },
- 'src/fixtures/GroupList.fixture.tsx': { module: fixture15 },
- 'src/fixtures/GalleryPost.fixture.tsx': { module: fixture16 },
- 'src/fixtures/ContactList.fixture.tsx': { module: fixture17 },
- 'src/fixtures/ChatReference.fixture.tsx': { module: fixture18 },
- 'src/fixtures/ChatMessage.fixture.tsx': { module: fixture19 },
- 'src/fixtures/ChannelSwitcherSheet.fixture.tsx': { module: fixture20 },
- 'src/fixtures/ChannelHeader.fixture.tsx': { module: fixture21 },
- 'src/fixtures/ChannelDivider.fixture.tsx': { module: fixture22 },
- 'src/fixtures/Channel.fixture.tsx': { module: fixture23 },
- 'src/fixtures/Button.fixture.tsx': { module: fixture24 },
- 'src/fixtures/AudioEmbed.fixture.tsx': { module: fixture25 },
- 'src/fixtures/ActionSheet.fixture.tsx': { module: fixture26 },
+ 'src/fixtures/InputToolbar.fixture.tsx': { module: fixture11 },
+ 'src/fixtures/Input.fixture.tsx': { module: fixture12 },
+ 'src/fixtures/ImageViewer.fixture.tsx': { module: fixture13 },
+ 'src/fixtures/HeaderButton.fixture.tsx': { module: fixture14 },
+ 'src/fixtures/GroupListItem.fixture.tsx': { module: fixture15 },
+ 'src/fixtures/GroupList.fixture.tsx': { module: fixture16 },
+ 'src/fixtures/GalleryPost.fixture.tsx': { module: fixture17 },
+ 'src/fixtures/ContactList.fixture.tsx': { module: fixture18 },
+ 'src/fixtures/ChatReference.fixture.tsx': { module: fixture19 },
+ 'src/fixtures/ChatMessage.fixture.tsx': { module: fixture20 },
+ 'src/fixtures/ChannelSwitcherSheet.fixture.tsx': { module: fixture21 },
+ 'src/fixtures/ChannelHeader.fixture.tsx': { module: fixture22 },
+ 'src/fixtures/ChannelDivider.fixture.tsx': { module: fixture23 },
+ 'src/fixtures/Channel.fixture.tsx': { module: fixture24 },
+ 'src/fixtures/Button.fixture.tsx': { module: fixture25 },
+ 'src/fixtures/AudioEmbed.fixture.tsx': { module: fixture26 },
+ 'src/fixtures/ActionSheet.fixture.tsx': { module: fixture27 },
};
const decorators = {
diff --git a/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj b/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj
index 3fd794b30a..9f90aea07a 100644
--- a/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj
+++ b/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj
@@ -488,7 +488,7 @@
mainGroup = 83CBB9F61A601CBA00E9B192;
packageReferences = (
70A62C5F2A5A6B1A00EBED16 /* XCRemoteSwiftPackageReference "SimpleKeychain" */,
- 70D386462A6098F800AFB46E /* XCRemoteSwiftPackageReference "Alamofire.git" */,
+ 70D386462A6098F800AFB46E /* XCRemoteSwiftPackageReference "Alamofire" */,
70D3866D2A60A3B300AFB46E /* XCRemoteSwiftPackageReference "UrsusSigil" */,
);
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
@@ -1482,7 +1482,7 @@
minimumVersion = 1.0.0;
};
};
- 70D386462A6098F800AFB46E /* XCRemoteSwiftPackageReference "Alamofire.git" */ = {
+ 70D386462A6098F800AFB46E /* XCRemoteSwiftPackageReference "Alamofire" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Alamofire/Alamofire.git";
requirement = {
@@ -1506,7 +1506,7 @@
minimumVersion = 1.0.0;
};
};
- 70DBBFE32B7C60B50021EA96 /* XCRemoteSwiftPackageReference "Alamofire.git" */ = {
+ 70DBBFE32B7C60B50021EA96 /* XCRemoteSwiftPackageReference "Alamofire" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Alamofire/Alamofire.git";
requirement = {
@@ -1532,7 +1532,7 @@
};
70D386472A6098F800AFB46E /* Alamofire */ = {
isa = XCSwiftPackageProductDependency;
- package = 70D386462A6098F800AFB46E /* XCRemoteSwiftPackageReference "Alamofire.git" */;
+ package = 70D386462A6098F800AFB46E /* XCRemoteSwiftPackageReference "Alamofire" */;
productName = Alamofire;
};
70D3866E2A60A3B300AFB46E /* UrsusSigil */ = {
@@ -1547,7 +1547,7 @@
};
70DBBFE22B7C60B50021EA96 /* Alamofire */ = {
isa = XCSwiftPackageProductDependency;
- package = 70DBBFE32B7C60B50021EA96 /* XCRemoteSwiftPackageReference "Alamofire.git" */;
+ package = 70DBBFE32B7C60B50021EA96 /* XCRemoteSwiftPackageReference "Alamofire" */;
productName = Alamofire;
};
70DBBFE42B7C60B50021EA96 /* UrsusSigil */ = {
diff --git a/apps/tlon-mobile/ios/Podfile.lock b/apps/tlon-mobile/ios/Podfile.lock
index a83b01d2ad..7b26f33cf5 100644
--- a/apps/tlon-mobile/ios/Podfile.lock
+++ b/apps/tlon-mobile/ios/Podfile.lock
@@ -1816,7 +1816,7 @@ SPEC CHECKSUMS:
sqlite3: f163dbbb7aa3339ad8fc622782c2d9d7b72f7e9c
tentap: 61bd1f665af146d1a1ac926f70cff8ee95d0f806
UMAppLoader: 5df85360d65cabaef544be5424ac64672e648482
- Yoga: 64cd2a583ead952b0315d5135bf39e053ae9be70
+ Yoga: 1b901a6d6eeba4e8a2e8f308f708691cdb5db312
PODFILE CHECKSUM: 2ba4689327c6a97c2a40ef1fe4918cbab90120c7
diff --git a/apps/tlon-mobile/src/fixtures/InputToolbar.fixture.tsx b/apps/tlon-mobile/src/fixtures/InputToolbar.fixture.tsx
new file mode 100644
index 0000000000..9d56c39458
--- /dev/null
+++ b/apps/tlon-mobile/src/fixtures/InputToolbar.fixture.tsx
@@ -0,0 +1,19 @@
+import { useEditorBridge } from '@10play/tentap-editor';
+import { InputToolbar } from '@tloncorp/ui';
+import { TlonEditorBridge } from '@tloncorp/ui/src/components/MessageInput/toolbarActions';
+
+import { FixtureWrapper } from './FixtureWrapper';
+
+const InputToolbarFixture = () => {
+ const editor = useEditorBridge() as TlonEditorBridge;
+
+ return (
+
+
+
+ );
+};
+
+export default {
+ default: InputToolbarFixture,
+};
diff --git a/apps/tlon-mobile/src/fixtures/MessageInput.fixture.tsx b/apps/tlon-mobile/src/fixtures/MessageInput.fixture.tsx
index e26d8e8a5c..6ab4c539c8 100644
--- a/apps/tlon-mobile/src/fixtures/MessageInput.fixture.tsx
+++ b/apps/tlon-mobile/src/fixtures/MessageInput.fixture.tsx
@@ -1,10 +1,11 @@
-import { MessageInput, View } from '@tloncorp/ui';
+import { BigInput, MessageInput, View } from '@tloncorp/ui';
import { useState } from 'react';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { FixtureWrapper } from './FixtureWrapper';
import { group } from './fakeData';
-const MessageInputFixture = () => {
+const ChatMessageInputFixture = () => {
const [inputShouldBlur, setInputShouldBlur] = useState(false);
return (
@@ -32,4 +33,31 @@ const MessageInputFixture = () => {
);
};
-export default MessageInputFixture;
+const NotebookInputFixture = () => {
+ const [inputShouldBlur, setInputShouldBlur] = useState(false);
+ const { top } = useSafeAreaInsets();
+
+ return (
+
+
+ {}}
+ channelId="channel-id"
+ groupMembers={group.members ?? []}
+ getDraft={async () => ({})}
+ storeDraft={() => {}}
+ clearDraft={() => {}}
+ placeholder="Write a note..."
+ />
+
+
+ );
+};
+
+export default {
+ chat: ChatMessageInputFixture,
+ notebook: NotebookInputFixture,
+};
diff --git a/apps/tlon-mobile/src/screens/ChannelScreen.tsx b/apps/tlon-mobile/src/screens/ChannelScreen.tsx
index 9d397d3766..fae47117e6 100644
--- a/apps/tlon-mobile/src/screens/ChannelScreen.tsx
+++ b/apps/tlon-mobile/src/screens/ChannelScreen.tsx
@@ -86,7 +86,7 @@ export default function ChannelScreen(props: ChannelScreenProps) {
});
const sendPost = useCallback(
- async (content: Story, _channelId: string) => {
+ async (content: Story, _channelId: string, metadata?: db.PostMetadata) => {
if (!channel) {
throw new Error('Tried to send message before channel loaded');
}
@@ -94,7 +94,7 @@ export default function ChannelScreen(props: ChannelScreenProps) {
channel: channel,
authorId: currentUserId,
content,
- attachment: uploadInfo.uploadedImage,
+ metadata,
});
uploadInfo.resetImageAttachment();
},
diff --git a/apps/tlon-web/src/chat/ChatContent/ChatContent.tsx b/apps/tlon-web/src/chat/ChatContent/ChatContent.tsx
index 300b8eed63..791dfd7ec1 100644
--- a/apps/tlon-web/src/chat/ChatContent/ChatContent.tsx
+++ b/apps/tlon-web/src/chat/ChatContent/ChatContent.tsx
@@ -215,7 +215,7 @@ export function BlockContent({
);
}
-function ChatContent({
+function ContentRenderer({
story,
isScrolling = false,
className = '',
@@ -346,4 +346,4 @@ function ChatContent({
);
}
-export default React.memo(ChatContent);
+export default React.memo(ContentRenderer);
diff --git a/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx b/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx
index 34bf2e896e..c65b7a925f 100644
--- a/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx
+++ b/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx
@@ -24,7 +24,7 @@ import { useInView } from 'react-intersection-observer';
import { NavLink, useParams, useSearchParams } from 'react-router-dom';
import { useEventListener } from 'usehooks-ts';
-import ChatContent from '@/chat/ChatContent/ChatContent';
+import ContentRenderer from '@/chat/ChatContent/ChatContent';
import Author from '@/chat/ChatMessage/Author';
import ChatMessageOptions from '@/chat/ChatMessage/ChatMessageOptions';
import DateDivider from '@/chat/ChatMessage/DateDivider';
@@ -471,13 +471,13 @@ const ChatMessage = React.memo<
)}
>
{isHidden ? (
-
) : essay.content ? (
-
-
+
diff --git a/apps/tlon-web/src/chat/ChatSearch/ChatSearchResult.tsx b/apps/tlon-web/src/chat/ChatSearch/ChatSearchResult.tsx
index 5e558dc0fe..8495ecee0e 100644
--- a/apps/tlon-web/src/chat/ChatSearch/ChatSearchResult.tsx
+++ b/apps/tlon-web/src/chat/ChatSearch/ChatSearchResult.tsx
@@ -8,7 +8,7 @@ import { Link } from 'react-router-dom';
import ReplyReactions from '@/replies/ReplyReactions/ReplyReactions';
-import ChatContent from '../ChatContent/ChatContent';
+import ContentRenderer from '../ChatContent/ChatContent';
import Author from '../ChatMessage/Author';
import ChatReactions from '../ChatReactions/ChatReactions';
@@ -86,7 +86,7 @@ function ChatSearchResult({
>
-
+
{reacts &&
Object.keys(reacts).length > 0 &&
('parent-id' in writ.seal ? (
diff --git a/apps/tlon-web/src/components/References/WritBaseReference.tsx b/apps/tlon-web/src/components/References/WritBaseReference.tsx
index d7bbce7dbb..6cbc23bb38 100644
--- a/apps/tlon-web/src/components/References/WritBaseReference.tsx
+++ b/apps/tlon-web/src/components/References/WritBaseReference.tsx
@@ -4,7 +4,7 @@ import cn from 'classnames';
import React, { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
-import ChatContent from '@/chat/ChatContent/ChatContent';
+import ContentRenderer from '@/chat/ChatContent/ChatContent';
import useGroupJoin from '@/groups/useGroupJoin';
import HeapLoadingBlock from '@/heap/HeapLoadingBlock';
import { useChannelFlag } from '@/logic/channel';
@@ -136,7 +136,7 @@ function WritBaseReference({
content.filter((c) => 'block' in c).length > 0 ? (
Nested content references
) : (
-
-
-
{isHidden ? (
-
) : memo.content ? (
- {
@@ -37,6 +41,12 @@ export const MessageInputEditor = () => {
CoreBridge,
BoldBridge,
ItalicBridge,
+ HeadingBridge,
+ BulletListBridge,
+ ListItemBridge,
+ OrderedListBridge,
+ TaskListBridge,
+ ImageBridge,
StrikeBridge,
ShortcutsBridge,
BlockquoteBridge,
@@ -44,7 +54,7 @@ export const MessageInputEditor = () => {
newGroupDelay: 100,
}),
CodeBridge,
- UnderlineBridge,
+ CodeBlockBridge,
PlaceholderBridge,
MentionsBridge,
LinkBridge.configureExtension({
@@ -54,7 +64,6 @@ export const MessageInputEditor = () => {
}),
],
tiptapOptions: {
- extensions: [CodeBlock],
editorProps: {
handlePaste,
},
@@ -72,7 +81,7 @@ export const MessageInputEditor = () => {
fontFamily:
"System, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', sans-serif",
}}
- // @ts-expect-error bad
+ //@ts-expect-error - not an actual type mismatch
editor={editor}
/>
);
diff --git a/packages/editor/src/bridges/codeBlock.ts b/packages/editor/src/bridges/codeBlock.ts
new file mode 100644
index 0000000000..3f3f3dfb82
--- /dev/null
+++ b/packages/editor/src/bridges/codeBlock.ts
@@ -0,0 +1,82 @@
+import { BridgeExtension } from '@10play/tentap-editor';
+import CodeBlock from '@tiptap/extension-code-block';
+
+type CodeBlockEditorState = {
+ isCodeBlockActive: boolean;
+ canToggleCodeBlock: boolean;
+};
+
+type CodeBlockEditorInstance = {
+ toggleCodeBlock: () => void;
+ setCodeBlock: () => void;
+};
+
+declare module '@10play/tentap-editor' {
+ interface BridgeState extends CodeBlockEditorState {}
+ interface EditorBridge extends CodeBlockEditorInstance {}
+}
+
+export enum CodeBlockEditorActionType {
+ ToggleCodeBlock = 'toggle-code-block',
+ SetCodeBlock = 'set-code-block',
+}
+
+type CodeBlockMessage = {
+ type:
+ | CodeBlockEditorActionType.ToggleCodeBlock
+ | CodeBlockEditorActionType.SetCodeBlock;
+ payload?: undefined;
+};
+
+export const CodeBlockBridge = new BridgeExtension<
+ CodeBlockEditorState,
+ CodeBlockEditorInstance,
+ CodeBlockMessage
+>({
+ //@ts-expect-error - not an actual type mismatch
+ tiptapExtension: CodeBlock.configure({
+ HTMLAttributes: {
+ class: 'code-block',
+ },
+ }),
+ onBridgeMessage: (editor, message) => {
+ if (message.type === CodeBlockEditorActionType.ToggleCodeBlock) {
+ //@ts-expect-error - not an actual type mismatch
+ editor.chain().focus().toggleCodeBlock().run();
+ }
+
+ if (message.type === CodeBlockEditorActionType.SetCodeBlock) {
+ //@ts-expect-error - not an actual type mismatch
+ editor.chain().focus().setCodeBlock().run();
+ }
+
+ return false;
+ },
+ extendEditorInstance: (sendBridgeMessage) => {
+ return {
+ toggleCodeBlock: () =>
+ sendBridgeMessage({ type: CodeBlockEditorActionType.ToggleCodeBlock }),
+ setCodeBlock: () => {
+ sendBridgeMessage({ type: CodeBlockEditorActionType.SetCodeBlock });
+ },
+ };
+ },
+ extendEditorState: (editor) => {
+ return {
+ //@ts-expect-error - not an actual type mismatch
+ canToggleCodeBlock: editor.can().toggleCodeBlock(),
+ isCodeBlockActive: editor.isActive('codeBlock'),
+ };
+ },
+ extendCSS: `
+ code {
+ background-color: transparent;
+ border-radius: 0px;
+ box-decoration-break: slice;
+ webkit-box-decoration-break: slice;
+ color: #616161;
+ font-size: 0.9rem;
+ padding: 0px;
+ }
+ `,
+});
diff --git a/packages/editor/src/bridges/index.ts b/packages/editor/src/bridges/index.ts
index 5c359535ab..6b444de080 100644
--- a/packages/editor/src/bridges/index.ts
+++ b/packages/editor/src/bridges/index.ts
@@ -1,2 +1,3 @@
export * from './shortcut';
export * from './mention';
+export * from './codeBlock';
diff --git a/packages/editor/src/bridges/shortcut.ts b/packages/editor/src/bridges/shortcut.ts
index 3e137fae6e..9a4e0b77f3 100644
--- a/packages/editor/src/bridges/shortcut.ts
+++ b/packages/editor/src/bridges/shortcut.ts
@@ -13,6 +13,7 @@ type ShortcutsEditorInstance = {
newLineInCode: () => void;
liftEmptyBlock: () => void;
splitBlock: () => void;
+ splitListItem: (type: listItemType) => void;
};
declare module '@10play/tentap-editor' {
@@ -22,14 +23,17 @@ declare module '@10play/tentap-editor' {
export enum ShortcutsActionType {
createParagraphNear = 'create-paragraph-near',
+ splitListItem = 'split-list-item',
newLineInCode = 'new-line-in-code',
liftEmptyBlock = 'toggle-lift-empty-block',
splitBlock = 'split-block',
}
+type listItemType = 'listItem' | 'taskItem';
+
type ShortcutsMessage = {
type: ShortcutsActionType;
- payload?: undefined;
+ payload?: undefined | { type: listItemType };
};
function Shortcuts(bindings: { [keyCode: string]: KeyboardShortcutCommand }) {
@@ -45,7 +49,7 @@ export const ShortcutsBridge = new BridgeExtension<
ShortcutsEditorInstance,
ShortcutsMessage
>({
- //@ts-expect-error version mismatch between tiptap and tentap
+ //@ts-expect-error - not an actual type mismatch
tiptapExtension: Shortcuts({
// this is necessary to override the default behavior of the editor
// which is to insert a new paragraph when the user presses enter.
@@ -56,11 +60,16 @@ export const ShortcutsBridge = new BridgeExtension<
switch (message.type) {
// we used all of these actions to create a new paragraph
// in the tiptap editor on the web.
- // for now it looks likwe we just need splitBlock, but we'll
+ // for now it looks like we just need splitBlock, but we'll
// keep the rest of the actions here for reference/possible future use.
case ShortcutsActionType.createParagraphNear:
editor.chain().focus().createParagraphNear().run();
break;
+ case ShortcutsActionType.splitListItem:
+ if (message.payload) {
+ editor.chain().focus().splitListItem(message.payload.type).run();
+ }
+ break;
case ShortcutsActionType.newLineInCode:
editor.chain().focus().newlineInCode().run();
break;
@@ -84,6 +93,11 @@ export const ShortcutsBridge = new BridgeExtension<
sendBridgeMessage({ type: ShortcutsActionType.newLineInCode }),
liftEmptyBlock: () =>
sendBridgeMessage({ type: ShortcutsActionType.liftEmptyBlock }),
+ splitListItem: (type) =>
+ sendBridgeMessage({
+ type: ShortcutsActionType.splitListItem,
+ payload: { type },
+ }),
splitBlock: () =>
sendBridgeMessage({ type: ShortcutsActionType.splitBlock }),
};
@@ -94,6 +108,8 @@ export const ShortcutsBridge = new BridgeExtension<
canNewLineInCode: editor.can().newlineInCode(),
canLiftEmptyBlock: editor.can().liftEmptyBlock(),
canSplitBlock: editor.can().splitBlock(),
+ canSplitListItem: (type: listItemType) =>
+ editor.can().splitListItem(type),
};
},
});
diff --git a/packages/editor/src/index.css b/packages/editor/src/index.css
index 4670ec7e91..bb2b4504e1 100644
--- a/packages/editor/src/index.css
+++ b/packages/editor/src/index.css
@@ -5,3 +5,10 @@
border-radius: 48px;
text-decoration: none;
}
+.code-block {
+ margin-right: 16px;
+ padding-left: 8px;
+ padding-right: 8px;
+ background-color: #6161611a;
+ border-radius: 4px;
+}
diff --git a/packages/shared/src/api/apiUtils.ts b/packages/shared/src/api/apiUtils.ts
index 6b2365d8f6..a293d1a39c 100644
--- a/packages/shared/src/api/apiUtils.ts
+++ b/packages/shared/src/api/apiUtils.ts
@@ -135,7 +135,7 @@ export function toPostEssay({
metadata?: db.PostMetadata;
}): ub.PostEssay {
const kindData = (): ub.KindData => {
- if (!metadata) {
+ if (!metadata || Object.keys(metadata).length === 0) {
switch (channelType) {
case 'chat':
return { chat: null };
diff --git a/packages/shared/src/store/postActions.ts b/packages/shared/src/store/postActions.ts
index 38da3807be..b57f67ff34 100644
--- a/packages/shared/src/store/postActions.ts
+++ b/packages/shared/src/store/postActions.ts
@@ -8,20 +8,12 @@ export async function sendPost({
authorId,
content,
metadata,
- attachment,
}: {
channel: db.Channel;
authorId: string;
content: urbit.Story;
- attachment?: api.UploadedFile | null;
metadata?: db.PostMetadata;
}) {
- // replace content with attachment if empty
- // TODO: what if we have both?
- if (content.length === 0 && attachment && channel.type === 'gallery') {
- content = [createVerseFromAttachment(attachment)];
- }
-
// if first message of a pending group dm, we need to first create
// it on the backend
if (channel.type === 'groupDm' && channel.isPendingChannel) {
@@ -53,19 +45,6 @@ export async function sendPost({
}
}
-function createVerseFromAttachment(file: api.UploadedFile): urbit.Verse {
- return {
- block: {
- image: {
- src: file.url,
- height: file.height ? file.height : 200,
- width: file.width ? file.width : 200,
- alt: 'image',
- },
- },
- };
-}
-
export async function editPost({
post,
content,
diff --git a/packages/ui/src/assets/icons/BlockQuote.svg b/packages/ui/src/assets/icons/BlockQuote.svg
new file mode 100644
index 0000000000..f8f4f00270
--- /dev/null
+++ b/packages/ui/src/assets/icons/BlockQuote.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/ui/src/assets/icons/Bold.svg b/packages/ui/src/assets/icons/Bold.svg
new file mode 100644
index 0000000000..32010dd56e
--- /dev/null
+++ b/packages/ui/src/assets/icons/Bold.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/ui/src/assets/icons/BulletList.svg b/packages/ui/src/assets/icons/BulletList.svg
new file mode 100644
index 0000000000..612b1da445
--- /dev/null
+++ b/packages/ui/src/assets/icons/BulletList.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/ui/src/assets/icons/Checkbox.svg b/packages/ui/src/assets/icons/Checkbox.svg
new file mode 100644
index 0000000000..db96c4242e
--- /dev/null
+++ b/packages/ui/src/assets/icons/Checkbox.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/ui/src/assets/icons/Code.svg b/packages/ui/src/assets/icons/Code.svg
new file mode 100644
index 0000000000..c7f92e3509
--- /dev/null
+++ b/packages/ui/src/assets/icons/Code.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/ui/src/assets/icons/CodeBlock.svg b/packages/ui/src/assets/icons/CodeBlock.svg
new file mode 100644
index 0000000000..98e1fd15b9
--- /dev/null
+++ b/packages/ui/src/assets/icons/CodeBlock.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/ui/src/assets/icons/FontSize.svg b/packages/ui/src/assets/icons/FontSize.svg
new file mode 100644
index 0000000000..0bcb19dd87
--- /dev/null
+++ b/packages/ui/src/assets/icons/FontSize.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/ui/src/assets/icons/Heading.svg b/packages/ui/src/assets/icons/Heading.svg
new file mode 100644
index 0000000000..4ad7ff4076
--- /dev/null
+++ b/packages/ui/src/assets/icons/Heading.svg
@@ -0,0 +1,9 @@
+
diff --git a/packages/ui/src/assets/icons/HeadingFive.svg b/packages/ui/src/assets/icons/HeadingFive.svg
new file mode 100644
index 0000000000..7f5d9e8852
--- /dev/null
+++ b/packages/ui/src/assets/icons/HeadingFive.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/ui/src/assets/icons/HeadingFour.svg b/packages/ui/src/assets/icons/HeadingFour.svg
new file mode 100644
index 0000000000..0209f87fa8
--- /dev/null
+++ b/packages/ui/src/assets/icons/HeadingFour.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/ui/src/assets/icons/HeadingOne.svg b/packages/ui/src/assets/icons/HeadingOne.svg
new file mode 100644
index 0000000000..edafb57fe6
--- /dev/null
+++ b/packages/ui/src/assets/icons/HeadingOne.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/ui/src/assets/icons/HeadingSix.svg b/packages/ui/src/assets/icons/HeadingSix.svg
new file mode 100644
index 0000000000..aac5f06978
--- /dev/null
+++ b/packages/ui/src/assets/icons/HeadingSix.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/ui/src/assets/icons/HeadingThree.svg b/packages/ui/src/assets/icons/HeadingThree.svg
new file mode 100644
index 0000000000..08576ff747
--- /dev/null
+++ b/packages/ui/src/assets/icons/HeadingThree.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/ui/src/assets/icons/HeadingTwo.svg b/packages/ui/src/assets/icons/HeadingTwo.svg
new file mode 100644
index 0000000000..9cf9c2d3ca
--- /dev/null
+++ b/packages/ui/src/assets/icons/HeadingTwo.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/ui/src/assets/icons/IndentDecrease.svg b/packages/ui/src/assets/icons/IndentDecrease.svg
new file mode 100644
index 0000000000..915f27ffb1
--- /dev/null
+++ b/packages/ui/src/assets/icons/IndentDecrease.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/ui/src/assets/icons/IndentIncrease.svg b/packages/ui/src/assets/icons/IndentIncrease.svg
new file mode 100644
index 0000000000..6009dbc5a2
--- /dev/null
+++ b/packages/ui/src/assets/icons/IndentIncrease.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/ui/src/assets/icons/Italic.svg b/packages/ui/src/assets/icons/Italic.svg
new file mode 100644
index 0000000000..1a732b28e2
--- /dev/null
+++ b/packages/ui/src/assets/icons/Italic.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/ui/src/assets/icons/Keyboard.svg b/packages/ui/src/assets/icons/Keyboard.svg
new file mode 100644
index 0000000000..d3fc5eee92
--- /dev/null
+++ b/packages/ui/src/assets/icons/Keyboard.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/ui/src/assets/icons/Link.svg b/packages/ui/src/assets/icons/Link.svg
new file mode 100644
index 0000000000..d45f2f923a
--- /dev/null
+++ b/packages/ui/src/assets/icons/Link.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/ui/src/assets/icons/OrderedList.svg b/packages/ui/src/assets/icons/OrderedList.svg
new file mode 100644
index 0000000000..4d0422d22d
--- /dev/null
+++ b/packages/ui/src/assets/icons/OrderedList.svg
@@ -0,0 +1,7 @@
+
diff --git a/packages/ui/src/assets/icons/Redo.svg b/packages/ui/src/assets/icons/Redo.svg
new file mode 100644
index 0000000000..2c8ab98c94
--- /dev/null
+++ b/packages/ui/src/assets/icons/Redo.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/ui/src/assets/icons/Strikethrough.svg b/packages/ui/src/assets/icons/Strikethrough.svg
new file mode 100644
index 0000000000..42ef760dd4
--- /dev/null
+++ b/packages/ui/src/assets/icons/Strikethrough.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/ui/src/assets/icons/Underline.svg b/packages/ui/src/assets/icons/Underline.svg
new file mode 100644
index 0000000000..ad6e221bed
--- /dev/null
+++ b/packages/ui/src/assets/icons/Underline.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/ui/src/assets/icons/Undo.svg b/packages/ui/src/assets/icons/Undo.svg
new file mode 100644
index 0000000000..aad75b561b
--- /dev/null
+++ b/packages/ui/src/assets/icons/Undo.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/ui/src/assets/icons/index.ts b/packages/ui/src/assets/icons/index.ts
index d62c14d83f..95382d91b8 100644
--- a/packages/ui/src/assets/icons/index.ts
+++ b/packages/ui/src/assets/icons/index.ts
@@ -1,3 +1,27 @@
+export { default as Bold } from './Bold.svg';
+export { default as BlockQuote } from './BlockQuote.svg';
+export { default as BulletList } from './BulletList.svg';
+export { default as FontSize } from './FontSize.svg';
+export { default as Italic } from './Italic.svg';
+export { default as Keyboard } from './Keyboard.svg';
+export { default as Strikethrough } from './Strikethrough.svg';
+export { default as Undo } from './Undo.svg';
+export { default as Redo } from './Redo.svg';
+export { default as Link } from './Link.svg';
+export { default as Checkbox } from './Checkbox.svg';
+export { default as Code } from './Code.svg';
+export { default as CodeBlock } from './CodeBlock.svg';
+export { default as Underline } from './Underline.svg';
+export { default as OrderedList } from './OrderedList.svg';
+export { default as IndentDecrease } from './IndentDecrease.svg';
+export { default as IndentIncrease } from './IndentIncrease.svg';
+export { default as Heading } from './Heading.svg';
+export { default as HeadingOne } from './HeadingOne.svg';
+export { default as HeadingTwo } from './HeadingTwo.svg';
+export { default as HeadingThree } from './HeadingThree.svg';
+export { default as HeadingFour } from './HeadingFour.svg';
+export { default as HeadingFive } from './HeadingFive.svg';
+export { default as HeadingSix } from './HeadingSix.svg';
export { default as Add } from './Add.svg';
export { default as AddPerson } from './AddPerson.svg';
export { default as ArrowDown } from './ArrowDown.svg';
diff --git a/packages/ui/src/components/AuthorRow.tsx b/packages/ui/src/components/AuthorRow.tsx
index f26fd9efe8..113ca1e853 100644
--- a/packages/ui/src/components/AuthorRow.tsx
+++ b/packages/ui/src/components/AuthorRow.tsx
@@ -22,6 +22,8 @@ const RoleBadge = ({ role }: { role: string }) => {
);
};
+export const AUTHOR_ROW_HEIGHT_DETAIL_VIEW = '$4xl';
+
export default function AuthorRow({
author,
authorId,
@@ -55,6 +57,7 @@ export default function AuthorRow({
padding="$l"
alignItems="center"
gap="$s"
+ height={AUTHOR_ROW_HEIGHT_DETAIL_VIEW}
justifyContent="space-between"
>
@@ -120,7 +123,7 @@ export default function AuthorRow({
return (
-
+
{timeDisplay}
diff --git a/packages/ui/src/components/BigInput.tsx b/packages/ui/src/components/BigInput.tsx
new file mode 100644
index 0000000000..14dfbeab48
--- /dev/null
+++ b/packages/ui/src/components/BigInput.tsx
@@ -0,0 +1,179 @@
+import { EditorBridge } from '@10play/tentap-editor';
+import * as db from '@tloncorp/shared/dist/db';
+import { useRef, useState } from 'react';
+import { Dimensions, KeyboardAvoidingView, Platform } from 'react-native';
+import { TouchableOpacity } from 'react-native-gesture-handler';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+// TODO: replace input with our own input component
+import { Image, Input, getToken } from 'tamagui';
+
+import { ScrollView, View, YStack } from '../core';
+import AttachmentSheet from './AttachmentSheet';
+import { Icon } from './Icon';
+import { LoadingSpinner } from './LoadingSpinner';
+import { MessageInput } from './MessageInput';
+import { InputToolbar } from './MessageInput/InputToolbar';
+import { MessageInputProps } from './MessageInput/MessageInputBase';
+import { TlonEditorBridge } from './MessageInput/toolbarActions';
+
+export function BigInput({
+ channelType,
+ channelId,
+ groupMembers,
+ shouldBlur,
+ setShouldBlur,
+ send,
+ storeDraft,
+ clearDraft,
+ getDraft,
+ editingPost,
+ setEditingPost,
+ editPost,
+ setShowBigInput,
+ placeholder,
+ uploadInfo,
+}: {
+ channelType: db.ChannelType;
+} & MessageInputProps) {
+ const [title, setTitle] = useState('');
+ const [showAttachmentSheet, setShowAttachmentSheet] = useState(false);
+ const editorRef = useRef<{
+ editor: TlonEditorBridge | null;
+ setEditor: (editor: EditorBridge) => void;
+ }>(null);
+ const { top } = useSafeAreaInsets();
+ const { width } = Dimensions.get('screen');
+ const titleInputHeight = getToken('$4xl', 'size');
+ const imageButtonHeight = getToken('$4xl', 'size');
+ const keyboardVerticalOffset =
+ Platform.OS === 'ios' ? top + titleInputHeight : top;
+
+ return (
+
+ {channelType === 'notebook' && (
+
+ {
+ setShowAttachmentSheet(true);
+ editorRef.current?.editor?.blur();
+ }}
+ >
+ {uploadInfo?.imageAttachment && !uploadInfo.uploading ? (
+
+ ) : (
+
+ {uploadInfo?.uploading ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {channelType === 'notebook' && (
+
+
+
+ )}
+
+ )}
+
+
+
+ {console.log('editorRef', editorRef.current)}
+ {channelType === 'notebook' &&
+ editorRef.current &&
+ editorRef.current.editor && (
+
+
+
+ )}
+ {channelType === 'notebook' && uploadInfo && (
+
+ )}
+
+ );
+}
diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx
index 279a815de1..44c6a9acfd 100644
--- a/packages/ui/src/components/Button.tsx
+++ b/packages/ui/src/components/Button.tsx
@@ -98,6 +98,8 @@ export const ButtonFrame = styled(Stack, {
},
});
+export type ButtonProps = React.ComponentProps;
+
export const ButtonFrameImpl = ButtonFrame.styleable((props, ref) => {
// adding group to the styled component itself seems to break typing for variants
return ;
diff --git a/packages/ui/src/components/Channel/Scroller.tsx b/packages/ui/src/components/Channel/Scroller.tsx
index eabedd1b21..0d0df24106 100644
--- a/packages/ui/src/components/Channel/Scroller.tsx
+++ b/packages/ui/src/components/Channel/Scroller.tsx
@@ -206,6 +206,8 @@ export default function Scroller({
previousItem?.receivedAt ?? 0
);
const showAuthor =
+ item.type === 'note' ||
+ item.type === 'block' ||
previousItem?.authorId !== item.authorId ||
previousItem?.type === 'notice' ||
(item.replyCount ?? 0) > 0 ||
diff --git a/packages/ui/src/components/Channel/index.tsx b/packages/ui/src/components/Channel/index.tsx
index 0bf02fa91d..5061366abb 100644
--- a/packages/ui/src/components/Channel/index.tsx
+++ b/packages/ui/src/components/Channel/index.tsx
@@ -10,6 +10,7 @@ import { JSONContent, Story } from '@tloncorp/shared/dist/urbit';
import { useCallback, useMemo, useState } from 'react';
import { KeyboardAvoidingView, Platform } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { AnimatePresence, getToken } from 'tamagui';
import {
CalmProvider,
@@ -20,9 +21,10 @@ import {
} from '../../contexts';
import { ReferencesProvider } from '../../contexts/references';
import { RequestsProvider } from '../../contexts/requests';
-import { SizableText, Spinner, View, YStack } from '../../core';
+import { SizableText, View, YStack } from '../../core';
import * as utils from '../../utils';
import AddGalleryPost from '../AddGalleryPost';
+import { BigInput } from '../BigInput';
import { ChatMessage } from '../ChatMessage';
import FloatingActionButton from '../FloatingActionButton';
import { GalleryPost } from '../GalleryPost';
@@ -107,7 +109,7 @@ export function Channel({
hasOlderPosts?: boolean;
}) {
const [inputShouldBlur, setInputShouldBlur] = useState(false);
- const [showGalleryInput, setShowGalleryInput] = useState(false);
+ const [showBigInput, setShowBigInput] = useState(false);
const [showAddGalleryPost, setShowAddGalleryPost] = useState(false);
const [groupPreview, setGroupPreview] = useState(null);
const title = channel ? utils.getChannelTitle(channel) : '';
@@ -150,6 +152,11 @@ export function Channel({
return null;
}, [selectedPostId, channel]);
+ const bigInputGoBack = () => {
+ setShowBigInput(false);
+ uploadInfo.resetImageAttachment();
+ };
+
const { bottom } = useSafeAreaInsets();
return (
@@ -179,7 +186,9 @@ export function Channel({
>
+ showBigInput ? bigInputGoBack() : goBack()
+ }
goToChannels={goToChannels}
goToSearch={goToSearch}
showPickerButton={!!group}
@@ -190,132 +199,172 @@ export function Channel({
style={{ flex: 1 }}
contentContainerStyle={{ flex: 1 }}
>
-
- {showGalleryInput ? (
-
- ) : uploadInfo.imageAttachment ? (
-
- ) : (
-
+
+
+ {showBigInput ? (
-
-
- {channel && posts && (
- 0
- ? channel.unread?.firstUnreadPostId
- : null
- }
- unreadCount={
- channel.unread?.countWithoutThreads ?? 0
- }
- onPressPost={goToPost}
- onPressReplies={goToPost}
- onPressImage={goToImageViewer}
- setInputShouldBlur={setInputShouldBlur}
- onEndReached={onScrollEndReached}
- onStartReached={onScrollStartReached}
+ setShowBigInput={setShowBigInput}
+ placeholder=""
+ uploadInfo={uploadInfo}
/>
- )}
-
- )}
+
+ ) : uploadInfo.imageAttachment &&
+ channel.type !== 'notebook' ? (
+
+ ) : (
+
+
+
+
+ {channel && posts && (
+ 0
+ ? channel.unread?.firstUnreadPostId
+ : null
+ }
+ unreadCount={
+ channel.unread?.countWithoutThreads ?? 0
+ }
+ onPressPost={goToPost}
+ onPressReplies={goToPost}
+ onPressImage={goToImageViewer}
+ setInputShouldBlur={setInputShouldBlur}
+ onEndReached={onScrollEndReached}
+ onStartReached={onScrollStartReached}
+ />
+ )}
+
+ )}
+
{negotiationMatch &&
!editingPost &&
- !channel.isDmInvite &&
- (isChatChannel || uploadInfo?.uploadedImage) &&
+ (isChatChannel ||
+ (channel.type === 'gallery' &&
+ uploadInfo?.uploadedImage)) &&
canWrite && (
)}
- {channel.isDmInvite && (
-
- )}
- {!negotiationMatch && isChatChannel && canWrite && (
-
- )}
- {!isChatChannel && canWrite && !showGalleryInput && (
+ {!isChatChannel && canWrite && !showBigInput && (
- {!uploadInfo.uploading &&
- !uploadInfo.uploadedImage && (
- setShowAddGalleryPost(true)}
- label="New Post"
- icon={
-
- }
- />
- )}
+ {(channel.type === 'gallery' &&
+ showAddGalleryPost) ||
+ uploadInfo.imageAttachment ? null : (
+
+ channel.type === 'gallery'
+ ? setShowAddGalleryPost(true)
+ : setShowBigInput(true)
+ }
+ label="New Post"
+ icon={
+
+ }
+ />
+ )}
)}
+ {!negotiationMatch && isChatChannel && canWrite && (
+
+ )}
+ {channel.isDmInvite && (
+
+ )}
+ {!negotiationMatch && isChatChannel && canWrite && (
+
+ )}
{channel.type === 'gallery' && canWrite && (
)}
diff --git a/packages/ui/src/components/ChatMessage/ChatEmbedContent.tsx b/packages/ui/src/components/ChatMessage/ChatEmbedContent.tsx
index 12fa22041a..fff1242028 100644
--- a/packages/ui/src/components/ChatMessage/ChatEmbedContent.tsx
+++ b/packages/ui/src/components/ChatMessage/ChatEmbedContent.tsx
@@ -4,8 +4,8 @@ import { Linking } from 'react-native';
import { useCalm } from '../../contexts';
import { Image, Text } from '../../core';
+import { PostViewMode } from '../ContentRenderer';
import { AudioEmbed, OutsideEmbed, VideoEmbed } from '../Embed';
-import { PostViewMode } from './ChatContent';
const trustedProviders = [
{
diff --git a/packages/ui/src/components/ChatMessage/ChatMessage.tsx b/packages/ui/src/components/ChatMessage/ChatMessage.tsx
index fce19674f2..6be8fb3039 100644
--- a/packages/ui/src/components/ChatMessage/ChatMessage.tsx
+++ b/packages/ui/src/components/ChatMessage/ChatMessage.tsx
@@ -4,9 +4,9 @@ import { memo, useCallback } from 'react';
import { SizableText, View, XStack, YStack } from '../../core';
import AuthorRow from '../AuthorRow';
+import ChatContent from '../ContentRenderer';
import { Icon } from '../Icon';
import { MessageInput } from '../MessageInput';
-import ChatContent from './ChatContent';
import { ChatMessageReplySummary } from './ChatMessageReplySummary';
import { ReactionsDisplay } from './ReactionsDisplay';
diff --git a/packages/ui/src/components/ContentReference/ChannelReference.tsx b/packages/ui/src/components/ContentReference/ChannelReference.tsx
index 28320d2721..cfb150f55a 100644
--- a/packages/ui/src/components/ContentReference/ChannelReference.tsx
+++ b/packages/ui/src/components/ContentReference/ChannelReference.tsx
@@ -1,6 +1,6 @@
import { getChannelType } from '@tloncorp/shared/dist/urbit';
-import { PostViewMode } from '../ChatMessage/ChatContent';
+import { PostViewMode } from '../ContentRenderer';
import ChatReferenceWrapper from './ChatReferenceWrapper';
import ReferenceSkeleton from './ReferenceSkeleton';
diff --git a/packages/ui/src/components/ContentReference/ChatReference.tsx b/packages/ui/src/components/ContentReference/ChatReference.tsx
index 86c413fded..44288ea3f4 100644
--- a/packages/ui/src/components/ContentReference/ChatReference.tsx
+++ b/packages/ui/src/components/ContentReference/ChatReference.tsx
@@ -2,8 +2,8 @@ import * as db from '@tloncorp/shared/dist/db';
import { useCallback } from 'react';
import { Avatar } from '../Avatar';
-import ChatContent, { PostViewMode } from '../ChatMessage/ChatContent';
import ContactName from '../ContactName';
+import ChatContent, { PostViewMode } from '../ContentRenderer';
import { Reference } from './Reference';
export default function ChatReference({
diff --git a/packages/ui/src/components/ContentReference/ChatReferenceWrapper.tsx b/packages/ui/src/components/ContentReference/ChatReferenceWrapper.tsx
index 1528cc35f7..fce6add58c 100644
--- a/packages/ui/src/components/ContentReference/ChatReferenceWrapper.tsx
+++ b/packages/ui/src/components/ContentReference/ChatReferenceWrapper.tsx
@@ -1,6 +1,6 @@
import { useNavigation } from '../../contexts';
import { useRequests } from '../../contexts/requests';
-import { PostViewMode } from '../ChatMessage/ChatContent';
+import { PostViewMode } from '../ContentRenderer';
import ChatReference from './ChatReference';
import ReferenceSkeleton from './ReferenceSkeleton';
diff --git a/packages/ui/src/components/ContentReference/index.tsx b/packages/ui/src/components/ContentReference/index.tsx
index fa6b8e95e2..2743ca56f8 100644
--- a/packages/ui/src/components/ContentReference/index.tsx
+++ b/packages/ui/src/components/ContentReference/index.tsx
@@ -1,7 +1,7 @@
import { ContentReference as ContentReferenceType } from '@tloncorp/shared/dist/api';
import { Text } from '../../core';
-import { PostViewMode } from '../ChatMessage/ChatContent';
+import { PostViewMode } from '../ContentRenderer';
import Pressable from '../Pressable';
import ChannelReference from './ChannelReference';
import { GroupReference } from './GroupReference';
diff --git a/packages/ui/src/components/ChatMessage/ChatContent.tsx b/packages/ui/src/components/ContentRenderer.tsx
similarity index 60%
rename from packages/ui/src/components/ChatMessage/ChatContent.tsx
rename to packages/ui/src/components/ContentRenderer.tsx
index 4e3411dda8..f973fbb022 100644
--- a/packages/ui/src/components/ChatMessage/ChatContent.tsx
+++ b/packages/ui/src/components/ContentRenderer.tsx
@@ -1,16 +1,28 @@
import { extractContentTypesFromPost, utils } from '@tloncorp/shared';
-import { Block, isImage } from '@tloncorp/shared/dist/urbit/channel';
+import {
+ Block,
+ Code,
+ Header,
+ HeaderLevel,
+ Listing,
+ isImage,
+} from '@tloncorp/shared/dist/urbit/channel';
import {
Inline,
isBlockCode,
isBlockquote,
isBold,
isBreak,
+ isCode,
+ isHeader,
isInlineCode,
isItalics,
isLink,
+ isListItem,
+ isListing,
isShip,
isStrikethrough,
+ isTask,
} from '@tloncorp/shared/dist/urbit/content';
import { ImageLoadEventData } from 'expo-image';
import { truncate } from 'lodash';
@@ -18,18 +30,31 @@ import { Post, PostDeliveryStatus } from 'packages/shared/dist/db';
import {
ComponentProps,
ReactElement,
+ ReactNode,
memo,
useCallback,
useMemo,
useState,
} from 'react';
-import { TouchableOpacity } from 'react-native';
+import { StyleSheet, TextStyle, TouchableOpacity } from 'react-native';
+import hoon from 'refractor/lang/hoon';
+import { refractor } from 'refractor/lib/common.js';
+
+import {
+ ColorTokens,
+ Image,
+ ScrollView,
+ Text,
+ View,
+ XStack,
+ YStack,
+} from '../core';
+import ChatEmbedContent from './ChatMessage/ChatEmbedContent';
+import { ChatMessageDeliveryStatus } from './ChatMessage/ChatMessageDeliveryStatus';
+import ContactName from './ContactName';
+import ContentReference from './ContentReference';
-import { ColorTokens, Image, Text, View, XStack, YStack } from '../../core';
-import ContactName from '../ContactName';
-import ContentReference from '../ContentReference';
-import ChatEmbedContent from './ChatEmbedContent';
-import { ChatMessageDeliveryStatus } from './ChatMessageDeliveryStatus';
+refractor.register(hoon);
function ShipMention(props: ComponentProps) {
return (
@@ -43,6 +68,299 @@ function ShipMention(props: ComponentProps) {
);
}
+function ListingContent({ content }: { content: Listing }) {
+ if (isListItem(content)) {
+ return (
+
+ {content.item.map((item, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (content.list.type === 'tasklist') {
+ return (
+
+ {content.list.contents.map((item, i) => (
+
+
+
+ ))}
+
+ {content.list.items.map((con, i) => (
+
+ ))}
+
+
+ );
+ }
+
+ const isOrderedList = content.list.type === 'ordered';
+
+ return (
+
+ {content.list.contents.map((item, i) => (
+
+
+
+ ))}
+
+ {content.list.items.map((con, i) => (
+
+
+ {isOrderedList ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+const headerStyles = StyleSheet.create({
+ h1: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ },
+ h2: {
+ fontSize: 20,
+ fontWeight: 'bold',
+ },
+ h3: {
+ fontSize: 16,
+ fontWeight: 'bold',
+ },
+ h4: {
+ fontSize: 14,
+ fontWeight: 'bold',
+ },
+ h5: {
+ fontSize: 12,
+ fontWeight: 'bold',
+ },
+ h6: {
+ fontSize: 10,
+ fontWeight: 'bold',
+ },
+});
+
+function getHeaderStyle(tag: HeaderLevel) {
+ switch (tag) {
+ case 'h1':
+ return headerStyles.h1;
+ case 'h2':
+ return headerStyles.h2;
+ case 'h3':
+ return headerStyles.h3;
+ case 'h4':
+ return headerStyles.h4;
+ case 'h5':
+ return headerStyles.h5;
+ case 'h6':
+ return headerStyles.h6;
+ default:
+ return headerStyles.h1;
+ }
+}
+
+function HeaderText({ header }: Header) {
+ const { tag, content } = header;
+ const style = getHeaderStyle(tag);
+
+ return (
+
+ {content.map((con, i) => (
+
+ ))}
+
+ );
+}
+
+function getStyles(className: string[] | undefined) {
+ if (!className) {
+ return null;
+ }
+
+ const styles = StyleSheet.create({
+ 'token comment': {
+ color: '#999',
+ },
+ 'token block-comment': {
+ color: '#999',
+ },
+ 'token prolog': {
+ color: '#999',
+ },
+ 'token doctype': {
+ color: '#999',
+ },
+ 'token cdata': {
+ color: '#999',
+ },
+ 'token punctuation': {
+ color: '#ccc',
+ },
+ 'token tag': {
+ color: '#e2777a',
+ },
+ 'token attr-name': {
+ color: '#e2777a',
+ },
+ 'token namespace': {
+ color: '#e2777a',
+ },
+ 'token deleted': {
+ color: '#e2777a',
+ },
+ 'token function-name': {
+ color: '#6196cc',
+ },
+ 'token boolean': {
+ color: '#f08d49',
+ },
+ 'token number': {
+ color: '#f08d49',
+ },
+ 'token function': {
+ color: '#f08d49',
+ },
+ 'token property': {
+ color: '#f8c555',
+ },
+ 'token class-name': {
+ color: '#f8c555',
+ },
+ 'token constant': {
+ color: '#f8c555',
+ },
+ 'token symbol': {
+ color: '#f8c555',
+ },
+ 'token selector': {
+ color: '#cc99cd',
+ },
+ 'token important': {
+ color: '#cc99cd',
+ fontWeight: 'bold',
+ },
+ 'token atrule': {
+ color: '#cc99cd',
+ },
+ 'token keyword': {
+ color: '#cc99cd',
+ },
+ 'token builtin': {
+ color: '#cc99cd',
+ },
+ 'token string': {
+ color: '#7ec699',
+ },
+ 'token char': {
+ color: '#7ec699',
+ },
+ 'token attr-value': {
+ color: '#7ec699',
+ },
+ 'token regex': {
+ color: '#7ec699',
+ },
+ 'token variable': {
+ color: '#7ec699',
+ },
+ 'token operator': {
+ color: '#67cdcc',
+ },
+ 'token entity': {
+ color: '#67cdcc',
+ // cursor: 'help',
+ },
+ 'token url': {
+ color: '#67cdcc',
+ },
+ 'token bold': {
+ fontWeight: 'bold',
+ },
+ 'token italic': {
+ fontStyle: 'italic',
+ },
+ 'token inserted': {
+ color: 'green',
+ },
+ });
+
+ const combinedClassNames = className.join(' ');
+
+ return styles[combinedClassNames as keyof typeof styles] as TextStyle;
+}
+
+type TreeNodeType = 'text' | 'element' | 'root';
+type TreeNode = {
+ type: TreeNodeType;
+ value: string;
+ tagName: string;
+ children: TreeNode[];
+ properties: { className: string[] };
+};
+
+function hastToReactNative(tree: TreeNode, index?: number): ReactNode {
+ if ('type' in tree && tree.type === 'text') {
+ return tree.value;
+ }
+
+ if ('type' in tree && tree.type === 'element') {
+ const children = (tree.children || []).map((child: TreeNode) =>
+ hastToReactNative(child)
+ );
+
+ const classNames = tree.properties.className
+ ? tree.properties.className.join(' ')
+ : tree.tagName;
+ const key = index ? `${classNames}-${index}` : classNames;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+ if ('type' in tree && tree.type === 'root') {
+ return tree.children.map((child: TreeNode, i: number) =>
+ hastToReactNative(child, i)
+ );
+ }
+
+ return null;
+}
+
+function CodeContent({ code }: Code) {
+ const { lang, code: content } = code;
+ const tree = refractor.highlight(content, lang) as TreeNode;
+ const element = hastToReactNative(tree);
+
+ return (
+
+
+
+ {element}
+
+
+
+ );
+}
+
export function InlineContent({
inline,
color = '$primaryText',
@@ -171,10 +489,25 @@ export function InlineContent({
if (isBreak(inline)) {
return ;
}
+
if (isShip(inline)) {
return ;
}
- console.error(`Unhandled message type: ${JSON.stringify(inline)}`);
+
+ if (isTask(inline)) {
+ return (
+
+
+ {inline.task.checked ? '☑' : '☐'}
+
+ {inline.task.content.map((s, k) => (
+
+ ))}
+
+ );
+ }
+
+ console.error('Unhandled message type:', { inline });
return (
This content cannot be rendered, unhandled message type.
@@ -232,7 +565,24 @@ export function BlockContent({
);
}
- console.error(`Unhandled message type: ${JSON.stringify(block)}`);
+
+ if (isListing(block)) {
+ return ;
+ }
+
+ if (isHeader(block)) {
+ return ;
+ }
+
+ if (isCode(block)) {
+ return ;
+ }
+
+ if ('rule' in block) {
+ return ;
+ }
+
+ console.error('Unhandled message type:', { block });
return (
This content cannot be rendered, unhandled message type.
diff --git a/packages/ui/src/components/GalleryPost/GalleryPost.tsx b/packages/ui/src/components/GalleryPost/GalleryPost.tsx
index dd0ba4f6b5..6b2739e303 100644
--- a/packages/ui/src/components/GalleryPost/GalleryPost.tsx
+++ b/packages/ui/src/components/GalleryPost/GalleryPost.tsx
@@ -20,8 +20,8 @@ import { getTokenValue } from 'tamagui';
import { LinearGradient, Text, View, YStack } from '../../core';
import AuthorRow from '../AuthorRow';
-import ChatContent from '../ChatMessage/ChatContent';
import ContentReference from '../ContentReference';
+import ContentRenderer from '../ContentRenderer';
import Pressable from '../Pressable';
export default function GalleryPost({
@@ -168,7 +168,7 @@ export default function GalleryPost({
paddingBottom="$xs"
position="relative"
>
-
diff --git a/packages/ui/src/components/IconButton.tsx b/packages/ui/src/components/IconButton.tsx
index c2c2c86a1c..910968e101 100644
--- a/packages/ui/src/components/IconButton.tsx
+++ b/packages/ui/src/components/IconButton.tsx
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react';
-import { Button } from '../components/Button';
+import { Button, ButtonProps } from '../components/Button';
import {
ColorTokens,
RadiusTokens,
@@ -18,21 +18,22 @@ export function IconButton({
backgroundColorOnPress = '$secondaryBackground',
disabled = false,
radius = '$l',
-}: PropsWithChildren<{
- onPress?: () => void;
- size?: SizeTokens;
- color?: ThemeTokens | ColorTokens;
- backgroundColor?: ThemeTokens | ColorTokens;
- backgroundColorOnPress?: ThemeTokens | ColorTokens;
- radius?: RadiusTokens;
- disabled?: boolean;
-}>) {
+ ...rest
+}: PropsWithChildren<
+ {
+ onPress?: () => void;
+ size?: SizeTokens;
+ color?: ThemeTokens | ColorTokens;
+ backgroundColor?: ThemeTokens | ColorTokens;
+ backgroundColorOnPress?: ThemeTokens | ColorTokens;
+ radius?: RadiusTokens;
+ disabled?: boolean;
+ } & ButtonProps
+>) {
const theme = useTheme();
return (
diff --git a/packages/ui/src/components/MessageInput/EditLinkBar.tsx b/packages/ui/src/components/MessageInput/EditLinkBar.tsx
new file mode 100644
index 0000000000..240b38b28b
--- /dev/null
+++ b/packages/ui/src/components/MessageInput/EditLinkBar.tsx
@@ -0,0 +1,66 @@
+import { EditorTheme, Images } from '@10play/tentap-editor';
+import React from 'react';
+import { TouchableOpacity } from 'react-native';
+// TODO: replace with our own input component
+import { Input } from 'tamagui';
+
+import { Image, View } from '../../core';
+import { Button } from '../Button';
+
+interface EditLinkBarProps {
+ theme: EditorTheme;
+ onBlur: () => void;
+ onEditLink: (newLink: string) => void;
+ onLinkIconClick: () => void;
+ initialLink: string | undefined;
+}
+
+export const EditLinkBar = ({
+ theme,
+ initialLink,
+ onEditLink,
+ onLinkIconClick,
+ onBlur,
+}: EditLinkBarProps) => {
+ const [link, setLink] = React.useState(initialLink || '');
+
+ return (
+
+ {/* TODO: replace with our own button component and styles */}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/MessageInput/InputToolbar.tsx b/packages/ui/src/components/MessageInput/InputToolbar.tsx
new file mode 100644
index 0000000000..3ce2eed652
--- /dev/null
+++ b/packages/ui/src/components/MessageInput/InputToolbar.tsx
@@ -0,0 +1,177 @@
+import { useBridgeState, useKeyboard } from '@10play/tentap-editor';
+import React, { memo, useCallback, useMemo } from 'react';
+import {
+ FlatList,
+ ListRenderItem,
+ Platform,
+ TouchableOpacity,
+} from 'react-native';
+
+import { View } from '../../core';
+import { Icon } from '../Icon';
+import { EditLinkBar } from './EditLinkBar';
+import {
+ DEFAULT_TOOLBAR_ITEMS,
+ HEADING_ITEMS,
+ TlonBridgeState,
+ TlonEditorBridge,
+ ToolbarContext,
+ type ToolbarItem,
+} from './toolbarActions';
+
+interface ToolbarProps {
+ editor: TlonEditorBridge;
+ hidden?: boolean;
+ items?: ToolbarItem[];
+}
+
+const InputToolbar = memo(
+ ({
+ editor,
+ hidden = undefined,
+ items = DEFAULT_TOOLBAR_ITEMS,
+ }: ToolbarProps) => {
+ const editorState = useBridgeState(editor) as TlonBridgeState;
+ const { isKeyboardUp } = useKeyboard();
+ const [toolbarContext, setToolbarContext] = React.useState(
+ ToolbarContext.Main
+ );
+
+ const hideToolbar =
+ hidden === undefined ? !isKeyboardUp || !editorState.isFocused : hidden;
+
+ const itemIsActive = useCallback(
+ (item: ToolbarItem) => {
+ const args = {
+ editor,
+ editorState,
+ setToolbarContext,
+ toolbarContext,
+ };
+
+ return item.active(args);
+ },
+ [editor, editorState, setToolbarContext, toolbarContext]
+ );
+
+ const itemIsDisabled = useCallback(
+ (item: ToolbarItem) => {
+ const args = {
+ editor,
+ editorState,
+ setToolbarContext,
+ toolbarContext,
+ };
+
+ return item.disabled(args);
+ },
+ [editor, editorState, setToolbarContext, toolbarContext]
+ );
+
+ const createItemViewStyle = useCallback(
+ (item: ToolbarItem) => [
+ editor.theme.toolbar.toolbarButton,
+ itemIsActive(item) ? editor.theme.toolbar.iconWrapperActive : undefined,
+ itemIsDisabled(item)
+ ? editor.theme.toolbar.iconWrapperDisabled
+ : undefined,
+ ],
+ [editor, itemIsActive, itemIsDisabled]
+ );
+
+ const touchableStyle = useMemo(
+ () => editor.theme.toolbar.toolbarButton,
+ [editor.theme.toolbar.toolbarButton]
+ );
+
+ const renderItem: ListRenderItem = useCallback(
+ ({ item: { onPress, disabled, active, icon } }) => {
+ const args = {
+ editor,
+ editorState,
+ setToolbarContext,
+ toolbarContext,
+ };
+
+ const style = createItemViewStyle({ onPress, disabled, active, icon });
+
+ return (
+
+
+
+
+
+ );
+ },
+ [
+ editor,
+ editorState,
+ setToolbarContext,
+ toolbarContext,
+ createItemViewStyle,
+ touchableStyle,
+ ]
+ );
+
+ switch (toolbarContext) {
+ case ToolbarContext.Main:
+ case ToolbarContext.Heading:
+ return (
+ ({
+ length: 43,
+ offset: 43 * index,
+ index,
+ })}
+ />
+ );
+ case ToolbarContext.Link:
+ return (
+ setToolbarContext(ToolbarContext.Main)}
+ onLinkIconClick={() => {
+ setToolbarContext(ToolbarContext.Main);
+ editor.focus();
+ }}
+ onEditLink={(link) => {
+ editor.setLink(link);
+ editor.focus();
+
+ if (Platform.OS === 'android') {
+ // On android we dont want to hide the link input before we finished focus on editor
+ // Add here 100ms and we can try to find better solution later
+ setTimeout(() => {
+ setToolbarContext(ToolbarContext.Main);
+ }, 100);
+ } else {
+ setToolbarContext(ToolbarContext.Main);
+ }
+ }}
+ />
+ );
+ }
+ }
+);
+
+InputToolbar.displayName = 'InputToolbar';
+
+export { InputToolbar };
diff --git a/packages/ui/src/components/MessageInput/MessageInputBase.tsx b/packages/ui/src/components/MessageInput/MessageInputBase.tsx
index be63892c0d..df675dabd7 100644
--- a/packages/ui/src/components/MessageInput/MessageInputBase.tsx
+++ b/packages/ui/src/components/MessageInput/MessageInputBase.tsx
@@ -1,11 +1,14 @@
-import { UploadInfo } from '@tloncorp/shared/dist/api';
+import { EditorBridge } from '@10play/tentap-editor';
+import { UploadInfo, UploadedFile } from '@tloncorp/shared/dist/api';
import * as db from '@tloncorp/shared/dist/db';
import { JSONContent, Story } from '@tloncorp/shared/dist/urbit';
import { PropsWithChildren, useMemo } from 'react';
+import { SpaceTokens } from 'tamagui';
import { ArrowUp, Checkmark, Close } from '../../assets/icons';
import { ThemeTokens, View, XStack, YStack } from '../../core';
import FloatingActionButton from '../FloatingActionButton';
+import { Icon } from '../Icon';
import { IconButton } from '../IconButton';
import AttachmentButton from './AttachmentButton';
import InputMentionPopup from './InputMentionPopup';
@@ -14,7 +17,7 @@ import ReferencePreview from './ReferencePreview';
export interface MessageInputProps {
shouldBlur: boolean;
setShouldBlur: (shouldBlur: boolean) => void;
- send: (content: Story, channelId: string) => void;
+ send: (content: Story, channelId: string, metadata?: db.PostMetadata) => void;
channelId: string;
uploadInfo?: UploadInfo;
groupMembers: db.ChatMember[];
@@ -24,10 +27,21 @@ export interface MessageInputProps {
editingPost?: db.Post;
setEditingPost?: (post: db.Post | undefined) => void;
editPost?: (post: db.Post, content: Story) => void;
- setShowGalleryInput?: (showGalleryInput: boolean) => void;
+ setShowBigInput?: (showBigInput: boolean) => void;
showAttachmentButton?: boolean;
floatingActionButton?: boolean;
+ paddingHorizontal?: SpaceTokens;
backgroundColor?: ThemeTokens;
+ placeholder?: string;
+ bigInput?: boolean;
+ title?: string;
+ image?: UploadedFile;
+ showToolbar?: boolean;
+ channelType?: db.ChannelType;
+ ref?: React.RefObject<{
+ editor: EditorBridge | null;
+ setEditor: (editor: EditorBridge) => void;
+ }>;
}
export const MessageInputContainer = ({
@@ -38,13 +52,13 @@ export const MessageInputContainer = ({
showMentionPopup = false,
showAttachmentButton = true,
floatingActionButton = false,
+ disableSend = false,
mentionText,
groupMembers,
onSelectMention,
isEditing = false,
cancelEditing,
onPressEdit,
- editorIsEmpty,
}: PropsWithChildren<{
onPressSend: () => void;
uploadInfo?: UploadInfo;
@@ -52,13 +66,13 @@ export const MessageInputContainer = ({
showMentionPopup?: boolean;
showAttachmentButton?: boolean;
floatingActionButton?: boolean;
+ disableSend?: boolean;
mentionText?: string;
groupMembers: db.ChatMember[];
onSelectMention: (contact: db.Contact) => void;
isEditing?: boolean;
cancelEditing?: () => void;
onPressEdit?: () => void;
- editorIsEmpty: boolean;
}>) => {
const hasUploadedImage = useMemo(
() => !!(uploadInfo?.uploadedImage && uploadInfo.uploadedImage.url !== ''),
@@ -103,16 +117,22 @@ export const MessageInputContainer = ({
{children}
{floatingActionButton ? (
- {editorIsEmpty ? null : (
+ {disableSend ? null : (
: }
+ icon={
+ isEditing ? (
+
+ ) : (
+
+ )
+ }
/>
)}
) : (
- {editorIsEmpty ? null : (
+ {disableSend ? null : (
{
// and the 20px line height of the text. 16 + 20 + 16 = 52
const DEFAULT_CONTAINER_HEIGHT = 52;
-export function MessageInput({
- shouldBlur,
- setShouldBlur,
- send,
- channelId,
- uploadInfo,
- groupMembers,
- storeDraft,
- clearDraft,
- getDraft,
- editingPost,
- setEditingPost,
- editPost,
- setShowGalleryInput,
- showAttachmentButton = true,
- floatingActionButton = false,
- backgroundColor = '$secondaryBackground',
-}: MessageInputProps) {
- const [hasSetInitialContent, setHasSetInitialContent] = useState(false);
- const [containerHeight, setContainerHeight] = useState(
- DEFAULT_CONTAINER_HEIGHT
- );
- const [mentionText, setMentionText] = useState();
- const [editorIsEmpty, setEditorIsEmpty] = useState(true);
- const [showMentionPopup, setShowMentionPopup] = useState(false);
- const { references, setReferences } = useReferences();
- const editor = useEditorBridge({
- customSource: editorHtml,
- autofocus: false,
- bridgeExtensions: [
+export interface MessageInputHandle {
+ editor: EditorBridge | null;
+ setEditor: (editor: EditorBridge) => void;
+}
+
+export const MessageInput = forwardRef(
+ (
+ {
+ shouldBlur,
+ setShouldBlur,
+ send,
+ channelId,
+ uploadInfo,
+ groupMembers,
+ storeDraft,
+ clearDraft,
+ getDraft,
+ editingPost,
+ setEditingPost,
+ editPost,
+ setShowBigInput,
+ showAttachmentButton = true,
+ floatingActionButton = false,
+ backgroundColor = '$secondaryBackground',
+ paddingHorizontal = '$l',
+ placeholder = 'Message',
+ bigInput = false,
+ title,
+ image,
+ channelType,
+ },
+ ref
+ ) => {
+ const localEditorRef = useRef(null);
+
+ useImperativeHandle(ref, () => ({
+ editor: localEditorRef.current,
+ setEditor: (editor: EditorBridge) => {
+ localEditorRef.current = editor;
+ },
+ }));
+
+ const [hasSetInitialContent, setHasSetInitialContent] = useState(false);
+ const [containerHeight, setContainerHeight] = useState(
+ DEFAULT_CONTAINER_HEIGHT
+ );
+ const { bottom, top } = useSafeAreaInsets();
+ const { height } = useWindowDimensions();
+ const headerHeight = 48;
+ const titleInputHeight = 48;
+ const inputBasePadding = getToken('$s', 'space');
+ const imageInputButtonHeight = 50;
+ const basicOffset =
+ top + headerHeight + titleInputHeight + imageInputButtonHeight;
+ const bigInputHeightBasic =
+ height - basicOffset - bottom - inputBasePadding * 2;
+ const [bigInputHeight, setBigInputHeight] = useState(bigInputHeightBasic);
+ const [mentionText, setMentionText] = useState();
+ const [editorIsEmpty, setEditorIsEmpty] = useState(true);
+ const [showMentionPopup, setShowMentionPopup] = useState(false);
+ const { references, setReferences } = useReferences();
+ const bridgeExtensions = [
...TenTapStartKit,
- PlaceholderBridge.configureExtension({
- placeholder: 'Message',
- }),
MentionsBridge,
ShortcutsBridge,
- ],
- });
- const editorState = useBridgeState(editor);
- const webviewRef = editor.webviewRef;
-
- useEffect(() => {
- if (!hasSetInitialContent && editorState.isReady) {
- try {
- getDraft().then((draft) => {
- if (draft) {
- // @ts-expect-error setContent does accept JSONContent
- editor.setContent(draft);
- setHasSetInitialContent(true);
- setEditorIsEmpty(false);
- }
- if (editingPost?.content) {
- const content = JSON.parse(
- editingPost.content as string
- ) as PostContent;
+ CodeBlockBridge,
+ ];
+
+ if (placeholder) {
+ bridgeExtensions.push(
+ PlaceholderBridge.configureExtension({
+ placeholder,
+ })
+ );
+ }
- if (!content) {
- return;
+ const editor = useEditorBridge({
+ customSource: editorHtml,
+ autofocus: false,
+ bridgeExtensions,
+ });
+ const editorState = useBridgeState(editor);
+ const webviewRef = editor.webviewRef;
+
+ useEffect(() => {
+ if (editor) {
+ localEditorRef.current = editor;
+ }
+
+ if (ref && typeof ref === 'object' && ref.current) {
+ ref.current.setEditor(editor);
+ }
+ }, [editor, ref]);
+
+ useEffect(() => {
+ if (!hasSetInitialContent && editorState.isReady) {
+ try {
+ getDraft().then((draft) => {
+ if (draft) {
+ // @ts-expect-error setContent does accept JSONContent
+ editor.setContent(draft);
+ setHasSetInitialContent(true);
+ setEditorIsEmpty(false);
}
+ if (editingPost?.content) {
+ const content = JSON.parse(
+ editingPost.content as string
+ ) as PostContent;
- const story =
- (content.filter((c) => 'inline' in c || 'block' in c) as Story) ??
- [];
- const tiptapContent = tiptap.diaryMixedToJSON(story);
- // @ts-expect-error setContent does accept JSONContent
- editor.setContent(tiptapContent);
- setHasSetInitialContent(true);
- }
- });
- } catch (e) {
- console.error('Error getting draft', e);
+ if (!content) {
+ return;
+ }
+
+ const story =
+ (content.filter(
+ (c) => 'inline' in c || 'block' in c
+ ) as Story) ?? [];
+ const tiptapContent = tiptap.diaryMixedToJSON(story);
+ // @ts-expect-error setContent does accept JSONContent
+ editor.setContent(tiptapContent);
+ setHasSetInitialContent(true);
+ }
+ });
+ } catch (e) {
+ console.error('Error getting draft', e);
+ }
}
- }
- }, [
- editor,
- getDraft,
- hasSetInitialContent,
- editorState.isReady,
- editingPost,
- ]);
-
- useEffect(() => {
- if (editor && shouldBlur && editorState.isFocused) {
- editor.blur();
- setShouldBlur(false);
- }
- }, [shouldBlur, editor, editorState, setShouldBlur]);
-
- useEffect(() => {
- editor.getJSON().then((json: JSONContent) => {
- const inlines = tiptap
- .JSONToInlines(json)
- .filter(
- (c) => typeof c === 'string' || (typeof c === 'object' && isInline(c))
- ) as Inline[];
- const blocks =
- (tiptap
+ }, [
+ editor,
+ getDraft,
+ hasSetInitialContent,
+ editorState.isReady,
+ editingPost,
+ ]);
+
+ useEffect(() => {
+ if (editor && shouldBlur && editorState.isFocused) {
+ editor.blur();
+ setShouldBlur(false);
+ }
+ }, [shouldBlur, editor, editorState, setShouldBlur]);
+
+ useEffect(() => {
+ editor.getJSON().then((json: JSONContent) => {
+ const inlines = tiptap
.JSONToInlines(json)
- .filter((c) => typeof c !== 'string' && 'block' in c) as Block[]) ||
- [];
-
- const inlineIsJustBreak = !!(
- inlines.length === 1 &&
- inlines[0] &&
- typeof inlines[0] === 'object' &&
- 'break' in inlines[0]
- );
+ .filter(
+ (c) =>
+ typeof c === 'string' || (typeof c === 'object' && isInline(c))
+ ) as Inline[];
+ const blocks =
+ (tiptap
+ .JSONToInlines(json)
+ .filter((c) => typeof c !== 'string' && 'block' in c) as Block[]) ||
+ [];
+
+ const inlineIsJustBreak = !!(
+ inlines.length === 1 &&
+ inlines[0] &&
+ typeof inlines[0] === 'object' &&
+ 'break' in inlines[0]
+ );
- const isEmpty =
- (inlines.length === 0 || inlineIsJustBreak) &&
- blocks.length === 0 &&
- !uploadInfo?.uploadedImage &&
- Object.entries(references).filter(([, ref]) => ref !== null).length ===
- 0;
+ const isEmpty =
+ (inlines.length === 0 || inlineIsJustBreak) &&
+ blocks.length === 0 &&
+ !uploadInfo?.uploadedImage &&
+ Object.entries(references).filter(([, ref]) => ref !== null)
+ .length === 0;
- if (isEmpty !== editorIsEmpty) {
- setEditorIsEmpty(isEmpty);
+ if (isEmpty !== editorIsEmpty) {
+ setEditorIsEmpty(isEmpty);
+ }
+ });
+ }, [editor, references, uploadInfo, editorIsEmpty]);
+
+ editor._onContentUpdate = async () => {
+ const json = await editor.getJSON();
+ const inlines = (
+ tiptap
+ .JSONToInlines(json)
+ .filter(
+ (c) =>
+ typeof c === 'string' || (typeof c === 'object' && isInline(c))
+ ) as Inline[]
+ ).filter((inline) => inline !== null) as Inline[];
+ // find the first mention in the inlines without refs
+ const mentionInline = inlines.find(
+ (inline) => typeof inline === 'string' && inline.match(/\B[~@]/)
+ ) as string | undefined;
+ // extract the mention text from the mention inline
+ const mentionText = mentionInline
+ ? mentionInline.slice((mentionInline.match(/\B[~@]/)?.index ?? -1) + 1)
+ : null;
+ if (mentionText !== null) {
+ // if we have a mention text, we show the mention popup
+ setShowMentionPopup(true);
+ setMentionText(mentionText);
+ } else {
+ setShowMentionPopup(false);
}
- });
- }, [editor, references, uploadInfo, editorIsEmpty]);
-
- editor._onContentUpdate = async () => {
- const json = await editor.getJSON();
- const inlines = (
- tiptap
- .JSONToInlines(json)
- .filter(
- (c) => typeof c === 'string' || (typeof c === 'object' && isInline(c))
- ) as Inline[]
- ).filter((inline) => inline !== null) as Inline[];
- // find the first mention in the inlines without refs
- const mentionInline = inlines.find(
- (inline) => typeof inline === 'string' && inline.match(/\B[~@]/)
- ) as string | undefined;
- // extract the mention text from the mention inline
- const mentionText = mentionInline
- ? mentionInline.slice((mentionInline.match(/\B[~@]/)?.index ?? -1) + 1)
- : null;
- if (mentionText !== null) {
- // if we have a mention text, we show the mention popup
- setShowMentionPopup(true);
- setMentionText(mentionText);
- } else {
- setShowMentionPopup(false);
- }
- storeDraft(json);
- };
-
- const handlePaste = useCallback(
- async (pastedText: string) => {
- if (pastedText) {
- const isRef = pastedText.match(tiptap.REF_REGEX);
-
- if (isRef) {
- const cite = pathToCite(isRef[0]);
-
- if (cite) {
- const reference = toContentReference(cite);
- setReferences({ [isRef[0]]: reference });
- const json = await editor.getJSON();
- const inlines = tiptap
- .JSONToInlines(json)
- .filter(
- (c) =>
- typeof c === 'string' ||
- (typeof c === 'object' && isInline(c))
- ) as Inline[];
- const blocks =
- (tiptap
+ storeDraft(json);
+ };
+
+ const handlePaste = useCallback(
+ async (pastedText: string) => {
+ if (pastedText) {
+ const isRef = pastedText.match(tiptap.REF_REGEX);
+
+ if (isRef) {
+ const cite = pathToCite(isRef[0]);
+
+ if (cite) {
+ const reference = toContentReference(cite);
+ setReferences({ [isRef[0]]: reference });
+ const json = await editor.getJSON();
+ const inlines = tiptap
.JSONToInlines(json)
.filter(
- (c) => typeof c !== 'string' && 'block' in c
- ) as Block[]) || [];
-
- // then we need to find all the inlines without refs
- // so we can render the input text without refs
- const inlinesWithOutRefs = inlines
- .map((inline) => {
- if (typeof inline === 'string') {
- const inlineLength = inline.length;
- const refLength =
- inline.match(tiptap.REF_REGEX)?.[0].length || 0;
-
- if (inlineLength === refLength) {
- return null;
+ (c) =>
+ typeof c === 'string' ||
+ (typeof c === 'object' && isInline(c))
+ ) as Inline[];
+ const blocks =
+ (tiptap
+ .JSONToInlines(json)
+ .filter(
+ (c) => typeof c !== 'string' && 'block' in c
+ ) as Block[]) || [];
+
+ // then we need to find all the inlines without refs
+ // so we can render the input text without refs
+ const inlinesWithOutRefs = inlines
+ .map((inline) => {
+ if (typeof inline === 'string') {
+ const inlineLength = inline.length;
+ const refLength =
+ inline.match(tiptap.REF_REGEX)?.[0].length || 0;
+
+ if (inlineLength === refLength) {
+ return null;
+ }
+
+ return inline.replace(tiptap.REF_REGEX, '');
}
+ return inline;
+ })
+ .filter((inline) => inline !== null) as string[];
- return inline.replace(tiptap.REF_REGEX, '');
- }
- return inline;
- })
- .filter((inline) => inline !== null) as string[];
-
- // we construct a story here so we can insert blocks back in
- // and then convert it back to tiptap's JSON format
- const newStory = constructStory(inlinesWithOutRefs);
+ // we construct a story here so we can insert blocks back in
+ // and then convert it back to tiptap's JSON format
+ const newStory = constructStory(inlinesWithOutRefs);
- if (blocks && blocks.length > 0) {
- newStory.push(...blocks.map((block) => ({ block })));
- }
+ if (blocks && blocks.length > 0) {
+ newStory.push(...blocks.map((block) => ({ block })));
+ }
- const newJson = tiptap.diaryMixedToJSON(newStory);
+ const newJson = tiptap.diaryMixedToJSON(newStory);
- // @ts-expect-error setContent does accept JSONContent
- editor.setContent(newJson);
- // editor.setSelection(initialSelection.from, initialSelection.to);
+ // @ts-expect-error setContent does accept JSONContent
+ editor.setContent(newJson);
+ // editor.setSelection(initialSelection.from, initialSelection.to);
+ }
}
}
- }
- },
- [editor, setReferences]
- );
-
- const onSelectMention = useCallback(
- async (contact: db.Contact) => {
- const json = await editor.getJSON();
- const inlines = tiptap.JSONToInlines(json);
-
- let textBeforeSig = '';
- let textBeforeAt = '';
-
- const newInlines = inlines.map((inline) => {
- if (typeof inline === 'string') {
- if (inline.match(`~`)) {
- textBeforeSig = inline.split('~')[0];
+ },
+ [editor, setReferences]
+ );
+
+ const onSelectMention = useCallback(
+ async (contact: db.Contact) => {
+ const json = await editor.getJSON();
+ const inlines = tiptap.JSONToInlines(json);
+
+ let textBeforeSig = '';
+ let textBeforeAt = '';
+
+ const newInlines = inlines.map((inline) => {
+ if (typeof inline === 'string') {
+ if (inline.match(`~`)) {
+ textBeforeSig = inline.split('~')[0];
+
+ return {
+ ship: contact.id,
+ };
+ }
- return {
- ship: contact.id,
- };
- }
+ if (inline.match(`@`)) {
+ textBeforeAt = inline.split('@')[0];
+ return {
+ ship: contact.id,
+ };
+ }
- if (inline.match(`@`)) {
- textBeforeAt = inline.split('@')[0];
- return {
- ship: contact.id,
- };
+ return inline;
}
-
return inline;
- }
- return inline;
- });
+ });
- if (textBeforeSig) {
- const indexOfMention = newInlines.findIndex(
- (inline) =>
- typeof inline === 'object' &&
- 'ship' in inline &&
- inline.ship === contact.id
- );
+ if (textBeforeSig) {
+ const indexOfMention = newInlines.findIndex(
+ (inline) =>
+ typeof inline === 'object' &&
+ 'ship' in inline &&
+ inline.ship === contact.id
+ );
- newInlines.splice(indexOfMention, 0, textBeforeSig);
- }
+ newInlines.splice(indexOfMention, 0, textBeforeSig);
+ }
- if (textBeforeAt) {
- const indexOfMention = newInlines.findIndex(
- (inline) =>
- typeof inline === 'object' &&
- 'ship' in inline &&
- inline.ship === contact.id
- );
+ if (textBeforeAt) {
+ const indexOfMention = newInlines.findIndex(
+ (inline) =>
+ typeof inline === 'object' &&
+ 'ship' in inline &&
+ inline.ship === contact.id
+ );
- newInlines.splice(indexOfMention, 0, textBeforeAt);
- }
+ newInlines.splice(indexOfMention, 0, textBeforeAt);
+ }
- const newStory = constructStory(newInlines);
+ const newStory = constructStory(newInlines);
+
+ const newJson = tiptap.diaryMixedToJSON(newStory);
+
+ // @ts-expect-error setContent does accept JSONContent
+ editor.setContent(newJson);
+ storeDraft(newJson);
+ setMentionText('');
+ setShowMentionPopup(false);
+ },
+ [editor, storeDraft]
+ );
+
+ const sendMessage = useCallback(
+ async (isEdit?: boolean) => {
+ const json = await editor.getJSON();
+ const blocks: Block[] = [];
+ const inlines = tiptap.JSONToInlines(json);
+ const story = constructStory(inlines);
+
+ if (Object.keys(references).length) {
+ Object.keys(references).forEach((ref) => {
+ const cite = pathToCite(ref);
+ if (!cite) {
+ return;
+ }
+ blocks.push({ cite });
+ });
+ }
- const newJson = tiptap.diaryMixedToJSON(newStory);
+ if (!image && uploadInfo?.uploadedImage) {
+ blocks.push({
+ image: {
+ src: uploadInfo.uploadedImage.url,
+ height: uploadInfo.uploadedImage.height,
+ width: uploadInfo.uploadedImage.width,
+ alt: 'image',
+ },
+ });
+ }
- // @ts-expect-error setContent does accept JSONContent
- editor.setContent(newJson);
- storeDraft(newJson);
- setMentionText('');
- setShowMentionPopup(false);
- },
- [editor, storeDraft]
- );
+ if (blocks && blocks.length > 0) {
+ story.push(...blocks.map((block) => ({ block })));
+ }
- const sendMessage = useCallback(
- async (isEdit?: boolean) => {
- const json = await editor.getJSON();
- const blocks: Block[] = [];
- const inlines = tiptap.JSONToInlines(json);
- const story = constructStory(inlines);
-
- if (Object.keys(references).length) {
- Object.keys(references).forEach((ref) => {
- const cite = pathToCite(ref);
- if (!cite) {
- return;
+ if (isEdit && editingPost) {
+ await editPost?.(editingPost, story);
+ setEditingPost?.(undefined);
+ } else {
+ const metadata: db.PostMetadata = {};
+ if (title && title.length > 0) {
+ metadata['title'] = title;
}
- blocks.push({ cite });
- });
- }
- if (uploadInfo?.uploadedImage) {
- blocks.push({
- image: {
- src: uploadInfo.uploadedImage.url,
- height: uploadInfo.uploadedImage.height,
- width: uploadInfo.uploadedImage.width,
- alt: 'image',
- },
- });
- }
+ if (image && image.url && image.url.length > 0) {
+ metadata['image'] = image.url;
+ }
- if (blocks && blocks.length > 0) {
- story.push(...blocks.map((block) => ({ block })));
- }
+ await send(story, channelId, metadata);
+ }
- if (isEdit && editingPost) {
- await editPost?.(editingPost, story);
- setEditingPost?.(undefined);
- } else {
- await send(story, channelId);
+ editor.setContent('');
+ setReferences({});
+ clearDraft();
+ setShowBigInput?.(false);
+ },
+ [
+ editor,
+ send,
+ channelId,
+ uploadInfo,
+ references,
+ setReferences,
+ clearDraft,
+ editPost,
+ editingPost,
+ setEditingPost,
+ setShowBigInput,
+ title,
+ image,
+ ]
+ );
+
+ const handleSend = useCallback(async () => {
+ Keyboard.dismiss();
+ await sendMessage();
+ }, [sendMessage]);
+
+ const handleEdit = useCallback(async () => {
+ Keyboard.dismiss();
+ if (!editingPost) {
+ return;
}
- editor.setContent('');
- setReferences({});
- clearDraft();
- setShowGalleryInput?.(false);
- },
- [
- editor,
- send,
- channelId,
- uploadInfo,
- references,
- setReferences,
- clearDraft,
- editPost,
- editingPost,
- setEditingPost,
- setShowGalleryInput,
- ]
- );
-
- const handleSend = useCallback(async () => {
- Keyboard.dismiss();
- await sendMessage();
- }, [sendMessage]);
-
- const handleEdit = useCallback(async () => {
- Keyboard.dismiss();
- if (!editingPost) {
- return;
- }
+ await sendMessage(true);
+ }, [sendMessage, editingPost]);
- await sendMessage(true);
- }, [sendMessage, editingPost]);
-
- const handleAddNewLine = useCallback(() => {
- editor.splitBlock();
- }, [editor]);
+ const handleAddNewLine = useCallback(() => {
+ if (editorState.isCodeBlockActive) {
+ editor.newLineInCode();
+ return;
+ }
- const handleMessage = useCallback(
- async (event: WebViewMessageEvent) => {
- const { data } = event.nativeEvent;
- if (data === 'enter') {
- handleAddNewLine();
+ if (editorState.isBulletListActive || editorState.isOrderedListActive) {
+ editor.splitListItem('listItem');
return;
}
- if (data === 'shift-enter') {
- handleAddNewLine();
+ if (editorState.isTaskListActive) {
+ editor.splitListItem('taskItem');
return;
}
- const { type, payload } = JSON.parse(data) as MessageEditorMessage;
+ editor.splitBlock();
+ }, [editor, editorState]);
- if (type === 'editor-ready') {
- webviewRef.current?.injectJavaScript(
- `
+ const handleMessage = useCallback(
+ async (event: WebViewMessageEvent) => {
+ const { data } = event.nativeEvent;
+ if (data === 'enter') {
+ handleAddNewLine();
+ return;
+ }
+
+ if (data === 'shift-enter') {
+ handleAddNewLine();
+ return;
+ }
+
+ const { type, payload } = JSON.parse(data) as MessageEditorMessage;
+
+ if (type === 'editor-ready') {
+ webviewRef.current?.injectJavaScript(
+ `
function updateContentHeight() {
const editorElement = document.querySelector('#root div .ProseMirror');
editorElement.style.height = 'auto';
@@ -465,61 +562,79 @@ export function MessageInput({
setupMutationObserver();
`
- );
- }
-
- if (type === 'contentHeight') {
- setContainerHeight(payload);
- return;
- }
+ );
+ }
- if (type === 'paste') {
- handlePaste(payload);
- return;
- }
+ if (type === 'contentHeight') {
+ setContainerHeight(payload);
+ return;
+ }
- editor.bridgeExtensions?.forEach((e) => {
- e.onEditorMessage && e.onEditorMessage({ type, payload }, editor);
- });
- },
- [editor, handleAddNewLine, handlePaste]
- );
+ if (type === 'paste') {
+ handlePaste(payload);
+ return;
+ }
- const tentapInjectedJs = useMemo(
- () => getInjectedJS(editor.bridgeExtensions || []),
- [editor.bridgeExtensions]
- );
+ editor.bridgeExtensions?.forEach((e) => {
+ e.onEditorMessage && e.onEditorMessage({ type, payload }, editor);
+ });
+ },
+ [editor, handleAddNewLine, handlePaste]
+ );
+
+ const tentapInjectedJs = useMemo(
+ () => getInjectedJS(editor.bridgeExtensions || []),
+ [editor.bridgeExtensions]
+ );
+
+ useEffect(() => {
+ if (bigInput) {
+ Keyboard.addListener('keyboardDidShow', () => {
+ // we should always have the keyboard height here but just in case
+ const keyboardHeight = Keyboard.metrics()?.height || 300;
+ setBigInputHeight(bigInputHeightBasic - keyboardHeight);
+ });
- return (
- setEditingPost?.(undefined)}
- editorIsEmpty={editorIsEmpty}
- showAttachmentButton={showAttachmentButton}
- floatingActionButton={floatingActionButton}
- >
- {
+ setBigInputHeight(bigInputHeightBasic);
+ });
+ }
+ }, [bigInput, bigInputHeightBasic]);
+
+ const titleIsEmpty = useMemo(() => !title || title.length === 0, [title]);
+
+ return (
+ setEditingPost?.(undefined)}
+ showAttachmentButton={showAttachmentButton}
+ floatingActionButton={floatingActionButton}
+ disableSend={
+ editorIsEmpty || (channelType === 'notebook' && titleIsEmpty)
+ }
>
-
+ {
@@ -543,8 +658,11 @@ export function MessageInput({
}
});
`}
- />
-
-
- );
-}
+ />
+
+
+ );
+ }
+);
+
+MessageInput.displayName = 'MessageInput';
diff --git a/packages/ui/src/components/MessageInput/index.tsx b/packages/ui/src/components/MessageInput/index.tsx
index cea22095ea..0d50b83659 100644
--- a/packages/ui/src/components/MessageInput/index.tsx
+++ b/packages/ui/src/components/MessageInput/index.tsx
@@ -19,7 +19,6 @@ export function MessageInput({
containerHeight={0}
groupMembers={groupMembers}
onSelectMention={() => {}}
- editorIsEmpty={true}
onPressSend={() => {}}
>