From a51656bba94491784f6c3c577c98c5bb1b470a4c Mon Sep 17 00:00:00 2001 From: Juwan Wheatley Date: Fri, 10 Jul 2026 02:50:41 +0700 Subject: [PATCH 1/5] Add list activity to profiles --- app/(tabs)/profile.tsx | 67 +++++- components/activity-feed-row.tsx | 202 +++++++++++++++++++ graphql/client.ts | 21 ++ i18n/translations/ja_JP.json | 3 + screens/ProfileScreen/UserProfileContent.tsx | 79 +++++++- screens/UserProfileScreen/index.tsx | 33 ++- translations/ja_JP.json | 36 ++++ 7 files changed, 435 insertions(+), 6 deletions(-) create mode 100644 components/activity-feed-row.tsx diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 05ff693..1ad2502 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -10,8 +10,13 @@ import { white } from "yep/colors"; import { EmptyState } from "yep/components/EmptyState"; import { Header } from "yep/components/Header"; import { PressableOpacity } from "yep/components/PressableOpacity"; +import { + ActivityFeedFragment, + type ActivityFeedItem, +} from "yep/components/activity-feed-row"; import { ANILIST_ACCESS_TOKEN_STORAGE } from "yep/constants"; 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 +24,28 @@ import { UserProfileContent } from "yep/screens/ProfileScreen/UserProfileContent import { darkTheme } from "yep/themes"; import { useAccessToken } from "yep/useAccessToken"; +const PROFILE_ACTIVITY_LIMIT = 10; + +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 +60,32 @@ 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: ActivityFeedItem[] = ( + activityData?.Page?.activities ?? [] + ).flatMap((activity) => + activity?.__typename === "ListActivity" && activity.user && activity.media + ? [activity] + : [], + ); + const isRefetching = + networkStatus === NetworkStatus.refetch || + activityNetworkStatus === NetworkStatus.refetch; return ( @@ -61,7 +113,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 +156,12 @@ export default function Profile() { ) : loading && !data?.Viewer ? ( ) : data?.Viewer ? ( - + ) : error ? ( ; + +export function ActivityFeedDivider() { + return ; +} + +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 action = [item.status ?? "updated", item.progress] + .filter(Boolean) + .join(" "); + const userHref = `/user/${user.id}` as const; + const mediaHref = `/details/${media.id}` as const; + + return ( + + router.push(userHref)} + > + + + + + router.push(userHref)} + > + {fakeHandle(user.name)} + + + {` ${action}${item.progress ? " of " : " "}`} + + router.push(mediaHref)} + > + {mediaTitle} + + + + {formatDistanceToNow(new Date(item.createdAt * 1000), { + addSuffix: true, + locale: getDateFnsLocale(locale), + })} + + + router.push(mediaHref)} + > + + + + ); +} + +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/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..83f449d 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": "マンガ総数", diff --git a/screens/ProfileScreen/UserProfileContent.tsx b/screens/ProfileScreen/UserProfileContent.tsx index 20e9f12..bf54c0b 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, + type ActivityFeedItem, + ActivityFeedRow, +} from "yep/components/activity-feed-row"; 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..aa44616 100644 --- a/screens/UserProfileScreen/index.tsx +++ b/screens/UserProfileScreen/index.tsx @@ -6,6 +6,10 @@ import { RefreshControl, ScrollView, StyleSheet } from "react-native"; import { EmptyState } from "yep/components/EmptyState"; import { FollowButton } from "yep/components/FollowButton"; +import { + ActivityFeedFragment, + type ActivityFeedItem, +} from "yep/components/activity-feed-row"; import { UserProfileFragment } from "yep/graphql/profile"; import { graphql } from "yep/graphql/tada"; import { ProfileSkeleton } from "yep/screens/ProfileScreen/ProfileSkeleton"; @@ -13,18 +17,33 @@ import { UserProfileContent } from "yep/screens/ProfileScreen/UserProfileContent import { darkTheme } from "yep/themes"; import { useAccessToken } from "yep/useAccessToken"; +const PROFILE_ACTIVITY_LIMIT = 10; + 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 +58,20 @@ export default function UserProfileScreen() { skip: !validUserId, variables: { userId, + perPage: PROFILE_ACTIVITY_LIMIT, + activityType: "MEDIA_LIST", includeViewer: Boolean(accessToken), }, notifyOnNetworkStatusChange: true, }, ); + const activities: ActivityFeedItem[] = (data?.Page?.activities ?? []).flatMap( + (activity) => + activity?.__typename === "ListActivity" && activity.user && activity.media + ? [activity] + : [], + ); + useEffect(() => { navigation.setOptions({ title: data?.User?.name ?? String(fbs("Profile", "User profile title")), @@ -70,6 +98,7 @@ export default function UserProfileScreen() { ) : data?.User ? ( Date: Fri, 10 Jul 2026 03:23:04 +0700 Subject: [PATCH 2/5] Address review feedback for profile activity - Localize the activity headline connector and profile link accessibility labels (the status verb itself only exists in English on AniList) --- components/activity-feed-row.tsx | 25 ++++++++++++++++++++----- i18n/translations/ja_JP.json | 1 + translations/ja_JP.json | 12 ++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/components/activity-feed-row.tsx b/components/activity-feed-row.tsx index 57ac4d0..8e8a591 100644 --- a/components/activity-feed-row.tsx +++ b/components/activity-feed-row.tsx @@ -1,6 +1,7 @@ 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"; @@ -67,9 +68,25 @@ export function ActivityFeedRow({ const avatarUrl = user.avatar?.medium ?? user.avatar?.large ?? ""; const coverUrl = media.coverImage?.medium ?? media.coverImage?.large ?? ""; const mediaTitle = getTitle(media.title) ?? ""; + // ponytail: `status` verbs ("watched episode", "plans to watch") only exist + // in English on AniList — the connector below is the localizable part. const action = [item.status ?? "updated", item.progress] .filter(Boolean) .join(" "); + const connector = item.progress + ? ` ${String( + fbs( + "of", + "Connector between a list activity action and the media title, as in 'watched episode 3 of TITLE'", + ), + )} ` + : " "; + 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; @@ -88,7 +105,7 @@ export function ActivityFeedRow({ router.push(userHref)} > router.push(userHref)} > {fakeHandle(user.name)} - - {` ${action}${item.progress ? " of " : " "}`} - + {` ${action}${connector}`} Date: Fri, 10 Jul 2026 03:33:43 +0700 Subject: [PATCH 3/5] Address review comments - Extract the shared activity fragment, profile activity limit, and ListActivity normalization into graphql/activity.ts so the personal and public profile paths can't drift (also clears react-doctor's only-export-components warning on the row component) - Hide the activity row's cover poster when media has no cover image instead of rendering an empty uri --- app/(tabs)/profile.tsx | 17 ++---- components/activity-feed-row.tsx | 62 +++++--------------- graphql/activity.ts | 55 +++++++++++++++++ screens/ProfileScreen/UserProfileContent.tsx | 2 +- screens/UserProfileScreen/index.tsx | 14 ++--- 5 files changed, 81 insertions(+), 69 deletions(-) create mode 100644 graphql/activity.ts diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 1ad2502..a604a01 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -10,11 +10,12 @@ import { white } from "yep/colors"; 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, - type ActivityFeedItem, -} from "yep/components/activity-feed-row"; -import { ANILIST_ACCESS_TOKEN_STORAGE } from "yep/constants"; + 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"; @@ -24,8 +25,6 @@ import { UserProfileContent } from "yep/screens/ProfileScreen/UserProfileContent import { darkTheme } from "yep/themes"; import { useAccessToken } from "yep/useAccessToken"; -const PROFILE_ACTIVITY_LIMIT = 10; - const GetProfileListActivity = graphql( ` query GetProfileListActivity( @@ -76,13 +75,7 @@ export default function Profile() { }, notifyOnNetworkStatusChange: true, }); - const activities: ActivityFeedItem[] = ( - activityData?.Page?.activities ?? [] - ).flatMap((activity) => - activity?.__typename === "ListActivity" && activity.user && activity.media - ? [activity] - : [], - ); + const activities = filterListActivities(activityData?.Page?.activities); const isRefetching = networkStatus === NetworkStatus.refetch || activityNetworkStatus === NetworkStatus.refetch; diff --git a/components/activity-feed-row.tsx b/components/activity-feed-row.tsx index 8e8a591..d0d187d 100644 --- a/components/activity-feed-row.tsx +++ b/components/activity-feed-row.tsx @@ -6,45 +6,13 @@ import React from "react"; import { StyleSheet, Text, View } from "react-native"; import { PressableOpacity } from "yep/components/PressableOpacity"; -import { graphql } from "yep/graphql/tada"; -import type { ResultOf } from "yep/graphql/tada"; +import type { ActivityFeedItem } 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 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; - export function ActivityFeedDivider() { return ; } @@ -144,19 +112,21 @@ export function ActivityFeedRow({ })} - router.push(mediaHref)} - > - - + {coverUrl ? ( + router.push(mediaHref)} + > + + + ) : null} ); } diff --git a/graphql/activity.ts b/graphql/activity.ts new file mode 100644 index 0000000..4c0a8a7 --- /dev/null +++ b/graphql/activity.ts @@ -0,0 +1,55 @@ +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; + +// 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/screens/ProfileScreen/UserProfileContent.tsx b/screens/ProfileScreen/UserProfileContent.tsx index bf54c0b..f454299 100644 --- a/screens/ProfileScreen/UserProfileContent.tsx +++ b/screens/ProfileScreen/UserProfileContent.tsx @@ -15,9 +15,9 @@ import { PosterAndTitle } from "yep/components/PosterAndTitle"; import { PressableOpacity } from "yep/components/PressableOpacity"; import { ActivityFeedDivider, - type ActivityFeedItem, 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"; diff --git a/screens/UserProfileScreen/index.tsx b/screens/UserProfileScreen/index.tsx index aa44616..977f23d 100644 --- a/screens/UserProfileScreen/index.tsx +++ b/screens/UserProfileScreen/index.tsx @@ -8,8 +8,9 @@ import { EmptyState } from "yep/components/EmptyState"; import { FollowButton } from "yep/components/FollowButton"; import { ActivityFeedFragment, - type ActivityFeedItem, -} from "yep/components/activity-feed-row"; + 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"; @@ -17,8 +18,6 @@ import { UserProfileContent } from "yep/screens/ProfileScreen/UserProfileContent import { darkTheme } from "yep/themes"; import { useAccessToken } from "yep/useAccessToken"; -const PROFILE_ACTIVITY_LIMIT = 10; - const GetUserProfile = graphql( ` query GetUserProfile( @@ -65,12 +64,7 @@ export default function UserProfileScreen() { notifyOnNetworkStatusChange: true, }, ); - const activities: ActivityFeedItem[] = (data?.Page?.activities ?? []).flatMap( - (activity) => - activity?.__typename === "ListActivity" && activity.user && activity.media - ? [activity] - : [], - ); + const activities = filterListActivities(data?.Page?.activities); useEffect(() => { navigation.setOptions({ From c9e607e201709f79ed87af3034b2e1a4fc0bcb06 Mon Sep 17 00:00:00 2001 From: Juwan Wheatley Date: Fri, 17 Jul 2026 20:41:42 +0900 Subject: [PATCH 4/5] Localize list activity actions --- components/activity-feed-row.tsx | 121 ++++++++++++++++++--- graphql/activity.test.ts | 38 +++++++ graphql/activity.ts | 44 ++++++++ i18n/translations/ja_JP.json | 16 ++- translations/ja_JP.json | 174 ++++++++++++++++++++++++++++++- 5 files changed, 374 insertions(+), 19 deletions(-) create mode 100644 graphql/activity.test.ts diff --git a/components/activity-feed-row.tsx b/components/activity-feed-row.tsx index d0d187d..80dc795 100644 --- a/components/activity-feed-row.tsx +++ b/components/activity-feed-row.tsx @@ -6,7 +6,11 @@ import React from "react"; import { StyleSheet, Text, View } from "react-native"; import { PressableOpacity } from "yep/components/PressableOpacity"; -import type { ActivityFeedItem } from "yep/graphql/activity"; +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"; @@ -17,6 +21,102 @@ export function ActivityFeedDivider() { return ; } +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, @@ -36,19 +136,10 @@ export function ActivityFeedRow({ const avatarUrl = user.avatar?.medium ?? user.avatar?.large ?? ""; const coverUrl = media.coverImage?.medium ?? media.coverImage?.large ?? ""; const mediaTitle = getTitle(media.title) ?? ""; - // ponytail: `status` verbs ("watched episode", "plans to watch") only exist - // in English on AniList — the connector below is the localizable part. - const action = [item.status ?? "updated", item.progress] - .filter(Boolean) - .join(" "); - const connector = item.progress - ? ` ${String( - fbs( - "of", - "Connector between a list activity action and the media title, as in 'watched episode 3 of TITLE'", - ), - )} ` - : " "; + const actionText = getActivityActionText( + getListActivityAction(item.status), + item.progress, + ); const profileLabel = String( fbs( [fbs.param("username", user.name), "'s profile"], @@ -95,7 +186,7 @@ export function ActivityFeedRow({ > {fakeHandle(user.name)} - {` ${action}${connector}`} + {actionText} { + 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 index 4c0a8a7..ff1252c 100644 --- a/graphql/activity.ts +++ b/graphql/activity.ts @@ -36,6 +36,50 @@ export type ActivityFeedItem = ResultOf; // 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). diff --git a/i18n/translations/ja_JP.json b/i18n/translations/ja_JP.json index e935669..c81d683 100644 --- a/i18n/translations/ja_JP.json +++ b/i18n/translations/ja_JP.json @@ -108,7 +108,21 @@ "2REe": "フォロー中", "4hHIEp": "フォロー", "2Archa": "フォローを更新できませんでした。接続を確認してもう一度お試しください。", - "1tYb0m": "の", + "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/translations/ja_JP.json b/translations/ja_JP.json index 957a07e..650ec31 100644 --- a/translations/ja_JP.json +++ b/translations/ja_JP.json @@ -2689,13 +2689,181 @@ ], "types": [] }, - "zq74ZOt7by+rxIvYGYLPmA==": { - "description": "Connector between a list activity action and the media title, as in 'watched episode 3 of TITLE'", + "qfB+N2yX0FWtg8dJUO/5+Q==": { + "description": "Anime list activity with episode progress", "status": "translated", "tokens": [], "translations": [ { - "translation": "の", + "translation": "がエピソード{progress}を視聴した作品:", + "variations": {} + } + ], + "types": [] + }, + "F94cpzUCzUfpt7CAbNSW1Q==": { + "description": "Anime list activity without episode progress", + "status": "translated", + "tokens": [], + "translations": [ + { + "translation": "がエピソードを視聴した作品:", + "variations": {} + } + ], + "types": [] + }, + "kyemmZGKxXOabFmZMPx4+A==": { + "description": "Anime rewatch list activity with episode progress", + "status": "translated", + "tokens": [], + "translations": [ + { + "translation": "がエピソード{progress}を再視聴した作品:", + "variations": {} + } + ], + "types": [] + }, + "SXNJtloDW40hAzdSkPdKcA==": { + "description": "Anime rewatch list activity without episode progress", + "status": "translated", + "tokens": [], + "translations": [ + { + "translation": "がエピソードを再視聴した作品:", + "variations": {} + } + ], + "types": [] + }, + "xatjkgS1DRWMob96zgCazw==": { + "description": "Manga list activity with chapter progress", + "status": "translated", + "tokens": [], + "translations": [ + { + "translation": "が第{progress}章を読んだ作品:", + "variations": {} + } + ], + "types": [] + }, + "xceDTP/D12iq1dapdYQs5w==": { + "description": "Manga list activity without chapter progress", + "status": "translated", + "tokens": [], + "translations": [ + { + "translation": "が章を読んだ作品:", + "variations": {} + } + ], + "types": [] + }, + "rqykQlQmnNDvgKVlJe6PZw==": { + "description": "Manga reread list activity with chapter progress", + "status": "translated", + "tokens": [], + "translations": [ + { + "translation": "が第{progress}章を再読した作品:", + "variations": {} + } + ], + "types": [] + }, + "cC88Ck/hX0RdDXJqKPd2dA==": { + "description": "Manga reread list activity without chapter progress", + "status": "translated", + "tokens": [], + "translations": [ + { + "translation": "が章を再読した作品:", + "variations": {} + } + ], + "types": [] + }, + "sYTUDia2BeaE66zxzszrlg==": { + "description": "Plans to watch list activity", + "status": "translated", + "tokens": [], + "translations": [ + { + "translation": "が視聴予定に追加:", + "variations": {} + } + ], + "types": [] + }, + "HMYT23HDvctVhipavmfzOA==": { + "description": "Plans to read list activity", + "status": "translated", + "tokens": [], + "translations": [ + { + "translation": "が読む予定に追加:", + "variations": {} + } + ], + "types": [] + }, + "82gaRNUuBRO4avQZIFVU9A==": { + "description": "Completed list activity", + "status": "translated", + "tokens": [], + "translations": [ + { + "translation": "が完了した作品:", + "variations": {} + } + ], + "types": [] + }, + "TzMb66HhcoODiYrTE8dBzQ==": { + "description": "Paused watching list activity", + "status": "translated", + "tokens": [], + "translations": [ + { + "translation": "が視聴を一時停止:", + "variations": {} + } + ], + "types": [] + }, + "inCJFDjPGGAKQB0TnGOlSw==": { + "description": "Paused reading list activity", + "status": "translated", + "tokens": [], + "translations": [ + { + "translation": "が読書を一時停止:", + "variations": {} + } + ], + "types": [] + }, + "3T0UXnE+0r26c7xPwXvUfA==": { + "description": "Dropped list activity", + "status": "translated", + "tokens": [], + "translations": [ + { + "translation": "が中断した作品:", + "variations": {} + } + ], + "types": [] + }, + "q5W3a4KI7dpwpxRbUg4yVA==": { + "description": "Generic list activity", + "status": "translated", + "tokens": [], + "translations": [ + { + "translation": "がリストを更新:", "variations": {} } ], From 25814fe2d7b330ed24215b829071879e6878f849 Mon Sep 17 00:00:00 2001 From: Juwan Wheatley Date: Fri, 17 Jul 2026 20:50:50 +0900 Subject: [PATCH 5/5] Preserve localized activity spacing --- components/activity-feed-row.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/activity-feed-row.tsx b/components/activity-feed-row.tsx index 80dc795..8853436 100644 --- a/components/activity-feed-row.tsx +++ b/components/activity-feed-row.tsx @@ -140,6 +140,12 @@ export function ActivityFeedRow({ 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"], @@ -186,7 +192,7 @@ export function ActivityFeedRow({ > {fakeHandle(user.name)} - {actionText} + {renderedActionText}