Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
60 changes: 57 additions & 3 deletions app/(tabs)/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,40 @@ import { EmptyState } from "yep/components/EmptyState";
import { Header } from "yep/components/Header";
import { PressableOpacity } from "yep/components/PressableOpacity";
import { ANILIST_ACCESS_TOKEN_STORAGE } from "yep/constants";
import {
ActivityFeedFragment,
PROFILE_ACTIVITY_LIMIT,
filterListActivities,
} from "yep/graphql/activity";
import { primeAccessToken } from "yep/graphql/client";
import { graphql } from "yep/graphql/tada";
import { GetViewer } from "yep/graphql/viewer";
import { useAniListAuthRequest } from "yep/hooks/auth";
import { ProfileSkeleton } from "yep/screens/ProfileScreen/ProfileSkeleton";
import { UserProfileContent } from "yep/screens/ProfileScreen/UserProfileContent";
import { darkTheme } from "yep/themes";
import { useAccessToken } from "yep/useAccessToken";

const GetProfileListActivity = graphql(
`
query GetProfileListActivity(
$userId: Int!
$perPage: Int!
$activityType: ActivityType!
) {
Page(page: 1, perPage: $perPage) {
activities(userId: $userId, type: $activityType, sort: ID_DESC) {
__typename
... on ListActivity {
...ActivityFeedFragment
}
}
}
}
`,
[ActivityFeedFragment],
);

export default function Profile() {
const router = useRouter();
const { accessToken, setAccessToken } = useAccessToken();
Expand All @@ -33,7 +59,26 @@ export default function Profile() {
skip: !accessToken,
notifyOnNetworkStatusChange: true,
});
const isRefetching = networkStatus === NetworkStatus.refetch;
const viewerId = data?.Viewer?.id;
const {
data: activityData,
loading: activityLoading,
error: activityError,
refetch: refetchActivity,
networkStatus: activityNetworkStatus,
} = useQuery(GetProfileListActivity, {
skip: !viewerId,
variables: {
userId: viewerId ?? 0,
perPage: PROFILE_ACTIVITY_LIMIT,
activityType: "MEDIA_LIST",
},
notifyOnNetworkStatusChange: true,
});
const activities = filterListActivities(activityData?.Page?.activities);
const isRefetching =
networkStatus === NetworkStatus.refetch ||
activityNetworkStatus === NetworkStatus.refetch;

