Skip to content

feat: add desktop subscription group management - #32

Draft
kapdon wants to merge 32 commits into
TypeType-Video:devfrom
kapdon:codex/subscription-groups-ux
Draft

kapdon wants to merge 32 commits into
TypeType-Video:devfrom
kapdon:codex/subscription-groups-ux

Conversation

@kapdon

@kapdon kapdon commented Sep 15, 2026

Copy link
Copy Markdown

Summary

Add a desktop subscription group manager at /subscriptions/groups, opened from Subscriptions → Channels → Manage groups, following the placement discussion.

Refs TypeType #172. Requires TypeType-Server #86 for paginated membership reads and selected-channel refreshes. This PR remains a draft while the coordinated change is reviewed.

  • Fetch and render server pages directly. Search, named/ungrouped membership filters and the In group / Not in group toggle run through the new page contract. Select page selects the visible page; previously selected channels and unsaved edits remain available across pages and filters. A bounded lookup refreshes explicitly selected channels, including those outside the current page. The inline editor and save calculations use these refreshed records while preserving unsaved drafts.
  • Create, rename and delete groups. One selected channel opens a searchable group combobox; multiple selections use bulk actions with the active sidebar group as the default target. Keep TypeType's square controls, theme tokens and compact desktop layout, with both lists' pagination visible.
  • Respect the API's 500-URL, 2048-character and 1 MiB limits. Run at most three membership writes concurrently, skip no-op changes and target cache refreshes. Keep partial-edit drafts; pause editing after failed data refreshes and expose Retry without replaying successful writes.
  • Add filtered Channels/Videos views, structured API errors, request cancellation, stale-group recovery, accessible group action menus and English/French/German messages. Preserve the empty-account path and independent filtered feed cursors.
  • Include the local 150-channel/18-group fixture (bun run dev:groups-fixture) with overlapping memberships and the paginated contract. No new Markdown files are included.

Scope and acceptance criteria

Desktop group CRUD, inline/bulk membership editing, server pagination/search, retained selections and error recovery are included. Compact mobile composition and organization of only newly imported channels are explicitly deferred; the post-import link opens the full manager. The stacked narrow-screen fallback is not the proposed mobile workflow. This is a partial implementation of #172 and does not close that issue.

Validation

  • bun run check, bun run test (378 passed), bun run knip, bun run sherif, bun run build and git diff --check passed.
  • Query/API regressions cover independent page/filter caches and feed cursors, cancellation, structured errors, byte-safe batches, bounded concurrency, off-page selection refresh, stale page records versus refreshed inline edits, retained drafts, partial writes and refresh recovery.
  • Chromium checks with the fixture cover cross-page selection/drafts, server request parameters, group search and inversion, last-page adjustment after removal, default bulk targets, action-menu keyboard behavior, Retry recovery, and saving the intended memberships after an external change. The latest 1280 × 720 desktop view fits without document scrolling.
  • The companion server PR passes its full test suite (1,251 passed, 3 skipped), build, OpenAPI and coverage checks against PostgreSQL 16, including route/service tests with 25,000 channels and 112,500 memberships, Unicode search and account isolation.
  • Firefox/WebKit and a browser session against the running Kotlin server remain unverified. Vite retains its existing large-chunk warning; Knip reports two existing stale ignore hints.
  • Each new commit has fewer than 300 insertions.

Latest desktop preview

Disposable fixture data, with Cooking selected, its matching bulk target and the inline group combobox open.

Latest subscription group manager with server pagination and inline group editing

@kapdon

kapdon commented Sep 15, 2026

Copy link
Copy Markdown
Author

Hey @Priveetee, where would you like the group management page to live?

Currently it is accessed through a Manage groups button under Subscriptions → Channels, as shown below. Would you prefer to:

  • Keep it as a separate page accessed from Channels;
  • Add a Groups tab next to Channels; or
  • Replace the current Channels page with the new management view, since the current page is mainly a list of subscribed channels?

Current placement (desktop preview with sample data):

Channels page with the Manage groups button at the upper right, below the Videos and Channels tabs

@Priveetee

Copy link
Copy Markdown
Member

Mmmh, I think that keeping it as a separate page for now makes the most sense, opened through the Manage groups button under Subscriptions → Channels.

This keeps the existing Videos / Channels navigation simple and preserves Channels as the normal subscription list, while the group manager stays focused on organization. I wouldn’t replace the Channels page.

We can revisit a dedicated Groups tab later if discoverability becomes an issue. For now, the current placement feels right to me.

@Priveetee

