From faa96d88f51eceb848649d0a7018571a8a15f8d2 Mon Sep 17 00:00:00 2001 From: qdust41 Date: Mon, 6 Apr 2026 14:11:10 -0400 Subject: [PATCH] added comments to tweets --- assets/css/app.css | 98 +++++++ assets/js/ash_rpc.ts | 1 + assets/js/ash_types.ts | 35 ++- assets/js/index.tsx | 256 +++++++++++++++++- lib/mixer/posts/tweet.ex | 22 +- .../20260406180126_add_tweet_comments.exs | 30 ++ .../repo/tweets/20260406180127.json | 154 +++++++++++ 7 files changed, 587 insertions(+), 9 deletions(-) create mode 100644 priv/repo/migrations/20260406180126_add_tweet_comments.exs create mode 100644 priv/resource_snapshots/repo/tweets/20260406180127.json diff --git a/assets/css/app.css b/assets/css/app.css index 4dd6a10..2d465c8 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -696,6 +696,104 @@ html, body { margin-bottom: 1rem; } +/* ── Comment button on tweet cards ── */ +.mx-comment-btn { + text-decoration: none; + margin-left: 0.5rem; + color: var(--mx-fg2); + transition: color 0.15s, border-color 0.15s, background 0.15s, transform 0.15s; +} +.mx-comment-btn:hover { + color: var(--mx-accent); + border-color: color-mix(in oklch, var(--mx-accent) 35%, transparent); + background: color-mix(in oklch, var(--mx-accent) 10%, transparent); + transform: translateY(-1px); +} + +/* Non-interactive reply count badge in detail view */ +.mx-comment-count-badge { + margin-left: 0.5rem; + cursor: default; + pointer-events: none; + color: var(--mx-fg2); +} + +/* ── Comments section (below tweet detail) ── */ +.mx-comments-section { + border-top: 1px solid var(--mx-border); + margin-top: 0.5rem; + padding: 0 1.5rem 1.5rem; +} + +.mx-comments-divider { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 0 0.75rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--mx-fg2); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.mx-comments-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--mx-border); +} + +.mx-comments-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.75rem; +} + +/* Comment card — slightly indented, more compact */ +.mx-comment { + padding: 0.75rem 1rem; + border-radius: var(--mx-radius-sm); +} + +/* Small avatar variant for comments and compose-comment */ +.mx-tweet-avatar--sm { + width: 28px; + height: 28px; + min-width: 28px; + font-size: 0.75rem; +} + +/* Compact compose box for replies */ +.mx-compose--comment { + padding: 0.75rem 0; + border-bottom: 1px solid var(--mx-border); + margin-bottom: 0.25rem; +} +.mx-compose--comment .mx-compose-avatar--sm { align-self: flex-start; } +.mx-compose-textarea--sm { + min-height: 2.5rem; + padding: 0.4rem 0.6rem; + font-size: 0.9375rem; +} + +.mx-btn-post--sm { + padding: 0.35rem 0.875rem; + font-size: 0.8125rem; +} + +/* Small empty state */ +.mx-empty--sm { + padding: 1.5rem 0.5rem; +} + +/* Small sign-in CTA */ +.mx-signin-cta--sm { + padding: 0.75rem 0; + font-size: 0.875rem; + color: var(--mx-muted); +} + /* ── Clickable media thumb (used in detail view) ── */ .mx-media-thumb { background: none; diff --git a/assets/js/ash_rpc.ts b/assets/js/ash_rpc.ts index d7de9ad..5296cd4 100644 --- a/assets/js/ash_rpc.ts +++ b/assets/js/ash_rpc.ts @@ -644,6 +644,7 @@ export async function validateReadMedia( export type CreateTweetInput = { content: string; + parentTweetId?: UUID | null; mediaId?: UUID; }; diff --git a/assets/js/ash_types.ts b/assets/js/ash_types.ts index b4b759f..6f2a25e 100644 --- a/assets/js/ash_types.ts +++ b/assets/js/ash_types.ts @@ -71,16 +71,20 @@ export type mediaAttributesOnlySchema = { // tweets Schema export type tweetsResourceSchema = { __type: "Resource"; - __primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "likedByMe" | "userEmail"; + __primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId" | "commentCount" | "likedByMe" | "userEmail"; id: UUID; content: string; likes: number; userId: UUID; insertedAt: UtcDateTimeUsec; state: "posted" | "drafted"; + parentTweetId: UUID | null; + commentCount: number; likedByMe: boolean; userEmail: string | null; user: { __type: "Relationship"; __resource: usersResourceSchema; }; + parentTweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; }; + comments: { __type: "Relationship"; __array: true; __resource: tweetsResourceSchema; }; media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; }; }; @@ -88,13 +92,14 @@ export type tweetsResourceSchema = { export type tweetsAttributesOnlySchema = { __type: "Resource"; - __primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state"; + __primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId"; id: UUID; content: string; likes: number; userId: UUID; insertedAt: UtcDateTimeUsec; state: "posted" | "drafted"; + parentTweetId: UUID | null; }; @@ -251,6 +256,13 @@ export type tweetsFilterInput = { in?: Array<"posted" | "drafted">; }; + parentTweetId?: { + eq?: UUID; + notEq?: UUID; + in?: Array; + isNil?: boolean; + }; + userEmail?: { eq?: string; notEq?: string; @@ -258,6 +270,17 @@ export type tweetsFilterInput = { isNil?: boolean; }; + commentCount?: { + eq?: number; + notEq?: number; + greaterThan?: number; + greaterThanOrEqual?: number; + lessThan?: number; + lessThanOrEqual?: number; + in?: Array; + isNil?: boolean; + }; + likedByMe?: { eq?: boolean; notEq?: boolean; @@ -266,6 +289,10 @@ export type tweetsFilterInput = { user?: usersFilterInput; + parentTweet?: tweetsFilterInput; + + comments?: tweetsFilterInput; + media?: mediaFilterInput; }; @@ -280,7 +307,7 @@ export type usersFilterField = (typeof usersFilterFields)[number]; export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const; export type mediaFilterField = (typeof mediaFilterFields)[number]; -export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "userEmail", "likedByMe", "user", "media"] as const; +export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "commentCount", "likedByMe", "user", "parentTweet", "comments", "media"] as const; export type tweetsFilterField = (typeof tweetsFilterFields)[number]; @@ -293,7 +320,7 @@ export type usersSortField = (typeof usersSortFields)[number]; export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const; export type mediaSortField = (typeof mediaSortFields)[number]; -export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "userEmail", "likedByMe"] as const; +export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "commentCount", "likedByMe"] as const; export type tweetsSortField = (typeof tweetsSortFields)[number]; diff --git a/assets/js/index.tsx b/assets/js/index.tsx index d08fe97..278c8e4 100644 --- a/assets/js/index.tsx +++ b/assets/js/index.tsx @@ -44,6 +44,8 @@ type Tweet = { content: string; likes: number; likedByMe?: boolean; + commentCount?: number; + parentTweetId?: string | null; userId: string; state: string; media?: MediaItem[]; @@ -425,6 +427,14 @@ function TweetMedia({ media }: { media: MediaItem[] }) { ); } +function CommentIcon() { + return ( + + + + ); +} + function TweetCard({ tweet }: { tweet: Tweet }) { const { userId: currentUserId } = useContext(AuthCtx); const canModify = !!currentUserId && tweet.userId === currentUserId; @@ -644,6 +654,15 @@ function TweetCard({ tweet }: { tweet: Tweet }) { {tweet.likes} + e.stopPropagation()} + title="View comments" + > + + {tweet.commentCount ?? 0} + {error && !editing &&

{error}

} @@ -684,8 +703,94 @@ function MediaLightbox({ item, onClose }: { item: MediaItem; onClose: () => void ); } +function ComposeComment({ parentTweetId, onSuccess }: { parentTweetId: string; onSuccess?: () => void }) { + const [text, setText] = useState(""); + const [error, setError] = useState(null); + const textareaRef = useRef(null); + const qc = useQueryClient(); + const MAX = 280; + + const mutation = useMutation({ + mutationFn: async (content: string) => { + const res = await createTweet({ + input: { content, parentTweetId }, + fields: ["id", "content", "userId", "state"], + headers: buildCSRFHeaders(), + }); + if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed"); + return res.data; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["comments", parentTweetId] }); + qc.invalidateQueries({ queryKey: ["tweet", parentTweetId] }); + qc.invalidateQueries({ queryKey: ["tweets"] }); + qc.invalidateQueries({ queryKey: ["following_tweets"] }); + setText(""); + setError(null); + onSuccess?.(); + }, + onError: (e: Error) => setError(e.message), + }); + + function handleKeyDown(e: React.KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + submit(); + } + } + + function submit() { + const trimmed = text.trim(); + if (!trimmed) return; + if (trimmed.length > MAX) { setError(`Max ${MAX} characters`); return; } + setError(null); + mutation.mutate(trimmed); + } + + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }, [text]); + + return ( +
+
+ M +
+
+