Channel typeahead: ~ links a channel #320

Merged
robbertbos merged 1 commit from channel-typeahead into main 2026-08-16 13:57:29 +00:00
Owner

Typing ~ in a composer offers the account's channels and inserts a reference to the one you pick. Closes #274. Rebased on #313, whose POST rule this follows.

Two forms, because Mattermost has two

~name is linkified client-side against the channel map of the team the message is read in (getChannelsNameMapInCurrentTeam), and slugs are unique per team only (channels_name_teamid_key UNIQUE (name, teamid)). A ~name from another team therefore arrives as plain grey text. There is no team-qualified ~ syntax: the tokenizer charset [a-z0-9.\-_] has no : or /.

The cross-team form is the channel URL, which is what MM's own "Copy Link" produces and what MM resolves in-app from any team by reading the team name out of the path.

So the list offers both, and the row says which it will insert:

Row Meta line Inserts
channel in this team ~incidenten ~incidenten
channel in another team its team name, plus a link icon https://host/ops/channels/ops-incidenten

Same-team rows first. The live region spells the difference out, since the icon is not readable: "Ops incidenten in Ops Team, voegt een link in".

No Mattermost traffic per keystroke

Candidates come from mm_targets_service, already cached per (user_id, account_id) for five minutes and already spanning every team, so after the first call a keystroke costs nothing upstream. Target gained channel_name and team_name: the two values a ~name and a channel URL are built from, both already fetched. That is a deliberate whitelist, so it is the one thing worth a second look in review.

Interaction with #313

Both mention-candidates routes keep the POST shape from #313; kind: "user" | "channel" sits on the shared MentionQuery body model, so the typed query never reaches a query string. channel_id becomes optional for kind=channel (a new message has no recipient yet) and yields a 400 for kind=user. Two things needed fixing to hold that line:

  • The ~ fetches were on GET with ?q=, next to an @ that #313 had already moved into a body. Verified after the change with network capture in the running app: every candidates request is a POST and no URL carries the typed text.
  • waggle:recent-channels joins SWEEP_PREFIXES in localTraces.ts. Without it the list of channels you link most survived logout on a shared machine. Registered by prefix, so it also covers the -v2 key and future versions.

Also in here

  • get_channel maps 401 to MattermostTokenInvalidError. It was the only path where an expired token surfaced as 502 instead of 401, and the two tests covering it patched the failing call away.
  • @'s empty state names the field it needs ("Kies eerst een kanaal bij Aan") instead of advising the user to keep typing, which could not help. ~ needs no recipient.
  • Empty-state text moved to the parent, so the visible row and the screen-reader announcement are one string rather than two hand-synced copies.
  • Recents key on channel id, not slug: slugs collide across teams.
  • The three typeahead dropdowns now share one CSS module (274 lines to 239) instead of three near-copies, and 16 lines of dead CSS are gone. NLDD has no component that fits: nldd-menu has the right ARIA but a fixed item content model, nldd-list's listbox mode renders its own input, and our query is typed at the caret inside CodeMirror.
  • The dev mock grew to 14 channels over two teams; with one team the team-scoping behaviour cannot be seen.

Checks

2002 pytest at 100% coverage, 1218 vitest, 120 e2e, vue-tsc, build, all pre-commit hooks. Driven by hand in the preview against the mock: both row kinds, both insertions, the recents hoist, the announcements, and the POST bodies.

Two findings the e2e caught that unit tests could not: nldd-icon[name="link"] never matches in a browser, because Vue sets a property on an upgraded custom element while the vitest stub takes an attribute; and the trigger regex needed the i flag, since MM searches display names while slugs are lowercase.

Not in scope

Waggle does not render ~channel in received messages (lib/mentions.ts handles @ only), so a link you send shows as literal text here and correctly in Mattermost. Worth its own issue.