Copy link
Copy Markdown
Member

And please don't forget to split ur commit ;p

@kapdon
kapdon force-pushed the codex/subscription-groups-ux branch from 4405d41 to 8683521 Compare September 15, 2026 21:44
@kapdon
kapdon marked this pull request as ready for review September 16, 2026 15:25

export function GroupChannelList(props: Props): React.JSX.Element {
const [anchor, setAnchor] = useState<string | null>(null);
const pagination = useGroupPagination({

@Priveetee Priveetee Sep 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In TypeType #172, the scope explicitly targets large subscription lists. This pagination only slices props.channels after /subscriptions/group-memberships has already returned and parsed the complete projection. The Server currently also builds that projection in memory. For those accounts this still means one large response, database work, and browser allocation before the first page is usable. Can we move page/search to the Server contract and add the corresponding OpenAPI/tests, or narrow the feature scope before merging?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented the server contract in TypeType-Server #86: SQL filtering/counting with page, limit, search and named/ungrouped/inverted membership filters, plus OpenAPI and route/service tests with 1,001 subscriptions. f14b697 and 681c587 render returned pages directly. “Select page” is explicit, and a bounded lookup refreshes selected channels retained across pages; the manager no longer downloads the complete projection.

};

export function useSubscriptionFeed(): Result {
export function useSubscriptionFeed(filter = "all"): Result {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The feed now accepts a filter, but the avatar fallback still calls useSubscriptions() without it. On every named-group Videos view, the page fetches the filtered subscriptions and this hook fetches the complete list again just to build avatarMap. Pass the active filter, or reuse the page query, so a group view does not double the subscription payload.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3656733: the feed passes its active filter to useSubscriptions(filter), sharing the page query/cache for avatar fallback. Named and ungrouped regression cases confirm that a filtered view does not fetch the default full subscription list for avatars.

const failed = new Set<string>();
for (const change of changes) {
const urls = [...new Set(change.channelUrls)];
for (let offset = 0; offset < urls.length; offset += 500) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API limit is not only 500 items; the Server also rejects bodies over 1 MiB and individual URLs over 2048 characters. Count-only chunks are not byte-safe for multibyte or escaped URLs. Please chunk by serialized byte size and cover the upper boundary, otherwise a valid large edit can fail with 413.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 991c863: batches are bounded by the UTF-8 byte size of the complete serialized JSON body as well as 500 URLs, and URLs over 2048 characters are rejected before sending. Tests cover multibyte and escaped URLs at the body-size boundary. The new selected-channel lookup reuses the same batch helper.

<p role="alert" className="py-10 text-center text-sm text-fg-muted">
{m.sg_load_error()}
</p>
) : subscriptions.length === 0 ? (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replaces the existing empty-account state for the default all view with sg_no_channel_match(). A user with no subscriptions now sees a “no channel match” message instead of the normal empty state. Keep the old message when group === "all" and use the match message only for an actual filter.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3656733: the all Channels view keeps the existing no-subscriptions message. The no-match message is used only for an actual group filter.

{query.isLoading ? (
<VideoGridSkeleton idPrefix="subscription-channels" />
) : query.isError ? (
<p role="alert" className="py-10 text-center text-sm text-fg-muted">

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error branch leaves the user with no Retry action. A transient failure on the filtered /subscriptions request can only be recovered by navigating away or waiting for cache state to change. Please expose query.refetch() here, as the Videos page now does.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3656733: the Channels error state now exposes a Retry action backed by query.refetch(), with a disabled/retrying state while the request is in flight. Verified transient-failure recovery in the browser.

>
<MoreHorizontal size={16} />
</button>
{menuOpen && (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This action popup is a fieldset with ordinary buttons and has no outside-click or focus handling. Opening more than one item can leave multiple menus open, and keyboard users do not get menu semantics. Please make it a real menu or popover with a focus boundary and close behavior.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 855e7a2: group actions now use menu/menuitem semantics, focus movement, Escape dismissal with focus restoration, and outside-click/focus-out closure. Opening another group’s menu closes the previous one. Checked keyboard navigation and close behavior in the browser.

className="h-9 max-w-64 border border-border-strong bg-app px-3 text-sm text-fg focus-visible:outline-2 focus-visible:outline-accent"
>
<option value="all">{m.sg_all_subscriptions()}</option>
<option value="ungrouped">{m.groups_preview_ungrouped()}</option>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Production code is using groups_preview_ungrouped, a key owned by the mock/prototype flow. Reusing it couples the real manager to prototype copy and makes future wording changes affect both surfaces. Please add a manager-specific message key.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3656733: production group filters and the manager use the new sg_ungrouped message, with English, French and German translations. The groups_preview_ungrouped key remains confined to the preview flow.

selection.select(new Set());
setOnlySelected(false);
}
async function bulk(groupId: string, action: "add" | "remove"): Promise<void> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bulk() can still call updateGroupMemberships with an empty channelUrls list when all selected channels already have, or do not have, the target group. That produces a success notice for zero changes and an unnecessary invalidation. Disable the action or return early when there is no effective change.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 991c863: bulk add/remove returns immediately when no selected membership would change; clearing an already-ungrouped selection does the same. No request, success notice, invalidation or selection clearing is produced for a no-op. Verified the no-op path in the browser.

value={group}
onChange={(value) => void navigate({ search: { group: value } })}
/>
{query.isLoading || isLoading ? (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old early return avoided starting the feed query for an account with no subscriptions. Now the default empty account waits for both /subscriptions and /subscriptions/feed (which may return 202) before showing the empty state. Keep the fast empty path, or short-circuit the feed when the unfiltered subscription query is empty.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3656733: the feed query is gated on subscription data, and prefetch also stops when the subscription list is empty. The default empty-account view can render without waiting for /subscriptions/feed. Added regression coverage and verified that the empty Videos view makes no feed request.

queryKey: SUBSCRIPTION_FEED_KEY,
queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam as string | null, signal),
queryKey: filter === "all" ? SUBSCRIPTION_FEED_KEY : [...SUBSCRIPTION_FEED_KEY, filter],
queryFn: ({ pageParam, signal }) =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new filter changes both query keys and request parameters, but the added hook tests only exercise the default all path. Please add coverage for named and ungrouped filters, including independent cursors, so a future change cannot cross-contaminate feed caches.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added named-group and ungrouped coverage in 3656733, including query parameters, independent feed cursors and avatar-query reuse. The tests exercise the shared query options used by the hook and verify that one filter cannot advance or overwrite another filter’s cached feed.

@Priveetee Priveetee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went through the PR again. I like the direction of the UI and the amount of work that went into it! :)

There are two important points I’d like to sort out before merging.

The first one is the channel-list pagination. The UI only slices props.channels after /subscriptions/group-memberships has already returned and parsed the complete membership projection. The Server also builds that complete projection in memory before sending it. With a large subscription list, the first page still waits for the full response, all database work, and all browser allocations. Changing page only changes which already-loaded rows are rendered.

This matters for TypeType #172, which is specifically about managing large subscription lists. Could we move page, limit, search, and group-membership filtering into the Server contract, return the requested page with its total count, and add the matching OpenAPI and route/service tests? The frontend could then render the returned page directly instead of paginating a complete in-memory projection.

I’d also like to keep this PR focused on the actual frontend implementation and its tests. Could you remove all Markdown files introduced by the PR: DESIGN.md, PRODUCT.md, docs/subscription-groups-fixture.md, and docs/subscription-groups-ux.md? I don’t want any new .md files in this PR. They contain planning notes, design summaries, fixture instructions, and verification claims rather than files required for the feature itself. Some of the claims are also ahead of the current implementation, especially the statement that selection works across every page while the complete list is still loaded up front.

Once the pagination contract is handled on the Server side and the extra Markdown files are removed, I’ll gladly review the PR again! ;)

If you need a hand with the Server-side code or the integration, don’t hesitate to ask, I’ll be happy to help! :)

@kapdon
kapdon marked this pull request as draft September 17, 2026 11:06
@kapdon

kapdon commented Sep 17, 2026

Copy link
Copy Markdown
Author

Hey @Priveetee, I marked this ready for review too early by mistake—sorry for the premature signal. It is back in draft while the coordinated frontend/server change is reviewed.

Following your latest review:

  • TypeType-Server #86 adds SQL pagination, search, membership filtering and totals, with OpenAPI and route/service tests. This PR now renders returned pages directly and retains explicit selections through a bounded lookup.
  • Removed all four added Markdown files and the design-planning metadata.
  • Replied individually to all 16 inline comments with the changes or the explicit desktop/import scope deferrals.

Frontend checks passed (376 tests), and the server checks passed (1,248 tests, 3 skipped). Firefox/WebKit and a browser session against the running Kotlin server still need verification.

Latest desktop UI at 1280 × 720, using the disposable 150-channel fixture:

Latest subscription group manager with server pagination and inline group editing

@Priveetee

Copy link
Copy Markdown
Member

oki doki np, take ur time :)

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.

3 participants