Compare commits
2 Commits
6927f6eb9b
...
109ef398ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 109ef398ee | |||
| faa96d88f5 |
@@ -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;
|
||||
|
||||
@@ -644,6 +644,7 @@ export async function validateReadMedia(
|
||||
|
||||
export type CreateTweetInput = {
|
||||
content: string;
|
||||
parentTweetId?: UUID | null;
|
||||
mediaId?: UUID;
|
||||
};
|
||||
|
||||
|
||||
@@ -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<UUID>;
|
||||
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<number>;
|
||||
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];
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
</svg>
|
||||
<span>{tweet.likes}</span>
|
||||
</button>
|
||||
<a
|
||||
href={`/feed/${tweet.id}`}
|
||||
className="mx-like-btn mx-comment-btn"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="View comments"
|
||||
>
|
||||
<CommentIcon />
|
||||
<span>{tweet.commentCount ?? 0}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
||||
@@ -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<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) {
|
||||
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 (
|
||||
<div className="mx-compose mx-compose--comment">
|
||||
<div className="mx-compose-avatar mx-compose-avatar--sm">
|
||||
<span>M</span>
|
||||
</div>
|
||||
<div className="mx-compose-body">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="mx-compose-textarea mx-compose-textarea--sm"
|
||||
placeholder="Post your reply…"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={1}
|
||||
maxLength={MAX + 1}
|
||||
/>
|
||||
{error && <p className="mx-compose-error">{error}</p>}
|
||||
<div className="mx-compose-footer">
|
||||
<div />
|
||||
<div className="mx-compose-actions">
|
||||
<CharCount current={text.length} max={MAX} />
|
||||
<button
|
||||
className="mx-btn-post mx-btn-post--sm"
|
||||
onClick={submit}
|
||||
disabled={!text.trim() || mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? "Replying…" : "Reply"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TweetDetail({ tweetId }: { tweetId: string }) {
|
||||
const { userId: currentUserId } = useContext(AuthCtx);
|
||||
const { userId: currentUserId, email } = useContext(AuthCtx);
|
||||
const [lightboxItem, setLightboxItem] = useState<MediaItem | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -698,7 +803,7 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
|
||||
queryKey: ["tweet", tweetId],
|
||||
queryFn: async () => {
|
||||
const res = await readTweet({
|
||||
fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
filter: { id: { eq: tweetId } },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
@@ -708,6 +813,49 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
|
||||
},
|
||||
});
|
||||
|
||||
const commentsSentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
data: commentsData,
|
||||
isLoading: commentsLoading,
|
||||
fetchNextPage: fetchNextComments,
|
||||
hasNextPage: hasMoreComments,
|
||||
isFetchingNextPage: isFetchingMoreComments,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["comments", tweetId],
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const res = await readTweet({
|
||||
fields: ["id", "content", "likes", "likedByMe", "parentTweetId", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
filter: { parentTweetId: { eq: tweetId } },
|
||||
sort: "insertedAt",
|
||||
page: { limit: COMMENTS_PAGE_SIZE, offset: pageParam },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error("Failed to load comments");
|
||||
const pageData = res.data as any;
|
||||
const comments: Tweet[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []);
|
||||
const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false);
|
||||
return { comments, hasMore, nextOffset: pageParam + COMMENTS_PAGE_SIZE };
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = commentsSentinelRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMoreComments && !isFetchingMoreComments) {
|
||||
fetchNextComments();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMoreComments, isFetchingMoreComments, fetchNextComments]);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await destroyTweet({ identity: tweetId, headers: buildCSRFHeaders() });
|
||||
@@ -859,17 +1007,151 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
|
||||
</svg>
|
||||
<span>{tweet.likes}</span>
|
||||
</button>
|
||||
<span className="mx-like-btn mx-comment-count-badge" style={{ cursor: "default" }}>
|
||||
<CommentIcon />
|
||||
<span>{tweet.commentCount ?? 0} {(tweet.commentCount ?? 0) === 1 ? "reply" : "replies"}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
||||
</div>
|
||||
|
||||
{lightboxItem && <MediaLightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />}
|
||||
|
||||
{/* ── Comments section ── */}
|
||||
<div className="mx-comments-section">
|
||||
<div className="mx-comments-divider">
|
||||
<span>Replies</span>
|
||||
</div>
|
||||
|
||||
{email ? (
|
||||
<ComposeComment parentTweetId={tweetId} />
|
||||
) : (
|
||||
<div className="mx-signin-cta mx-signin-cta--sm">
|
||||
<p><a href="/sign-in" style={{ color: "var(--mx-accent)", textDecoration: "none" }}>Sign in</a> to reply.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{commentsLoading ? (
|
||||
<Spinner />
|
||||
) : (() => {
|
||||
const comments = commentsData?.pages.flatMap((p) => p.comments) ?? [];
|
||||
return comments.length > 0 ? (
|
||||
<div className="mx-comments-list">
|
||||
{comments.map((c) => (
|
||||
<CommentCard key={c.id} comment={c} parentTweetOwnerId={tweet?.userId} />
|
||||
))}
|
||||
<div ref={commentsSentinelRef} style={{ height: "1px" }} />
|
||||
{isFetchingMoreComments && <Spinner />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-empty mx-empty--sm">
|
||||
<p className="mx-empty-sub">No replies yet. Be the first!</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentCard({ comment, parentTweetOwnerId }: { comment: Tweet; parentTweetOwnerId?: string }) {
|
||||
const { userId: currentUserId } = useContext(AuthCtx);
|
||||
const canLike = !!currentUserId;
|
||||
const canModify = !!currentUserId && (
|
||||
comment.userId === currentUserId || parentTweetOwnerId === currentUserId
|
||||
);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await destroyTweet({ identity: comment.id, headers: buildCSRFHeaders() });
|
||||
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["comments", comment.parentTweetId] });
|
||||
qc.invalidateQueries({ queryKey: ["tweet", comment.parentTweetId] });
|
||||
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||
qc.invalidateQueries({ queryKey: ["following_tweets"] });
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const likeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const action = comment.likedByMe ? unlikeTweet : likeTweet;
|
||||
const res = await action({ identity: comment.id, fields: ["id", "likes", "likedByMe"], headers: buildCSRFHeaders() });
|
||||
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed");
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", comment.parentTweetId] }),
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<article className="mx-tweet mx-comment">
|
||||
<div className="mx-tweet-avatar mx-tweet-avatar--sm">
|
||||
<span>M</span>
|
||||
</div>
|
||||
<div className="mx-tweet-body">
|
||||
<div className="mx-tweet-header">
|
||||
<span className="mx-tweet-handle">{comment.userEmail ?? "@mixer"}</span>
|
||||
<span className="mx-tweet-dot">·</span>
|
||||
<span className="mx-tweet-time" title={comment.insertedAt ? new Date(comment.insertedAt).toLocaleString() : undefined}>{timeAgo(comment.insertedAt)}</span>
|
||||
{canModify && (
|
||||
<div className="mx-tweet-actions">
|
||||
<button
|
||||
className={`mx-action-btn mx-action-delete${confirmDelete ? " mx-action-confirm" : ""}`}
|
||||
title={confirmDelete ? "Confirm delete" : "Delete"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirmDelete) {
|
||||
setConfirmDelete(true);
|
||||
setTimeout(() => setConfirmDelete(false), 3000);
|
||||
} else {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<span style={{ fontSize: "0.65rem" }}>…</span>
|
||||
) : confirmDelete ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mx-tweet-text">{comment.content}</p>
|
||||
{comment.media && comment.media.length > 0 && <TweetMedia media={comment.media} />}
|
||||
<div className="mx-tweet-footer">
|
||||
<button
|
||||
className={`mx-like-btn${comment.likedByMe ? " mx-like-btn-active" : ""}`}
|
||||
onClick={() => likeMutation.mutate()}
|
||||
disabled={!canLike || likeMutation.isPending}
|
||||
title={canLike ? (comment.likedByMe ? "Remove like" : "Like reply") : "Sign in to like replies"}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.1 21.35 10.55 19.93C5.4 15.27 2 12.19 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.69-3.4 6.77-8.55 11.44z" />
|
||||
</svg>
|
||||
<span>{comment.likes}</span>
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="mx-compose-error">{error}</p>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const FEED_PAGE_SIZE = 10;
|
||||
const COMMENTS_PAGE_SIZE = 10;
|
||||
|
||||
function FollowingFeed() {
|
||||
const { userId } = useContext(AuthCtx);
|
||||
@@ -887,9 +1169,10 @@ function FollowingFeed() {
|
||||
queryKey: ["following_tweets"],
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const res = await readFollowingFeed({
|
||||
fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
sort: "-insertedAt",
|
||||
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
|
||||
filter: { parentTweetId: { isNil: true } },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error("Failed to load following feed");
|
||||
@@ -980,9 +1263,10 @@ function Feed() {
|
||||
queryKey: ["tweets"],
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const res = await readTweet({
|
||||
fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
sort: "-insertedAt",
|
||||
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
|
||||
filter: { parentTweetId: { isNil: true } },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error("Failed to load tweets");
|
||||
|
||||
@@ -12,6 +12,10 @@ defmodule Mixer.Posts.Tweet do
|
||||
postgres do
|
||||
table "tweets"
|
||||
repo Mixer.Repo
|
||||
|
||||
references do
|
||||
reference :parent_tweet, on_delete: :delete
|
||||
end
|
||||
end
|
||||
|
||||
state_machine do
|
||||
@@ -39,7 +43,7 @@ defmodule Mixer.Posts.Tweet do
|
||||
|
||||
create :create do
|
||||
upsert? true
|
||||
accept [:content]
|
||||
accept [:content, :parent_tweet_id]
|
||||
argument :media_id, :uuid, allow_nil?: true
|
||||
change relate_actor(:user)
|
||||
change transition_state(:posted)
|
||||
@@ -136,6 +140,7 @@ defmodule Mixer.Posts.Tweet do
|
||||
|
||||
policy action(:destroy) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
authorize_if relates_to_actor_via([:parent_tweet, :user])
|
||||
end
|
||||
|
||||
policy action(:like) do
|
||||
@@ -181,6 +186,18 @@ defmodule Mixer.Posts.Tweet do
|
||||
public? true
|
||||
end
|
||||
|
||||
belongs_to :parent_tweet, Mixer.Posts.Tweet do
|
||||
attribute_type :uuid
|
||||
attribute_writable? true
|
||||
allow_nil? true
|
||||
public? true
|
||||
end
|
||||
|
||||
has_many :comments, Mixer.Posts.Tweet do
|
||||
destination_attribute :parent_tweet_id
|
||||
public? true
|
||||
end
|
||||
|
||||
has_many :media, Mixer.Posts.Media do
|
||||
public? true
|
||||
end
|
||||
@@ -195,6 +212,10 @@ defmodule Mixer.Posts.Tweet do
|
||||
end
|
||||
|
||||
aggregates do
|
||||
count :comment_count, :comments do
|
||||
public? true
|
||||
end
|
||||
|
||||
exists :liked_by_me, :tweet_likes do
|
||||
public? true
|
||||
filter expr(user_id == ^actor(:id))
|
||||
|
||||
30
priv/repo/migrations/20260406180126_add_tweet_comments.exs
Normal file
30
priv/repo/migrations/20260406180126_add_tweet_comments.exs
Normal file
@@ -0,0 +1,30 @@
|
||||
defmodule Mixer.Repo.Migrations.AddTweetComments do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:tweets) do
|
||||
add :parent_tweet_id,
|
||||
references(:tweets,
|
||||
column: :id,
|
||||
name: "tweets_parent_tweet_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public",
|
||||
on_delete: :delete_all
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
drop constraint(:tweets, "tweets_parent_tweet_id_fkey")
|
||||
|
||||
alter table(:tweets) do
|
||||
remove :parent_tweet_id
|
||||
end
|
||||
end
|
||||
end
|
||||
154
priv/resource_snapshots/repo/tweets/20260406180127.json
Normal file
154
priv/resource_snapshots/repo/tweets/20260406180127.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "content",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "0",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "likes",
|
||||
"type": "bigint"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "tweets_user_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "users"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "user_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "\"drafted\"",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "state",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "tweets_parent_tweet_id_fkey",
|
||||
"on_delete": "delete",
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "tweets"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "parent_tweet_id",
|
||||
"type": "uuid"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "090E928120B2CFAA2B8D5D2EB43AD6E782ABB552AFC211BB6173D6337F487218",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mixer.Repo",
|
||||
"schema": null,
|
||||
"table": "tweets"
|
||||
}
|
||||
Reference in New Issue
Block a user