diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx
index 05ff693..a604a01 100644
--- a/app/(tabs)/profile.tsx
+++ b/app/(tabs)/profile.tsx
@@ -11,7 +11,13 @@ 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";
@@ -19,6 +25,26 @@ 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();
@@ -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 (
@@ -61,7 +106,11 @@ export default function Profile() {
refreshControl={
void refetchViewer().catch(() => {})}
+ onRefresh={() => {
+ const requests: Promise[] = [refetchViewer()];
+ if (viewerId) requests.push(refetchActivity());
+ void Promise.all(requests).catch(() => {});
+ }}
tintColor={white}
titleColor={white}
/>
@@ -100,7 +149,12 @@ export default function Profile() {
) : loading && !data?.Viewer ? (
) : data?.Viewer ? (
-
+
) : error ? (
;
+}
+
+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 ?? "";
+ 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 (
+
+ router.push(userHref)}
+ >
+
+
+
+
+ router.push(userHref)}
+ >
+ {fakeHandle(user.name)}
+
+ {renderedActionText}
+ router.push(mediaHref)}
+ >
+ {mediaTitle}
+
+
+
+ {formatDistanceToNow(new Date(item.createdAt * 1000), {
+ addSuffix: true,
+ locale: getDateFnsLocale(locale),
+ })}
+
+
+ {coverUrl ? (
+ router.push(mediaHref)}
+ >
+
+
+ ) : null}
+
+ );
+}
+
+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,
+ },
+});
diff --git a/graphql/activity.test.ts b/graphql/activity.test.ts
new file mode 100644
index 0000000..0d3bbef
--- /dev/null
+++ b/graphql/activity.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, test } from "bun:test";
+
+import { getListActivityAction } from "./activity";
+
+describe("getListActivityAction", () => {
+ test("normalizes every AniList list-activity status", () => {
+ expect(
+ [
+ "completed",
+ "dropped",
+ "paused reading",
+ "paused watching",
+ "plans to read",
+ "plans to watch",
+ "read chapter",
+ "reread chapter",
+ "rewatched episode",
+ "watched episode",
+ ].map(getListActivityAction),
+ ).toEqual([
+ "completed",
+ "dropped",
+ "pausedReading",
+ "pausedWatching",
+ "plansToRead",
+ "plansToWatch",
+ "readChapter",
+ "rereadChapter",
+ "rewatchedEpisode",
+ "watchedEpisode",
+ ]);
+ });
+
+ test("uses a localizable fallback for unknown or missing statuses", () => {
+ expect(getListActivityAction("future status")).toBe("updated");
+ expect(getListActivityAction(null)).toBe("updated");
+ });
+});
diff --git a/graphql/activity.ts b/graphql/activity.ts
new file mode 100644
index 0000000..ff1252c
--- /dev/null
+++ b/graphql/activity.ts
@@ -0,0 +1,99 @@
+import { graphql } from "yep/graphql/tada";
+import type { ResultOf } from "yep/graphql/tada";
+
+export const ActivityFeedFragment = graphql(`
+ fragment ActivityFeedFragment on ListActivity @_unmask {
+ id
+ createdAt
+ status
+ progress
+ user {
+ id
+ name
+ avatar {
+ medium
+ large
+ }
+ }
+ media {
+ id
+ title {
+ romaji
+ native
+ english
+ }
+ coverImage {
+ medium
+ large
+ }
+ }
+ }
+`);
+
+export type ActivityFeedItem = ResultOf;
+
+// How many list activities the profile screens show (shared so the personal
+// and public profile paths can't drift apart).
+export const PROFILE_ACTIVITY_LIMIT = 10;
+
+export type ListActivityAction =
+ | "completed"
+ | "dropped"
+ | "pausedReading"
+ | "pausedWatching"
+ | "plansToRead"
+ | "plansToWatch"
+ | "readChapter"
+ | "rereadChapter"
+ | "rewatchedEpisode"
+ | "updated"
+ | "watchedEpisode";
+
+// AniList sends these as English display strings rather than an enum. Collapse
+// them to stable app values so every visible action can be localized, while an
+// unknown future status still gets a safe generic label.
+export function getListActivityAction(
+ status: string | null | undefined,
+): ListActivityAction {
+ switch (status) {
+ case "completed":
+ case "dropped":
+ return status;
+ case "paused reading":
+ return "pausedReading";
+ case "paused watching":
+ return "pausedWatching";
+ case "plans to read":
+ return "plansToRead";
+ case "plans to watch":
+ return "plansToWatch";
+ case "read chapter":
+ return "readChapter";
+ case "reread chapter":
+ return "rereadChapter";
+ case "rewatched episode":
+ return "rewatchedEpisode";
+ case "watched episode":
+ return "watchedEpisode";
+ default:
+ return "updated";
+ }
+}
+
+// Shared normalization for every activities(...) query: keep only concrete
+// ListActivity entries whose user and media survived (AniList allows both to
+// be null, e.g. after moderation).
+export function filterListActivities(
+ activities:
+ | readonly ({ __typename: string } | null | undefined)[]
+ | null
+ | undefined,
+): ActivityFeedItem[] {
+ return (activities ?? []).flatMap((activity) => {
+ if (activity?.__typename !== "ListActivity") return [];
+ // Callers select the fragment on every ListActivity member; the union's
+ // other members only carry __typename, so this narrowing cast is safe.
+ const listActivity = activity as unknown as ActivityFeedItem;
+ return listActivity.user && listActivity.media ? [listActivity] : [];
+ });
+}
diff --git a/graphql/client.ts b/graphql/client.ts
index 4e43406..19ddb67 100644
--- a/graphql/client.ts
+++ b/graphql/client.ts
@@ -148,6 +148,7 @@ const cache = new InMemoryCache({
// the concrete object types to match inline fragments on reads. Only the
// members we query are listed — add more if the query grows.
possibleTypes: {
+ ActivityUnion: ["ListActivity"],
NotificationUnion: [
"AiringNotification",
"RelatedMediaAdditionNotification",
@@ -163,6 +164,9 @@ const cache = new InMemoryCache({
// page. Hence `page` is excluded from every container key.
Page: {
keyArgs: (args, { variables }) => {
+ if (variables?.activityType != null) {
+ return `activities:${variables.activityType}:${variables.userId ?? "global"}:${variables.isFollowing ?? false}:${variables.perPage ?? "default"}`;
+ }
if (variables?.userId != null && variables?.status != null) {
return `mediaList:${variables.type}:${variables.userId}:${variables.status}:${JSON.stringify(variables.sort ?? null)}`;
}
@@ -241,6 +245,23 @@ const cache = new InMemoryCache({
);
},
},
+ activities: {
+ keyArgs: ["userId", "isFollowing", "type", "sort"],
+ merge(existing, incoming, { variables, readField }) {
+ if (!existing || (variables?.page ?? 1) <= 1) return incoming;
+ if (
+ (variables?.page ?? 1) !==
+ nextPageForCount(existing.length, variables?.perPage)
+ ) {
+ return existing;
+ }
+ return mergeMediaListPages(
+ existing as unknown[],
+ incoming as unknown[],
+ (entry) => readField("id", entry as Reference),
+ );
+ },
+ },
users: {
keyArgs: ["search", "sort"],
merge(existing, incoming, { variables, readField }) {
diff --git a/i18n/translations/ja_JP.json b/i18n/translations/ja_JP.json
index adf62fb..c81d683 100644
--- a/i18n/translations/ja_JP.json
+++ b/i18n/translations/ja_JP.json
@@ -71,6 +71,9 @@
"1fxZV0": "プロフィール",
"423B0j": "プロフィールを読み込めませんでした",
"2GfG5Y": "このユーザーは見つかりませんでした。",
+ "36AXPs": "最近のアクティビティ",
+ "3HeW2l": "最近のアクティビティを読み込めませんでした。",
+ "2ZAAjB": "リストの更新はまだありません。",
"2piT5K": "アニメ合計",
"12fjol": "視聴日数",
"D8UG0": "マンガ総数",
@@ -105,6 +108,21 @@
"2REe": "フォロー中",
"4hHIEp": "フォロー",
"2Archa": "フォローを更新できませんでした。接続を確認してもう一度お試しください。",
+ "2AZYPd": "がエピソード{progress}を視聴した作品:",
+ "19I36w": "がエピソードを視聴した作品:",
+ "2TkOPM": "がエピソード{progress}を再視聴した作品:",
+ "2qz4MV": "がエピソードを再視聴した作品:",
+ "2ZxPF3": "が第{progress}章を読んだ作品:",
+ "1DmfNm": "が章を読んだ作品:",
+ "Xir7z": "が第{progress}章を再読した作品:",
+ "1MkA43": "が章を再読した作品:",
+ "4m36q6": "が視聴予定に追加:",
+ "20qEZK": "が読む予定に追加:",
+ "kS7jL": "が完了した作品:",
+ "2a40nb": "が視聴を一時停止:",
+ "2ElLb5": "が読書を一時停止:",
+ "2WVIkA": "が中断した作品:",
+ "2YIBv8": "がリストを更新:",
"4Akn4q": "検索をクリア",
"1JIpM2": "キャンセル",
"IEtNR": "お気に入りから削除",
diff --git a/screens/ProfileScreen/UserProfileContent.tsx b/screens/ProfileScreen/UserProfileContent.tsx
index 20e9f12..f454299 100644
--- a/screens/ProfileScreen/UserProfileContent.tsx
+++ b/screens/ProfileScreen/UserProfileContent.tsx
@@ -2,11 +2,22 @@ import { Image, ImageBackground } from "expo-image";
import { useRouter } from "expo-router";
import { fbs } from "fbtee";
import React, { PropsWithChildren, ReactNode } from "react";
-import { FlatList, StyleSheet, Text, View } from "react-native";
+import {
+ ActivityIndicator,
+ FlatList,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
import { black50 } from "yep/colors";
import { PosterAndTitle } from "yep/components/PosterAndTitle";
import { PressableOpacity } from "yep/components/PressableOpacity";
+import {
+ ActivityFeedDivider,
+ ActivityFeedRow,
+} from "yep/components/activity-feed-row";
+import type { ActivityFeedItem } from "yep/graphql/activity";
import { UserProfileFragment } from "yep/graphql/profile";
import type { ResultOf } from "yep/graphql/tada";
import { SCREENSHOT_BLUR_RADIUS, fakeHandle } from "yep/screenshotMode";
@@ -15,6 +26,7 @@ import { Manrope } from "yep/typefaces";
import { notEmpty, useGetName, useGetTitle } from "yep/utils";
type ProfileUser = ResultOf;
+const EMPTY_ACTIVITIES: readonly ActivityFeedItem[] = [];
type ItemWithId = { id: number };
type FavoriteAnimeItem = NonNullable<
NonNullable["anime"]>["nodes"]
@@ -105,12 +117,65 @@ function Stat({ label, value }: { label: string; value: number }) {
);
}
+function UserActivityFeed({
+ activities,
+ loading,
+ error,
+}: {
+ activities: readonly ActivityFeedItem[];
+ loading: boolean;
+ error: boolean;
+}) {
+ return (
+
+
+ {String(fbs("Recent activity", "Profile recent list activity title"))}
+
+ {loading ? (
+
+ ) : activities.length ? (
+
+ {activities.map((activity, index) => (
+
+ {index ? : null}
+
+
+ ))}
+
+ ) : error ? (
+
+ {String(
+ fbs("Could not load recent activity.", "Profile activity error"),
+ )}
+
+ ) : (
+
+ {String(fbs("No list updates yet.", "Empty profile activity text"))}
+
+ )}
+
+ );
+}
+
export function UserProfileContent({
user,
action,
+ activities = EMPTY_ACTIVITIES,
+ activityLoading = false,
+ activityError = false,
}: {
user: ProfileUser;
action?: ReactNode;
+ activities?: readonly ActivityFeedItem[];
+ activityLoading?: boolean;
+ activityError?: boolean;
}) {
const avatarUrl = user.avatar?.large ?? user.avatar?.medium;
const animeList = (user.favourites?.anime?.nodes ?? []).filter(notEmpty);
@@ -195,14 +260,26 @@ export function UserProfileContent({
/>
) : null}
+
);
}
const styles = StyleSheet.create({
+ activityLoading: { alignSelf: "flex-start", paddingVertical: 24 },
avatar: { borderRadius: 24, height: 48, width: 48 },
banner: { borderCurve: "continuous", borderRadius: 16, overflow: "hidden" },
container: { flex: 1, gap: 20 },
+ emptyActivity: {
+ color: darkTheme.subText,
+ fontFamily: Manrope.regular,
+ fontSize: 14,
+ paddingVertical: 12,
+ },
sectionTitle: {
color: darkTheme.text,
fontFamily: Manrope.semiBold,
diff --git a/screens/UserProfileScreen/index.tsx b/screens/UserProfileScreen/index.tsx
index fa63f38..977f23d 100644
--- a/screens/UserProfileScreen/index.tsx
+++ b/screens/UserProfileScreen/index.tsx
@@ -6,6 +6,11 @@ import { RefreshControl, ScrollView, StyleSheet } from "react-native";
import { EmptyState } from "yep/components/EmptyState";
import { FollowButton } from "yep/components/FollowButton";
+import {
+ ActivityFeedFragment,
+ PROFILE_ACTIVITY_LIMIT,
+ filterListActivities,
+} from "yep/graphql/activity";
import { UserProfileFragment } from "yep/graphql/profile";
import { graphql } from "yep/graphql/tada";
import { ProfileSkeleton } from "yep/screens/ProfileScreen/ProfileSkeleton";
@@ -15,16 +20,29 @@ import { useAccessToken } from "yep/useAccessToken";
const GetUserProfile = graphql(
`
- query GetUserProfile($userId: Int!, $includeViewer: Boolean!) {
+ query GetUserProfile(
+ $userId: Int!
+ $perPage: Int!
+ $activityType: ActivityType!
+ $includeViewer: Boolean!
+ ) {
Viewer @include(if: $includeViewer) {
id
}
User(id: $userId) {
...UserProfileFragment
}
+ Page(page: 1, perPage: $perPage) {
+ activities(userId: $userId, type: $activityType, sort: ID_DESC) {
+ __typename
+ ... on ListActivity {
+ ...ActivityFeedFragment
+ }
+ }
+ }
}
`,
- [UserProfileFragment],
+ [ActivityFeedFragment, UserProfileFragment],
);
export default function UserProfileScreen() {
@@ -39,11 +57,15 @@ export default function UserProfileScreen() {
skip: !validUserId,
variables: {
userId,
+ perPage: PROFILE_ACTIVITY_LIMIT,
+ activityType: "MEDIA_LIST",
includeViewer: Boolean(accessToken),
},
notifyOnNetworkStatusChange: true,
},
);
+ const activities = filterListActivities(data?.Page?.activities);
+
useEffect(() => {
navigation.setOptions({
title: data?.User?.name ?? String(fbs("Profile", "User profile title")),
@@ -70,6 +92,7 @@ export default function UserProfileScreen() {
) : data?.User ? (