Typing `~` in a composer offers the account's channels and inserts a reference to the one you pick. Closes #274. Rebased on #313, whose POST rule this follows. ## Two forms, because Mattermost has two `~name` is linkified client-side against the channel map of the team the message is *read* in (`getChannelsNameMapInCurrentTeam`), and slugs are unique per team only (`channels_name_teamid_key UNIQUE (name, teamid)`). A `~name` from another team therefore arrives as plain grey text. There is no team-qualified `~` syntax: the tokenizer charset `[a-z0-9.\-_]` has no `:` or `/`. The cross-team form is the channel URL, which is what MM's own "Copy Link" produces and what MM resolves in-app from any team by reading the team name out of the path. So the list offers both, and the row says which it will insert: | Row | Meta line | Inserts | |---|---|---| | channel in this team | `~incidenten` | `~incidenten ` | | channel in another team | its team name, plus a link icon | `https://host/ops/channels/ops-incidenten ` | Same-team rows first. The live region spells the difference out, since the icon is not readable: *"Ops incidenten in Ops Team, voegt een link in"*. ## No Mattermost traffic per keystroke Candidates come from `mm_targets_service`, already cached per `(user_id, account_id)` for five minutes and already spanning every team, so after the first call a keystroke costs nothing upstream. `Target` gained `channel_name` and `team_name`: the two values a `~name` and a channel URL are built from, both already fetched. That is a deliberate whitelist, so it is the one thing worth a second look in review. ## Interaction with #313 Both mention-candidates routes keep the POST shape from #313; `kind: "user" | "channel"` sits on the shared `MentionQuery` body model, so the typed query never reaches a query string. `channel_id` becomes optional for `kind=channel` (a new message has no recipient yet) and yields a 400 for `kind=user`. Two things needed fixing to hold that line: - The `~` fetches were on GET with `?q=`, next to an `@` that #313 had already moved into a body. Verified after the change with network capture in the running app: every candidates request is a POST and no URL carries the typed text. - `waggle:recent-channels` joins `SWEEP_PREFIXES` in `localTraces.ts`. Without it the list of channels you link most survived logout on a shared machine. Registered by prefix, so it also covers the `-v2` key and future versions. ## Also in here - `get_channel` maps 401 to `MattermostTokenInvalidError`. It was the only path where an expired token surfaced as 502 instead of 401, and the two tests covering it patched the failing call away. - `@`'s empty state names the field it needs ("Kies eerst een kanaal bij Aan") instead of advising the user to keep typing, which could not help. `~` needs no recipient. - Empty-state text moved to the parent, so the visible row and the screen-reader announcement are one string rather than two hand-synced copies. - Recents key on channel id, not slug: slugs collide across teams. - The three typeahead dropdowns now share one CSS module (274 lines to 239) instead of three near-copies, and 16 lines of dead CSS are gone. NLDD has no component that fits: `nldd-menu` has the right ARIA but a fixed item content model, `nldd-list`'s listbox mode renders its own input, and our query is typed at the caret inside CodeMirror. - The dev mock grew to 14 channels over two teams; with one team the team-scoping behaviour cannot be seen. ## Checks 2002 pytest at 100% coverage, 1218 vitest, 120 e2e, `vue-tsc`, build, all pre-commit hooks. Driven by hand in the preview against the mock: both row kinds, both insertions, the recents hoist, the announcements, and the POST bodies. Two findings the e2e caught that unit tests could not: `nldd-icon[name="link"]` never matches in a browser, because Vue sets a *property* on an upgraded custom element while the vitest stub takes an attribute; and the trigger regex needed the `i` flag, since MM searches display names while slugs are lowercase. ## Not in scope Waggle does not render `~channel` in *received* messages (`lib/mentions.ts` handles `@` only), so a link you send shows as literal text here and correctly in Mattermost. Worth its own issue.
Channel typeahead: ~ links a channel
Some checks failed
CI / release-scripts (pull_request) Successful in 7s
security-scan / SBOM (trivy) (pull_request) Successful in 10s
security-scan / Filesystem scan (trivy fs) (pull_request) Failing after 15s
security-scan / JS SCA (npm audit) (pull_request) Failing after 16s
security-scan / Python SAST (bandit) (pull_request) Successful in 17s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 35s
test-build / build (frontend) (pull_request) Successful in 55s
CI / pre-commit (pull_request) Successful in 1m3s
test-build / build (backend) (pull_request) Successful in 1m5s
CI / frontend-test (pull_request) Successful in 1m6s
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 5m50s
CI / e2e (pull_request) Successful in 7m5s
8acd442673
Typing ~ in a composer offers the account's channels and inserts a
reference to the picked one. Candidates come from the mm_targets cache,
so a keystroke costs no Mattermost request.

A ~name is only linkified against the team the message is read in, and
slugs are unique per team only, so a channel in another team is inserted
as its URL instead. The row shows which of the two it will be.

