added comments to tweets
This commit is contained in:
@@ -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,21 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: comments, isLoading: commentsLoading } = useQuery({
|
||||
queryKey: ["comments", tweetId],
|
||||
queryFn: async () => {
|
||||
const res = await readTweet({
|
||||
fields: ["id", "content", "likes", "likedByMe", "parentTweetId", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
filter: { parentTweetId: { eq: tweetId } },
|
||||
sort: "insertedAt",
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error("Failed to load comments");
|
||||
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
|
||||
return results as Tweet[];
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await destroyTweet({ identity: tweetId, headers: buildCSRFHeaders() });
|
||||
@@ -859,16 +979,142 @@ 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 />
|
||||
) : comments && comments.length > 0 ? (
|
||||
<div className="mx-comments-list">
|
||||
{comments.map((c) => (
|
||||
<CommentCard key={c.id} comment={c} />
|
||||
))}
|
||||
</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 }: { comment: Tweet }) {
|
||||
const { userId: currentUserId } = useContext(AuthCtx);
|
||||
const canLike = !!currentUserId;
|
||||
const canModify = !!currentUserId && comment.userId === 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;
|
||||
|
||||
function FollowingFeed() {
|
||||
@@ -887,9 +1133,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 +1227,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");
|
||||
|
||||
Reference in New Issue
Block a user