Compare commits

...

3 Commits

7 changed files with 334 additions and 21 deletions

View File

@@ -303,6 +303,12 @@ html, body {
user-select: none; user-select: none;
} }
.mx-tweet-avatar--lg {
width: 56px;
height: 56px;
font-size: 1.25rem;
}
.mx-compose-body { flex: 1; } .mx-compose-body { flex: 1; }
.mx-compose-textarea, .mx-edit-textarea { .mx-compose-textarea, .mx-edit-textarea {
@@ -964,4 +970,6 @@ html, body {
} }
.mx-header { padding: 0.75rem 1rem; } .mx-header { padding: 0.75rem 1rem; }
.mx-detail { padding: 0.875rem 1rem; } .mx-detail { padding: 0.875rem 1rem; }
/* 5-item nav: slightly smaller labels so nothing wraps */
.mx-mobile-nav-item { font-size: 0.6rem; }
} }

View File

@@ -843,6 +843,73 @@ export async function validateLikeTweet(
} }
export type ReadFollowingFeedFields = UnifiedFieldSelection<tweetsResourceSchema>[];
export type InferReadFollowingFeedResult<
Fields extends ReadFollowingFeedFields,
> = Array<InferResult<tweetsResourceSchema, Fields>>;
export type ReadFollowingFeedResult<Fields extends ReadFollowingFeedFields> = | { success: true; data: InferReadFollowingFeedResult<Fields>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Read Tweet records
*
* @ashActionType :read
*/
export async function readFollowingFeed<Fields extends ReadFollowingFeedFields>(
config: {
tenant?: string;
fields: Fields;
filter?: tweetsFilterInput;
sort?: SortString<tweetsSortField> | SortString<tweetsSortField>[];
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ReadFollowingFeedResult<Fields>> {
const payload = {
action: "read_following_feed",
...(config.tenant !== undefined && { tenant: config.tenant }),
...(config.fields !== undefined && { fields: config.fields }),
...(config.filter && { filter: config.filter }),
...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort })
};
return executeActionRpcRequest<ReadFollowingFeedResult<Fields>>(
payload,
config
);
}
/**
* Validate: Read Tweet records
*
* @ashActionType :read
* @validation true
*/
export async function validateReadFollowingFeed(
config: {
tenant?: string;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "read_following_feed",
...(config.tenant !== undefined && { tenant: config.tenant })
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
export type ReadTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[]; export type ReadTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];

View File

@@ -5,12 +5,14 @@ import {
QueryClient, QueryClient,
QueryClientProvider, QueryClientProvider,
useQuery, useQuery,
useInfiniteQuery,
useMutation, useMutation,
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
createTweet, createTweet,
readTweet, readTweet,
readFollowingFeed,
destroyTweet, destroyTweet,
likeTweet, likeTweet,
unlikeTweet, unlikeTweet,
@@ -223,6 +225,7 @@ function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
}, },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tweets"] }); qc.invalidateQueries({ queryKey: ["tweets"] });
qc.invalidateQueries({ queryKey: ["following_tweets"] });
setText(""); setText("");
setError(null); setError(null);
setMediaId(null); setMediaId(null);
@@ -476,7 +479,10 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
}); });
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete"); if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
}, },
onSuccess: () => qc.invalidateQueries({ queryKey: ["tweets"] }), onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tweets"] });
qc.invalidateQueries({ queryKey: ["following_tweets"] });
},
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
}); });
@@ -492,6 +498,7 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
}, },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tweets"] }); qc.invalidateQueries({ queryKey: ["tweets"] });
qc.invalidateQueries({ queryKey: ["following_tweets"] });
setEditing(false); setEditing(false);
setError(null); setError(null);
}, },
@@ -510,6 +517,7 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
}, },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tweets"] }); qc.invalidateQueries({ queryKey: ["tweets"] });
qc.invalidateQueries({ queryKey: ["following_tweets"] });
setError(null); setError(null);
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
@@ -861,21 +869,148 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
); );
} }
const FEED_PAGE_SIZE = 10;
function FollowingFeed() {
const { userId } = useContext(AuthCtx);
const sentinelRef = useRef<HTMLDivElement>(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", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
sort: "-insertedAt",
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
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 (
<div className="mx-empty">
<div className="mx-empty-icon"></div>
<p className="mx-empty-title">Your personalised feed</p>
<p className="mx-empty-sub">
<a href="/sign-in" style={{ color: "var(--mx-accent)", textDecoration: "none" }}>Sign in</a>
{" "}to see posts from people you follow.
</p>
</div>
);
}
if (isLoading) return <Spinner />;
if (isError) {
return (
<ErrorBanner message={(error as Error)?.message ?? "Could not load following feed"} />
);
}
const tweets = data?.pages.flatMap((p) => p.tweets) ?? [];
if (tweets.length === 0) {
return (
<div className="mx-empty">
<div className="mx-empty-icon"></div>
<p className="mx-empty-title">Nothing here yet</p>
<p className="mx-empty-sub">
Follow some people from the{" "}
<a href="/users" style={{ color: "var(--mx-accent)", textDecoration: "none" }}>Users</a>
{" "}page to fill this feed.
</p>
</div>
);
}
return (
<div className="mx-feed">
{tweets.map((t) => (
<TweetCard key={t.id} tweet={t} />
))}
<div ref={sentinelRef} style={{ height: "1px" }} />
{isFetchingNextPage && <Spinner />}
</div>
);
}
function Feed() { function Feed() {
const { data, isLoading, isError, error, refetch } = useQuery({ const sentinelRef = useRef<HTMLDivElement>(null);
const {
data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["tweets"], queryKey: ["tweets"],
queryFn: async () => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const res = await readTweet({ const res = await readTweet({
fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }], fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
sort: "-insertedAt", sort: "-insertedAt",
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
headers: buildCSRFHeaders(), headers: buildCSRFHeaders(),
}); });
if (!res.success) throw new Error("Failed to load tweets"); if (!res.success) throw new Error("Failed to load tweets");
const tweets = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? []; const pageData = res.data as any;
return tweets as Tweet[]; 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 <Spinner />; if (isLoading) return <Spinner />;
if (isError) { if (isError) {
return ( return (
@@ -883,7 +1018,7 @@ function Feed() {
); );
} }
const tweets = data ?? []; const tweets = data?.pages.flatMap((p) => p.tweets) ?? [];
if (tweets.length === 0) { if (tweets.length === 0) {
return ( return (
@@ -900,16 +1035,19 @@ function Feed() {
{tweets.map((t) => ( {tweets.map((t) => (
<TweetCard key={t.id} tweet={t} /> <TweetCard key={t.id} tweet={t} />
))} ))}
{/* Sentinel element — entering the viewport triggers loading the next page */}
<div ref={sentinelRef} style={{ height: "1px" }} />
{isFetchingNextPage && <Spinner />}
</div> </div>
); );
} }
function RefreshButton() { function RefreshButton({ queryKey = ["tweets"] }: { queryKey?: string[] }) {
const qc = useQueryClient(); const qc = useQueryClient();
const [spinning, setSpinning] = useState(false); const [spinning, setSpinning] = useState(false);
async function refresh() { async function refresh() {
setSpinning(true); setSpinning(true);
await qc.invalidateQueries({ queryKey: ["tweets"] }); await qc.invalidateQueries({ queryKey });
setTimeout(() => setSpinning(false), 600); setTimeout(() => setSpinning(false), 600);
} }
return ( return (
@@ -1070,7 +1208,7 @@ function UserList() {
); );
} }
function UserDetail({ userId }: { userId: string }) { function UserDetail({ userId, isStandalone = false }: { userId: string; isStandalone?: boolean }) {
const { userId: currentUserId } = useContext(AuthCtx); const { userId: currentUserId } = useContext(AuthCtx);
const { follow, unfollow, isPending } = useFollowUser(userId); const { follow, unfollow, isPending } = useFollowUser(userId);
const { data: user, isLoading, isError } = useQuery({ const { data: user, isLoading, isError } = useQuery({
@@ -1090,11 +1228,13 @@ function UserDetail({ userId }: { userId: string }) {
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
if (isError || !user) return <ErrorBanner message="Could not load user" />; if (isError || !user) return <ErrorBanner message="Could not load user" />;
const canFollow = !!currentUserId && currentUserId !== userId; const isOwnProfile = currentUserId === userId;
const canFollow = !!currentUserId && !isOwnProfile;
const amIFollowing = user.amIFollowing ?? false; const amIFollowing = user.amIFollowing ?? false;
return ( return (
<div className="mx-detail"> <div className="mx-detail">
{!isStandalone && (
<div className="mx-detail-header"> <div className="mx-detail-header">
<a href="/users" className="mx-back-btn"> <a href="/users" className="mx-back-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
@@ -1103,17 +1243,23 @@ function UserDetail({ userId }: { userId: string }) {
Back Back
</a> </a>
</div> </div>
)}
<div className="mx-detail-body"> <div className="mx-detail-body">
<div className="mx-detail-author"> <div className="mx-detail-author">
<div className="mx-tweet-avatar"> <div className="mx-tweet-avatar mx-tweet-avatar--lg">
<span>M</span> <span>{user.email?.[0]?.toUpperCase() ?? "M"}</span>
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}> <div style={{ display: "flex", alignItems: "center", gap: "12px", flexWrap: "wrap" }}>
<span className="mx-tweet-handle">{user.email}</span> <span className="mx-tweet-handle">{user.email}</span>
{canFollow && ( {canFollow && (
<FollowButton amIFollowing={amIFollowing} isPending={isPending} onToggle={amIFollowing ? unfollow : follow} /> <FollowButton amIFollowing={amIFollowing} isPending={isPending} onToggle={amIFollowing ? unfollow : follow} />
)} )}
{isOwnProfile && isStandalone && (
<a href="/sign-out" className="mx-btn-cancel" style={{ textDecoration: "none", fontSize: "0.8rem" }}>
Sign out
</a>
)}
</div> </div>
<div style={{ fontSize: "0.85rem", color: "var(--mx-muted)", marginTop: "6px", display: "flex", gap: "16px" }}> <div style={{ fontSize: "0.85rem", color: "var(--mx-muted)", marginTop: "6px", display: "flex", gap: "16px" }}>
<span><strong>{user.followerCount ?? 0}</strong> followers</span> <span><strong>{user.followerCount ?? 0}</strong> followers</span>
@@ -1126,6 +1272,25 @@ function UserDetail({ userId }: { userId: string }) {
); );
} }
function MyProfile() {
const { userId } = useContext(AuthCtx);
if (!userId) {
return (
<div className="mx-empty">
<div className="mx-empty-icon"></div>
<p className="mx-empty-title">Your profile</p>
<p className="mx-empty-sub">
<a href="/sign-in" style={{ color: "var(--mx-accent)", textDecoration: "none" }}>Sign in</a>
{" "}to view your profile.
</p>
</div>
);
}
return <UserDetail userId={userId} isStandalone />;
}
// ── Mobile bottom nav ───────────────────────────────────────────────────────── // ── Mobile bottom nav ─────────────────────────────────────────────────────────
function MobileNav({ function MobileNav({
@@ -1136,7 +1301,9 @@ function MobileNav({
onCompose: () => void; onCompose: () => void;
}) { }) {
const onFeedPage = page === "feed" || page === "tweet"; const onFeedPage = page === "feed" || page === "tweet";
const onFollowingPage = page === "following";
const onUsersPage = page === "users" || page === "user-detail"; const onUsersPage = page === "users" || page === "user-detail";
const onProfilePage = page === "profile";
return ( return (
<nav className="mx-mobile-nav"> <nav className="mx-mobile-nav">
@@ -1150,6 +1317,16 @@ function MobileNav({
<span>Feed</span> <span>Feed</span>
</a> </a>
<a
href="/following"
className={`mx-mobile-nav-item${onFollowingPage ? " mx-mobile-nav-item--active" : ""}`}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
</svg>
<span>Following</span>
</a>
<button <button
className="mx-mobile-nav-compose" className="mx-mobile-nav-compose"
onClick={onCompose} onClick={onCompose}
@@ -1179,6 +1356,16 @@ function MobileNav({
</svg> </svg>
<span>Users</span> <span>Users</span>
</a> </a>
<a
href="/profile"
className={`mx-mobile-nav-item${onProfilePage ? " mx-mobile-nav-item--active" : ""}`}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
<span>Profile</span>
</a>
</nav> </nav>
); );
} }
@@ -1228,7 +1415,9 @@ function App() {
const isDesktop = useIsDesktop(); const isDesktop = useIsDesktop();
const onFeedPage = page === "feed" || page === "tweet"; const onFeedPage = page === "feed" || page === "tweet";
const onFollowingPage = page === "following";
const onUsersPage = page === "users" || page === "user-detail"; const onUsersPage = page === "users" || page === "user-detail";
const onProfilePage = page === "profile";
function renderMain() { function renderMain() {
switch (page) { switch (page) {
@@ -1241,6 +1430,16 @@ function App() {
<TweetDetail tweetId={tweetId!} /> <TweetDetail tweetId={tweetId!} />
</> </>
); );
case "following":
return (
<>
<header className="mx-header">
<h1 className="mx-header-title">Following</h1>
<RefreshButton queryKey={["following_tweets"]} />
</header>
<FollowingFeed />
</>
);
case "users": case "users":
return ( return (
<> <>
@@ -1259,6 +1458,15 @@ function App() {
<UserDetail userId={profileUserId!} /> <UserDetail userId={profileUserId!} />
</> </>
); );
case "profile":
return (
<>
<header className="mx-header">
<h1 className="mx-header-title">My Profile</h1>
</header>
<MyProfile />
</>
);
default: default:
return ( return (
<> <>
@@ -1303,12 +1511,24 @@ function App() {
</svg> </svg>
Feed Feed
</a> </a>
<a className={`mx-nav-item${onFollowingPage ? " mx-nav-active" : ""}`} href="/following">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
</svg>
Following
</a>
<a className={`mx-nav-item${onUsersPage ? " mx-nav-active" : ""}`} href="/users"> <a className={`mx-nav-item${onUsersPage ? " mx-nav-active" : ""}`} href="/users">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" /> <path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" />
</svg> </svg>
Users Users
</a> </a>
<a className={`mx-nav-item${onProfilePage ? " mx-nav-active" : ""}`} href="/profile">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
Profile
</a>
</nav> </nav>
<div className="mx-sidebar-footer"> <div className="mx-sidebar-footer">
{email ? ( {email ? (

View File

@@ -8,6 +8,7 @@ defmodule Mixer.Posts do
rpc_action :create_tweet, :create rpc_action :create_tweet, :create
rpc_action :like_tweet, :like rpc_action :like_tweet, :like
rpc_action :read_tweet, :read rpc_action :read_tweet, :read
rpc_action :read_following_feed, :following_feed
rpc_action :unlike_tweet, :unlike rpc_action :unlike_tweet, :unlike
rpc_action :update_tweet, :update rpc_action :update_tweet, :update
rpc_action :destroy_tweet, :destroy rpc_action :destroy_tweet, :destroy

View File

@@ -30,6 +30,13 @@ defmodule Mixer.Posts.Tweet do
actions do actions do
defaults [:read, :destroy] defaults [:read, :destroy]
read :following_feed do
filter expr(
user_id == ^actor(:id) or
exists(user.followers, follower_id == ^actor(:id))
)
end
create :create do create :create do
upsert? true upsert? true
accept [:content] accept [:content]

View File

@@ -17,6 +17,14 @@ defmodule MixerWeb.PageController do
render_spa(conn, %{page: "tweet", tweet_id: tweet_id, user_id: nil}) render_spa(conn, %{page: "tweet", tweet_id: tweet_id, user_id: nil})
end end
def following(conn, _params) do
render_spa(conn, %{page: "following", tweet_id: nil, user_id: nil})
end
def profile(conn, _params) do
render_spa(conn, %{page: "profile", tweet_id: nil, user_id: nil})
end
def users_index(conn, _params) do def users_index(conn, _params) do
render_spa(conn, %{page: "users", tweet_id: nil, user_id: nil}) render_spa(conn, %{page: "users", tweet_id: nil, user_id: nil})
end end

View File

@@ -40,6 +40,8 @@ defmodule MixerWeb.Router do
get "/", PageController, :home get "/", PageController, :home
get "/feed", PageController, :index get "/feed", PageController, :index
get "/feed/:tweet_id", PageController, :show get "/feed/:tweet_id", PageController, :show
get "/following", PageController, :following
get "/profile", PageController, :profile
get "/users", PageController, :users_index get "/users", PageController, :users_index
get "/users/:user_id", PageController, :user_show get "/users/:user_id", PageController, :user_show
post "/rpc/run", AshTypescriptRpcController, :run post "/rpc/run", AshTypescriptRpcController, :run