mirror of
https://github.com/luminous-devs/lume.git
synced 2024-09-19 19:46:34 +00:00
wip: tiptap editor
This commit is contained in:
parent
8f4cf7e948
commit
64cd17389d
@ -25,6 +25,12 @@
|
|||||||
"@tanstack/react-query-devtools": "^4.29.25",
|
"@tanstack/react-query-devtools": "^4.29.25",
|
||||||
"@tanstack/react-virtual": "3.0.0-beta.54",
|
"@tanstack/react-virtual": "3.0.0-beta.54",
|
||||||
"@tauri-apps/api": "^1.4.0",
|
"@tauri-apps/api": "^1.4.0",
|
||||||
|
"@tiptap/extension-mention": "^2.0.4",
|
||||||
|
"@tiptap/extension-placeholder": "^2.0.4",
|
||||||
|
"@tiptap/pm": "^2.0.4",
|
||||||
|
"@tiptap/react": "^2.0.4",
|
||||||
|
"@tiptap/starter-kit": "^2.0.4",
|
||||||
|
"@tiptap/suggestion": "^2.0.4",
|
||||||
"cheerio": "1.0.0-rc.12",
|
"cheerio": "1.0.0-rc.12",
|
||||||
"dayjs": "^1.11.9",
|
"dayjs": "^1.11.9",
|
||||||
"destr": "^1.2.2",
|
"destr": "^1.2.2",
|
||||||
@ -48,6 +54,7 @@
|
|||||||
"tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart#v1",
|
"tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart#v1",
|
||||||
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
|
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
|
||||||
"tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1",
|
"tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1",
|
||||||
|
"tippy.js": "^6.3.7",
|
||||||
"zustand": "^4.3.9"
|
"zustand": "^4.3.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
893
pnpm-lock.yaml
893
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -18,6 +18,14 @@ button {
|
|||||||
@apply prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:mb-2 prose-p:mt-0 prose-p:last:mb-0 prose-a:break-all prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-400 hover:prose-a:text-fuchsia-500 prose-blockquote:m-0 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-hr:mx-0 prose-hr:my-2;
|
@apply prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:mb-2 prose-p:mt-0 prose-p:last:mb-0 prose-a:break-all prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-400 hover:prose-a:text-fuchsia-500 prose-blockquote:m-0 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-hr:mx-0 prose-hr:my-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ProseMirror p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
@apply text-zinc-400;
|
||||||
|
}
|
||||||
|
|
||||||
/* For Webkit-based browsers (Chrome, Safari and Opera) */
|
/* For Webkit-based browsers (Chrome, Safari and Opera) */
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
|
99
src/shared/composer/composer.tsx
Normal file
99
src/shared/composer/composer.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import Mention from '@tiptap/extension-mention';
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
|
import { EditorContent, useEditor } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
import { Button } from '@shared/button';
|
||||||
|
import { Suggestion } from '@shared/composer';
|
||||||
|
import { CancelIcon } from '@shared/icons';
|
||||||
|
import { MentionNote } from '@shared/notes';
|
||||||
|
|
||||||
|
import { useComposer } from '@stores/composer';
|
||||||
|
import { FULL_RELAYS } from '@stores/constants';
|
||||||
|
|
||||||
|
import { usePublish } from '@utils/hooks/usePublish';
|
||||||
|
|
||||||
|
export function Composer() {
|
||||||
|
const [reply, clearReply, toggle] = useComposer((state) => [
|
||||||
|
state.reply,
|
||||||
|
state.clearReply,
|
||||||
|
state.toggleModal,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const publish = usePublish();
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Placeholder.configure({ placeholder: "What's on your mind?" }),
|
||||||
|
Mention.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'mention',
|
||||||
|
},
|
||||||
|
suggestion: Suggestion,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: '',
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: twMerge(
|
||||||
|
'markdown max-h-[500px] overflow-y-auto outline-none',
|
||||||
|
`${reply.id ? '!min-h-42' : '!min-h-[86px]'}`
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
let tags: string[][] = [];
|
||||||
|
|
||||||
|
if (reply.id && reply.pubkey) {
|
||||||
|
tags = [
|
||||||
|
['e', reply.id, FULL_RELAYS[0], 'reply'],
|
||||||
|
['p', reply.pubkey],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
tags = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// serialize content
|
||||||
|
const serializedContent = editor.getText();
|
||||||
|
|
||||||
|
// publish message
|
||||||
|
// await publish({ content: serializedContent, kind: 1, tags });
|
||||||
|
|
||||||
|
// close modal
|
||||||
|
toggle(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col px-4 pb-4">
|
||||||
|
<div className="flex h-full w-full gap-2">
|
||||||
|
<div className="flex w-8 shrink-0 items-center justify-center">
|
||||||
|
<div className="h-full w-[2px] bg-zinc-800" />
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
{reply.id && (
|
||||||
|
<div className="relative">
|
||||||
|
<MentionNote id={reply.id} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => clearReply()}
|
||||||
|
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center gap-2 rounded bg-zinc-800 px-2 hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
<CancelIcon className="h-4 w-4 text-zinc-100" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div />
|
||||||
|
<Button onClick={() => submit()} preset="publish">
|
||||||
|
Publish
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,134 +0,0 @@
|
|||||||
import { open } from '@tauri-apps/api/dialog';
|
|
||||||
import { listen } from '@tauri-apps/api/event';
|
|
||||||
import { Body, fetch } from '@tauri-apps/api/http';
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { Transforms } from 'slate';
|
|
||||||
import { useSlateStatic } from 'slate-react';
|
|
||||||
|
|
||||||
import { PlusCircleIcon } from '@shared/icons';
|
|
||||||
|
|
||||||
import { createBlobFromFile } from '@utils/createBlobFromFile';
|
|
||||||
|
|
||||||
export function ImageUploader() {
|
|
||||||
const editor = useSlateStatic();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const insertImage = (editor, url) => {
|
|
||||||
const image = { type: 'image', url, children: [{ text: url }] };
|
|
||||||
Transforms.insertNodes(editor, image);
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadToVoidCat = useCallback(
|
|
||||||
async (filepath) => {
|
|
||||||
const filename = filepath.split('/').pop();
|
|
||||||
const file = await createBlobFromFile(filepath);
|
|
||||||
const buf = await file.arrayBuffer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res: { data: { file: { id: string } } } = await fetch(
|
|
||||||
'https://void.cat/upload?cli=false',
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
timeout: 5,
|
|
||||||
headers: {
|
|
||||||
accept: '*/*',
|
|
||||||
'Content-Type': 'application/octet-stream',
|
|
||||||
'V-Filename': filename,
|
|
||||||
'V-Description': 'Uploaded from https://lume.nu',
|
|
||||||
'V-Strip-Metadata': 'true',
|
|
||||||
},
|
|
||||||
body: Body.bytes(buf),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const image = `https://void.cat/d/${res.data.file.id}.webp`;
|
|
||||||
// update parent state
|
|
||||||
insertImage(editor, image);
|
|
||||||
// reset loading state
|
|
||||||
setLoading(false);
|
|
||||||
} catch (error) {
|
|
||||||
// reset loading state
|
|
||||||
setLoading(false);
|
|
||||||
// handle error
|
|
||||||
if (error instanceof SyntaxError) {
|
|
||||||
// Unexpected token < in JSON
|
|
||||||
console.log('There was a SyntaxError', error);
|
|
||||||
} else {
|
|
||||||
console.log('There was an error', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[editor]
|
|
||||||
);
|
|
||||||
|
|
||||||
const openFileDialog = async () => {
|
|
||||||
const selected: any = await open({
|
|
||||||
multiple: false,
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: 'Image',
|
|
||||||
extensions: ['png', 'jpeg', 'jpg', 'gif'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (Array.isArray(selected)) {
|
|
||||||
// user selected multiple files
|
|
||||||
} else if (selected === null) {
|
|
||||||
// user cancelled the selection
|
|
||||||
} else {
|
|
||||||
setLoading(true);
|
|
||||||
// upload file
|
|
||||||
uploadToVoidCat(selected);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function initFileDrop() {
|
|
||||||
const unlisten = await listen('tauri://file-drop', (event) => {
|
|
||||||
// set loading state
|
|
||||||
setLoading(true);
|
|
||||||
// upload file
|
|
||||||
uploadToVoidCat(event.payload[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlisten();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
initFileDrop();
|
|
||||||
}, [uploadToVoidCat]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openFileDialog()}
|
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 animate-spin text-black dark:text-zinc-100"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<title id="loading">Loading</title>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<PlusCircleIcon width={20} height={20} className="text-zinc-500" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
5
src/shared/composer/index.tsx
Normal file
5
src/shared/composer/index.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './user';
|
||||||
|
export * from './modal';
|
||||||
|
export * from './composer';
|
||||||
|
export * from './mention/list';
|
||||||
|
export * from './mention/suggestion';
|
68
src/shared/composer/mention/list.tsx
Normal file
68
src/shared/composer/mention/list.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
||||||
|
|
||||||
|
export const MentionList = forwardRef((props: any, ref: any) => {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
const selectItem = (index) => {
|
||||||
|
const item = props.items[index];
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
props.command({ id: item });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const upHandler = () => {
|
||||||
|
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downHandler = () => {
|
||||||
|
setSelectedIndex((selectedIndex + 1) % props.items.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const enterHandler = () => {
|
||||||
|
selectItem(selectedIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => setSelectedIndex(0), [props.items]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
onKeyDown: ({ event }) => {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
upHandler();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
downHandler();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
enterHandler();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="items">
|
||||||
|
{props.items.length ? (
|
||||||
|
props.items.map((item, index) => (
|
||||||
|
<button
|
||||||
|
className={`item ${index === selectedIndex ? 'is-selected' : ''}`}
|
||||||
|
key={index}
|
||||||
|
onClick={() => selectItem(index)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="item">No result</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
MentionList.displayName = 'MentionList';
|
93
src/shared/composer/mention/suggestion.tsx
Normal file
93
src/shared/composer/mention/suggestion.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { ReactRenderer } from '@tiptap/react';
|
||||||
|
import tippy from 'tippy.js';
|
||||||
|
|
||||||
|
import { MentionList } from '@shared/composer';
|
||||||
|
|
||||||
|
export const Suggestion = {
|
||||||
|
items: ({ query }) => {
|
||||||
|
return [
|
||||||
|
'Lea Thompson',
|
||||||
|
'Cyndi Lauper',
|
||||||
|
'Tom Cruise',
|
||||||
|
'Madonna',
|
||||||
|
'Jerry Hall',
|
||||||
|
'Joan Collins',
|
||||||
|
'Winona Ryder',
|
||||||
|
'Christina Applegate',
|
||||||
|
'Alyssa Milano',
|
||||||
|
'Molly Ringwald',
|
||||||
|
'Ally Sheedy',
|
||||||
|
'Debbie Harry',
|
||||||
|
'Olivia Newton-John',
|
||||||
|
'Elton John',
|
||||||
|
'Michael J. Fox',
|
||||||
|
'Axl Rose',
|
||||||
|
'Emilio Estevez',
|
||||||
|
'Ralph Macchio',
|
||||||
|
'Rob Lowe',
|
||||||
|
'Jennifer Grey',
|
||||||
|
'Mickey Rourke',
|
||||||
|
'John Cusack',
|
||||||
|
'Matthew Broderick',
|
||||||
|
'Justine Bateman',
|
||||||
|
'Lisa Bonet',
|
||||||
|
]
|
||||||
|
.filter((item) => item.toLowerCase().startsWith(query.toLowerCase()))
|
||||||
|
.slice(0, 5);
|
||||||
|
},
|
||||||
|
|
||||||
|
render: () => {
|
||||||
|
let component;
|
||||||
|
let popup;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props) => {
|
||||||
|
component = new ReactRenderer(MentionList, {
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!props.clientRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
popup = tippy('body', {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: component.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: 'manual',
|
||||||
|
placement: 'bottom-start',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdate(props) {
|
||||||
|
component.updateProps(props);
|
||||||
|
|
||||||
|
if (!props.clientRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
popup[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeyDown(props) {
|
||||||
|
if (props.event.key === 'Escape') {
|
||||||
|
popup[0].hide();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return component.ref?.onKeyDown(props);
|
||||||
|
},
|
||||||
|
|
||||||
|
onExit() {
|
||||||
|
popup[0].destroy();
|
||||||
|
component.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
@ -3,8 +3,7 @@ import { Fragment } from 'react';
|
|||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
import { Button } from '@shared/button';
|
import { Button } from '@shared/button';
|
||||||
import { Post } from '@shared/composer/types/post';
|
import { Composer, ComposerUser } from '@shared/composer';
|
||||||
import { User } from '@shared/composer/user';
|
|
||||||
import {
|
import {
|
||||||
CancelIcon,
|
CancelIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
@ -17,9 +16,8 @@ import { COMPOSE_SHORTCUT } from '@stores/shortcuts';
|
|||||||
|
|
||||||
import { useAccount } from '@utils/hooks/useAccount';
|
import { useAccount } from '@utils/hooks/useAccount';
|
||||||
|
|
||||||
export function Composer() {
|
export function ComposerModal() {
|
||||||
const { account } = useAccount();
|
const { account } = useAccount();
|
||||||
|
|
||||||
const [toggle, open] = useComposer((state) => [state.toggleModal, state.open]);
|
const [toggle, open] = useComposer((state) => [state.toggleModal, state.open]);
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
@ -60,7 +58,7 @@ export function Composer() {
|
|||||||
<Dialog.Panel className="relative h-min w-full max-w-xl rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
<Dialog.Panel className="relative h-min w-full max-w-xl rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
||||||
<div className="flex items-center justify-between px-4 py-4">
|
<div className="flex items-center justify-between px-4 py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div>{account && <User pubkey={account.pubkey} />}</div>
|
{account && <ComposerUser pubkey={account.pubkey} />}
|
||||||
<span>
|
<span>
|
||||||
<ChevronRightIcon
|
<ChevronRightIcon
|
||||||
width={14}
|
width={14}
|
||||||
@ -83,7 +81,7 @@ export function Composer() {
|
|||||||
<CancelIcon width={16} height={16} className="text-zinc-500" />
|
<CancelIcon width={16} height={16} className="text-zinc-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{account && <Post />}
|
<Composer />
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,162 +0,0 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
import { Node, Transforms, createEditor } from 'slate';
|
|
||||||
import { withHistory } from 'slate-history';
|
|
||||||
import { Editable, ReactEditor, Slate, useSlateStatic, withReact } from 'slate-react';
|
|
||||||
|
|
||||||
import { Button } from '@shared/button';
|
|
||||||
import { ImageUploader } from '@shared/composer/imageUploader';
|
|
||||||
import { CancelIcon, TrashIcon } from '@shared/icons';
|
|
||||||
import { MentionNote } from '@shared/notes/mentions/note';
|
|
||||||
|
|
||||||
import { useComposer } from '@stores/composer';
|
|
||||||
import { FULL_RELAYS } from '@stores/constants';
|
|
||||||
|
|
||||||
import { usePublish } from '@utils/hooks/usePublish';
|
|
||||||
|
|
||||||
const withImages = (editor) => {
|
|
||||||
const { isVoid } = editor;
|
|
||||||
|
|
||||||
editor.isVoid = (element) => {
|
|
||||||
return element.type === 'image' ? true : isVoid(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
return editor;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ImagePreview = ({
|
|
||||||
attributes,
|
|
||||||
children,
|
|
||||||
element,
|
|
||||||
}: {
|
|
||||||
attributes: any;
|
|
||||||
children: any;
|
|
||||||
element: any;
|
|
||||||
}) => {
|
|
||||||
const editor: any = useSlateStatic();
|
|
||||||
const path = ReactEditor.findPath(editor, element);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<figure {...attributes} className="m-0 mt-3">
|
|
||||||
{children}
|
|
||||||
<div contentEditable={false} className="relative">
|
|
||||||
<img
|
|
||||||
alt={element.url}
|
|
||||||
src={element.url}
|
|
||||||
className="m-0 h-auto max-h-[300px] w-full rounded-md object-cover"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
|
||||||
className="shadow-mini-button absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center gap-0.5 rounded bg-zinc-800 text-base font-medium text-zinc-400 hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
<TrashIcon width={14} height={14} className="text-zinc-100" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</figure>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Post() {
|
|
||||||
const publish = usePublish();
|
|
||||||
const editor = useMemo(() => withReact(withImages(withHistory(createEditor()))), []);
|
|
||||||
|
|
||||||
const [reply, clearReply, toggle] = useComposer((state) => [
|
|
||||||
state.reply,
|
|
||||||
state.clearReply,
|
|
||||||
state.toggleModal,
|
|
||||||
]);
|
|
||||||
const [content, setContent] = useState<Node[]>([
|
|
||||||
{
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
text: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const serialize = useCallback((nodes: Node[]) => {
|
|
||||||
return nodes.map((n) => Node.string(n)).join('\n');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeReply = () => {
|
|
||||||
clearReply();
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
let tags: string[][] = [];
|
|
||||||
|
|
||||||
if (reply.id && reply.pubkey) {
|
|
||||||
tags = [
|
|
||||||
['e', reply.id, FULL_RELAYS[0], 'reply'],
|
|
||||||
['p', reply.pubkey],
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
tags = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// serialize content
|
|
||||||
const serializedContent = serialize(content);
|
|
||||||
|
|
||||||
// publish message
|
|
||||||
await publish({ content: serializedContent, kind: 1, tags });
|
|
||||||
|
|
||||||
// close modal
|
|
||||||
toggle(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderElement = useCallback((props) => {
|
|
||||||
switch (props.element.type) {
|
|
||||||
case 'image':
|
|
||||||
if (props.element.url) {
|
|
||||||
return <ImagePreview {...props} />;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return <p {...props.attributes}>{props.children}</p>;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Slate editor={editor} value={content} onChange={setContent}>
|
|
||||||
<div className="flex h-full flex-col px-4 pb-4">
|
|
||||||
<div className="flex h-full w-full gap-2">
|
|
||||||
<div className="flex w-8 shrink-0 items-center justify-center">
|
|
||||||
<div className="h-full w-[2px] bg-zinc-800" />
|
|
||||||
</div>
|
|
||||||
<div className="w-full">
|
|
||||||
<Editable
|
|
||||||
placeholder={
|
|
||||||
reply.id ? 'Share your thoughts on it' : "What's on your mind?"
|
|
||||||
}
|
|
||||||
spellCheck="false"
|
|
||||||
className={`${
|
|
||||||
reply.id ? '!min-h-42' : '!min-h-[86px]'
|
|
||||||
} markdown max-h-[500px] overflow-y-auto`}
|
|
||||||
renderElement={renderElement}
|
|
||||||
/>
|
|
||||||
{reply.id && (
|
|
||||||
<div className="relative">
|
|
||||||
<MentionNote id={reply.id} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeReply()}
|
|
||||||
className="absolute right-3 top-3 inline-flex h-6 w-max items-center justify-center gap-2 rounded bg-zinc-800 px-2 hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
<CancelIcon className="h-4 w-4 text-zinc-100" />
|
|
||||||
<span className="text-sm">Stop reply</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
|
||||||
<ImageUploader />
|
|
||||||
<Button onClick={() => submit()} preset="publish">
|
|
||||||
Publish
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Slate>
|
|
||||||
);
|
|
||||||
}
|
|
@ -4,7 +4,7 @@ import { DEFAULT_AVATAR } from '@stores/constants';
|
|||||||
|
|
||||||
import { useProfile } from '@utils/hooks/useProfile';
|
import { useProfile } from '@utils/hooks/useProfile';
|
||||||
|
|
||||||
export function User({ pubkey }: { pubkey: string }) {
|
export function ComposerUser({ pubkey }: { pubkey: string }) {
|
||||||
const { user } = useProfile(pubkey);
|
const { user } = useProfile(pubkey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -5,7 +5,7 @@ import { twMerge } from 'tailwind-merge';
|
|||||||
import { ChatsList } from '@app/chat/components/list';
|
import { ChatsList } from '@app/chat/components/list';
|
||||||
|
|
||||||
import { AppHeader } from '@shared/appHeader';
|
import { AppHeader } from '@shared/appHeader';
|
||||||
import { Composer } from '@shared/composer/modal';
|
import { ComposerModal } from '@shared/composer/modal';
|
||||||
import { NavArrowDownIcon, SpaceIcon, TrendingIcon } from '@shared/icons';
|
import { NavArrowDownIcon, SpaceIcon, TrendingIcon } from '@shared/icons';
|
||||||
import { LumeBar } from '@shared/lumeBar';
|
import { LumeBar } from '@shared/lumeBar';
|
||||||
|
|
||||||
@ -15,7 +15,7 @@ export function Navigation() {
|
|||||||
<AppHeader />
|
<AppHeader />
|
||||||
<div className="scrollbar-hide flex flex-col gap-5 overflow-y-auto pb-20">
|
<div className="scrollbar-hide flex flex-col gap-5 overflow-y-auto pb-20">
|
||||||
<div className="inlin-lflex h-8 px-3.5">
|
<div className="inlin-lflex h-8 px-3.5">
|
||||||
<Composer />
|
<ComposerModal />
|
||||||
</div>
|
</div>
|
||||||
{/* Newsfeed */}
|
{/* Newsfeed */}
|
||||||
<div className="flex flex-col gap-0.5 px-1.5">
|
<div className="flex flex-col gap-0.5 px-1.5">
|
||||||
|
Loading…
Reference in New Issue
Block a user