post applet: unify header across post.html + reelhouse chat; seat-based recipients/access; fix async chip styling

Unifies the Post applet across post.html and the room game-views POST chat:

- Extract _post_recipient.html (the @handle chip: post-recipient + post-attribution) + _post_header.html (title + 'shared between … & me' / 'just me' prose). post.html's owner branch + the reelhouse POST view both render via _post_header; the invitee branch stays bespoke but reuses the chip.

- Async-chip styling bug (post.html): the bud-invite append built a bare <span class=post-recipient> with the raw display name, so the recipient rendered without the --quaUser key + the @ until a refresh. Now billboard:share_post returns recipient_chip_html (the server-rendered _post_recipient.html) and the bud panel splices THAT in — identical classes + @handle. Also fixed the 'just me'→'& me' flip to mutate only the leading text node so the self line's own .post-attribution span survives.

- Reelhouse POST chat: gains the full .post-header — title hardcoded to 'Gamer Introduction' (dynamic template later) + recipients = the gamers OCCUPYING SEATS (room.table_seats, deduped, viewer excluded), NOT gate-slot/token depositors. And room_post ACCESS now requires a TableSeat, not a filled gate slot: a depositor who never took a seat can retract + leave, so they must not have R/W access to the private chat.

Tests: header IT (seatmate listed, transient gate-slot-only depositor not — scoped to the recipients paragraph since the position strip carries every gate-slot handle elsewhere); room_post seat-access ITs (seated OK; non-seated + gate-slot-only → 403); share_post recipient_chip_html IT; carousel FT setUp now seats disco/amigo/bud (pal/dude/bro stay transient). All green: 255 ITs, 11 carousel FTs, 29 bud-btn/composer FTs.

