Added following page to see posts from yourself and people you follow
This commit is contained in:
@@ -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>[];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createTweet,
|
createTweet,
|
||||||
readTweet,
|
readTweet,
|
||||||
|
readFollowingFeed,
|
||||||
destroyTweet,
|
destroyTweet,
|
||||||
likeTweet,
|
likeTweet,
|
||||||
unlikeTweet,
|
unlikeTweet,
|
||||||
@@ -224,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);
|
||||||
@@ -477,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),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -493,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);
|
||||||
},
|
},
|
||||||
@@ -511,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),
|
||||||
@@ -864,6 +871,100 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
|
|||||||
|
|
||||||
const FEED_PAGE_SIZE = 10;
|
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 sentinelRef = useRef<HTMLDivElement>(null);
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -941,12 +1042,12 @@ function Feed() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
||||||
@@ -1173,6 +1274,7 @@ 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";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1187,6 +1289,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}
|
||||||
@@ -1265,6 +1377,7 @@ 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";
|
||||||
|
|
||||||
function renderMain() {
|
function renderMain() {
|
||||||
@@ -1278,6 +1391,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 (
|
||||||
<>
|
<>
|
||||||
@@ -1340,6 +1463,12 @@ 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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ 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 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
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ 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 "/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
|
||||||
|
|||||||
Reference in New Issue
Block a user