Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Friends feed #3161

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
03c3972
Create /friends server endpoint for friends' listens feed
MonkeyDo Jan 31, 2025
fe0ac68
Use generic type for TimelineEvent metadata
MonkeyDo Jan 31, 2025
41e26d5
Create FriendsFeed page
MonkeyDo Jan 31, 2025
9367a0b
Small UI changes in FriendsFeed page
MonkeyDo Jan 31, 2025
454b378
UserFeed: remove followers cards, add refresh button
MonkeyDo Jan 31, 2025
2292cf5
Fix secondary navbar tab highlighting
MonkeyDo Jan 31, 2025
3997ca4
Move new feed to /feed/friends
MonkeyDo Jan 31, 2025
28b1ae1
UserFeed: limit width
MonkeyDo Jan 31, 2025
30240e9
Cleanup
MonkeyDo Jan 31, 2025
6ecceb7
Feeds: less striking refresh button
MonkeyDo Jan 31, 2025
040f2b2
Remove test_it_sends_listens_for_users_that_are_being_followed test
MonkeyDo Jan 31, 2025
9fae02c
Update test_it_returns_all_types_of_events_sorted_by_time_in_descendi…
MonkeyDo Jan 31, 2025
21c81c0
Add frontend tests for FriendsFeed page
MonkeyDo Jan 31, 2025
f712e78
Fix feed test test_it_honors_request_parameters
MonkeyDo Jan 31, 2025
2e33abb
Add similar users listens feed
MonkeyDo Feb 3, 2025
7aa3d66
Update FriendsFeed tests
MonkeyDo Feb 3, 2025
ac69137
FriendsFeed: Fix refetch mechanism
MonkeyDo Feb 3, 2025
c0a6d6a
Refactor common feed types
MonkeyDo Feb 4, 2025
2001eaa
Fix UserFeed refresh button
MonkeyDo Feb 4, 2025
d084675
Add "play all" buttons in feed pages
MonkeyDo Feb 4, 2025
b8de0ec
More space for followers/similar users lists
MonkeyDo Feb 4, 2025
8238ae1
Fix imports
MonkeyDo Feb 4, 2025
87e3be5
Tweak buttons at top of feed
MonkeyDo Feb 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/css/follow.less
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@

.follower-following-list,
.similar-users-list {
max-height: 250px;
max-height: 350px;
padding: 10px;
overflow-y: auto;
border-radius: 2px;
Expand Down
22 changes: 19 additions & 3 deletions frontend/js/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,21 +149,37 @@ const getIndexRoutes = (): RouteObject[] => {
],
},
{
path: "/",
path: "feed/",
lazy: async () => {
const UserFeedLayout = await import("../user-feed/UserFeedLayout");
return { Component: UserFeedLayout.default };
},
children: [
{
path: "/feed/",
index: true,
lazy: async () => {
const UserFeed = await import("../user-feed/UserFeed");
return { Component: UserFeed.default };
},
},
{
path: "/recent/",
path: ":mode/",
lazy: async () => {
const FriendsFeed = await import("../user-feed/FriendsFeed");
return { Component: FriendsFeed.default };
},
},
],
},
{
path: "recent/",
lazy: async () => {
const UserFeedLayout = await import("../user-feed/UserFeedLayout");
return { Component: UserFeedLayout.default };
},
children: [
{
index: true,
lazy: async () => {
const RecentListens = await import("../recent/RecentListens");
return {
Expand Down
270 changes: 270 additions & 0 deletions frontend/js/src/user-feed/FriendsFeed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import * as React from "react";
import { Helmet } from "react-helmet";

import { InfiniteData, useInfiniteQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPeopleArrows,
faPlayCircle,
faRefresh,
faUser,
} from "@fortawesome/free-solid-svg-icons";
import { faCalendarPlus } from "@fortawesome/free-regular-svg-icons";
import { useBrainzPlayerDispatch } from "../common/brainzplayer/BrainzPlayerContext";
import ListenCard from "../common/listens/ListenCard";
import UserSocialNetwork from "../user/components/follow/UserSocialNetwork";
import GlobalAppContext from "../utils/GlobalAppContext";
import { getTrackName } from "../utils/utils";
import { type FeedFetchParams, FeedModes } from "./types";

export type FriendsFeedPageProps = {
events: TimelineEvent<Listen>[];
};
type FriendsFeedLoaderData = FriendsFeedPageProps;

export default function FriendsFeedPage() {
const { currentUser, APIService } = React.useContext(GlobalAppContext);
const { getListensFromFriends, getListensFromSimilarUsers } = APIService;
const dispatch = useBrainzPlayerDispatch();
const prevListens = React.useRef<Listen[]>([]);

const navigate = useNavigate();

const params = useParams();
const { mode } = params as { mode: FeedModes };

React.useEffect(() => {
if (mode !== FeedModes.Follows && mode !== FeedModes.Similar) {
// We use a dynamic segment ":mode" on the route, and need to enforce valid values and default here
navigate(`/feed/${FeedModes.Follows}`, { replace: true });
}
}, [mode, navigate]);

const queryKey = ["network-feed", params];

const fetchEvents = React.useCallback(
async ({ pageParam }: any) => {
let fetchFunction;
const { minTs, maxTs } = pageParam;
if (mode === FeedModes.Follows) {
fetchFunction = getListensFromFriends;
} else if (mode === FeedModes.Similar) {
fetchFunction = getListensFromSimilarUsers;
} else {
return { events: [] };
}
const newEvents = await fetchFunction(
currentUser.name,
currentUser.auth_token!,
minTs,
maxTs
);
return { events: newEvents };
},
[currentUser, getListensFromFriends, getListensFromSimilarUsers, mode]
);

const {
refetch,
data,
isLoading,
isError,
fetchNextPage,
fetchPreviousPage,
hasNextPage,
isFetching,
isFetchingNextPage,
} = useInfiniteQuery<
FriendsFeedLoaderData,
unknown,
InfiniteData<FriendsFeedLoaderData>,
unknown[],
FeedFetchParams
>({
queryKey,
initialPageParam: { maxTs: Math.ceil(Date.now() / 1000) },
queryFn: fetchEvents,
getNextPageParam: (lastPage, allPages, lastPageParam) => ({
maxTs:
lastPage.events[lastPage.events.length - 1]?.metadata?.listened_at ??
lastPageParam.maxTs,
}),
getPreviousPageParam: (lastPage, allPages, lastPageParam) => ({
minTs:
lastPage.events[0]?.metadata?.listened_at ??
lastPageParam.minTs ??
Math.ceil(Date.now() / 1000),
}),
});

const { pages } = data || {}; // safe destructuring of possibly undefined data object
// Flatten the pages of events from the infite query
const listenEvents = pages?.map((page) => page.events).flat();
const listens = listenEvents?.map((evt) => evt.metadata);

React.useEffect(() => {
// Since we're using infinite queries, we need to manually set the ambient queue and also ensure
// that only the newly fetched listens are added to the botom of the queue.
// But on first load, we need to add replace the entire queue with the listens

if (!prevListens.current?.length) {
dispatch({
type: "SET_AMBIENT_QUEUE",
data: listens,
});
} else {
const newListens = listens?.filter(
(listen) => !prevListens.current?.includes(listen)
);
if (!listens?.length) {
return;
}
dispatch({
type: "ADD_MULTIPLE_LISTEN_TO_BOTTOM_OF_AMBIENT_QUEUE",
data: newListens,
});
}

prevListens.current = listens ?? [];
}, [dispatch, listens]);

return (
<>
<Helmet>
<title>My Network Feed</title>
</Helmet>
<div className="row">
<div className="col-sm-8 col-xs-12">
<div className="listen-header pills">
<h3 className="header-with-line">
What are{" "}
{mode === FeedModes.Follows ? "users I follow" : "similar users"}{" "}
listening to?
</h3>
<div style={{ flexShrink: 0 }}>
<button
type="button"
onClick={() => {
navigate(`/feed/${FeedModes.Follows}/`);
}}
className={`pill secondary ${
mode === FeedModes.Follows ? "active" : ""
}`}
>
<FontAwesomeIcon icon={faUser} /> Following
</button>
<button
type="button"
onClick={() => {
navigate(`/feed/${FeedModes.Similar}/`);
}}
className={`pill secondary ${
mode === FeedModes.Similar ? "active" : ""
}`}
>
<FontAwesomeIcon icon={faPeopleArrows} /> Similar users
</button>
</div>
</div>
{isError ? (
<>
<div className="alert alert-warning text-center">
There was an error while trying to load your feed. Please try
again
</div>
<div className="text-center">
<button
type="button"
className="btn btn-warning"
onClick={() => {
refetch();
}}
>
Reload feed
</button>
</div>
</>
) : (
<>
<div className="mb-15" style={{ display: 'flex', justifyContent: 'center', gap: '1em' }}>
<button
type="button"
className="btn btn-outline"
onClick={() => {
fetchPreviousPage();
}}
disabled={isFetching}
>
<FontAwesomeIcon icon={faRefresh} />
&nbsp;
{isLoading || isFetching ? "Refreshing..." : "Refresh"}
</button>
<button
type="button"
className="btn btn-info btn-rounded play-tracks-button"
title="Play album"
onClick={() => {
window.postMessage(
{
brainzplayer_event: "play-ambient-queue",
payload: listens,
},
window.location.origin
);
}}
>
<FontAwesomeIcon icon={faPlayCircle} fixedWidth /> Play all
</button>
</div>
{!listenEvents?.length && (
<h5 className="text-center">No listens to show</h5>
)}
{Boolean(listenEvents?.length) && (
<div id="listens" data-testid="listens">
{listenEvents?.map((event) => {
const listen = event.metadata;
return (
<ListenCard
key={`${listen.listened_at}-${getTrackName(listen)}-${
listen.user_name
}`}
showTimestamp
showUsername
listen={listen}
/>
);
})}
</div>
)}
<div
className="text-center mb-15"
style={{
width: "50%",
marginLeft: "auto",
marginRight: "auto",
}}
>
<button
type="button"
className="btn btn-outline btn-block"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
<FontAwesomeIcon icon={faCalendarPlus} />
&nbsp;
{(isLoading || isFetchingNextPage) && "Loading more..."}
{!(isLoading || isFetchingNextPage) &&
(hasNextPage ? "Load More" : "Nothing more to load")}
</button>
</div>
</>
)}
</div>
<div className="col-sm-4">
<UserSocialNetwork user={currentUser} />
</div>
</div>
</>
);
}
Loading