[[project-room-game-views-carousel]] [[feedback-at-handle-for-usernames]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-02 16:34:28 -04:00
parent 73644e226b
commit b243d512e4
10 changed files with 158 additions and 44 deletions

View File

@@ -333,3 +333,13 @@ class SharePostAlreadyPresentTest(TestCase):
"""`recipient_display` already exists on the success path; on
duplicate the same field must carry the matched user's handle."""
self.assertEqual(self._share("alice@test.io").json()["recipient_display"], "alice")
def test_recipient_chip_html_carries_attribution_class_and_handle(self):
"""The server-rendered chip the bud-panel JS splices in matches a
post-refresh — `post-attribution` (the --quaUser key) + the @handle —
so the async append isn't bare until reload."""
chip = self._share("alice@test.io").json()["recipient_chip_html"]
self.assertIn("post-recipient", chip)
self.assertIn("post-attribution", chip)
self.assertIn("@alice", chip)
self.assertIn(f'data-user-id="{self.alice.id}"', chip)

View File

@@ -6,6 +6,7 @@ from django.utils.html import mark_safe
from django.db.models import Max, Q
from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render
from django.template.loader import render_to_string
from apps.applets.utils import applet_context, apply_applet_toggle
@@ -544,15 +545,24 @@ def share_post(request, post_id):
# post detail page already shows publicly.
recipient_display = None
recipient_user_id = None
recipient_chip_html = None
if recipient is not None:
recipient_display = recipient.username or recipient.email
recipient_user_id = str(recipient.id)
# Render the chip SERVER-SIDE (shared `_post_recipient.html`) so the
# optimistic bud-panel append carries the SAME `post-recipient
# post-attribution` classes + @handle as a post-refresh — fixes the
# async chip rendering bare (no --quaUser) until reload.
recipient_chip_html = render_to_string(
"apps/billboard/_partials/_post_recipient.html", {"r": recipient},
)
return JsonResponse({
"brief": brief.to_banner_dict() if brief is not None else None,
"line_text": line_text,
"recipient_display": recipient_display,
"recipient_user_id": recipient_user_id,
"already_present": is_reshare,
"brief": brief.to_banner_dict() if brief is not None else None,
"line_text": line_text,
"recipient_display": recipient_display,
"recipient_user_id": recipient_user_id,
"recipient_chip_html": recipient_chip_html,
"already_present": is_reshare,
})
messages.success(request, "An invite has been sent if that address is registered.")

View File

@@ -3428,9 +3428,15 @@ class RoomViewsCarouselTest(TestCase):
name="Whataburgher", owner=self.user,
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
)
# Seat the viewer (filled slot 1) — required to post to the thread.
slot = self.room.gate_slots.get(slot_number=1)
slot.gamer = self.user
# Seat the viewer + one bud (TableSeat = committed to a seat). A third
# gamer fills a gate slot ONLY (transient depositor) — must NOT show as
# a chat participant. The viewer's seat is what grants POST access.
self.seatmate = User.objects.create(email="amigo@test.io", username="amigo")
self.transient = User.objects.create(email="pal@test.io", username="pal")
TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=1, role="PC")
TableSeat.objects.create(room=self.room, gamer=self.seatmate, slot_number=2, role="NC")
slot = self.room.gate_slots.get(slot_number=3)
slot.gamer = self.transient
slot.status = GateSlot.FILLED
slot.save()
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
@@ -3477,6 +3483,21 @@ class RoomViewsCarouselTest(TestCase):
content = self.client.get(self.url).content.decode()
self.assertIn("room-view-stub", content)
def test_post_chat_header_lists_seat_occupants_not_token_depositors(self):
"""The POST chat header hardcodes a 'Gamer Introduction' title and lists
the gamers OCCUPYING SEATS as recipients — the viewer's seatmate shows;
a gate-slot-only (transient) depositor does not (he could retract +
leave). Scoped to the shared-recipients paragraph (the position strip
carries every gate-slot gamer's handle elsewhere on the page)."""
import re
content = self.client.get(self.url).content.decode()
self.assertIn("Gamer Introduction", content)
recipients = re.search(
r'<p class="post-shared-recipients">(.*?)</p>', content, re.S)
recipients_html = recipients.group(1) if recipients else ""
self.assertIn("@amigo", recipients_html) # seated → a participant
self.assertNotIn("@pal", recipients_html) # gate-slot-only → not
def test_atlas_gear_menu_has_source_checkboxes(self):
"""The ATLAS view's gear pane carries a source checkbox per other
reelhouse view; scroll + post are wired (checked), yarn + pulse have no
@@ -3515,10 +3536,9 @@ class RoomPostEndpointTest(TestCase):
name="Whataburgher", owner=self.user,
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
)
slot = self.room.gate_slots.get(slot_number=1)
slot.gamer = self.user
slot.status = GateSlot.FILLED
slot.save()
# Access is SEAT-based: the poster must occupy a TableSeat, not merely
# have a filled gate slot.
TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=1, role="PC")
self.url = reverse("epic:room_post", args=[self.room.id])
def test_post_appends_line_and_returns_line_html(self):
@@ -3538,6 +3558,20 @@ class RoomPostEndpointTest(TestCase):
self.assertEqual(resp.status_code, 403)
self.assertFalse(self.room.get_thread_post().lines.exists())
def test_gate_slot_only_depositor_forbidden(self):
"""A token-depositor who filled a gate slot but never took a SEAT can't
post — they could retract their token + leave, so they must not have
R/W access to the private chat."""
transient = User.objects.create(email="pal@test.io", username="pal")
slot = self.room.gate_slots.get(slot_number=2)
slot.gamer = transient
slot.status = GateSlot.FILLED
slot.save()
self.client.force_login(transient)
resp = self.client.post(self.url, data={"text": "let me in"})
self.assertEqual(resp.status_code, 403)
self.assertFalse(self.room.get_thread_post().lines.exists())
def test_duplicate_line_rejected(self):
self.client.post(self.url, data={"text": "echo"})
resp = self.client.post(self.url, data={"text": "echo"})

View File

@@ -665,6 +665,19 @@ def room_view(request, room_id):
ctx["room_post"] = room_post
ctx["room_post_lines"] = room_post.lines.select_related("author").all()
ctx["text_btn_active"] = True
# The POST chat's participants are the gamers OCCUPYING SEATS (a TableSeat
# with their gamer) — NOT mere gate-slot/token depositors, who can retract
# their token + leave without ever committing to the game. `seated_others`
# excludes the viewer (who is the "& me" line); deduped, slot-ordered.
seen_seated = set()
seated_others = []
for seat in (room.table_seats.filter(gamer__isnull=False)
.exclude(gamer=request.user)
.select_related("gamer").order_by("slot_number")):
if seat.gamer_id not in seen_seated:
seen_seated.add(seat.gamer_id)
seated_others.append(seat.gamer)
ctx["seated_others"] = seated_others
return render(request, "apps/gameboard/room.html", ctx)
@@ -680,10 +693,10 @@ def room_post(request, room_id):
room = Room.objects.get(id=room_id)
if request.method != "POST":
return redirect("epic:room", room_id=room_id)
# Only gamers holding a filled seat at this table may post to its thread.
if not room.gate_slots.filter(
gamer=request.user, status=GateSlot.FILLED
).exists():
# Only gamers OCCUPYING A SEAT (a TableSeat with their gamer) may speak in
# the chat — NOT gate-slot/token depositors who haven't committed to a seat
# (they can retract + leave, so they must not have R/W access to the chat).
if not room.table_seats.filter(gamer=request.user).exists():
return HttpResponseForbidden()
post = room.get_thread_post()
form = ExistingPostLineForm(for_post=post, data=request.POST)

View File

@@ -29,7 +29,7 @@ from selenium.webdriver.common.by import By
from .base import FunctionalTest
from .room_page import _equip_earthman_deck, _fill_room_via_orm
from apps.drama.models import GameEvent
from apps.epic.models import Room
from apps.epic.models import Room, TableSeat
from apps.lyric.models import User
VIEW_ORDER = ["atlas", "scroll", "yarn", "post", "pulse"]
@@ -46,6 +46,16 @@ class GameViewsCarouselTest(FunctionalTest):
["disco@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io"],
)
# Seat disco + amigo + bud (a TableSeat = committed to a seat). pal/
# dude/bro stay gate-slot-only (transient depositors) → they must NOT
# appear as chat participants nor be able to post to the thread.
for slot_n, email, role in [(1, "disco@test.io", "PC"),
(2, "amigo@test.io", "NC"),
(3, "bud@test.io", "EC")]:
TableSeat.objects.create(
room=self.room, gamer=User.objects.get(email=email),
slot_number=slot_n, role=role,
)
# A provenance line so the Scroll + Atlas feeds have content.
GameEvent.objects.create(
room=self.room, actor=self.viewer, verb=GameEvent.SLOT_FILLED,

View File

@@ -45,17 +45,21 @@
// ≥1 → +1 recipients: append chip + ", " separator before existing.
// `userId` is stamped onto the chip as data-user-id so a later duplicate-
// share attempt can highlight this same element via .bud-duplicate-flash.
function _appendRecipientChip(displayName, userId) {
if (!displayName) return;
// Insert the SERVER-RENDERED chip (`chipHtml` from share_post's
// `_post_recipient.html`) so the async append carries the same
// `post-recipient post-attribution` classes + @handle as a post-refresh —
// fixes the chip rendering bare (no --quaUser) until reload.
function _appendRecipientChip(chipHtml) {
if (!chipHtml) return;
var header = document.querySelector('.post-page .post-header');
if (!header) return;
var existingRecipients = header.querySelector('.post-shared-recipients');
var selfLine = header.querySelector('.post-shared-self');
var chip = document.createElement('span');
chip.className = 'post-recipient';
chip.textContent = displayName;
if (userId) chip.dataset.userId = userId;
var tpl = document.createElement('template');
tpl.innerHTML = chipHtml.trim();
var chip = tpl.content.firstElementChild;
if (!chip) return;
if (existingRecipients) {
existingRecipients.appendChild(document.createTextNode(', '));
@@ -69,7 +73,12 @@
recipientsLine.appendChild(chip);
if (selfLine) {
header.insertBefore(recipientsLine, selfLine);
selfLine.textContent = selfLine.textContent.replace(/^just me,/, '& me,');
// Flip ONLY the leading "just me," text node so the self line's
// own .post-attribution span (the owner's handle) survives.
var lead = selfLine.firstChild;
if (lead && lead.nodeType === 3) {
lead.textContent = lead.textContent.replace(/^\s*just me,/, '& me,');
}
} else {
header.appendChild(recipientsLine);
}
@@ -81,8 +90,8 @@
onSuccess: function (data) {
if (data.line_text) _appendLine(data.line_text);
if (window.Brief && data.brief) Brief.showBanner(data.brief);
if (data.recipient_display) {
_appendRecipientChip(data.recipient_display, data.recipient_user_id);
if (data.recipient_chip_html) {
_appendRecipientChip(data.recipient_chip_html);
}
},
duplicateTargetSelector: function (data) {

View File

@@ -0,0 +1,21 @@
{# Shared post-header (owner-style): a title + the "shared between … & me" / #}
{# "just me" prose. Used by post.html's owner branch AND the room game-views #}
{# POST view (the private game chat), so the two render identically. Params: #}
{# header_title — the title text (post.title; or "Gamer Introduction") #}
{# other_recipients — the users to list as chips (post recipients; or the #}
{# gamers OCCUPYING SEATS at the table — NOT mere token- #}
{# depositors, who can retract + leave without committing) #}
{# self_user — whose "& me, …" / "just me, …" line this is (the post #}
{# owner, or the viewer in the game chat) #}
{# The invitee branch of post.html ("shared with" + "created by") stays bespoke #}
{# there; only the chip + owner-style header are shared. #}
{% load lyric_extras %}
<header class="post-header">
<h3 class="post-title">{{ header_title }}</h3>
{% if other_recipients %}
<p class="post-shared-recipients">shared between {% for r in other_recipients %}{% include "apps/billboard/_partials/_post_recipient.html" with r=r %}{% if not forloop.last %}, {% endif %}{% endfor %}</p>
<p class="post-shared-self">&amp; me, <span class="post-attribution">{{ self_user|at_handle }} the {{ self_user.active_title_display }}</span></p>
{% else %}
<p class="post-shared-self">just me, <span class="post-attribution">{{ self_user|at_handle }} the {{ self_user.active_title_display }}</span></p>
{% endif %}
</header>

View File

@@ -0,0 +1,6 @@
{# One recipient chip — the @handle in the --quaUser palette key. Shared by #}
{# post.html's shared-with lists, the room game-views POST header (seat #}
{# occupants), and the `billboard:share_post` AJAX response (rendered server- #}
{# side so the optimistic bud-panel append carries the SAME classes as a #}
{# refresh — both `post-recipient` AND `post-attribution`). `r` = the user. #}
{% load lyric_extras %}<span class="post-recipient post-attribution"{% if r.id %} data-user-id="{{ r.id }}"{% endif %}>{{ r|at_handle }}</span>

View File

@@ -12,30 +12,26 @@
<span id="id_post_owner" hidden>{{ post.owner|display_name }}</span>
<div class="post-page">
<header class="post-header">
<h3 class="post-title">{{ post.title }}</h3>
{% if viewer_is_owner %}
{# Owner viewing — owner-centric prose. "shared between" lists #}
{# every recipient; the self line is the owner's own handle. #}
{% if viewer_is_owner %}
{# Owner viewing — owner-centric "shared between … & me" prose, via the #}
{# shared header (also used by the room game-views POST chat). #}
{% include "apps/billboard/_partials/_post_header.html" with header_title=post.title other_recipients=other_recipients self_user=post.owner %}
{% else %}
{# Invitee viewing — "shared with" prose centred on the viewer #}
{# (request.user). Sole invitee collapses to a single line; the #}
{# "created by …" line attributes the post to its founder. Bespoke #}
{# (the shared header is owner-style), but reuses the chip partial. #}
<header class="post-header">
<h3 class="post-title">{{ post.title }}</h3>
{% if other_recipients %}
<p class="post-shared-recipients">shared between {% for r in other_recipients %}<span class="post-recipient post-attribution" data-user-id="{{ r.id }}">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
<p class="post-shared-self">&amp; me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
{% else %}
<p class="post-shared-self">just me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
{% endif %}
{% else %}
{# Invitee viewing — "shared with" prose centred on the viewer #}
{# (request.user). Sole invitee collapses to a single line; the #}
{# "created by …" line attributes the post to its founder. #}
{% if other_recipients %}
<p class="post-shared-recipients">shared with {% for r in other_recipients %}<span class="post-recipient post-attribution" data-user-id="{{ r.id }}">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
<p class="post-shared-recipients">shared with {% for r in other_recipients %}{% include "apps/billboard/_partials/_post_recipient.html" with r=r %}{% if not forloop.last %}, {% endif %}{% endfor %}</p>
<p class="post-shared-self">&amp; me, <span class="post-attribution">{{ request.user|at_handle }} the {{ request.user.active_title_display }}</span></p>
{% else %}
<p class="post-shared-self">shared with me, <span class="post-attribution">{{ request.user|at_handle }} the {{ request.user.active_title_display }}</span></p>
{% endif %}
<p class="post-created-by">created by <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
{% endif %}
</header>
</header>
{% endif %}
{# @mailman invite Lines have no in-line OK/BYE block — the Line's prose #}
{# embeds a post-attribution anchor (see apps.billboard.mail. #}

View File

@@ -41,6 +41,11 @@
<div class="room-view room-view--post" data-view="post">
<div class="applet-scroll room-view-card">
<h2>{{ room.name }}</h2>
{# Shared post-header (same partial as post.html): a hardcoded #}
{# "Gamer Introduction" title + the gamers OCCUPYING SEATS as the #}
{# chat's recipients (seat occupants, NOT token-depositors who could #}
{# retract + leave). A dynamic title template comes later. #}
{% include "apps/billboard/_partials/_post_header.html" with header_title="Gamer Introduction" other_recipients=seated_others self_user=request.user %}
<ul id="id_post_table" class="post-lines">
{% for line in room_post_lines %}
{% include "apps/billboard/_partials/_post_line.html" %}