A Discover result for an RSSHub instance address such as
http://localhost:1200/test/sort kept offering Follow after the follow, as
the server keeps the feed as rsshub://test/sort. The store now remembers
the URL an address was looked up as, and finding a feed by URL follows it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new feed address was previewed under a random nonce id that mobile sent
as feedId, so following it failed with 422 "Invalid Snowflake ID". The
nonce is no longer sent and the feed is followed by its URL; the feed that
arrives for that URL then replaces the preview.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Edit Profile sent blank names, values too long for their columns and handles
with spaces, then toasted the server's English text or a bare 500. Fields are
now checked as the server checks them, its update-user codes show translated
messages under their fields, and after a bare 500 a taken handle is looked up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Highlighted words kept the theme's white or gray text on the pale
highlight colors, about 2:1 contrast in dark mode, in the timeline, entry
titles and entry text. Highlights now set black text, or white on a dark
color.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
updateProfile sent a cleared or space-only handle as "", which the unique
index on handles refuses with a bare 500 once another user has one, and
the updated server stores a blank handle as none. It now sends and keeps null.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
updateProfile sent socialLinks: null unless the caller passed links, so a
mobile avatar change or a name-only save wiped them. It now sends them only
when given, and trims the name and handle as the server saves them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unfollowing feeds and lists together sent one DELETE with feedIdList and
listId; the server only handles feedIdList, so the lists stayed followed
(and of several lists only the first was sent). Feeds now go in one
request and each list in its own; a failed request restores only its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests for confirmed follows and unfollows did not check that a failed
unfollow confirms nothing, or that a replica answering with an older sync
id leaves the cursor and the confirmed unfollow in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Right after unfollowing a feed or list, a refresh could put it back for up
to a minute: /sync/delta lags behind the DELETE and still returned the
follow before it, and an undo vanished the same way. Confirmed follows and
unfollows now keep transaction-queue overlays until the engine applies
their sync id; older delta rows and lagging subscription lists yield.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The model picker was a plain div that could not take focus, and Tab in
the composer indented text instead of leaving it, so keyboard users could
not change the model. The picker is now a button named "Model: <name>",
Tab moves on from the composer, and the title that opens the chat history
is named "Chat history: <title>" instead of repeating "New Chat".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Export Database saved follow.db, Export Settings saved
follow-integration-settings-<date>.json, the OPML link asked for
follow.opml, and authenticator apps listed the account under "Follow".
They now say Folo; the 2FA setup passes "Folo" as the TOTP issuer. The
local database keeps its follow.db name so existing data stays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every time the Feeds tab opened, it first sent POST /feeds/analytics
with an empty id list, before the table had measured its rows, and the
server answered 422. The analytics query now waits for ids.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An invalid regex read "Invalid regex: Invalid regular expression:
Invalid regular expression: /[abc/: Unterminated character class", and
an empty pattern switched to Regex was called an invalid regex. The
validator now returns only the engine's explanation, and an empty
pattern shows no error. Mobile uses the same validator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coming back to the AI tab showed the saved prompt with "0/500", because
the counter only moved after an edit. The editor's length plugin never
called its callback; it now reports the opening length and each change,
which also brings back the AI task prompt counter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Choosing SN Pro as the global or content font did nothing on the web:
the production Tailwind pass rewrites the whole stylesheet and drops the
font CSS's relative url()s, so no font file was emitted and each request
got index.html. The font CSS is now imported from JS on its own, in the
renderer and the SSR client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor saves rules with an empty name and shows "Action N" for them,
but the importer rejected an empty name as missing, so such an export or
clipboard copy could not be imported again. Only reject a missing name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When GET /actions failed on a fresh device, the Actions page showed the
empty state, and saving a new rule there replaced every rule on the server.
Show a load error with Retry instead, and make saveRules refuse to send
rules before the server's rules were loaded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The subscription column focuses its sidebar in a mount effect, which
writes the focus scope in the same commit that mounts the timeline
header. jotai 3 subscribes after that commit, so the header kept the
empty scope and never bound Shift+Cmd+A until focus moved elsewhere.
Read focus scopes with useAtomValueRawSync.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upserting a feed kept the stored subscriber count, updates per week and
latest entry date whenever one was set, in memory and in SQLite, so the
follow screen and search cards kept showing the first count ever seen.
Keep the stored values only when the incoming feed has none, as feeds
from subscriptions carry no analytics.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The OTA provider fetched the store policy but never used it. Fetch it
once per launch, from the env profile's OTA URL, and show an alert when
the App Store or Google Play has a newer version. Later puts that
version off, a prompt shows at most once per launch, and a blocked
version asks every time the app returns to the foreground. APKs from
outside Google Play are not prompted, the alert waits until the app is
in front, and while it is open the review prompt stays away.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /actions answers without data when the account has no rules, and
fetchRules then left the store alone. Unsaved edits could not be
discarded for such accounts, since there was no confirmed copy to go
back to, and rules deleted on another device stayed in the store.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Leaving the Actions screen after choosing to discard the changes only
navigated away. The edits stayed in the store marked dirty, so the next
visit showed them again, remote updates from the sync engine were held
back, and the next save sent them to the server.
The store now restores the confirmed rules when the editor unmounts. On
mobile a swipe back is also disabled while there are unsaved changes, so
leaving goes through the prompt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving the manage-list screen only sent every selected feed to the add
endpoint, so feeds the user unchecked stayed in the list and the local
copy collected duplicate ids. It now adds only the new feeds and removes
the unchecked ones. The selection also started empty when the list was
still loading; it now starts from the list's feeds once they arrive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The segment control's group could take focus, but its segments couldn't
be reached and nothing handled keys, so keyboard users couldn't change
a selection in the settings, Discover, action rules or view toggles,
and screen readers weren't told which segment was selected. Move the
selection with the arrow, Home and End keys, and expose the selected
segment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Below 1024px the responsive select renders a button over a native
select, and neither received disabled, so a phone or a narrow window
could still change the fields of a disabled action rule or the language
while it was loading. Disable both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prefetching the contents of up to 30 entries updated the entry store
once per streamed entry, and every update re-ran the timeline selectors
that filter and sort every entry of the session. Store the entries that
arrive together in one update, and report a failed stream instead of
leaving the rejection unhandled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mobile subscription list sorted by count and hid read subscriptions
through subscription store selectors that read the unread counts from
the unread store, which never ran them again, so a feed read to zero
stayed visible and the order froze until a subscription changed. The
folder sort also walked every subscription twice per comparison.
Run the selectors again when the counts they use change, and add up
each folder's unread count once per sort from the ids already grouped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every new-entry action in the change log updated the dirty feed set,
copied and published the whole unread map and wrote the database on
its own. After a night away that is thousands of sequential writes and
store updates, re-rendering every badge and sidebar count each time.
Add up consecutive new-entry actions and apply them in one update,
before any other action so counts set outright still apply in order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paid settings fell back to their defaults whenever the user's role was
unknown, which it is at every launch until the session loads and for
the whole session offline, because the role isn't stored. The setting
hooks also never re-rendered once the role arrived. Keep a signed-in
user's saved values while the role is unknown, and refresh the setting
hooks when it changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Launch hydration read every stored entry with its content, kept the 20
newest per subscription and deleted the rest, but entries of feeds no
longer followed, such as previewed feeds or opened links, were neither
kept nor deleted, so the table and the launch time grew without bound.
An entry shared by a list and a full feed was also hydrated twice and
deleted at the same time.
Decide from the ids and sources only, delete every entry not kept for a
followed source, then read the kept entries in batches below SQLite's
bound variable limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New entries skipped refreshing a list that was fetched after the newest
entry was logged, but compared the server's log time with this device's
fetch time. With a clock a few minutes fast, a list fetched before the
entries looked newer and never showed them until it went stale. Only
skip lists fetched clearly after the entries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refetching a single view's subscriptions, the fallback when the sync
engine isn't running such as a pull to refresh on mobile, emptied the
set of subscribed ids for every view but refilled it only for that
view, so feeds in other views looked unsubscribed. It also kept the
subscriptions the server had dropped. Reset only the refetched view's
subscriptions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unfollowing a list only cleared the unread counts of feeds unfollowed
directly, so feeds that had been followed only through the list kept
counting toward the app badge until the next hourly recount, and a list
deleted on another device never cleared them at all. Clear the counts of
feeds no longer followed after a local unfollow, and recount after a
synced list delete.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The subscription upsert listed the columns to update on conflict before
hide from timeline existed, so re-saving an existing subscription kept
the old value in the database while the store had the new one, and it
came back after a restart. Update every column except the id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After every summary update the store sorted all summaries in memory to
keep the ten most recent, but it deleted them by language code instead
of entry id, so nothing was ever removed and every update paid for the
sort. Hydration loads every stored summary anyway, and evicting them
would only refetch summaries still shown in the timeline, so remove the
cleanup together with an unused getter that wrote to the store on read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Editing a subscribed list's view only updated its record, so the
sidebar kept listing it under the old view and view-wide unread counts
and mark-as-read used the wrong view until a restart. The later sync
patch couldn't repair it because the record already had the new view.
Move the list between views like feeds, and refresh both views.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sorting feeds by unread count sorted the caller's array in place and
returned the same reference, so the store's equality check skipped the
update and the order inside a folder went stale; folders and lists also
sorted that shared array in opposite directions. The folder sort built
new tuples on every unread change, which re-rendered every folder.
Sort a copy, keep the folder tuples stable, and pass them through
unchanged so an unchanged order doesn't re-render.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replacing the unread counts returned early for an empty snapshot before
deleting the stored rows. When everything was read, memory showed zero
but the database kept the old counts, which came back as badges on the
next launch and were never repaired by later recounts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Taking a feed out of its category sends a null category, but the batch
update only wrote truthy categories to the store, so the feed stayed in
its folder until the next full sync even though the server had moved
it. Apply null as well, drop categories the move leaves empty, and
restore them on rollback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The All view merges same-named folders from every view, but folder
actions used the view of the folder's first feed. Renaming or
ungrouping changed only the feeds of that view and split the folder,
unsubscribing it left the other feeds, its view switcher hid that view,
and dropping a feed on it or adding a feed to a category moved the feed
into that view.
Run folder actions in the All view across all of its feeds, update the
categories of each view they touch, and keep every feed's own view when
it is dropped on a folder or added to a category.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every entry link in an AI answer fetched the entry detail with its full
content as soon as it rendered, even when the entry was already in the
store, so an answer citing a whole timeline sent a request per entry.
Only fetch the entries the hover card can't show yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
updateProfile, updateEmail and sendVerificationEmail awaited the
better-auth client, which resolves with an error instead of throwing.
On mobile a rejected handle or email kept the new value in the local
user and database and showed a success toast, and a failed verification
email reported as sent. They now throw on an auth error, so the
optimistic change rolls back and callers show the error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changing a rule condition's field only merged the new field, so a
"title contains foo" condition switched to "Subscription view" kept the
text operator and value. The operator select showed nothing and the rule
saved an invalid condition. Desktop and mobile now go through
changeConditionField, which keeps the operator and value only when the
new field has the same type.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since jotai 3, useAtomValue subscribes in a passive effect without
re-reading the atom. StableRouterProvider writes the route atom in a
layout effect, so components that mount in the same commit (a full page
load in dev, or opening a timeline from Discover, AI and other layouts)
kept the previous params: feedId and entryId stayed undefined, the view
fell back to Articles, the header and title were empty and scroll
mark-read never fired.
Read the route atom with useAtomValueRawSync, which re-checks the
snapshot after subscribing, and cover both cases with a regression test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The picture masonry marked entries up to the smallest index among the
items an IntersectionObserver callback reported as gone. Several items
leave in the same frame, so later callbacks only caught up mid-list and
the last entries stayed unread. Use the first laid out item still in view
as the boundary instead and reuse the list's anchor range logic.
The list and grid virtualizers memoized onChange without onRangeChange. A
list mounted while a scroll reset was pending kept a handler that always
returned early, leaving only onScroll, which runs before the range update,
so the range of the last scroll event was never marked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move the hard-coded English left in the desktop renderer, the Electron
main process, the mobile app and the shared packages to i18next, with
en, zh-CN, zh-TW, ja and fr-FR translations. SSR share pages are
unchanged.
- Fill every namespace in every language, delete keys no code reads
(server error codes stay) and update translations left behind by
later English changes.
- Stop HTML-escaping interpolated values in the desktop renderer.
React already escapes text, so names with & or quotes showed entities.
- Render error boundary fallbacks as components so they can use hooks.
- Localize zod validation messages with the app language.
- Throw ClipboardError from the clipboard helpers and show translated
toasts. formatEstimatedMins takes a formatter so callers localize the
units.
- Use one glossary term per concept in ja, fr-FR and zh-TW.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entry titles and settings labels went through title-case unconditionally,
but it applies English capitalization rules, so with a French UI and French
feeds titles rendered as "Pourquoi Votre Café Est Une Arme De Défense" and
settings labels as "Afficher Le Nombre De Non Lus". CJK titles were also
touched ("iOS 27 beta" became "IOS 27 Beta").
titleCaseIfEnglish in @follow/utils only applies title case when the
language is English (en, en-US, en_GB, or franc's eng). On desktop, entry
titles use the entry language and translated titles the translation
language, falling back to the UI language when unknown; settings labels
and section titles use the UI language. Mobile grouped list cells use the
UI language too. AI chat tool names keep title case since they are English
identifiers.
Mobile no longer imports title-case directly, so the dependency moves to
@follow/utils.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
api.dev.follow.is no longer resolves to a working API, so the dev env
profile (the default in mobile development builds) could not load
anything. Use the current api.dev.folo.is and dev.folo.is hosts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shared pages built with Vite 8 received the whole react-fast-marquee module object for its default import and crashed with React error #130 when a title marquee rendered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>