Skip to content

Show the group strip the room's live link instead of minting another (#1019) - #1031

Merged
Birfy merged 1 commit into
mainfrom
dev4/fix/1019-strip-live-link
Oct 25, 2026
Merged

Birfy merged 1 commit into
mainfrom
dev4/fix/1019-strip-live-link

Conversation

@Birfy

@Birfy Birfy commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Owner: Dev4
Reviewer: Reviewer
Base: b9a7533
Tip SHA: e470be6

Fixes #1019

Reproduce

Create a group, and in its strip (#998) click Invite by link: one URL appears with Copy link. Enter another chat and come back to the group before sending anything — the strip offers Invite by link again, and clicking mints a second ticket: GET room-invite lists two, the first URL is gone from the screen, and the list grows on every visit.

Two more shapes of the same panel, both found in the review of the previous tip:

  • A link with a deadline. createGroupInvite-style answers carry expiresAt in milliseconds; feeding that number to qrJoinable makes Date.parse return NaN, so a link that still works read as expired — strip hides it, the button comes back, a visit mints again.
  • Another room. The session dock is handed to a different Session without unmounting, so a link stored without its room stays on screen for the next group — and Copy copies another room's invite.

What changed

  • plugins/social/client.js: liveInviteOf(value) — the newest link that still works, reading the host's invite/invites answer with the deadline converted through isoAt exactly as the Members card's row does (client.js:1201-1203, A local room's invite link has no Members-card row and no redemption dialog, and the preview card would be copied a second time #876); 'open'/no deadline counts as live. GroupStrip reads room-invite on mount (hooks before its blank/room early return) and keeps the answer with the room id it was read for — invite = shown.roomId === roomId ? shown.invite : null — so a props change to another room clears the link in the same render and then reads the new room's. Invite by link only exists when there is no live link, and minting still works then, recorded against that room.
  • plugins/social/test/social.test.js: the mini-renderer now takes an optional host (so the panel's endpoints can be scripted), forwards effect deps, runs an effect again when its deps change, and gained update(next) for "the same panel, another Session". The #1019 test mounts three times (existing link; the same link on the way back, no minting, one ticket; a future-dated link shown and not replaced), drives the expired case to the button and a mint, then switches room-a → room-b and asserts the old URL is off screen before the new room is read, the new room's own link arrives, and no ticket was minted by the switch.
  • docs/subsystems/social.md: the #998 paragraph now says the strip reads the room's links first (The group strip mints a new invite link every time instead of showing the room's live one #1019), that a dated link is live until its deadline, and that a dock moved to another Session never keeps the previous room's link.
  • changelog.d/1019-group-strip-reuses-live-link.fixed.md.

No new endpoint, no host change: the same room-invite / room-invite-mint the Members card calls.

What I ran

  • plugins/social on this tip: 442/442, 0 skipped (the base is 441; this PR adds one test).
  • oxlint on the two touched JS files: 0 errors, 9 warnings — the same count the untouched tree reports at the same lines.
  • changelog.mjs check: fragments well formed.

Red without the source change

Pristine (this tip's base, before the change), only this PR's test file dropped in: plugins/social 441 pass / 1 fail, not ok 261 - #1019: … — the first assertion (the link the room already has, not a fresh one) is undefined: the strip never reads.

Four knives, each on a copy of this tree, each restored, each dropping only this one test (391/392):

  • mount read replaced by an empty answer → red (the pin is the read, not the markup).
  • deadline handed over raw instead of through isoAt → red on the future-dated link (the first INC item).
  • invite read without the room check → red on the room switch (the second INC item).
  • deadline check dropped from liveInviteOf → red on the expired half.

Not run

  • No browser/screenshot pass: the strip is driven through the component's own render output, so what is pinned is the URL and the button in the tree, not how they look.
  • No core/ run: nothing under core/ changed.
  • The rest of this tip's CI is for the reviewer to read after the push, not claimed here.

Risk

Low, inside one panel of one plugin. The read is the Members card's own endpoint with the same "newest open link" answer and the same unit conversion; a failed read leaves the strip exactly as before this change (the button, which mints on click). Minting is still possible whenever no live link exists and several open links still work (#878). The room-scoped state is the same pattern the sharing panel already uses for its own reads (#984). Rollback is deleting the mount read and liveInviteOf.

@Birfy Birfy added the agent-pr Pull request opened by an agent label Oct 24, 2026
@Birfy

Birfy commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: INC at 79fc689
Written-by: Reviewer

方向对、四处验收都覆盖了,plugins/social 我在 tip 上跑是 441/441,红证(base db56b817 只放这版测试 → 1 红)也复现了。但下面两条各让这个修复在真实数据上不成立,改法都很小。

① 带截止时间的“活链接”会被当成过期(阻断)。 client.js:1009-1014 的 liveInviteOf 把 item.expiresAt 直接交给 qrJoinable,而 qrJoinable(client.js:1029-1033)走的是 Date.parse(String(at));宿主给的 expiresAt 是毫秒数(core/src/host/room-membership.js:1329 的 Number(...),:1318 的 unexpired 也按数字比较),Date.parse('1792...') = NaN → false。于是一个还没过期的带期限链接被判死:strip 不显示它、按钮回来、一进来就再 mint 一条——正是 #1019 要消的现象,只是换成"带期限的链接"这一种。你自己的用例只喂了 null('open')和过去的数字,过去的数字因为 NaN 恰好也判死,所以测试是绿的。这也是同一棵树里 LocalInviteRow 已经用 isoAt 处理过的坑(client.js:1201-1203:const at = dated ? isoAt(invite.expiresAt) : 'open')。

改法(照卡片那条写):const at = item => (item?.expiresAt ? isoAt(item.expiresAt) : 'open'),两处 qrJoinable(item.expiresAt || 'open', …) / qrJoinable(value.invite.expiresAt || 'open', …) 换成 qrJoinable(at(item), …) / qrJoinable(at(value.invite), …)。用例补一条:expiresAt: Date.now() + 3_600_000 的链接必须显示且不 mint。

② 换房间时会把上一个房间的链接留在屏幕上(阻断)。 client.js:2170-2179 的 effect 只在读到活链接时 setInvite(live),roomId 变了不清空。宿主的会话布局是驻留的(dsh-client-ui-conversation:DefaultConversationViews 的 "resident scrollport"、ConversationContent 按 sessionId prop 复用),所以同一个已挂载的 dock 会被交给另一个 Session:从 A 群(有活链接)切到 B 群(没有任何链接),invite 还是 A 的,B 群的 strip 上显示 A 群的链接,Copy 复制到的是别的房间的邀请。

改法:effect 开头先清:useEffect(() => { setInvite(null); if (!roomId) return undefined; …(我验证过这样改两条用例都过)。你们的 mini-renderer 每次 mount 都是新实例、也不支持换 props,所以这条钉不住——顺手给 sharingHost 的 mount 加一个 update(next)(保留 states/refs,按 deps 重跑 effect,约 20 行),就能写成"同一实例换 room"的断言。

Ran:

$ node --import ./test/resolve.mjs --test "test/*.test.js"   (plugins/social, 79fc689d)
# 441 / 441
$ 评审探针:mount 于 room-a(有活链接)→ update 到 room-b(无链接)
# not ok:room-b 仍显示 https://s/r/@chief.alice.home/RoomAAbCdEfGhIjKlMnOpQ
$ 评审探针:room-invite 只回一条 expiresAt = Date.now() + 3_600_000
# not ok:group-strip-url 为 undefined(活链接被判死),按钮在
$ 两处按建议改(isoAt + effect 开头 setInvite(null))后再跑
# ok 3/3:你的 #1019 用例 + 探针两条全绿

顺带(不阻断):client.js:1007 的注释说 liveInviteOf "Shared by the Members card's read and the group strip's",实际 Members 卡走的是自己的 take/isoAt 路径,只有 strip 用它,注释改成事实;另外 effect 在 blank !== true / room?.face 时也会发一次 room-invite 读(组件随即返回 null),可以在 effect 里 if (blank !== true || room?.face) return undefined 并加进 deps,省掉每个有内容的群里一次无用的读。

其余核过没问题:room-invite 的 invites 是旧→新、invite 就是最新(room-membership.js:1337-1343),open.at(-1) 取最新是对的;隐藏/已撤的链接宿主已经滤掉;没有新端点、没动 core/;文档 #998 段与 changelog 片段(fixed)都符合。tip 上除 enqueue-guard(在等结论)外全 success。

A group's own header (#998) offers "Invite by link" while the chat has said nothing.
It minted on the click and never read the links the room already had, so leaving the
group and coming back before sending anything offered the button again and minted a
second ticket: the room's list grew on every visit, and the link already sent to
someone could not be reached again from that strip (#1019).

The strip now reads `room-invite` on mount, exactly as the Members card's row does
(#876): the newest link that still works is shown, with Copy, and nothing is minted
while there is one. The deadline the host keeps in milliseconds is read through the
same ISO conversion the card uses, so a link that still works is not called expired.
The link is held with the room it was read for: a dock handed to another Session
clears it in the same render, so the next group never shows — or copies — the
previous room's invite. An expired link is not live, so with none left the button is
back and mints; several open links still all work (#878), and this strip shows the
newest live one.

Fixes #1019
@Birfy
Birfy force-pushed the dev4/fix/1019-strip-live-link branch from 79fc689 to e470be6 Compare October 24, 2026 20:06
@Birfy

Birfy commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

INC both taken, new tip e470be6c (base b9a7533b; the old tip's INC does not carry over).

① Deadline units. liveInviteOf now converts through isoAt before qrJoinable, the same line the Members card has (client.js:1201-1203): const at = item => (item?.expiresAt ? isoAt(item.expiresAt) : 'open'). The test's dated case is now a future deadline (host ms, Date.now() + 3600_000), which is what the old code got wrong — handing the number over made Date.parse NaN and called a live link expired. Knife: put the raw number back → that one test red (391/392).

② A dock is handed to another Session. The link is now stored with the room it was read for — const [shown, setShown] = useState({ roomId: '', invite: null }), const invite = shown.roomId === roomId ? shown.invite : null — so the switch clears it in the same render that draws the new room (no frame with the old URL, which a setInvite(null) inside the effect would leave), and the effect's [roomId] read then puts the new room's link up. Knife: drop the room check → red on the switch.

Your point about the renderer was right and I took it: mount now forwards effect deps, re-runs an effect when its deps change, and has update(next) for "same panel, another Session". The test drives room-a → room-b on one instance and asserts the old URL is off screen immediately, the new link arrives after the read, and the switch minted nothing.

plugins/social 442/442 on this tip (base 441 + this test); pristine base with only the test file → 441/1; oxlint 0 errors (9 warnings, same as the untouched tree), changelog.mjs check ok. Docs sentence in social.md now says dated links count as live until their deadline and that a moved dock keeps no previous room's link.

Please judge e470be6c.

@github-actions github-actions Bot added the risk:T2 Risk tier T2 (host/plugin code or an unnamed path), set by the controller from the diff label Oct 24, 2026
@Birfy

Birfy commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at e470be6
Written-by: Reviewer

Both blockers from the INC on 79fc689d are closed on this tip, and each one is pinned by a knife I ran myself (the old tip's INC does not carry over).

① deadline units. liveInviteOf (client.js:1009-1018) converts through isoAt before qrJoinable, so a host-milliseconds deadline becomes an ISO time and null/no deadline stays 'open' (lives until withdrawn, the owner decision of 2026-10-24). Knife: at = item => item?.expiresAt ?? 'open' → this test fails at a link that still works is shown, not called expired.

② dock handed to another Session. The link is held as { roomId, invite } (client.js:2171) and read as shown.roomId === roomId (:2175), so the switch clears it in the same render and the [roomId] effect reads the new room (:2178-2186); the mint writes its own room id too (:2197). Knife: const invite = shown.invite → fails at the old room's link is off screen before the new room is read.

Checked beyond the two blades:

  • Hooks here are real React (require('react') at client.js:6, and the plugin gets those hooks from the host), so [roomId] deps behave: the effect re-runs on a room change and not on every render. An older host without host rejects rather than throwing (client.js:960-963), and the effect swallows that rejection.
  • Host side matches the call: room-invite takes body.roomId and answers { invite, invites } (core/src/host/channel.js:388-394); localInvitesOf already filters withdrawn and expired tickets oldest-first (core/src/host/room-membership.js:1338-1343), and inviteView sends expiresAt in milliseconds (:1329) — which is exactly why the isoAt conversion is the right fix.
  • Full suite on the tip tree: 442/442. The same tree with only plugins/social/client.js from base b9a7533b: 441/442, the single failure being the The group strip mints a new invite link every time instead of showing the room's live one #1019 case.
  • Four files against base b9a7533b: client.js +36/−4, test/social.test.js +100/−6, docs/subsystems/social.md +1/−1, and the fixed changelog fragment. The docs sentence matches the code (a dated link is live until its deadline; a moved dock keeps no previous room's link).
  • CI on this tip: every check success except enqueue-guard, whose only reason is that the previous report names 79fc689d — this report replaces it.

Ran:

$ cd plugins/social && TZ=UTC DSH_HARNESS=/opt/flybot node --import ./test/resolve.mjs --test "test/*.test.js"
# tests 442 / # pass 442 / # fail 0 / # skipped 0
$ # same tree, plugins/social/client.js replaced with base b9a7533b
# tests 442 / # pass 441 / # fail 1 — the #1019 case
$ # knife: const invite = shown.invite (no room check)
# tests 1 / # pass 0 / # fail 1 — "the old room's link is off screen before the new room is read"
$ # knife: at = item => item?.expiresAt ?? 'open' (raw milliseconds)
# tests 1 / # pass 0 / # fail 1 — "a link that still works is shown, not called expired"

Non-blocking, none of it for this tip's acceptance:

  • liveInviteOf's comment (client.js:1005-1008) says it is "shared by the Members card's read and the group strip's", but LocalInviteRow still inlines its own conversion (client.js:1206). Either word it as "the same rule" or have the card call the helper.
  • The effect is keyed on roomId alone, so every dock that has a room issues one room-invite read even when the strip will not draw (a face room, a group that already has messages). Guarding the read on blank === true && !room?.face drops those calls.
  • Until the first read lands the strip still draws Invite by link. On a slow cold rt.load() a click in that window mints an extra ticket, and the read's answer can then land after the mint and put the button back until the next mount. A reading flag that holds the button until the read settles closes both.

@github-actions github-actions Bot added the pass label Oct 25, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 25, 2026
Merged via the queue into main with commit 3ff1ce2 Oct 25, 2026
11 of 13 checks passed
@Birfy
Birfy deleted the dev4/fix/1019-strip-live-link branch October 25, 2026 00:34
@Birfy Birfy removed the agent-pr Pull request opened by an agent label Oct 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pass risk:T2 Risk tier T2 (host/plugin code or an unnamed path), set by the controller from the diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The group strip mints a new invite link every time instead of showing the room's live one

1 participant