import React, { createContext, useContext, useState, useRef, useEffect, useSyncExternalStore } from "react";
import { createRoot } from "react-dom/client";
import { createPortal } from "react-dom";
import {
QueryClient,
QueryClientProvider,
useQuery,
useInfiniteQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import {
createTweet,
readTweet,
readFollowingFeed,
destroyTweet,
likeTweet,
unlikeTweet,
updateTweet,
updateProfile,
readUser,
followUser,
unfollowUser,
buildCSRFHeaders,
} from "./ash_rpc";
import { uploadFile, uploadAvatar } from "./upload";
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 10_000 } },
});
// ── Types ──────────────────────────────────────────────────────────────────────
type User = {
id: string;
email: string;
username?: string | null;
displayName?: string | null;
avatarUrl?: string | null;
followerCount?: number;
followingCount?: number;
amIFollowing?: boolean;
myFollowId?: string | null;
};
type MediaItem = { id: string; s3Key: string };
type Tweet = {
id: string;
content: string;
likes: number;
likedByMe?: boolean;
commentCount?: number;
parentTweetId?: string | null;
userId: string;
state: string;
media?: MediaItem[];
userEmail?: string | null;
userUsername?: string | null;
userDisplayName?: string | null;
userAvatarUrl?: string | null;
insertedAt?: string | null;
};
// ── Auth context ───────────────────────────────────────────────────────────────
const AuthCtx = createContext({
email: "",
userId: "",
username: "",
displayName: "",
avatarUrl: "",
});
// ── Responsive helper ─────────────────────────────────────────────────────────
// Returns true when the viewport is wider than 960 px (desktop layout).
// Uses useSyncExternalStore so it re-renders on resize without a manual
// useEffect + useState dance.
const DESKTOP_MQ = typeof window !== "undefined"
? window.matchMedia("(min-width: 961px)")
: null;
function subscribe(cb: () => void) {
DESKTOP_MQ?.addEventListener("change", cb);
return () => DESKTOP_MQ?.removeEventListener("change", cb);
}
function useIsDesktop(): boolean {
return useSyncExternalStore(
subscribe,
() => DESKTOP_MQ?.matches ?? true,
() => true, // SSR snapshot (never actually used here)
);
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function timeAgo(insertedAt?: string | null): string {
if (!insertedAt) return "just now";
const now = Date.now();
const then = new Date(insertedAt).getTime();
const diffSec = Math.floor((now - then) / 1000);
if (diffSec < 5) return "just now";
if (diffSec < 60) return `${diffSec}s`;
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h`;
const diffDay = Math.floor(diffHr / 24);
if (diffDay < 7) return `${diffDay}d`;
return new Date(insertedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function getAssetHost(): string {
const appEl = document.getElementById("app");
return appEl?.dataset.assetHost ?? "http://localhost:9000";
}
// ── Display-name helpers ─────────────────────────────────────────────
function userDisplayLabel(u: {
displayName?: string | null;
username?: string | null;
email?: string | null;
}): string {
return u.displayName || u.username || u.email || "@mixer";
}
function userHandle(u: { username?: string | null; email?: string | null }): string {
return u.username ? `@${u.username}` : u.email ?? "@mixer";
}
// ── Avatar ───────────────────────────────────────────────────────────────
function Avatar({
avatarUrl,
name,
size = "md",
}: {
avatarUrl?: string | null;
name?: string | null;
size?: "sm" | "md" | "lg";
}) {
const assetHost = getAssetHost();
const initial = ((name ?? "")[0] || "M").toUpperCase();
const cls = size === "sm"
? "mx-tweet-avatar mx-tweet-avatar--sm"
: size === "lg"
? "mx-tweet-avatar mx-tweet-avatar--lg"
: "mx-tweet-avatar";
return (
{avatarUrl ? (

) : (
{initial}
)}
);
}
// ── Context menu ──────────────────────────────────────────────────────────────
type ContextMenuItem =
| { type: "item"; label: string; onClick: () => void }
| { type: "separator" };
function ContextMenu({
x,
y,
items,
onClose,
}: {
x: number;
y: number;
items: ContextMenuItem[];
onClose: () => void;
}) {
const ref = useRef(null);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose]);
const itemCount = items.filter((i) => i.type === "item").length;
const sepCount = items.filter((i) => i.type === "separator").length;
const menuH = itemCount * 34 + sepCount * 9 + 8;
const menuW = 180;
const left = Math.min(x, window.innerWidth - menuW - 8);
const top = Math.min(y, window.innerHeight - menuH - 8);
return createPortal(
e.preventDefault()}
>
{items.map((item, i) =>
item.type === "separator" ? (
) : (
)
)}
,
document.body
);
}
// ── Components ─────────────────────────────────────────────────────────────────
function Spinner() {
return (
);
}
function ErrorBanner({ message }: { message: string }) {
return (
⚠
{message}
);
}
function CharCount({ current, max }: { current: number; max: number }) {
const remaining = max - current;
const pct = current / max;
const color =
pct > 0.9 ? "#ef4444" : pct > 0.75 ? "#f59e0b" : "var(--mx-muted)";
return (
{remaining}
);
}
function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
const { username, displayName, email, avatarUrl } = useContext(AuthCtx);
const [text, setText] = useState("");
const [error, setError] = useState(null);
const [pendingFile, setPendingFile] = useState(null);
const [previewUrl, setPreviewUrl] = useState(null);
const [mediaId, setMediaId] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState(null);
const textareaRef = useRef(null);
const fileInputRef = useRef(null);
const qc = useQueryClient();
const MAX = 280;
const mutation = useMutation({
mutationFn: async (content: string) => {
const res = await createTweet({
input: { content, mediaId: mediaId ?? undefined },
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: ["tweets"] });
qc.invalidateQueries({ queryKey: ["following_tweets"] });
setText("");
setError(null);
setMediaId(null);
setPendingFile(null);
setUploadError(null);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
onSuccess?.();
},
onError: (e: Error) => setError(e.message),
});
async function handleFileChange(e: React.ChangeEvent) {
const file = e.target.files?.[0];
if (!file) return;
// Reset the input so the same file can be re-selected after removal
e.target.value = "";
// Revoke any previous object URL to avoid memory leaks
if (previewUrl) URL.revokeObjectURL(previewUrl);
const localUrl = URL.createObjectURL(file);
setPendingFile(file);
setPreviewUrl(localUrl);
setMediaId(null);
setUploadError(null);
setUploading(true);
const csrfToken = buildCSRFHeaders()["X-CSRF-Token"] as string;
const result = await uploadFile(file, csrfToken);
setUploading(false);
if ("error" in result) {
setUploadError(result.error);
setPendingFile(null);
URL.revokeObjectURL(localUrl);
setPreviewUrl(null);
} else {
setMediaId(result.mediaId);
}
}
function removeAttachment() {
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPendingFile(null);
setPreviewUrl(null);
setMediaId(null);
setUploadError(null);
}
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);
}
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [text]);
return (
);
}
function TweetMedia({ media }: { media: MediaItem[] }) {
const assetHost = getAssetHost();
return (
{media.map((m) =>
/\.(mp4|mov)$/i.test(m.s3Key) ? (
) : (

)
)}
);
}
function CommentIcon() {
return (
);
}
function TweetCard({ tweet }: { tweet: Tweet }) {
const { userId: currentUserId } = useContext(AuthCtx);
const canModify = !!currentUserId && tweet.userId === currentUserId;
const canLike = !!currentUserId;
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState(tweet.content);
const [error, setError] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const qc = useQueryClient();
const tweetUrl = `${window.location.origin}/feed/${tweet.id}`;
const ctxItems: ContextMenuItem[] = canModify
? [
{
type: "item",
label: "Edit",
onClick: () => {
setEditText(tweet.content);
setEditing(true);
setConfirmDelete(false);
},
},
{ type: "separator" },
{
type: "item",
label: "Share",
onClick: () => navigator.clipboard.writeText(tweetUrl),
},
]
: [
{
type: "item",
label: "View",
onClick: () => { window.location.href = tweetUrl; },
},
{ type: "separator" },
{
type: "item",
label: "Share",
onClick: () => navigator.clipboard.writeText(tweetUrl),
},
];
const deleteMutation = useMutation({
mutationFn: async () => {
const res = await destroyTweet({
identity: tweet.id,
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tweets"] });
qc.invalidateQueries({ queryKey: ["following_tweets"] });
},
onError: (e: Error) => setError(e.message),
});
const updateMutation = useMutation({
mutationFn: async (content: string) => {
const res = await updateTweet({
identity: tweet.id,
input: { content },
fields: ["id", "content", "userId", "state"],
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update");
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tweets"] });
qc.invalidateQueries({ queryKey: ["following_tweets"] });
setEditing(false);
setError(null);
},
onError: (e: Error) => setError(e.message),
});
const likeMutation = useMutation({
mutationFn: async () => {
const action = tweet.likedByMe ? unlikeTweet : likeTweet;
const res = await action({
identity: tweet.id,
fields: ["id", "likes", "likedByMe"],
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update like");
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tweets"] });
qc.invalidateQueries({ queryKey: ["following_tweets"] });
setError(null);
},
onError: (e: Error) => setError(e.message),
});
function saveEdit() {
const trimmed = editText.trim();
if (!trimmed) return;
updateMutation.mutate(trimmed);
}
return (
{ window.location.href = `/feed/${tweet.id}`; }}
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
>
{userDisplayLabel({ displayName: tweet.userDisplayName, username: tweet.userUsername, email: tweet.userEmail })}
{tweet.userUsername && (
@{tweet.userUsername}
)}
·
{timeAgo(tweet.insertedAt)}
{canModify && (
)}
{editing ? (
) : (
{tweet.content}
)}
{tweet.media && tweet.media.length > 0 && (
)}
{error && !editing &&
{error}
}
{ctxMenu && (
setCtxMenu(null)}
/>
)}
);
}
function MediaLightbox({ item, onClose }: { item: MediaItem; onClose: () => void }) {
const assetHost = getAssetHost();
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
return createPortal(
e.stopPropagation()}>
{/\.(mp4|mov)$/i.test(item.s3Key) ? (
) : (

)}
,
document.body
);
}
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]);
const { username, displayName, email, avatarUrl } = useContext(AuthCtx);
return (
);
}
function TweetDetail({ tweetId }: { tweetId: string }) {
const { userId: currentUserId, email } = useContext(AuthCtx);
const [lightboxItem, setLightboxItem] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState("");
const [error, setError] = useState(null);
const qc = useQueryClient();
const assetHost = getAssetHost();
const { data: tweet, isLoading, isError } = useQuery({
queryKey: ["tweet", tweetId],
queryFn: async () => {
const res = await readTweet({
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
filter: { id: { eq: tweetId } },
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error("Failed to load tweet");
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
return (results[0] as Tweet) ?? null;
},
});
const commentsSentinelRef = useRef(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", "userUsername", "userDisplayName", "userAvatarUrl", "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() });
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
},
onSuccess: () => { window.location.href = "/feed"; },
onError: (e: Error) => setError(e.message),
});
const updateMutation = useMutation({
mutationFn: async (content: string) => {
const res = await updateTweet({
identity: tweetId,
input: { content },
fields: ["id", "content", "userId", "state"],
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update");
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tweet", tweetId] });
setEditing(false);
setError(null);
},
onError: (e: Error) => setError(e.message),
});
const likeMutation = useMutation({
mutationFn: async () => {
if (!tweet) return;
const action = tweet.likedByMe ? unlikeTweet : likeTweet;
const res = await action({ identity: tweetId, fields: ["id", "likes", "likedByMe"], headers: buildCSRFHeaders() });
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update like");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["tweet", tweetId] }),
onError: (e: Error) => setError(e.message),
});
if (isLoading) return ;
if (isError || !tweet) return ;
const canModify = !!currentUserId && tweet.userId === currentUserId;
const canLike = !!currentUserId;
return (
Back
{canModify && (
)}
{userDisplayLabel({ displayName: tweet.userDisplayName, username: tweet.userUsername, email: tweet.userEmail })}
{tweet.userUsername && (
@{tweet.userUsername}
)}
{editing ? (
) : (
{tweet.content}
)}
{tweet.media && tweet.media.length > 0 && (
{tweet.media.map((m) => (
))}
)}
{tweet.commentCount ?? 0} {(tweet.commentCount ?? 0) === 1 ? "reply" : "replies"}
{error && !editing &&
{error}
}
{lightboxItem &&
setLightboxItem(null)} />}
{/* ── Comments section ── */}
Replies
{email ? (
) : (
)}
{commentsLoading ? (
) : (() => {
const comments = commentsData?.pages.flatMap((p) => p.comments) ?? [];
return comments.length > 0 ? (
{comments.map((c) => (
))}
{isFetchingMoreComments &&
}
) : (
No replies yet. Be the first!
);
})()}
);
}
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(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 (
{userDisplayLabel({ displayName: comment.userDisplayName, username: comment.userUsername, email: comment.userEmail })}
{comment.userUsername && (
@{comment.userUsername}
)}
·
{timeAgo(comment.insertedAt)}
{canModify && (
)}
{comment.content}
{comment.media && comment.media.length > 0 &&
}
{error &&
{error}
}
);
}
const FEED_PAGE_SIZE = 10;
const COMMENTS_PAGE_SIZE = 10;
function FollowingFeed() {
const { userId } = useContext(AuthCtx);
const sentinelRef = useRef(null);
const {
data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["following_tweets"],
queryFn: async ({ pageParam }: { pageParam: number }) => {
const res = await readFollowingFeed({
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "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");
const pageData = res.data as any;
const tweets: Tweet[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []);
const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false);
return { tweets, hasMore, nextOffset: pageParam + FEED_PAGE_SIZE };
},
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
enabled: !!userId,
});
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(el);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (!userId) {
return (
★
Your personalised feed
Sign in
{" "}to see posts from people you follow.
);
}
if (isLoading) return ;
if (isError) {
return (
);
}
const tweets = data?.pages.flatMap((p) => p.tweets) ?? [];
if (tweets.length === 0) {
return (
★
Nothing here yet
Follow some people from the{" "}
Users
{" "}page to fill this feed.
);
}
return (
{tweets.map((t) => (
))}
{isFetchingNextPage &&
}
);
}
function Feed() {
const sentinelRef = useRef(null);
const {
data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["tweets"],
queryFn: async ({ pageParam }: { pageParam: number }) => {
const res = await readTweet({
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "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");
const pageData = res.data as any;
const tweets: Tweet[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []);
const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false);
return { tweets, hasMore, nextOffset: pageParam + FEED_PAGE_SIZE };
},
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
});
// IntersectionObserver — fires fetchNextPage when the sentinel div scrolls into view
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(el);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) return ;
if (isError) {
return (
);
}
const tweets = data?.pages.flatMap((p) => p.tweets) ?? [];
if (tweets.length === 0) {
return (
◎
Nothing posted yet
Be the first to mix something in.
);
}
return (
{tweets.map((t) => (
))}
{/* Sentinel element — entering the viewport triggers loading the next page */}
{isFetchingNextPage &&
}
);
}
function RefreshButton({ queryKey = ["tweets"] }: { queryKey?: string[] }) {
const qc = useQueryClient();
const [spinning, setSpinning] = useState(false);
async function refresh() {
setSpinning(true);
await qc.invalidateQueries({ queryKey });
setTimeout(() => setSpinning(false), 600);
}
return (
);
}
function useFollowUser(targetUserId: string) {
const qc = useQueryClient();
const followMutation = useMutation({
mutationFn: async () => {
const res = await followUser({
input: { followingId: targetUserId },
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error((res.errors?.[0] as any)?.message ?? "Follow failed");
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["users"] });
qc.invalidateQueries({ queryKey: ["user", targetUserId] });
},
});
const unfollowMutation = useMutation({
mutationFn: async () => {
const res = await unfollowUser({
input: { followingId: targetUserId },
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error((res.errors?.[0] as any)?.message ?? "Unfollow failed");
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["users"] });
qc.invalidateQueries({ queryKey: ["user", targetUserId] });
},
});
return {
follow: () => followMutation.mutate(),
unfollow: () => unfollowMutation.mutate(),
isPending: followMutation.isPending || unfollowMutation.isPending,
};
}
function FollowButton({ amIFollowing, isPending, onToggle }: { amIFollowing: boolean; isPending: boolean; onToggle: () => void }) {
return (
);
}
function UserCard({ user }: { user: User }) {
const { userId: currentUserId } = useContext(AuthCtx);
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const { follow, unfollow, isPending } = useFollowUser(user.id);
const userUrl = `${window.location.origin}/users/${user.id}`;
const canFollow = !!currentUserId && currentUserId !== user.id;
const amIFollowing = user.amIFollowing ?? false;
const ctxItems: ContextMenuItem[] = [
{ type: "item", label: "Share", onClick: () => navigator.clipboard.writeText(userUrl) },
...(canFollow ? [
{ type: "separator" as const },
amIFollowing
? { type: "item" as const, label: "Unfollow", onClick: unfollow }
: { type: "item" as const, label: "Follow", onClick: follow },
] : []),
];
return (
{ window.location.href = `/users/${user.id}`; }}
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
>
{userDisplayLabel(user)}
{user.username && (
@{user.username}
)}
{(user.followerCount !== undefined || user.followingCount !== undefined) && (
{user.followerCount ?? 0} followers
{user.followingCount ?? 0} following
)}
{canFollow && (
)}
{ctxMenu && (
setCtxMenu(null)} />
)}
);
}
function UserList() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ["users"],
queryFn: async () => {
const res = await readUser({
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error("Failed to load users");
const users = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
return users as User[];
},
});
if (isLoading) return ;
if (isError) return ;
const users = data ?? [];
if (users.length === 0) {
return (
◎
No users yet
Be the first to sign up.
);
}
return (
{users.map((u) => (
))}
);
}
function UserDetail({ userId, isStandalone = false }: { userId: string; isStandalone?: boolean }) {
const { userId: currentUserId } = useContext(AuthCtx);
const { follow, unfollow, isPending } = useFollowUser(userId);
const { data: user, isLoading, isError } = useQuery({
queryKey: ["user", userId],
queryFn: async () => {
const res = await readUser({
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
filter: { id: { eq: userId } },
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error("Failed to load user");
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
return (results[0] as User) ?? null;
},
});
if (isLoading) return ;
if (isError || !user) return ;
const isOwnProfile = currentUserId === userId;
const canFollow = !!currentUserId && !isOwnProfile;
const amIFollowing = user.amIFollowing ?? false;
return (
{!isStandalone && (
)}
{userDisplayLabel(user)}
{user.username && (
@{user.username}
)}
{canFollow && (
)}
{user.followerCount ?? 0} followers
{user.followingCount ?? 0} following
);
}
function ProfileEditor({ userId }: { userId: string }) {
const assetHost = getAssetHost();
const qc = useQueryClient();
const { data: user, isLoading } = useQuery({
queryKey: ["user", userId],
queryFn: async () => {
const res = await readUser({
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
filter: { id: { eq: userId } },
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error("Failed to load user");
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
return (results[0] as User) ?? null;
},
});
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [saveError, setSaveError] = useState(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const [avatarUploading, setAvatarUploading] = useState(false);
const [avatarError, setAvatarError] = useState(null);
const [previewAvatarUrl, setPreviewAvatarUrl] = useState(null);
const avatarInputRef = useRef(null);
// Sync form fields when user data loads
useEffect(() => {
if (user) {
setUsername(user.username ?? "");
setDisplayName(user.displayName ?? "");
}
}, [user?.id]);
const saveMutation = useMutation({
mutationFn: async () => {
const res = await updateProfile({
identity: userId,
input: {
username: username.trim() || null,
displayName: displayName.trim() || null,
},
fields: ["id", "username", "displayName", "avatarUrl"],
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error((res.errors?.[0] as any)?.message ?? "Save failed");
return res.data;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["user", userId] });
setSaveSuccess(true);
setSaveError(null);
setTimeout(() => setSaveSuccess(false), 3000);
},
onError: (e: Error) => setSaveError(e.message),
});
async function handleAvatarChange(e: React.ChangeEvent) {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
if (previewAvatarUrl) URL.revokeObjectURL(previewAvatarUrl);
setPreviewAvatarUrl(URL.createObjectURL(file));
setAvatarError(null);
setAvatarUploading(true);
const csrfToken = buildCSRFHeaders()["X-CSRF-Token"] as string;
const result = await uploadAvatar(file, csrfToken);
setAvatarUploading(false);
if ("error" in result) {
setAvatarError(result.error);
if (previewAvatarUrl) URL.revokeObjectURL(previewAvatarUrl);
setPreviewAvatarUrl(null);
} else {
qc.invalidateQueries({ queryKey: ["user", userId] });
}
}
if (isLoading || !user) return ;
const currentAvatarUrl = previewAvatarUrl
? previewAvatarUrl
: user.avatarUrl
? `${assetHost}/${user.avatarUrl}`
: null;
return (
{/* Avatar section */}
{currentAvatarUrl ? (

) : (
{(user.displayName || user.username || user.email || "M")[0].toUpperCase()}
)}
{avatarError &&
{avatarError}
}
{/* Stats row */}
{user.followerCount ?? 0} followers
{user.followingCount ?? 0} following
{/* Email (read-only) */}
{/* Display name */}
setDisplayName(e.target.value)}
maxLength={50}
/>
{/* Username */}
{saveError &&
{saveError}
}
{saveSuccess &&
✓ Saved!
}
Sign out
);
}
function MyProfile() {
const { userId } = useContext(AuthCtx);
if (!userId) {
return (
◎
Your profile
Sign in
{" "}to view your profile.
);
}
return ;
}
// ── Mobile bottom nav ─────────────────────────────────────────────────────────
function MobileNav({
page,
onCompose,
}: {
page: string;
onCompose: () => void;
}) {
const onFeedPage = page === "feed" || page === "tweet";
const onFollowingPage = page === "following";
const onUsersPage = page === "users" || page === "user-detail";
const onProfilePage = page === "profile";
return (
);
}
// ── Mobile compose overlay ─────────────────────────────────────────────────────
function MobileComposePage({
email,
onClose,
}: {
email: string;
onClose: () => void;
}) {
return (
New Post
{/* right spacer keeps title centred */}
);
}
function App() {
const appEl = document.getElementById("app")!;
const email = appEl.dataset.currentUserEmail ?? "";
const userId = appEl.dataset.currentUserId ?? "";
const username = appEl.dataset.currentUserUsername ?? "";
const displayName = appEl.dataset.currentUserDisplayName ?? "";
const avatarUrl = appEl.dataset.currentUserAvatarUrl ?? "";
const tweetId = appEl.dataset.tweetId || null;
const page = appEl.dataset.page ?? "feed";
const profileUserId = appEl.dataset.userId || null;
const [mobileCompose, setMobileCompose] = useState(false);
const isDesktop = useIsDesktop();
const onFeedPage = page === "feed" || page === "tweet";
const onFollowingPage = page === "following";
const onUsersPage = page === "users" || page === "user-detail";
const onProfilePage = page === "profile";
function renderMain() {
switch (page) {
case "tweet":
return (
<>
>
);
case "following":
return (
<>
>
);
case "users":
return (
<>
>
);
case "user-detail":
return (
<>
>
);
case "profile":
return (
<>
>
);
default:
return (
<>
>
);
}
}
return (
{isDesktop && (
)}
{renderMain()}
{isDesktop && (
About Mixer
A minimal social feed built with Ash Framework, Phoenix, and React.
{["Ash 3", "Phoenix 1.8", "AshTypescript", "React 19"].map((s) => (
{s}
))}
)}
{/* Mobile-only bottom nav — hidden on desktop via CSS */}
setMobileCompose(true)} />
{/* Mobile compose overlay — only visible on mobile via CSS */}
{mobileCompose && (
setMobileCompose(false)}
/>
)}
);
}
// ── Bootstrap ──────────────────────────────────────────────────────────────────
const root = createRoot(document.getElementById("app")!);
root.render(
);