Skip to content

fix: the 2026-09-25 review — backend, federation, queries, jobs, web client, operations - #2363

Merged
karlitschek merged 76 commits into
masterfrom
fix/review-2026-09-25
Sep 25, 2026
Merged

karlitschek merged 76 commits into
masterfrom
fix/review-2026-09-25

Conversation

@karlitschek

Copy link
Copy Markdown
Member

Author's description to follow. The list below is a factual index of the commits, not the description.

Fixes from the 2026-09-25 review of master 1f45852 (report: /Volumes/frank/social-tests/fresh-look-2026-09-25/). One commit per finding, each with a regression test that fails without it.

Backend

  • A poll deleted from the web is deleted (by its own type), inbound too; imported-post record removed with the post; following count only on a real change; an incoming Flag resolves from the cache and names at most 50 objects; session routes answer 404/422/429/502 for the caller's own mistakes instead of 500; an unknown account is "User not found", not "lookup failed".

Federation

  • JSON-LD responses carry application/ld+json (GoToSocial refused every fetch) and keep their status (404/410/401 were 200s); forwarded Create/Update without an LD signature are fetched from their origin instead of refused; single attachment/tag objects read as lists (was a 500); PeerTube avatar lists, Undo of a boost embeds the Announce, Lemmy link posts keep the link, display names with < kept, followers/following without the self row, signatures verify on a non-default port, all three spellings of Public, banner media type; outbox/featured without internal bookkeeping; WebFinger subject and host case; profile fields as HTML with rel=me; one activity id per edit; Create by id fetched; inReplyTo object and single alsoKnownAs; 12 h Date window with a replay record.

Queries (each with before/after EXPLAIN in its commit)

  • Actor joins on the prim columns (were LOWER() full scans); home timeline windowed by time and capped in collections; public/notifications/DM ordered on the recipient key; hashtags stored and compared normalised (+ backfill step); indexed account-handle search (MySQL/MariaDB prefix); keyset paging on the ActivityPub collections; unread count as COUNT(*).

Background jobs

  • Cron\Cache pages local actors; parallel delivery with a database-backed per-host breaker; feed and block-list bodies streamed with a byte cap; feed follows/imports fetched by the job, items pruned; feed and remote-actor refresh budgeted by time; trends in one pass; block lists applied with one write; sidebar routes answer ETag/304; a migration paging fix; the cost of the stream-rewriting upgrade steps documented.

Web client

  • Subscriptions: list refills after actions, errors shown, no CSP-blocked thumbnails; Video reels: post link, keys, loading/error states, reduced motion; statistics labels in UTC and the window control on phones; block-list source applied when switched on; migration inputs out of the tab order; setup page no longer blank; emoji picker loaded on the first reaction, not on every page; no familiar_followers call logged out; profile page no longer bundles the post renderer (422 KB → 147 KB); @discordapp/twemoji 16 (Unicode 16) with a native fallback; first-run introduction reachable again from Settings and accepting the Pixelfed JSON; sass moved to devDependencies.

Operations and docs

  • DurableCache: inbox throttle, replay records, Idempotency-Key and statistics hold without a memcache; setup check for a missing memcache; Discover's directory list refreshed by cron instead of ten live requests per page load; Cron\BlocklistSync registered (with a test that every job is); social_url derived from cloud_url, and a setup check comparing them; interop CI installs with --no-dev, symfony/console dev range ^6.4 || ^7.4; docs corrected against the code, counts removed from prose.

New migration steps: Version1000Date20260925000001 (hashtag backfill), …000002 (account handle column), …000003 (host breaker table), …000010 (durable cache table).