return (
<View style={styles.container}>
Expand Down Expand Up @@ -61,7 +106,11 @@ export default function Profile() {
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={() => void refetchViewer().catch(() => {})}
onRefresh={() => {
const requests: Promise<unknown>[] = [refetchViewer()];
if (viewerId) requests.push(refetchActivity());
void Promise.all(requests).catch(() => {});
}}
tintColor={white}
titleColor={white}
/>
Expand Down Expand Up @@ -100,7 +149,12 @@ export default function Profile() {
) : loading && !data?.Viewer ? (
<ProfileSkeleton />
) : data?.Viewer ? (
<UserProfileContent user={data.Viewer} />
<UserProfileContent
user={data.Viewer}
activities={activities}
activityLoading={activityLoading && !activityData}
activityError={Boolean(activityError)}
/>
) : error ? (
<EmptyState
title={String(fbs("Could not load profile", "Profile error title"))}
Expand Down
284 changes: 284 additions & 0 deletions components/activity-feed-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
import { formatDistanceToNow } from "date-fns/formatDistanceToNow";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { fbs } from "fbtee";
import React from "react";
import { StyleSheet, Text, View } from "react-native";

import { PressableOpacity } from "yep/components/PressableOpacity";
import {
getListActivityAction,
type ActivityFeedItem,
type ListActivityAction,
} from "yep/graphql/activity";
import { useLocaleContext } from "yep/i18n/LocaleContext";
import { fakeHandle } from "yep/screenshotMode";
import { darkTheme } from "yep/themes";
import { Manrope } from "yep/typefaces";
import { getDateFnsLocale, useGetTitle } from "yep/utils";

export function ActivityFeedDivider() {
return <View style={styles.divider} />;
}

function getActivityActionText(
action: ListActivityAction,
progress: string | null | undefined,
) {
const progressText = progress?.trim();

switch (action) {
case "watchedEpisode":
return progressText
? String(
fbs(
[
" watched episode ",
fbs.param("progress", progressText),
" of ",
],
"Anime list activity with episode progress",
),
)
: String(
fbs(
" watched an episode of ",
"Anime list activity without episode progress",
),
);
case "rewatchedEpisode":
return progressText
? String(
fbs(
[
" rewatched episode ",
fbs.param("progress", progressText),
" of ",
],
"Anime rewatch list activity with episode progress",
),
)
: String(
fbs(
" rewatched an episode of ",
"Anime rewatch list activity without episode progress",
),
);
case "readChapter":
return progressText
? String(
fbs(
[
" read chapter ",
fbs.param("progress", progressText),
" of ",
],
"Manga list activity with chapter progress",
),
)
: String(
fbs(
" read a chapter of ",
"Manga list activity without chapter progress",
),
);
case "rereadChapter":
return progressText
? String(
fbs(
[
" reread chapter ",
fbs.param("progress", progressText),
" of ",
],
"Manga reread list activity with chapter progress",
),
)
: String(
fbs(
" reread a chapter of ",
"Manga reread list activity without chapter progress",
),
);
case "plansToWatch":
return String(fbs(" plans to watch ", "Plans to watch list activity"));
case "plansToRead":
return String(fbs(" plans to read ", "Plans to read list activity"));
case "completed":
return String(fbs(" completed ", "Completed list activity"));
case "pausedWatching":
return String(fbs(" paused watching ", "Paused watching list activity"));
case "pausedReading":
return String(fbs(" paused reading ", "Paused reading list activity"));
case "dropped":
return String(fbs(" dropped ", "Dropped list activity"));
case "updated":
return String(fbs(" updated ", "Generic list activity"));
}
}

export function ActivityFeedRow({
item,
first = false,
last = false,
}: {
item: ActivityFeedItem;
first?: boolean;
last?: boolean;
}) {
const router = useRouter();
const { locale } = useLocaleContext();
const getTitle = useGetTitle();
const { media, user } = item;

if (!media || !user) return null;

const avatarUrl = user.avatar?.medium ?? user.avatar?.large ?? "";
const coverUrl = media.coverImage?.medium ?? media.coverImage?.large ?? "";
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const mediaTitle = getTitle(media.title) ?? "";
const actionText = getActivityActionText(
getListActivityAction(item.status),
item.progress,
);
// fbtee trims boundary whitespace from source and translated strings.
// English needs word spacing; Japanese particles and punctuation should
// join directly to the username and title.
const renderedActionText = locale.startsWith("ja")
? actionText
: ` ${actionText} `;
const profileLabel = String(
fbs(
[fbs.param("username", user.name), "'s profile"],
"User profile link accessibility label",
),
);
const userHref = `/user/${user.id}` as const;
const mediaHref = `/details/${media.id}` as const;

return (
<View
style={[
styles.row,
{
borderTopLeftRadius: first ? 16 : undefined,
borderTopRightRadius: first ? 16 : undefined,
borderBottomLeftRadius: last ? 16 : undefined,
borderBottomRightRadius: last ? 16 : undefined,
},
]}
>
<PressableOpacity
borderRadius={24}
accessibilityRole="link"
accessibilityLabel={profileLabel}
onPress={() => router.push(userHref)}
>
<Image
source={
avatarUrl
? { uri: avatarUrl }
: require("yep/assets/icons/avatar-placeholder.png")
}
style={styles.avatar}
/>
</PressableOpacity>
<View style={styles.activityText}>
<Text style={styles.summary} numberOfLines={3}>
<Text
style={styles.username}
accessibilityRole="link"
accessibilityLabel={profileLabel}
onPress={() => router.push(userHref)}
>
{fakeHandle(user.name)}
</Text>
<Text style={styles.action}>{renderedActionText}</Text>
<Text
style={styles.mediaTitle}
accessibilityRole="link"
accessibilityLabel={mediaTitle}
onPress={() => router.push(mediaHref)}
>
{mediaTitle}
</Text>
</Text>
<Text style={styles.timestamp} numberOfLines={1}>
{formatDistanceToNow(new Date(item.createdAt * 1000), {
addSuffix: true,
locale: getDateFnsLocale(locale),
})}
</Text>
</View>
{coverUrl ? (
<PressableOpacity
borderRadius={8}
accessibilityRole="link"
accessibilityLabel={mediaTitle}
onPress={() => router.push(mediaHref)}
>
<Image
contentFit="cover"
recyclingKey={coverUrl}
source={{ uri: coverUrl }}
style={styles.cover}
/>
</PressableOpacity>
) : null}
</View>
);
}

const styles = StyleSheet.create({
action: {
color: darkTheme.subText,
fontFamily: Manrope.regular,
fontSize: 15,
},
activityText: {
alignSelf: "stretch",
flex: 1,
justifyContent: "space-between",
},
avatar: {
borderCurve: "continuous",
borderRadius: 24,
height: 48,
width: 48,
},
cover: {
backgroundColor: darkTheme.listItemBackground,
borderCurve: "continuous",
borderRadius: 8,
height: 80,
overflow: "hidden",
width: 56,
},
divider: {
backgroundColor: darkTheme.listItemBorder,
height: StyleSheet.hairlineWidth,
},
mediaTitle: {
color: darkTheme.text,
fontFamily: Manrope.semiBold,
fontSize: 15,
},
row: {
alignItems: "flex-start",
backgroundColor: darkTheme.listItemBackground,
borderCurve: "continuous",
flexDirection: "row",
gap: 12,
padding: 12,
},
summary: { color: darkTheme.subText, lineHeight: 20 },
timestamp: {
color: darkTheme.subHeader,
fontFamily: Manrope.regular,
fontSize: 12,
},
username: {
color: darkTheme.text,
fontFamily: Manrope.semiBold,
fontSize: 15,
},
});
Loading
Loading