summaryrefslogtreecommitdiff
path: root/front
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-09-18 08:26:30 +0700
committerpolwex <polwex@sortug.com>2025-09-18 08:26:30 +0700
commit7bac4927e8895719a91011da9a2b997579238145 (patch)
tree8b47f7370718e874af9a292a1b8e0e09555f3990 /front
parent0549cfd01f1feed9c313a84a3884328d08887caf (diff)
damn my trill codebase was really something
Diffstat (limited to 'front')
-rw-r--r--front/src/Router.tsx2
-rw-r--r--front/src/components/composer/Composer.tsx198
-rw-r--r--front/src/components/composer/Snippets.tsx82
-rw-r--r--front/src/components/post/Body.tsx2
-rw-r--r--front/src/components/post/Footer.tsx62
-rw-r--r--front/src/components/post/Loader.tsx124
-rw-r--r--front/src/components/post/Reactions.tsx4
-rw-r--r--front/src/logic/requests/nostrill.ts62
-rw-r--r--front/src/logic/trill/helpers.ts10
-rw-r--r--front/src/pages/Thread.tsx127
-rw-r--r--front/src/styles/styles.css412
-rw-r--r--front/src/styles/trill.css11
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; */