diff options
Diffstat (limited to 'front')
-rw-r--r-- | front/src/Router.tsx | 2 | ||||
-rw-r--r-- | front/src/components/composer/Composer.tsx | 198 | ||||
-rw-r--r-- | front/src/components/composer/Snippets.tsx | 82 | ||||
-rw-r--r-- | front/src/components/post/Body.tsx | 2 | ||||
-rw-r--r-- | front/src/components/post/Footer.tsx | 62 | ||||
-rw-r--r-- | front/src/components/post/Loader.tsx | 124 | ||||
-rw-r--r-- | front/src/components/post/Reactions.tsx | 4 | ||||
-rw-r--r-- | front/src/logic/requests/nostrill.ts | 62 | ||||
-rw-r--r-- | front/src/logic/trill/helpers.ts | 10 | ||||
-rw-r--r-- | front/src/pages/Thread.tsx | 127 | ||||
-rw-r--r-- | front/src/styles/styles.css | 412 | ||||
-rw-r--r-- | front/src/styles/trill.css | 11 |
12 files changed, 830 insertions, 266 deletions
diff --git a/front/src/Router.tsx b/front/src/Router.tsx index 1293709..ee3aa0d 100644 --- a/front/src/Router.tsx +++ b/front/src/Router.tsx @@ -3,6 +3,7 @@ import Sidebar from "@/components/layout/Sidebar"; // new import Feed from "@/pages/Feed"; import Settings from "@/pages/Settings"; +import Thread from "@/pages/Thread"; import { Switch, Router, Redirect, Route } from "wouter"; export default function r() { @@ -14,6 +15,7 @@ export default function r() { <Route path="/" component={toGlobal} /> <Route path="/sets" component={Settings} /> <Route path="/feed/:taip" component={Feed} /> + <Route path="/feed/:host/:id" component={Thread} /> </main> </Router> <Route component={P404} /> diff --git a/front/src/components/composer/Composer.tsx b/front/src/components/composer/Composer.tsx index 43d38cd..81d0358 100644 --- a/front/src/components/composer/Composer.tsx +++ b/front/src/components/composer/Composer.tsx @@ -1,28 +1,44 @@ import useLocalState from "@/state/state"; import type { Poast } from "@/types/trill"; import Sigil from "@/components/Sigil"; -import { useState, type FormEvent } from "react"; -import type { ComposerData } from "@/types/ui"; +import { useState, useEffect, useRef, type FormEvent } from "react"; import Snippets, { ReplySnippet } from "./Snippets"; import toast from "react-hot-toast"; -import { useLocation } from "wouter"; +import Icon from "@/components/Icon"; +import { wait } from "@/logic/utils"; -function Composer({ - isAnon, - replying, -}: { - isAnon?: boolean; - replying?: Poast; -}) { - const [loc, navigate] = useLocation(); - const { api, composerData, addNotification, setComposerData } = useLocalState((s) => ({ - api: s.api, - composerData: s.composerData, - addNotification: s.addNotification, - setComposerData: s.setComposerData, - })); +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(replying ? `${replying}: ` : ""); + 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 @@ -39,13 +55,32 @@ function Composer({ // tags: input.match(HASHTAGS_REGEX) || [], // }; // TODO make it user choosable - const res = await api!.addPost(input); - if (res) { - // Check for mentions in the post (ship names starting with ~) + 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 + mentions.forEach((mention) => { + if (mention !== our) { + // Don't notify self-mentions addNotification({ type: "mention", from: our, @@ -56,40 +91,113 @@ function Composer({ } // If this is a reply, add notification - if (composerData?.type === "reply" && 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, - }); + 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"); - navigate(`/feed/${our}`); + setIsExpanded(false); } } - const placeHolder = isAnon ? "> be me" : "What's going on in Urbit"; + 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" onSubmit={poast}> + <form + id="composer" + className={`${isExpanded ? "expanded" : ""} ${composerData ? "has-context" : ""}`} + onSubmit={poast} + > <div className="sigil avatar"> <Sigil patp={our} size={46} /> </div> - {composerData && composerData.type === "reply" && ( - <ReplySnippet post={composerData?.post} /> - )} - <input - value={input} - onInput={(e) => setInput(e.currentTarget.value)} - placeholder={placeHolder} - /> - {composerData && composerData.type === "quote" && ( - <Snippets post={composerData?.post} /> - )} - <button type="submit">Post</button> + <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> ); } diff --git a/front/src/components/composer/Snippets.tsx b/front/src/components/composer/Snippets.tsx index 30498d0..49d9b88 100644 --- a/front/src/components/composer/Snippets.tsx +++ b/front/src/components/composer/Snippets.tsx @@ -1,5 +1,5 @@ import Quote from "@/components/post/Quote"; -import type { ComposerData, SPID } from "@/types/ui"; +import type { SPID } from "@/types/ui"; import { NostrSnippet } from "../post/wrappers/Nostr"; export default Snippets; @@ -20,43 +20,67 @@ export function ComposerSnippet({ }) { function onc(e: React.MouseEvent) { e.stopPropagation(); - onClick(); + if (onClick) onClick(); } return ( <div className="composer-snippet"> - <div className="pop-snippet-icon cp" role="link" onClick={onc}></div> + {onClick && ( + <div className="pop-snippet-icon cp" role="link" onClick={onc}> + × + </div> + )} {children} </div> ); } function PostSnippet({ post }: { post: SPID }) { - 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 <></>; + 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 ("trill" in post) - return ( - <div id="reply"> - <Quote data={post.trill} nest={0} /> - </div> - ); - else return <div />; + 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>; + } } diff --git a/front/src/components/post/Body.tsx b/front/src/components/post/Body.tsx index e8b659c..b4f1bb2 100644 --- a/front/src/components/post/Body.tsx +++ b/front/src/components/post/Body.tsx @@ -161,7 +161,7 @@ function Heading({ string, num }: { string: string; num: number }) { } function Ref({ r, nest }: { r: Reference; nest: number }) { - if (r.ref.type === "nostril") { + if (r.ref.type === "trill") { const comp = PostData({ host: r.ref.ship, id: r.ref.path.slice(1), diff --git a/front/src/components/post/Footer.tsx b/front/src/components/post/Footer.tsx index d16f4fc..5b79da0 100644 --- a/front/src/components/post/Footer.tsx +++ b/front/src/components/post/Footer.tsx @@ -13,33 +13,33 @@ function Footer({ poast, refetch }: PostProps) { const [_showMenu, setShowMenu] = useState(false); const [location, navigate] = useLocation(); const [reposting, _setReposting] = useState(false); - const { api, setComposerData, setModal, addNotification } = useLocalState((s) => ({ - api: s.api, - setComposerData: s.setComposerData, - setModal: s.setModal, - addNotification: s.addNotification, - })); + const { api, setComposerData, setModal, addNotification } = useLocalState( + (s) => ({ + api: s.api, + setComposerData: s.setComposerData, + setModal: s.setModal, + addNotification: s.addNotification, + }), + ); const our = api!.airlock.our!; function doReply(e: React.MouseEvent) { + console.log("do reply"); e.stopPropagation(); + e.preventDefault(); setComposerData({ type: "reply", post: { trill: poast } }); - // Only add notification if replying to someone else's post - if (poast.author !== our) { - addNotification({ - type: "reply", - from: our, - message: `You replied to ${poast.author}'s post`, - postId: poast.id, - }); - } + // Scroll to top where composer is located + window.scrollTo({ top: 0, behavior: "smooth" }); + // Focus will be handled by the composer component } function doQuote(e: React.MouseEvent) { e.stopPropagation(); + e.preventDefault(); setComposerData({ type: "quote", post: { trill: poast }, }); - navigate("/composer"); + // Scroll to top where composer is located + window.scrollTo({ top: 0, behavior: "smooth" }); } const childrenCount = poast.children ? poast.children.length @@ -49,6 +49,7 @@ function Footer({ poast, refetch }: PostProps) { const myRP = poast.engagement.shared.find((r) => r.pid.ship === our); async function cancelRP(e: React.MouseEvent) { e.stopPropagation(); + e.preventDefault(); const r = await api!.deletePost(our); if (r) toast.success("Repost deleted"); refetch(); @@ -57,6 +58,7 @@ function Footer({ poast, refetch }: PostProps) { async function sendRP(e: React.MouseEvent) { // TODO update backend because contents are only markdown now e.stopPropagation(); + e.preventDefault(); // const c = [ // { // ref: { @@ -85,6 +87,7 @@ function Footer({ poast, refetch }: PostProps) { } function doReact(e: React.MouseEvent) { e.stopPropagation(); + e.preventDefault(); const modal = <TrillReactModal poast={poast} />; setModal(modal); } @@ -138,13 +141,17 @@ function Footer({ poast, refetch }: PostProps) { <span role="link" onMouseUp={showReplyCount} className="reply-count"> {displayCount(childrenCount)} </span> - <Icon name="reply" size={20} onClick={doReply} /> + <div className="icon-wrapper" role="link" onMouseUp={doReply}> + <Icon name="reply" size={20} /> + </div> </div> <div className="icon"> <span role="link" onMouseUp={showQuoteCount} className="quote-count"> {displayCount(poast.engagement.quoted.length)} </span> - <Icon name="quote" size={20} onClick={doQuote} /> + <div className="icon-wrapper" role="link" onMouseUp={doQuote}> + <Icon name="quote" size={20} /> + </div> </div> <div className="icon"> <span @@ -157,15 +164,18 @@ function Footer({ poast, refetch }: PostProps) { {reposting ? ( <p>...</p> ) : myRP ? ( - <Icon - name="repost" - size={20} - className="my-rp" - onClick={cancelRP} - title="cancel repost" - /> + <div className="icon-wrapper" role="link" onMouseUp={cancelRP}> + <Icon + name="repost" + size={20} + className="my-rp" + title="cancel repost" + /> + </div> ) : ( - <Icon name="repost" size={20} onClick={sendRP} title="repost" /> + <div className="icon-wrapper" role="link" onMouseUp={sendRP}> + <Icon name="repost" size={20} title="repost" /> + </div> )} </div> <div className="icon" role="link" onMouseUp={doReact}> diff --git a/front/src/components/post/Loader.tsx b/front/src/components/post/Loader.tsx index a23bea1..e45e01a 100644 --- a/front/src/components/post/Loader.tsx +++ b/front/src/components/post/Loader.tsx @@ -2,23 +2,30 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import spinner from "@/assets/triangles.svg"; import { useEffect, useRef, useState } from "react"; import useLocalState from "@/state/state"; -import type { PostID } from "@/types/trill"; +import type { FullNode, PostID } from "@/types/trill"; import type { Ship } from "@/types/urbit"; +import type { AsyncRes } from "@/types/ui"; +import { toFlat } from "@/logic/trill/helpers"; -function PostData(props: { +type Props = { host: Ship; id: PostID; + nest?: number; // nested quotes rter?: Ship; rtat?: number; rtid?: PostID; - nest?: number; // nested quotes className?: string; -}) { - const { api } = useLocalState((s) => ({ api: s.api })); +}; +function PostData(props: Props) { + const { api } = useLocalState((s) => ({ + api: s.api, + })); + const { host, id, nest } = props; - const [enest, setEnest] = useState(nest); + + const [enest, setEnest] = useState(nest || 0); useEffect(() => { - setEnest(nest); + if (nest) setEnest(nest); }, [nest]); return function (Component: React.ElementType) { @@ -39,61 +46,52 @@ function PostData(props: { dataRef.current = data; }, [data]); - async function fetchNode(): Promise<any> { - const res = await api!.scryPost(host, id, null, null); - if ("fpost" in res) return res; + async function fetchNode(): AsyncRes<FullNode> { + let error = ""; + const res = await api!.scryThread(host, id); + console.log("scry res", res); + if ("error" in res) error = res.error; + if ("ok" in res) return res; else { - const existing = queryClient.getQueryData(["trill-thread", host, id]); - const existingData = existing || data; - if ("bugen" in res) { - // we peek for the actual node - peekTheNode(); - // if we have a cache we don't invalidate it - if (existingData && "fpost" in existingData) return existingData; - // if we don't have a cache then we show the loading screen - else return res; - } - if ("no-node" in res) { - if (existingData && "fpost" in existingData) return existingData; - else return res; - } + const res2 = await api!.peekThread(host, id); + return res2; } } - function peekTheNode() { - let timer; - peekNode({ ship: host, id }); - timer = setTimeout(() => { - const gotPost = dataRef.current && "fpost" in dataRef.current; - setDead(!gotPost); - // clearTimeout(timer); - }, 10_000); + async function peekTheNode() { + // let timer; + // peekNode({ ship: host, id }); + // timer = setTimeout(() => { + // const gotPost = dataRef.current && "fpost" in dataRef.current; + // setDead(!gotPost); + // // clearTimeout(timer); + // }, 10_000); } - useEffect(() => { - const path = `${host}/${id}`; - if (path in peekedPosts) { - queryClient.setQueryData(["trill-thread", host, id], { - fpost: peekedPosts[path], - }); - } else if (path in deniedPosts) { - setDenied(true); - } - }, [peekedPosts]); - useEffect(() => { - const path = `${host}/${id}`; - if (path in deniedPosts) setDenied(true); - }, [deniedPosts]); + // useEffect(() => { + // const path = `${host}/${id}`; + // if (path in peekedPosts) { + // queryClient.setQueryData(["trill-thread", host, id], { + // fpost: peekedPosts[path], + // }); + // } else if (path in deniedPosts) { + // setDenied(true); + // } + // }, [peekedPosts]); + // useEffect(() => { + // const path = `${host}/${id}`; + // if (path in deniedPosts) setDenied(true); + // }, [deniedPosts]); - useEffect(() => { - const l = lastThread; - if (l && l.thread == id) { - queryClient.setQueryData(["trill-thread", host, id], { fpost: l }); - } - }, [lastThread]); + // useEffect(() => { + // const l = lastThread; + // if (l && l.thread == id) { + // queryClient.setQueryData(["trill-thread", host, id], { fpost: l }); + // } + // }, [lastThread]); function retryPeek(e: React.MouseEvent) { - e.stopPropagation(); - setDead(false); - peekTheNode(); + // e.stopPropagation(); + // setDead(false); + // peekTheNode(); } if (enest > 3) return ( @@ -122,24 +120,14 @@ function PostData(props: { {host} denied you access to this post </p> </div> - ) : "no-node" in data || "bucun" in data ? ( + ) : "error" in data ? ( <div className={props.className}> <p className="x-center not-found">Post not found</p> - </div> - ) : "bugen" in data ? ( - <div className={props.className}> - <div className="x-center not-found"> - <p className="x-center">Post not found, requesting...</p> - <img src={spinner} className="x-center s-100" alt="" /> - </div> - </div> - ) : "fpost" in data && data.fpost.contents === null ? ( - <div className={props.className}> - <p className="x-center not-found">Post deleted</p> + <p className="x-center not-found">{data.error}</p> </div> ) : ( <Component - data={data.fpost} + data={toFlat(data.ok)} refetch={refetch} {...props} nest={enest} diff --git a/front/src/components/post/Reactions.tsx b/front/src/components/post/Reactions.tsx index ee40d26..ae75d8c 100644 --- a/front/src/components/post/Reactions.tsx +++ b/front/src/components/post/Reactions.tsx @@ -20,7 +20,7 @@ import Modal from "../modals/Modal"; import useLocalState from "@/state/state"; export function ReactModal({ send }: { send: (s: string) => Promise<number> }) { - const { setModal } = useLocalState(); + const { setModal } = useLocalState((s) => ({ setModal: s.setModal })); async function sendReact(e: React.MouseEvent, s: string) { e.stopPropagation(); const res = await send(s); @@ -115,7 +115,7 @@ export function TrillReactModal({ poast }: { poast: Poast }) { addNotification: s.addNotification, })); const our = api!.airlock.our!; - + async function sendReact(s: string) { const result = await api!.addReact(poast.host, poast.id, s); // Only add notification if reacting to someone else's post diff --git a/front/src/logic/requests/nostrill.ts b/front/src/logic/requests/nostrill.ts index 4147e35..e35b939 100644 --- a/front/src/logic/requests/nostrill.ts +++ b/front/src/logic/requests/nostrill.ts @@ -1,5 +1,5 @@ import type Urbit from "urbit-api"; -import type { Cursor, FC, PostID } from "@/types/trill"; +import type { Cursor, FC, FullNode, PID, PostID } from "@/types/trill"; import type { Ship } from "@/types/urbit"; import { FeedPostCount } from "../constants"; import type { UserProfile, UserType } from "@/types/nostrill"; @@ -57,25 +57,39 @@ export default class IO { } // scries - async scryFeed(start: Cursor, end: Cursor, desc = true) { - const order = desc ? 1 : 0; - const term = "feed"; - - const path = `/j/feed/${term}/${start}/${end}/${FeedPostCount}/${order}`; - return await this.scry(path); - } - async scryPost( + async scryFeed( host: Ship, - id: PostID, start: Cursor, end: Cursor, desc = true, + + replies = false, ) { const order = desc ? 1 : 0; + const rp = replies ? 1 : 0; - const path = `/j/post/${host}/${id}/${start}/${end}/${FeedPostCount}/${order}`; + const path = `/j/feed/${host}/${start}/${end}/${FeedPostCount}/${order}/${rp}`; return await this.scry(path); } + async scryThread( + host: Ship, + id: PostID, + // start: Cursor, + // end: Cursor, + // desc = true, + ): AsyncRes<FullNode> { + // const order = desc ? 1 : 0; + + // const path = `/j/thread/${host}/${id}/${start}/${end}/${FeedPostCount}/${order}`; + const path = `/j/thread/${host}/${id}`; + const res = await this.scry(path); + if (!("begs" in res)) return { error: "wrong result" }; + if ("ng" in res.begs) return { error: res.begs.ng }; + if ("ok" in res.begs) { + if (!("thread" in res.begs.ok)) return { error: "wrong result" }; + else return { ok: res.begs.ok.thread }; + } else return { error: "wrong result" }; + } // pokes async pokeAlive() { @@ -85,6 +99,19 @@ export default class IO { const json = { add: { content } }; return this.poke({ post: json }); } + async addReply(content: string, host: string, id: string, thread: string) { + const json = { reply: { content, host, id, thread } }; + return this.poke({ post: json }); + } + async addQuote(content: string, pid: PID) { + const json = { quote: { content, host: pid.ship, id: pid.id } }; + return this.poke({ post: json }); + } + async addRP(pid: PID) { + const json = { quote: { host: pid.ship, id: pid.id } }; + return this.poke({ post: json }); + } + // async addPost(post: SentPoast, gossip: boolean) { // const json = { // "new-post": { @@ -177,6 +204,19 @@ export default class IO { return { error: `${e}` }; } } + async peekThread(host: string, id: string): AsyncRes<FullNode> { + try { + const json = { begs: { thread: { host, id } } }; + const res: any = await this.thread("beg", json); + console.log("peeking feed", res); + if (!("begs" in res)) return { error: "wrong request" }; + if ("ng" in res.begs) return { error: res.begs.ng }; + if (!("thread" in res.begs.ok)) return { error: "wrong request" }; + else return { ok: res.begs.ok.thread }; + } catch (e) { + return { error: `${e}` }; + } + } } // notifications diff --git a/front/src/logic/trill/helpers.ts b/front/src/logic/trill/helpers.ts new file mode 100644 index 0000000..6b5a138 --- /dev/null +++ b/front/src/logic/trill/helpers.ts @@ -0,0 +1,10 @@ +import type { FullNode, Poast } from "@/types/trill"; + +export function toFlat(n: FullNode): Poast { + return { + ...n, + children: !n.children + ? [] + : Object.keys(n.children).map((c) => n.children[c].id), + }; +} diff --git a/front/src/pages/Thread.tsx b/front/src/pages/Thread.tsx new file mode 100644 index 0000000..8296f07 --- /dev/null +++ b/front/src/pages/Thread.tsx @@ -0,0 +1,127 @@ +import { useParams } from "wouter"; +import { useQuery } from "@tanstack/react-query"; +import useLocalState from "@/state/state"; +import PostList from "@/components/feed/PostList"; +import Composer from "@/components/composer/Composer"; +import Icon from "@/components/Icon"; +import spinner from "@/assets/triangles.svg"; +import { ErrorPage } from "@/Router"; +import "@/styles/trill.css"; +import "@/styles/feed.css"; +import Post from "@/components/post/Post"; +import { toFlat } from "@/logic/trill/helpers"; + +export default function Thread() { + const params = useParams<{ host: string; id: string }>(); + const { host, id } = params; + const { api } = useLocalState((s) => ({ api: s.api })); + + async function fetchThread() { + return await api!.scryThread(host, id); + } + const { isPending, data, error, refetch } = useQuery({ + queryKey: ["thread", params.host, params.id], + queryFn: fetchThread, + enabled: !!api && !!params.host && !!params.id, + }); + + console.log({ data }); + if (!params.host || !params.id) { + return <ErrorPage msg="Invalid thread URL" />; + } + + if (isPending) { + return ( + <main> + <div className="thread-header"> + <h2>Loading Thread...</h2> + </div> + <div className="loading-container"> + <img className="x-center" src={spinner} alt="Loading" /> + </div> + </main> + ); + } + + if (error) { + return ( + <main> + <div className="thread-header"> + <h2>Error Loading Thread</h2> + </div> + <ErrorPage msg={error.message || "Failed to load thread"} /> + </main> + ); + } + + if (!data || "error" in data) { + return ( + <main> + <div className="thread-header"> + <h2>Thread Not Found</h2> + </div> + <ErrorPage + msg={data?.error || "This thread doesn't exist or isn't accessible"} + /> + </main> + ); + } + + return ( + <main> + <div className="thread-header"> + <div className="thread-nav"> + <button + className="back-btn" + onClick={() => window.history.back()} + title="Go back" + > + <Icon name="reply" size={16} /> + <span>Back to Feed</span> + </button> + </div> + <h2>Thread</h2> + <div className="thread-info"> + <span className="thread-host">~{params.host}</span> + <span className="thread-separator">•</span> + <span className="thread-id">#{params.id}</span> + </div> + </div> + + <div id="feed-proper"> + <Composer /> + <div className="thread-content"> + <Post poast={toFlat(data.ok)} /> + </div> + </div> + </main> + ); +} +// function OwnData(props: Props) { +// const { api } = useLocalState((s) => ({ +// api: s.api, +// })); +// const { host, id } = props; +// async function fetchThread() { +// return await api!.scryThread(host, id); +// } +// const { isLoading, isError, data, refetch } = useQuery({ +// queryKey: ["trill-thread", host, id], +// queryFn: fetchThread, +// }); +// return isLoading ? ( +// <div className={props.className}> +// <div className="x-center not-found"> +// <p className="x-center">Scrying Post, please wait...</p> +// <img src={spinner} className="x-center s-100" alt="" /> +// </div> +// </div> +// ) : null; +// } +// function SomeoneElses(props: Props) { +// // const { api, following } = useLocalState((s) => ({ +// // api: s.api, +// // following: s.following, +// // })); +// return <div>ho</div>; +// } diff --git a/front/src/styles/styles.css b/front/src/styles/styles.css index ede283d..42a2e3c 100644 --- a/front/src/styles/styles.css +++ b/front/src/styles/styles.css @@ -249,152 +249,396 @@ h6 { border-radius: 0.75rem; & #composer { - padding: 10px; + padding: 16px; display: flex; - gap: 0.5rem; + gap: 0.75rem; + transition: all 0.3s ease; + border-bottom: 1px solid rgba(128, 128, 128, 0.2); + + &.expanded { + padding: 20px 16px; + background: linear-gradient(to bottom, rgba(128, 128, 128, 0.05), transparent); + } + + &.has-context { + min-height: 120px; + } & .sigil { width: 48px; height: 48px; + flex-shrink: 0; & img { width: inherit; + border-radius: 50%; } } - & input { - background-color: transparent; - color: var(--color-text); - flex-grow: 1; - border: none; - outline: none; + & .composer-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; } - } - } - & .trill-post, - & .twatter-post { - border-top: 1px solid grey; + & .composer-context { + background: rgba(128, 128, 128, 0.08); + border-radius: 12px; + padding: 12px; + position: relative; + animation: slideDown 0.3s ease; + + & .composer-snippet { + max-height: 200px; + overflow-y: auto; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + + &>div { + padding: 8px; + } + } - & .left { - margin-right: 10px; - width: unset; + & #reply { + background: transparent; + padding: 0; + } + } - & .sigil { - width: 48px; - height: 48px; + & .reply-context { + margin-bottom: 12px; + border-left: 3px solid var(--color-accent, #2a9d8f); + background: rgba(42, 157, 143, 0.08); } - } - & header { - align-items: center; - justify-content: left; + & .quote-context { + margin-top: 12px; + border-left: 3px solid var(--color-secondary, #e76f51); + background: rgba(231, 111, 81, 0.08); + } - & .author { - flex: unset; - gap: 0; + & .quote-header { + margin-bottom: 12px; + padding: 8px 12px; + background: rgba(231, 111, 81, 0.08); + border-radius: 8px; + border-left: 3px solid var(--color-secondary, #e76f51); + } - & .name { + & .context-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + + & .context-type { display: flex; align-items: center; + gap: 6px; + font-size: 0.85rem; + color: var(--color-text-muted, #888); + font-weight: 500; + } - & .p { - font-family: "Source Code Pro"; + & .clear-context { + background: none; + border: none; + color: var(--color-text-muted, #888); + cursor: pointer; + font-size: 1.5rem; + line-height: 1; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s; + + &:hover { + background: rgba(128, 128, 128, 0.2); + color: var(--color-text); } } } - & .date { - color: grey; + & .quote-header .context-header { + margin-bottom: 0; } - } - - & footer { - justify-content: left; - margin: unset; - - & .icon { - margin: 0; + & .composer-input-row { + display: flex; + gap: 12px; align-items: center; - gap: 0.2rem; - width: 64px; + } - & img { - height: 18px; + & input { + background-color: transparent; + color: var(--color-text); + flex-grow: 1; + border: none; + outline: none; + font-size: 1rem; + padding: 8px 0; + border-bottom: 2px solid transparent; + transition: border-color 0.2s; + + &:focus { + border-bottom-color: var(--color-accent, #2a9d8f); } - & .react-img { - height: 24px; + &::placeholder { + color: var(--color-text-muted, #888); } + } - & .react-icon { - font-size: 20px; + & .post-btn { + padding: 8px 20px; + background: var(--color-accent, #2a9d8f); + color: white; + border: none; + border-radius: 20px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + + &:hover:not(:disabled) { + background: var(--color-accent-hover, #238b7f); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(42, 157, 143, 0.3); } - & span { - margin-right: unset; - text-align: left; - font-size: 14px; - line-height: 1rem; - color: grey; - width: unset; + &:disabled { + opacity: 0.5; + cursor: not-allowed; } } + } + + @keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } - & .menu-icon { - margin-left: auto; + to { + opacity: 1; + transform: translateY(0); } } } + /* Thread page styling */ + & .thread-header { + margin-bottom: 1rem; + padding: 1rem 0; + border-bottom: 1px solid rgba(128, 128, 128, 0.2); + + & h2 { + margin: 0.5rem 0; + font-size: 1.5rem; + color: var(--color-text); + } - & .user-contact { - & .contact-cover { - margin-bottom: -40px; + & .thread-nav { + margin-bottom: 0.5rem; - & img { - width: 100%; - height: 100%; - object-fit: cover; + & .back-btn { + background: rgba(128, 128, 128, 0.1); + border: 1px solid rgba(128, 128, 128, 0.3); + border-radius: 8px; + padding: 8px 12px; + color: var(--color-text); + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9rem; + transition: all 0.2s; + + &:hover { + background: rgba(128, 128, 128, 0.2); + transform: translateX(-2px); + } + + & span { + font-weight: 500; + } } } - & .contact-name { + & .thread-info { display: flex; align-items: center; - gap: 0.5rem; - } + gap: 8px; + font-size: 0.9rem; + color: var(--color-text-muted, #888); + + & .thread-host { + font-family: "Source Code Pro", monospace; + background: rgba(128, 128, 128, 0.1); + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; + } - & .contact-username { - margin-top: 1rem; - font-family: "Source Code Pro"; - font-weight: 400; + & .thread-separator { + opacity: 0.5; + } + + & .thread-id { + font-family: "Source Code Pro", monospace; + background: rgba(42, 157, 143, 0.1); + color: var(--color-accent, #2a9d8f); + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; + } } + } + + & .thread-content { + /* Use same styling as feed content */ + } + + & .loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; - & button { - width: unset; - margin: unset; - height: unset; + & img { + width: 40px; + height: 40px; } } } - & button { - font-size: 0.9rem; - font-weight: 700; - line-height: 1rem; - border: none; - border-radius: 2rem; - padding: 0.5rem 2rem; + & .trill-post, + & .twatter-post { + border-top: 1px solid grey; + + & .left { + margin-right: 10px; + width: unset; + + & .sigil { + width: 48px; + height: 48px; + } + } + + & header { + align-items: center; + justify-content: left; + + & .author { + flex: unset; + gap: 0; + + & .name { + display: flex; + align-items: center; + + & .p { + font-family: "Source Code Pro"; + } + } + } + + & .date { + color: grey; + } + + } + + & footer { + justify-content: left; + margin: unset; + + & .icon { + margin: 0; + align-items: center; + gap: 0.2rem; + width: 64px; + + & img { + height: 18px; + } + + & .react-img { + height: 24px; + } + + & .react-icon { + font-size: 20px; + } + + & span { + margin-right: unset; + text-align: left; + font-size: 14px; + line-height: 1rem; + color: grey; + width: unset; + } + } + + & .menu-icon { + margin-left: auto; + } + } } - & .sigil, - & .sigil svg { - border-radius: 0.5rem; + + & .user-contact { + & .contact-cover { + margin-bottom: -40px; + + & img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + & .contact-name { + display: flex; + align-items: center; + gap: 0.5rem; + } + + & .contact-username { + margin-top: 1rem; + font-family: "Source Code Pro"; + font-weight: 400; + } + + & button { + width: unset; + margin: unset; + height: unset; + } } } +& button { + font-size: 0.9rem; + font-weight: 700; + line-height: 1rem; + border: none; + border-radius: 2rem; + padding: 0.5rem 2rem; +} + +& .sigil, +& .sigil svg { + border-radius: 0.5rem; +} + #big-button { position: absolute; right: 2rem; diff --git a/front/src/styles/trill.css b/front/src/styles/trill.css index 5687c7a..0a21ed5 100644 --- a/front/src/styles/trill.css +++ b/front/src/styles/trill.css @@ -306,6 +306,17 @@ footer .icon { /* min-width: 64px; */ } +footer .icon .icon-wrapper { + cursor: pointer; + display: inline-block; + transition: transform 0.1s ease, opacity 0.1s ease; +} + +footer .icon .icon-wrapper:hover { + transform: scale(1.1); + opacity: 0.8; +} + footer #menu-icon { width: 32px !important; /* margin-left: 20px; */ |