feat: view transitions, keyboard reading, and a face for every instance - #2075
Merged
Conversation
Blocking and muting could only be done from an account's profile, and there was no way to see what you had blocked: `/api/v1/blocks` and `/api/v1/mutes` answered correctly but nothing in the web client ever called them, so undoing a block meant remembering who it was. Settings → Blocked and muted accounts now lists both, with unblock and unmute inline. Unblocking goes through the store action the profile page uses, so a relationship stays in step wherever it is shown, and a row only disappears once the server has confirmed — a refusal leaves it where it was rather than claiming success. The page paths of the client-side router also needed server routes: /blocked, and /follow_requests which had the same defect already — reloading or bookmarking either was a 404. They both render the app shell now. The route names carry a postfix because routes are keyed by name, and re-using one silently drops every declaration but the last. Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com> Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
'Reset local cache' posted to /api/v1/cache/refresh, a route that does not exist and never has: the request 404s, nothing catches it, so the button reported nothing and did nothing. It arrived with the v0.9.3 design refresh without a backend to talk to. 'Help & documentation' pointed at a personal fork rather than any documentation of this app. That leaves the settings section with the one entry that does something. Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com> Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The app runs on Vue 3.5 but carried Vue 2 idioms that the compiler accepts and then ignores, so each one silently did nothing: - The timeline's fade was dead. Both <transition-group>s use the `list` transition, whose starting class was `.list-enter` — Vue 2's name. Vue 3 applies `.list-enter-from`, which nothing defined, so posts appeared abruptly. Corrected, given the `.list-move` that makes the neighbours slide instead of jump when an entry arrives, and defined once in the global block rather than duplicated in a scoped one. - The welcome banner animated out but never in: `slide-fade` had no enter classes at all. - The report dialog could not close itself. `:open.sync` is Vue 2; Vue 3 ignores the modifier, so the binding was one-way and the dialog's own dismissal never reached the flag. Now `v-model:open`. - eslint was linting a Vue 3 app against the Vue 2 ruleset. The shared config ships a `vue3` sub-config for exactly this; switching to it rejected nothing valid and immediately found four unused bindings, including an unused linkifyjs namespace import. - Five components emitted events without declaring `emits`. - Motion now respects prefers-reduced-motion everywhere, which nothing did before. - Deleted the popoverMenu mixin, unused since NcActions replaced hand-rolled menus, and the App-level composer path that Navigation stopped emitting when it grew its own modal. tests/js/vue3.test.js asserts all of it against the source, because none of these fail loudly: it reports the file and line of a Vue 2 transition class, a `.sync` binding, a removed lifecycle hook or API, an undeclared emit, or an animation without a motion guard. Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com> Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The app had four opacity transitions and no keyframes at all. This gives the surfaces people touch most some feedback, in plain CSS — no animation library, nothing added to the bundle but the rules themselves. - Liking a post pops the heart and sends a ring out of it, once, and boosting spins the icon. Undoing neither: there is nothing to celebrate about taking a like back. - Both actions are applied optimistically and were rolled back in silence when the server refused. The button now shakes, so a refusal is something you see rather than something you miss. - The first page of a timeline is three post-shaped placeholders with a travelling sheen instead of a spinner on an empty page, so the timeline settles into its own shape. Later pages keep the spinner: the posts are already on screen. - Poll results grow from zero, one bar after another. - Images resolve out of the blurhash the app already decodes and then threw away in a single frame — it now fades under the image. - Posts that arrive while you are further down the page offer a pill instead of appearing unannounced above the fold. - The composer shows the length allowance as a ring that fills, turning amber near the limit and counting down past it, rather than a limit discovered by hitting it. - Post cards lift slightly under the pointer and take a focus ring. Every one of these is behind prefers-reduced-motion, and the guard test now insists on it for `animation` as well as `transition`. TimelineList also asks for its own translate functions rather than leaning on the implicit globals, which is how the components that were already testable do it — the global `n` does not interpolate. Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com> Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Where the previous pass gave the app feedback, this one gives it depth, identity and speed — still CSS and platform APIs only, still nothing added to the dependency list. The platform, where it exists and motion is welcome: - Tapping a picture grows the thumbnail into the lightbox and shrinks it back, through the View Transitions API and a shared name on the two elements. Route changes go through a transition too, so timeline, profile and post read as one surface changing. - Posts rise into place as they scroll into view, driven off the scroll position by the compositor: `animation-timeline: view()`, no listener, no observer, no main-thread work. `@supports` means browsers without it simply show the posts. Federation, which was invisible: - Every instance gets a colour of its own, derived from its hostname, so it is the same everywhere without any coordination. Remote avatars carry it as a ring, and a chip next to the handle names the server. A timeline now visibly spans the network instead of hiding it after the @. Reading, which needed a mouse: - j/k walk the timeline, l/f like, b boosts, r replies, o opens, n writes, g goes home, ? lists all of it. Nothing fires while you are typing — in a field, in the composer, or with a modifier held. - The post the keyboard is on says so, and it is the one l/b/r act on. And the smaller things: - A reply chain is drawn on one spine instead of three stacked lists, with the post being read raised off it. - A profile is tinted by its own banner, sampled from a few pixels of it, and the banner drifts as the page scrolls. A banner that cannot be read leaves the theme's colour alone. - Empty-state artwork settles in rather than appearing, and stops glaring on a dark background. - A like ticks the phone, for anyone holding one. Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com> Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
This was referenced Sep 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacks on #2074, which stacks on #2073. Merge in that order.
Where #2074 gave the app feedback, this gives it depth, identity and speed. Still CSS and platform APIs only — no new dependencies, nothing added to the entrypoints but rules.
The platform features the app wasn't using
The codebase had no
startViewTransition, noanimation-timeline, no@supportsanywhere.NcModal. The thumbnail and the viewer now share aview-transition-namefor the length of one transition, so the browser carries one into the other — and back on close.animation-timeline: view(). No scroll listener, no IntersectionObserver, no main-thread work. Behind@supports, so browsers without it just show the posts.All three are enhancements: no API, or
prefers-reduced-motion: reduce, and the app behaves exactly as it does today.Federation, which was invisible
The instance an account lives on appeared only inside a
titleattribute. Now every instance has a colour derived from its hostname — the same colour on every client and every session, with no storage or coordination:A timeline now visibly spans the network.
instanceIdentity.jsis a pure function with 11 specs, including that two accounts on one instance always agree and that five different hosts get five different colours.Reading without a mouse
There were no shortcuts at all beyond ctrl+enter. Now: j/k walk the timeline, l/f like, b boost, r reply, o/Enter open, n write, g home, ? lists them all.
The part that matters is what doesn't fire: nothing happens while you are typing — in an input, in the contenteditable composer, or with any modifier held — because a shortcut that eats a keystroke mid-sentence is worse than no shortcut. The focused post is outlined, and it is the one
l/b/ract on; boosting a direct message is refused rather than attempted.And the rest
TimelineSinglePoststacked ancestors, the post and descendants as three unrelated lists. They now hang off a single spine, with the post being read raised off it.Tests
41 new specs — 681 vitest total, 1839 PHP, eslint and stylelint clean. The interesting ones: shortcuts staying silent while typing (four ways), j/k stopping at both ends, the focused post acting and its neighbours not, view transitions applying the change exactly once and still applying it when the transition itself fails, and the instance colour being stable and collision-free.
Two notes on honesty:
The empty-state illustrations are not accent-themed. I said I'd theme them; they turn out to hardcode
#0082c9and are served as<img>, so CSS can't reach inside. Doing it properly means inlining eight ~30 KB SVGs into the bundle, which isn't worth it. They now behave well in dark mode instead, and I'd rather say that than claim the feature."Delivering to N servers" is not here. The delivery queue is real (
social_req_queue) but no endpoint exposes per-activity state and the status response carries no token to correlate with, so it needs backend work rather than a frontend touch. Happy to do it as its own PR.🤖 Generated with Claude Code