Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 58 additions & 20 deletions apps/mobile/app/(tabs)/library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
View, Text, StyleSheet, FlatList, TouchableOpacity, RefreshControl, ScrollView, useWindowDimensions,
} from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Image } from 'expo-image'
import { useRouter, useFocusEffect } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
Expand Down Expand Up @@ -33,6 +34,7 @@ import { useLibrarySearch } from '../../src/hooks/useLibrarySearch'
import { matchesQuery } from '../../src/lib/searchUtils'
import { LibrarySearch } from '../../src/components/library/LibrarySearch'
import { useBookActions } from '../../src/hooks/useBookActions'
import { clearLibraryShelvesCache } from '../../src/hooks/useLibraryShelves'

const NEW_BADGE_TTL_MS = 24 * 60 * 60 * 1000
const isNewUpload = (createdAt?: string): boolean => {
Expand Down Expand Up @@ -153,6 +155,11 @@ export default function LibraryScreen() {

const onRefresh = async () => {
setRefreshing(true)
// Pull-to-refresh is an explicit "give me fresh data" intent: invalidate
// the shelves TTL cache too (it fires an immediate refetch in the live
// LibraryShelves via the pub/sub) so the carousels aren't left stale while
// the rest of the screen reloads (FIX 3).
clearLibraryShelvesCache()
await loadData()
setRefreshing(false)
}
Expand Down Expand Up @@ -195,6 +202,13 @@ export default function LibraryScreen() {
: tab
const showTabs = source === 'all'

// Rendered inside each list's ListHeaderComponent so the shelf carousels
// scroll together with the rest of the screen (they used to sit in a fixed
// top region, which cut off "Quick reads" and below with no way to scroll).
const shelvesHeader = (
<LibraryShelves hasAnyContent={library.length > 0 || userBooks.length > 0} />
)

return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.sidebarHeader}>
Expand All @@ -208,7 +222,6 @@ export default function LibraryScreen() {
{user.isGuest ? getAnonymousReaderName(user.id) : user.email}
</Text>
)}
<LibraryShelves hasAnyContent={library.length > 0 || userBooks.length > 0} />
<View style={{ flexDirection: 'row', alignItems: 'center', borderBottomWidth: 1, borderBottomColor: colors.border }}>
{showTabs && (
<View style={[styles.tabs, { flex: 1, borderBottomWidth: 0 }]}>
Expand All @@ -235,9 +248,9 @@ export default function LibraryScreen() {
</View>

{effectiveTab === 'saved' ? (
<SavedList library={library} setLibrary={setLibrary} progressMap={progressMap} setProgressMap={setProgressMap} refreshing={refreshing} onRefresh={onRefresh} viewMode={viewMode} collectionFilterIds={collectionSavedIds} />
<SavedList library={library} setLibrary={setLibrary} progressMap={progressMap} setProgressMap={setProgressMap} refreshing={refreshing} onRefresh={onRefresh} viewMode={viewMode} collectionFilterIds={collectionSavedIds} shelvesHeader={shelvesHeader} hasAnyContent={library.length > 0 || userBooks.length > 0} />
) : (
<UploadsList books={userBooks} refreshing={refreshing} onRefresh={onRefresh} viewMode={viewMode} collectionFilterIds={collectionUploadIds} />
<UploadsList books={userBooks} refreshing={refreshing} onRefresh={onRefresh} viewMode={viewMode} collectionFilterIds={collectionUploadIds} shelvesHeader={shelvesHeader} />
)}
<LibrarySidebarDrawer
visible={drawerOpen}
Expand Down Expand Up @@ -268,14 +281,18 @@ function formatTimeAgo(dateStr: string): string {
return `${days}d ago`
}

function SavedList({ library, setLibrary, progressMap, setProgressMap, refreshing, onRefresh, viewMode, collectionFilterIds }: {
library: UserLibraryItem[]; setLibrary: React.Dispatch<React.SetStateAction<UserLibraryItem[]>>; progressMap: Record<string, ReadingProgressDto>; setProgressMap: React.Dispatch<React.SetStateAction<Record<string, ReadingProgressDto>>>; refreshing: boolean; onRefresh: () => void; viewMode: ViewMode; collectionFilterIds: Set<string> | null
function SavedList({ library, setLibrary, progressMap, setProgressMap, refreshing, onRefresh, viewMode, collectionFilterIds, shelvesHeader, hasAnyContent }: {
library: UserLibraryItem[]; setLibrary: React.Dispatch<React.SetStateAction<UserLibraryItem[]>>; progressMap: Record<string, ReadingProgressDto>; setProgressMap: React.Dispatch<React.SetStateAction<Record<string, ReadingProgressDto>>>; refreshing: boolean; onRefresh: () => void; viewMode: ViewMode; collectionFilterIds: Set<string> | null; shelvesHeader: React.ReactNode; hasAnyContent: boolean
}) {
const router = useRouter()
const { colors } = useTheme()
const { t } = useLanguage()
const { show: showToast } = useToast()
const { width } = useWindowDimensions()
const insets = useSafeAreaInsets()
// Clear the floating tab bar (~56 + bottom inset) so the last row/card isn't
// hidden behind it or the raised "+" button.
const bottomPad = 56 + insets.bottom + 24
const { sort, setSort } = useLibrarySort('saved')
const { status: filter, setStatus: setFilter } = useLibraryStatus()
const { query, debouncedQuery, setQuery, clear: clearQuery } = useLibrarySearch('saved')
Expand All @@ -292,15 +309,27 @@ function SavedList({ library, setLibrary, progressMap, setProgressMap, refreshin

if (library.length === 0) {
return (
<View style={styles.center}>
<EmptyState
icon="book-outline"
title={t('library.emptyLibrary')}
subtitle={t('library.browseBooks')}
buttonLabel={t('library.browseBooks')}
onButtonPress={() => router.push('/(tabs)/search')}
/>
</View>
<ScrollView
contentContainerStyle={{ flexGrow: 1, paddingBottom: bottomPad }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.primary} />}
showsVerticalScrollIndicator={false}
>
{/* When nothing exists anywhere, LibraryShelves renders its own empty
placeholder (with a browse CTA) — stacking it above this EmptyState
gives two CTAs. Suppress the shelves header in that all-empty case
and let the single EmptyState stand. If uploads exist, keep the
shelves so their carousels still render here (FIX 6). */}
{hasAnyContent && shelvesHeader}
<View style={styles.center}>
<EmptyState
icon="book-outline"
title={t('library.emptyLibrary')}
subtitle={t('library.browseBooks')}
buttonLabel={t('library.browseBooks')}
onButtonPress={() => router.push('/(tabs)/search')}
/>
</View>
</ScrollView>
)
}

Expand All @@ -318,6 +347,7 @@ function SavedList({ library, setLibrary, progressMap, setProgressMap, refreshin
keyExtractor={item => item.editionId}
ListHeaderComponent={
<View>
{shelvesHeader}
<LibrarySearch value={query} onChange={setQuery} onClear={clearQuery} />
<LibraryStatusTabs value={filter} onChange={setFilter} counts={counts} />
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.savedSortRow}>
Expand Down Expand Up @@ -349,7 +379,7 @@ function SavedList({ library, setLibrary, progressMap, setProgressMap, refreshin
</View>
}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.primary} />}
contentContainerStyle={viewMode === 'grid' ? styles.gridContent : styles.listContent}
contentContainerStyle={[viewMode === 'grid' ? styles.gridContent : styles.listContent, { paddingBottom: bottomPad }]}
columnWrapperStyle={viewMode === 'grid' ? { gap: 10 } : undefined}
renderItem={({ item }) => {
const progress = progressMap[item.editionId]
Expand Down Expand Up @@ -485,14 +515,16 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}

function UploadsList({ books, refreshing, onRefresh, viewMode, collectionFilterIds }: {
books: UserBookDto[]; refreshing: boolean; onRefresh: () => void; viewMode: ViewMode; collectionFilterIds: Set<string> | null
function UploadsList({ books, refreshing, onRefresh, viewMode, collectionFilterIds, shelvesHeader }: {
books: UserBookDto[]; refreshing: boolean; onRefresh: () => void; viewMode: ViewMode; collectionFilterIds: Set<string> | null; shelvesHeader: React.ReactNode
}) {
const router = useRouter()
const { colors } = useTheme()
const { t } = useLanguage()
const { show: showToast } = useToast()
const { width } = useWindowDimensions()
const insets = useSafeAreaInsets()
const bottomPad = 56 + insets.bottom + 24
const { sort, setSort } = useLibrarySort('uploads')
const { status: filter, setStatus: setFilter } = useLibraryStatus()
const { query, debouncedQuery, setQuery, clear: clearQuery } = useLibrarySearch('uploads')
Expand Down Expand Up @@ -535,6 +567,7 @@ function UploadsList({ books, refreshing, onRefresh, viewMode, collectionFilterI

const listHeader = (
<>
{shelvesHeader}
<TouchableOpacity
style={[styles.uploadBtn, { borderColor: colors.primary }]}
onPress={() => router.push('/my-books/upload')}
Expand Down Expand Up @@ -579,7 +612,12 @@ function UploadsList({ books, refreshing, onRefresh, viewMode, collectionFilterI

if (sorted.length === 0) {
return (
<View style={{ flex: 1 }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ flexGrow: 1, paddingBottom: bottomPad }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.primary} />}
showsVerticalScrollIndicator={false}
>
{listHeader}
{books.length === 0 ? (
<View style={styles.center}>
Expand All @@ -604,7 +642,7 @@ function UploadsList({ books, refreshing, onRefresh, viewMode, collectionFilterI
</TouchableOpacity>
</View>
)}
</View>
</ScrollView>
)
}

Expand All @@ -617,7 +655,7 @@ function UploadsList({ books, refreshing, onRefresh, viewMode, collectionFilterI
keyExtractor={item => item.id}
ListHeaderComponent={listHeader}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.primary} />}
contentContainerStyle={viewMode === 'grid' ? styles.gridContent : styles.listContent}
contentContainerStyle={[viewMode === 'grid' ? styles.gridContent : styles.listContent, { paddingBottom: bottomPad }]}
columnWrapperStyle={viewMode === 'grid' ? { gap: 10 } : undefined}
renderItem={({ item }) => {
const s = item.status.toLowerCase()
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/app/book/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useTheme } from '../../src/context/ThemeContext'
import { useLanguage } from '../../src/context/LanguageContext'
import { useToast } from '../../src/context/ToastContext'
import { AddToCollectionSheet } from '../../src/components/library/AddToCollectionSheet'
import { clearLibraryShelvesCache } from '../../src/hooks/useLibraryShelves'
import {
isBookFullyCached,
getAllCachedBooks,
Expand Down Expand Up @@ -280,6 +281,9 @@ export default function BookDetailScreen() {
} else {
await libraryApi.addToLibrary(book.id)
}
// Library membership changed — drop the cached shelves so the
// book appears in / disappears from the shelves on next focus.
clearLibraryShelvesCache()
} catch (err) {
console.warn('library toggle failed:', err)
setInLibrary(wasInLibrary)
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/app/my-books/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { fonts } from '../../src/theme/typography'
import { LoadingScreen } from '../../src/components/ui/LoadingScreen'
import { trackBookOpened } from '../../src/lib/analytics'
import { AddToCollectionSheet } from '../../src/components/library/AddToCollectionSheet'
import { clearLibraryShelvesCache } from '../../src/hooks/useLibraryShelves'

export default function UserBookDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
Expand Down Expand Up @@ -138,6 +139,9 @@ export default function UserBookDetailScreen() {
setDeleting(true)
try {
await userBooksApi.deleteUserBook(id)
// Invalidate the library shelves cache so the deleted book is gone
// from Continue reading / Recently added when we land back on Library.
clearLibraryShelvesCache()
// Don't reset `deleting` on success — the screen unmounts on router.back()
// and any lingering state change would warn. (P2-2)
router.back()
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/components/reader/ReaderShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,10 @@ export function ReaderShell(props: ReaderShellProps) {
} else if (data.type === 'highlightTap') {
const hl = highlightsRef.current.find(h => h.id === data.highlightId)
if (hl) setEditingHighlight(hl)
} else if (data.type === 'wordEngage') {
// Word resolved via deliberate long-press (Item A). Light selection
// impact confirms the hold registered before the WordCard opens.
haptics.play('flip')
} else if (data.type === 'selection') {
const mode: 'tap' | 'drag' = data.mode === 'tap' ? 'tap' : 'drag'
const nextId = openSelection(data.text ? { ...data, mode } : null)
Expand All @@ -307,7 +311,7 @@ export function ReaderShell(props: ReaderShellProps) {
if (__DEV__) console.warn('[reader] postMessage handler threw', err, event?.nativeEvent?.data)
}
}, [chapters, chapterSlug, language, settings.ttsSpeed, toggleTts, toggleBars, showBars, hideBars,
setEditingHighlight, updateSessionProgress, onChapterLoaded, onRequestNextChapter, openSelection, bumpProgress])
setEditingHighlight, updateSessionProgress, onChapterLoaded, onRequestNextChapter, openSelection, bumpProgress, haptics])

const navigateChapter = (slug: string) => {
saveProgress()
Expand Down
25 changes: 22 additions & 3 deletions apps/mobile/src/hooks/useBookActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '@textstack/shared'
import { useLanguage } from '../context/LanguageContext'
import { useToast } from '../context/ToastContext'
import { clearLibraryShelvesCache } from './useLibraryShelves'

interface SavedCtx {
progressMap: Record<string, ReadingProgressDto>
Expand Down Expand Up @@ -73,6 +74,9 @@ export function useBookActions() {
onPress: async () => {
const snapshot = ctx.library
ctx.setLibrary(prev => prev.filter(l => l.editionId !== item.editionId))
// Drop the shelves cache so Continue reading / Recently added don't
// keep showing the removed book on the next focus (TTL is 60s).
clearLibraryShelvesCache()
try {
await libraryApi.removeFromLibrary(item.editionId)
} catch (e) {
Expand Down Expand Up @@ -111,7 +115,12 @@ export function useBookActions() {
buttons.push({
text: isFinished ? t('library.actions.markUnfinished') : t('library.actions.markFinished'),
onPress: () => run(
() => isFinished ? userBooksApi.unmarkUserBookComplete(item.id) : userBooksApi.markUserBookComplete(item.id),
async () => {
await (isFinished ? userBooksApi.unmarkUserBookComplete(item.id) : userBooksApi.markUserBookComplete(item.id))
// Completion toggles shelf membership (continueReading ↔
// finishedThisMonth) — invalidate so the live carousel refetches.
clearLibraryShelvesCache()
},
isFinished ? 'Mark as unfinished' : 'Mark as finished',
),
})
Expand All @@ -132,7 +141,12 @@ export function useBookActions() {
buttons.push({
text: t('library.actions.cancel'),
style: 'destructive',
onPress: () => run(() => userBooksApi.cancelUserBook(item.id), 'Cancel upload'),
onPress: () => run(async () => {
await userBooksApi.cancelUserBook(item.id)
// Cancelling removes the in-progress upload from recentlyAdded /
// continueReading — invalidate so the live carousel refetches.
clearLibraryShelvesCache()
}, 'Cancel upload'),
})
}
buttons.push({
Expand All @@ -147,7 +161,12 @@ export function useBookActions() {
{
text: t('library.actions.confirmDeleteConfirm'),
style: 'destructive',
onPress: () => run(() => userBooksApi.deleteUserBook(item.id), 'Delete book'),
onPress: () => run(async () => {
await userBooksApi.deleteUserBook(item.id)
// Invalidate shelves so the deleted upload disappears from
// Continue reading / Recently added immediately.
clearLibraryShelvesCache()
}, 'Delete book'),
},
],
)
Expand Down
Loading
Loading