270 lines
11 KiB
TypeScript
270 lines
11 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||
import {
|
||
View, Text, ScrollView, TouchableOpacity,
|
||
ActivityIndicator, useColorScheme,
|
||
} from "react-native";
|
||
import { SafeAreaView } from "react-native-safe-area-context";
|
||
import { Ionicons } from "@expo/vector-icons";
|
||
import SearchBar from "../components/SearchBar";
|
||
import ExpandableItem from "../components/ExpandableItem";
|
||
import { useAuth } from "../context/AuthContext";
|
||
import { Entry, entriesApi } from "../services/api";
|
||
|
||
type SortMode = "time" | "subject";
|
||
|
||
|
||
function formatDate(iso: string): string {
|
||
return new Date(iso).toLocaleDateString("en-GB", {
|
||
day: "numeric", month: "short", year: "numeric",
|
||
});
|
||
}
|
||
|
||
function groupBySubject(entries: Entry[]): { label: string; items: Entry[] }[] {
|
||
const map = new Map<string, Entry[]>();
|
||
entries.forEach((e) => {
|
||
const key = e.subject?.name ?? e.groupName;
|
||
if (!map.has(key)) map.set(key, []);
|
||
map.get(key)!.push(e);
|
||
});
|
||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
||
}
|
||
|
||
function groupByDate(entries: Entry[]): { label: string; items: Entry[] }[] {
|
||
const map = new Map<string, Entry[]>();
|
||
entries.forEach((e) => {
|
||
const key = formatDate(e.timePublished);
|
||
if (!map.has(key)) map.set(key, []);
|
||
map.get(key)!.push(e);
|
||
});
|
||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
||
}
|
||
|
||
|
||
function SectionHeader({ label, dark }: { label: string; dark: boolean }) {
|
||
return (
|
||
<Text className={`px-4 pt-5 pb-2 text-xs font-sans font-bold tracking-widest uppercase ${dark ? "text-muted-dark" : "text-muted-light"
|
||
}`}>
|
||
{label}
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
|
||
function SortToggle({
|
||
mode, onChange, dark, accent,
|
||
}: {
|
||
mode: SortMode;
|
||
onChange: (m: SortMode) => void;
|
||
dark: boolean;
|
||
accent: string;
|
||
}) {
|
||
return (
|
||
<View
|
||
className={`flex-row mx-4 mb-3 rounded-xl p-1 ${dark ? "bg-obsidian-100" : "bg-parchment-200"
|
||
}`}
|
||
>
|
||
{(["time", "subject"] as SortMode[]).map((m) => {
|
||
const active = mode === m;
|
||
return (
|
||
<TouchableOpacity
|
||
key={m}
|
||
onPress={() => onChange(m)}
|
||
className="flex-1 flex-row items-center justify-center gap-1.5 py-2 rounded-lg"
|
||
style={{ backgroundColor: active ? accent : "transparent" }}
|
||
activeOpacity={0.7}
|
||
>
|
||
<Ionicons
|
||
name={m === "time" ? "time-outline" : "albums-outline"}
|
||
size={14}
|
||
color={active ? "#fff" : dark ? "#706D67" : "#8A8278"}
|
||
/>
|
||
<Text
|
||
className="text-xs font-sans font-semibold"
|
||
style={{ color: active ? "#fff" : dark ? "#706D67" : "#8A8278" }}
|
||
>
|
||
{m === "time" ? "By time" : "By subject"}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
);
|
||
})}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
|
||
export default function SubscribedFeed() {
|
||
const dark = useColorScheme() === "dark";
|
||
const accent = dark ? "#E07B45" : "#C4622D";
|
||
const { user } = useAuth();
|
||
|
||
const [entries, setEntries] = useState<Entry[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [query, setQuery] = useState("");
|
||
const [sortMode, setSortMode] = useState<SortMode>("time");
|
||
|
||
const fetchFeed = useCallback(async () => {
|
||
if (!user || user.subscribedSubjectIds.length === 0) { setEntries([]); return; }
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const pages = await Promise.all(
|
||
user.subscribedSubjectIds.map((subjectId) =>
|
||
entriesApi.getEntries({ subjectId, page: 0 })
|
||
)
|
||
);
|
||
const merged = pages
|
||
.flatMap((p) => p.content)
|
||
.sort((a, b) =>
|
||
new Date(b.timePublished).getTime() - new Date(a.timePublished).getTime()
|
||
);
|
||
setEntries(merged);
|
||
} catch {
|
||
setError("Couldn't load your feed.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [user?.subscribedSubjectIds]);
|
||
|
||
useEffect(() => { fetchFeed(); }, [fetchFeed]);
|
||
|
||
// Filter then group
|
||
const filtered = useMemo(() => {
|
||
if (!query.trim()) return entries;
|
||
const lower = query.toLowerCase();
|
||
return entries.filter((e) =>
|
||
e.title.toLowerCase().includes(lower) ||
|
||
e.subject?.name?.toLowerCase().includes(lower) ||
|
||
e.groupName?.toLowerCase().includes(lower)
|
||
);
|
||
}, [query, entries]);
|
||
|
||
const grouped = useMemo(() =>
|
||
sortMode === "time"
|
||
? groupByDate(filtered)
|
||
: groupBySubject(filtered),
|
||
[filtered, sortMode]);
|
||
|
||
if (!user) return (
|
||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||
<View className={`border-b px-4 pt-2 pb-3 ${dark ? "border-border-dark" : "border-border-light"}`}>
|
||
<Text className={`text-2xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>Your Feed</Text>
|
||
</View>
|
||
<View className="flex-1 items-center justify-center px-8">
|
||
<Text className="text-4xl mb-3">📰</Text>
|
||
<Text className={`text-base font-sans font-semibold text-center mb-1 ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||
Sign in to see your feed
|
||
</Text>
|
||
<Text className={`text-sm font-sans text-center ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||
Subscribe to subjects and their latest entries will appear here.
|
||
</Text>
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
|
||
if (!loading && user.subscribedSubjectIds.length === 0) return (
|
||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||
<View className={`border-b px-4 pt-2 pb-3 ${dark ? "border-border-dark" : "border-border-light"}`}>
|
||
<Text className={`text-2xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>Your Feed</Text>
|
||
</View>
|
||
<View className="flex-1 items-center justify-center px-8">
|
||
<Text className="text-4xl mb-3">🔖</Text>
|
||
<Text className={`text-base font-sans font-semibold text-center mb-1 ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||
No subscriptions yet
|
||
</Text>
|
||
<Text className={`text-sm font-sans text-center ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||
Go to Discover and tap a subject tag to subscribe.
|
||
</Text>
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
|
||
return (
|
||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||
{/* Header */}
|
||
<View className={`border-b ${dark ? "border-border-dark" : "border-border-light"}`}>
|
||
<View className="px-4 pt-2 pb-1 flex-row items-center">
|
||
<View className="flex-1">
|
||
<Text className={`text-2xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||
Your Feed
|
||
</Text>
|
||
<Text className={`text-xs mt-0.5 font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||
{loading
|
||
? "Loading…"
|
||
: `${entries.length} entries · ${user.subscribedSubjectIds.length} subjects`}
|
||
</Text>
|
||
</View>
|
||
<TouchableOpacity
|
||
onPress={fetchFeed}
|
||
disabled={loading}
|
||
className={`w-9 h-9 rounded-xl items-center justify-center ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}
|
||
activeOpacity={0.7}
|
||
>
|
||
{loading
|
||
? <ActivityIndicator size="small" color={accent} />
|
||
: <Ionicons name="refresh-outline" size={18} color={accent} />
|
||
}
|
||
</TouchableOpacity>
|
||
</View>
|
||
<SearchBar placeholder="Search your feed…" onSearch={setQuery} />
|
||
</View>
|
||
|
||
{loading && entries.length === 0 ? (
|
||
<View className="flex-1 items-center justify-center">
|
||
<ActivityIndicator color={accent} />
|
||
</View>
|
||
|
||
) : error ? (
|
||
<View className="flex-1 items-center justify-center px-8">
|
||
<Text className="text-3xl mb-3">⚠️</Text>
|
||
<Text className={`text-sm font-sans text-center mb-4 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||
{error}
|
||
</Text>
|
||
<TouchableOpacity
|
||
onPress={fetchFeed}
|
||
className="px-5 py-2.5 rounded-xl"
|
||
style={{ backgroundColor: accent }}
|
||
>
|
||
<Text className="text-sm font-sans font-semibold text-white">Retry</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
) : (
|
||
<ScrollView
|
||
contentContainerStyle={{ paddingBottom: 32 }}
|
||
showsVerticalScrollIndicator={false}
|
||
keyboardDismissMode="on-drag"
|
||
>
|
||
{/* Toggle */}
|
||
<View className="pt-3">
|
||
<SortToggle
|
||
mode={sortMode}
|
||
onChange={setSortMode}
|
||
dark={dark}
|
||
accent={accent}
|
||
/>
|
||
</View>
|
||
|
||
{filtered.length === 0 ? (
|
||
<View className="items-center mt-16 px-8">
|
||
<Text className="text-4xl mb-3">🔍</Text>
|
||
<Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||
No results for "{query}"
|
||
</Text>
|
||
</View>
|
||
) : (
|
||
grouped.map((section) => (
|
||
<View key={section.label}>
|
||
<SectionHeader label={section.label} dark={dark} />
|
||
{section.items.map((item) => (
|
||
<ExpandableItem key={item.id} item={item} />
|
||
))}
|
||
</View>
|
||
))
|
||
)}
|
||
</ScrollView>
|
||
)}
|
||
</SafeAreaView>
|
||
);
|
||
}
|