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={() => {}} >