chore(release): 0.26.64 with the rebuilt bundle, on top of master 0.26.63 (#2361, #2362).

Checks run locally

phpunit (6905), vitest (3220), eslint, stylelint, typecheck, psalm, php-cs-fixer — green on the tip, and phpunit on each of the 75 commits; vue-tsc within the baseline. Integration tests run in CI only.

AI disclosure

The code, tests and this index were produced with Claude Code (Claude Opus 5.5) and are awaiting the author's review. Signed-off-by is added by the author.

🤖 Generated with Claude Code

Frank Karlitschek and others added 30 commits September 25, 2026 11:03
A poll is stored with type `Question`, but the web client's delete route
asked `StreamService::deleteLocalItem()` for a guarded delete of a
`Note`. The guard matched no row and returned before the cascade, after
the Delete had already been queued and the post counter decremented: the
network was told the poll was gone, the author's count dropped, and the
poll came back on reload, for good. The route now passes the post's own
type, as the client API's status delete and the Pixelfed message delete
already do.

The inbound side had the same guard: `NoteInterface` handles both `Note`
and `Question`, yet deleted only `Note`, so a remote author's Delete of
their poll left the copy here. It now deletes a `Question` as a
`Question`, and still refuses to remove any other type through this
interface.

The other callers were checked: the client API and Pixelfed pass the
item's type, moderation takedowns and the account cascade pass none,
and scheduled statuses are not stream rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The post importer skips every source id it finds in `social_import_post`,
and writes one row there per post it brings over, keyed on the local
post. That table was not part of the post cascade in
`StreamRequest::deleteRelatedTo()`, only of the account cascade, so once
a user or a moderator deleted an imported post, re-running the same
archive counted it as already imported and never wrote it again.

The cascade now clears the import record with the post. The test checks
the rule behind it rather than the one table: every table in the schema
that keys rows on a single post by `stream_id_prim` is cleared.

Rows orphaned before this change are left as they are; they only keep
their own posts from being imported a second time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Both follow routes, the client API's and the web client's, bumped
`count_following` after every call to `FollowService::followAccount()`,
but that call does nothing when the follow already exists, and it is the
same call a Mastodon client makes to change `notify` or `reblogs` on an
existing follow. Each such change added one to the counter. Unfollowing
somebody not followed took one off in the same way. The cron's recount
put the number back, so it wobbled rather than drifted.

`followAccount()`/`followActor()` now say whether they made a follow, and
`unfollowAccount()` whether it removed one, and the routes move the
counter only then.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
`ReportService::reportFromFlag()` looked for the reported local account
by calling `CacheActorService::getFromId()` on each id the Flag named,
and that call fetches an actor it has not cached. A local account is
always in the cache, so every fetch was for somebody else's URL: any
signed fediverse actor could send one Flag naming a few hundred URLs on
hosts of their choosing and have this instance make that many outbound
GETs, one after the other, inside the inbox request.

The ids are now resolved in one query against the cache alone, the way
`BlockInterface` resolves a block target, and an incoming Flag keeps at
most 50 of the ids it names. Mastodon's carry the account and a handful
of statuses; the cap also bounds what is stored against the report.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
… a 4xx

Every failure on the routes of `LocalController`, `NavigationController`
and `OStatusController` went through `TNCDataResponse::fail()` with its
default 500 and a warning with a stack trace: deleting a post that is
gone or somebody else's, following a handle that does not resolve or a
host that is down, following yourself or past the follow limit, asking
for an account nobody holds, opening `/ostatus/follow/` for an unknown
account, and every stale link to a document, which also wrote an
error-level line per hit, so a crawler with old image links filled the
log.

`TNCDataResponse::failFor()` maps these the way `ApiController` already
does for the client API: 404 for what does not exist, 422 for what
cannot be done as asked, 403 for a remote refusal, 429 over the follow
limit, 502 when another server failed. Those are not logged. What is
left, a failure of this server's own, is still a 500 and still logged.
The 401s of the client API map are left out: these routes run on the
Nextcloud session, and a 401 reads as that session having ended.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Asking for a handle nobody holds used to answer 500, and the account
store turned every failure into an app error, "Account lookup failed …
The remote server may be unreachable", with an entry in the sidebar's
error list, for a local name that simply does not exist. The route now
answers 404 for that, and the store treats a 404 as the answer it is:
it returns nothing and the profile page shows its own "User not found".
Other failures are still reported as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
A peer whose Accept lists `application/ld+json; profile=...` first gets the
responder registered for that type, and it answered `Content-Type: ld+json;
profile=...` with no `application/`. GoToSocial asks in exactly that order and
rejects any body whose type is not ActivityStreams, so every dereference of
an actor, key or post on this server failed there, and with it every
delivery from here that GoToSocial had to verify.

Both custom responders also rebuilt the returned DataResponse as a fresh
200 JSONResponse, dropping its status and headers: an unknown actor, a
deleted post or a refused secure-mode read reached every ActivityPub peer
as a 200 with an error body. They now go through the framework's own json
responder and only replace the Content-Type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…efusing it

A reply forwarded under AP 7.1.2 is signed by the forwarder's key, not
its author's, and a reply written on a server that makes no Linked Data
signatures (GoToSocial, Pleroma, Pixelfed, PeerTube, Lemmy, ...) carries
nothing else to vouch for it. assertSignerSpeaksFor() refused it with a
401, which is final for Mastodon's queue, so such replies never reached
this instance's copy of the thread.

The body is still not trusted and never imported. A Create or Update of
an object on its actor's own host is answered 202 and the object is
queued by id as a new StreamQueue fetch item; the queue fetches it
signed through CurlService (access list and local-network guard
included), requires the id it was asked for and a post type, and hands
it to the ordinary inbox path as a Create of its author with the URL's
host as origin, so NoteInterface's author-on-host checks judge what the
origin served. A stored copy is updated only when the fetched `updated`
differs. Forwarded Delete and Announce are acknowledged and dropped;
anything else signed by a third party is still a 401.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
… one

ActivityStreams lets every non-functional property be one value or a
list, and peers send `"attachment": {...}` and `"tag": {...}`. Read
through getArray(), a single object was iterated value by value, and
Stream::importAttachments() handed each string to AP::getItemFromData(),
which takes an array: a TypeError that `catch (Exception)` did not stop,
so the whole delivery answered 500 and Mastodon re-sent it for two days.
A single tag or profile field was silently lost the same way.

ACore::listOf() reads such a field as a list whatever its form, and is
used for `attachment`, `tag` (tags and emoji), `to`/`cc` and actor
fields. importAttachments() also skips an entry that is not an object.
At the import boundary, a TypeError from a model is turned into an
ActivityPubFormatException, which the inbox answers 400: the sender's
bytes are wrong and redelivering them will not help.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…nner list

PeerTube publishes an account's `icon` and a channel's `image` as a list
of Image objects, one per size. Person::import() handed the whole list to
Image::import(), which found no `type` and dropped it, and `image.url`
read nothing out of a list, so PeerTube accounts and channels arrived
without an avatar or banner. The largest candidate is kept, the same rule
PeerTubeService::thumbnail() applies to a video's poster; a single object
is read as before, also when the cached copy is rebuilt from its source.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Undo{Announce} named its object by id only, while every other Undo this
app sends embeds the object. PeerTube's processUndoActivity dispatches on
`object.type` and logs an unknown object type for a string, so a video
un-boosted here stayed shared there. The Undo now embeds an Announce with
the stored boost's id, actor, object and audience; it is built fresh
rather than exported from the row, which carries the boosted post and
this app's bookkeeping.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
A Lemmy link post is a Page whose link is its only attachment,
`{type: "Link", href: ...}`, with no `url`. There is no model for a Link
attachment, so importAttachments() dropped it and the post arrived as
title and body without the link it exists for. The href of the first
http(s) Link attachment of a note-like object is now appended to the
content, unless the content already links it; the link preview is then
made from it like any other link in a post.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
AS_USERNAME and AS_ACCOUNT went through strip_tags(), which reads a bare
`<` as the start of a tag and drops everything after it: every remote
account called `Alice <3 cats` was shown as `Alice `. It is the bug the
bio and content-warning paths were already fixed for, and the same
withoutMarkup() is used here: only what really opens a tag is removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Every local actor has an accepted `Loopback` row on itself in
social_follow, which is how its own posts reach its home timeline. The
followers and following page queries did not filter on type, so every
actor was listed as its own follower and followee and each page held one
id more than the collection's totalItems; Mastodon's follower sync and
migration tools saw a phantom follower. Both queries now read Follow
rows only, which also cleans the migration export and the other callers.
The home timeline reads getHomeCollectionPrims(), which keeps the row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The `host` line of an inbound signature is rebuilt from the configured
host so a captured request cannot be replayed against another instance,
but getCloudHost() drops the port. A peer signs the Host it connected
to, which on an install at `example.org:8443` includes the port, so every
delivery there failed verification with a final 401.

ConfigService::getCloudAuthority() answers host[:port], the port only
when it is not the scheme's default, and is what the draft-cavage and
RFC 9421 paths substitute now. The authority rule is the one
HttpSignatureService already used when signing, moved to
ConfigService::authorityOf() so both sides share it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
ActivityStreams names the public collection three ways: the full IRI,
the compacted `as:Public` and a bare `Public`. The short two are not
http URIs, so validateRecipients() dropped them as ids and a public post
addressed that way was stored with no public recipient, which
estimateVisibility() classifies as a direct message. Both are now
canonicalised to the full IRI before validation, as Mastodon does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The actor document's `image` always said `mediaType: image/jpeg`,
whatever the banner was. Mastodon sniffs the file and does not care, but
GoToSocial stores the declared type, so every PNG or WebP banner was
described wrongly there. A remote banner now keeps the type its own
document gave; a local one is typed by its `/media/{uuid}.{subtype}`
address; when neither says, `mediaType` is left out rather than guessed.
Replacing the banner forgets the old type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…okkeeping

getPublicByAuthor() and the pinned-posts read join the author, which
flags each row for complete details, and outboxPage() and featured()
exported them that way: every item carried `source` (the whole stored
document again, as a string under a key AS2 defines as an object),
`actor_info` (a full actor document), `details`, `action`, `cache` and
`publishedTime`. An outbox page was about five times the size of the
same Notes fetched one by one and exposed internal state. Both now turn
complete details off before export, as displayPost() does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The WebFinger subject echoed the query: `acct:@maya@host` came back as
the subject `acct:@maya@host`, which a consumer splitting on `@` reads as
a user with no name, and `acct:Maya@host` or a bare `maya@host` came back
as typed. The subject is now always `acct:<preferredUsername>@<social
address>`, as Mastodon answers. The host part of a local handle was also
compared case-sensitively while the user part was not, so
`acct:maya@HOST` was a 404; it is compared case-insensitively now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
PropertyValue.value is HTML on the wire: Mastodon renders it as such and
verifies a remote field only from a link in it. A local value is stored
as the user typed it and went out raw, so `<` and `&` reached every peer
as markup and an address was plain text on Mastodon that could never be
verified there. Local values are now escaped, and a value that is an
address on its own becomes a link with rel="me", as Mastodon's own fields
are. A remote actor's values are served as they arrived.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Every Update of a post went out as `<post>/activity#update`, so all edits
of one post shared an activity id, which ActivityPub requires to be
unique. Mastodon compares `updated` and copes; a peer that keeps a set of
seen activity ids dropped every edit after the first. The id is now
`<object>#updates/<n>` as Mastodon names its own, with `n` the post's
`updated` time, or the current time in milliseconds for an object that
has none (an actor, a poll whose counts moved).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
`object` may be a link as well as an embedded object, and a Create
carrying only the id returned on `!hasObject()` without a trace.
CreateInterface now queues the id as a fetch from its origin, through
the queue a forwarded post takes, when it lives on the host the activity
came from and its actor is on; the inbox drains it inline under the
activity's token. What is stored is only what that server serves.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Two legal AS2 forms were lost on import. An `inReplyTo` embedding the
post it answers, `{id: ...}`, was read as a string and came out empty,
so the reply arrived outside its thread; it is now read as the embedded
post's id. An `alsoKnownAs` given as one string went through getArray(),
which json-decodes a string into nothing, so the actor had no aliases and
MoveInterface refused a Move whose target listed its old account that
way; it is read with listOf() now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The Date of an inbound signature (and an RFC 9421 `created`) had to be
within five minutes of this server's clock, and a miss is a final 401:
a peer whose clock was six minutes off, or whose queue delivered a
request it had signed a little earlier, lost every delivery here with
nothing on its side to retry. Mastodon accepts twelve hours back and one
hour ahead, and so does this now; the digest binding is unchanged.

The five minutes were also the only thing standing against a captured
request being sent again, so a window that wide needs a replay guard:
once a delivery has been taken in, its signature is remembered in the
distributed cache for as long as its date would be accepted, and the
same signed request again is answered 200 without being processed. It
is remembered only after success, so identical bytes resent after a
failure are still processed; a peer's own retry signs anew anyway.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The three deprecated join helpers in CoreRequestBuilder -
leftJoinCacheActors(), leftJoinAccounts() and leftJoinFollowAsViewer(),
two of which make up leftJoinDetails() - compared LOWER(id) with LOWER(id).
None of those id columns has an index, and LOWER() would defeat one anyway,
so every row of the page was compared with every cached actor and, for the
viewer's relation, with every follow row: a block-nested-loop join per
request on the followers/following ActivityPub collections, the follow
requests list, favourited_by/reblogged_by, getFollowersByFollowId() and
every account search.

The indexed `_prim` columns exist for exactly this and the rest of the app
already joins on them (linkToCacheActors()). The prim is md5() of the id as
written, so the join is now an exact match where it used to fold case. An
id is a URI and two that differ in case are different resources; on devel
no follow or like row matches a cached actor by LOWER() without matching it
by prim (checked for actor_id and object_id of social_follow and actor_id
of social_action), and every page below returns the same rows before and
after. The unused $author branch of leftJoinCacheActors() is removed rather
than converted: nothing passes one.

EXPLAIN on devel (MariaDB 11.8), SQL printed from the builders:

followers page (getFollowersByActorId, 32 rows)
  before: f ref social_f_ocr ... Using temporary; Using filesort
          ca ALL (60) BNL join; as_follower_f ALL (336) BNL join;
          as_followed_f ALL (336) BNL join
  after:  f ref social_f_ocr const, Using where
          ca eq_ref UNIQ(id_prim) f.actor_id_prim
          as_follower_f eq_ref social_f_oa_u ca.id_prim,const
          as_followed_f eq_ref social_f_oa_u const,ca.id_prim
following page (getFollowingByActorId, 32 rows)
  before: f ref social_f_aa ... temporary; filesort; ca/as_*_f ALL BNL
  after:  f ref social_f_aa ... filesort (over this account's follows);
          ca, as_follower_f, as_followed_f eq_ref
getFollowersByFollowId (31 rows)
  before: lja ALL BNL join   after: lja eq_ref PRIMARY f.actor_id_prim
favourited_by (getActionsOnObject, 12 rows)
  before: ca ALL BNL join, temporary   after: ca eq_ref, no temporary
searchAccounts (2 rows)
  before: as_follower_f ALL, as_followed_f ALL BNL join
  after:  both eq_ref social_f_oa_u (the ca scan itself is D12)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…ent nid

paginate() bounded and ordered every page on s.nid. For the three timelines
whose recipient join fixes the collection and the type - public (the public
collection, 'recipient'), notifications (the viewer, 'notif') and direct
(the viewer, 'dm') - MariaDB drives from those recipient rows anyway, so it
joined every one of them to its post to read s.nid and sorted the result in
a temporary table to keep twenty: a sort over every public post ever
written, and over every notification an account ever received, on the
reads clients poll most.

The recipient row carries the post's nid (StreamDestRequest::create() is
handed it on every write, and the Version1000Date20260917000001 backfill
filled the old rows), so paginate() takes the alias to page on and these
three pass `sd`. social_sd_atn (actor_id, type, nid) then serves the filter
and the order as one descending range. While the backfill flag is unset a
row may still carry 0, so they keep to s.nid until it is written, and a
zero is excluded as on the home timeline. On devel every recipient row's
nid equals its post's; the flag there is unset, so the after plans were
taken with it forced on, and all four pages return the same posts in the
same order before and after.

EXPLAIN on devel (MariaDB 11.8), page-selection query:

public local=true
  before: sd ref social_sd_atn (const,const) rows 120
          Using index condition; Using where; Using temporary; Using filesort
  after:  sd range social_sd_atn key_len 203 rows 120 Using where
public (all)      same before/after as above
notifications
  before: sd ref social_sd_at (const,const) rows 29 ... Using temporary;
          Using filesort
  after:  sd range social_sd_atn key_len 203 rows 29 Using where
direct
  before: sd ref social_sd_at (const,const) rows 3 ... Using temporary;
          Using filesort
  after:  sd range social_sd_atn key_len 203 rows 3 Using where
(s joins eq_ref on sd.stream_id in both; the per-page hydration of the
chosen twenty is unchanged.)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
countNotificationsSince() answers the notification badge, which every
client polls every thirty seconds. It ran getStreamSelectSql() - SELECT
DISTINCT over every social_stream column - with LIMIT 100 and counted the
fetched rows in PHP, so each poll carried up to a hundred whole posts
through a temporary table to produce one number.

The rows are now chosen exactly as before (the notification type, the
viewer's `notif` recipient rows, the hidden-actor filter, archived posts
left out, cut at cap + 1) but projected to one column, and the database
counts them: SELECT COUNT(*) FROM (the page) with the page's parameters
bound on the outer query. The marker compares sd.nid once the backfill
flag says the recipient rows carry their nids (as the previous commit
does for the notifications page), which makes it a range over
social_sd_atn instead of a walk of social_stream by primary key.

EXPLAIN on devel (MariaDB 11.8), marker 1789688205063486330, 11 unread:
  before: s range PRIMARY rows 98 Using where; Using temporary
          sd eq_ref sat ... Distinct
          (every column of every post newer than the marker, instance-wide)
  after:  <derived2> ALL rows 11
          sd range social_sd_atn key_len 203 rows 11 Using index condition
          s eq_ref on sd.stream_id
Same counts before and after: 29 since 0, 11 since the marker, 6 at cap 5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The fast home query is `sd.actor_id IN (every followed collection) AND
sd.type = 'recipient' ORDER BY sd.nid DESC LIMIT 60`. Across several IN
ranges (actor_id, type, nid) cannot hand out rows in nid order, so the
database reads every index entry the predicate admits and sorts them: a page
cost everything the reader's follows ever posted, and newestNidFor(), the
home ETag a client polls every thirty seconds, did the same for LIMIT 1. The
collection list was unbounded too - getHomeCollectionPrims() without a
limit - although FollowsRequest::limitToHomeCollections() and its cap
existed for exactly that and had no caller: an account following 40k others
bound 40k parameters, past SQLite's 32,766.

Both are now bounded by the predicate:

- the collections come from limitToHomeCollections(): up to 500 named, past
  that an EXISTS over social_follow. The reader's own follower collection is
  read by a second, single-collection query and merged (mergeNidPages() now
  takes the cut) instead of being passed as $also: OR'ed beside the EXISTS
  it stopped MariaDB turning it into a semi-join, and the plan became a full
  index scan of social_stream_dest (checked, below).
- the read is windowed by publication time. A nid is published_time * 1e9 +
  random (Nid::publishedTimeOf() reads it back), so the window is a range on
  the column the query orders by: the day before the cursor first, then a
  week, a month, a year and finally no bound, widening only when the page
  comes back short. The query is built once and each attempt rebinds one
  parameter. It is exact: every row outside a window is further from the
  cursor than every row inside it, so a window that fills the page holds the
  rows the unbounded query returns, and the merge with the followed-hashtag
  half keeps the guarantee getTimelineHome() relies on.

newestHomeNid() replaces newestNidFor(viewerCollections()) for the ETag with
the same collections and windows and a limit of one; viewerCollections() and
ApiController's FollowsRequest dependency had no other use and are gone.
docs/Architecture.md described the plan as "a descending index range per
followed collection, merged, stopping at the limit"; it now says what the
plan is and what bounds it.

EXPLAIN on devel (MariaDB 11.8, 32 followed collections, 790 recipient
rows); the returned page is the same 20 posts in the same order:

home page
  before: sd range social_sd_atn rows 146 Using index; Using filesort
  after:  window 1 day:  sd range social_sd_atn rows 32  Using index; Using filesort
          window 1 week: sd range social_sd_atn rows 34  (page was short)
          window 1 month: rows 140 (devel's data spans a few weeks)
          own collection: sd range social_sd_atn rows 6 Using index (no sort)
home ETag
  before: sd range social_sd_atn rows 146 Using index; Using filesort
  after:  sd range social_sd_atn rows 32 (the first window answers it)
past the cap (HOME_COLLECTIONS_IN_A_QUERY forced to 5 in the copy run on
devel)
  EXISTS OR IN(own): sd index social_sd_atn rows 790 (every row) Using filesort
  EXISTS alone:      <subquery2> materialised from hf ref social_f_aatf,
                     sd ref social_sd_atn (hf.follow_id_prim, const) rows 6

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
social_stream_tag.hashtag held each tag as its author wrote it, while
social_hashtag and social_followed_tag hold FollowedTagsRequest::normalise()
forms. So the hashtag timeline compared LOWER(st.hashtag) = LOWER(:tag) and
the followed-tags join ft.hashtag = LOWER(ft_st.hashtag), and no index
answers a comparison over a function of the column: social_st_ht was never
used, and the tag was a filter over every tag row of every candidate post.

generateStreamTags() now writes normalise()d tags (deduplicated, empty ones
dropped), the hashtag timeline normalises its argument the same way and
compares st.hashtag exactly, and the followed-tags join compares the two
columns directly. The other readers of the column (related(), the featured
tags' counts) already compared it with a lowercased value and now find the
mixed-case rows they used to miss.

Version1000Date20260925000001 rewrites the existing rows: keyset-paged on
the primary key in batches of 5,000 (three parameters a row, inside
SQLite's ceiling), reading only rows with hashtag <> LOWER(hashtag) where
LOWER() folds Unicode and every row on SQLite, whose LOWER() is ASCII-only.
Two tags of one post that normalise to the same one would violate the
unique (stream_id, hashtag) index, so the later is deleted instead of
rewritten, whether its twin is in the same page or an earlier one. It sits
beside the squash, which every instance has already recorded as run.

EXPLAIN on devel (MariaDB 11.8), same posts returned before and after,
for `birding` and for `Birding`:

hashtag timeline
  before: st range sh rows 75 (every tag row) Using where; Using index;
          Using temporary; Using filesort
  after:  st ref social_st_ht const rows 3 Using index condition;
          Using temporary; Using filesort (a sort of this tag's rows only)
home, followed-tags half
  before: ft_st index sh rows 75 Using join buffer (flat, BNL join)
  after:  ft_st ref social_st_ht ft.hashtag rows 1

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
searchAccounts() - the composer's mention picker on every keystroke,
/api/v2/search, /api/v1/accounts/search and unified search - matched
`account LIKE 'x%'` through iLike(), which on MySQL compares the column
COLLATE utf8mb4_general_ci; getFromAccount(), which SearchService calls
first for the exact handle, compared LOWER(account) = LOWER(?). account
has no index, and neither comparison could use one, so each keystroke was
two scans of every cached actor.

social_cache_actor gains account_lower, the handle through
CacheActorsRequest::lowerAccount() (mb_strtolower), indexed social_ca_al,
written by save() and update() wherever `account` is (and by the benchmark
seeder, which inserts rows directly). The search is a prefix LIKE on it,
compared as it stands, and the lookup by handle an equality on it.
Version1000Date20260925000002 adds the column and index and backfills the
cached rows keyset-paged on nid, 5,000 a statement. It sits beside the
squash, which instances have already recorded as run; the column is
registered in CoreRequestBuilder and docs/Architecture.md, and
IndexCoverageTest now pins the index.

On MySQL and MariaDB (utf8mb4_bin) a prefix LIKE on the plain column is an
index range. PostgreSQL and SQLite plan LIKE as a range only under a C
collation or case_sensitive_like, so there the search stays a scan (of one
short column); the lookup by handle is an index lookup on all three.

EXPLAIN on devel (MariaDB 11.8). The new column cannot be added there, so
the after plan is shown on social_cache_actor.host, which has the same
type, collation and a single-column index:
  before, search:   ca ALL rows 60 Using where
                    (account COLLATE utf8mb4_general_ci LIKE 'ni%')
  before, lookup:   ca ALL rows 60 (LOWER(account) = LOWER('nightsky'))
  same predicate shape on an indexed column, with COLLATE:
                    ca index social_ca_host rows 60 (a full index scan)
  after shape, as it stands:
                    host LIKE 'mastodon%'   -> ca range social_ca_host rows 1
                    host = 'mastodon.social' -> ca ref social_ca_host const

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Frank Karlitschek and others added 24 commits September 25, 2026 11:03
While the first page was in flight the page was a black rectangle with a
close button, and when the fetch failed `load()` only logged: the
template's `reels.length === 0 && !loading` branch then said "No videos
here yet." about a server error.

An empty stack now shows a spinner while it loads, and an alert with "Try
again" after a failed request (`failed`, reset on every attempt), as
`TimelineList` does; "No videos here yet." is left for a feed that
answered with nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…w scope

Three small things on the reels page:

- The wrapper carried an `aria-label` on a plain `<div>`, which assistive
  technology does not announce; it is now `role="region"`.
- The arrow keys scrolled to the next slide with `behavior: 'smooth'`
  regardless of `prefers-reduced-motion`, which the rest of the app
  checks before every animated scroll.
- `scope` (from `?scope=`) was read once in `mounted()`. The router reuses
  the view when only the query changes, so a changed scope now switches
  the feed and fetches its first page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
`toggle()` replaced `this.sources` with the server's fresh rows and then
called `check()` with the row it had been handed, whose `enabled` was
still `false`. So the follow-up fetch went out with `dryRun: true`, and
the line under the source said "Would block 12 and silence 3 of 200
servers." — which reads as applied, while nothing was until the daily
job ran.

The check now runs against the source as the server returned it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The six file inputs on the Migration page use Nextcloud's hidden-visually
class, which moves them off screen but leaves them focusable and in the
accessibility tree. A keyboard user tabbing through the page met six
invisible "file upload" stops with no name between the buttons that open
them. The button is the control the reader is meant to use and it carries
the name, so the inputs are taken out of the tab order and the
accessibility tree instead of being given a second, duplicate name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The four window choices are unbreakable labels in a flex row with
`overflow: hidden`. At 400 px the row needs 448 px, so "Last 365 days"
was cut to "Last 36" and could only be reached blind with the keyboard;
it was the one element on the pages checked that overflowed a phone's
width. The group now wraps inside the width it has, and the choices grow
to fill each row so the second row is not a lone short button.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
App rendered `<ReactionPicker />` unconditionally. It is an async
component, so rendering it at all fetched its chunk, which statically
imports NcEmojiPicker -- the 935 KB emoji-picker chunk with its emoji data
and NcColorPicker. Every page load, logged-out visitors included, paid for
it although most readers never add a reaction.

App now listens for the first REACTION_PICK itself and only then mounts
the picker, handing it that first request as a prop: the picker was not
listening yet when it was sent. From then on the picker takes requests
over the bus as before, and App stops listening.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The profile asked `/accounts/familiar_followers` on every profile it
drew. The route needs a login, so a visitor who is not logged in got a
401 in the console on every `/@user` page, for a line that could never
say anything to them: they follow nobody here. The request is now left
out when the page is served to a visitor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Without `overwrite.cli.url` an administrator is handed the setup page by
an early return in `NavigationController::navigate()`, before the admin
checks are added to the page's data. The setup form reads
`serverData.checks.success`, threw while rendering, and the administrator
saw an empty content area instead of the form. Once the form was
submitted, the main page read the same key.

Both sides now hold: the setup path sends a well-formed `checks` (nothing
probed yet, since there is no address to probe from, and the addresses as
configured), and App only reads `checks` where there are some. The App
test for this now uses the payload the server actually sends on that
path instead of one that always carried `checks`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Post text draws each Unicode emoji as `img/twemoji/<codepoint>.svg`,
copied at build time out of the `twemoji` package. That was pinned at
12.0.1 (March 2019, Unicode 12), and `twemoji` itself stopped at 14.0.2,
so every emoji added since -- 🥹, 🫠, 🩷 and the rest -- had no file and
drew as a broken image, in the reader's own posts and in everything
arriving from Mastodon.

`@twemoji/api`, the maintained continuation of the library, ships no
pictures in its npm package (only the JavaScript; the SVGs are served
from a CDN), so it cannot be the copy source. `@discordapp/twemoji`
16.0.1, Discord's fork, ships them in the same layout under `dist/svg/`
(3,846 files, Unicode 16) under the same MIT / CC-BY-4.0 licences. It
replaces `twemoji` as the devDependency and as the code point reference
the tests compare against. The dependabot rule holding `twemoji` below
13 goes with the package.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Whatever Twemoji the build copies stops at some Unicode version, and an
emoji newer than that has no file under `img/twemoji/`. `Emoji.vue` had
no error handler, so such an emoji drew as the browser's broken-image
glyph. It now falls back to the character, in the reader's own emoji
font, when the picture fails to load, and tries the picture again for
the next emoji it is handed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…ed's file

The first-run introduction is shown only for `?welcome=1`, which the
account setup screen reloads with; the server's `firstrun` flag is
always false. So an account made by `occ social:account:create`, every
account older than the setup screen, and anybody who navigated away half
way never saw it, and nothing opened it again. Settings now has an
Introduction section whose button goes to the home timeline with
`?welcome=1`. Closing the introduction takes `welcome` out of the
address, so a reload does not bring it back.

The follows step accepted only `.csv`, while the user guide tells
Pixelfed users to upload their `pixelfed-following.json` there. The
import route already reads JSON (Settings → Migration uses it), so the
picker now offers `.json` too, and the text names both files. Its hidden
file input is kept out of the tab order, as on the Migration page.

Showing the introduction once per account however the account was made
needs a server-side flag and is not part of this change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The same pattern as on the Migration page: the file input is moved off
screen with hidden-visually but stays focusable, so a keyboard user met
an unnamed "file upload" stop next to the "Choose a picture or a video"
button that opens it. The composer's own attachment input already takes
itself out with `tabindex="-1"` and `aria-hidden`; this one now does too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
`social-profilePage.js` is added to Nextcloud's core profile page for
every user, and `ProfileStatusCard` imported `TimelineEntry` statically,
so the entry carried the whole post renderer -- TimelinePost, PostMenu,
MediaAttachment, GalleryCarousel, the hover card, polls, quotes -- at
422 KB, and a second copy of the components the app already has in its
own chunk. The card now loads `TimelineEntry` with
`defineAsyncComponent`; webpack puts it in the chunk the app shares, and
the entry is 147 KB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
`sass` compiles the components' SCSS during the webpack build and is
imported by nothing that runs in the browser, but it was listed under
`dependencies`. A test now checks that everything under `dependencies`
is imported from `src/` or is a peer that such an import requires.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…n it does not

The announcement card was drawn only from `choose()`, with the handle and
address `mounted()` fetches. A reader who picked a network before that
answer landed got a card saying `@you` with no address, which was never
redrawn, and "Save the card" saved exactly that. A failed fetch was only
logged, and "Copy the words" then copied an empty string and said
"Copied".

The card is now redrawn whenever the announcement changes; a failed
fetch says so, with a way to ask again; and saving or copying waits until
there is something to save or copy. Around it: "Copied" goes back to
"Copy the words" after a moment, picking another network forgets the
archive and the people found for the previous one, and the canvas is a
`role="img"` so its label is announced.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
App.vue carried two `prefers-reduced-motion: reduce` blocks for the page
transition; the second was a subset of the first, one of those copies
that drift apart the first time one of them is edited. The first, which
also covers the directional transitions and the view-transition
pseudo-elements, stays.

MigrationSettings' header comment still described it as a section of
Settings whose heading lives in `Settings.vue`; it is the body of its
own page now, and says so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…emcache

Without memcache.local or memcache.distributed, createDistributed() hands
the app a NullCache: every set() is dropped and every get() answers null.
The inbox throttle therefore read a count of zero on every delivery and
never refused one, and the LD-signature replay cache accepted the same
signature as often as it was sent. Nothing told the administrator.

DurableCache is the distributed cache when ICacheFactory::isAvailable()
says there is one and a small table (social_durable_cache, new step
Version1000Date20260925000010) when there is not, behind the get / set /
inc / remove-with-TTL subset those callers need. Cron\Cache purges the
expired rows. InboxLimiter and SignatureService's social.ldsig records
use it; the delivery breaker and the HTTP-signature replay cache are left
to their own changes.

A new setup check, MemcacheConfigured, warns when there is no memcache
and names what is kept in the database, what is off and what is
recomputed on every request, with a section in docs/Admin.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
StatisticsService kept its fifteen-minute copy of the page in the
distributed cache, which on an instance with no memcache is a NullCache.
There the page, the most expensive read the app has, was counted again
on every load whether the client asked for a fresh one or not: 1.2 s and
27 queries per request on a 400-post instance.

It now keeps the page in DurableCache, which falls back to the
social_durable_cache table. The setup check and the admin guide no
longer list the statistics among what is recomputed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
GET /api/v1/directories took 3.4 to 5.3 s on every load: sources() found
out each federated peer's software from its NodeInfo and fetched the
fediverse.info server list right there, and the day and week it kept the
answers for were in the distributed cache, which on an instance with no
memcache keeps nothing. Even with a memcache the first load after every
expiry paid for it.

FediverseDirectoryService::refresh() now does that work from Cron\Cache,
at most eight NodeInfo lookups a run, and writes what it learned to the
directory_known app value. sources() only reads it, so neither the page
nor the peer trends that share it ever ask a remote host to list the
sources. An app value rather than a cache: the cron writes it and a web
request reads it, and APCu, the memcache of most small instances, is not
shared between the two. A fresh install lists no peers or discovered
servers until the cron has run once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Cron\BlocklistSync is a TimedJob, and a TimedJob that takes no argument
runs only once <background-jobs> in appinfo/info.xml lists it. It was
never listed and nothing adds it to the job list, so a followed block
list was read once, when an administrator pressed Check now, and never
again, while docs/Admin.md promises a daily re-read.

It is in info.xml now, which Nextcloud registers on the next app upgrade
or enable. A test asserts that every TimedJob in lib/Cron is registered
there and that the rest are the two QueuedJobs that are added on demand
with an argument (ActorCleanup, DomainPurge), so the next one cannot be
forgotten the same way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…uest

Every actor, inbox and post id is minted from social_url. It was set on
the first page load that found it empty, from getAbsoluteURL() of that
request's route, so from the host and scheme the request arrived with and
not from cloud_url / overwrite.cli.url. An instance first opened through
an internal hostname, or behind a proxy without overwritehost /
overwriteprotocol, federated every id under that address for good while
cloud_url was right, and the address check compared only cloud_url.
The line also ran before cloud_url was set, on the fall-through for a
non-admin.

ConfigService::setSocialUrl() now derives it from cloud_url plus the
app's route path (taken from /apps/ on, so web root and index.php come
from cloud_url) and stores nothing while cloud_url is unset.
occ social:reset derives it again after the flush, so --uri moves both.
Existing values are left alone: they are inside every id already
federated. Instead CloudAddressMatches now also compares the scheme,
host and port of social_url with cloud_url and says how to get out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Application.php loads the app's own vendor/autoload.php, and composer
prepends it to the server's, so a dev-only package the server also
carries is served in place of the server's own. With symfony/console
6.4 in require-dev, a dev checkout inside Nextcloud 36 (which ships 7.4
and calls Application::addCommand()) broke every occ call. That is why
the interop workflow, the one job that runs the app against Nextcloud
master, has been red since 2026-09-21.

The interop job now installs with --no-dev before the server is set up
and adds the dev dependencies only for the test runner. The dev
requirement is widened to ^6.4 || ^7.4 (psalm, the only other package
needing it, takes ^6 || ^7 || ^8) and the lock moves symfony/console
alone to 7.4.19, the major Nextcloud 35 and 36 ship. A dev checkout next
to Nextcloud 34, which ships 6.4, is now the mismatched one; a lock can
only match one of them. A test pins the locked major and the order of
the interop steps.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…rose

The review found the documents a reader acts on saying things the code
no longer does, none of which DocumentationTest could see:

- README, the app-store text and the user guide promised every Nextcloud
  group as a list; a group is a list only once an administrator chose it.
- The same three said Mastodon apps cannot connect, and
  Mastodon-Compatibility.md said no rewrite or guidance ships (§3.1, §9
  item 1). The rules are in contrib/webserver/, explained in Admin.md and
  checked by ClientApiAtRoot; all of them now say so, and that it is a
  web-server change.
- The README called per-user domain blocks API-only (Blocking → Hidden
  servers exists); the user guide said there is no post translation
  (there is, through the server's provider) and that the handle is the
  user id (it is chosen on the setup screen and cannot be changed).
- Mastodon-Compatibility §5.3 and item 28 said posts are never written
  back; PostImportService does, as new local posts.
- The annual report was listed as a feature of the web client; it is
  API-only.
- Counts of commands, setup checks, routes, router entries, admin cards,
  review switches, settings rows, background jobs and dashboard widgets
  were wrong nearly everywhere. They are gone from the prose; the
  router table in Architecture.md is complete again.

The User Guide and Mastodon-Compatibility stamps now say which sections
were re-checked against 0.26.60; Technical-Debt and Performance were not
re-verified and keep theirs. DocumentationTest gains checks for the
client-connection wording, the group-list promise and counted prose.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The Idempotency-Key record of POST /api/v1/statuses was kept in the plain
distributed cache, which without a memcache forgets every write: a client
that retries a post (Tusky and Ivory do) published it twice. It is in
DurableCache now, beside the inbox throttle and the replay records.

The memcache setup check no longer says the delivery breaker is off: it is
kept in social_host_breaker whether or not there is a memcache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Frank Karlitschek and others added 2 commits September 25, 2026 11:14
Stream tag rows are stored in FollowedTagsRequest::normalise() form now,
which drops a leading '#', so the trend counts come back keyed by the
bare tag. The integration test wrote its fixtures as '#itest-steady' and
looked the counts up by that key, and found nothing. A post never holds
the '#' anyway: Note::fillHashtags() strips it when a post is read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
@karlitschek
karlitschek merged commit bf3c123 into master Sep 25, 2026
52 checks passed
@karlitschek
karlitschek deleted the fix/review-2026-09-25 branch September 25, 2026 09:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant