From 8756d3238898a2782f3d37017c15844cee92cec1 Mon Sep 17 00:00:00 2001 From: Erik Brinkman Date: Mon, 19 Oct 2026 22:57:44 -0400 Subject: [PATCH] feat: packed chip rows, pull to close, pending read state and faster open --- PLAN.md | 154 ++++++--- .../cc/hafa/reader/ReaderApplication.kt | 47 ++- .../kotlin/cc/hafa/reader/ui/ArticleBody.kt | 15 +- .../kotlin/cc/hafa/reader/ui/ArticleScreen.kt | 184 +++++----- .../main/kotlin/cc/hafa/reader/ui/ChipRows.kt | 44 +-- .../kotlin/cc/hafa/reader/ui/Components.kt | 35 +- .../kotlin/cc/hafa/reader/ui/EntryList.kt | 313 ++++++++++++------ .../cc/hafa/reader/ui/NaturalSizeImage.kt | 49 +++ .../src/main/kotlin/cc/hafa/reader/ui/Pull.kt | 279 ++++++++++++++++ .../kotlin/cc/hafa/reader/ui/ReaderScreen.kt | 65 +++- .../cc/hafa/reader/ui/ReaderViewModel.kt | 36 +- .../main/kotlin/cc/hafa/reader/ui/Sidebar.kt | 17 +- .../kotlin/cc/hafa/reader/ui/Transitions.kt | 130 ++++++++ .../res/drawable/ic_launcher_foreground.xml | 10 +- .../res/drawable/ic_launcher_monochrome.xml | 8 +- android/app/src/main/res/values/strings.xml | 6 +- .../test/kotlin/cc/hafa/reader/ui/PullTest.kt | 70 ++++ .../cc/hafa/reader/ui/TransitionsTest.kt | 47 +++ .../main/kotlin/cc/hafa/reader/core/Feed.kt | 124 +++++-- .../kotlin/cc/hafa/reader/core/FeedCheck.kt | 12 +- .../kotlin/cc/hafa/reader/core/LeadImage.kt | 98 +++--- .../kotlin/cc/hafa/reader/core/LoadGate.kt | 8 + .../kotlin/cc/hafa/reader/core/ReaderModel.kt | 163 +++++++-- .../kotlin/cc/hafa/reader/core/ReaderView.kt | 45 ++- .../main/kotlin/cc/hafa/reader/core/Srcset.kt | 108 ++++++ .../kotlin/cc/hafa/reader/core/TopicRows.kt | 19 +- .../hafa/reader/core/OrderAndPicturesTest.kt | 54 +++ .../cc/hafa/reader/core/ReaderModelTest.kt | 148 ++++++++- .../cc/hafa/reader/core/TopicRowsTest.kt | 18 + extension/src/background.test.ts | 47 +++ extension/src/background.ts | 43 ++- shared/fixtures/images.xml | 62 ++++ shared/fixtures/parse.json | 101 ++++++ shared/src/feed.ts | 192 ++++++++--- shared/src/protocol.ts | 10 +- shared/src/srcset.test.ts | 31 ++ shared/src/srcset.ts | 84 +++++ web/src/app.css | 16 + web/src/lib/components/article.svelte | 215 +++++++++++- web/src/lib/components/chip-rows.svelte | 35 +- web/src/lib/components/entry-card.svelte | 13 +- web/src/lib/components/entry-list.svelte | 308 ++++++++++++----- web/src/lib/components/pull-marker.svelte | 42 +++ web/src/lib/components/sidebar.svelte | 15 +- web/src/lib/copy.ts | 12 +- web/src/lib/extension.svelte.ts | 42 +++ web/src/lib/extension.test.ts | 66 ++++ web/src/lib/gestures.test.ts | 70 +++- web/src/lib/gestures.ts | 112 ++++++- web/src/lib/layout.ts | 7 + web/src/lib/load-gate.ts | 7 + web/src/lib/reader.svelte.ts | 183 ++++++++-- web/src/lib/reader.test.ts | 262 ++++++++++++++- web/src/lib/sanitize.test.ts | 36 +- web/src/lib/sanitize.ts | 43 +-- web/src/lib/topic-rows.test.ts | 14 +- web/src/lib/topic-rows.ts | 30 ++ web/src/routes/+page.svelte | 29 +- 58 files changed, 3693 insertions(+), 740 deletions(-) create mode 100644 android/app/src/main/kotlin/cc/hafa/reader/ui/NaturalSizeImage.kt create mode 100644 android/app/src/main/kotlin/cc/hafa/reader/ui/Pull.kt create mode 100644 android/app/src/main/kotlin/cc/hafa/reader/ui/Transitions.kt create mode 100644 android/app/src/test/kotlin/cc/hafa/reader/ui/PullTest.kt create mode 100644 android/app/src/test/kotlin/cc/hafa/reader/ui/TransitionsTest.kt create mode 100644 android/core/src/main/kotlin/cc/hafa/reader/core/Srcset.kt create mode 100644 shared/fixtures/images.xml create mode 100644 shared/src/srcset.test.ts create mode 100644 shared/src/srcset.ts create mode 100644 web/src/lib/components/pull-marker.svelte create mode 100644 web/src/lib/extension.test.ts create mode 100644 web/src/lib/layout.ts diff --git a/PLAN.md b/PLAN.md index 867caa9..a054cf3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -50,7 +50,7 @@ fetches feeds itself and syncs through Firebase. searches and accounts, not from the person, and trying them must not reach the device or its network. A followed http feed is never put on the schedule and never counts as due; each one is tried once - each time the app starts (alongside its checks of due feeds), at most six + each time the app starts (alongside its checks of every feed), at most six at once, and again on each later refresh while its tries get no definite answer (no answer at all, as offline or after a timeout, or a 408, 425, 429 or 5xx), at most three tries without one (`UNSETTLED_TRIES`) until the app @@ -230,10 +230,14 @@ from a device. over the weekend and quiet hours are skipped. Waits range from 15 minutes to 7 days, never shorter than the site's `Retry-After` or `Cache-Control: max-age`, and a failing feed backs off from 15 minutes by doubling, or longer when the - site asks. Opening the app queues a check of every feed that is due, - without waiting for it and without showing a refresh (the web sends - `checkDue`, which the extension answers as soon as the checks are queued, - then takes in what the extension found while the app was closed; the + site asks. Opening the app queues a check of every feed without showing + a refresh: the feeds past their due time first, waited for, then the rest + by the chance below. The loading screen waits until the feeds past due are + saved or 1 second has passed (`OPENING_WAIT_MS`), whichever is first, and + not at all offline; the other checks go on behind it (the web sends + `checkDue` with `all`, which the extension answers once the feeds past due + are saved, then takes in what the extension found; an extension that does + not know `all` checks only the due feeds and answers at once, and later results land through the usual drain), and the refresh controls force a check of the feeds they cover, so freshness comes from the reader and the schedule only has to avoid missing posts. Every fetch sends `ETag` and `If-Modified-Since`; a `304` ends @@ -280,8 +284,10 @@ from a device. their own `updatedAt` and never sent back. Writes are batched (at most 500) and queued by Firestore's offline cache while offline. -Background updates: the extension wakes on a timer; Android uses WorkManager -(about every 15 minutes at best); iOS uses background app refresh (system +Background updates: the extension wakes on a timer (every 5 minutes); Android +uses WorkManager (about every 15 minutes at best, often much less), and while +its screen shows it checks due feeds every 5 minutes too, and at once on +coming back to the app; iOS uses background app refresh (system decides). Web with the extension closed gets nothing until a device opens. ## Reading features @@ -304,8 +310,9 @@ decides). Web with the extension closed gets nothing until a device opens. words and topics, and an article's topics, on Starred too) is ordered by how many of the view's articles carry or match each (most first, then by name; an article lists each of its topics once), wrapping into up to three rows; when - they need more, they go in three rows read down each column and scroll - sideways. Chips never move when toggled. On a phone each topic chip, and + they need more, they go in three rows that scroll sideways together, each + chip in turn at the end of the row that is shortest so far (the upper on a + tie), so the rows are packed with no columns. Chips never move when toggled. On a phone each topic chip, and each word's remove button, takes taps 44 pixels tall (48 dp on Android) around its smaller outline; both lists use the same chip. A feed's own settings also apply in every group and view it appears in. @@ -339,14 +346,20 @@ decides). Web with the extension closed gets nothing until a device opens. letter tile. The sidebar rows keep one icon column in both states. - **Refreshing:** the Reader name goes to All items and checks every feed, as does `Shift R`; clicking the row of the view you are in, the `r` key and pulling - the list down from its top on a phone check the feeds of that view, as does the - Refresh bar that ends every non-empty list (shown once every row is drawn); - there is no Refresh button in the bars and no gesture at the bottom. Opening - the app checks only the feeds that are due, in the background: nothing turns, - the logo does not pulse, the list keeps its rows and Refresh stays usable. A + the list down from its top or up past its end on a phone check the feeds of that view, as does the + Refresh bar that ends every non-empty list on a wide screen (shown once every row is drawn); + there is no Refresh button in the bars. Opening + the app checks every feed, those past due first, behind the loading screen + and then in the background: nothing turns, the logo does not pulse, the + list keeps its rows and Refresh stays usable. A refresh drops the read articles - kept in the list as soon as it starts; on a phone the list shows a turning - marker until it ends. Only a refresh the person asks for shows: an import's + kept in the list as soon as it starts; then the list holds still until it + ends, whatever arrives or changes (checks, the account's changes, its + settings): no row appears, moves or goes. When the last refresh under way + ends, the lists take in everything at once and the list goes back to its + top. Changing view or unread only lets the list go at once. On a phone the + list shows a turning marker until it ends, at the top, or at the end for a refresh + asked for by a pull past the end. Only a refresh the person asks for shows: an import's first check of its feeds runs behind the loading screen, and every other check and every change from the account lands unseen. - **Unread only:** one switch for the whole app, an eye button in the list's bar (at the bottom on a phone), @@ -380,7 +393,12 @@ decides). Web with the extension closed gets nothing until a device opens. read or starred marks change. With 20,000 articles, toggling unread only takes about 60 ms, all on the main thread. - **Reading state:** opening an article does not mark - it read, leaving it does; read articles, here or on another device, stay + it read, leaving it does; while it is open its read button already shows it + as read ("Mark as unread"). Pressing it then only stops leaving from marking + it read (nothing is saved or synced), and the button offers "Mark as read", + which marks it read at once. An article already read when opened is marked + unread at once and stays unread on leaving. The `m` key and the open + article's card in the list work the same way. Read articles, here or on another device, stay listed until the view changes; stepping past either end closes the article. - **Sidebar order:** groups come first, then loose feeds; each list is ordered by @@ -396,7 +414,8 @@ decides). Web with the extension closed gets nothing until a device opens. opaque screen covers the app and takes every tap, key and swipe (Back still leaves the app from the list on Android): at opening, while stored data is read (and, on the web, the extension is found and what it fetched while the - app was closed is taken in); while the account's data arrives, at opening + app was closed is taken in), and until the feeds past their due time have + been checked or 1 s has passed; while the account's data arrives, at opening when signed in and after signing in or replacing this device's data, until its feeds and settings have come from the server, the entry listener's first changes are stored and the first pass has the last 90 days (sync offline, @@ -418,8 +437,8 @@ decides). Web with the extension closed gets nothing until a device opens. the view changes, as one read here does. A list or sidebar that shows nothing takes in what arrives, since no row can move; a feed the person adds shows at once. Unread counts still change as marks and articles arrive. -- **Refresh dot:** a small accent dot on the sidebar logo and on the phone's menu - button ("Some feeds are due for a check") says a refresh would help: a feed's +- **Refresh dot:** a small accent dot on the sidebar logo and on the logo in the + phone list's bottom bar ("Some feeds are due for a check") says a refresh would help: a feed's scheduled check is more than 30 minutes late (`OVERDUE_MS`, twice the shortest wait, so the background timers normally get there first) and nothing is checking feeds. It is worked out from the earliest next check, one pass per @@ -443,7 +462,19 @@ decides). Web with the extension closed gets nothing until a device opens. article open, the app fetches ahead up to four pictures of each article on either side (the lead picture, then the body's first, as the sanitizer keeps them), unless the browser asks to save data or Android's Data Saver is on; the - web fetches them without a referrer and at low priority. + web fetches them without a referrer and at low priority. The lead picture is + the feed's own (Media RSS image contents and image enclosures before + thumbnails, then those in Media RSS groups; JSON Feed's `image`), given up + for a later one only when both widths are known and the later is wider, the + body's first picture coming last as its largest `src` or `srcset` + candidate (`shared/fixtures/images.xml`). The list and the article both use + that largest picture; the article shows no separate lead picture when the + body already shows it at any of its candidates. Pictures are never shown wider than their + own size (one picture pixel to one CSS pixel or dp) nor scaled down by the + app. The web keeps `srcset`, `sizes` and `` sources for the browser + to choose from; Android, which cannot, loads the largest candidate among the + image's `srcset` and its ``'s sources that have no `media` and a + type every Android version shows. - **Star and share:** the article's button is "Star", a status that fills when set. Share uses the device's share sheet where the browser has one, and otherwise copies the link. @@ -451,42 +482,89 @@ decides). Web with the extension closed gets nothing until a device opens. Telegram public channels have only a web preview page, which is not read: such readers break when the site changes. - **Navigation:** one page app; the extension plays the part a service worker does - for a PWA (fetching, schedule, background). On a phone the menu button in the - list's bottom bar opens the feed list over the articles, as does tapping the view's name - beside it ("Show feeds"), - as does a swipe in from the left edge on the web (the Android app opens it only from + for a PWA (fetching, schedule, background). On a phone the list's bottom bar starts with one button, the + logo, the view's name and a small up-and-down chevron ("Show feeds"), that opens the + feed list over the articles, as does a swipe in from the left edge on the web (the Android app opens it only from the button, since a sideways swipe on a card marks it read); moving between the article list and an article adds browser history entries, so Back steps out. On Android, Back on the article list leaves the app, Back with the feed list open closes it, and Back from an article or a panel returns to the list. - **Bars (phone):** every screen puts its controls in a bar at the bottom, where a thumb reaches them (Material's bottom app bar on Android, the same bar on the web, - above the home indicator and the keyboard). The list has the menu button, the view's - name, unread only, view settings and mark all as read; the article has Back, star, + above the home indicator and the keyboard). The list has the button that opens the feed + list (the logo, which carries the refresh dot and broadcasts its waves while feeds + are checked, still when the system or browser turns motion off; the view's name, + cut short with an ellipsis when long; and a small chevron), unread only, view settings + and mark all as read (dimmed and off, for screen readers too, while the view has + nothing unread, empty or not; `Shift A` then does nothing); the article has Back, star, read, share and open the original, which on a phone appear only there; view settings, add feed and sync have Back and the screen's name. On Android the bars slide away while scrolling down and come back on the way up, on every new screen or article, and - whenever the article is too short to scroll; there every scrolling screen ends with + whenever the article is at its top or end (as when it is too short to scroll), sliding + back in then as it does after a scroll; there every scrolling screen ends with room for the bar's full height and a margin, the same whether the bar is shown or not, so the end clears the bar and there is room to scroll back up and bring it back. The web's bars always show and sit below the content, so it needs no such room. Wide screens keep the bars at the top. - **Gestures (phone):** swipe a card sideways to mark it read or unread; swipe an article left or right and it slides to the next or previous one in the list's order - (a pager on Android); a swipe of at least 90 pixels (90 dp on Android) past the first or last slides - the article off and closes it, as stepping past either end does with the keys, - while a shorter one springs back; pull down at the top of the + (a pager on Android; on the web a swipe of at least 90 pixels steps); a swipe of at + least 96 pixels (96 dp on Android) of finger travel past the first or last article + slides the article off and closes it, as stepping past either end does with the + keys, while a shorter one springs back; a one-finger drag of at least 96 pixels (96 dp) of + finger travel on past the article's top or end, started with it already there, + closes it, so a scroll that merely reaches the edge does not. Both are pulls, with + the same feedback, sideways for the swipe. During the pull the + article follows the finger less and less (moved `160 × (1 − e^(−travel / 160))`, + the same function on both, `pullStretch`, in place of the browser's own + overscroll); a round marker with an arrow, the one pull to refresh shows (Material's + pull to refresh indicator on Android: its 40 dp raised circle and colors; on the web + the list's own pull marker), fades and grows in, centered in the gap at that + edge and pointing the way of the pull, and turns over, in the accent color, once letting go would close + the article, which screen readers hear as "Release to close", with a short haptic + tick each time the pull crosses that point, either way (Android's gesture + threshold feedback; on the web a 10 ms `navigator.vibrate` where the browser has + it, as Chrome on Android does, and nothing elsewhere). Letting go + past it slides the article on off the way it was pulled and closes it; short of + it, it springs back. With motion turned off the article stays put and only the + arrow shows the pull. Pull down at the top of the list to check the view's feeds (Material's pull to refresh on Android, the same - marker on the web). -- **End of list:** the last row of a list that has articles is a full-width - "Refresh" bar, tappable anywhere across it, that checks the view's feeds as - pulling down does; while they are checked it keeps its label, is disabled and - its icon turns (still when the system or browser turns motion off). It sits - above the bottom bar; there is no pull-up gesture. A list with no articles - shows only a line of text, with no picture. + marker on the web), with the same tick each time a finger's pull crosses the point + where letting go checks them. Pulling + the list up past its end, started with it already there (on the web with every row + drawn), checks them too, built as the article's pull is: the same distance (96), + `pullStretch`, marker below the list's end turning over at that point ("Release to + refresh"), tick and spring back; letting go past it checks the feeds as pulling down + does, and the marker then turns there until they are checked. While they are being + checked neither pull does anything (no stretch, marker or tick; the list scrolls as + usual and the turning marker stays where it is), and a refresh starting midway ends + a pull, which springs back. +- **Screen changes (phone):** the article and the other detail screens slide in + from the end over the list, which shrinks a little and dims under them (300 ms, + Material's standard easing). Leaving for the list, by Back, the bar's Back, a + swipe past either end or a pull past the top or end, the screen slides off, + toward the end for Back and on the way a drag sent the article, while the list + comes back up under it, at its scroll position and with the article just left in + full view (on Android). On Android a pager's + articles fade a little as they slide; one detail screen replacing another fades + through. Nothing moves when the system turns animations off or the browser asks + for reduced motion. The web plays the same entrances when a pane is shown; the + pane going away disappears at once, except an article swiped or pulled off, which slides. +- **End of list:** on a phone a list ends with its last card; pulling up past it + checks the feeds (see Gestures). Screen readers still have a way: on Android the + list has a "Refresh" accessibility action, and on the web the "Refresh" bar below + stays for them and for the keyboard, unseen until focused. On a wide web screen + the last row of a list that has articles is that full-width "Refresh" bar, + tappable anywhere across it, that checks the view's feeds as pulling down does; + while they are checked it keeps its label, is disabled and its icon turns (still + when the browser turns motion off). A list with no articles shows only a line of + text, with no picture. - **Sidebar:** a brand header (the icon and "Reader") stays in place while the sidebar collapses to an icon strip, which opens over the list when hovered; icons and rows keep their positions so the change is a smooth width and fade. + Below the feeds, in this order: a "Contact us" row that opens the contact + page (in the browser on Android), shown signed in or not on every layout so + contact details are easy to find in the app; the sync row; and Add feed. - **Account picture:** when signed in, the sidebar's sync row and the account card show the Google account's picture in a round avatar (asked for at 96 pixels, https only, without a referrer), with the email's first letter until diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ReaderApplication.kt b/android/app/src/main/kotlin/cc/hafa/reader/ReaderApplication.kt index 2ea9c9b..2ece7ea 100644 --- a/android/app/src/main/kotlin/cc/hafa/reader/ReaderApplication.kt +++ b/android/app/src/main/kotlin/cc/hafa/reader/ReaderApplication.kt @@ -4,8 +4,10 @@ import android.app.Activity import android.app.Application import android.content.Context import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.os.Bundle import android.util.Log +import cc.hafa.reader.core.DUE_CHECK_MS import cc.hafa.reader.core.HttpFeedFetcher import cc.hafa.reader.core.ReaderModel import cc.hafa.reader.core.ReaderPreferences @@ -25,7 +27,9 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -52,6 +56,12 @@ class ReaderApplication : Application() { // Set once a screen shows, so sync listens to the account; a process started only to check feeds just uploads. private val onScreen = AtomicBoolean(false) + // Activities started and not stopped; touched only on the main thread. + private var startedActivities = 0 + + // Checks due feeds while a screen shows, as the extension's timer does; null while none shows. + private var dueTimer: Job? = null + /** The reader's small choices on this device. */ val preferences: ReaderPreferences by lazy { SharedReaderPreferences(this) } @@ -68,6 +78,11 @@ class ReaderApplication : Application() { getSystemService(ConnectivityManager::class.java)?.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED }, + online = { + val connectivity = getSystemService(ConnectivityManager::class.java) + connectivity?.getNetworkCapabilities(connectivity.activeNetwork) + ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + }, ) } @@ -130,11 +145,39 @@ class ReaderApplication : Application() { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit override fun onActivityStarted(activity: Activity) { - onScreen.set(true) + // Coming back to a running app checks what fell due meanwhile, since Android may have held back the + // background worker; the first start leaves that to the checks the screen makes when it opens. + val returning = onScreen.getAndSet(true) mutableSync.value?.enterForeground() + startedActivities += 1 + if (dueTimer == null) { + dueTimer = scope.launch { + if (!returning) { + delay(DUE_CHECK_MS) + } + while (true) { + try { + model.queueDue() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + Log.w("Reader", "checking due feeds failed", failure) + } + delay(DUE_CHECK_MS) + } + } + } } - override fun onActivityStopped(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) { + startedActivities -= 1 + // A rotation or theme switch stops the activity and starts its replacement at once; the timer runs on. + if (startedActivities <= 0 && !activity.isChangingConfigurations) { + startedActivities = 0 + dueTimer?.cancel() + dueTimer = null + } + } override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/ArticleBody.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/ArticleBody.kt index 6849452..2f818e7 100644 --- a/android/app/src/main/kotlin/cc/hafa/reader/ui/ArticleBody.kt +++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/ArticleBody.kt @@ -1,7 +1,7 @@ package cc.hafa.reader.ui import cc.hafa.reader.core.ALT_TEXT_CLASS -import cc.hafa.reader.core.bestSrcsetCandidate +import cc.hafa.reader.core.pictureChoice import org.jsoup.Jsoup import org.jsoup.nodes.Element import org.jsoup.nodes.TextNode @@ -59,6 +59,10 @@ sealed interface HtmlBlock { val link: String, /** What the picture shows, for screen readers. */ val alt: String = "", + /** The image's `width` attribute in CSS pixels; 0 when it has none. */ + val declaredWidth: Double = 0.0, + /** Pixel density of the picture at [source]. */ + val density: Double = 1.0, ) : HtmlBlock /** A table, row by row. */ @@ -102,10 +106,11 @@ private class Walker(private val blocks: MutableList) { val name = node.normalName() if (name == "img") { flush() - val best = bestSrcsetCandidate(node.attr("srcset")) - val source = best.ifEmpty { node.absUrl("src").ifEmpty { node.attr("src") } } - if (source.isNotEmpty()) { - blocks.add(HtmlBlock.Image(source, place.link, node.attr("alt").trim())) + val picture = pictureChoice(node) + if (picture.address.isNotEmpty()) { + blocks.add( + HtmlBlock.Image(picture.address, place.link, node.attr("alt").trim(), picture.declaredWidth, picture.density), + ) } } else if (name == "br") { inline.append("
") diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/ArticleScreen.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/ArticleScreen.kt index b85251d..1ca8c5e 100644 --- a/android/app/src/main/kotlin/cc/hafa/reader/ui/ArticleScreen.kt +++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/ArticleScreen.kt @@ -3,11 +3,10 @@ package cc.hafa.reader.ui import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -47,32 +46,30 @@ import androidx.compose.material3.BottomAppBarDefaults import androidx.compose.material3.BottomAppBarScrollBehavior import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle @@ -85,7 +82,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.withLink -import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.hafa.reader.R @@ -96,7 +92,6 @@ import cc.hafa.reader.core.decodeHtmlEntities import cc.hafa.reader.core.showsLeadImage import cc.hafa.reader.core.topicOff import coil3.SingletonImageLoader -import coil3.compose.AsyncImage import coil3.request.ImageRequest import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation @@ -107,15 +102,11 @@ import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale -import kotlin.math.abs -import kotlin.math.sign private val ARTICLE_WIDTH = 680.dp private val ARTICLE_MARGIN = 20.dp // room between articles while one slides over the next private val PAGE_GAP = 16.dp -// How far past the first or last article a drag must go to close it, as on the web (90 CSS pixels). -private val CLOSE_DRAG = 90.dp private fun isWebLink(link: String): Boolean = link.startsWith("https://") @@ -140,12 +131,19 @@ fun articleDate(published: Long, zone: ZoneId = ZoneId.systemDefault(), locale: /** * The open article: its details, actions and body. Swiping sideways slides to - * the next or previous article in the list's order, which then counts as opened. + * the next or previous article in the list's order, which then counts as opened; + * pulling well past the top or end, starting there, or sideways past the + * first or last article, closes it: the article follows the pull less and + * less the further it goes, and an arrow in the gap turns over, with a tick + * each time the pull crosses the point where letting go would close it. Closing goes + * through [onClose] with which way a drag sent the article (a unit step), or + * zero for Back; while [leaving], it is on its way out and closes nothing. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ArticleScreen(snapshot: Snapshot, viewModel: ReaderViewModel) { +fun ArticleScreen(snapshot: Snapshot, viewModel: ReaderViewModel, leaving: Boolean, onClose: (Offset) -> Unit) { val (state, view) = snapshot + val close by rememberUpdatedState(onClose) val entry = view.current val colors = ReaderTheme.colors // The screen can open before the list knows its article, so only an article that was shown and then went (removed by sync) leaves it. @@ -153,9 +151,9 @@ fun ArticleScreen(snapshot: Snapshot, viewModel: ReaderViewModel) { if (entry != null) { shown = true } - LaunchedEffect(entry == null && shown) { - if (entry == null && shown) { - viewModel.back() + LaunchedEffect(entry == null && shown && !leaving) { + if (entry == null && shown && !leaving) { + close(Offset.Zero) } } val context = LocalContext.current @@ -169,29 +167,38 @@ fun ArticleScreen(snapshot: Snapshot, viewModel: ReaderViewModel) { } } val scrollBehavior = BottomAppBarDefaults.exitAlwaysScrollBehavior() - ReaderScaffold( - bottomBar = { - ReaderBottomBar(scrollBehavior) { - BackButton(viewModel::back) - Spacer(Modifier.weight(1f)) - if (entry != null) { - ArticleBarActions(entry, viewModel) + val pull = rememberEdgeDrag(Orientation.Vertical, fromEdgeOnly = true) { towards -> close(towards) } + val sideways = rememberEdgeDrag(Orientation.Horizontal, fromEdgeOnly = false) { towards -> close(towards) } + // Outside the bar's own handling, so a pull past the end does not count as scrolling that hides the bar. + Box(Modifier.fillMaxSize().nestedScroll(pull)) { + ReaderScaffold( + bottomBar = { + ReaderBottomBar(scrollBehavior) { + BackButton { close(Offset.Zero) } + Spacer(Modifier.weight(1f)) + if (entry != null) { + ArticleBarActions(entry, state.shownRead(entry), viewModel) + } } - } - }, - scrollBehavior = scrollBehavior, - ) { padding -> - if (entry != null) { - val rows = view.visible - val index = rows.indexOfFirst { row -> row.id == entry.id } - val pageModifier = Modifier - .fillMaxSize() - .padding(top = padding.calculateTopPadding()) - if (index == -1) { - // An article the list no longer holds, such as one a filter just hid, stays open on its own. - ArticlePage(entry, snapshot, viewModel, padding, scrollBehavior, pageModifier) - } else { - ArticlePager(rows, index, snapshot, viewModel, padding, scrollBehavior, pageModifier) + }, + scrollBehavior = scrollBehavior, + ) { padding -> + if (entry != null) { + val rows = view.visible + val index = rows.indexOfFirst { row -> row.id == entry.id } + val pageModifier = Modifier + .fillMaxSize() + .padding(top = padding.calculateTopPadding()) + .graphicsLayer { translationY = pull.offset } + if (index == -1) { + // An article the list no longer holds, such as one a filter just hid, stays open on its own. + ArticlePage(entry, snapshot, viewModel, padding, scrollBehavior, pageModifier) + } else { + ArticlePager(rows, index, snapshot, viewModel, padding, scrollBehavior, sideways, pageModifier) + } + val release = stringResource(R.string.release_to_close) + PullIndicator(pull, padding, release) + PullIndicator(sideways, padding, release) } } } @@ -199,10 +206,9 @@ fun ArticleScreen(snapshot: Snapshot, viewModel: ReaderViewModel) { /** * The list's articles side by side, on the one at [index]; settling on another - * opens it. Dragging on past the first or last article moves it with the - * finger, and far enough closes it, as stepping past either end does. The - * bottom bar comes back whenever the page changes, so an article too short to - * scroll never leaves it hidden. + * opens it. Dragging on past the first or last article is [sideways], which + * moves it and far enough closes it, as stepping past either end does. The bottom bar comes back whenever the page changes, so an article + * too short to scroll never leaves it hidden. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -213,6 +219,7 @@ private fun ArticlePager( viewModel: ReaderViewModel, padding: PaddingValues, scrollBehavior: BottomAppBarScrollBehavior, + sideways: EdgeDrag, modifier: Modifier, ) { val pagerState = rememberPagerState(initialPage = index) { rows.size } @@ -227,8 +234,6 @@ private fun ArticlePager( previous = page } } - val threshold = with(LocalDensity.current) { CLOSE_DRAG.toPx() } - val edgeDrag = remember(viewModel, threshold) { EdgeDrag(threshold, viewModel::back) } LaunchedEffect(pagerState, scrollBehavior) { snapshotFlow { pagerState.currentPage }.drop(1).collectLatest { scrollBehavior.showBar() } } @@ -241,20 +246,23 @@ private fun ArticlePager( HorizontalPager( state = pagerState, modifier = modifier - .onSizeChanged { size -> edgeDrag.width = size.width.toFloat() } - .nestedScroll(edgeDrag) - .graphicsLayer { translationX = edgeDrag.offset }, + .nestedScroll(sideways) + .graphicsLayer { translationX = sideways.offset }, pageSpacing = PAGE_GAP, key = { page -> rows[page].id }, ) { page -> - ArticlePage(rows[page], snapshot, viewModel, padding, scrollBehavior.takeIf { page == pagerState.currentPage }, Modifier.fillMaxSize()) + // Fading as it slides, so the article coming in reads as taking the place of the one going out. + val pageModifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = pageAlpha(pagerState.getOffsetDistanceInPages(page)) } + ArticlePage(rows[page], snapshot, viewModel, padding, scrollBehavior.takeIf { page == pagerState.currentPage }, pageModifier) } } /** * One article's details, actions and body; the body shows once it is the open * article and has loaded. On the page in view, [scrollBehavior] is the bottom - * bar's, kept shown while the article is too short to scroll. + * bar's, kept shown while the article is at its top or end. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -271,13 +279,18 @@ private fun ArticlePage( // A neighbor's body is ready before it is opened, so swiping onto it shows it at once. val shownBody = if (state.bodyId == entry.id) state.body else state.aheadBodies[entry.id] if (scrollBehavior != null) { + // At the top or the end, including an article too short to scroll, the bar slides back and stays shown. LaunchedEffect(listState, scrollBehavior) { - snapshotFlow { !(listState.canScrollForward || listState.canScrollBackward) && scrollBehavior.state.heightOffset != 0f } - .collect { stuck -> - if (stuck) { - scrollBehavior.state.heightOffset = 0f + snapshotFlow { !(listState.canScrollForward && listState.canScrollBackward) }.collectLatest { atEdge -> + if (atEdge) { + // Again whenever the bar's own settling after a fling moves it off. + snapshotFlow { scrollBehavior.state.heightOffset != 0f }.collect { hidden -> + if (hidden) { + scrollBehavior.showBar() + } } } + } } } val colors = ReaderTheme.colors @@ -343,7 +356,7 @@ private fun ArticlePage( } if (showImage) { item(key = "image") { - AsyncImage( + NaturalSizeImage( model = decodeHtmlEntities(entry.imageUrl), contentDescription = null, contentScale = ContentScale.Crop, @@ -378,56 +391,9 @@ private fun ArticlePage( } } -/** - * The drag past the first or last page, which the pager leaves unused: the - * pages follow it, and on release one of at least [threshold] pixels slides - * them off and calls [onClose], while a shorter one springs back. - */ -private class EdgeDrag(private val threshold: Float, private val onClose: () -> Unit) : NestedScrollConnection { - /** How far the pages are moved past the edge, in pixels. */ - var offset by mutableFloatStateOf(0f) - private set - - /** The pager's width, which a closing slide covers. */ - var width = 0f - - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - // Dragging back toward the pages first undoes the distance past the edge. - return if (source == NestedScrollSource.UserInput && offset != 0f && sign(available.x) != sign(offset)) { - val taken = if (abs(available.x) >= abs(offset)) -offset else available.x - offset += taken - Offset(taken, 0f) - } else { - Offset.Zero - } - } - - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - return if (source == NestedScrollSource.UserInput && available.x != 0f) { - offset += available.x - Offset(available.x, 0f) - } else { - Offset.Zero - } - } - - override suspend fun onPreFling(available: Velocity): Velocity { - return if (offset == 0f) { - Velocity.Zero - } else if (abs(offset) >= threshold) { - animate(offset, sign(offset) * maxOf(width, abs(offset))) { value, _ -> offset = value } - onClose() - available - } else { - animate(offset, 0f, animationSpec = spring()) { value, _ -> offset = value } - available - } - } -} - -/** Star, read, share and open the original, for the bottom bar. */ +/** Star, read, share and open the original, for the bottom bar; [read] is the read mark the article shows. */ @Composable -private fun RowScope.ArticleBarActions(entry: Entry, viewModel: ReaderViewModel) { +private fun RowScope.ArticleBarActions(entry: Entry, read: Boolean, viewModel: ReaderViewModel) { val colors = ReaderTheme.colors val context = LocalContext.current val uriHandler = LocalUriHandler.current @@ -439,8 +405,8 @@ private fun RowScope.ArticleBarActions(entry: Entry, viewModel: ReaderViewModel) pressed = entry.starred, ) IconAction( - if (entry.read) Icons.AutoMirrored.Outlined.Undo else Icons.Outlined.Check, - stringResource(if (entry.read) R.string.mark_unread else R.string.mark_read), + if (read) Icons.AutoMirrored.Outlined.Undo else Icons.Outlined.Check, + stringResource(if (read) R.string.mark_unread else R.string.mark_read), viewModel.model::toggleRead, ) if (entry.url.isNotEmpty()) { @@ -501,10 +467,12 @@ private fun BodyBlock(block: HtmlBlock) { fontSize = 13.sp, softWrap = false, ) - is HtmlBlock.Image -> AsyncImage( + is HtmlBlock.Image -> NaturalSizeImage( model = block.source, contentDescription = block.alt.ifEmpty { null }, contentScale = ContentScale.FillWidth, + declaredWidth = block.declaredWidth, + density = block.density, modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(4.dp)) diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/ChipRows.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/ChipRows.kt index e4bdb11..24d1a54 100644 --- a/android/app/src/main/kotlin/cc/hafa/reader/ui/ChipRows.kt +++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/ChipRows.kt @@ -15,14 +15,15 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import cc.hafa.reader.core.TOPIC_ROWS +import cc.hafa.reader.core.packedRows import cc.hafa.reader.core.rowCount import cc.hafa.reader.core.wrappedRows /** * A [chip] for each of [items], laid left to right in up to [TOPIC_ROWS] rows; when they need more, they go in that - * many rows read down each column, and the block scrolls sideways. Each chip sits in its own traversal group, ordered - * by its place in [items], so screen readers go through them in that order either way. Every list of topics or words - * is shown this way. + * many rows, each chip at the end of the row that is shortest so far, and the block scrolls sideways. Each chip sits + * in its own traversal group, ordered by its place in [items], so screen readers go through them in that order either + * way. Every list of topics or words is shown this way. */ @Composable fun ChipRows(items: List, chip: @Composable (Item) -> Unit) { @@ -41,33 +42,20 @@ fun ChipRows(items: List, chip: @Composable (Item) -> Unit) { Layout(content, Modifier.semantics { isTraversalGroup = true }.horizontalScroll(rememberScrollState())) { measurables, _ -> val gapPx = 8.dp.roundToPx() val placeables = measurables.map { measurable -> measurable.measure(Constraints()) } - val wrapped = wrappedRows(placeables.map { placeable -> placeable.width }, available, gapPx) - val positions: List - val width: Int - val height: Int - if (rowCount(wrapped) <= TOPIC_ROWS) { - val rowHeights = IntArray(rowCount(wrapped)) - wrapped.forEachIndexed { index, position -> - rowHeights[position.row] = maxOf(rowHeights[position.row], placeables[index].height) - } - val rowTops = rowHeights.runningFold(0) { top, rowHeight -> top + rowHeight } - positions = wrapped.map { position -> IntOffset(position.left, rowTops[position.row]) } - width = wrapped.indices.maxOfOrNull { index -> wrapped[index].left + placeables[index].width } ?: 0 - height = rowTops.last() - } else { - val rowHeight = placeables.maxOf { placeable -> placeable.height } - val columnPositions = ArrayList(placeables.size) - var left = 0 - for (column in placeables.chunked(TOPIC_ROWS)) { - column.forEachIndexed { row, _ -> columnPositions.add(IntOffset(left, row * rowHeight)) } - left += column.maxOf { placeable -> placeable.width } + gapPx - } - positions = columnPositions - width = left - gapPx - height = rowHeight * TOPIC_ROWS + val widths = placeables.map { placeable -> placeable.width } + val wrapped = wrappedRows(widths, available, gapPx) + val positions = if (rowCount(wrapped) <= TOPIC_ROWS) wrapped else packedRows(widths, TOPIC_ROWS, gapPx) + val rowHeights = IntArray(rowCount(positions)) + positions.forEachIndexed { index, position -> + rowHeights[position.row] = maxOf(rowHeights[position.row], placeables[index].height) } + val rowTops = rowHeights.runningFold(0) { top, rowHeight -> top + rowHeight } + val width = positions.indices.maxOfOrNull { index -> positions[index].left + placeables[index].width } ?: 0 + val height = rowTops.last() layout(width, height) { - placeables.forEachIndexed { index, placeable -> placeable.place(positions[index]) } + placeables.forEachIndexed { index, placeable -> + placeable.place(IntOffset(positions[index].left, rowTops[positions[index].row])) + } } } } diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/Components.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/Components.kt index 66214ca..3cd32d3 100644 --- a/android/app/src/main/kotlin/cc/hafa/reader/ui/Components.kt +++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/Components.kt @@ -2,10 +2,10 @@ package cc.hafa.reader.ui import android.content.res.Resources import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.asPaddingValues @@ -70,7 +70,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription @@ -100,7 +99,8 @@ private const val TOO_MANY_REQUESTS = 429 /** * An icon button labeled for screen readers; a toggle that reads as on or off * when [pressed] is given. [dotted] puts a due dot on the icon's corner (see - * [WithDueDot]) and has screen readers say a refresh is suggested. + * [WithDueDot]) and has screen readers say a refresh is suggested. Unless + * [enabled], it is dimmed and does nothing, and screen readers say so. */ @Composable fun IconAction( @@ -111,6 +111,7 @@ fun IconAction( iconSize: Dp = 24.dp, pressed: Boolean? = null, dotted: Boolean = false, + enabled: Boolean = true, ) { val suggestion = stringResource(R.string.refresh_suggested) val announced = Modifier.semantics { @@ -119,7 +120,12 @@ fun IconAction( } } if (pressed == null) { - IconButton(onClick = onClick, modifier = announced, colors = IconButtonDefaults.iconButtonColors(contentColor = tint)) { + IconButton( + onClick = onClick, + modifier = announced, + enabled = enabled, + colors = IconButtonDefaults.iconButtonColors(contentColor = tint), + ) { WithDueDot(dotted) { Icon(icon, contentDescription = label, modifier = Modifier.size(iconSize)) } } } else { @@ -127,6 +133,7 @@ fun IconAction( checked = pressed, onCheckedChange = { onClick() }, modifier = announced, + enabled = enabled, colors = IconButtonDefaults.iconToggleButtonColors(contentColor = tint, checkedContentColor = tint), ) { WithDueDot(dotted) { Icon(icon, contentDescription = label, modifier = Modifier.size(iconSize)) } @@ -233,10 +240,10 @@ fun Modifier.scrollingScreen(padding: PaddingValues, margin: Dp = 20.dp): Modifi .verticalScroll(rememberScrollState()) .padding(start = margin, top = 24.dp, end = margin, bottom = scrollEndPadding()) -/** Slide a hidden bottom bar back into view. */ +/** Slide a hidden bottom bar back into view, as it settles after a scroll. */ @OptIn(ExperimentalMaterial3Api::class) suspend fun BottomAppBarScrollBehavior.showBar() { - animate(state.heightOffset, 0f) { value, _ -> state.heightOffset = value } + animate(state.heightOffset, 0f, animationSpec = snapAnimationSpec ?: spring()) { value, _ -> state.heightOffset = value } } /** The bottom bar of a form screen: Back and the screen's name, above the keyboard while typing. */ @@ -249,24 +256,12 @@ fun BackBottomBar(title: String, scrollBehavior: BottomAppBarScrollBehavior, onB } } -/** - * A screen's name in its bottom bar, taking the room the buttons leave; with - * [onClick], tapping it does what [clickLabel] says. - */ +/** A screen's name in its bottom bar, taking the room the buttons leave. */ @Composable -fun RowScope.BarTitle(title: String, onClick: (() -> Unit)? = null, clickLabel: String? = null) { - val tappable = if (onClick == null) { - Modifier - } else { - Modifier - .heightIn(min = 48.dp) - .clip(RoundedCornerShape(8.dp)) - .clickable(onClickLabel = clickLabel, role = Role.Button, onClick = onClick) - } +fun RowScope.BarTitle(title: String) { Box( Modifier .weight(1f) - .then(tappable) .padding(horizontal = 8.dp), contentAlignment = Alignment.CenterStart, ) { diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/EntryList.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/EntryList.kt index 6d0641c..594063e 100644 --- a/android/app/src/main/kotlin/cc/hafa/reader/ui/EntryList.kt +++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/EntryList.kt @@ -1,14 +1,13 @@ package cc.hafa.reader.ui import android.text.format.DateFormat -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -25,14 +24,13 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Undo -import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material.icons.outlined.ErrorOutline -import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.UnfoldMore import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.ExperimentalMaterial3Api @@ -42,21 +40,38 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -64,10 +79,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.hafa.reader.R import cc.hafa.reader.core.Entry +import cc.hafa.reader.core.ReaderState import cc.hafa.reader.core.Selection import cc.hafa.reader.core.ViewTitle import cc.hafa.reader.core.articleTopics import coil3.compose.AsyncImage +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.time.Instant import java.time.LocalDate @@ -84,121 +102,205 @@ fun viewTitleText(title: ViewTitle): String = is ViewTitle.Named -> title.name } -/** The article list: its bottom bar, the cards, and pulling down to check the view's feeds. */ +/** + * The article list: its bottom bar, the cards, and pulling down from the top + * or up past the end to check the view's feeds, with a haptic tick each time + * the pull crosses the point where letting go checks them; the pull past the + * end works as pulling an article past its end does, and screen readers have + * a Refresh action instead. As it shows, it scrolls the card of [reveal], the + * id of the article just left, into full view when it is not already. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EntryList(snapshot: Snapshot, viewModel: ReaderViewModel, listState: LazyListState, onMenu: () -> Unit) { +fun EntryList(snapshot: Snapshot, viewModel: ReaderViewModel, listState: LazyListState, onMenu: () -> Unit, reveal: String = "") { val (state, view) = snapshot val colors = ReaderTheme.colors val model = viewModel.model val feedTitles = remember(state.feeds) { state.feeds.associate { feed -> feed.id to feed.title } } val shownFeed = (state.selection as? Selection.FeedView)?.let { chosen -> state.feeds.firstOrNull { feed -> feed.id == chosen.id } } - ReaderScaffold( - bottomBar = { scrollBehavior -> - ReaderBottomBar(scrollBehavior) { - val suggested = refreshSuggested(state) - IconAction(Icons.Filled.Menu, stringResource(R.string.feeds), onMenu, tint = colors.text, dotted = suggested) - BarTitle(viewTitleText(view.viewTitle), onClick = onMenu, clickLabel = stringResource(R.string.show_feeds)) - IconAction( - if (state.unreadOnly) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - stringResource(R.string.unread_only), - { model.setUnreadOnly(!state.unreadOnly) }, - tint = if (state.unreadOnly) colors.accent else colors.muted, - pressed = state.unreadOnly, - ) - IconAction(Icons.Outlined.Tune, stringResource(R.string.settings), viewModel::toggleSettings) - IconAction(Icons.Outlined.DoneAll, stringResource(R.string.mark_all_read), model::markAllRead) + val latestView by rememberUpdatedState(view) + val problem = shownFeed?.error ?: "" + val bannerRows = if (problem.isNotEmpty()) 1 else 0 + LaunchedEffect(reveal, listState) { + val row = latestView.visible.indexOfFirst { entry -> entry.id == reveal } + if (row != -1) { + val info = snapshotFlow { listState.layoutInfo }.first { layout -> layout.totalItemsCount > 0 } + val whole = info.visibleItemsInfo.filter { item -> + item.offset >= info.viewportStartOffset && item.offset + item.size <= info.viewportEndOffset - info.afterContentPadding } - }, - ) { padding -> - val layoutDirection = LocalLayoutDirection.current - PullToRefreshBox( - isRefreshing = state.refreshing, - onRefresh = viewModel::refreshView, - modifier = Modifier - .fillMaxSize() - .padding(top = padding.calculateTopPadding()), - ) { - LazyColumn( - Modifier.fillMaxSize(), - state = listState, - contentPadding = PaddingValues( - start = padding.calculateStartPadding(layoutDirection), - end = padding.calculateEndPadding(layoutDirection), - bottom = scrollEndPadding(), - ), - ) { - val problem = shownFeed?.error ?: "" - if (problem.isNotEmpty()) { - item(key = "problem") { FeedProblemBanner(problemText(problem)) } - } - items(view.visible, key = { entry -> entry.id }) { entry -> - EntryCard( - entry = entry, - source = feedTitles[entry.feedId] ?: "", - topics = remember(entry.categories, view.topicOrder) { articleTopics(entry.categories, view.topicOrder) }, - active = entry.id == state.currentId, - onOpen = { viewModel.openEntry(entry.id) }, - onToggleRead = { model.toggleReadFor(entry.id) }, - onStar = { model.toggleStarFor(entry.id) }, + val target = revealIndex(row + bannerRows, whole.firstOrNull()?.index ?: 0, whole.lastOrNull()?.index ?: -1) + if (target != null) { + listState.scrollToItem(target) + } + } + } + val refreshing = viewModel.refreshing(state) + val latestRefreshing by rememberUpdatedState(refreshing) + // Whether the refresh under way came from a pull past the end, so its indicator turns there rather than at the top. + var refreshFromEnd by remember { mutableStateOf(false) } + LaunchedEffect(refreshing) { + if (!refreshing) { + refreshFromEnd = false + } + } + val endPull = rememberEdgeDrag(Orientation.Vertical, fromEdgeOnly = true, pastEndOnly = true, staysOnRelease = false) { + viewModel.refreshView() + refreshFromEnd = true + } + // Not while feeds are checked, when the indicator is already turning at the end or the top. + SideEffect { endPull.enabled = !refreshing } + val refreshLabel = stringResource(R.string.refresh) + val pullState = rememberPullToRefreshState() + // Whether a finger is on the list, so the indicator moving on its own (a refresh starting or ending) does not tick. + var touching by remember { mutableStateOf(false) } + val haptics = LocalHapticFeedback.current + LaunchedEffect(pullState, haptics) { + snapshotFlow { pullState.distanceFraction >= 1f }.drop(1).collect { + if (touching && !latestRefreshing) { + haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + } + } + } + // Outside the bar's own handling, so a pull past the end does not count as scrolling that hides the bar. + Box(Modifier.fillMaxSize().nestedScroll(endPull)) { + ReaderScaffold( + bottomBar = { scrollBehavior -> + ReaderBottomBar(scrollBehavior) { + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + ViewButton(viewTitleText(view.viewTitle), state, onMenu) + } + IconAction( + if (state.unreadOnly) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + stringResource(R.string.unread_only), + { model.setUnreadOnly(!state.unreadOnly) }, + tint = if (state.unreadOnly) colors.accent else colors.muted, + pressed = state.unreadOnly, ) + IconAction(Icons.Outlined.Tune, stringResource(R.string.settings), viewModel::toggleSettings) + IconAction(Icons.Outlined.DoneAll, stringResource(R.string.mark_all_read), model::markAllRead, enabled = view.canMarkAllRead) } - if (view.visible.isNotEmpty()) { - item(key = "end") { - ListEnd(refreshing = state.refreshing, onRefresh = viewModel::refreshView) + }, + ) { padding -> + val layoutDirection = LocalLayoutDirection.current + val topRefreshing = refreshing && !refreshFromEnd + Box( + Modifier + .fillMaxSize() + .padding(top = padding.calculateTopPadding()) + // Pulling does nothing while feeds are checked; a refresh from the end leaves the top indicator hidden. + .pullToRefresh(topRefreshing, pullState, enabled = !refreshing, onRefresh = viewModel::refreshView) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + touching = true + try { + do { + val event = awaitPointerEvent(PointerEventPass.Initial) + } while (event.changes.any { change -> change.pressed }) + } finally { + touching = false + } + } + }, + ) { + LazyColumn( + Modifier + .fillMaxSize() + .graphicsLayer { translationY = endPull.offset } + .semantics { + customActions = listOf( + CustomAccessibilityAction(refreshLabel) { + viewModel.refreshView() + true + }, + ) + }, + state = listState, + contentPadding = PaddingValues( + start = padding.calculateStartPadding(layoutDirection), + end = padding.calculateEndPadding(layoutDirection), + bottom = scrollEndPadding(), + ), + ) { + if (bannerRows > 0) { + item(key = "problem") { FeedProblemBanner(problemText(problem)) } + } + items(view.visible, key = { entry -> entry.id }) { entry -> + EntryCard( + entry = entry, + read = state.shownRead(entry), + source = feedTitles[entry.feedId] ?: "", + topics = remember(entry.categories, view.topicOrder) { articleTopics(entry.categories, view.topicOrder) }, + active = entry.id == state.currentId, + onOpen = { viewModel.openEntry(entry.id) }, + onToggleRead = { model.toggleReadFor(entry.id) }, + onStar = { model.toggleStarFor(entry.id) }, + ) } - } else { - item(key = "empty") { - Column( - Modifier - .fillParentMaxSize() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(stringResource(R.string.no_entries), fontSize = 14.sp, lineHeight = 20.sp, color = colors.muted) + if (view.visible.isEmpty()) { + item(key = "empty") { + Column( + Modifier + .fillParentMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringResource(R.string.no_entries), fontSize = 14.sp, lineHeight = 20.sp, color = colors.muted) + } } } } + PullToRefreshDefaults.Indicator(pullState, topRefreshing, Modifier.align(Alignment.TopCenter)) } + PullIndicator(endPull, padding, stringResource(R.string.release_to_refresh), spinning = refreshing && refreshFromEnd) } } } -// One turn of the refresh icon while feeds are checked. -private const val FULL_TURN = 360f -private const val TURN_MS = 1000 - -/** The end of the list: a full-width row that checks the view's feeds as pulling down does; its icon turns while they are checked. */ +/** + * The list bar's button that opens the feed list: the logo, which carries the + * due dot and broadcasts while feeds are checked, the view's [title], cut short + * with an ellipsis when long, and a small chevron; the web's in `entry-list.svelte`. + */ @Composable -private fun ListEnd(refreshing: Boolean, onRefresh: () -> Unit) { +private fun ViewButton(title: String, state: ReaderState, onMenu: () -> Unit) { val colors = ReaderTheme.colors - val turning = refreshing && animationsOn() - val angle = if (turning) { - rememberInfiniteTransition(label = "refresh").animateFloat( - initialValue = 0f, - targetValue = FULL_TURN, - animationSpec = infiniteRepeatable(tween(TURN_MS, easing = LinearEasing)), - label = "refresh angle", - ).value - } else { - 0f - } - val content = if (refreshing) colors.muted else colors.text - Column { - Row( - Modifier - .fillMaxWidth() - .heightIn(min = 56.dp) - .clickable(enabled = !refreshing, role = Role.Button, onClick = onRefresh) - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(Icons.Outlined.Refresh, contentDescription = null, tint = content, modifier = Modifier.size(18.dp).rotate(angle)) - Text(stringResource(R.string.refresh), fontSize = 14.sp, lineHeight = 20.sp, color = content) + val suggested = refreshSuggested(state) + val suggestion = stringResource(R.string.refresh_suggested) + Row( + Modifier + .heightIn(min = 48.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(onClickLabel = stringResource(R.string.show_feeds), role = Role.Button, onClick = onMenu) + .semantics { + if (suggested) { + stateDescription = suggestion + } + } + .padding(start = 10.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + WithDueDot(suggested) { + Logo(Modifier.size(28.dp), loading = !state.loaded || state.refreshing || state.adding, labeled = false) } - HorizontalDivider(color = colors.line) + Text( + title, + modifier = Modifier + .weight(1f, fill = false) + .padding(start = 8.dp, end = 4.dp) + .semantics { heading() }, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + Icons.Outlined.UnfoldMore, + contentDescription = null, + tint = colors.muted, + modifier = Modifier.size(16.dp), + ) } } @@ -239,6 +341,7 @@ private fun age(published: Long): String { @Composable private fun EntryCard( entry: Entry, + read: Boolean, source: String, topics: List, active: Boolean, @@ -247,7 +350,7 @@ private fun EntryCard( onStar: () -> Unit, ) { val colors = ReaderTheme.colors - val readIcon = if (entry.read) Icons.AutoMirrored.Outlined.Undo else Icons.Outlined.Check + val readIcon = if (read) Icons.AutoMirrored.Outlined.Undo else Icons.Outlined.Check val hasImage = entry.imageUrl.isNotEmpty() val swipe = rememberSwipeToDismissBoxState() val scope = rememberCoroutineScope() @@ -300,7 +403,7 @@ private fun EntryCard( Text( entry.title, fontWeight = FontWeight.SemiBold, - color = if (entry.read && !active) colors.read else colors.text, + color = if (read && !active) colors.read else colors.text, lineHeight = 21.sp, ) if (entry.excerpt.isNotEmpty()) { @@ -344,7 +447,7 @@ private fun EntryCard( ) IconAction( readIcon, - stringResource(if (entry.read) R.string.mark_unread else R.string.mark_read), + stringResource(if (read) R.string.mark_unread else R.string.mark_read), onToggleRead, iconSize = 20.dp, ) diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/NaturalSizeImage.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/NaturalSizeImage.kt new file mode 100644 index 0000000..821165c --- /dev/null +++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/NaturalSizeImage.kt @@ -0,0 +1,49 @@ +package cc.hafa.reader.ui + +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import cc.hafa.reader.core.PictureChoice +import coil3.compose.AsyncImage + +/** + * A picture from the web shown no wider than a browser would show it, so a + * small picture is not stretched to fill [modifier]'s width: [declaredWidth] + * dp when the image declares a width (0 when not), else its pixel width over + * [density], as a browser counts CSS pixels. + */ +@Composable +fun NaturalSizeImage( + model: String, + contentDescription: String?, + contentScale: ContentScale, + modifier: Modifier = Modifier, + declaredWidth: Double = 0.0, + density: Double = 1.0, +) { + val picture = PictureChoice(model, declaredWidth, density) + var maxWidth by remember(picture) { + mutableStateOf(if (declaredWidth > 0) picture.displayWidth(0.0).dp else Dp.Unspecified) + } + AsyncImage( + model = model, + contentDescription = contentDescription, + contentScale = contentScale, + onSuccess = { state -> + val width = state.painter.intrinsicSize.width + // Coil decodes no smaller than the space it is given, so a picture + // narrower than the screen reads here at its own pixel width. + if (width.isFinite() && width > 0) { + maxWidth = picture.displayWidth(width.toDouble()).dp + } + }, + modifier = Modifier.widthIn(max = maxWidth).then(modifier), + ) +} diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/Pull.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/Pull.kt new file mode 100644 index 0000000..0acb9b6 --- /dev/null +++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/Pull.kt @@ -0,0 +1,279 @@ +package cc.hafa.reader.ui + +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowDownward +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.AbsoluteAlignment +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.drop +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.sign + +/** How far an article must be pulled past its top or end to close it, in dp, as on the web (96 CSS pixels); the list's end refreshes at the same distance. */ +const val ARTICLE_PULL_DISTANCE = 96f + +/** How far a pulled article can move however far the finger goes, in dp, as on the web. */ +const val ARTICLE_PULL_STRETCH = 160f + +/** + * How far a pulled article moves when the finger has gone [travel] dp past its + * edge: at first with the finger, then less and less, never quite reaching + * [ARTICLE_PULL_STRETCH]. Keeps the sign of [travel]; the web's `pullStretch`. + */ +fun pullStretch(travel: Float): Float = sign(travel) * ARTICLE_PULL_STRETCH * (1f - exp(-abs(travel) / ARTICLE_PULL_STRETCH)) + +/** How far along a pull of [travel] dp is toward closing the article, from 0 to 1. */ +fun pullProgress(travel: Float): Float = minOf(1f, abs(travel) / ARTICLE_PULL_DISTANCE) + +// Material's pull to refresh indicator: its container and the spinner in it. +private val INDICATOR_SIZE = 40.dp +private val SPINNER_SIZE = 16.dp +private val SPINNER_STROKE = 2.5.dp +private val ARROW_SIZE = 18.dp + +/** + * A drag along [orientation] that what it wraps leaves unused, past its first or last page or its top or end: the + * content follows it, moved by [stretch] of the drag's length, and on release one of at least [threshold] pixels + * calls [onRelease] with which way it went, as a unit step, leaving the content where it is for the screen change to + * carry off when [staysOnRelease], else springing back as a shorter one does. With [fromEdgeOnly], only a drag that + * is unused from its very start counts, so a scroll that merely reaches the edge does not; with [pastEndOnly], only + * one past the end or last page. + */ +class EdgeDrag( + val orientation: Orientation, + private val threshold: Float, + private val fromEdgeOnly: Boolean, + private val pastEndOnly: Boolean = false, + private val staysOnRelease: Boolean = true, + private val stretch: (Float) -> Float = { travel -> travel }, + private val onRelease: (Offset) -> Unit, +) : NestedScrollConnection { + /** How far the finger has gone past the edge, in pixels. */ + var travel by mutableFloatStateOf(0f) + private set + + /** How far the content is moved past the edge, in pixels. */ + val offset: Float + get() = stretch(travel) + + /** How far the content is moved when letting go starts to act, in pixels. */ + val thresholdOffset: Float + get() = stretch(threshold) + + /** Whether letting go now acts. */ + val armed: Boolean + get() = abs(travel) >= threshold + + /** Whether a drag may start; one under way goes on unless [cancel]ed. */ + var enabled: Boolean = true + + /** Whether a finger is moving the content, rather than it springing back. */ + val dragging: Boolean + get() = counts == true + + // Whether the drag under way may move the content; null until its first movement. + private var counts: Boolean? = null + + private fun along(offset: Offset): Float = if (orientation == Orientation.Horizontal) offset.x else offset.y + + private fun offsetOf(amount: Float): Offset = + if (orientation == Orientation.Horizontal) Offset(amount, 0f) else Offset(0f, amount) + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val amount = along(available) + // Dragging back toward the content first undoes the distance past the edge. + return if (source == NestedScrollSource.UserInput && counts == true && travel != 0f && sign(amount) != sign(travel)) { + val taken = if (abs(amount) >= abs(travel)) -travel else amount + travel += taken + offsetOf(taken) + } else { + Offset.Zero + } + } + + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + val amount = along(available) + if (source == NestedScrollSource.UserInput && counts == null) { + counts = enabled && (!fromEdgeOnly || (along(consumed) == 0f && amount != 0f)) && !(pastEndOnly && amount > 0f) + } + val allowed = !pastEndOnly || travel + amount <= 0f + return if (source == NestedScrollSource.UserInput && counts == true && amount != 0f && allowed) { + travel += amount + offsetOf(amount) + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val counted = counts == true + counts = null + return if (travel == 0f || !counted) { + Velocity.Zero + } else { + if (armed) { + onRelease(offsetOf(sign(travel))) + } + if (!armed || !staysOnRelease) { + animate(travel, 0f, animationSpec = spring()) { value, _ -> travel = value } + } + available + } + } + + /** Stop the drag under way, if any: the content springs back and the rest of the drag scrolls it as usual. */ + suspend fun cancel() { + if (counts == true) { + counts = false + } + if (travel != 0f) { + animate(travel, 0f, animationSpec = spring()) { value, _ -> travel = value } + } + } +} + +/** + * A drag along [orientation] past the content's edge, as [EdgeDrag] describes, at the article's distance and + * following the finger by [pullStretch], or not at all with animations off, where the indicator alone shows the pull; + * it gives a haptic tick each time the finger crosses the point where letting go acts. + */ +@Composable +fun rememberEdgeDrag( + orientation: Orientation, + fromEdgeOnly: Boolean, + pastEndOnly: Boolean = false, + staysOnRelease: Boolean = true, + onRelease: (Offset) -> Unit, +): EdgeDrag { + val release by rememberUpdatedState(onRelease) + val density = LocalDensity.current + val animate = animationsOn() + val drag = remember(orientation, fromEdgeOnly, pastEndOnly, staysOnRelease, density, animate) { + EdgeDrag( + orientation, + with(density) { ARTICLE_PULL_DISTANCE.dp.toPx() }, + fromEdgeOnly, + pastEndOnly, + staysOnRelease, + stretch = { travel -> if (animate) density.density * pullStretch(travel / density.density) else 0f }, + ) { towards -> release(towards) } + } + val haptics = LocalHapticFeedback.current + LaunchedEffect(drag, haptics) { + snapshotFlow { drag.armed }.drop(1).collect { + if (drag.dragging) { + haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + } + } + } + return drag +} + +/** + * Material's pull to refresh indicator, for a pull past the content's edge: in the gap [pull] opens, inside + * [padding], with an arrow pointing the way of the pull that grows in as the pull goes on and turns over, in the + * accent color, once letting go acts, when screen readers hear [armedLabel]. While [spinning], it turns past the + * end, where letting go began to act, until that ends, as Material's does while refreshing. + */ +@Composable +fun PullIndicator(pull: EdgeDrag, padding: PaddingValues, armedLabel: String, spinning: Boolean = false) { + val resting by animateFloatAsState(if (spinning) 1f else 0f, label = "pull indicator rest") + val pulling by remember(pull) { derivedStateOf { pull.travel != 0f } } + val forward by remember(pull) { derivedStateOf { pull.travel > 0f } } + val armed by remember(pull) { derivedStateOf { pull.armed } } + if (pulling || resting > 0f) { + val colors = ReaderTheme.colors + val sideways = pull.orientation == Orientation.Horizontal + val turn by animateFloatAsState(if (armed == forward) 180f else 0f, label = "pull arrow") + val containerColor = PullToRefreshDefaults.indicatorContainerColor + val color = PullToRefreshDefaults.indicatorColor + val shape = PullToRefreshDefaults.indicatorShape + Box( + Modifier + .fillMaxSize() + .padding(top = padding.calculateTopPadding(), bottom = padding.calculateBottomPadding()), + contentAlignment = when { + sideways && forward -> AbsoluteAlignment.CenterLeft + sideways -> AbsoluteAlignment.CenterRight + forward -> Alignment.TopCenter + else -> Alignment.BottomCenter + }, + ) { + Box( + Modifier + .graphicsLayer { + val progress = if (spinning) 1f else maxOf(pullProgress(pull.travel / density), resting) + val moved = if (spinning || !pulling) -pull.thresholdOffset / 2 else pull.offset / 2 + if (sideways) { + translationX = moved + } else { + translationY = moved + } + alpha = progress + scaleX = 0.6f + 0.4f * progress + scaleY = scaleX + shadowElevation = PullToRefreshDefaults.Elevation.toPx() + this.shape = shape + clip = true + } + .size(INDICATOR_SIZE) + .background(containerColor, shape) + .semantics { + liveRegion = LiveRegionMode.Polite + if (armed) { + contentDescription = armedLabel + } + }, + contentAlignment = Alignment.Center, + ) { + if (pulling && !spinning) { + Icon( + Icons.Outlined.ArrowDownward, + contentDescription = null, + // Turned a quarter, the downward arrow points right, the way of a pull past the first article. + modifier = Modifier.size(ARROW_SIZE).graphicsLayer { rotationZ = turn + if (sideways) -90f else 0f }, + tint = if (armed) colors.accent else color, + ) + } else { + CircularProgressIndicator(Modifier.size(SPINNER_SIZE), color = color, strokeWidth = SPINNER_STROKE) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/ReaderScreen.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/ReaderScreen.kt index 14201a7..7309df7 100644 --- a/android/app/src/main/kotlin/cc/hafa/reader/ui/ReaderScreen.kt +++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/ReaderScreen.kt @@ -2,6 +2,8 @@ package cc.hafa.reader.ui import android.content.ActivityNotFoundException import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.EnterExitState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -16,13 +18,16 @@ import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler @@ -73,7 +78,8 @@ fun ReaderScreen(viewModel: ReaderViewModel) { ) { if (current != null && current.state.loaded) { val state = current.state - val listKey = "${viewKey(state.selection)}|${state.unreadOnly}|${current.view.viewSettings.sort}" + // The list starts again from its top for another view, and when a refresh ends. + val listKey = "${viewKey(state.selection)}|${state.unreadOnly}|${current.view.viewSettings.sort}|${state.refreshesEnded}" LaunchedEffect(listKey) { listState.scrollToItem(0) } // On the list Back is left to the system, which leaves the app; the open drawer's sheet closes it on Back. BackHandler(enabled = drawerState.isClosed && viewModel.screen == Screen.Detail) { viewModel.back() } @@ -125,21 +131,54 @@ private fun LoadingCover(showLogo: Boolean) { } } -/** The list, or the detail screen on show. */ +/** + * The list, or the detail screen on show, moving between them as + * [placeTransform] describes. An article keeps showing as it slides off, + * and the list comes back with that article in view. + */ @Composable private fun ScreenContent(snapshot: Snapshot, viewModel: ReaderViewModel, listState: LazyListState, onMenu: () -> Unit) { - if (viewModel.screen == Screen.List) { - if (snapshot.state.feeds.isEmpty()) { - FirstFeedScreen(snapshot, viewModel, onMenu) - } else { - EntryList(snapshot, viewModel, listState, onMenu) + val place = PhonePlace(viewModel.screen, viewModel.detail.takeIf { viewModel.screen == Screen.Detail }) + val leaving = remember { LeavingArticle() } + SideEffect { + if (place.detail == Detail.Article && snapshot.view.current != null) { + leaving.snapshot = snapshot + leaving.towards = Offset.Zero + } else if (place.detail != null && place.detail != Detail.Article) { + leaving.snapshot = null } - } else { - when (viewModel.detail) { - Detail.Article -> ArticleScreen(snapshot, viewModel) - Detail.Settings -> SettingsScreen(snapshot, viewModel) - Detail.Add -> AddFeedScreen(snapshot, viewModel) - Detail.Account -> AccountScreen(viewModel) + } + val animate = animationsOn() + val layoutDirection = LocalLayoutDirection.current + AnimatedContent( + targetState = place, + transitionSpec = { placeTransform(animate, leaving, layoutDirection) }, + label = "screen", + ) { shown -> + val exiting = transition.targetState == EnterExitState.PostExit + Box(Modifier.fillMaxSize().blockingTouches(exiting)) { + if (shown.detail == null) { + if (snapshot.state.feeds.isEmpty()) { + FirstFeedScreen(snapshot, viewModel, onMenu) + } else { + EntryList(snapshot, viewModel, listState, onMenu, reveal = leaving.snapshot?.view?.current?.id ?: "") + } + } else { + when (shown.detail) { + Detail.Article -> ArticleScreen( + if (exiting) leaving.snapshot ?: snapshot else snapshot, + viewModel, + leaving = exiting, + onClose = { towards -> + leaving.towards = towards + viewModel.back() + }, + ) + Detail.Settings -> SettingsScreen(snapshot, viewModel) + Detail.Add -> AddFeedScreen(snapshot, viewModel) + Detail.Account -> AccountScreen(viewModel) + } + } } } } diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/ReaderViewModel.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/ReaderViewModel.kt index 51c6c31..f2cbdf1 100644 --- a/android/app/src/main/kotlin/cc/hafa/reader/ui/ReaderViewModel.kt +++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/ReaderViewModel.kt @@ -6,6 +6,7 @@ import android.util.Log import androidx.annotation.PluralsRes import androidx.annotation.StringRes import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel @@ -141,6 +142,9 @@ class ReaderViewModel(application: Application) : AndroidViewModel(application) // Whether the account's data holds the loading screen: from opening and from signing in or replacing this device's data, until the screen lets the person in. private val accountAwaited = MutableStateFlow(true) + // Whether opening still waits for the checks of feeds past due; see ReaderModel.openChecks. + private val openingChecks = MutableStateFlow(true) + private val mutableGate = MutableStateFlow(LoadGate.started(SystemClock.elapsedRealtime())) /** The loading screen, which holds the person while what can still change the lists loads. */ @@ -159,6 +163,13 @@ class ReaderViewModel(application: Application) : AndroidViewModel(application) var detail by mutableStateOf(Detail.Article) private set + // Refreshes started here and not yet ended. The model's refreshing state reaches the list a few frames late, + // through the off-thread snapshot, and pull to refresh's indicator would spring back up meanwhile. + private var refreshesStarted by mutableIntStateOf(0) + + /** Whether feeds are being checked, from the moment a refresh is asked for here. */ + fun refreshing(state: ReaderState): Boolean = state.refreshing || refreshesStarted > 0 + private val preferences = application.readerApplication.preferences /** The font for article text and titles on this device. */ @@ -177,10 +188,16 @@ class ReaderViewModel(application: Application) : AndroidViewModel(application) .collect { notify(Notice.Text(R.string.sync_error)) } } viewModelScope.launch(logFailures) { - model.openChecks() + try { + model.openChecks() + } finally { + openingChecks.value = false + } } viewModelScope.launch(logFailures) { - combine(model.state, syncUi, accountAwaited) { state, sync, awaited -> loadingBusy(state, sync.report, awaited) } + combine(model.state, syncUi, accountAwaited, openingChecks) { state, sync, awaited, opening -> + opening || loadingBusy(state, sync.report, awaited) + } .distinctUntilChanged() .collectLatest { busy -> var wake = stepGate(busy) @@ -341,13 +358,24 @@ class ReaderViewModel(application: Application) : AndroidViewModel(application) /** Go to All items and check every feed. */ fun goHome() { model.select(Selection.All) - viewModelScope.launch(logFailures) { model.refresh() } + startRefresh { model.refresh() } showList() } /** Check the feeds of the view on show. */ fun refreshView() { - viewModelScope.launch(logFailures) { model.refreshView() } + startRefresh { model.refreshView() } + } + + private fun startRefresh(refresh: suspend () -> Unit) { + refreshesStarted += 1 + viewModelScope.launch(logFailures) { + try { + refresh() + } finally { + refreshesStarted -= 1 + } + } } /** diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/Sidebar.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/Sidebar.kt index 3350d29..bad5ab6 100644 --- a/android/app/src/main/kotlin/cc/hafa/reader/ui/Sidebar.kt +++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/Sidebar.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Inbox +import androidx.compose.material.icons.outlined.MailOutline import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -31,6 +32,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription @@ -44,18 +46,27 @@ import cc.hafa.reader.R import cc.hafa.reader.core.Selection import cc.hafa.reader.core.sidebarRows import cc.hafa.reader.core.viewKey +import cc.hafa.reader.sync.SyncConfig -private val PINNED_FOOTER_HEIGHT = 480.dp +private val PINNED_FOOTER_HEIGHT = 536.dp -/** The feed list in the left drawer: the brand, All items, Starred, groups and feeds, sync, and Add feed. */ +/** The feed list in the left drawer: the brand, All items, Starred, groups and feeds, Contact us, sync, and Add feed. */ @Composable fun Sidebar(snapshot: Snapshot, viewModel: ReaderViewModel, onPicked: () -> Unit) { val (state, view) = snapshot val colors = ReaderTheme.colors val selectedKey = viewKey(state.selection) val sync by viewModel.syncUi.collectAsStateWithLifecycle() + val links = LocalUriHandler.current val footer: @Composable () -> Unit = { HorizontalDivider(Modifier.padding(horizontal = 28.dp, vertical = 8.dp), color = colors.line) + SidebarItem( + label = stringResource(R.string.contact_us), + count = 0, + selected = false, + icon = { tint -> Icon(Icons.Outlined.MailOutline, null, tint = tint) }, + onClick = { links.openUri(SyncConfig.CONTACT_URL) }, + ) SidebarItem( label = syncStatusText(sync.report.status), count = 0, @@ -83,7 +94,7 @@ fun Sidebar(snapshot: Snapshot, viewModel: ReaderViewModel, onPicked: () -> Unit }, ) } - // On a short screen, such as a phone on its side, sync and Add feed scroll with the list rather than squeezing it. + // On a short screen, such as a phone on its side, Contact us, sync and Add feed scroll with the list rather than squeezing it. BoxWithConstraints(Modifier.fillMaxHeight()) { val footerPinned = maxHeight >= PINNED_FOOTER_HEIGHT Column( diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/Transitions.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/Transitions.kt new file mode 100644 index 0000000..b684e9c --- /dev/null +++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/Transitions.kt @@ -0,0 +1,130 @@ +package cc.hafa.reader.ui + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideIn +import androidx.compose.animation.slideOut +import androidx.compose.animation.togetherWith +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import kotlin.math.abs +import kotlin.math.roundToInt + +// How long a screen takes to slide over or off the list, and the list to come back under it. +private const val SCREEN_MS = 300 +// A switch between two detail screens fades the old one out, then the new one in. +private const val FADE_OUT_MS = 90 +private const val FADE_IN_MS = 210 +// How far the list shrinks and fades while a screen covers it. +private const val UNDER_SCALE = 0.95f +private const val UNDER_ALPHA = 0.4f +// How faded an article is a whole page away from the middle of the pager. +private const val FAR_PAGE_ALPHA = 0.5f + +/** What the phone shows: the list, or a detail screen. */ +data class PhonePlace( + /** The screen on show. */ + val screen: Screen, + /** What the detail screen holds; null on the list. */ + val detail: Detail?, +) + +/** + * The article being left: the last snapshot that showed it, so it stays drawn + * while it slides off, and which way it was sent. + */ +class LeavingArticle { + /** The last snapshot with the open article, while it is open and as it leaves; null after another detail screen. */ + var snapshot: Snapshot? = null + + /** Which way a drag sent the article off, as a unit step along one axis; zero for Back, which sends it toward the end. */ + var towards: Offset = Offset.Zero +} + +/** + * Where content of [size] ends up when sent [towards] (a unit step, or zero + * for toward the end of the reading direction): a whole width or height away. + */ +fun offscreen(towards: Offset, size: IntSize, layoutDirection: LayoutDirection): IntOffset = + if (towards == Offset.Zero) { + IntOffset(if (layoutDirection == LayoutDirection.Ltr) size.width else -size.width, 0) + } else { + IntOffset((towards.x * size.width).roundToInt(), (towards.y * size.height).roundToInt()) + } + +/** How opaque a pager's page is [distance] pages from the middle: whole there, fading evenly to half opacity a whole page away. */ +fun pageAlpha(distance: Float): Float = 1f - (1f - FAR_PAGE_ALPHA) * minOf(1f, abs(distance)) + +/** + * The list index to scroll to so the row at [index] is in full view, given the + * first and last rows wholly shown; null when it already is or is not in the list. + */ +fun revealIndex(index: Int, firstWhole: Int, lastWhole: Int): Int? = + if (index < 0 || index in firstWhole..lastWhole) { + null + } else { + index + } + +/** + * How the phone moves between places. A detail screen slides in from the end + * over the list, which shrinks and dims under it; leaving one slides it off, + * toward the end for Back or the way a drag sent the article, while the list + * comes back up under it. One detail screen replacing another fades through. + * Nothing moves when the system turns animations off. + */ +fun AnimatedContentTransitionScope.placeTransform( + animate: Boolean, + leaving: LeavingArticle, + layoutDirection: LayoutDirection, +): ContentTransform { + val opening = initialState.screen == Screen.List && targetState.screen == Screen.Detail + val closing = initialState.screen == Screen.Detail && targetState.screen == Screen.List + return if (!animate) { + EnterTransition.None togetherWith ExitTransition.None + } else if (opening) { + val enter = slideIn(tween(SCREEN_MS, easing = FastOutSlowInEasing)) { size -> offscreen(Offset.Zero, size, layoutDirection) } + val exit = scaleOut(tween(SCREEN_MS, easing = FastOutSlowInEasing), targetScale = UNDER_SCALE) + + fadeOut(tween(SCREEN_MS, easing = FastOutSlowInEasing), targetAlpha = UNDER_ALPHA) + (enter togetherWith exit).apply { targetContentZIndex = 1f } + } else if (closing) { + // Only an article is dragged off; Back sends every detail screen toward the end. + val towards = if (initialState.detail == Detail.Article) leaving.towards else Offset.Zero + // A drag hands the article on already moving, so it slows to a stop rather than starting slowly. + val easing = if (towards == Offset.Zero) FastOutSlowInEasing else LinearOutSlowInEasing + val enter = scaleIn(tween(SCREEN_MS, easing = FastOutSlowInEasing), initialScale = UNDER_SCALE) + + fadeIn(tween(SCREEN_MS, easing = FastOutSlowInEasing), initialAlpha = UNDER_ALPHA) + val exit = slideOut(tween(SCREEN_MS, easing = easing)) { size -> offscreen(towards, size, layoutDirection) } + (enter togetherWith exit).apply { targetContentZIndex = -1f } + } else { + fadeIn(tween(FADE_IN_MS, delayMillis = FADE_OUT_MS)) togetherWith fadeOut(tween(FADE_OUT_MS)) + } +} + +/** Takes every touch when [blocking], so a screen sliding away cannot be used on its way out. */ +fun Modifier.blockingTouches(blocking: Boolean): Modifier = + if (blocking) { + pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial).changes.forEach { change -> change.consume() } + } + } + } + } else { + this + } diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml index 8716f32..22d1d49 100644 --- a/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,15 +1,15 @@ - + + android:translateX="24.27" + android:translateY="26.12" + android:scaleX="0.9" + android:scaleY="0.9"> + android:translateX="24.27" + android:translateY="26.12" + android:scaleX="0.9" + android:scaleY="0.9"> 1 year Forever Refresh - + Some feeds are due for a check Select a topic to turn it off. Articles with only turned-off topics are hidden. Name @@ -75,6 +75,9 @@ Feeds Show feeds Back + + Release to close + Release to refresh The site did not answer. Only https addresses can be added. @@ -116,4 +119,5 @@ Privacy Terms Contact + Contact us diff --git a/android/app/src/test/kotlin/cc/hafa/reader/ui/PullTest.kt b/android/app/src/test/kotlin/cc/hafa/reader/ui/PullTest.kt new file mode 100644 index 0000000..fb60b23 --- /dev/null +++ b/android/app/src/test/kotlin/cc/hafa/reader/ui/PullTest.kt @@ -0,0 +1,70 @@ +package cc.hafa.reader.ui + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.runtime.MonotonicFrameClock +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// The same numbers as the web's gestures.test.ts. +class PullTest { + @Test + fun `a pulled article moves less and less as the finger goes further`() { + assertEquals(0f, pullStretch(0f)) + assertEquals(9.694f, pullStretch(10f), 0.001f) + assertEquals(72.19f, pullStretch(96f), 0.001f) + assertEquals(-72.19f, pullStretch(-96f), 0.001f) + assertEquals(114.159f, pullStretch(200f), 0.001f) + assertEquals(146.866f, pullStretch(400f), 0.001f) + assertTrue(pullStretch(1000f) < ARTICLE_PULL_STRETCH) + } + + @Test + fun `a pull's progress reaches 1 at the closing distance and stays there`() { + assertEquals(0f, pullProgress(0f)) + assertEquals(0.5f, pullProgress(48f)) + assertEquals(0.5f, pullProgress(-48f)) + assertEquals(1f, pullProgress(ARTICLE_PULL_DISTANCE)) + assertEquals(1f, pullProgress(400f)) + } + + // Frames 16 ms apart, as fast as the test can take them. + private object TestClock : MonotonicFrameClock { + private var frame = 0L + + override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R { + frame += 16_000_000L + return onFrame(frame) + } + } + + private fun endPull(): EdgeDrag = + EdgeDrag(Orientation.Vertical, ARTICLE_PULL_DISTANCE, fromEdgeOnly = true, pastEndOnly = true, staysOnRelease = false) {} + + private fun EdgeDrag.dragUp(amount: Float) { + onPostScroll(Offset.Zero, Offset(0f, -amount), NestedScrollSource.UserInput) + } + + @Test + fun `a pull past the end does nothing while it is off, as while refreshing`() { + val drag = endPull() + drag.enabled = false + drag.dragUp(50f) + assertEquals(0f, drag.travel) + } + + @Test + fun `a pull cancelled midway springs back and stops following the finger`() = runBlocking { + val drag = endPull() + drag.dragUp(50f) + assertEquals(-50f, drag.travel) + withContext(TestClock) { drag.cancel() } + assertEquals(0f, drag.travel) + drag.dragUp(50f) + assertEquals(0f, drag.travel) + } +} diff --git a/android/app/src/test/kotlin/cc/hafa/reader/ui/TransitionsTest.kt b/android/app/src/test/kotlin/cc/hafa/reader/ui/TransitionsTest.kt new file mode 100644 index 0000000..95f0c12 --- /dev/null +++ b/android/app/src/test/kotlin/cc/hafa/reader/ui/TransitionsTest.kt @@ -0,0 +1,47 @@ +package cc.hafa.reader.ui + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TransitionsTest { + private val size = IntSize(400, 800) + + @Test + fun `back sends a screen toward the end of the reading direction`() { + assertEquals(IntOffset(400, 0), offscreen(Offset.Zero, size, LayoutDirection.Ltr)) + assertEquals(IntOffset(-400, 0), offscreen(Offset.Zero, size, LayoutDirection.Rtl)) + } + + @Test + fun `a drag sends the article on the way it went, a whole width or height`() { + assertEquals(IntOffset(-400, 0), offscreen(Offset(-1f, 0f), size, LayoutDirection.Ltr)) + assertEquals(IntOffset(-400, 0), offscreen(Offset(-1f, 0f), size, LayoutDirection.Rtl)) + assertEquals(IntOffset(0, 800), offscreen(Offset(0f, 1f), size, LayoutDirection.Ltr)) + assertEquals(IntOffset(0, -800), offscreen(Offset(0f, -1f), size, LayoutDirection.Ltr)) + } + + @Test + fun `pages fade evenly to half opacity a page away either side`() { + assertEquals(1f, pageAlpha(0f)) + assertEquals(0.75f, pageAlpha(0.5f)) + assertEquals(0.75f, pageAlpha(-0.5f)) + assertEquals(0.5f, pageAlpha(1f)) + assertEquals(0.5f, pageAlpha(2f)) + } + + @Test + fun `the list scrolls only to a row not wholly in view`() { + assertNull(revealIndex(5, 3, 8)) + assertNull(revealIndex(3, 3, 8)) + assertNull(revealIndex(8, 3, 8)) + assertEquals(2, revealIndex(2, 3, 8)) + assertEquals(9, revealIndex(9, 3, 8)) + assertEquals(0, revealIndex(0, 0, -1)) + assertNull(revealIndex(-1, 3, 8)) + } +} diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/Feed.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/Feed.kt index 0541f8e..cac83da 100644 --- a/android/core/src/main/kotlin/cc/hafa/reader/core/Feed.kt +++ b/android/core/src/main/kotlin/cc/hafa/reader/core/Feed.kt @@ -64,7 +64,14 @@ private val NAMED_ENTITIES = mapOf( "nbsp" to " ", ) private const val MAX_CODE_POINT = 0x10FFFF -private val IMAGE_PATTERN = Regex("]*?\\bsrc\\s*=\\s*[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE) +private val IMAGE_TAG = Regex("]*>", RegexOption.IGNORE_CASE) + +// an attribute of an image tag the lead image is chosen from, its value quoted or bare +private val IMAGE_ATTRIBUTE = Regex( + "[\\t\\n\\f\\r ](srcset|src|width)[\\t\\n\\f\\r ]*=[\\t\\n\\f\\r ]*(?:\"([^\"]*)\"|'([^']*)'|([^\\t\\n\\f\\r \"'>]+))", + RegexOption.IGNORE_CASE, +) +private val PIXELS = Regex("^\\d+$") private fun codePointOr(code: Long?, fallback: String): String = if (code != null && code > 0 && code <= MAX_CODE_POINT) { @@ -139,11 +146,66 @@ private fun firstDate(node: Element, fields: List): Long { return found } -private fun firstImage(html: String, base: String): String { - val source = IMAGE_PATTERN.find(html)?.groupValues?.get(1) - return if (source.isNullOrEmpty()) "" else resolveWebAddress(decodeEntities(source), base) +/** A picture that could lead an article, with its width in pixels when the feed says (else 0). */ +private data class LeadCandidate(val address: String, val size: CandidateSize) + +private val UNKNOWN_SIZE = CandidateSize(0.0, 1.0) + +private fun pixels(value: String): Double { + val trimmed = value.trim() + return if (PIXELS.matches(trimmed)) trimmed.toDouble() else 0.0 +} + +/** + * The lead picture among candidates in order of preference: the first, given + * up for a later one only when both widths are known and the later is wider. + */ +private fun widestLead(candidates: List): String { + var chosen = LeadCandidate("", UNKNOWN_SIZE) + for (candidate in candidates) { + if (candidate.address.isNotEmpty() && + (chosen.address.isEmpty() || (chosen.size.width > 0 && candidate.size.width > chosen.size.width)) + ) { + chosen = candidate + } + } + return chosen.address +} + +/** + * The first `img` of article HTML as a lead candidate: the largest of its + * `src` and `srcset` candidates (`width` turns densities into widths), the + * `src` winning ties. + */ +private fun firstImage(html: String, base: String): LeadCandidate { + val tag = IMAGE_TAG.find(html)?.value ?: "" + val attributes = mutableMapOf() + for (match in IMAGE_ATTRIBUTE.findAll(tag)) { + val value = match.groups[2]?.value ?: match.groups[3]?.value ?: match.groups[4]?.value ?: "" + attributes.putIfAbsent(match.groupValues[1].lowercase(), decodeEntities(value)) + } + val declaredWidth = pixels(attributes["width"] ?: "") + val source = attributes["src"] ?: "" + val resolvedSource = if (source.isEmpty()) "" else resolveWebAddress(source, base) + var largest = if (resolvedSource.isEmpty()) { + LeadCandidate("", UNKNOWN_SIZE) + } else { + LeadCandidate(resolvedSource, candidateSize("", declaredWidth)) + } + for (candidate in srcsetCandidates(attributes["srcset"] ?: "")) { + val resolved = resolveWebAddress(candidate.address, base) + val size = candidateSize(candidate.descriptor, declaredWidth) + if (resolved.isNotEmpty() && (largest.address.isEmpty() || isLarger(size, largest.size))) { + largest = LeadCandidate(resolved, size) + } + } + return largest } +/** The lead picture: the feed's own unless the body's first picture is known to be wider. */ +private fun leadImage(feedImages: List, html: String, base: String): String = + widestLead(feedImages + firstImage(html, base)) + /** Text with common HTML entities decoded, as an attribute value reads in a browser. */ fun decodeHtmlEntities(text: String): String = decodeEntities(text) @@ -177,30 +239,32 @@ private fun atomLink(links: List, base: String): String = resolveWebAdd /** The Media RSS groups of an item, which YouTube and others put its picture and description in. */ private fun mediaGroups(item: Element): List = childrenNamed(item, "group") -private fun mediaImage(item: Element, base: String, depth: Int = 0): String { - var image = "" - for (thumbnail in childrenNamed(item, "thumbnail")) { - if (image.isEmpty() && thumbnail.hasAttr("url")) { - image = resolveWebAddress(attrOf(thumbnail, "url"), base) - } - } - for (content in childrenNamed(item, "content")) { - val kind = attrOf(content, "medium") + attrOf(content, "type") - if (image.isEmpty() && content.hasAttr("url") && "image" in kind) { - image = resolveWebAddress(attrOf(content, "url"), base) - } - } - for (enclosure in childrenNamed(item, "enclosure")) { - if (image.isEmpty() && enclosure.hasAttr("url") && attrOf(enclosure, "type").startsWith("image/")) { - image = resolveWebAddress(attrOf(enclosure, "url"), base) - } - } - for (group in mediaGroups(item)) { - if (image.isEmpty() && depth < MAX_TEXT_DEPTH) { - image = mediaImage(group, base, depth + 1) - } +/** A Media RSS picture's address and declared width. */ +private fun mediaCandidate(node: Element, base: String): LeadCandidate = + LeadCandidate( + if (node.hasAttr("url")) resolveWebAddress(attrOf(node, "url"), base) else "", + CandidateSize(pixels(attrOf(node, "width")), 1.0), + ) + +/** + * An item's pictures in order of preference: Media RSS image contents and + * image enclosures (the picture itself), then thumbnails, then those of its + * Media RSS groups. + */ +private fun mediaImages(item: Element, base: String, depth: Int = 0): List { + val contents = childrenNamed(item, "content") + .filter { content -> "image" in attrOf(content, "medium") + attrOf(content, "type") } + .map { content -> mediaCandidate(content, base) } + val enclosures = childrenNamed(item, "enclosure") + .filter { enclosure -> attrOf(enclosure, "type").startsWith("image/") } + .map { enclosure -> mediaCandidate(enclosure, base).copy(size = UNKNOWN_SIZE) } + val thumbnails = childrenNamed(item, "thumbnail").map { thumbnail -> mediaCandidate(thumbnail, base) } + val groups = if (depth < MAX_TEXT_DEPTH) { + mediaGroups(item).flatMap { group -> mediaImages(group, base, depth + 1) } + } else { + emptyList() } - return image + return contents + enclosures + thumbnails + groups } /** An item's Media RSS description as a body and a summary; empty when it has none. */ @@ -240,7 +304,7 @@ private fun parseRssItem(item: Element, base: String): FeedEntry { url = link, published = published, author = displayText(childText(item, "creator").ifEmpty { childText(item, "author") }), - imageUrl = mediaImage(item, base).ifEmpty { firstImage(html, base) }, + imageUrl = leadImage(mediaImages(item, base), html, base), summary = plainText(childText(item, "description")).ifEmpty { media.summary }, categories = categoryNames(childrenNamed(item, "category")), html = html, @@ -277,7 +341,7 @@ private fun parseAtomEntry(entry: Element, base: String): FeedEntry { url = link, published = published, author = if (author == null) "" else displayText(childText(author, "name")), - imageUrl = mediaImage(entry, base).ifEmpty { firstImage(html, base) }, + imageUrl = leadImage(mediaImages(entry, base), html, base), summary = plainText(summary).ifEmpty { media.summary }, categories = categoryNames(childrenNamed(entry, "category")), html = html, @@ -354,7 +418,7 @@ private fun parseJsonFeed(source: JsonObject, base: String): ParsedFeed { url = url, published = published, author = authors.firstOrNull()?.let { author -> jsonText(author["name"]) } ?: "", - imageUrl = resolveWebAddress(jsonText(item["image"]), base).ifEmpty { firstImage(html, base) }, + imageUrl = leadImage(listOf(LeadCandidate(resolveWebAddress(jsonText(item["image"]), base), UNKNOWN_SIZE)), html, base), summary = plainText(jsonText(item["summary"])), categories = jsonList(item["tags"]).map { tag -> jsonText(tag) }.filter { tag -> tag.isNotEmpty() }, html = html, diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/FeedCheck.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/FeedCheck.kt index a8dabaf..acd7aba 100644 --- a/android/core/src/main/kotlin/cc/hafa/reader/core/FeedCheck.kt +++ b/android/core/src/main/kotlin/cc/hafa/reader/core/FeedCheck.kt @@ -79,7 +79,14 @@ class HttpFeedFetcher( private val httpsOnly: Boolean = true, ) : FeedFetcher { // A redirect between https and plain http comes back unfollowed, to be followed here at https. - private val client = client.newBuilder().followSslRedirects(!httpsOnly).build() + // Each call's own deadline is its only limit, as the extension's fetch has; OkHttp's default + // 10 s connect and read limits would otherwise end a slow site's answer well before it. + private val client = client.newBuilder() + .followSslRedirects(!httpsOnly) + .connectTimeout(0, TimeUnit.MILLISECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .writeTimeout(0, TimeUnit.MILLISECONDS) + .build() override suspend fun fetch(url: String, etag: String, lastModified: String, timeoutMs: Long): FetchOutcome { val address = url.toHttpUrlOrNull() @@ -227,9 +234,10 @@ fun checkOutcome(schedule: FeedSchedule, url: String, outcome: FetchOutcome, now if (hash == schedule.hash) { CheckResult(quiet(schedule, now, outcome).copy(etag = outcome.etag, lastModified = outcome.lastModified), null) } else { + // Any failure to parse counts as an unreadable feed, as on the web, rather than leaving the feed due unseen. val parsed = try { parseFeed(outcome.text, outcome.url.ifEmpty { url }) - } catch (_: NotAFeedException) { + } catch (_: Exception) { null } if (parsed == null) { diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/LeadImage.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/LeadImage.kt index 60df944..bd2e0b8 100644 --- a/android/core/src/main/kotlin/cc/hafa/reader/core/LeadImage.kt +++ b/android/core/src/main/kotlin/cc/hafa/reader/core/LeadImage.kt @@ -1,6 +1,7 @@ package cc.hafa.reader.core import org.jsoup.Jsoup +import org.jsoup.nodes.Element /** * Whether an article's lead image is shown above its body: only when the @@ -19,63 +20,58 @@ fun showsLeadImage(imageUrl: String, bodyHtml: String): Boolean { } } -/** One picture a `srcset` offers: its address and its width or density descriptor. */ -data class SrcsetCandidate( - /** Picture address, which may hold commas (as `data:` addresses do). */ +// picture types every Android version shows; a `source` of another type may not decode +private val DECODABLE_TYPES = setOf("", "image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif") + +/** The picture Android loads for an `img`, with what sizes it as a browser would. */ +data class PictureChoice( + /** Picture address; empty when the image has none. */ val address: String, - /** Width (`640w`) or density (`2x`); empty when none is given. */ - val descriptor: String, -) + /** The image's `width` attribute in CSS pixels; 0 when it has none. */ + val declaredWidth: Double, + /** Pixel density of the chosen picture: its `x` descriptor, else 1. */ + val density: Double, +) { + /** + * Width to show the picture at in CSS pixels (dp), before fitting it to + * the screen, given the loaded file's [naturalWidth] in pixels: the + * declared width when there is one, else the natural width over the + * density. + */ + fun displayWidth(naturalWidth: Double): Double = + if (declaredWidth > 0) declaredWidth else naturalWidth / density +} /** - * The candidates of a `srcset`, read as browsers do: an address is a run of - * non-space characters (a trailing comma ends it), then an optional - * descriptor up to the next comma. + * The picture Android loads for a sanitized `img`, since it cannot pick from + * a `srcset` as a browser does: the largest candidate among the image's + * `srcset` and those of its `picture`'s `source`s that apply on every screen + * in a type Android shows, else its `src`. */ -fun srcsetCandidates(srcset: String): List { - val found = mutableListOf() - var index = 0 - while (index < srcset.length) { - while (index < srcset.length && (srcset[index].isWhitespace() || srcset[index] == ',')) { - index += 1 - } - val start = index - while (index < srcset.length && !srcset[index].isWhitespace()) { - index += 1 - } - var address = srcset.substring(start, index) - var descriptor = "" - if (address.endsWith(",")) { - address = address.trimEnd(',') - } else { - val end = srcset.indexOf(',', index).let { comma -> if (comma == -1) srcset.length else comma } - descriptor = srcset.substring(index, end).trim() - index = end - } - if (address.isNotEmpty()) { - found.add(SrcsetCandidate(address, descriptor)) +fun pictureChoice(image: Element): PictureChoice { + val parent = image.parent() + val sources = if (parent != null && parent.normalName() == "picture") { + parent.children().filter { child -> + child.normalName() == "source" && + !child.hasAttr("media") && + child.attr("type").trim().lowercase() in DECODABLE_TYPES } + } else { + emptyList() + } + val candidates = (sources + image).flatMap { picture -> srcsetCandidates(picture.attr("srcset")) } + val declaredWidth = image.attr("width").trim().toIntOrNull()?.takeIf { width -> width > 0 }?.toDouble() ?: 0.0 + val chosen = largestOf(candidates, declaredWidth) + return if (chosen != null) { + val density = candidateSize(chosen.descriptor, declaredWidth).density.takeIf { density -> density > 0 } ?: 1.0 + PictureChoice(chosen.address, declaredWidth, density) + } else { + PictureChoice(image.absUrl("src").ifEmpty { image.attr("src") }, declaredWidth, 1.0) } - return found } -/** A `srcset` written back from its candidates. */ -fun joinSrcset(candidates: List): String = - candidates.joinToString(", ") { candidate -> listOf(candidate.address, candidate.descriptor).filter(String::isNotEmpty).joinToString(" ") } - -/** The addresses a `srcset` lists. */ -fun srcsetAddresses(srcset: String): List = srcsetCandidates(srcset).map { candidate -> candidate.address } - -/** The largest picture a `srcset` offers, by its width or density; empty if none. */ -fun bestSrcsetCandidate(srcset: String): String = - srcsetCandidates(srcset) - .filter { candidate -> !candidate.address.startsWith("data:") } - .maxByOrNull { candidate -> - val descriptor = candidate.descriptor.ifEmpty { "1x" } - val size = descriptor.dropLast(1).toDoubleOrNull() ?: 1.0 - if (descriptor.endsWith("w")) size else size * 1000 - } - ?.address ?: "" +/** The address [pictureChoice] picks for a sanitized `img`. */ +fun pictureAddress(image: Element): String = pictureChoice(image).address /** Most pictures fetched ahead for one article, its lead picture included. */ const val PICTURES_PER_ARTICLE = 4 @@ -83,13 +79,13 @@ const val PICTURES_PER_ARTICLE = 4 /** * The pictures an article shows first, at the addresses the article view * loads: its lead picture when the body does not show it, then the body's - * first pictures (the largest `srcset` candidate, else `src`); https only, + * first pictures (as [pictureAddress] picks them); https only, * at most [limit]. [body] must be the sanitized body the article shows. */ fun aheadPictures(imageUrl: String, body: String, limit: Int = PICTURES_PER_ARTICLE): List { val lead = decodeHtmlEntities(imageUrl).takeIf { address -> showsLeadImage(imageUrl, body) && isSecure(address) } val pictures = Jsoup.parseBodyFragment(body).select("img").asSequence() - .map { image -> bestSrcsetCandidate(image.attr("srcset")).ifEmpty { image.absUrl("src").ifEmpty { image.attr("src") } } } + .map(::pictureAddress) .filter(::isSecure) return (listOfNotNull(lead).asSequence() + pictures).take(limit).toList() } diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/LoadGate.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/LoadGate.kt index 87d9080..a81f0bb 100644 --- a/android/core/src/main/kotlin/cc/hafa/reader/core/LoadGate.kt +++ b/android/core/src/main/kotlin/cc/hafa/reader/core/LoadGate.kt @@ -15,6 +15,14 @@ fun loadingBusy(state: ReaderState, sync: SyncReport, awaited: Boolean): Boolean return !state.loaded || state.importing > 0 || (awaited && arriving) } +/** + * Longest time, in milliseconds, the loading screen waits at opening for the + * checks of feeds past their due time; checks still running then go on + * behind it, and what they bring waits for the lists to be settled. The same + * as the web app's `OPENING_WAIT_MS`. + */ +const val OPENING_WAIT_MS = 1_000L + /** What the loading screen shows. */ enum class GatePhase { /** Nothing: the person is let in. */ diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderModel.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderModel.kt index 09a9fa7..45d8511 100644 --- a/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderModel.kt +++ b/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderModel.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.util.concurrent.ConcurrentHashMap import java.util.logging.Level @@ -124,6 +125,9 @@ fun markedHttp(feed: Feed): Feed = feed } +/** Time between checks of due feeds while the app is on screen, in milliseconds: as often as the extension's timer wakes. */ +const val DUE_CHECK_MS = 5 * 60_000L + /** Shortest time the refreshing marks stay on, so a quick check is still seen. */ private const val MIN_REFRESH_MS = 700L private const val MAX_DISCOVERY_TRIES = 12 @@ -185,6 +189,8 @@ private fun hostOf(address: String): String = address.toHttpUrlOrNull()?.host ?: * * [savingData] says whether the device asks to save data (Data Saver on * Android); then no neighbor's body is downloaded and no picture fetched ahead. + * [online] says whether the device has a network, so opening does not wait + * for checks that cannot answer. */ class ReaderModel( private val store: ReaderStore, @@ -196,6 +202,7 @@ class ReaderModel( private val minRefreshMillis: Long = MIN_REFRESH_MS, private val onBackgroundWork: () -> Unit = {}, private val savingData: () -> Boolean = { false }, + private val online: () -> Boolean = { true }, ) { private val mutableState = MutableStateFlow(ReaderState()) @@ -204,7 +211,6 @@ class ReaderModel( private val loadLock = Mutex() private val writes = Channel Unit>(Channel.UNLIMITED) - private val manual: MutableSet = ConcurrentHashMap.newKeySet() private val iconsTried: MutableSet = ConcurrentHashMap.newKeySet() // Plain http feeds tried over https since the reader started with a definite answer, or being tried, by id. private val httpTried: MutableSet = ConcurrentHashMap.newKeySet() @@ -361,7 +367,7 @@ class ReaderModel( first.settings === second.settings && first.pinned === second.pinned && first.heldTopics === second.heldTopics && first.frozenRank === second.frozenRank && first.heldEntries === second.heldEntries && first.heldFeeds === second.heldFeeds && - first.selection == second.selection && first.unreadOnly == second.unreadOnly && + first.frozenList === second.frozenList && first.selection == second.selection && first.unreadOnly == second.unreadOnly && first.currentId == second.currentId ) @@ -374,7 +380,9 @@ class ReaderModel( if (before.currentId.isNotEmpty()) { close() } - update { current -> current.copy(pinned = emptySet(), heldTopics = emptyMap(), heldEntries = current.heldEntries?.let { current.entryIds() }) } + update { current -> + current.copy(pinned = emptySet(), heldTopics = emptyMap(), heldEntries = current.heldEntries?.let { current.entryIds() }, frozenList = null) + } } preferences.lastView = viewKey(chosen) } @@ -388,6 +396,7 @@ class ReaderModel( heldTopics = emptyMap(), heldEntries = current.heldEntries?.let { current.entryIds() }, heldFeeds = current.heldFeeds?.let { current.feedIds() }, + frozenList = null, ) } if (next && entryById(state.value.currentId)?.read == true) { @@ -403,15 +412,16 @@ class ReaderModel( * worked out again. */ fun settle() { - update { current -> - current.copy( - frozenRank = frozenRankOf(current.feeds, current.entries, current.settings), - heldEntries = current.entryIds(), - heldFeeds = current.feedIds(), - ) - } + update { current -> current.settled() } } + /** This state with everything that has arrived let into the lists and the sidebar's order worked out again. */ + private fun ReaderState.settled(): ReaderState = copy( + frozenRank = frozenRankOf(feeds, entries, settings), + heldEntries = entryIds(), + heldFeeds = feedIds(), + ) + private fun ReaderState.entryIds(): Set = entries.mapTo(HashSet()) { entry -> entry.id } private fun ReaderState.feedIds(): Set = feeds.mapTo(HashSet()) { feed -> feed.id } @@ -592,12 +602,11 @@ class ReaderModel( toggleStarFor(state.value.currentId) } - /** Flip the read mark on the open entry; the choice sticks when the article is left. */ + /** Flip the read mark the open entry shows; see [ReaderState.shownRead]. */ fun toggleRead() { val entry = entryById(state.value.currentId) if (entry != null) { - manual.add(entry.id) - setRead(listOf(entry.id), !entry.read) + toggleOpenRead(entry) } } @@ -617,9 +626,10 @@ class ReaderModel( val entry = entryById(id) if (entry != null) { if (id == state.value.currentId) { - manual.add(id) + toggleOpenRead(entry) + } else { + setRead(listOf(id), !entry.read) } - setRead(listOf(id), !entry.read) } } @@ -628,12 +638,10 @@ class ReaderModel( val current = state.value val view = viewOf(current) val shown = view.visible.mapTo(HashSet()) { entry -> entry.id } - setRead( - view.baseEntries - .filter { entry -> !entry.read && (entry.id in shown || countedInView(current, entry)) } - .map { entry -> entry.id }, - true, - ) + val marked = view.baseEntries.filter { entry -> markedByMarkAllRead(current, shown, entry) }.map { entry -> entry.id } + if (marked.isNotEmpty()) { + setRead(marked, true) + } } /** Give a feed its own name; a blank name is ignored. */ @@ -749,8 +757,10 @@ class ReaderModel( /** * Check feeds now whatever their schedule says. With no ids, every feed. * Requests are still conditional, so unchanged feeds cost little. Read - * articles kept in the list go at once, and the sidebar order is worked - * out again when it ends. + * articles kept in the list go at once; then the list holds still (see + * [ReaderState.frozenList]) until the last refresh under way ends, when + * the lists take in everything that arrived, the sidebar order is worked + * out again and the list goes back to its top. */ suspend fun refresh(feedIds: List = emptyList()) { load() @@ -762,13 +772,14 @@ class ReaderModel( if (open.unreadOnly && open.selection != Selection.Starred && entryById(open.currentId)?.read == true) { close() } + val frozen = FrozenList(viewKey(state.value.selection), view().visible.map { entry -> entry.id }) + update { current -> if (current.frozenList == null) current.copy(frozenList = frozen) else current } if (state.value.feeds.any { feed -> isPlainHttp(feed.url) && feed.id !in httpTried }) { scope.launch { upgradeHttpFeeds() } } val started = clock() try { checkNow(feedIds) - settle() } finally { try { val remaining = minRefreshMillis - (clock() - started) @@ -776,7 +787,18 @@ class ReaderModel( delay(remaining) } } finally { - update { current -> current.copy(refreshCount = current.refreshCount - 1) } + update { current -> + val left = current.refreshCount - 1 + if (left > 0) { + current.copy(refreshCount = left) + } else { + current.settled().copy( + refreshCount = 0, + frozenList = null, + refreshesEnded = current.refreshesEnded + 1, + ) + } + } } } } @@ -816,20 +838,54 @@ class ReaderModel( } /** - * Start the checks the app makes when it opens, without waiting for them: - * load, try plain http feeds over https, and queue the feeds whose schedule - * says they are due. Nothing shows as refreshing, the list keeps its rows - * and the sidebar keeps the order worked out at load. + * Start the checks the app makes when it opens: load, try plain http + * feeds over https, and queue a check of every feed, those past their due + * time first and waited for, the rest after, likelier ones first. Returns + * once the feeds past due are saved or [waitMs] has passed, whichever is + * first, and at once when the device is offline; the other checks go on in + * the background. Nothing shows as refreshing, the list keeps its rows and + * the sidebar keeps the order worked out at load. */ - suspend fun openChecks() { + suspend fun openChecks(waitMs: Long = OPENING_WAIT_MS) { load() if (state.value.feeds.any { feed -> isPlainHttp(feed.url) && feed.id !in httpTried }) { scope.launch { upgradeHttpFeeds() } } - queue.enqueue(dueRequests(clock())) + val now = clock() + // a plain http feed is never fetched; its https address is tried apart from the schedule + val requests = state.value.feeds + .filter { feed -> !isPlainHttp(feed.url) } + .map { feed -> + CheckRequest( + feed.id, + refreshProbability(feed.schedule, now), + foreground = feed.schedule.nextFetch <= now, + timeoutMs = FETCH_TIMEOUT_MS, + ) + } + val checks = queue.enqueue(requests) if (queue.busy()) { onBackgroundWork() } + if (online()) { + withTimeoutOrNull(waitMs) { + checks.filter { (_, request) -> request.foreground }.forEach { (check, _) -> check.stored.await() } + } + } + } + + /** + * Queue background checks of the feeds whose schedule says they are due, + * without waiting for them, as the extension's timer does while the web + * app is open; nothing before the stored data is loaded. + */ + suspend fun queueDue() { + if (state.value.loaded) { + queue.enqueue(dueRequests(clock())) + if (queue.busy()) { + onBackgroundWork() + } + } } /** Check the feeds whose schedule says they are due, through the same queue, and wait until they are saved. */ @@ -1185,10 +1241,38 @@ class ReaderModel( private fun leave(id: String) { val entry = entryById(id) - if (entry != null && !(entry.read || id in manual)) { + if (entry != null && !(entry.read || id in state.value.keptUnread)) { setRead(listOf(id), true) } - manual.remove(id) + keepUnread(id, false) + } + + /** + * Flip the read mark the open entry shows. An unread one waiting to be + * marked read on leaving stops waiting and saves nothing; one that has + * stopped is marked read now; a read one is marked unread and stays so on + * leaving. + */ + private fun toggleOpenRead(entry: Entry) { + if (entry.read) { + keepUnread(entry.id, true) + setRead(listOf(entry.id), false) + } else if (entry.id in state.value.keptUnread) { + keepUnread(entry.id, false) + setRead(listOf(entry.id), true) + } else { + keepUnread(entry.id, true) + } + } + + private fun keepUnread(id: String, kept: Boolean) { + update { current -> + when { + (id in current.keptUnread) == kept -> current + kept -> current.copy(keptUnread = current.keptUnread + id) + else -> current.copy(keptUnread = current.keptUnread - id) + } + } } private fun setRead(ids: List, read: Boolean) { @@ -1270,9 +1354,9 @@ class ReaderModel( /** Remove every feed, entry, body, setting and tombstone on this device. */ private suspend fun clearLocal() { - manual.clear() update { current -> current.copy( + keptUnread = emptySet(), feeds = emptyList(), entries = emptyList(), settings = emptyMap(), @@ -1288,6 +1372,7 @@ class ReaderModel( frozenRank = emptyMap(), heldEntries = null, heldFeeds = null, + frozenList = null, ) } persist { store.clear() } @@ -1297,7 +1382,15 @@ class ReaderModel( /** Fetch and judge one feed; null when the fetch ran out of a time shorter than the full one. */ private suspend fun checkFeed(feed: Feed, timeoutMs: Long): FinishedCheck? { - val outcome = fetcher.fetch(feed.url, feed.schedule.etag, feed.schedule.lastModified, timeoutMs) + val outcome = try { + fetcher.fetch(feed.url, feed.schedule.etag, feed.schedule.lastModified, timeoutMs) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + // Counted as no answer, as the extension counts any fetch that throws, so the feed backs off and shows its problem. + logger.log(Level.WARNING, "fetching a feed failed", failure) + FetchOutcome(status = 0) + } return if (outcome.timedOut && timeoutMs < FETCH_TIMEOUT_MS) { null } else { diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderView.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderView.kt index d2a274b..76641d8 100644 --- a/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderView.kt +++ b/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderView.kt @@ -27,6 +27,8 @@ data class ReaderState( val unreadOnly: Boolean = true, /** Id of the open article; empty for none. */ val currentId: String = "", + /** Open articles not to mark read on leaving, since the person took that mark back or marked them unread. */ + val keptUnread: Set = emptySet(), /** Sanitized body of the open article. */ val body: String = "", /** Id of the article [body] belongs to; empty while it loads. */ @@ -55,11 +57,30 @@ data class ReaderState( val heldFeeds: Set? = null, /** Imports whose feeds are being checked for the first time. */ val importing: Int = 0, + /** + * The list as it stood when a refresh the person asked for began. That + * view lists exactly these articles, whatever arrives or changes, until + * the refresh ends; null when none is held. + */ + val frozenList: FrozenList? = null, + /** Refreshes the person asked for that have ended; the list goes back to its top at each. */ + val refreshesEnded: Int = 0, ) { /** Whether feeds are being checked now. */ val refreshing: Boolean get() = refreshCount > 0 + + /** Whether an entry shows as read: it is, or it is open and will be marked read when it is left. */ + fun shownRead(entry: Entry): Boolean = entry.read || (entry.id == currentId && entry.id !in keptUnread) } +/** A list held still while a refresh runs. */ +data class FrozenList( + /** Key of the view it belongs to; see [viewKey]. */ + val key: String, + /** Ids of its articles, in order. */ + val ids: List, +) + /** * When a refresh starts to help: once the first feed is overdue (see * [overdueAt]), or [Long.MAX_VALUE] while nothing is overdue or feeds are @@ -155,6 +176,8 @@ data class ReaderView( val starredCount: Int, /** Group names in use, sorted. */ val folders: List, + /** Whether mark all as read has anything to mark in the current view. */ + val canMarkAllRead: Boolean, ) /** Most unread first, then working before failing, then by name. */ @@ -252,6 +275,13 @@ fun unreadCounts(entries: List, feeds: List, settings: Map, entry: Entry): Boolean = + !entry.read && (entry.id in visible || countedInView(state, entry)) + /** Whether the current view's unread count includes an entry, read or not. */ fun countedInView(state: ReaderState, entry: Entry): Boolean { val own = settingsKeep(state.settings["feed:${entry.feedId}"], entry) @@ -388,9 +418,15 @@ fun deriveView(state: ReaderState): ReaderView { val kept = baseEntries.filter { entry -> keeps(state, viewSettings, entry) }.map { entry -> entry.id }.toSet() val unreadOnly = state.unreadOnly && selection != Selection.Starred val held = state.heldEntries - val visible = ordered.filter { entry -> - entry.id == state.currentId || - ((held == null || entry.id in held) && entry.id in kept && !(unreadOnly && entry.read && entry.id !in state.pinned)) + val frozen = state.frozenList + val visible = if (frozen != null && frozen.key == viewKey(selection)) { + val byId = state.entries.associateBy { entry -> entry.id } + frozen.ids.mapNotNull { id -> byId[id] } + } else { + ordered.filter { entry -> + entry.id == state.currentId || + ((held == null || entry.id in held) && entry.id in kept && !(unreadOnly && entry.read && entry.id !in state.pinned)) + } } val counts = unreadCounts(state.entries, state.feeds, state.settings) val unread = counts.byFeed @@ -421,6 +457,9 @@ fun deriveView(state: ReaderState): ReaderView { unreadTotal = counts.total, starredCount = state.entries.count { entry -> entry.starred }, folders = foldersOf(state.feeds), + canMarkAllRead = visible.mapTo(HashSet()) { entry -> entry.id }.let { shown -> + baseEntries.any { entry -> markedByMarkAllRead(state, shown, entry) } + }, ) } diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/Srcset.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/Srcset.kt new file mode 100644 index 0000000..747d5ca --- /dev/null +++ b/android/core/src/main/kotlin/cc/hafa/reader/core/Srcset.kt @@ -0,0 +1,108 @@ +package cc.hafa.reader.core + +private val WIDTH_DESCRIPTOR = Regex("^(\\d+)w$", RegexOption.IGNORE_CASE) +private val DENSITY_DESCRIPTOR = Regex("^(\\d+(?:\\.\\d+)?|\\.\\d+)x$", RegexOption.IGNORE_CASE) +private val WHITESPACE = Regex("\\s+") + +/** One picture a `srcset` offers: its address and its width or density descriptor. */ +data class SrcsetCandidate( + /** Picture address, which may hold commas (as `data:` addresses do). */ + val address: String, + /** Width (`640w`) or density (`2x`); empty when none is given. */ + val descriptor: String, +) + +/** + * The candidates of a `srcset`, read as browsers do: an address is a run of + * non-space characters (a trailing comma ends it), then an optional + * descriptor up to the next comma. + */ +fun srcsetCandidates(srcset: String): List { + val found = mutableListOf() + var index = 0 + while (index < srcset.length) { + while (index < srcset.length && (srcset[index].isWhitespace() || srcset[index] == ',')) { + index += 1 + } + val start = index + while (index < srcset.length && !srcset[index].isWhitespace()) { + index += 1 + } + var address = srcset.substring(start, index) + var descriptor = "" + if (address.endsWith(",")) { + address = address.trimEnd(',') + } else { + val end = srcset.indexOf(',', index).let { comma -> if (comma == -1) srcset.length else comma } + descriptor = srcset.substring(index, end).trim() + index = end + } + if (address.isNotEmpty()) { + found.add(SrcsetCandidate(address, descriptor)) + } + } + return found +} + +/** A `srcset` written back from its candidates. */ +fun joinSrcset(candidates: List): String = + candidates.joinToString(", ") { candidate -> listOf(candidate.address, candidate.descriptor).filter(String::isNotEmpty).joinToString(" ") } + +/** The addresses a `srcset` lists. */ +fun srcsetAddresses(srcset: String): List = srcsetCandidates(srcset).map { candidate -> candidate.address } + +/** The largest picture a `srcset` offers, by its width, then its density; empty if none. */ +fun bestSrcsetCandidate(srcset: String): String = largestCandidate(srcsetCandidates(srcset)) + +/** + * The largest of [candidates] that loads from the web, by width, then + * density, with [declaredWidth] (0 when unknown) turning densities into + * widths; empty if none. + */ +fun largestCandidate(candidates: List, declaredWidth: Double = 0.0): String = + largestOf(candidates, declaredWidth)?.address ?: "" + +/** The candidate [largestCandidate] picks; null if none loads from the web. */ +fun largestOf(candidates: List, declaredWidth: Double = 0.0): SrcsetCandidate? { + var chosen: SrcsetCandidate? = null + var chosenSize = CandidateSize(0.0, 0.0) + for (candidate in candidates.filter { candidate -> !candidate.address.startsWith("data:") }) { + val size = candidateSize(candidate.descriptor, declaredWidth) + if (chosen == null || isLarger(size, chosenSize)) { + chosen = candidate + chosenSize = size + } + } + return chosen +} + +/** How large a picture is: its width in pixels when known, and its pixel density. */ +data class CandidateSize( + /** Width in pixels; 0 when neither a width nor a density with a known width gives it. */ + val width: Double, + /** Pixel density, 1 unless an `x` descriptor says otherwise. */ + val density: Double, +) + +/** + * The size a candidate's [descriptors] give, with [declaredWidth] (the + * image's `width` attribute, 0 when unknown) turning a density into a width. + */ +fun candidateSize(descriptors: String, declaredWidth: Double): CandidateSize { + var width = 0.0 + var density = 1.0 + for (token in descriptors.split(WHITESPACE)) { + val widthMatch = WIDTH_DESCRIPTOR.find(token) + val densityMatch = DENSITY_DESCRIPTOR.find(token) + if (widthMatch != null) { + width = widthMatch.groupValues[1].toDouble() + } else if (densityMatch != null) { + density = densityMatch.groupValues[1].toDouble() + } + } + return CandidateSize(if (width != 0.0) width else density * declaredWidth, density) +} + +/** Whether a picture of size [next] is larger than one of size [current]: by width, then by density. */ +fun isLarger(next: CandidateSize, current: CandidateSize): Boolean = + if (next.width == current.width) next.density > current.density else next.width > current.width diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/TopicRows.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/TopicRows.kt index 1207383..8b4a598 100644 --- a/android/core/src/main/kotlin/cc/hafa/reader/core/TopicRows.kt +++ b/android/core/src/main/kotlin/cc/hafa/reader/core/TopicRows.kt @@ -3,7 +3,7 @@ package cc.hafa.reader.core /** The most rows of topics shown before the topics scroll sideways. */ const val TOPIC_ROWS = 3 -/** Where one item goes in [wrappedRows]: its [row], counted from zero, and its [left] edge. */ +/** Where one item goes in [wrappedRows] or [packedRows]: its [row], counted from zero, and its [left] edge. */ data class RowPosition(val row: Int, val left: Int) /** Where items of these widths go when laid left to right and wrapped at [available], with [gap] between neighbors. */ @@ -24,5 +24,20 @@ fun wrappedRows(widths: List, available: Int, gap: Int): List return positions } +/** + * Where items of these widths go in [rows] rows that grow sideways without limit: each in turn goes at the end of the + * row that is shortest so far (the upper one on a tie), [gap] after the item before it. + */ +fun packedRows(widths: List, rows: Int, gap: Int): List { + // Where each row ends, counting a gap before its first item. + val ends = IntArray(rows) { -gap } + return widths.map { width -> + val row = ends.indices.minBy { candidate -> ends[candidate] } + val left = ends[row] + gap + ends[row] = left + width + RowPosition(row, left) + } +} + /** The number of rows [positions] fill. */ -fun rowCount(positions: List): Int = positions.lastOrNull()?.row?.plus(1) ?: 0 +fun rowCount(positions: List): Int = positions.maxOfOrNull { position -> position.row + 1 } ?: 0 diff --git a/android/core/src/test/kotlin/cc/hafa/reader/core/OrderAndPicturesTest.kt b/android/core/src/test/kotlin/cc/hafa/reader/core/OrderAndPicturesTest.kt index da640ef..9e4f3f5 100644 --- a/android/core/src/test/kotlin/cc/hafa/reader/core/OrderAndPicturesTest.kt +++ b/android/core/src/test/kotlin/cc/hafa/reader/core/OrderAndPicturesTest.kt @@ -1,5 +1,6 @@ package cc.hafa.reader.core +import org.jsoup.Jsoup import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -44,10 +45,63 @@ class OrderAndPicturesTest { assertFalse(showsLeadImage("", "

text

")) } + @Test + fun `a lead image stored as the body picture's largest candidate is hidden`() { + val body = sanitize( + """""", + "https://pictures.example.com/wider-body", + ) + assertFalse(showsLeadImage("https://pictures.example.com/three-1024.jpg", body)) + } + @Test fun `the largest srcset picture is chosen`() { assertEquals("https://i.example/l.png", bestSrcsetCandidate("https://i.example/s.png 320w, https://i.example/l.png 1280w, https://i.example/m.png 640w")) assertEquals("b.png", bestSrcsetCandidate("a.png 1x, b.png 2x")) assertEquals("", bestSrcsetCandidate("")) } + + @Test + fun `a picture's sources that fit every screen are weighed with its srcset`() { + val body = """ + + + + + + + + + """.trimIndent() + val images = Jsoup.parseBodyFragment(body).select("img") + assertEquals( + listOf("https://i.example/big.webp", "https://i.example/double.jpg", "https://i.example/only.jpg"), + images.map(::pictureAddress), + ) + } + + @Test + fun `a picture is sized by its declared width, else by its density`() { + val body = """ + + + + + + + """.trimIndent() + val choices = Jsoup.parseBodyFragment(body).select("img").map(::pictureChoice) + assertEquals( + listOf( + PictureChoice("https://i.example/a@2x.jpg", 300.0, 2.0), + PictureChoice("https://i.example/800px.jpg", 100.0, 1.0), + PictureChoice("https://i.example/b@2x.jpg", 0.0, 2.0), + PictureChoice("https://i.example/c.jpg", 0.0, 1.0), + PictureChoice("https://i.example/d.jpg", 0.0, 1.0), + PictureChoice("https://i.example/e.jpg", 0.0, 1.0), + ), + choices, + ) + assertEquals(listOf(300.0, 100.0, 300.0, 600.0, 600.0, 600.0), choices.map { choice -> choice.displayWidth(600.0) }) + } } diff --git a/android/core/src/test/kotlin/cc/hafa/reader/core/ReaderModelTest.kt b/android/core/src/test/kotlin/cc/hafa/reader/core/ReaderModelTest.kt index eb80d6f..6d983b0 100644 --- a/android/core/src/test/kotlin/cc/hafa/reader/core/ReaderModelTest.kt +++ b/android/core/src/test/kotlin/cc/hafa/reader/core/ReaderModelTest.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import cc.hafa.reader.core.sync.RemoteChange +import cc.hafa.reader.core.sync.SavedSettings import cc.hafa.reader.core.sync.SyncReport import cc.hafa.reader.core.sync.SyncStatus import kotlinx.coroutines.test.StandardTestDispatcher @@ -232,16 +233,39 @@ class ReaderModelTest { } @Test - fun `marking the open article unread sticks when it is left`() = runTest { + fun `an open unread article shows as read, and taking that back keeps it unread on leaving`() = runTest { val model = model() - model.addWithItems("https://a.example/feed", "one") - val id = model.view().visible.first().id - model.open(id) - model.toggleRead() + model.addWithItems("https://a.example/feed", "one", "two") + val (first, second) = model.view().visible + model.open(first.id) + assertTrue(model.state.value.shownRead(model.view().current!!)) model.toggleRead() assertFalse(model.view().current!!.read) + assertFalse(model.state.value.shownRead(model.view().current!!)) + model.open(second.id) + assertFalse(model.state.value.entries.first { entry -> entry.id == first.id }.read) + assertTrue(model.state.value.shownRead(model.view().current!!), "a newly opened article waits again") model.close() - assertFalse(model.state.value.entries.first().read) + assertTrue(model.state.value.entries.first { entry -> entry.id == second.id }.read) + assertFalse(model.state.value.shownRead(model.state.value.entries.first { entry -> entry.id == first.id })) + } + + @Test + fun `marking a kept-unread article read does it at once, and marking a read one unread sticks on leaving`() = runTest { + val model = model() + model.addWithItems("https://a.example/feed", "one", "two") + val (first, second) = model.view().visible + model.open(first.id) + model.toggleRead() + model.toggleRead() + assertTrue(model.view().current!!.read) + model.toggleReadFor(first.id) + assertFalse(model.view().current!!.read) + model.open(second.id) + assertFalse(model.state.value.entries.first { entry -> entry.id == first.id }.read) + model.open(first.id) + assertTrue(model.state.value.entries.first { entry -> entry.id == second.id }.read) + assertEquals(listOf("one", "two"), model.titles()) } @Test @@ -266,6 +290,8 @@ class ReaderModelTest { model.setUnreadOnly(false) model.open(model.view().visible[1].id) val open = model.state.value.currentId + // the first tap only stops it being marked read on leaving + model.toggleRead() model.toggleRead() model.setUnreadOnly(true) assertEquals("", model.state.value.currentId) @@ -283,6 +309,19 @@ class ReaderModelTest { assertEquals(open, model.state.value.currentId) } + @Test + fun `mark all read is off once the view has nothing unread, or nothing at all`() = runTest { + val model = model() + assertFalse(model.view().canMarkAllRead) + model.addWithItems("https://a.example/feed", "one", "two") + assertTrue(model.view().canMarkAllRead) + model.markAllRead() + assertFalse(model.view().canMarkAllRead) + model.setUnreadOnly(false) + assertEquals(2, model.view().visible.size) + assertFalse(model.view().canMarkAllRead) + } + @Test fun `refresh adds new articles, keeps checks conditional and clears kept read ones`() = runTest { val model = model() @@ -375,13 +414,62 @@ class ReaderModelTest { } @Test - fun `a check that breaks releases its feed without saving anything`() = runTest { + fun `a fetch that breaks counts as no answer, so the feed backs off`() = runTest { val model = model() model.addWithItems("https://a.example/feed", "one") - val before = model.state.value.feeds.single().schedule fetcher.failure = IllegalStateException("broken") model.refresh() - assertEquals(before, model.state.value.feeds.single().schedule) + val schedule = model.state.value.feeds.single().schedule + assertEquals(1, schedule.failures) + assertEquals(FeedProblem.NoAnswer.code, schedule.problem) + assertTrue(schedule.nextFetch > now) + } + + @Test + fun `a refresh holds the list still, then takes in what arrived and goes back to the top`() = runTest { + val model = model() + model.addWithItems("https://a.example/feed", "one") + val feed = model.state.value.feeds.single() + model.select(Selection.All) + model.settle() + fetcher.answers["https://a.example/feed"] = FetchOutcome(status = 304) + val gate = CompletableDeferred() + fetcher.gate = gate + val refreshing = launch { model.refresh() } + runCurrent() + model.syncHost.apply( + RemoteChange( + entries = listOf(article("later", feed.id, now)), + settings = mapOf("all" to SavedSettings(DEFAULT_SETTINGS.copy(hideWords = listOf("one")), now)), + ), + ) + assertEquals(listOf("one"), model.titles()) + gate.complete(Unit) + refreshing.join() + assertEquals(listOf("Story later"), model.titles()) + assertEquals(1, model.state.value.refreshesEnded) + assertEquals(null, model.state.value.frozenList) + } + + @Test + fun `an empty list stays empty while a refresh runs`() = runTest { + val model = model() + model.addWithItems("https://a.example/feed", "one") + val feed = model.state.value.feeds.single() + model.markAllRead() + model.select(Selection.Starred) + model.select(Selection.All) + model.settle() + fetcher.answers["https://a.example/feed"] = FetchOutcome(status = 304) + val gate = CompletableDeferred() + fetcher.gate = gate + val refreshing = launch { model.refresh() } + runCurrent() + model.syncHost.apply(RemoteChange(entries = listOf(article("later", feed.id, now)))) + assertEquals(emptyList(), model.titles()) + gate.complete(Unit) + refreshing.join() + assertEquals(listOf("Story later"), model.titles()) } @Test @@ -454,9 +542,10 @@ class ReaderModelTest { fun `marking the open article unread from the list sticks when it is left`() = runTest { val model = model() model.addWithItems("https://a.example/feed", "one") + model.setUnreadOnly(false) val id = model.view().visible.single().id - model.open(id) model.toggleReadFor(id) + model.open(id) model.toggleReadFor(id) model.close() assertFalse(model.state.value.entries.single().read) @@ -583,7 +672,7 @@ class ReaderModelTest { } @Test - fun `opening checks only due feeds in the background without showing a refresh`() = runTest { + fun `opening checks every feed, due ones first, waiting no longer than its limit`() = runTest { val first = model() first.addWithItems("https://a.example/feed", "one") now += MAX_INTERVAL_MS @@ -603,19 +692,52 @@ class ReaderModelTest { minRefreshMillis = 0, onBackgroundWork = { backgroundCalls += 1 }, ) + val started = testScheduler.currentTime model.openChecks() + assertEquals(OPENING_WAIT_MS, testScheduler.currentTime - started) runCurrent() assertTrue(model.state.value.loaded) assertFalse(model.state.value.refreshing) assertEquals(1, backgroundCalls) - assertEquals(listOf("https://a.example/feed"), fetcher.requests) + assertEquals(listOf("https://a.example/feed", "https://b.example/feed"), fetcher.requests) val rank = model.state.value.frozenRank gate.complete(Unit) model.awaitChecks() - assertEquals(listOf("https://a.example/feed"), fetcher.requests) + assertEquals(listOf("https://a.example/feed", "https://b.example/feed"), fetcher.requests) assertTrue(rank === model.state.value.frozenRank) } + @Test + fun `opening ends its wait once the due feeds are saved, and at once offline`() = runTest { + val first = model() + first.addWithItems("https://a.example/feed", "one") + now += MAX_INTERVAL_MS + first.addWithItems("https://b.example/feed", "two") + first.awaitWrites() + val gate = CompletableDeferred() + fetcher.gates["https://b.example/feed"] = gate + var online = true + fun opened() = ReaderModel( + store, + preferences, + fetcher, + backgroundScope, + clock = { now }, + workDispatcher = StandardTestDispatcher(testScheduler), + minRefreshMillis = 0, + online = { online }, + ) + val started = testScheduler.currentTime + opened().openChecks() + assertTrue(testScheduler.currentTime - started < OPENING_WAIT_MS) + fetcher.gates["https://a.example/feed"] = CompletableDeferred() + online = false + val offline = testScheduler.currentTime + opened().openChecks() + assertEquals(offline, testScheduler.currentTime) + gate.complete(Unit) + } + @Test fun `the sidebar order holds until the next refresh`() = runTest { val model = model() diff --git a/android/core/src/test/kotlin/cc/hafa/reader/core/TopicRowsTest.kt b/android/core/src/test/kotlin/cc/hafa/reader/core/TopicRowsTest.kt index e50a6cd..4c22f47 100644 --- a/android/core/src/test/kotlin/cc/hafa/reader/core/TopicRowsTest.kt +++ b/android/core/src/test/kotlin/cc/hafa/reader/core/TopicRowsTest.kt @@ -27,4 +27,22 @@ class TopicRowsTest { wrappedRows(listOf(150, 20, 20), 100, 8), ) } + + @Test + fun `packed items each go at the end of the shortest row, the upper on a tie`() { + assertEquals(emptyList(), packedRows(emptyList(), 3, 8)) + val packed = packedRows(listOf(50, 30, 30, 20, 10, 40), 3, 8) + assertEquals( + listOf( + RowPosition(0, 0), + RowPosition(1, 0), + RowPosition(2, 0), + RowPosition(1, 38), + RowPosition(2, 38), + RowPosition(2, 56), + ), + packed, + ) + assertEquals(3, rowCount(packed)) + } } diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index da45396..3590243 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -188,6 +188,53 @@ test("checkDue queues nothing for a feed that is not due", async () => { expect(page.status[0]?.nextCheck).toBeGreaterThan(Date.now()); }); +test("checkDue for every feed checks the due ones first and answers once they are saved", async () => { + const answers: ((response: Response) => void)[] = []; + respond = (): Promise => + new Promise((resolve) => { + answers.push(resolve); + }); + const otherUrl = "https://other.example/rss"; + await ask({ + type: "track", + feeds: [ + { + id: "checked", + url: otherUrl, + empty: true, + first: { + status: 200, + url: otherUrl, + text: RSS, + etag: "", + lastModified: "", + hint: 0, + insecure: false, + }, + }, + { id: "due", url: FEED_URL, empty: true }, + ], + }); + let answered = false; + const reply = ask({ type: "checkDue", all: true }).then((response) => { + answered = true; + return response; + }); + await settle(); + expect(fetched).toEqual([FEED_URL, otherUrl]); + expect(answered).toBe(false); + + answers[0]?.(new Response(RSS, { status: 200 })); + expect(await reply).toEqual({ type: "queued" }); + const page = await drained(); + expect(page.pulled.map((pulled) => pulled.feedId)).toEqual(["due"]); + expect(page.checking).toBe(true); + + answers[1]?.(new Response(null, { status: 304 })); + await settle(); + expect((await drained()).checking).toBe(false); +}); + test("a malformed first answer leaves the feed due instead of failing the track", async () => { expect( await ask({ diff --git a/extension/src/background.ts b/extension/src/background.ts index 2552beb..e208957 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -361,20 +361,49 @@ function enqueue( }); } -/** Queue background checks of the feeds whose schedule says they are due. */ -function queueDue(): Promise<{ check: CheckHandle; request: CheckRequest }[]> { +/** + * Queue checks, at their scheduled odds of news, of the tracked feeds + * `include` picks, those `foreground` picks waited for by the caller. + */ +function queueScheduled( + include: (feed: ScheduledFeed, now: number) => boolean, + foreground: (feed: ScheduledFeed, now: number) => boolean, +): Promise<{ check: CheckHandle; request: CheckRequest }[]> { return enqueue((tracked, now) => tracked - .filter((feed) => feed.nextFetch <= now) + .filter((feed) => include(feed, now)) .map((feed) => ({ feed, probability: refreshProbability(feed, now), - foreground: false, + foreground: foreground(feed, now), timeoutMs: FETCH_TIMEOUT_MS, })), ); } +function isDue(feed: ScheduledFeed, now: number): boolean { + return feed.nextFetch <= now; +} + +/** Queue background checks of the feeds whose schedule says they are due. */ +function queueDue(): Promise<{ check: CheckHandle; request: CheckRequest }[]> { + return queueScheduled(isDue, () => false); +} + +/** + * Queue a check of every tracked feed, as the web app asks when it opens: the + * due ones first and waited for, the rest after in the background, likelier + * ones first. Resolves once the due ones are saved. + */ +async function checkAll(): Promise { + const checks = await queueScheduled(() => true, isDue); + await Promise.all( + checks + .filter(({ request }) => request.foreground) + .map(({ check }) => check.stored), + ); +} + /** Check the feeds that are due and resolve once they are saved. */ async function checkDue(): Promise { const checks = await queueDue(); @@ -500,7 +529,11 @@ async function handle(request: ExtensionRequest): Promise { await refresh(request.feedIds); response = { type: "refreshed" }; } else if (request.type === "checkDue") { - await queueDue(); + if (request.all === true) { + await checkAll(); + } else { + await queueDue(); + } response = { type: "queued" }; } else if (request.type === "drain") { response = await drain(); diff --git a/shared/fixtures/images.xml b/shared/fixtures/images.xml new file mode 100644 index 0000000..8190ee9 --- /dev/null +++ b/shared/fixtures/images.xml @@ -0,0 +1,62 @@ + + + + Picture Desk + https://pictures.example.com/ + + wider-content + Two sizes of one picture + https://pictures.example.com/wider-content + + + + + content-before-thumbnail + A thumbnail and the picture + https://pictures.example.com/content-before-thumbnail + + + + + wider-body + A small thumbnail and a large body picture + https://pictures.example.com/wider-body + +

]]>
+
+ + density + A picture offered at twice the density + https://pictures.example.com/density + ]]> + + + upgraded-srcset + Candidates that cannot load are skipped + https://pictures.example.com/upgraded-srcset + ]]> + + + unknown-width-kept + A picture of unknown width stays + https://pictures.example.com/unknown-width-kept + + ]]> + + + group-thumbnail + A larger thumbnail in a group + https://pictures.example.com/group-thumbnail + + + + + + + srcset-only + A body picture with only a srcset + https://pictures.example.com/srcset-only + ]]> + +
+
diff --git a/shared/fixtures/parse.json b/shared/fixtures/parse.json index 160ee1d..9a1c5f7 100644 --- a/shared/fixtures/parse.json +++ b/shared/fixtures/parse.json @@ -58,6 +58,107 @@ } ] } + }, + { + "input": { + "file": "images.xml", + "feedUrl": "https://pictures.example.com/feed.xml" + }, + "output": { + "title": "Picture Desk", + "siteUrl": "https://pictures.example.com/", + "iconUrl": "", + "entries": [ + { + "guid": "wider-content", + "title": "Two sizes of one picture", + "url": "https://pictures.example.com/wider-content", + "published": 0, + "author": "", + "imageUrl": "https://i.example.com/one-460.jpg", + "summary": "", + "categories": [], + "html": "" + }, + { + "guid": "content-before-thumbnail", + "title": "A thumbnail and the picture", + "url": "https://pictures.example.com/content-before-thumbnail", + "published": 0, + "author": "", + "imageUrl": "https://i.example.com/two.jpg", + "summary": "", + "categories": [], + "html": "" + }, + { + "guid": "wider-body", + "title": "A small thumbnail and a large body picture", + "url": "https://pictures.example.com/wider-body", + "published": 0, + "author": "", + "imageUrl": "https://pictures.example.com/three-1024.jpg", + "summary": "", + "categories": [], + "html": "

" + }, + { + "guid": "density", + "title": "A picture offered at twice the density", + "url": "https://pictures.example.com/density", + "published": 0, + "author": "", + "imageUrl": "https://pictures.example.com/four@2x.jpg", + "summary": "", + "categories": [], + "html": "" + }, + { + "guid": "upgraded-srcset", + "title": "Candidates that cannot load are skipped", + "url": "https://pictures.example.com/upgraded-srcset", + "published": 0, + "author": "", + "imageUrl": "https://cdn.example.com/five.jpg?w=800&q=80", + "summary": "", + "categories": [], + "html": "" + }, + { + "guid": "unknown-width-kept", + "title": "A picture of unknown width stays", + "url": "https://pictures.example.com/unknown-width-kept", + "published": 0, + "author": "", + "imageUrl": "https://i.example.com/six.jpg", + "summary": "", + "categories": [], + "html": "" + }, + { + "guid": "group-thumbnail", + "title": "A larger thumbnail in a group", + "url": "https://pictures.example.com/group-thumbnail", + "published": 0, + "author": "", + "imageUrl": "https://i.example.com/seven-480.jpg", + "summary": "", + "categories": [], + "html": "" + }, + { + "guid": "srcset-only", + "title": "A body picture with only a srcset", + "url": "https://pictures.example.com/srcset-only", + "published": 0, + "author": "", + "imageUrl": "https://pictures.example.com/eight-1600.jpg", + "summary": "", + "categories": [], + "html": "" + } + ] + } } ] } diff --git a/shared/src/feed.ts b/shared/src/feed.ts index 98d8f73..4c17135 100644 --- a/shared/src/feed.ts +++ b/shared/src/feed.ts @@ -1,5 +1,11 @@ import { XMLParser } from "fast-xml-parser"; import { upgradedAddress } from "./address.ts"; +import { + type CandidateSize, + candidateSize, + isLarger, + srcsetCandidates, +} from "./srcset.ts"; /** One article from a feed. */ export interface FeedEntry { @@ -221,11 +227,99 @@ function firstDate(node: Node, fields: string[]): number { return found; } -const IMAGE_PATTERN = /]*?\bsrc\s*=\s*["'](?[^"']+)["']/iu; +const IMAGE_TAG = /]*>/iu; +// an attribute of an image tag the lead image is chosen from, its value quoted or bare +const IMAGE_ATTRIBUTE = + /[\t\n\f\r ](?srcset|src|width)[\t\n\f\r ]*=[\t\n\f\r ]*(?:"(?[^"]*)"|'(?[^']*)'|(?[^\t\n\f\r "'>]+))/giu; +const PIXELS = /^\d+$/u; -function firstImage(html: string, base: string): string { - const source = IMAGE_PATTERN.exec(html)?.groups?.src; - return source ? upgradedAddress(decodeEntities(source), base) : ""; +/** A picture that could lead an article, with its width in pixels when the feed says (else 0). */ +interface LeadCandidate { + address: string; + size: CandidateSize; +} + +const NO_CANDIDATE: LeadCandidate = { + address: "", + size: { width: 0, density: 1 }, +}; + +function pixels(value: string): number { + const trimmed = value.trim(); + return PIXELS.test(trimmed) ? Number(trimmed) : 0; +} + +/** + * The lead picture among candidates in order of preference: the first, given + * up for a later one only when both widths are known and the later is wider. + */ +function widestLead(candidates: LeadCandidate[]): LeadCandidate { + let chosen = NO_CANDIDATE; + for (const candidate of candidates) { + if ( + candidate.address !== "" && + (chosen.address === "" || + (chosen.size.width > 0 && candidate.size.width > chosen.size.width)) + ) { + chosen = candidate; + } + } + return chosen; +} + +/** The `src`, `srcset` and `width` of an image tag, by lowercase name, entities decoded; the first of each wins. */ +function imageAttributes(tag: string): Map { + const attributes = new Map(); + for (const match of tag.matchAll(IMAGE_ATTRIBUTE)) { + const name = (match.groups?.["name"] ?? "").toLowerCase(); + const value = + match.groups?.["double"] ?? + match.groups?.["single"] ?? + match.groups?.["bare"] ?? + ""; + if (!attributes.has(name)) { + attributes.set(name, decodeEntities(value)); + } + } + return attributes; +} + +/** + * The first `img` of article HTML as a lead candidate: the largest of its + * `src` and `srcset` candidates (`width` turns densities into widths), the + * `src` winning ties. + */ +function firstImage(html: string, base: string): LeadCandidate { + const attributes = imageAttributes(IMAGE_TAG.exec(html)?.[0] ?? ""); + const declaredWidth = pixels(attributes.get("width") ?? ""); + const source = attributes.get("src") ?? ""; + const resolvedSource = source === "" ? "" : upgradedAddress(source, base); + let largest = + resolvedSource === "" + ? NO_CANDIDATE + : { address: resolvedSource, size: candidateSize("", declaredWidth) }; + for (const { address, descriptors } of srcsetCandidates( + attributes.get("srcset") ?? "", + )) { + const resolved = upgradedAddress(address, base); + const size = candidateSize(descriptors, declaredWidth); + if ( + resolved !== "" && + (largest.address === "" || isLarger(size, largest.size)) + ) { + largest = { address: resolved, size }; + } + } + return largest; +} + +/** The lead picture: the feed's own unless the body's first picture is known to be wider. */ +function leadImage( + feedImages: LeadCandidate[], + html: string, + base: string, +): string { + return widestLead([...feedImages, firstImage(html, base)]).address; } function atomHref(links: unknown[]): string { @@ -242,37 +336,49 @@ function mediaGroups(item: Node): Node[] { return list(item.group).filter(isNode); } -function mediaImage(item: Node, base: string): string { - let image = ""; - for (const thumbnail of list(item.thumbnail).filter(isNode)) { - const url = thumbnail["@_url"]; - if (!image && typeof url === "string") { - image = upgradedAddress(url, base); - } - } - for (const content of list(item.content).filter(isNode)) { - const url = content["@_url"]; - const kind = `${content["@_medium"] ?? ""}${content["@_type"] ?? ""}`; - if (!image && typeof url === "string" && kind.includes("image")) { - image = upgradedAddress(url, base); - } - } - for (const enclosure of list(item.enclosure).filter(isNode)) { - const url = enclosure["@_url"]; - const kind = enclosure["@_type"]; - if ( - !image && - typeof url === "string" && - typeof kind === "string" && - kind.startsWith("image/") - ) { - image = upgradedAddress(url, base); - } - } - for (const group of mediaGroups(item)) { - image ||= mediaImage(group, base); - } - return image; +/** A Media RSS picture's address and declared width. */ +function mediaCandidate(node: Node, base: string): LeadCandidate { + const url = node["@_url"]; + const width = node["@_width"]; + return { + address: typeof url === "string" ? upgradedAddress(url, base) : "", + size: { + width: typeof width === "string" ? pixels(width) : 0, + density: 1, + }, + }; +} + +/** + * An item's pictures in order of preference: Media RSS image contents and + * image enclosures (the picture itself), then thumbnails, then those of its + * Media RSS groups. + */ +function mediaImages(item: Node, base: string): LeadCandidate[] { + const contents = list(item.content) + .filter(isNode) + .filter((content) => + `${content["@_medium"] ?? ""}${content["@_type"] ?? ""}`.includes( + "image", + ), + ); + const enclosures = list(item.enclosure) + .filter(isNode) + .filter((enclosure) => { + const kind = enclosure["@_type"]; + return typeof kind === "string" && kind.startsWith("image/"); + }); + return [ + ...contents.map((content) => mediaCandidate(content, base)), + ...enclosures.map((enclosure) => ({ + ...mediaCandidate(enclosure, base), + size: { width: 0, density: 1 }, + })), + ...list(item.thumbnail) + .filter(isNode) + .map((thumbnail) => mediaCandidate(thumbnail, base)), + ...mediaGroups(item).flatMap((group) => mediaImages(group, base)), + ]; } /** @@ -333,7 +439,7 @@ function parseRssItem(item: Node, base: string): FeedEntry { url: link, published, author: displayText(item.creator) || displayText(item.author), - imageUrl: mediaImage(item, base) || firstImage(html, base), + imageUrl: leadImage(mediaImages(item, base), html, base), summary: plainText(text(item.description)) || media.summary, categories: categoryNames(list(item.category)), html, @@ -361,7 +467,7 @@ function parseAtomEntry(entry: Node, base: string): FeedEntry { url: link, published, author: author ? displayText(author.name) : "", - imageUrl: mediaImage(entry, base) || firstImage(html, base), + imageUrl: leadImage(mediaImages(entry, base), html, base), summary: plainText(text(entry.summary)) || media.summary, categories: categoryNames(list(entry.category)), html, @@ -394,8 +500,16 @@ function parseJsonFeed(source: Node, base: string): ParsedFeed { url, published, author: authors[0] ? text(authors[0].name) : "", - imageUrl: - upgradedAddress(text(item.image), base) || firstImage(html, base), + imageUrl: leadImage( + [ + { + address: upgradedAddress(text(item.image), base), + size: NO_CANDIDATE.size, + }, + ], + html, + base, + ), summary: plainText(text(item.summary)), categories: list(item.tags) .map((tag) => text(tag)) diff --git a/shared/src/protocol.ts b/shared/src/protocol.ts index d351fe4..b4e827a 100644 --- a/shared/src/protocol.ts +++ b/shared/src/protocol.ts @@ -46,7 +46,15 @@ export type ExtensionRequest = } | { type: "track"; feeds: TrackedFeed[] } | { type: "refresh"; feedIds: string[] } - | { type: "checkDue" } + | { + type: "checkDue"; + /** + * Check every feed, not just the due ones: the due ones first, answered + * once they are saved, and the rest after in the background. Older + * extensions ignore it and check only the due ones, answering at once. + */ + all?: boolean; + } | { type: "drain" } | { type: "ack"; upTo: number }; diff --git a/shared/src/srcset.test.ts b/shared/src/srcset.test.ts new file mode 100644 index 0000000..c44299c --- /dev/null +++ b/shared/src/srcset.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test"; +import { candidateSize, isLarger, srcsetCandidates } from "./srcset.ts"; + +test("candidates are read as the spec reads them", () => { + expect(srcsetCandidates(" a.png, b.png 2x,c.png 3x , d.png,")).toEqual([ + { address: "a.png", descriptors: "" }, + { address: "b.png", descriptors: "2x" }, + { address: "c.png", descriptors: "3x" }, + { address: "d.png", descriptors: "" }, + ]); +}); + +test("a candidate's size comes from its width, or its density times the declared width", () => { + expect(candidateSize("640w", 0)).toEqual({ width: 640, density: 1 }); + expect(candidateSize("2x", 300)).toEqual({ width: 600, density: 2 }); + expect(candidateSize("1.5x", 0)).toEqual({ width: 0, density: 1.5 }); + expect(candidateSize("", 200)).toEqual({ width: 200, density: 1 }); + expect(candidateSize("wide", 0)).toEqual({ width: 0, density: 1 }); +}); + +test("a known width beats an unknown one, and density breaks ties", () => { + expect(isLarger({ width: 640, density: 1 }, { width: 0, density: 3 })).toBe( + true, + ); + expect(isLarger({ width: 0, density: 2 }, { width: 0, density: 1 })).toBe( + true, + ); + expect(isLarger({ width: 300, density: 1 }, { width: 300, density: 1 })).toBe( + false, + ); +}); diff --git a/shared/src/srcset.ts b/shared/src/srcset.ts new file mode 100644 index 0000000..45a95df --- /dev/null +++ b/shared/src/srcset.ts @@ -0,0 +1,84 @@ +// a srcset candidate: its address after any separators, then descriptors up to a comma +const CANDIDATE_START = /[\s,]*(?
\S+)/uy; +const DESCRIPTORS = /(?[^,]*),?/uy; +const TRAILING_COMMAS = /,+$/u; +const WIDTH_DESCRIPTOR = /^(?\d+)w$/iu; +const DENSITY_DESCRIPTOR = /^(?\d+(?:\.\d+)?|\.\d+)x$/iu; +const WHITESPACE = /\s+/u; + +/** One picture offered by a `srcset`. */ +export interface SrcsetCandidate { + /** The picture's address. */ + address: string; + /** Its width or density, such as `424w` or `2x`; empty when not given. */ + descriptors: string; +} + +/** + * The candidates of a `srcset`, read as the HTML spec does: an address runs + * to the next whitespace, so it may hold commas (as CDN addresses and data + * addresses do), and its descriptors run to the next comma. + */ +export function srcsetCandidates(srcset: string): SrcsetCandidate[] { + const candidates: SrcsetCandidate[] = []; + let position = 0; + CANDIDATE_START.lastIndex = position; + let start = CANDIDATE_START.exec(srcset); + while (start !== null) { + let address = start.groups?.["address"] ?? ""; + let descriptors = ""; + position = CANDIDATE_START.lastIndex; + if (address.endsWith(",")) { + address = address.replace(TRAILING_COMMAS, ""); + } else { + DESCRIPTORS.lastIndex = position; + descriptors = ( + DESCRIPTORS.exec(srcset)?.groups?.["descriptors"] ?? "" + ).trim(); + position = DESCRIPTORS.lastIndex; + } + if (address !== "") { + candidates.push({ address, descriptors }); + } + CANDIDATE_START.lastIndex = position; + start = CANDIDATE_START.exec(srcset); + } + return candidates; +} + +/** How large a candidate is: its width in pixels when known (else 0), and its pixel density. */ +export interface CandidateSize { + /** Width in pixels; 0 when neither a width nor a density with a known width gives it. */ + width: number; + /** Pixel density, 1 unless an `x` descriptor says otherwise. */ + density: number; +} + +/** + * The size a candidate's descriptors give, with `declaredWidth` (the image's + * `width` attribute, 0 when unknown) turning a density into a width. + */ +export function candidateSize( + descriptors: string, + declaredWidth: number, +): CandidateSize { + let width = 0; + let density = 1; + for (const token of descriptors.split(WHITESPACE)) { + const widthMatch = WIDTH_DESCRIPTOR.exec(token)?.groups?.["width"]; + const densityMatch = DENSITY_DESCRIPTOR.exec(token)?.groups?.["density"]; + if (widthMatch !== undefined) { + width = Number(widthMatch); + } else if (densityMatch !== undefined) { + density = Number(densityMatch); + } + } + return { width: width || density * declaredWidth, density }; +} + +/** Whether a picture of size `next` is larger than one of size `current`: by width, then by density. */ +export function isLarger(next: CandidateSize, current: CandidateSize): boolean { + return next.width === current.width + ? next.density > current.density + : next.width > current.width; +} diff --git a/web/src/app.css b/web/src/app.css index 6b937c0..317a5a5 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -52,6 +52,9 @@ --color-danger: var(--danger); /* the account dot while syncing */ --animate-dot: dot-pulse 1.4s ease-in-out infinite; + /* on a phone, a screen slides in over the list, and the list comes back up under it, as on Android */ + --animate-screen-in: screen-in 300ms cubic-bezier(0.4, 0, 0.2, 1); + --animate-list-in: list-in 300ms cubic-bezier(0.4, 0, 0.2, 1); --color-faded: var(--read); --font-sans: "Public Sans Variable", ui-sans-serif, system-ui, sans-serif; --font-reading: var(--reading-font); @@ -127,6 +130,19 @@ input, } } +@keyframes screen-in { + from { + transform: translateX(100%); + } +} + +@keyframes list-in { + from { + opacity: 0.4; + transform: scale(0.95); + } +} + @keyframes dot-pulse { 50% { opacity: 0.45; diff --git a/web/src/lib/components/article.svelte b/web/src/lib/components/article.svelte index 3977026..1396c97 100644 --- a/web/src/lib/components/article.svelte +++ b/web/src/lib/components/article.svelte @@ -6,11 +6,24 @@ import Star from "@lucide/svelte/icons/star"; import Undo2 from "@lucide/svelte/icons/undo-2"; import { copy } from "#lib/copy.ts"; - import { type Axis, articleSwipe, touchAxis } from "#lib/gestures.ts"; + import { + ARTICLE_PULL_DISTANCE, + type Axis, + articleSwipe, + closesByPull, + pullProgress, + pullStretch, + pullTravel, + swipesPastEnd, + tick, + touchAxis, + } from "#lib/gestures.ts"; + import { isNarrow } from "#lib/layout.ts"; import { logFailure, type Reader } from "#lib/reader.svelte.ts"; import { mentionsUrl } from "#lib/sanitize.ts"; import { articleTopics } from "$shared/filter.ts"; import ChipRows from "./chip-rows.svelte"; + import PullMarker from "./pull-marker.svelte"; import TopicChip from "./topic-chip.svelte"; const { @@ -31,6 +44,8 @@ const END_RESISTANCE = 0.25; const entry = $derived(reader.current); + // an unread article shows as read while open, since leaving it marks it read + const shownRead = $derived(entry ? reader.shownRead(entry) : false); const topics = $derived( entry ? articleTopics(entry.categories, reader.topicOrder) : [], ); @@ -39,6 +54,30 @@ entry.imageUrl !== "" && !mentionsUrl(reader.body, entry.imageUrl), ); + // the lead picture's own width once loaded, so a small picture is not stretched + let leadSize = $state({ address: "", width: 0 }); + const leadMaxWidth = $derived( + leadSize.address !== "" && + leadSize.address === entry?.imageUrl && + leadSize.width > 0 + ? `${leadSize.width}px` + : undefined, + ); + + function measureLead(image: HTMLImageElement): () => void { + function measure(): void { + leadSize = { + address: image.getAttribute("src") ?? "", + width: image.naturalWidth, + }; + } + // a cached picture may have loaded before this runs + if (image.complete) { + measure(); + } + image.addEventListener("load", measure); + return () => image.removeEventListener("load", measure); + } const byline = $derived( [ reader.feeds.find((feed) => feed.id === entry?.feedId)?.title ?? "", @@ -60,18 +99,46 @@ let scroller: HTMLDivElement | undefined = $state(); let startX = 0; let startY = 0; + // whether the touch began where the page leaves sideways swipes to what is under it let ignoreSwipe = false; + // whether, on a phone, the touch began alone with the article at its top or its end + let startedAtTop = false; + let startedAtEnd = false; // which way the current touch is going, once it is clear let axis: Axis = "none"; // how far the article is moved sideways, in pixels, and whether it is sliding on its own let offset = $state(0); let sliding = $state(false); + // how far the finger has pulled the article past its top (positive) or end (negative), and how far the article moved + let pull = $state(0); + // whether the pull is sideways, past the first (positive) or last (negative) article, rather than past the top or end + let pullSideways = $state(false); + let shift = $state(0); + // whether the browser asked for reduced motion when the touch began, so a pull leaves the article in place + let still = false; + const armed = $derived(Math.abs(pull) >= ARTICLE_PULL_DISTANCE); + // how far the pull arrow sits from the edge, centered in the gap the pull opens + const pullGap = $derived(`${Math.abs(pullSideways ? offset : shift) / 2}px`); + // the arrow points the way of the pull, and turns over once letting go closes the article + const pullTurn = $derived( + (pullSideways ? -90 : 0) + (pull > 0 === armed ? 180 : 0), + ); /** Where the open article sits in the list; -1 when the list no longer holds it. */ const index = $derived( reader.visible.findIndex((row) => row.id === reader.currentId), ); + /** Whether the article is scrolled as far up as it goes. */ + function atTop(box: HTMLElement): boolean { + return box.scrollTop <= 0; + } + + /** Whether the article is scrolled as far down as it goes. */ + function atEnd(box: HTMLElement): boolean { + return box.scrollTop + box.clientHeight >= box.scrollHeight - 1; + } + function reducedMotion(): boolean { return globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches; } @@ -122,10 +189,31 @@ async function settle(): Promise { sliding = true; offset = 0; + shift = 0; await wait(SLIDE_MS); sliding = false; } + /** + * Slide the pulled article on off the screen the way it was pulled + * (`direction` 1 for down), then close it, unless it changed or closed + * meanwhile. + */ + async function pullOff(direction: number): Promise { + const startId = reader.currentId; + pull = 0; + if (!still) { + sliding = true; + shift = direction * (scroller?.clientHeight ?? globalThis.innerHeight); + await wait(SLIDE_MS); + sliding = false; + } + if (reader.currentId === startId) { + onback(); + } + shift = 0; + } + $effect(() => { if (entry?.id && scroller) { scroller.scrollTop = 0; @@ -164,36 +252,111 @@ ignoreSwipe = target instanceof Element && target.closest("pre, .table-scroll, code, [data-scrolls]") !== null; + // a second finger, as in a pinch, or the wide layout never pulls the article closed + const pulls = event.touches.length === 1 && isNarrow(); + startedAtTop = pulls && scroller !== undefined && atTop(scroller); + startedAtEnd = pulls && scroller !== undefined && atEnd(scroller); + still = reducedMotion(); } } - /** The article follows a sideways finger, with resistance when the list no longer holds it. */ + /** Move the article with a finger `down` pixels from where it began, when that pulls it past its edge. */ + function followPull(event: TouchEvent, down: number): void { + if (event.touches.length > 1) { + startedAtTop = false; + startedAtEnd = false; + } + pull = + axis === "sideways" ? 0 : pullTravel(down, startedAtTop, startedAtEnd); + shift = still ? 0 : pullStretch(pull); + // the article moves instead of the browser's own overscroll, which would start on this first move + if (pull !== 0 && event.cancelable) { + event.preventDefault(); + } + } + + /** + * Move the article with a finger `across` pixels from where it began: with + * resistance when the list no longer holds it, and past the first or last + * article as a pull, less and less the further it goes. + */ + function followSwipe(across: number): void { + if (index === -1) { + offset = across * END_RESISTANCE; + } else if (swipesPastEnd(across, index, reader.visible.length)) { + pull = across; + offset = still ? 0 : pullStretch(across); + } else { + offset = across; + } + } + + /** + * The article follows a sideways finger, and, pulled on past the edge the + * touch began at, follows it up or down less and less the further it goes; + * a pull that crosses the point where letting go closes the article ticks. + */ function onMove(event: TouchEvent): void { const [touch] = event.touches; - if (touch && !ignoreSwipe && !sliding) { + if (touch && !sliding) { const across = touch.clientX - startX; const down = touch.clientY - startY; if (axis === "none") { axis = touchAxis(across, down); } - if (axis === "sideways") { - offset = index === -1 ? across * END_RESISTANCE : across; + const wasArmed = armed; + followPull(event, down); + pullSideways = axis === "sideways"; + if (axis === "sideways" && !ignoreSwipe) { + followSwipe(across); + } + if (armed !== wasArmed) { + tick(); } } } + // Svelte's touchmove handlers are passive, so they could not stop the browser's overscroll. + function listenMoves(box: HTMLElement): () => void { + box.addEventListener("touchmove", onMove, { passive: false }); + return () => box.removeEventListener("touchmove", onMove); + } + + /** + * A sideways swipe steps or closes; on a phone, pulling the article well + * past the top or end it was already at when the touch began closes it, as + * Back does. + */ function onEnd(event: TouchEvent): void { const [touch] = event.changedTouches; - if (touch && axis === "sideways") { + if ( + touch && + event.type === "touchend" && + axis === "vertical" && + scroller !== undefined && + closesByPull( + touch.clientY - startY, + startedAtTop && atTop(scroller), + startedAtEnd && atEnd(scroller), + ) + ) { + logFailure(pullOff(Math.sign(touch.clientY - startY))); + } else if (touch && axis === "sideways" && !ignoreSwipe) { const across = touch.clientX - startX; const outcome = articleSwipe(across, index, reader.visible.length); + pull = 0; if (outcome === "stay") { logFailure(settle()); } else { logFailure(slideTo(across < 0 ? 1 : -1, outcome === "close")); } + } else if (pull !== 0 || shift !== 0) { + pull = 0; + logFailure(settle()); } axis = "none"; + startedAtTop = false; + startedAtEnd = false; } @@ -224,10 +387,10 @@