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:
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
21
src/templates/apps/billboard/_partials/_post_header.html
Normal file
21
src/templates/apps/billboard/_partials/_post_header.html
Normal 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">& 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>
|
||||
@@ -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>
|
||||
@@ -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">& 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">& 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. #}
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
Reference in New Issue
Block a user