The @ empty state now names the field it needs ("Kies eerst een kanaal
bij Aan") rather than inviting the user to keep typing; ~ needs no
recipient. Empty-state text moved to the parent so the visible row and
the live region cannot drift.

Closes #274
robbertbos force-pushed channel-typeahead from 8acd442673
Some checks failed
CI / release-scripts (pull_request) Successful in 7s
security-scan / SBOM (trivy) (pull_request) Successful in 10s
security-scan / Filesystem scan (trivy fs) (pull_request) Failing after 15s
security-scan / JS SCA (npm audit) (pull_request) Failing after 16s
security-scan / Python SAST (bandit) (pull_request) Successful in 17s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 35s
test-build / build (frontend) (pull_request) Successful in 55s
CI / pre-commit (pull_request) Successful in 1m3s
test-build / build (backend) (pull_request) Successful in 1m5s
CI / frontend-test (pull_request) Successful in 1m6s
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 5m50s
CI / e2e (pull_request) Successful in 7m5s
to 7bd9bde3b3
All checks were successful
CI / release-scripts (pull_request) Successful in 7s
security-scan / SBOM (trivy) (pull_request) Successful in 9s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 17s
security-scan / JS SCA (npm audit) (pull_request) Successful in 18s
security-scan / Python SAST (bandit) (pull_request) Successful in 18s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 37s
test-build / build (frontend) (pull_request) Successful in 1m1s
CI / frontend-test (pull_request) Successful in 1m11s
CI / pre-commit (pull_request) Successful in 1m17s
test-build / build (backend) (pull_request) Successful in 1m24s
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 3m21s
CI / e2e (pull_request) Successful in 4m52s
2026-08-15 20:30:17 +00:00
Compare
robbertbos force-pushed channel-typeahead from 7bd9bde3b3
All checks were successful
CI / release-scripts (pull_request) Successful in 7s
security-scan / SBOM (trivy) (pull_request) Successful in 9s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 17s
security-scan / JS SCA (npm audit) (pull_request) Successful in 18s
security-scan / Python SAST (bandit) (pull_request) Successful in 18s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 37s
test-build / build (frontend) (pull_request) Successful in 1m1s
CI / frontend-test (pull_request) Successful in 1m11s
CI / pre-commit (pull_request) Successful in 1m17s
test-build / build (backend) (pull_request) Successful in 1m24s
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 3m21s
CI / e2e (pull_request) Successful in 4m52s
to f5b94bb4e2
All checks were successful
CI / e2e (pull_request) Successful in 5m48s
CI / release-scripts (pull_request) Successful in 5s
security-scan / SBOM (trivy) (pull_request) Successful in 11s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 15s
security-scan / JS SCA (npm audit) (pull_request) Successful in 16s
security-scan / Python SAST (bandit) (pull_request) Successful in 17s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 42s
test-build / build (frontend) (pull_request) Successful in 1m4s
CI / frontend-test (pull_request) Successful in 1m6s
CI / pre-commit (pull_request) Successful in 1m12s
test-build / build (backend) (pull_request) Successful in 1m23s
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 4m41s
2026-08-15 21:25:26 +00:00
Compare
robbertbos force-pushed channel-typeahead from f5b94bb4e2
All checks were successful
CI / e2e (pull_request) Successful in 5m48s
CI / release-scripts (pull_request) Successful in 5s
security-scan / SBOM (trivy) (pull_request) Successful in 11s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 15s
security-scan / JS SCA (npm audit) (pull_request) Successful in 16s
security-scan / Python SAST (bandit) (pull_request) Successful in 17s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 42s
test-build / build (frontend) (pull_request) Successful in 1m4s
CI / frontend-test (pull_request) Successful in 1m6s
CI / pre-commit (pull_request) Successful in 1m12s
test-build / build (backend) (pull_request) Successful in 1m23s
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 4m41s
to 23cbaa6f27
All checks were successful
CI / release-scripts (pull_request) Successful in 5s
security-scan / SBOM (trivy) (pull_request) Successful in 12s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 15s
security-scan / JS SCA (npm audit) (pull_request) Successful in 16s
security-scan / Python SAST (bandit) (pull_request) Successful in 18s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 37s
test-build / build (frontend) (pull_request) Successful in 54s
CI / frontend-test (pull_request) Successful in 1m0s
test-build / build (backend) (pull_request) Successful in 1m5s
test-build / build (pull_request) Successful in 0s
CI / pre-commit (pull_request) Successful in 1m39s
CI / backend-test (pull_request) Successful in 2m33s
CI / e2e (pull_request) Successful in 4m10s
2026-08-15 21:47:35 +00:00
Compare
robbertbos force-pushed channel-typeahead from 23cbaa6f27
All checks were successful
CI / release-scripts (pull_request) Successful in 5s
security-scan / SBOM (trivy) (pull_request) Successful in 12s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 15s
security-scan / JS SCA (npm audit) (pull_request) Successful in 16s
security-scan / Python SAST (bandit) (pull_request) Successful in 18s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 37s
test-build / build (frontend) (pull_request) Successful in 54s
CI / frontend-test (pull_request) Successful in 1m0s
test-build / build (backend) (pull_request) Successful in 1m5s
test-build / build (pull_request) Successful in 0s
CI / pre-commit (pull_request) Successful in 1m39s
CI / backend-test (pull_request) Successful in 2m33s
CI / e2e (pull_request) Successful in 4m10s
to a4fa10cb8e
All checks were successful
CI / release-scripts (pull_request) Successful in 5s
security-scan / SBOM (trivy) (pull_request) Successful in 11s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 17s
security-scan / JS SCA (npm audit) (pull_request) Successful in 18s
security-scan / Python SAST (bandit) (pull_request) Successful in 19s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 39s
CI / pre-commit (pull_request) Successful in 59s
test-build / build (frontend) (pull_request) Successful in 1m0s
CI / frontend-test (pull_request) Successful in 1m1s
test-build / build (backend) (pull_request) Successful in 1m5s
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 2m21s
CI / e2e (pull_request) Successful in 3m28s
2026-08-16 13:16:46 +00:00
Compare
robbertbos deleted branch channel-typeahead 2026-08-16 13:57:29 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
robbertbos/waggle!320
No description provided.