Skip to content

feat: pin messages in orgs - #1101

Draft
simonhamp wants to merge 1 commit into
dev-null-sh:productionfrom
simonhamp:feat/pinned-messages
Draft

simonhamp wants to merge 1 commit into
dev-null-sh:productionfrom
simonhamp:feat/pinned-messages

Conversation

@simonhamp

Copy link
Copy Markdown
Contributor

Summary

Org maintainers can now pin messages. Everyone in the org sees pinned messages, including people who were added without Share history and can't read anything from before they joined.

Pinning sits next to Reply and React on a message. Each time a maintainer pins, a dialog warns them that the whole org will see the message, history or not, until they tick "Don't show this again". A pinned message keeps its pin button visible beside it, so maintainers can unpin it without hovering, and contributors see a plain pin icon there.

Pinned messages show as a one-line bar at the top of the chat: a count and the latest pin. Clicking it opens a panel with each pinned message, its author and time, the text cut to three lines with Show more, and Reply and React. Maintainers can unpin from the panel too.

Changes

  • messages.pinned_at is a new nullable timestamp with an index on (conversation_id, pinned_at). It is needed to know which messages an org pinned and in what order. Everyone in the org can see it. It is cleared on unpin and goes with the message.
  • UpdateMessagePin and DeleteMessagePin set and clear it through MessagePinController (PUT/DELETE remotes/messages/{message}/pin). The form requests allow maintainers of the message's org only, not on events, not on messages older than the maintainer's own history, and at most 25 pins per org (Conversation::MAX_PINS). Pinning is blocked during a timeout like the other org actions.
  • Message::isVisibleTo() and RepliableMessage treat a pinned message as visible to every member of its org. That is what lets someone without history load its images and voice note, react, and reply. Unpinning takes that access away again.
  • The org thread payload has a pinned list. To avoid a new column, pinning and unpinning move the conversation's existing reactions_version, so polls only get the list again when a pin or a reaction changed (pinned: null otherwise). The pin endpoints return the list and that version, and the client only accepts a list at least as new as the one it has, so a slow poll can't undo a pin.
  • The pinned list leaves out reply_to, reply_to_id and link previews. Otherwise a member without history could read what an old message replied to.
  • In org threads, every bubble now reserves a bit more room for the action buttons (three instead of two), so nothing rewraps when a message is sent or pinned. Org bubbles in the chat dock are about 2rem narrower than before because of this.
  • The panel reuses ReactionTray with a new placement="beside", so the emoji tray opens next to the item's buttons.
  • The long-press sheet on touch gets a Pin message / Unpin message action for maintainers.
  • The mobile API thread includes pinned too, through MessageResource. There are no pin endpoints in the API yet, so the knowledge base says pins show on the web.

Things to look at:

  • Any reaction anywhere in the org moves reactions_version, so the next poll for each member re-sends the pinned list (up to 25 messages). That felt like a fair trade for not adding a column, but say if you'd rather have a separate version.
  • If someone without history reacts to an old message while it's pinned, they can't remove that reaction after it's unpinned, because the message is no longer visible to them.
  • After pinning, the pin button reads "Pinned" and ignores clicks until the pointer moves off it, so a double click doesn't unpin, following the Follow button.
  • "Don't show this again" is kept in localStorage per user, so it's per browser.

Validation

Local, in a clone on this branch:

  • pint --parallel --test, rector --dry-run and phpstan pass.
  • pest --type-coverage --min=100 is at 100%.
  • The full suite with pest --parallel --ci --coverage --exactly=100.0 passes, 3987 tests, 100% coverage.
  • The frontend format check, lint, both tsc runs and the node unit tests (147) pass, as does the build. These ran through node_modules/.bin because bun isn't installed on this machine.

New tests cover who can pin and unpin, the cap, timeouts, events, history limits, the pinned list on loads and polls, a member without history seeing, reacting to, replying to and loading the image of a pinned message, and the pinned list not leaking what it replies to. The client's pin bookkeeping has unit tests in tests/Unit/Pins.test.ts.

I also used it in the browser as the maintainer and as a member added without history. That covered pinning, the warning, the banner and panel, Show more, reacting and replying from the panel, and unpinning.

