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 ? (