diff options
Diffstat (limited to 'front/src/components/post')
-rw-r--r-- | front/src/components/post/Body.tsx | 174 | ||||
-rw-r--r-- | front/src/components/post/Card.tsx | 9 | ||||
-rw-r--r-- | front/src/components/post/External.tsx | 41 | ||||
-rw-r--r-- | front/src/components/post/Footer.tsx | 238 | ||||
-rw-r--r-- | front/src/components/post/Header.tsx | 40 | ||||
-rw-r--r-- | front/src/components/post/Loader.tsx | 160 | ||||
-rw-r--r-- | front/src/components/post/Media.tsx | 35 | ||||
-rw-r--r-- | front/src/components/post/Post.tsx | 84 | ||||
-rw-r--r-- | front/src/components/post/PostWrapper.tsx | 14 | ||||
-rw-r--r-- | front/src/components/post/Quote.tsx | 64 | ||||
-rw-r--r-- | front/src/components/post/RP.tsx | 47 | ||||
-rw-r--r-- | front/src/components/post/Reactions.tsx | 118 | ||||
-rw-r--r-- | front/src/components/post/StatsModal.tsx | 106 | ||||
-rw-r--r-- | front/src/components/post/wrappers/Nostr.tsx | 15 | ||||
-rw-r--r-- | front/src/components/post/wrappers/NostrIcon.tsx | 22 |
15 files changed, 1167 insertions, 0 deletions
diff --git a/front/src/components/post/Body.tsx b/front/src/components/post/Body.tsx new file mode 100644 index 0000000..2e4e2f8 --- /dev/null +++ b/front/src/components/post/Body.tsx @@ -0,0 +1,174 @@ +import type { + // TODO ref backend fetching!! + Reference, + Block, + Inline, + Media as MediaType, + ExternalContent, +} from "@/types/trill"; +import crow from "@/assets/icons/crow.svg"; +import type { PostProps } from "./Post"; +import Media from "./Media"; +import JSONContent, { YoutubeSnippet } from "./External"; +import { useLocation } from "wouter"; +import Quote from "./Quote"; +import PostData from "./Loader"; +import Card from "./Card.tsx"; +import type { Ship } from "@/types/urbit.ts"; + +function Body(props: PostProps) { + const text = props.poast.contents.filter((c) => { + return ( + "paragraph" in c || + "blockquote" in c || + "heading" in c || + "codeblock" in c || + "list" in c + ); + }); + + const media: MediaType[] = props.poast.contents.filter( + (c): c is MediaType => "media" in c, + ); + + const refs = props.poast.contents.filter((c): c is Reference => "ref" in c); + const json = props.poast.contents.filter( + (c): c is ExternalContent => "json" in c, + ); + + return ( + <div className="trill-post-body body"> + <div className="body-text"> + {text.map((b, i) => ( + <TextBlock key={JSON.stringify(b) + i} block={b} /> + ))} + </div> + {media.length > 0 && <Media media={media} />} + {refs.map((r, i) => ( + <Ref r={r} nest={props.nest || 0} key={JSON.stringify(r) + i} /> + ))} + <JSONContent content={json} /> + </div> + ); +} +export default Body; + +function TextBlock({ block }: { block: Block }) { + const key = JSON.stringify(block); + return "paragraph" in block ? ( + <div className="trill-post-paragraph"> + {block.paragraph.map((i, ind) => ( + <Inlin key={key + ind} i={i} /> + ))} + </div> + ) : "blockquote" in block ? ( + <blockquote> + {block.blockquote.map((i, ind) => ( + <Inlin key={key + ind} i={i} /> + ))} + </blockquote> + ) : "heading" in block ? ( + <Heading string={block.heading.text} num={block.heading.num} /> + ) : "codeblock" in block ? ( + <pre> + <code className={`language-${block.codeblock.lang}`}> + {block.codeblock.code} + </code> + </pre> + ) : "list" in block ? ( + block.list.ordered ? ( + <ol> + {block.list.text.map((i, ind) => ( + <li key={JSON.stringify(i) + ind}> + <Inlin key={key + ind} i={i} /> + </li> + ))} + </ol> + ) : ( + <ul> + {block.list.text.map((i, ind) => ( + <li key={JSON.stringify(i) + ind}> + <Inlin key={JSON.stringify(i) + ind} i={i} /> + </li> + ))} + </ul> + ) + ) : null; +} +function Inlin({ i }: { i: Inline }) { + const [_, navigate] = useLocation(); + function gotoShip(e: React.MouseEvent, ship: Ship) { + e.stopPropagation(); + navigate(`/feed/${ship}`); + } + return "text" in i ? ( + <span>{i.text}</span> + ) : "italic" in i ? ( + <i>{i.italic}</i> + ) : "bold" in i ? ( + <strong>{i.bold}</strong> + ) : "strike" in i ? ( + <span>{i.strike}</span> + ) : "underline" in i ? ( + <span>{i.underline}</span> + ) : "sup" in i ? ( + <sup>{i.sup}</sup> + ) : "sub" in i ? ( + <sub>{i.sub}</sub> + ) : "ship" in i ? ( + <span + className="mention" + role="link" + onMouseUp={(e) => gotoShip(e, i.ship)} + > + {i.ship} + </span> + ) : "codespan" in i ? ( + <code>{i.codespan}</code> + ) : "link" in i ? ( + <LinkParser {...i.link} /> + ) : "break" in i ? ( + <br /> + ) : null; +} + +function LinkParser({ href, show }: { href: string; show: string }) { + const YOUTUBE_REGEX_1 = /(youtube\.com\/watch\?v=)(\w+)/; + const YOUTUBE_REGEX_2 = /(youtu\.be\/)([a-zA-Z0-9-_]+)/; + const m1 = href.match(YOUTUBE_REGEX_1); + const m2 = href.match(YOUTUBE_REGEX_2); + const ytb = m1 && m1[2] ? m1[2] : m2 && m2[2] ? m2[2] : ""; + return ytb ? ( + <YoutubeSnippet href={href} id={ytb} /> + ) : ( + <a href={href}>{show}</a> + ); +} +function Heading({ string, num }: { string: string; num: number }) { + return num === 1 ? ( + <h1>{string}</h1> + ) : num === 2 ? ( + <h2>{string}</h2> + ) : num === 3 ? ( + <h3>{string}</h3> + ) : num === 4 ? ( + <h4>{string}</h4> + ) : num === 5 ? ( + <h5>{string}</h5> + ) : num === 6 ? ( + <h6>{string}</h6> + ) : null; +} + +function Ref({ r, nest }: { r: Reference; nest: number }) { + if (r.ref.type === "nostril") { + const comp = PostData({ + host: r.ref.ship, + id: r.ref.path.slice(1), + nest: nest + 1, + className: "quote-in-post", + })(Quote); + return <Card logo={crow}>{comp}</Card>; + } + return <></>; +} diff --git a/front/src/components/post/Card.tsx b/front/src/components/post/Card.tsx new file mode 100644 index 0000000..37f4911 --- /dev/null +++ b/front/src/components/post/Card.tsx @@ -0,0 +1,9 @@ +export default function ({ children, logo, cn}: { cn?: string; logo: string; children: any }) { + const className = "trill-post-card" + (cn ? ` ${cn}`: "") + return ( + <div className={className}> + <img src={logo} alt="" className="trill-post-card-logo" /> + {children} + </div> + ); +} diff --git a/front/src/components/post/External.tsx b/front/src/components/post/External.tsx new file mode 100644 index 0000000..0ea1500 --- /dev/null +++ b/front/src/components/post/External.tsx @@ -0,0 +1,41 @@ +import type { ExternalContent } from "@/types/trill"; +import youtube from "@/assets/icons/youtube.svg"; +import Card from "./Card"; + +interface JSONProps { + content: ExternalContent[]; +} + +function JSONContent({ content }: JSONProps) { + return ( + <> + {content.map((c, i) => { + if (!JSON.parse(c.json.content)) return <p key={i}>Error</p>; + else + return ( + <p + key={JSON.stringify(c.json)} + className="external-content-warning" + > + External content from "{c.json.origin}", use + <a href="https://urbit.org/applications/~sortug/ufa">UFA</a> + to display. + </p> + ); + })} + </> + ); +} +export default JSONContent; + +export function YoutubeSnippet({ href, id }: { href: string; id: string }) { + const thumbnail = `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; + // todo styiling + return ( + <Card logo={youtube} cn="youtube-thumbnail"> + <a href={href}> + <img src={thumbnail} alt="" /> + </a> + </Card> + ); +} diff --git a/front/src/components/post/Footer.tsx b/front/src/components/post/Footer.tsx new file mode 100644 index 0000000..3b48241 --- /dev/null +++ b/front/src/components/post/Footer.tsx @@ -0,0 +1,238 @@ +import type { PostProps } from "./Post"; +import reply from "@/assets/icons/reply.svg"; +import quote from "@/assets/icons/quote.svg"; +import repost from "@/assets/icons/rt.svg"; +import { useState } from "react"; +import useLocalState from "@/state/state"; +import { useLocation } from "wouter"; +import { displayCount } from "@/logic/utils"; +import { TrillReactModal, stringToReact } from "./Reactions"; +import toast from "react-hot-toast"; +import NostrIcon from "./wrappers/NostrIcon"; +// TODO abstract this somehow + +function Footer({ poast, refetch }: PostProps) { + const [_showMenu, setShowMenu] = useState(false); + const [location, navigate] = useLocation(); + const [reposting, _setReposting] = useState(false); + const { api, setComposerData, setModal } = useLocalState(); + const our = api!.airlock.our!; + function doReply(e: React.MouseEvent) { + e.stopPropagation(); + setComposerData({ type: "reply", post: { trill: poast } }); + } + function doQuote(e: React.MouseEvent) { + e.stopPropagation(); + setComposerData({ + type: "quote", + post: { trill: poast }, + }); + navigate("/composer"); + } + const childrenCount = poast.children + ? poast.children.length + ? poast.children.length + : Object.keys(poast.children).length + : 0; + const myRP = poast.engagement.shared.find((r) => r.pid.ship === our); + async function cancelRP(e: React.MouseEvent) { + e.stopPropagation(); + const r = await api!.deletePost(our); + if (r) toast.success("Repost deleted"); + refetch(); + if (location.includes(poast.id)) navigate("/"); + } + async function sendRP(e: React.MouseEvent) { + // TODO update backend because contents are only markdown now + e.stopPropagation(); + // const c = [ + // { + // ref: { + // type: "trill", + // ship: poast.host, + // path: `/${poast.id}`, + // }, + // }, + // ]; + // const post: SentPoast = { + // host: our, + // author: our, + // thread: null, + // parent: null, + // contents: input, + // read: openLock, + // write: openLock, + // tags: [], // TODO + // }; + // const r = await api!.addPost(post, false); + // setReposting(true); + // if (r) { + // setReposting(false); + // toast.success("Your post was published"); + // } + } + function doReact(e: React.MouseEvent) { + e.stopPropagation(); + const modal = <TrillReactModal poast={poast} />; + setModal(modal); + } + function showReplyCount() { + if (poast.children[0]) fetchAndShow(); // Flatpoast + // else { + // const authors = Object.keys(poast.children).map( + // (i) => poast.children[i].post.author + // ); + // setEngagement({ type: "replies", ships: authors }, poast); + // } + } + async function fetchAndShow() { + // let authors = []; + // for (let i of poast.children as string[]) { + // const res = await scrypoastFull(poast.host, i); + // if (res) + // authors.push(res.post.author || "deleter"); + // } + // setEngagement({ type: "replies", ships: authors }, poast); + } + function showRepostCount() { + // const ships = poast.engagement.shared.map((entry) => entry.host); + // setEngagement({ type: "reposts", ships: ships }, poast); + } + function showQuoteCount() { + // setEngagement({ type: "quotes", quotes: poast.engagement.quoted }, poast); + } + function showReactCount() { + // setEngagement({ type: "reacts", reacts: poast.engagement.reacts }, poast); + } + + const mostCommonReact = Object.values(poast.engagement.reacts).reduce( + (acc: any, item) => { + if (!acc.counts[item]) acc.counts[item] = 0; + acc.counts[item] += 1; + if (!acc.winner || acc.counts[item] > acc.counts[acc.winner]) + acc.winner = item; + return acc; + }, + { counts: {}, winner: "" }, + ).winner; + const reactIcon = stringToReact(mostCommonReact); + + // TODO round up all helpers + + return ( + <div className="footer-wrapper post-footer"> + <footer> + <div className="icon"> + <span role="link" onMouseUp={showReplyCount} className="reply-count"> + {displayCount(childrenCount)} + </span> + <img role="link" onMouseUp={doReply} src={reply} alt="" /> + </div> + <div className="icon"> + <span role="link" onMouseUp={showQuoteCount} className="quote-count"> + {displayCount(poast.engagement.quoted.length)} + </span> + <img role="link" onMouseUp={doQuote} src={quote} alt="" /> + </div> + <div className="icon"> + <span + role="link" + onMouseUp={showRepostCount} + className="repost-count" + > + {displayCount(poast.engagement.shared.length)} + </span> + {reposting ? ( + <p>...</p> + ) : myRP ? ( + <img + role="link" + className="my-rp" + onMouseUp={cancelRP} + src={repost} + title="cancel repost" + /> + ) : ( + <img role="link" onMouseUp={sendRP} src={repost} title="repost" /> + )} + </div> + <div className="icon" role="link" onMouseUp={doReact}> + <span + role="link" + onMouseUp={showReactCount} + className="reaction-count" + > + {displayCount(Object.keys(poast.engagement.reacts).length)} + </span> + {reactIcon} + </div> + <NostrIcon poast={poast} /> + </footer> + </div> + ); +} +export default Footer; + +// function Menu({ +// poast, +// setShowMenu, +// refetch, +// }: { +// poast: Poast; +// setShowMenu: Function; +// refetch: Function; +// }) { +// const ref = useRef<HTMLDivElement>(null); +// const [location, navigate] = useLocation(); +// // TODO this is a mess and the event still propagates +// useEffect(() => { +// const checkIfClickedOutside = (e: any) => { +// e.stopPropagation(); +// if (ref && ref.current && !ref.current.contains(e.target)) +// setShowMenu(false); +// }; +// document.addEventListener("mousedown", checkIfClickedOutside); +// return () => { +// document.removeEventListener("mousedown", checkIfClickedOutside); +// }; +// }, []); +// const { our, setModal, setAlert } = useLocalState(); +// const mine = our === poast.host || our === poast.author; +// async function doDelete(e: React.MouseEvent) { +// e.stopPropagation(); +// deletePost(poast.host, poast.id); +// setAlert("Post deleted"); +// setShowMenu(false); +// refetch(); +// if (location.includes(poast.id)) navigate("/"); +// } +// async function copyLink(e: React.MouseEvent) { +// e.stopPropagation(); +// const link = trillPermalink(poast); +// await navigator.clipboard.writeText(link); +// // some alert +// setShowMenu(false); +// } +// function openStats(e: React.MouseEvent) { +// e.stopPropagation(); +// e.preventDefault(); +// const m = <StatsModal poast={poast} close={() => setModal(null)} />; +// setModal(m); +// } +// return ( +// <div ref={ref} id="post-menu"> +// {/* <p onClick={openShare}>Share to Groups</p> */} +// <p role="link" onMouseUp={openStats}> +// See Stats +// </p> +// <p role="link" onMouseUp={copyLink}> +// Permalink +// </p> +// {mine && ( +// <p role="link" onMouseUp={doDelete}> +// Delete Post +// </p> +// )} +// </div> +// ); +// } diff --git a/front/src/components/post/Header.tsx b/front/src/components/post/Header.tsx new file mode 100644 index 0000000..e541fa5 --- /dev/null +++ b/front/src/components/post/Header.tsx @@ -0,0 +1,40 @@ +import { date_diff } from "@/logic/utils"; +import type { PostProps } from "./Post"; +import { useLocation } from "wouter"; +import useLocalState from "@/state/state"; +function Header(props: PostProps) { + const [_, navigate] = useLocation(); + const { profiles } = useLocalState(); + const profile = profiles.get(props.poast.author); + // console.log("profile", profile); + // console.log(props.poast.author.length, "length"); + function go(e: React.MouseEvent) { + e.stopPropagation(); + } + function openThread(e: React.MouseEvent) { + e.stopPropagation(); + const sel = window.getSelection()?.toString(); + if (!sel) navigate(`/feed/${poast.host}/${poast.id}`); + } + const { poast } = props; + const name = profile ? ( + profile.name + ) : ( + <div className="name cp"> + <p className="p-only">{poast.author}</p> + </div> + ); + return ( + <header> + <div className="author flex-align" role="link" onMouseUp={go}> + {name} + </div> + <div role="link" onMouseUp={openThread} className="date"> + <p title={new Date(poast.time).toLocaleString()}> + {date_diff(poast.time, "short")} + </p> + </div> + </header> + ); +} +export default Header; diff --git a/front/src/components/post/Loader.tsx b/front/src/components/post/Loader.tsx new file mode 100644 index 0000000..f3c4715 --- /dev/null +++ b/front/src/components/post/Loader.tsx @@ -0,0 +1,160 @@ +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 { Ship } from "@/types/urbit"; + +function PostData(props: { + host: Ship; + id: PostID; + rter?: Ship; + rtat?: number; + rtid?: PostID; + nest?: number; // nested quotes + className?: string; +}) { + const { api } = useLocalState(); + const { host, id, nest } = props; + const [enest, setEnest] = useState(nest); + useEffect(() => { + setEnest(nest); + }, [nest]); + + return function (Component: React.ElementType) { + // const [showNested, setShowNested] = useState(nest <= 3); + const handleShowNested = (e: React.MouseEvent) => { + e.stopPropagation(); + setEnest(enest! - 3); + }; + const [dead, setDead] = useState(false); + const [denied, setDenied] = useState(false); + const { isLoading, isError, data, refetch } = useQuery({ + queryKey: ["trill-thread", host, id], + queryFn: fetchNode, + }); + const queryClient = useQueryClient(); + const dataRef = useRef(data); + useEffect(() => { + dataRef.current = data; + }, [data]); + + async function fetchNode(): Promise<any> { + const res = await api!.scryPost(host, id, null, null); + if ("fpost" 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; + } + } + } + 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 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(); + } + if (enest > 3) + return ( + <div className={props.className}> + <div className="lazy x-center not-found"> + <button className="x-center" onMouseUp={handleShowNested}> + Load more + </button> + </div> + </div> + ); + else + return data ? ( + dead ? ( + <div className={props.className}> + <div className="no-response x-center not-found"> + <p>{host} did not respond</p> + <button className="x-center" onMouseUp={retryPeek}> + Try again + </button> + </div> + </div> + ) : denied ? ( + <div className={props.className}> + <p className="x-center not-found"> + {host} denied you access to this post + </p> + </div> + ) : "no-node" in data || "bucun" 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> + </div> + ) : ( + <Component + data={data.fpost} + refetch={refetch} + {...props} + nest={enest} + /> + ) + ) : // no data + isLoading || isError ? ( + <div className={props.className}> + <img className="x-center post-spinner" src={spinner} alt="" /> + </div> + ) : ( + <div className={props.className}> + <p>...</p> + </div> + ); + }; +} +export default PostData; diff --git a/front/src/components/post/Media.tsx b/front/src/components/post/Media.tsx new file mode 100644 index 0000000..04ea156 --- /dev/null +++ b/front/src/components/post/Media.tsx @@ -0,0 +1,35 @@ +import type { Media } from "@/types/trill"; +interface Props { + media: Media[]; +} +function M({ media }: Props) { + return ( + <div className="body-media"> + {media.map((m, i) => { + return "video" in m.media ? ( + <video key={JSON.stringify(m) + i} src={m.media.video} controls /> + ) : "audio" in m.media ? ( + <audio key={JSON.stringify(m) + i} src={m.media.audio} controls /> + ) : "images" in m.media ? ( + <Images key={JSON.stringify(m) + i} urls={m.media.images} /> + ) : null; + })} + </div> + ); +} +export default M; + +function Images({ urls }: { urls: string[] }) { + return ( + <> + {urls.map((u, i) => ( + <img + key={u + i} + className={`body-img body-img-1-of-${urls.length}`} + src={u} + alt="" + /> + ))} + </> + ); +} diff --git a/front/src/components/post/Post.tsx b/front/src/components/post/Post.tsx new file mode 100644 index 0000000..e61efb0 --- /dev/null +++ b/front/src/components/post/Post.tsx @@ -0,0 +1,84 @@ +import type { PostID, Poast, Reference } from "@/types/trill"; + +import Header from "./Header"; +import Body from "./Body"; +import Footer from "./Footer"; +import { useLocation } from "wouter"; +import useLocalState from "@/state/state"; +import RP from "./RP"; +import ShipModal from "../modals/ShipModal"; +import type { Ship } from "@/types/urbit"; +import Sigil from "../Sigil"; +import type { UserProfile } from "@/types/nostrill"; + +export interface PostProps { + poast: Poast; + fake?: boolean; + rter?: Ship; + rtat?: number; + rtid?: PostID; + nest?: number; + refetch?: Function; + profile?: UserProfile; +} +function Post(props: PostProps) { + const { poast } = props; + if (!poast || poast.contents === null) { + return null; + } + const isRP = + poast.contents.length === 1 && + "ref" in poast.contents[0] && + poast.contents[0].ref.type === "trill"; + if (isRP) { + const ref = (poast.contents[0] as Reference).ref; + return ( + <RP + host={ref.ship} + id={ref.path.slice(1)} + rter={poast.author} + rtat={poast.time} + rtid={poast.id} + /> + ); + } else return <TrillPost {...props} />; +} +export default Post; + +function TrillPost(props: PostProps) { + const { poast, profile, fake } = props; + const { setModal } = useLocalState(); + const [_, navigate] = useLocation(); + function openThread(_e: React.MouseEvent) { + const sel = window.getSelection()?.toString(); + if (!sel) navigate(`/feed/${poast.host}/${poast.id}`); + } + + function openModal(e: React.MouseEvent) { + e.stopPropagation(); + setModal(<ShipModal ship={poast.author} />); + } + const avatar = profile ? ( + <div className="avatar cp" role="link" onMouseUp={openModal}> + <img src={profile.picture} /> + </div> + ) : ( + <div className="avatar sigil cp" role="link" onMouseUp={openModal}> + <Sigil patp={poast.author} size={42} /> + </div> + ); + return ( + <div + className={`timeline-post trill-post cp`} + role="link" + onMouseUp={openThread} + > + <div className="left">{avatar}</div> + <div className="right"> + <Header {...props} /> + <Body {...props} /> + {!fake && <Footer {...props} />} + </div> + </div> + ); +} diff --git a/front/src/components/post/PostWrapper.tsx b/front/src/components/post/PostWrapper.tsx new file mode 100644 index 0000000..c4e754f --- /dev/null +++ b/front/src/components/post/PostWrapper.tsx @@ -0,0 +1,14 @@ +import useLocalState from "@/state/state"; +import type { NostrPost, PostWrapper } from "@/types/nostrill"; + +export default Post; +function Post(pw: PostWrapper) { + if ("nostr" in pw) return <NostrPost post={pw.nostr} />; + else return <TrillPost post={pw.urbit.post} nostr={pw.urbit.nostr} />; +} + +function NostrPost({ post, event, relay }: NostrPost) { + const { profiles } = useLocalState(); + const profile = profiles.get(event.pubkey); + return <></>; +} diff --git a/front/src/components/post/Quote.tsx b/front/src/components/post/Quote.tsx new file mode 100644 index 0000000..28149f0 --- /dev/null +++ b/front/src/components/post/Quote.tsx @@ -0,0 +1,64 @@ +import type { FullNode, Poast } from "@/types/trill"; +import { date_diff } from "@/logic/utils"; +import { useLocation } from "wouter"; +import Body from "./Body"; +import Sigil from "../Sigil"; + +// function Quote({ +// data, +// refetch, +// nest, +// }: { +// data: FullNode; +// refetch?: Function; +// nest: number; +// }) { +// const [_, navigate] = useLocation(); +// function gotoQuote(e: React.MouseEvent) { +// e.stopPropagation(); +// navigate(`/feed/${data.host}/${data.id}`); +// } +// return ( +// <div onMouseUp={gotoQuote} className="quote-in-post"> +// <header className="btw"> +// ( +// <div className="quote-author flex"> +// <Sigil patp={data.author} size={20} /> +// {data.author} +// </div> +// )<span>{date_diff(data.time, "short")}</span> +// </header> +// <Body poast={toFlat(data)} nest={nest} refetch={refetch!} /> +// </div> +// ); +// } +function Quote({ + data, + refetch, + nest, +}: { + data: Poast; + refetch?: Function; + nest: number; +}) { + const [_, navigate] = useLocation(); + function gotoQuote(e: React.MouseEvent) { + e.stopPropagation(); + navigate(`/feed/${data.host}/${data.id}`); + } + return ( + <div onMouseUp={gotoQuote} className="quote-in-post"> + <header className="btw"> + ( + <div className="quote-author flex"> + <Sigil patp={data.author} size={20} /> + {data.author} + </div> + )<span>{date_diff(data.time, "short")}</span> + </header> + <Body poast={data} nest={nest} refetch={refetch!} /> + </div> + ); +} + +export default Quote; diff --git a/front/src/components/post/RP.tsx b/front/src/components/post/RP.tsx new file mode 100644 index 0000000..27fa02d --- /dev/null +++ b/front/src/components/post/RP.tsx @@ -0,0 +1,47 @@ +import Post from "./Post"; +import type { Ship } from "@/types/urbit"; +import type { Poast, FullNode, ID } from "@/types/trill"; +import PostData from "./Loader"; +export default function (props: { + host: string; + id: string; + rter: Ship; + rtat: number; + rtid: ID; + refetch?: Function; +}) { + return PostData(props)(RP); +} + +function RP({ + data, + refetch, + rter, + rtat, + rtid, +}: { + data: FullNode; + refetch: Function; + rter: Ship; + rtat: number; + rtid: ID; +}) { + return ( + <Post + poast={toFlat(data)} + rter={rter} + rtat={rtat} + rtid={rtid} + refetch={refetch} + /> + ); +} + +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/components/post/Reactions.tsx b/front/src/components/post/Reactions.tsx new file mode 100644 index 0000000..58662cd --- /dev/null +++ b/front/src/components/post/Reactions.tsx @@ -0,0 +1,118 @@ +import type { Poast } from "@/types/trill"; +import yeschad from "@/assets/reacts/yeschad.png"; +import cringe from "@/assets/reacts/cringe.png"; +import cry from "@/assets/reacts/cry.png"; +import doom from "@/assets/reacts/doom.png"; +import galaxy from "@/assets/reacts/galaxy.png"; +import gigachad from "@/assets/reacts/gigachad.png"; +import pepechin from "@/assets/reacts/pepechin.png"; +import pepeeyes from "@/assets/reacts/pepeeyes.png"; +import pepegmi from "@/assets/reacts/pepegmi.png"; +import pepesad from "@/assets/reacts/pepesad.png"; +import pink from "@/assets/reacts/pink.png"; +import soy from "@/assets/reacts/soy.png"; +import chad from "@/assets/reacts/chad.png"; +import pika from "@/assets/reacts/pika.png"; +import facepalm from "@/assets/reacts/facepalm.png"; +import emoji from "@/assets/icons/emoji.svg"; +import emojis from "@/logic/emojis.json"; +import Modal from "../modals/Modal"; +import useLocalState from "@/state/state"; + +export function ReactModal({ send }: { send: (s: string) => Promise<number> }) { + const { setModal } = useLocalState(); + async function sendReact(e: React.MouseEvent, s: string) { + e.stopPropagation(); + const res = await send(s); + if (res) setModal(null); + } + // todo one more meme + return ( + <Modal> + <div id="react-list"> + <span onMouseUp={(e) => sendReact(e, "❤️")}>️️❤️</span> + <span onMouseUp={(e) => sendReact(e, "🤔")}>🤔</span> + <span onMouseUp={(e) => sendReact(e, "😅")}>😅</span> + <span onMouseUp={(e) => sendReact(e, "🤬")}>🤬</span> + <span onMouseUp={(e) => sendReact(e, "😂")}>😂️</span> + <span onMouseUp={(e) => sendReact(e, "🫡")}>🫡️</span> + <span onMouseUp={(e) => sendReact(e, "🤢")}>🤢</span> + <span onMouseUp={(e) => sendReact(e, "😭")}>😭</span> + <span onMouseUp={(e) => sendReact(e, "😱")}>😱</span> + <img + onMouseUp={(e) => sendReact(e, "facepalm")} + src={facepalm} + alt="" + /> + <span onMouseUp={(e) => sendReact(e, "👍")}>👍️</span> + <span onMouseUp={(e) => sendReact(e, "👎")}>👎️</span> + <span onMouseUp={(e) => sendReact(e, "☝")}>☝️</span> + <span onMouseUp={(e) => sendReact(e, "🤝")}>🤝</span>️ + <span onMouseUp={(e) => sendReact(e, "🙏")}>🙏</span> + <span onMouseUp={(e) => sendReact(e, "🤡")}>🤡</span> + <span onMouseUp={(e) => sendReact(e, "👀")}>👀</span> + <span onMouseUp={(e) => sendReact(e, "🎤")}>🎤</span> + <span onMouseUp={(e) => sendReact(e, "💯")}>💯</span> + <span onMouseUp={(e) => sendReact(e, "🔥")}>🔥</span> + <img onMouseUp={(e) => sendReact(e, "yeschad")} src={yeschad} alt="" /> + <img + onMouseUp={(e) => sendReact(e, "gigachad")} + src={gigachad} + alt="" + /> + <img onMouseUp={(e) => sendReact(e, "pika")} src={pika} alt="" /> + <img onMouseUp={(e) => sendReact(e, "cringe")} src={cringe} alt="" /> + <img onMouseUp={(e) => sendReact(e, "pepegmi")} src={pepegmi} alt="" /> + <img onMouseUp={(e) => sendReact(e, "pepesad")} src={pepesad} alt="" /> + <img onMouseUp={(e) => sendReact(e, "galaxy")} src={galaxy} alt="" /> + <img onMouseUp={(e) => sendReact(e, "pink")} src={pink} alt="" /> + <img onMouseUp={(e) => sendReact(e, "soy")} src={soy} alt="" /> + <img onMouseUp={(e) => sendReact(e, "cry")} src={cry} alt="" /> + <img onMouseUp={(e) => sendReact(e, "doom")} src={doom} alt="" /> + </div> + </Modal> + ); +} + +export function stringToReact(s: string) { + const em = (emojis as Record<string, string>)[s.replace(/\:/g, "")]; + if (s === "yeschad") + return <img className="react-img" src={yeschad} alt="" />; + if (s === "facepalm") + return <img className="react-img" src={facepalm} alt="" />; + if (s === "yes.jpg") + return <img className="react-img" src={yeschad} alt="" />; + if (s === "gigachad") + return <img className="react-img" src={gigachad} alt="" />; + if (s === "pepechin") + return <img className="react-img" src={pepechin} alt="" />; + if (s === "pepeeyes") + return <img className="react-img" src={pepeeyes} alt="" />; + if (s === "pepegmi") + return <img className="react-img" src={pepegmi} alt="" />; + if (s === "pepesad") + return <img className="react-img" src={pepesad} alt="" />; + if (s === "") + return <img className="react-img no-react" src={emoji} alt="" />; + if (s === "cringe") return <img className="react-img" src={cringe} alt="" />; + if (s === "cry") return <img className="react-img" src={cry} alt="" />; + if (s === "crywojak") return <img className="react-img" src={cry} alt="" />; + if (s === "doom") return <img className="react-img" src={doom} alt="" />; + if (s === "galaxy") return <img className="react-img" src={galaxy} alt="" />; + if (s === "pink") return <img className="react-img" src={pink} alt="" />; + if (s === "pinkwojak") return <img className="react-img" src={pink} alt="" />; + if (s === "soy") return <img className="react-img" src={soy} alt="" />; + if (s === "chad") return <img className="react-img" src={chad} alt="" />; + if (s === "pika") return <img className="react-img" src={pika} alt="" />; + if (em) return <span className="react-icon">{em}</span>; + else if (s.length > 2) return <span className="react-icon"></span>; + else return <span className="react-icon">{s}</span>; +} + +export function TrillReactModal({ poast }: { poast: Poast }) { + const { api } = useLocalState(); + async function sendReact(s: string) { + return await api!.addReact(poast.host, poast.id, s); + } + return <ReactModal send={sendReact} />; +} diff --git a/front/src/components/post/StatsModal.tsx b/front/src/components/post/StatsModal.tsx new file mode 100644 index 0000000..4720b2a --- /dev/null +++ b/front/src/components/post/StatsModal.tsx @@ -0,0 +1,106 @@ +import type { Poast } from "@/types/trill"; +import Modal from "../modals/Modal"; +import { useState } from "react"; +import Post from "./Post"; +import RP from "./RP"; +import Avatar from "../Avatar"; +import { stringToReact } from "./Reactions"; + +function StatsModal({ poast, close }: { close: any; poast: Poast }) { + const [tab, setTab] = useState("replies"); + const replies = poast.children || []; + const quotes = poast.engagement.quoted; + const reposts = poast.engagement.shared; + const reacts = poast.engagement.reacts; + function set(e: React.MouseEvent, s: string) { + e.stopPropagation(); + setTab(s); + } + // TODO revise the global thingy here + return ( + <Modal close={close}> + <div id="stats-modal"> + <Post poast={poast} refetch={() => {}} /> + <div id="tabs"> + <div + role="link" + className={"tab" + (tab === "replies" ? " active-tab" : "")} + onClick={(e) => set(e, "replies")} + > + <h4>Replies</h4> + </div> + <div + role="link" + className={"tab" + (tab === "quotes" ? " active-tab" : "")} + onClick={(e) => set(e, "quotes")} + > + <h4>Quotes</h4> + </div> + <div + role="link" + className={"tab" + (tab === "reposts" ? " active-tab" : "")} + onClick={(e) => set(e, "reposts")} + > + <h4>Reposts</h4> + </div> + <div + role="link" + className={"tab" + (tab === "reacts" ? " active-tab" : "")} + onClick={(e) => set(e, "reacts")} + > + <h4>Reacts</h4> + </div> + </div> + <div id="engagement"> + {tab === "replies" ? ( + <div id="replies"> + {replies.map((p) => ( + <div key={p} className="reply-stat"> + <RP + host={poast.host} + id={p} + rter={undefined} + rtat={undefined} + rtid={undefined} + /> + </div> + ))} + </div> + ) : tab === "quotes" ? ( + <div id="quotes"> + {quotes.map((p) => ( + <div key={p.pid.id} className="quote-stat"> + <RP + host={p.pid.ship} + id={p.pid.id} + rter={undefined} + rtat={undefined} + rtid={undefined} + /> + </div> + ))} + </div> + ) : tab === "reposts" ? ( + <div id="reposts"> + {reposts.map((p) => ( + <div key={p.pid.id} className="repost-stat"> + <Avatar p={p.pid.ship} size={40} /> + </div> + ))} + </div> + ) : tab === "reacts" ? ( + <div id="reacts"> + {Object.keys(reacts).map((p) => ( + <div key={p} className="react-stat btw"> + <Avatar p={p} size={32} /> + {stringToReact(reacts[p])} + </div> + ))} + </div> + ) : null} + </div> + </div> + </Modal> + ); +} +export default StatsModal; diff --git a/front/src/components/post/wrappers/Nostr.tsx b/front/src/components/post/wrappers/Nostr.tsx new file mode 100644 index 0000000..bdc5ba9 --- /dev/null +++ b/front/src/components/post/wrappers/Nostr.tsx @@ -0,0 +1,15 @@ +import type { NostrMetadata, NostrPost } from "@/types/nostrill"; +import Post from "../Post"; +import useLocalState from "@/state/state"; + +export default NostrPost; +function NostrPost({ data }: { data: NostrPost }) { + const { profiles } = useLocalState(); + const profile = profiles.get(data.event.pubkey); + + return <Post poast={data.post} profile={profile} />; +} + +export function NostrSnippet({ eventId, pubkey, relay }: NostrMetadata) { + return <div>wtf</div>; +} diff --git a/front/src/components/post/wrappers/NostrIcon.tsx b/front/src/components/post/wrappers/NostrIcon.tsx new file mode 100644 index 0000000..0c368fb --- /dev/null +++ b/front/src/components/post/wrappers/NostrIcon.tsx @@ -0,0 +1,22 @@ +import nostrIcon from "@/assets/icons/nostr.svg"; +import useLocalState from "@/state/state"; +import toast from "react-hot-toast"; +import type { Poast } from "@/types/trill"; +export default function ({ poast }: { poast: Poast }) { + const { relays, api, keys } = useLocalState(); + + async function sendToRelay(e: React.MouseEvent) { + e.stopPropagation(); + // + const urls = Object.keys(relays); + await api!.relayPost(poast.host, poast.id, urls); + toast.success("Post relayed"); + } + // TODO round up all helpers + + return ( + <div className="icon" role="link" onMouseUp={sendToRelay}> + <img role="link" src={nostrIcon} title="repost" /> + </div> + ); +} |