diff options
| author | polwex <polwex@sortug.com> | 2025-10-06 10:13:39 +0700 |
|---|---|---|
| committer | polwex <polwex@sortug.com> | 2025-10-06 10:13:39 +0700 |
| commit | 8751ba26ebf7b7761b9e237f2bf3453623dd1018 (patch) | |
| tree | dc37f12b3fd9b1a1e7a1b54a51c80697f37a04e8 /gui/src/components/composer | |
| parent | 6704650dcfccf609ccc203308df9004e0b511bb6 (diff) | |
added frontend WS connection for demonstration purposes
Diffstat (limited to 'gui/src/components/composer')
| -rw-r--r-- | gui/src/components/composer/Composer.tsx | 205 | ||||
| -rw-r--r-- | gui/src/components/composer/Snippets.tsx | 86 |
2 files changed, 291 insertions, 0 deletions
diff --git a/gui/src/components/composer/Composer.tsx b/gui/src/components/composer/Composer.tsx new file mode 100644 index 0000000..81d0358 --- /dev/null +++ b/gui/src/components/composer/Composer.tsx @@ -0,0 +1,205 @@ +import useLocalState from "@/state/state"; +import type { Poast } from "@/types/trill"; +import Sigil from "@/components/Sigil"; +import { useState, useEffect, useRef, type FormEvent } from "react"; +import Snippets, { ReplySnippet } from "./Snippets"; +import toast from "react-hot-toast"; +import Icon from "@/components/Icon"; +import { wait } from "@/logic/utils"; + +function Composer({ isAnon }: { isAnon?: boolean }) { + const { api, composerData, addNotification, setComposerData } = useLocalState( + (s) => ({ + api: s.api, + composerData: s.composerData, + addNotification: s.addNotification, + setComposerData: s.setComposerData, + }), + ); + const our = api!.airlock.our!; + const [input, setInput] = useState(""); + const [isExpanded, setIsExpanded] = useState(false); + const [isLoading, setLoading] = useState(false); + const inputRef = useRef<HTMLInputElement>(null); + + useEffect(() => { + if (composerData) { + setIsExpanded(true); + if ( + composerData.type === "reply" && + composerData.post && + "trill" in composerData.post + ) { + const author = composerData.post.trill.author; + setInput(`${author} `); + } + // Auto-focus input when composer opens + setTimeout(() => { + inputRef.current?.focus(); + }, 100); // Small delay to ensure the composer is rendered + } + }, [composerData]); + async function poast(e: FormEvent<HTMLFormElement>) { + e.preventDefault(); + // TODO + // const parent = replying ? replying : null; + // const tokens = tokenize(input); + // const post: SentPoast = { + // host: parent ? parent.host : our, + // author: our, + // thread: parent ? parent.thread : null, + // parent: parent ? parent.id : null, + // contents: input, + // read: openLock, + // write: openLock, + // tags: input.match(HASHTAGS_REGEX) || [], + // }; + // TODO make it user choosable + setLoading(true); + + const res = + composerData?.type === "reply" && "trill" in composerData.post + ? api!.addReply( + input, + composerData.post.trill.host, + composerData.post.trill.id, + composerData.post.trill.thread || composerData.post.trill.id, + ) + : composerData?.type === "quote" && "trill" in composerData.post + ? api!.addQuote(input, { + ship: composerData.post.trill.host, + id: composerData.post.trill.id, + }) + : !composerData + ? api!.addPost(input) + : wait(500); + const ares = await res; + if (ares) { + // // Check for mentions in the post (ship names starting with ~) + const mentions = input.match(/~[a-z-]+/g); + if (mentions) { + mentions.forEach((mention) => { + if (mention !== our) { + // Don't notify self-mentions + addNotification({ + type: "mention", + from: our, + message: `You mentioned ${mention} in a post`, + }); + } + }); + } + + // If this is a reply, add notification + if ( + composerData?.type === "reply" && + composerData.post && + "trill" in composerData.post + ) { + if (composerData.post.trill.author !== our) { + addNotification({ + type: "reply", + from: our, + message: `You replied to ${composerData.post.trill.author}'s post`, + postId: composerData.post.trill.id, + }); + } + } + + setInput(""); + setComposerData(null); // Clear composer data after successful post + toast.success("post sent"); + setIsExpanded(false); + } + } + const placeHolder = + composerData?.type === "reply" + ? "Write your reply..." + : composerData?.type === "quote" + ? "Add your thoughts..." + : isAnon + ? "> be me" + : "What's going on in Urbit"; + + const clearComposer = (e: React.MouseEvent) => { + e.preventDefault(); + setComposerData(null); + setInput(""); + setIsExpanded(false); + }; + + return ( + <form + id="composer" + className={`${isExpanded ? "expanded" : ""} ${composerData ? "has-context" : ""}`} + onSubmit={poast} + > + <div className="sigil avatar"> + <Sigil patp={our} size={46} /> + </div> + + <div className="composer-content"> + {/* Reply snippets appear above input */} + {composerData && composerData.type === "reply" && ( + <div className="composer-context reply-context"> + <div className="context-header"> + <span className="context-type"> + <Icon name="reply" size={14} /> Replying to + </span> + <button + className="clear-context" + onClick={clearComposer} + title="Clear" + type="button" + > + × + </button> + </div> + <ReplySnippet post={composerData.post} /> + </div> + )} + + {/* Quote context header above input (without snippet) */} + {composerData && composerData.type === "quote" && ( + <div className="quote-header"> + <div className="context-header"> + <span className="context-type"> + <Icon name="quote" size={14} /> Quote posting + </span> + <button + className="clear-context" + onClick={clearComposer} + title="Clear" + type="button" + > + × + </button> + </div> + </div> + )} + + <div className="composer-input-row"> + <input + ref={inputRef} + value={input} + onInput={(e) => setInput(e.currentTarget.value)} + onFocus={() => setIsExpanded(true)} + placeholder={placeHolder} + /> + <button type="submit" disabled={!input.trim()} className="post-btn"> + Post + </button> + </div> + + {/* Quote snippets appear below input */} + {composerData && composerData.type === "quote" && ( + <div className="composer-context quote-context"> + <Snippets post={composerData.post} /> + </div> + )} + </div> + </form> + ); +} + +export default Composer; diff --git a/gui/src/components/composer/Snippets.tsx b/gui/src/components/composer/Snippets.tsx new file mode 100644 index 0000000..49d9b88 --- /dev/null +++ b/gui/src/components/composer/Snippets.tsx @@ -0,0 +1,86 @@ +import Quote from "@/components/post/Quote"; +import type { SPID } from "@/types/ui"; +import { NostrSnippet } from "../post/wrappers/Nostr"; + +export default Snippets; +function Snippets({ post }: { post: SPID }) { + return ( + <ComposerSnippet> + <PostSnippet post={post} /> + </ComposerSnippet> + ); +} + +export function ComposerSnippet({ + onClick, + children, +}: { + onClick?: any; + children: any; +}) { + function onc(e: React.MouseEvent) { + e.stopPropagation(); + if (onClick) onClick(); + } + return ( + <div className="composer-snippet"> + {onClick && ( + <div className="pop-snippet-icon cp" role="link" onClick={onc}> + × + </div> + )} + {children} + </div> + ); +} +function PostSnippet({ post }: { post: SPID }) { + if (!post) return <div className="snippet-error">No post data</div>; + + try { + if ("trill" in post) return <Quote data={post.trill} nest={0} />; + else if ("nostr" in post) return <NostrSnippet {...post.nostr} />; + // else if ("twatter" in post) + // return ( + // <div id={`composer-${type}`}> + // <Tweet tweet={post.post} quote={true} /> + // </div> + // ); + // else if ("rumors" in post) + // return ( + // <div id={`composer-${type}`}> + // <div className="rumor-quote f1"> + // <img src={rumorIcon} alt="" /> + // <Body poast={post.post} refetch={() => {}} /> + // <span>{date_diff(post.post.time, "short")}</span> + // </div> + // </div> + // ); + else return <div className="snippet-error">Unsupported post type</div>; + } catch (error) { + console.error("Error rendering post snippet:", error); + return <div className="snippet-error">Failed to load post</div>; + } +} + +export function ReplySnippet({ post }: { post: SPID }) { + if (!post) return <div className="snippet-error">No post to reply to</div>; + + try { + if ("trill" in post) + return ( + <div id="reply" className="reply-snippet"> + <Quote data={post.trill} nest={0} /> + </div> + ); + else if ("nostr" in post) + return ( + <div id="reply" className="reply-snippet"> + <NostrSnippet {...post.nostr} /> + </div> + ); + else return <div className="snippet-error">Cannot reply to this post type</div>; + } catch (error) { + console.error("Error rendering reply snippet:", error); + return <div className="snippet-error">Failed to load reply context</div>; + } +} |