Layout shift, measured with a layout-shift observer:

Scenario Before After
First load at 1440, 1366 and 390 0 0
Opening and closing the panel n/a 0
Pinning and unpinning n/a 0
Someone else's pin or unpin arriving by poll n/a 0
Count going 3 to 2 to 1 n/a 0

Visual evidence

All captures are from a local copy with seeded data, in Chrome through Playwright. The before shots come from production at the base commit with the same data.

Before, 1440 light After, 1440 light
Base thread with the hover row showing Reply and React Pinned bar at the top, pins beside pinned messages, hover row with Pin, Reply, React
Before, 1440 dark After, 1440 dark
Base thread in dark mode The same after, dark
Panel, light Panel expanded, dark
Panel with three pinned messages, the long one cut to three lines with Show more The long message expanded
Emoji tray in the panel Pin warning
Tray opened beside the item's Reply, React and Unpin buttons Pin for the whole org? dialog with Don't show this again
Member added without history Their panel
Only their own history, but the pinned bar shows three pins Reply and React, no Unpin
1366x650, six pins, light Scrolled inside, dark
Panel capped at the thread area List scrolled inside the panel
Dock before Dock after Dock panel
Org in the chat dock on production The same with the pinned bar and the wider gutter Panel inside the dock
Failed pin (simulated 403) Failed unpin (simulated 500)
Pin rolled back with the cap message Row back, toast, focus on its Unpin

The two failures were faked with Playwright's page.route, so no request reached the server.

Device verification

No physical device. Touch was checked in Chrome's device emulation at 390x844 (isMobile, hasTouch, real touch events through CDP), so it says nothing about the on-screen keyboard or safe areas on a real phone.

Before, 390 After, 390 Panel, dark Without history, dark
Base thread on a phone Pinned bar and pins on a phone Panel on a phone Panel for a member without history
Long press Then Pin message
Long-press sheet with Pin message The warning opens cleanly after the sheet
  • Long press opens the sheet with Pin message (or Unpin message on a pinned one). Tapping it closes the sheet and opens the warning, and confirming pins the message.
  • On touch, the pin beside a pinned message stays visible without hover. The panel's Reply, React and Unpin have 44px tap areas that don't overlap.
  • The panel has no sideways scroll at 390, and the thread doesn't scroll behind it.

Independent review

A separate agent that didn't write the code reviewed it independently, read-only, on base 8adba040 and head 3fdfdaa0, against the original request, the repo guidelines and every capture above. It took three rounds.

  • First round (an earlier head): CODE FAIL and VISUAL FAIL. The pinned list leaked what pinned messages replied to, and bubbles rewrapped when pin state changed. It also flagged a double click unpinning, a copied emoji tray, a stale list race, a lock order that could deadlock, and a missing timeout test.
  • Second round: CODE PASS, VISUAL FAIL. The panel's touch targets were too small, some captures were missing, and the banner count could shift the preview.
  • Final round on 3fdfdaa0: CODE: PASS, VISUAL: PASS, no blockers. It confirmed each earlier finding is fixed.

Limitations

  • No physical device testing, only emulation.
  • Author names in the panel truncate on narrow phones (the full name is in the tooltip).
  • After pinning from the long-press sheet, focus ends up on the page body, since the sheet button that opened the warning is gone.
  • While the thread is scrolled, the pinned bar sits over its top 36px. That's on purpose, so new pins never push the messages down.
  • A reply ghost pointing at a pinned message from before your history doesn't jump anywhere when clicked.
  • The 25-pin cap is checked before the write, so two maintainers pinning at the same moment could reach 26.
  • The panel's list has no fade at its scroll edge.
  • If an unpin fails more than 300ms after the click, the row comes back without animating.
  • Reply ghosts in the thread already show the text of older messages to members without history. That predates this PR and I'll fix it separately.

🤖 Generated with Claude Code

Maintainers can pin up to 25 messages in an org. Everyone in the org sees
pinned messages, including people added without the history, and can reply
and react to them. A one-line banner at the top of the thread opens a panel
with each pinned message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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