From 9a764b8750fa6205c352d749fdab117a11b25179 Mon Sep 17 00:00:00 2001 From: Nicolas Becharat Date: Mon, 15 Jun 2026 10:36:35 +0200 Subject: [PATCH 1/2] feat(mobile): person detail screen with tappable cast (#32) Make cast members tappable: tapping one opens a person page showing their photo, bio, and filmography split into Acting and Directing/Crew. Titles in the filmography open their media detail, all within the current tab. - api: GET /tmdb/person/:personId (person + combined_credits), new to-person-detail mapper (movie/tv only, poster-filtered, de-duplicated, sorted by popularity/recency, normalized to the shared summary shape) - mobile: lib wrapper, tmdbKeys.person, usePersonDetail hook - mobile: personDetailHref + person/[id] routes in all four tab stacks; useMediaRouteBase now also resolves the insights tab - mobile: PersonDetail screen (parallax photo header, reused expandable bio, two filmography shelves), cast avatars navigate with a tap haptic - i18n: person.biography/acting/crew (EN/FR) - whats-new: "Cast & Crew Pages" release Co-Authored-By: Claude Opus 4.8 --- apps/api/src/modules/tmdb/model.ts | 15 +++++ apps/api/src/modules/tmdb/queries/index.ts | 1 + .../src/modules/tmdb/queries/person-detail.ts | 19 ++++++ apps/api/src/modules/tmdb/resources/index.ts | 1 + .../tmdb/resources/to-person-detail.ts | 51 +++++++++++++++ apps/api/src/modules/tmdb/router.ts | 12 ++++ apps/api/src/modules/whats-new/data.ts | 13 ++++ .../src/app/(tabs)/insights/_layout.tsx | 11 ++++ .../src/app/(tabs)/insights/person/[id].tsx | 5 ++ .../mobile/src/app/(tabs)/profile/_layout.tsx | 11 ++++ .../src/app/(tabs)/profile/person/[id].tsx | 5 ++ apps/mobile/src/app/(tabs)/search/_layout.tsx | 11 ++++ .../src/app/(tabs)/search/person/[id].tsx | 5 ++ .../src/app/(tabs)/watchlist/_layout.tsx | 11 ++++ .../src/app/(tabs)/watchlist/person/[id].tsx | 5 ++ .../screens/episode-detail/index.tsx | 4 +- .../screens/media-detail/cast-section.tsx | 61 ++++++++++-------- .../components/screens/media-detail/index.tsx | 2 +- .../screens/media-detail/overview-section.tsx | 7 ++- .../person-detail/filmography-section.tsx | 54 ++++++++++++++++ .../screens/person-detail/index.tsx | 62 +++++++++++++++++++ .../screens/person-detail/person-summary.tsx | 29 +++++++++ .../use-person-detail-view-model.ts | 29 +++++++++ .../src/hooks/tmdb/use-person-detail.ts | 28 +++++++++ apps/mobile/src/hooks/use-media-route-base.ts | 1 + apps/mobile/src/lib/i18n/locales/en.ts | 5 ++ apps/mobile/src/lib/i18n/locales/fr.ts | 5 ++ apps/mobile/src/lib/navigation.ts | 11 ++++ apps/mobile/src/lib/tmdb/index.ts | 2 + apps/mobile/src/lib/tmdb/person.ts | 15 +++++ apps/mobile/src/lib/tmdb/types.ts | 1 + 31 files changed, 461 insertions(+), 31 deletions(-) create mode 100644 apps/api/src/modules/tmdb/queries/person-detail.ts create mode 100644 apps/api/src/modules/tmdb/resources/to-person-detail.ts create mode 100644 apps/mobile/src/app/(tabs)/insights/person/[id].tsx create mode 100644 apps/mobile/src/app/(tabs)/profile/person/[id].tsx create mode 100644 apps/mobile/src/app/(tabs)/search/person/[id].tsx create mode 100644 apps/mobile/src/app/(tabs)/watchlist/person/[id].tsx create mode 100644 apps/mobile/src/components/screens/person-detail/filmography-section.tsx create mode 100644 apps/mobile/src/components/screens/person-detail/index.tsx create mode 100644 apps/mobile/src/components/screens/person-detail/person-summary.tsx create mode 100644 apps/mobile/src/components/screens/person-detail/use-person-detail-view-model.ts create mode 100644 apps/mobile/src/hooks/tmdb/use-person-detail.ts create mode 100644 apps/mobile/src/lib/tmdb/person.ts diff --git a/apps/api/src/modules/tmdb/model.ts b/apps/api/src/modules/tmdb/model.ts index edb6ff3..4aba032 100644 --- a/apps/api/src/modules/tmdb/model.ts +++ b/apps/api/src/modules/tmdb/model.ts @@ -112,6 +112,16 @@ const episodeDetail = t.Composite([ }), ]); +const personDetail = t.Object({ + id: t.Number(), + name: t.String(), + biography: t.Optional(t.Nullable(t.String())), + profile_path: t.Optional(t.Nullable(t.String())), + known_for_department: t.Optional(t.Nullable(t.String())), + acting: t.Array(summary), + crew: t.Array(summary), +}); + const providerRef = t.Object({ providerId: t.Number(), name: t.String(), @@ -158,6 +168,10 @@ export const TmdbModel = new Elysia({ name: "Tmdb.Model" }).model({ mediaType, tmdbId: t.Numeric(), }), + "tmdb.PersonParams": t.Object({ + personId: t.Numeric(), + }), + "tmdb.PersonDetail": personDetail, "tmdb.LanguageQuery": t.Object({ language: t.Optional(t.String({ minLength: 2, maxLength: 12 })), }), @@ -191,6 +205,7 @@ export type CreditDto = Static; export type SeasonSummaryDto = Static; export type EpisodeSummaryDto = Static; export type MovieDetailDto = Static; +export type PersonDetailDto = Static; export type SeasonDetailDto = Static; export type EpisodeDetailDto = Static; export type DiscoverFeedDto = Static; diff --git a/apps/api/src/modules/tmdb/queries/index.ts b/apps/api/src/modules/tmdb/queries/index.ts index 88d2dc8..76e0d34 100644 --- a/apps/api/src/modules/tmdb/queries/index.ts +++ b/apps/api/src/modules/tmdb/queries/index.ts @@ -2,6 +2,7 @@ export * from "./discover-feed"; export * from "./search"; export * from "./media-detail"; export * from "./media-recommendations"; +export * from "./person-detail"; export * from "./tv-season-detail"; export * from "./tv-episode-detail"; export * from "./watch-providers"; diff --git a/apps/api/src/modules/tmdb/queries/person-detail.ts b/apps/api/src/modules/tmdb/queries/person-detail.ts new file mode 100644 index 0000000..17e1cc1 --- /dev/null +++ b/apps/api/src/modules/tmdb/queries/person-detail.ts @@ -0,0 +1,19 @@ +import { tmdbFetch } from "../client"; +import { DEFAULT_LANGUAGE } from "../constants"; +import type { PersonDetailDto } from "../model"; +import { toPersonDetail } from "../resources"; + +// Person bios + filmographies change slowly; cache for a week. +const PERSON_TTL_SECONDS = 7 * 24 * 3600; + +export async function getPersonDetail( + personId: number, + language = DEFAULT_LANGUAGE, +): Promise { + const raw = await tmdbFetch>( + `/person/${personId}`, + { language, append_to_response: "combined_credits" }, + PERSON_TTL_SECONDS, + ); + return toPersonDetail(raw); +} diff --git a/apps/api/src/modules/tmdb/resources/index.ts b/apps/api/src/modules/tmdb/resources/index.ts index 2512236..9a2f6d9 100644 --- a/apps/api/src/modules/tmdb/resources/index.ts +++ b/apps/api/src/modules/tmdb/resources/index.ts @@ -3,5 +3,6 @@ export { toCredit, toCredits } from "./to-credit"; export { toEpisodeDetail, toEpisodeSummary } from "./to-episode-detail"; export { toSeasonDetail, toSeasonSummary } from "./to-season-detail"; export { toMovieDetail } from "./to-movie-detail"; +export { toPersonDetail } from "./to-person-detail"; export { toWatchProviders } from "./to-watch-providers"; export type { ProviderRef, WatchProvidersResource } from "./to-watch-providers"; diff --git a/apps/api/src/modules/tmdb/resources/to-person-detail.ts b/apps/api/src/modules/tmdb/resources/to-person-detail.ts new file mode 100644 index 0000000..de87002 --- /dev/null +++ b/apps/api/src/modules/tmdb/resources/to-person-detail.ts @@ -0,0 +1,51 @@ +import { asNumber, asString } from "../../../lib/coerce"; +import { normalizeSummary } from "../normalize"; +import type { RawTmdbItem, TmdbMovieSummary } from "../types"; +import type { PersonDetailDto } from "../model"; + +// One credit can appear several times in combined_credits (multiple crew jobs on +// the same title, recurring TV roles); collapse to one entry per title. +function dedupeByTitle(items: TmdbMovieSummary[]): TmdbMovieSummary[] { + const seen = new Set(); + const out: TmdbMovieSummary[] = []; + for (const item of items) { + const key = `${item.media_type}:${item.id}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(item); + } + return out; +} + +// Most relevant work first: popularity, then most recent. +function byPopularityThenRecency(a: TmdbMovieSummary, b: TmdbMovieSummary): number { + const popularity = (b.popularity ?? 0) - (a.popularity ?? 0); + if (popularity !== 0) return popularity; + return (b.release_date ?? "").localeCompare(a.release_date ?? ""); +} + +// Normalize a combined-credits list (cast or crew) to the shared media-summary +// shape: movie/tv only, poster-only, de-duplicated, sorted by relevance. +function toFilmography(entries: unknown): TmdbMovieSummary[] { + if (!Array.isArray(entries)) return []; + const normalized = entries + .map((entry) => entry as RawTmdbItem) + .filter((entry) => entry?.media_type === "movie" || entry?.media_type === "tv") + .map((entry) => normalizeSummary(entry, "movie")) + .filter((entry) => !!entry.poster_path); + return dedupeByTitle(normalized).sort(byPopularityThenRecency); +} + +export function toPersonDetail(raw: Record): PersonDetailDto { + const credits = raw.combined_credits as Record | undefined; + + return { + id: asNumber(raw.id) ?? 0, + name: asString(raw.name) ?? "", + biography: asString(raw.biography) ?? null, + profile_path: (raw.profile_path as string | null | undefined) ?? null, + known_for_department: asString(raw.known_for_department) ?? null, + acting: toFilmography(credits?.cast), + crew: toFilmography(credits?.crew), + }; +} diff --git a/apps/api/src/modules/tmdb/router.ts b/apps/api/src/modules/tmdb/router.ts index cbe84fd..58e369f 100644 --- a/apps/api/src/modules/tmdb/router.ts +++ b/apps/api/src/modules/tmdb/router.ts @@ -6,6 +6,7 @@ import { discoverFeed, getMediaDetail, getMediaRecommendations, + getPersonDetail, getTvEpisodeDetail, getTvSeasonDetail, getWatchProviders, @@ -38,6 +39,17 @@ export const tmdbController = new Elysia({ }, }, ) + .get( + "/person/:personId", + ({ params, query }) => getPersonDetail(params.personId, query.language ?? DEFAULT_LANGUAGE), + { + params: "tmdb.PersonParams", + query: "tmdb.LanguageQuery", + response: { + 200: "tmdb.PersonDetail", + }, + }, + ) .get( "/:mediaType/:tmdbId", ({ params, query }) => diff --git a/apps/api/src/modules/whats-new/data.ts b/apps/api/src/modules/whats-new/data.ts index 2819929..6f72c5d 100644 --- a/apps/api/src/modules/whats-new/data.ts +++ b/apps/api/src/modules/whats-new/data.ts @@ -20,6 +20,19 @@ interface WhatsNewRelease { } export const WHATS_NEW_RELEASES: WhatsNewRelease[] = [ + { + id: "person-pages", + features: [ + { + icon: "person.crop.rectangle.stack", + title: { en: "Cast & Crew Pages", fr: "Fiches des acteurs" }, + description: { + en: "Tap any cast member to explore their photo, bio, and filmography.", + fr: "Touchez un acteur pour voir sa photo, sa bio et sa filmographie.", + }, + }, + ], + }, { id: "you-may-also-like", features: [ diff --git a/apps/mobile/src/app/(tabs)/insights/_layout.tsx b/apps/mobile/src/app/(tabs)/insights/_layout.tsx index 0deb1d0..999ae22 100644 --- a/apps/mobile/src/app/(tabs)/insights/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/insights/_layout.tsx @@ -37,6 +37,17 @@ export default function InsightsLayout() { headerBackButtonDisplayMode: "minimal", }} /> + ; +} diff --git a/apps/mobile/src/app/(tabs)/profile/_layout.tsx b/apps/mobile/src/app/(tabs)/profile/_layout.tsx index 26d9e8c..19f0f91 100644 --- a/apps/mobile/src/app/(tabs)/profile/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/profile/_layout.tsx @@ -115,6 +115,17 @@ export default function ProfileLayout() { headerBackButtonDisplayMode: "minimal", }} /> + ; +} diff --git a/apps/mobile/src/app/(tabs)/search/_layout.tsx b/apps/mobile/src/app/(tabs)/search/_layout.tsx index 2e4dd11..a312a42 100644 --- a/apps/mobile/src/app/(tabs)/search/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/search/_layout.tsx @@ -37,6 +37,17 @@ export default function SearchLayout() { headerBackButtonDisplayMode: "minimal", }} /> + ; +} diff --git a/apps/mobile/src/app/(tabs)/watchlist/_layout.tsx b/apps/mobile/src/app/(tabs)/watchlist/_layout.tsx index e003b76..75767fc 100644 --- a/apps/mobile/src/app/(tabs)/watchlist/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/watchlist/_layout.tsx @@ -37,6 +37,17 @@ export default function WatchlistLayout() { headerBackButtonDisplayMode: "minimal", }} /> + ; +} diff --git a/apps/mobile/src/components/screens/episode-detail/index.tsx b/apps/mobile/src/components/screens/episode-detail/index.tsx index 1bb3f26..ae25def 100644 --- a/apps/mobile/src/components/screens/episode-detail/index.tsx +++ b/apps/mobile/src/components/screens/episode-detail/index.tsx @@ -5,6 +5,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ScreenHeader, ScreenToolbar } from "@/components/navigation"; import { Text } from "@/components/ui/text"; +import { useMediaRouteBase } from "@/hooks/use-media-route-base"; import { useTheme } from "@/hooks/use-theme"; import { useWatchAction } from "@/hooks/watch-sessions/use-watch-action"; @@ -21,6 +22,7 @@ export function EpisodeDetail() { const theme = useTheme(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); + const base = useMediaRouteBase(); const vm = useEpisodeDetailViewModel(); const watchAction = useWatchAction({ @@ -96,7 +98,7 @@ export function EpisodeDetail() { onOpenReviews={vm.openReviews} /> - + diff --git a/apps/mobile/src/components/screens/media-detail/cast-section.tsx b/apps/mobile/src/components/screens/media-detail/cast-section.tsx index d7cfdc6..103bbe9 100644 --- a/apps/mobile/src/components/screens/media-detail/cast-section.tsx +++ b/apps/mobile/src/components/screens/media-detail/cast-section.tsx @@ -1,63 +1,72 @@ import { Image } from "expo-image"; -import { StyleSheet, View } from "react-native"; +import { Link } from "expo-router"; +import { Pressable, StyleSheet } from "react-native"; import { HorizontalScrollRow } from "@/components/ui/horizontal-scroll-row"; import { Text } from "@/components/ui/text"; import { SPACING } from "@/constants/design-tokens"; import { useTheme } from "@/hooks/use-theme"; import { truncate } from "@/lib/format"; +import { hapticTap } from "@/lib/haptics"; +import { personDetailHref, type MediaRouteBase } from "@/lib/navigation"; import { tmdbImageUrl } from "@/lib/tmdb"; import { DetailSection } from "./detail-section"; import type { CastMember } from "./types"; -export function CastSection({ cast }: { cast: CastMember[] }) { +export function CastSection({ cast, base }: { cast: CastMember[]; base: MediaRouteBase }) { if (cast.length === 0) return null; return ( {cast.map((member) => ( - + ))} ); } -function CastAvatar({ member }: { member: CastMember }) { +function CastAvatar({ member, base }: { member: CastMember; base: MediaRouteBase }) { const theme = useTheme(); const avatar = tmdbImageUrl(member.profile_path, "w185"); return ( - - - - {truncate(member.name, 18)} - - {member.character ? ( + + hapticTap()}> + - {truncate(member.character, 18)} + {truncate(member.name, 18)} - ) : null} - + {member.character ? ( + + {truncate(member.character, 18)} + + ) : null} + + ); } diff --git a/apps/mobile/src/components/screens/media-detail/index.tsx b/apps/mobile/src/components/screens/media-detail/index.tsx index e7d0836..bdead07 100644 --- a/apps/mobile/src/components/screens/media-detail/index.tsx +++ b/apps/mobile/src/components/screens/media-detail/index.tsx @@ -134,7 +134,7 @@ export function MediaDetail() { /> ) : null} - + diff --git a/apps/mobile/src/components/screens/media-detail/overview-section.tsx b/apps/mobile/src/components/screens/media-detail/overview-section.tsx index f7ffafd..069ab72 100644 --- a/apps/mobile/src/components/screens/media-detail/overview-section.tsx +++ b/apps/mobile/src/components/screens/media-detail/overview-section.tsx @@ -33,11 +33,12 @@ const PREVIEW_LINES = 3; const PREVIEW_FADE_WIDTH = 116; const PREVIEW_LINE_HEIGHT = LINE_HEIGHT.SM; -export function OverviewSection({ overview }: { overview?: string | null }) { +export function OverviewSection({ overview, title }: { overview?: string | null; title?: string }) { const theme = useTheme(); const { t } = useTranslation(); const [isPresented, setIsPresented] = useState(false); const trimmedOverview = overview?.trim() ?? ""; + const sectionTitle = title ?? t("mediaDetail.about"); if (!trimmedOverview) return null; @@ -51,7 +52,7 @@ export function OverviewSection({ overview }: { overview?: string | null }) { return ( <> - + {isLong ? ( - {t("mediaDetail.about")} + {sectionTitle} + + {title} + + + `${item.media_type}-${item.id}`} + renderItem={(item, _index, cardWidth) => ( + + )} + visibleCards={3} + /> + + + ); +} + +const styles = StyleSheet.create({ + section: { + gap: SPACING.SM, + paddingTop: SPACING.MD, + }, + // Break out of the parallax body's horizontal padding so the shelf spans + // edge-to-edge and Shelf owns its own padding. + shelf: { + marginHorizontal: -SPACING.MD, + }, +}); diff --git a/apps/mobile/src/components/screens/person-detail/index.tsx b/apps/mobile/src/components/screens/person-detail/index.tsx new file mode 100644 index 0000000..5b4a7e9 --- /dev/null +++ b/apps/mobile/src/components/screens/person-detail/index.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "react-i18next"; +import { StyleSheet, View } from "react-native"; +import { Stack } from "expo-router"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { ScreenHeader } from "@/components/navigation"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/use-theme"; + +import { OverviewSection } from "../media-detail/overview-section"; +import { MediaParallaxHeader } from "../media-detail/media-parallax-header"; +import { FilmographySection } from "./filmography-section"; +import { PersonSummary } from "./person-summary"; +import { usePersonDetailViewModel } from "./use-person-detail-view-model"; + +export function PersonDetail() { + const theme = useTheme(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const vm = usePersonDetailViewModel(); + + return ( + <> + + {vm.name} + + + + + + {vm.error && !vm.isLoading ? ( + + {vm.error} + + ) : null} + + + + + + + + {vm.isLoading ? ( + + Loading… + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + }, +}); diff --git a/apps/mobile/src/components/screens/person-detail/person-summary.tsx b/apps/mobile/src/components/screens/person-detail/person-summary.tsx new file mode 100644 index 0000000..28fe846 --- /dev/null +++ b/apps/mobile/src/components/screens/person-detail/person-summary.tsx @@ -0,0 +1,29 @@ +import { StyleSheet, View } from "react-native"; + +import { Text } from "@/components/ui/text"; +import { SPACING } from "@/constants/design-tokens"; +import { useTheme } from "@/hooks/use-theme"; + +export function PersonSummary({ name, role }: { name: string; role?: string | null }) { + const theme = useTheme(); + + return ( + + + {name} + + {role ? ( + + {role} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { + gap: SPACING.XS, + paddingTop: SPACING.SM, + }, +}); diff --git a/apps/mobile/src/components/screens/person-detail/use-person-detail-view-model.ts b/apps/mobile/src/components/screens/person-detail/use-person-detail-view-model.ts new file mode 100644 index 0000000..4b80d38 --- /dev/null +++ b/apps/mobile/src/components/screens/person-detail/use-person-detail-view-model.ts @@ -0,0 +1,29 @@ +import { useLocalSearchParams } from "expo-router"; + +import { usePersonDetail } from "@/hooks/tmdb/use-person-detail"; +import { useMediaRouteBase } from "@/hooks/use-media-route-base"; +import { tmdbImageUrl } from "@/lib/tmdb"; + +export function usePersonDetailViewModel() { + const params = useLocalSearchParams<{ id: string; name?: string }>(); + const personId = Number(params.id); + const base = useMediaRouteBase(); + + const { person, isLoading, error } = usePersonDetail(personId); + + const name = person?.name ?? params.name ?? ""; + const profileUri = tmdbImageUrl(person?.profile_path ?? null, "w500"); + + return { + personId, + base, + name, + profileUri, + role: person?.known_for_department ?? null, + biography: person?.biography ?? null, + acting: person?.acting ?? [], + crew: person?.crew ?? [], + isLoading, + error, + }; +} diff --git a/apps/mobile/src/hooks/tmdb/use-person-detail.ts b/apps/mobile/src/hooks/tmdb/use-person-detail.ts new file mode 100644 index 0000000..883e18d --- /dev/null +++ b/apps/mobile/src/hooks/tmdb/use-person-detail.ts @@ -0,0 +1,28 @@ +import { useQuery } from "@tanstack/react-query"; +import { tmdbKeys } from "@seen/shared"; +import { useTranslation } from "react-i18next"; + +import { errorMessage } from "@/lib/format"; +import { getPersonDetail, type TmdbPersonDetail, tmdbLanguage } from "@/lib/tmdb"; + +interface PersonDetailState { + person: TmdbPersonDetail | null; + isLoading: boolean; + error: string | null; +} + +export function usePersonDetail(personId: number): PersonDetailState { + const { i18n } = useTranslation(); + const language = tmdbLanguage(i18n.language); + const query = useQuery({ + queryKey: tmdbKeys.person(personId, language), + queryFn: () => getPersonDetail(personId, language), + enabled: Number.isFinite(personId) && personId > 0, + }); + + return { + person: query.data ?? null, + isLoading: query.isLoading, + error: query.error ? errorMessage(query.error, "Failed to load person") : null, + }; +} diff --git a/apps/mobile/src/hooks/use-media-route-base.ts b/apps/mobile/src/hooks/use-media-route-base.ts index f5be007..b18ed1e 100644 --- a/apps/mobile/src/hooks/use-media-route-base.ts +++ b/apps/mobile/src/hooks/use-media-route-base.ts @@ -10,5 +10,6 @@ export function useMediaRouteBase(): MediaRouteBase { if (segments.includes("search")) return "search"; if (segments.includes("watchlist")) return "watchlist"; if (segments.includes("profile")) return "profile"; + if (segments.includes("insights")) return "insights"; return "search"; } diff --git a/apps/mobile/src/lib/i18n/locales/en.ts b/apps/mobile/src/lib/i18n/locales/en.ts index 43137e5..c574b9d 100644 --- a/apps/mobile/src/lib/i18n/locales/en.ts +++ b/apps/mobile/src/lib/i18n/locales/en.ts @@ -172,6 +172,11 @@ export const en = { whereToWatch: "Where to watch", youMayAlsoLike: "You may also like", }, + person: { + biography: "Biography", + acting: "Acting", + crew: "Directing & Crew", + }, likes: { like: "Like", unlike: "Unlike", diff --git a/apps/mobile/src/lib/i18n/locales/fr.ts b/apps/mobile/src/lib/i18n/locales/fr.ts index 30a4e35..5392047 100644 --- a/apps/mobile/src/lib/i18n/locales/fr.ts +++ b/apps/mobile/src/lib/i18n/locales/fr.ts @@ -176,6 +176,11 @@ export const fr: typeof en = { whereToWatch: "Où regarder", youMayAlsoLike: "À voir aussi", }, + person: { + biography: "Biographie", + acting: "Rôles", + crew: "Réalisation & Équipe", + }, likes: { like: "J'aime", unlike: "Je n'aime plus", diff --git a/apps/mobile/src/lib/navigation.ts b/apps/mobile/src/lib/navigation.ts index 41d4b49..af460ac 100644 --- a/apps/mobile/src/lib/navigation.ts +++ b/apps/mobile/src/lib/navigation.ts @@ -38,6 +38,17 @@ export function mediaDetailHref(media: MediaDetailLink, base: MediaRouteBase = " } as Href; } +export function personDetailHref( + personId: number, + name: string, + base: MediaRouteBase = "search", +): Href { + return { + pathname: `/(tabs)/${base}/person/[id]`, + params: { id: String(personId), name }, + } as Href; +} + export function imageViewerHref(uri: string, base: MediaRouteBase = "search"): Href { return { pathname: `/(tabs)/${base}/image`, diff --git a/apps/mobile/src/lib/tmdb/index.ts b/apps/mobile/src/lib/tmdb/index.ts index f0043a4..ab0c420 100644 --- a/apps/mobile/src/lib/tmdb/index.ts +++ b/apps/mobile/src/lib/tmdb/index.ts @@ -5,6 +5,7 @@ export type { GenreRow, TmdbMovieSummary, TmdbMovieDetail, + TmdbPersonDetail, TmdbCredit, TmdbProviderRef, TmdbTvEpisodeDetail, @@ -22,6 +23,7 @@ export { trendingMedia } from "./trending"; export { findByExternalId } from "./find"; export { getMovieDetail } from "./movie"; export { getMediaRecommendations } from "./recommendations"; +export { getPersonDetail } from "./person"; export { getTvEpisodeDetail, getTvSeasonDetail } from "./tv"; export { getWatchProviders } from "./watch-providers"; export { tmdbLanguage } from "./client"; diff --git a/apps/mobile/src/lib/tmdb/person.ts b/apps/mobile/src/lib/tmdb/person.ts new file mode 100644 index 0000000..f23213b --- /dev/null +++ b/apps/mobile/src/lib/tmdb/person.ts @@ -0,0 +1,15 @@ +import { eden, unwrapEden } from "@/lib/eden"; + +import { tmdbLanguage } from "./client"; +import type { TmdbPersonDetail } from "./types"; + +export async function getPersonDetail( + personId: number, + language = tmdbLanguage(), +): Promise { + return unwrapEden( + eden.tmdb.person({ personId }).get({ + query: { language }, + }), + ); +} diff --git a/apps/mobile/src/lib/tmdb/types.ts b/apps/mobile/src/lib/tmdb/types.ts index 088bc44..8455471 100644 --- a/apps/mobile/src/lib/tmdb/types.ts +++ b/apps/mobile/src/lib/tmdb/types.ts @@ -2,6 +2,7 @@ import type { MediaType } from "@seen/shared"; export type { MovieDetailDto as TmdbMovieDetail, + PersonDetailDto as TmdbPersonDetail, SeasonDetailDto as TmdbTvSeasonDetail, EpisodeDetailDto as TmdbTvEpisodeDetail, EpisodeSummaryDto as TmdbTvEpisodeSummary, From df9567790bcf589525e7685a543bbd626b56821b Mon Sep 17 00:00:00 2001 From: Nicolas Becharat Date: Mon, 15 Jun 2026 10:48:37 +0200 Subject: [PATCH 2/2] feat: in-app media recommendations (#33) Recommend a movie or show to people you follow, with an optional note. Recipients get a push, find it in a dedicated "Received" inbox (with a bell + badge in the profile header), and tap through to the title. - db: new media_recommendations table (sender, recipient, media snapshot, message, readAt) + migration 0015 - api: new /media-recommendations module (send fan-out + per-recipient push, received inbox, mark-read, unread-count, recommendable-friends = people you follow, one-directional) - push: media-recommendation.received type + deep-link to the inbox - mobile: service handlers, query-key factory, send/received/unread hooks - mobile: send sheet (multi-select friends + optional message) opened from a media-detail header button; "Received" inbox marks entries read on view and opens the title; profile header bell + badge - i18n: recommend.* (EN/FR) - whats-new: "Recommend to Friends" release Run `bun run db:migrate` to apply the new table. Co-Authored-By: Claude Opus 4.8 --- .../modules/media-recommendations/index.ts | 1 + .../modules/media-recommendations/model.ts | 67 + .../media-recommendations/mutations/index.ts | 2 + .../mutations/mark-read.ts | 20 + .../mutations/send-recommendation.ts | 56 + .../notification-rules.ts | 20 + .../modules/media-recommendations/notify.ts | 40 + .../queries/count-unread.ts | 11 + .../media-recommendations/queries/index.ts | 3 + .../queries/list-received.ts | 45 + .../queries/list-recommendable-friends.ts | 33 + .../modules/media-recommendations/router.ts | 34 + apps/api/src/modules/router.ts | 2 + apps/api/src/modules/whats-new/data.ts | 13 + apps/mobile/src/app/_layout.tsx | 20 + apps/mobile/src/app/media-recommendations.tsx | 5 + apps/mobile/src/app/recommend.tsx | 5 + .../components/screens/media-detail/index.tsx | 23 +- .../media-recommendations-inbox/index.tsx | 165 + .../src/components/screens/profile/index.tsx | 18 +- .../screens/recommend-sheet/index.tsx | 258 ++ .../use-received-recommendations.ts | 30 + .../use-send-recommendation.ts | 24 + .../use-unread-recommendations.ts | 21 + apps/mobile/src/lib/i18n/locales/en.ts | 13 + apps/mobile/src/lib/i18n/locales/fr.ts | 13 + apps/mobile/src/lib/navigation.ts | 21 + apps/mobile/src/lib/push-notifications.ts | 10 +- .../handlers/count-unread.ts | 5 + .../handlers/list-received.ts | 7 + .../handlers/list-recommendable-friends.ts | 9 + .../handlers/mark-read.ts | 5 + .../media-recommendations/handlers/send.ts | 9 + .../services/media-recommendations/index.ts | 11 + .../services/media-recommendations/types.ts | 27 + packages/db/drizzle/0015_giant_wong.sql | 18 + packages/db/drizzle/meta/0015_snapshot.json | 3795 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/index.ts | 1 + .../src/schema/media-recommendations/index.ts | 3 + .../schema/media-recommendations/relations.ts | 17 + .../schema/media-recommendations/schema.ts | 35 + .../src/schema/media-recommendations/types.ts | 4 + packages/shared/src/query-keys.ts | 7 + 44 files changed, 4927 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/modules/media-recommendations/index.ts create mode 100644 apps/api/src/modules/media-recommendations/model.ts create mode 100644 apps/api/src/modules/media-recommendations/mutations/index.ts create mode 100644 apps/api/src/modules/media-recommendations/mutations/mark-read.ts create mode 100644 apps/api/src/modules/media-recommendations/mutations/send-recommendation.ts create mode 100644 apps/api/src/modules/media-recommendations/notification-rules.ts create mode 100644 apps/api/src/modules/media-recommendations/notify.ts create mode 100644 apps/api/src/modules/media-recommendations/queries/count-unread.ts create mode 100644 apps/api/src/modules/media-recommendations/queries/index.ts create mode 100644 apps/api/src/modules/media-recommendations/queries/list-received.ts create mode 100644 apps/api/src/modules/media-recommendations/queries/list-recommendable-friends.ts create mode 100644 apps/api/src/modules/media-recommendations/router.ts create mode 100644 apps/mobile/src/app/media-recommendations.tsx create mode 100644 apps/mobile/src/app/recommend.tsx create mode 100644 apps/mobile/src/components/screens/media-recommendations-inbox/index.tsx create mode 100644 apps/mobile/src/components/screens/recommend-sheet/index.tsx create mode 100644 apps/mobile/src/hooks/media-recommendations/use-received-recommendations.ts create mode 100644 apps/mobile/src/hooks/media-recommendations/use-send-recommendation.ts create mode 100644 apps/mobile/src/hooks/media-recommendations/use-unread-recommendations.ts create mode 100644 apps/mobile/src/services/media-recommendations/handlers/count-unread.ts create mode 100644 apps/mobile/src/services/media-recommendations/handlers/list-received.ts create mode 100644 apps/mobile/src/services/media-recommendations/handlers/list-recommendable-friends.ts create mode 100644 apps/mobile/src/services/media-recommendations/handlers/mark-read.ts create mode 100644 apps/mobile/src/services/media-recommendations/handlers/send.ts create mode 100644 apps/mobile/src/services/media-recommendations/index.ts create mode 100644 apps/mobile/src/services/media-recommendations/types.ts create mode 100644 packages/db/drizzle/0015_giant_wong.sql create mode 100644 packages/db/drizzle/meta/0015_snapshot.json create mode 100644 packages/db/src/schema/media-recommendations/index.ts create mode 100644 packages/db/src/schema/media-recommendations/relations.ts create mode 100644 packages/db/src/schema/media-recommendations/schema.ts create mode 100644 packages/db/src/schema/media-recommendations/types.ts diff --git a/apps/api/src/modules/media-recommendations/index.ts b/apps/api/src/modules/media-recommendations/index.ts new file mode 100644 index 0000000..9ca8201 --- /dev/null +++ b/apps/api/src/modules/media-recommendations/index.ts @@ -0,0 +1 @@ +export * from "./router"; diff --git a/apps/api/src/modules/media-recommendations/model.ts b/apps/api/src/modules/media-recommendations/model.ts new file mode 100644 index 0000000..afeab16 --- /dev/null +++ b/apps/api/src/modules/media-recommendations/model.ts @@ -0,0 +1,67 @@ +import { Elysia, t } from "elysia"; + +const profileCard = t.Object({ + user_id: t.String(), + username: t.Nullable(t.String()), + full_name: t.Nullable(t.String()), + avatar_path: t.Nullable(t.String()), +}); + +const received = t.Object({ + id: t.String(), + tmdb_id: t.Number(), + media_type: t.String(), + title: t.String(), + poster_path: t.Nullable(t.String()), + message: t.Nullable(t.String()), + read_at: t.Nullable(t.String()), + created_at: t.String(), + sender: profileCard, +}); + +export const MediaRecommendationModel = new Elysia({ + name: "MediaRecommendation.Model", +}).model({ + "mediaRecommendations.SendInput": t.Object({ + tmdb_id: t.Number(), + media_type: t.Union([t.Literal("movie"), t.Literal("tv")]), + title: t.String({ minLength: 1 }), + poster_path: t.Optional(t.Nullable(t.String())), + recipient_ids: t.Array(t.String({ minLength: 1 }), { minItems: 1 }), + message: t.Optional(t.Nullable(t.String({ maxLength: 500 }))), + }), + "mediaRecommendations.SendResult": t.Object({ + ok: t.Boolean(), + count: t.Number(), + }), + "mediaRecommendations.Received": received, + "mediaRecommendations.ReceivedList": t.Array(received), + "mediaRecommendations.FriendList": t.Array(profileCard), + "mediaRecommendations.UnreadCount": t.Object({ + count: t.Number(), + }), + "mediaRecommendations.OkResponse": t.Object({ + ok: t.Boolean(), + }), +}); + +export const mediaRecommendationModels = MediaRecommendationModel.models; + +export type RecommendationProfileCardDto = { + user_id: string; + username: string | null; + full_name: string | null; + avatar_path: string | null; +}; + +export type ReceivedRecommendationDto = { + id: string; + tmdb_id: number; + media_type: string; + title: string; + poster_path: string | null; + message: string | null; + read_at: string | null; + created_at: string; + sender: RecommendationProfileCardDto; +}; diff --git a/apps/api/src/modules/media-recommendations/mutations/index.ts b/apps/api/src/modules/media-recommendations/mutations/index.ts new file mode 100644 index 0000000..54fe7fa --- /dev/null +++ b/apps/api/src/modules/media-recommendations/mutations/index.ts @@ -0,0 +1,2 @@ +export { sendRecommendation } from "./send-recommendation"; +export { markRecommendationRead } from "./mark-read"; diff --git a/apps/api/src/modules/media-recommendations/mutations/mark-read.ts b/apps/api/src/modules/media-recommendations/mutations/mark-read.ts new file mode 100644 index 0000000..50a8309 --- /dev/null +++ b/apps/api/src/modules/media-recommendations/mutations/mark-read.ts @@ -0,0 +1,20 @@ +import { db } from "@seen/db"; +import { mediaRecommendations } from "@seen/db/schema"; +import { and, eq, isNull } from "@seen/db/orm"; + +export async function markRecommendationRead( + userId: string, + recommendationId: string, +): Promise<{ ok: boolean }> { + await db + .update(mediaRecommendations) + .set({ readAt: new Date() }) + .where( + and( + eq(mediaRecommendations.id, recommendationId), + eq(mediaRecommendations.recipientId, userId), + isNull(mediaRecommendations.readAt), + ), + ); + return { ok: true }; +} diff --git a/apps/api/src/modules/media-recommendations/mutations/send-recommendation.ts b/apps/api/src/modules/media-recommendations/mutations/send-recommendation.ts new file mode 100644 index 0000000..c17e34f --- /dev/null +++ b/apps/api/src/modules/media-recommendations/mutations/send-recommendation.ts @@ -0,0 +1,56 @@ +import { db } from "@seen/db"; +import { follows, mediaRecommendations } from "@seen/db/schema"; +import { and, eq, inArray } from "@seen/db/orm"; + +import { notifyRecommendation } from "../notify"; + +type SendInput = { + tmdb_id: number; + media_type: "movie" | "tv"; + title: string; + poster_path?: string | null; + recipient_ids: string[]; + message?: string | null; +}; + +export async function sendRecommendation( + senderId: string, + input: SendInput, +): Promise<{ ok: boolean; count: number }> { + const requested = [...new Set(input.recipient_ids)].filter((id) => id !== senderId); + if (requested.length === 0) return { ok: true, count: 0 }; + + // Recipients must be people the sender follows (one-directional). + const following = await db + .select({ id: follows.followeeId }) + .from(follows) + .where(and(eq(follows.followerId, senderId), inArray(follows.followeeId, requested))); + const recipientIds = following.map((row) => row.id); + if (recipientIds.length === 0) return { ok: true, count: 0 }; + + const rows = await db + .insert(mediaRecommendations) + .values( + recipientIds.map((recipientId) => ({ + senderId, + recipientId, + tmdbId: input.tmdb_id, + mediaType: input.media_type, + title: input.title, + posterPath: input.poster_path ?? null, + message: input.message ?? null, + })), + ) + .returning({ id: mediaRecommendations.id, recipientId: mediaRecommendations.recipientId }); + + for (const row of rows) { + void notifyRecommendation({ + recommendationId: row.id, + recipientUserId: row.recipientId, + actorId: senderId, + title: input.title, + }); + } + + return { ok: true, count: rows.length }; +} diff --git a/apps/api/src/modules/media-recommendations/notification-rules.ts b/apps/api/src/modules/media-recommendations/notification-rules.ts new file mode 100644 index 0000000..c53024c --- /dev/null +++ b/apps/api/src/modules/media-recommendations/notification-rules.ts @@ -0,0 +1,20 @@ +import type { ExpoPushMessage } from "../../lib/expo-push"; + +export type RecommendationNotification = { + recommendationId: string; + actorName: string; + title: string; +}; + +export function recommendationPushMessages( + event: RecommendationNotification, + tokens: string[], +): ExpoPushMessage[] { + return tokens.map((token) => ({ + to: token, + title: "New recommendation", + body: `${event.actorName} recommended ${event.title}.`, + sound: "default", + data: { type: "media-recommendation.received", recommendationId: event.recommendationId }, + })); +} diff --git a/apps/api/src/modules/media-recommendations/notify.ts b/apps/api/src/modules/media-recommendations/notify.ts new file mode 100644 index 0000000..bbf5f12 --- /dev/null +++ b/apps/api/src/modules/media-recommendations/notify.ts @@ -0,0 +1,40 @@ +import { db } from "@seen/db"; +import { profiles, pushTokens } from "@seen/db/schema"; +import { eq } from "@seen/db/orm"; + +import { sendExpoPush } from "../../lib/expo-push"; +import { maybeTrigger } from "../../lib/trigger"; +import { recommendationPushMessages } from "./notification-rules"; + +export async function notifyRecommendation(event: { + recommendationId: string; + recipientUserId: string; + actorId: string; + title: string; +}): Promise { + try { + const [tokens, [actor]] = await Promise.all([ + db + .select({ token: pushTokens.token }) + .from(pushTokens) + .where(eq(pushTokens.userId, event.recipientUserId)), + db + .select({ fullName: profiles.fullName }) + .from(profiles) + .where(eq(profiles.id, event.actorId)), + ]); + const messages = recommendationPushMessages( + { + recommendationId: event.recommendationId, + actorName: actor?.fullName ?? "Someone", + title: event.title, + }, + tokens.map((row) => row.token), + ); + if (messages.length === 0) return; + const enqueued = maybeTrigger("send-push", { messages }); + if (!enqueued) await sendExpoPush(messages); + } catch (error) { + console.error("media-recommendations: push dispatch failed", error); + } +} diff --git a/apps/api/src/modules/media-recommendations/queries/count-unread.ts b/apps/api/src/modules/media-recommendations/queries/count-unread.ts new file mode 100644 index 0000000..be44616 --- /dev/null +++ b/apps/api/src/modules/media-recommendations/queries/count-unread.ts @@ -0,0 +1,11 @@ +import { db } from "@seen/db"; +import { mediaRecommendations } from "@seen/db/schema"; +import { and, eq, isNull, sql } from "@seen/db/orm"; + +export async function countUnread(userId: string): Promise<{ count: number }> { + const [row] = await db + .select({ count: sql`count(*)::int` }) + .from(mediaRecommendations) + .where(and(eq(mediaRecommendations.recipientId, userId), isNull(mediaRecommendations.readAt))); + return { count: row?.count ?? 0 }; +} diff --git a/apps/api/src/modules/media-recommendations/queries/index.ts b/apps/api/src/modules/media-recommendations/queries/index.ts new file mode 100644 index 0000000..58897d2 --- /dev/null +++ b/apps/api/src/modules/media-recommendations/queries/index.ts @@ -0,0 +1,3 @@ +export { listReceived } from "./list-received"; +export { listRecommendableFriends } from "./list-recommendable-friends"; +export { countUnread } from "./count-unread"; diff --git a/apps/api/src/modules/media-recommendations/queries/list-received.ts b/apps/api/src/modules/media-recommendations/queries/list-received.ts new file mode 100644 index 0000000..ddfa1e8 --- /dev/null +++ b/apps/api/src/modules/media-recommendations/queries/list-received.ts @@ -0,0 +1,45 @@ +import { db } from "@seen/db"; +import { mediaRecommendations, profiles } from "@seen/db/schema"; +import { desc, eq } from "@seen/db/orm"; + +import type { ReceivedRecommendationDto } from "../model"; + +export async function listReceived(userId: string): Promise { + const rows = await db + .select({ + id: mediaRecommendations.id, + tmdbId: mediaRecommendations.tmdbId, + mediaType: mediaRecommendations.mediaType, + title: mediaRecommendations.title, + posterPath: mediaRecommendations.posterPath, + message: mediaRecommendations.message, + readAt: mediaRecommendations.readAt, + createdAt: mediaRecommendations.createdAt, + senderId: profiles.id, + username: profiles.username, + fullName: profiles.fullName, + avatarPath: profiles.avatarPath, + }) + .from(mediaRecommendations) + .innerJoin(profiles, eq(profiles.id, mediaRecommendations.senderId)) + .where(eq(mediaRecommendations.recipientId, userId)) + .orderBy(desc(mediaRecommendations.createdAt)) + .limit(100); + + return rows.map((row) => ({ + id: row.id, + tmdb_id: row.tmdbId, + media_type: row.mediaType, + title: row.title, + poster_path: row.posterPath, + message: row.message, + read_at: row.readAt?.toISOString() ?? null, + created_at: row.createdAt.toISOString(), + sender: { + user_id: row.senderId, + username: row.username, + full_name: row.fullName, + avatar_path: row.avatarPath, + }, + })); +} diff --git a/apps/api/src/modules/media-recommendations/queries/list-recommendable-friends.ts b/apps/api/src/modules/media-recommendations/queries/list-recommendable-friends.ts new file mode 100644 index 0000000..cfee758 --- /dev/null +++ b/apps/api/src/modules/media-recommendations/queries/list-recommendable-friends.ts @@ -0,0 +1,33 @@ +import { db } from "@seen/db"; +import { sql } from "@seen/db/orm"; + +import type { RecommendationProfileCardDto } from "../model"; + +type FriendRow = { + id: string; + username: string; + full_name: string; + avatar_path: string | null; +}; + +// Candidate recipients = people the current user follows (one-directional, no +// mutual-follow requirement — unlike watch-session invites). +export async function listRecommendableFriends( + userId: string, +): Promise { + const result = await db.execute(sql` + SELECT p.id, p.username, p.full_name, p.avatar_path + FROM follows f + JOIN profiles p ON p.id = f.followee_id + WHERE f.follower_id = ${userId} + ORDER BY p.full_name + LIMIT 100 + `); + + return result.rows.map((row) => ({ + user_id: row.id, + username: row.username, + full_name: row.full_name, + avatar_path: row.avatar_path, + })); +} diff --git a/apps/api/src/modules/media-recommendations/router.ts b/apps/api/src/modules/media-recommendations/router.ts new file mode 100644 index 0000000..4c3922f --- /dev/null +++ b/apps/api/src/modules/media-recommendations/router.ts @@ -0,0 +1,34 @@ +import { Elysia } from "elysia"; + +import { authGuard } from "../../auth-plugin"; +import { MediaRecommendationModel } from "./model"; +import { countUnread, listReceived, listRecommendableFriends } from "./queries"; +import { markRecommendationRead, sendRecommendation } from "./mutations"; + +export const mediaRecommendationController = new Elysia({ + name: "MediaRecommendation.Controller", + prefix: "/media-recommendations", +}) + .use(authGuard) + .use(MediaRecommendationModel) + .post("/", ({ user, body }) => sendRecommendation(user.id, body), { + auth: true, + body: "mediaRecommendations.SendInput", + response: { 200: "mediaRecommendations.SendResult" }, + }) + .get("/received", ({ user }) => listReceived(user.id), { + auth: true, + response: { 200: "mediaRecommendations.ReceivedList" }, + }) + .get("/unread-count", ({ user }) => countUnread(user.id), { + auth: true, + response: { 200: "mediaRecommendations.UnreadCount" }, + }) + .get("/recommendable-friends", ({ user }) => listRecommendableFriends(user.id), { + auth: true, + response: { 200: "mediaRecommendations.FriendList" }, + }) + .post("/:id/read", ({ user, params }) => markRecommendationRead(user.id, params.id), { + auth: true, + response: { 200: "mediaRecommendations.OkResponse" }, + }); diff --git a/apps/api/src/modules/router.ts b/apps/api/src/modules/router.ts index 410c962..23138de 100644 --- a/apps/api/src/modules/router.ts +++ b/apps/api/src/modules/router.ts @@ -7,6 +7,7 @@ import { eventsController } from "./events"; import { importController } from "./import"; import { libraryController } from "./library"; import { likesController } from "./likes"; +import { mediaRecommendationController } from "./media-recommendations"; import { notInterestedController } from "./not-interested"; import { notificationController } from "./notifications"; import { platformsController } from "./platforms"; @@ -38,6 +39,7 @@ export const apiRouter = new Elysia({ name: "api.router" }) .use(recommendationsController) .use(socialController) .use(watchSessionController) + .use(mediaRecommendationController) .use(notificationController) .use(analyticsController) .use(whatsNewController); diff --git a/apps/api/src/modules/whats-new/data.ts b/apps/api/src/modules/whats-new/data.ts index 6f72c5d..3bd939d 100644 --- a/apps/api/src/modules/whats-new/data.ts +++ b/apps/api/src/modules/whats-new/data.ts @@ -20,6 +20,19 @@ interface WhatsNewRelease { } export const WHATS_NEW_RELEASES: WhatsNewRelease[] = [ + { + id: "in-app-recommendations", + features: [ + { + icon: "paperplane", + title: { en: "Recommend to Friends", fr: "Recommander à des amis" }, + description: { + en: "Send a movie or series to people you follow, with an optional note.", + fr: "Envoyez un film ou une série à vos abonnements, avec un mot si vous voulez.", + }, + }, + ], + }, { id: "person-pages", features: [ diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 57d81b6..7d5209e 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -93,6 +93,26 @@ function RootNavigator() { contentStyle: { backgroundColor: theme.background }, }} /> + + ; +} diff --git a/apps/mobile/src/app/recommend.tsx b/apps/mobile/src/app/recommend.tsx new file mode 100644 index 0000000..d8da6a7 --- /dev/null +++ b/apps/mobile/src/app/recommend.tsx @@ -0,0 +1,5 @@ +import { RecommendSheet } from "@/components/screens/recommend-sheet"; + +export default function RecommendRoute() { + return ; +} diff --git a/apps/mobile/src/components/screens/media-detail/index.tsx b/apps/mobile/src/components/screens/media-detail/index.tsx index bdead07..035af3a 100644 --- a/apps/mobile/src/components/screens/media-detail/index.tsx +++ b/apps/mobile/src/components/screens/media-detail/index.tsx @@ -1,13 +1,15 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { StyleSheet, View } from "react-native"; -import { Stack } from "expo-router"; +import { Stack, useRouter } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ScreenHeader, ScreenToolbar, type ScreenAction } from "@/components/navigation"; import { Text } from "@/components/ui/text"; import { useMediaRouteBase } from "@/hooks/use-media-route-base"; import { useTheme } from "@/hooks/use-theme"; +import { hapticTap } from "@/lib/haptics"; +import { recommendHref } from "@/lib/navigation"; import { useWatchAction } from "@/hooks/watch-sessions/use-watch-action"; import { CastSection } from "./cast-section"; @@ -26,11 +28,24 @@ export function MediaDetail() { const theme = useTheme(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); + const router = useRouter(); const base = useMediaRouteBase(); const vm = useMediaDetailViewModel(); const handleRate = useCallback(() => vm.openReview(vm.myStars || undefined), [vm]); + const handleRecommend = useCallback(() => { + hapticTap(); + router.push( + recommendHref({ + tmdbId: vm.tmdbId, + mediaType: vm.mediaType, + title: vm.title, + posterPath: vm.posterPath, + }), + ); + }, [router, vm.tmdbId, vm.mediaType, vm.title, vm.posterPath]); + const watchAction = useWatchAction({ mediaType: "movie", tmdbId: vm.tmdbId, @@ -38,6 +53,12 @@ export function MediaDetail() { }); const toolbarActions: ScreenAction[] = [ + { + key: "recommend", + icon: "paperplane", + onPress: handleRecommend, + label: t("recommend.action"), + }, { key: "like", icon: vm.isLiked ? "heart.fill" : "heart", diff --git a/apps/mobile/src/components/screens/media-recommendations-inbox/index.tsx b/apps/mobile/src/components/screens/media-recommendations-inbox/index.tsx new file mode 100644 index 0000000..06d2486 --- /dev/null +++ b/apps/mobile/src/components/screens/media-recommendations-inbox/index.tsx @@ -0,0 +1,165 @@ +import { Image } from "expo-image"; +import { useRouter } from "expo-router"; +import { useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, FlatList, Pressable, StyleSheet, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { ScreenToolbar } from "@/components/navigation"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Text } from "@/components/ui/text"; +import { BORDER_RADIUS, SPACING } from "@/constants/design-tokens"; +import { useReceivedRecommendations } from "@/hooks/media-recommendations/use-received-recommendations"; +import { useTheme } from "@/hooks/use-theme"; +import { hapticTap } from "@/lib/haptics"; +import { mediaDetailHref } from "@/lib/navigation"; +import { tmdbImageUrl } from "@/lib/tmdb/images"; +import type { ReceivedRecommendation } from "@/services/media-recommendations"; + +export function MediaRecommendationsInbox() { + const { t } = useTranslation(); + const theme = useTheme(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { inbox, markRead } = useReceivedRecommendations(); + const items = useMemo(() => inbox.data ?? [], [inbox.data]); + + // Mark each unread entry read once the inbox is viewed; the ref keeps the + // effect from re-firing for the same row after the list refetches. + const processed = useRef>(new Set()); + const markReadMutate = markRead.mutate; + useEffect(() => { + for (const item of items) { + if (!item.read_at && !processed.current.has(item.id)) { + processed.current.add(item.id); + markReadMutate(item.id); + } + } + }, [items, markReadMutate]); + + function close() { + hapticTap(); + router.back(); + } + + function open(item: ReceivedRecommendation) { + hapticTap(); + router.back(); + router.push( + mediaDetailHref( + { + id: item.tmdb_id, + media_type: item.media_type, + title: item.title, + poster_path: item.poster_path, + }, + "search", + ), + ); + } + + const renderItem = ({ item }: { item: ReceivedRecommendation }) => ( + open(item)}> + + + + {t("recommend.fromSender", { + name: item.sender.full_name ?? item.sender.username ?? "", + })} + + + {item.title} + + {item.message ? ( + + {item.message} + + ) : null} + + + ); + + const renderEmpty = () => { + if (inbox.isLoading) { + return ( + + + + ); + } + + return ( + + + + ); + }; + + return ( + + + item.id} + renderItem={renderItem} + ItemSeparatorComponent={() => } + ListEmptyComponent={renderEmpty} + contentInsetAdjustmentBehavior="automatic" + contentContainerStyle={[ + styles.list, + { paddingBottom: insets.bottom + SPACING.LG }, + items.length === 0 && styles.emptyList, + ]} + showsVerticalScrollIndicator={false} + /> + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + }, + list: { + width: "100%", + alignSelf: "center", + padding: SPACING.MD, + }, + emptyList: { + flexGrow: 1, + }, + separator: { + height: SPACING.SM, + }, + empty: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: SPACING.LG, + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: SPACING.SM, + padding: SPACING.SM, + borderRadius: BORDER_RADIUS.MD, + }, + poster: { + width: 44, + height: 62, + borderRadius: BORDER_RADIUS.SM, + backgroundColor: "#00000022", + }, + rowText: { + flex: 1, + gap: 2, + }, +}); diff --git a/apps/mobile/src/components/screens/profile/index.tsx b/apps/mobile/src/components/screens/profile/index.tsx index e81b3d9..ccc094e 100644 --- a/apps/mobile/src/components/screens/profile/index.tsx +++ b/apps/mobile/src/components/screens/profile/index.tsx @@ -14,12 +14,13 @@ import { BottomTabInset } from "@/constants/theme"; import { LAYOUT, OPACITY, SPACING } from "@/constants/design-tokens"; import { useProfileActivity } from "@/hooks/profiles/use-profile-activity"; import { useMyProfile } from "@/hooks/profiles/use-my-profile"; +import { useUnreadRecommendations } from "@/hooks/media-recommendations/use-unread-recommendations"; import { useAccentColor } from "@/hooks/use-accent-color"; import { useAuthContext } from "@/hooks/use-auth-context"; import { useFollowRequests } from "@/hooks/social/use-follow-requests"; import { useTheme } from "@/hooks/use-theme"; import { hapticTap } from "@/lib/haptics"; -import { findFriendsHref, followRequestsHref } from "@/lib/navigation"; +import { findFriendsHref, followRequestsHref, recommendationsInboxHref } from "@/lib/navigation"; import { profileAvatarUrl } from "@/services/profiles"; import { shareProfile } from "@/services/share"; @@ -37,16 +38,19 @@ export function ProfileScreen() { const profile = useMyProfile(); const activity = useProfileActivity(); const requests = useFollowRequests(); + const recommendations = useUnreadRecommendations(); const refetchProfile = profile.refetch; const refetchActivity = activity.refetch; const refetchRequests = requests.refetch; + const refetchRecommendations = recommendations.refetch; useFocusEffect( useCallback(() => { refetchProfile(); refetchActivity(); refetchRequests(); - }, [refetchActivity, refetchProfile, refetchRequests]), + refetchRecommendations(); + }, [refetchActivity, refetchProfile, refetchRequests, refetchRecommendations]), ); const avatarUri = profileAvatarUrl(profile.data); @@ -75,6 +79,11 @@ export function ProfileScreen() { router.push(followRequestsHref()); }, [router]); + const openRecommendations = useCallback(() => { + hapticTap(); + router.push(recommendationsInboxHref()); + }, [router]); + const userId = user?.id; const handleShare = useCallback(() => { if (!userId || !username) return; @@ -97,6 +106,11 @@ export function ProfileScreen() { return ( <> + 0 ? "bell.badge" : "bell"} + onPress={openRecommendations}> + {t("recommend.inboxTitle")} + {t("share.profileTitle")} diff --git a/apps/mobile/src/components/screens/recommend-sheet/index.tsx b/apps/mobile/src/components/screens/recommend-sheet/index.tsx new file mode 100644 index 0000000..70cff38 --- /dev/null +++ b/apps/mobile/src/components/screens/recommend-sheet/index.tsx @@ -0,0 +1,258 @@ +import { SymbolView } from "expo-symbols"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, Alert, FlatList, Pressable, StyleSheet, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { ScreenToolbar } from "@/components/navigation"; +import { Button } from "@/components/ui/button"; +import { ContentUnavailable } from "@/components/ui/content-unavailable"; +import { Input } from "@/components/ui/input"; +import { ProfileAvatar } from "@/components/ui/profile-avatar"; +import { Text } from "@/components/ui/text"; +import { BORDER_RADIUS, LAYOUT, SPACING } from "@/constants/design-tokens"; +import { useAccentColor } from "@/hooks/use-accent-color"; +import { useSendRecommendation } from "@/hooks/media-recommendations/use-send-recommendation"; +import { useTheme } from "@/hooks/use-theme"; +import { hapticError, hapticSelection, hapticSuccess, hapticTap } from "@/lib/haptics"; +import type { MediaType } from "@/lib/tmdb"; +import { profileAvatarUrl } from "@/services/profiles"; +import type { RecommendationProfileCard } from "@/services/media-recommendations"; + +export function RecommendSheet() { + const params = useLocalSearchParams<{ + tmdbId?: string; + mediaType?: MediaType; + title?: string; + poster_path?: string; + }>(); + const tmdbId = Number(params.tmdbId); + const mediaType: MediaType = params.mediaType === "tv" ? "tv" : "movie"; + const title = params.title ?? ""; + const posterPath = params.poster_path || null; + + const router = useRouter(); + const { t } = useTranslation(); + const theme = useTheme(); + const insets = useSafeAreaInsets(); + const { accentHex } = useAccentColor(); + const { friends, send } = useSendRecommendation(); + const [selectedIds, setSelectedIds] = useState([]); + const [message, setMessage] = useState(""); + const [isSending, setIsSending] = useState(false); + + const selected = useMemo( + () => + new Set(selectedIds.filter((id) => friends.data?.some((friend) => friend.user_id === id))), + [friends.data, selectedIds], + ); + const selectedCount = selected.size; + const candidates = friends.data ?? []; + const canSend = selectedCount > 0 && !isSending && Number.isFinite(tmdbId) && tmdbId > 0; + + function close() { + hapticTap(); + router.back(); + } + + function toggleFriend(friendId: string) { + hapticSelection(); + setSelectedIds((current) => + current.includes(friendId) + ? current.filter((selectedId) => selectedId !== friendId) + : [...current, friendId], + ); + } + + async function sendRecommendations() { + if (!canSend) return; + setIsSending(true); + try { + const trimmed = message.trim(); + await send.mutateAsync({ + tmdb_id: tmdbId, + media_type: mediaType, + title, + poster_path: posterPath, + recipient_ids: [...selected], + message: trimmed.length > 0 ? trimmed : null, + }); + hapticSuccess(); + router.back(); + } catch { + setIsSending(false); + hapticError(); + Alert.alert(t("recommend.error")); + } + } + + const renderFriend = ({ item }: { item: RecommendationProfileCard }) => ( + toggleFriend(item.user_id)} + /> + ); + + return ( + + + + {friends.isLoading ? ( + + + + ) : candidates.length === 0 ? ( + + + + ) : ( + friend.user_id} + renderItem={renderFriend} + ItemSeparatorComponent={() => } + contentInsetAdjustmentBehavior="automatic" + contentContainerStyle={[styles.content, { paddingBottom: SPACING.XL }]} + showsVerticalScrollIndicator={false} + /> + )} + + + +