buddies sprint phase 1: User.buddies M2M(self,symm=False) + my_buddies aperture page + add_buddy JSON endpoint + buddy btn slide-out — TDD; My Contacts applet renamed → My Buddies (slug + name + partial)

- lyric/0004 adds User.buddies = ManyToManyField('self', symmetrical=False, blank=True, related_name='added_as_buddy'). Asymmetric one-way add: A.buddies.add(B) doesn't reciprocate. Reverse via B.added_as_buddy.all() — load-bearing for the future "buddy changed username" snapshot-accept flow noted in design.
  - applets/0006 renames slug my-contacts → my-buddies + name 'Contacts' → 'My Buddies'. Existing migrations 0003/0004 untouched (historical artifacts).
  - billboard.views.my_buddies + add_buddy:
    • my_buddies: GET /billboard/my-buddies/ → renders the aperture page with request.user.buddies.all().
    • add_buddy: POST /billboard/buddies/add → JSON {buddy: {id, username, email}|null}. Privacy: returns null when email isn't a registered User OR is the requester's own; never leaks membership. Idempotent on re-add (M2M dedup).
  - templates:
    • _applet-my-contacts.html → _applet-my-buddies.html (heading + link to /billboard/my-buddies/).
    • my_buddies.html — bottom-anchored aperture list of buddies w. {% empty %} fallback "No buddies yet."
    • _buddy_add_panel.html — bottom-left handshake btn + slide-out, mirrors _buddy_panel.html (post share) but POSTs to add_buddy and appends to #id_buddies_list. Skips append if data-buddy-id already in DOM (race-safe). Drops the .buddy-entry--empty row on first add.
  - SCSS: page-billbuddies joins the body-class aperture trio; .buddies-page extends %billboard-page-base + flex-column + bottom-anchor for #id_buddies_list. id_applet_my_contacts → id_applet_my_buddies (test references + grid placement).
  - tests: new test_buddies.py — 14 ITs covering UserBuddiesM2MTest (asymmetric, idempotent), MyBuddiesViewTest (lists own buddies only, anon redirect), AddBuddyViewTest (registered/unregistered/self/idempotent/email-fallback/405). Existing test_views/test_billboard/test_game_kit references swapped to my-buddies. New test_my_buddies.py FT — 4 tests: pre-existing buddies render, empty state, add via panel appends entry w. username, unregistered silent no-op.
  - 841 ITs (+14) + 4 my_buddies FTs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-08 22:31:42 -04:00
parent b3eb14140c
commit 5f6002aa70
14 changed files with 562 additions and 23 deletions

View File

@@ -0,0 +1,41 @@
"""Rename the billboard `my-contacts` applet to `my-buddies` (slug + name).
User.buddies M2M (lyric/0004) lands at the same time; the applet links
to the new /billboard/my-buddies/ page where the user manages their
buddy list. "Contacts" was a placeholder name from the original
billboard scaffold.
"""
from django.db import migrations
def forward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
try:
applet = Applet.objects.get(slug="my-contacts")
except Applet.DoesNotExist:
return
applet.slug = "my-buddies"
applet.name = "My Buddies"
applet.save(update_fields=["slug", "name"])
def backward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
try:
applet = Applet.objects.get(slug="my-buddies")
except Applet.DoesNotExist:
return
applet.slug = "my-contacts"
applet.name = "Contacts"
applet.save(update_fields=["slug", "name"])
class Migration(migrations.Migration):
dependencies = [
("applets", "0005_seed_pronouns_applet"),
]
operations = [
migrations.RunPython(forward, backward),
]

View File

@@ -0,0 +1,143 @@
"""ITs for the My Buddies feature (User.buddies M2M + my_buddies view +
add_buddy JSON endpoint).
User.buddies is a self M2M (symmetrical=False) — adding Alice to Disco's
list does NOT auto-reciprocate. Implicit auto-add on shared events
(post-share, gate-invite) is layered separately in those views.
Privacy: add_buddy returns 200 with {buddy: null} when the email is
unregistered, so the response shape never leaks membership.
"""
from django.test import TestCase
from django.urls import reverse
from apps.lyric.models import User
class UserBuddiesM2MTest(TestCase):
"""The buddies field is asymmetric — A.buddies.add(B) doesn't
reciprocate to B.buddies, only to B.added_as_buddy."""
def setUp(self):
self.disco = User.objects.create(email="disco@test.io")
self.alice = User.objects.create(email="alice@test.io")
def test_add_buddy_one_way(self):
self.disco.buddies.add(self.alice)
self.assertIn(self.alice, self.disco.buddies.all())
self.assertNotIn(self.disco, self.alice.buddies.all())
def test_added_as_buddy_reverse_relation(self):
self.disco.buddies.add(self.alice)
self.assertIn(self.disco, self.alice.added_as_buddy.all())
def test_add_is_idempotent(self):
self.disco.buddies.add(self.alice)
self.disco.buddies.add(self.alice)
self.assertEqual(self.disco.buddies.count(), 1)
class MyBuddiesViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="me@test.io")
self.client.force_login(self.user)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.bob = User.objects.create(email="bob@test.io", username="bob")
self.user.buddies.add(self.alice, self.bob)
def test_my_buddies_renders_template(self):
response = self.client.get(reverse("billboard:my_buddies"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/billboard/my_buddies.html")
def test_my_buddies_lists_users_buddies(self):
response = self.client.get(reverse("billboard:my_buddies"))
buddies = list(response.context["buddies"])
self.assertIn(self.alice, buddies)
self.assertIn(self.bob, buddies)
def test_my_buddies_does_not_list_others_buddies(self):
other = User.objects.create(email="other@test.io")
carol = User.objects.create(email="carol@test.io", username="carol")
other.buddies.add(carol)
response = self.client.get(reverse("billboard:my_buddies"))
self.assertNotIn(carol, list(response.context["buddies"]))
def test_my_buddies_redirects_anon_to_login(self):
self.client.logout()
response = self.client.get(reverse("billboard:my_buddies"))
self.assertEqual(response.status_code, 302)
class AddBuddyViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="me@test.io", username="me")
self.client.force_login(self.user)
def test_add_registered_email_adds_to_buddies(self):
alice = User.objects.create(email="alice@test.io", username="alice")
response = self.client.post(
reverse("billboard:add_buddy"),
data={"recipient": "alice@test.io"},
)
self.assertEqual(response.status_code, 200)
self.assertIn(alice, self.user.buddies.all())
def test_add_returns_buddy_payload_with_username(self):
User.objects.create(email="alice@test.io", username="alice")
response = self.client.post(
reverse("billboard:add_buddy"),
data={"recipient": "alice@test.io"},
)
body = response.json()
self.assertIsNotNone(body["buddy"])
self.assertEqual(body["buddy"]["username"], "alice")
def test_add_unregistered_email_returns_null_buddy(self):
"""Privacy: 200 with buddy=null so the response shape doesn't leak
whether the address is on the system."""
response = self.client.post(
reverse("billboard:add_buddy"),
data={"recipient": "ghost@test.io"},
)
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["buddy"])
self.assertEqual(self.user.buddies.count(), 0)
def test_add_own_email_is_silent_noop(self):
"""Adding yourself: no buddy added, response carries buddy=null."""
response = self.client.post(
reverse("billboard:add_buddy"),
data={"recipient": "me@test.io"},
)
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["buddy"])
self.assertNotIn(self.user, self.user.buddies.all())
def test_add_existing_buddy_is_idempotent(self):
alice = User.objects.create(email="alice@test.io", username="alice")
self.user.buddies.add(alice)
response = self.client.post(
reverse("billboard:add_buddy"),
data={"recipient": "alice@test.io"},
)
self.assertEqual(response.status_code, 200)
# Still only one buddy entry — M2M dedup
self.assertEqual(self.user.buddies.count(), 1)
# Response still carries the buddy payload (so the JS can refresh
# an entry if a fast double-click bypassed the data-buddy-id guard).
self.assertIsNotNone(response.json()["buddy"])
def test_add_falls_back_to_email_when_no_username(self):
"""Buddy payload returns email when buddy.username is None — display
layer matches the navbar fallback (display_name filter)."""
User.objects.create(email="anon@test.io")
response = self.client.post(
reverse("billboard:add_buddy"),
data={"recipient": "anon@test.io"},
)
self.assertEqual(response.json()["buddy"]["username"], "anon@test.io")
def test_get_returns_405(self):
response = self.client.get(reverse("billboard:add_buddy"))
self.assertEqual(response.status_code, 405)

View File

@@ -13,7 +13,7 @@ from apps.lyric.models import User
def _seed_billboard_applets(): def _seed_billboard_applets():
for slug, name, cols, rows in [ for slug, name, cols, rows in [
("my-scrolls", "My Scrolls", 4, 3), ("my-scrolls", "My Scrolls", 4, 3),
("my-contacts", "Contacts", 4, 3), ("my-buddies", "My Buddies", 4, 3),
("most-recent-scroll", "Most Recent Scroll", 8, 6), ("most-recent-scroll", "Most Recent Scroll", 8, 6),
]: ]:
Applet.objects.get_or_create( Applet.objects.get_or_create(
@@ -37,7 +37,7 @@ class BillboardViewTest(TestCase):
self.assertIn("applets", response.context) self.assertIn("applets", response.context)
slugs = [e["applet"].slug for e in response.context["applets"]] slugs = [e["applet"].slug for e in response.context["applets"]]
self.assertIn("my-scrolls", slugs) self.assertIn("my-scrolls", slugs)
self.assertIn("my-contacts", slugs) self.assertIn("my-buddies", slugs)
self.assertIn("most-recent-scroll", slugs) self.assertIn("most-recent-scroll", slugs)
def test_passes_my_rooms_context(self): def test_passes_my_rooms_context(self):
@@ -111,7 +111,7 @@ class ToggleBillboardAppletsTest(TestCase):
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
from apps.applets.models import UserApplet from apps.applets.models import UserApplet
contacts = Applet.objects.get(slug="my-contacts") contacts = Applet.objects.get(slug="my-buddies")
ua = UserApplet.objects.get(user=self.user, applet=contacts) ua = UserApplet.objects.get(user=self.user, applet=contacts)
self.assertFalse(ua.visible) self.assertFalse(ua.visible)
@@ -136,7 +136,7 @@ class ToggleBillboardAppletsTest(TestCase):
reverse("billboard:toggle_applets"), reverse("billboard:toggle_applets"),
{"applets": [ {"applets": [
"my-scrolls", "my-scrolls",
"my-contacts", "my-buddies",
"most-recent-scroll", "most-recent-scroll",
]}, ]},
HTTP_HX_REQUEST="true", HTTP_HX_REQUEST="true",
@@ -160,7 +160,7 @@ class ToggleBillboardAppletsTest(TestCase):
self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1) self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1)
def test_second_toggle_preserves_prior_hidden_state(self): def test_second_toggle_preserves_prior_hidden_state(self):
# First toggle: hide Contacts only. # First toggle: hide My Buddies only.
self.client.post( self.client.post(
reverse("billboard:toggle_applets"), reverse("billboard:toggle_applets"),
{"applets": [ {"applets": [
@@ -170,7 +170,7 @@ class ToggleBillboardAppletsTest(TestCase):
]}, ]},
HTTP_HX_REQUEST="true", HTTP_HX_REQUEST="true",
) )
# Second toggle: hide Most Recent Scroll additionally — Contacts must stay hidden. # Second toggle: hide Most Recent Scroll additionally — My Buddies must stay hidden.
self.client.post( self.client.post(
reverse("billboard:toggle_applets"), reverse("billboard:toggle_applets"),
{"applets": [ {"applets": [
@@ -180,7 +180,7 @@ class ToggleBillboardAppletsTest(TestCase):
HTTP_HX_REQUEST="true", HTTP_HX_REQUEST="true",
) )
from apps.applets.models import UserApplet from apps.applets.models import UserApplet
contacts = Applet.objects.get(slug="my-contacts") contacts = Applet.objects.get(slug="my-buddies")
most_recent_scroll = Applet.objects.get(slug="most-recent-scroll") most_recent_scroll = Applet.objects.get(slug="most-recent-scroll")
self.assertFalse( self.assertFalse(
UserApplet.objects.get(user=self.user, applet=contacts).visible UserApplet.objects.get(user=self.user, applet=contacts).visible

View File

@@ -18,4 +18,6 @@ urlpatterns = [
path("post/<uuid:post_id>/", views.view_post, name="view_post"), path("post/<uuid:post_id>/", views.view_post, name="view_post"),
path("post/<uuid:post_id>/share-post", views.share_post, name="share_post"), path("post/<uuid:post_id>/share-post", views.share_post, name="share_post"),
path("users/<uuid:user_id>/", views.my_posts, name="my_posts"), path("users/<uuid:user_id>/", views.my_posts, name="my_posts"),
path("my-buddies/", views.my_buddies, name="my_buddies"),
path("buddies/add", views.add_buddy, name="add_buddy"),
] ]

View File

@@ -373,6 +373,46 @@ def share_post(request, post_id):
return redirect(our_post) return redirect(our_post)
# ── My Buddies ────────────────────────────────────────────────────────────
# User.buddies is an asymmetric self M2M (lyric/0004). `my_buddies` is the
# manage-page; `add_buddy` is the JSON endpoint hit by the buddy panel slide-
# out. Privacy: when an entered email isn't a registered User, we 200 with
# {buddy: null} so the response shape doesn't leak membership.
@login_required(login_url="/")
def my_buddies(request):
return render(request, "apps/billboard/my_buddies.html", {
"buddies": request.user.buddies.all(),
"page_class": "page-billbuddies",
})
@login_required(login_url="/")
def add_buddy(request):
if request.method != "POST":
from django.http import HttpResponseNotAllowed
return HttpResponseNotAllowed(["POST"])
email = (request.POST.get("recipient") or "").strip()
buddy = None
try:
candidate = User.objects.get(email=email)
except User.DoesNotExist:
candidate = None
if candidate is not None and candidate != request.user:
if candidate not in request.user.buddies.all():
request.user.buddies.add(candidate)
buddy = {
"id": str(candidate.id),
"username": candidate.username or candidate.email,
"email": candidate.email,
}
return JsonResponse({"buddy": buddy})
@login_required(login_url="/") @login_required(login_url="/")
def save_scroll_position(request, room_id): def save_scroll_position(request, room_id):
if request.method != "POST": if request.method != "POST":

View File

@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("lyric", "0003_seed_adman"),
]
operations = [
migrations.AddField(
model_name="user",
name="buddies",
field=models.ManyToManyField(
blank=True,
related_name="added_as_buddy",
to="lyric.user",
),
),
]

View File

@@ -113,6 +113,14 @@ class User(AbstractBaseUser):
unlocked_decks = models.ManyToManyField( unlocked_decks = models.ManyToManyField(
"epic.DeckVariant", blank=True, related_name="unlocked_by", "epic.DeckVariant", blank=True, related_name="unlocked_by",
) )
# Asymmetric self M2M — `user.buddies.all()` = people I've explicitly
# added (or implicitly via post-share / game-invite, which auto-adds
# the recipient to the inviter's buddies list). `user.added_as_buddy`
# = the inverse (people who have me in their buddies list); useful
# for the future "buddy changed username" snapshot-accept flow.
buddies = models.ManyToManyField(
"self", symmetrical=False, blank=True, related_name="added_as_buddy",
)
active_title = models.ForeignKey( active_title = models.ForeignKey(
"drama.Note", null=True, blank=True, "drama.Note", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+", on_delete=models.SET_NULL, related_name="+",

View File

@@ -26,7 +26,7 @@ class BillboardScrollTest(FunctionalTest):
super().setUp() super().setUp()
for slug, name, cols, rows in [ for slug, name, cols, rows in [
("my-scrolls", "My Scrolls", 4, 3), ("my-scrolls", "My Scrolls", 4, 3),
("my-contacts", "Contacts", 4, 3), ("my-buddies", "My Buddies", 4, 3),
("most-recent-scroll", "Most Recent Scroll", 8, 6), ("most-recent-scroll", "Most Recent Scroll", 8, 6),
]: ]:
Applet.objects.get_or_create( Applet.objects.get_or_create(
@@ -193,7 +193,7 @@ class BillscrollPositionTest(FunctionalTest):
class BillboardAppletsTest(FunctionalTest): class BillboardAppletsTest(FunctionalTest):
""" """
FT: billboard page renders three applets in the grid — My Scrolls, FT: billboard page renders three applets in the grid — My Scrolls,
My Contacts, and Most Recent Scroll — with a functioning gear menu. My Buddies, and Most Recent Scroll — with a functioning gear menu.
""" """
def setUp(self): def setUp(self):
@@ -203,7 +203,7 @@ class BillboardAppletsTest(FunctionalTest):
self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder) self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder)
for slug, name, cols, rows in [ for slug, name, cols, rows in [
("my-scrolls", "My Scrolls", 4, 3), ("my-scrolls", "My Scrolls", 4, 3),
("my-contacts", "Contacts", 4, 3), ("my-buddies", "My Buddies", 4, 3),
("most-recent-scroll", "Most Recent Scroll", 8, 6), ("most-recent-scroll", "Most Recent Scroll", 8, 6),
]: ]:
Applet.objects.get_or_create( Applet.objects.get_or_create(
@@ -219,7 +219,7 @@ class BillboardAppletsTest(FunctionalTest):
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls") lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
) )
self.browser.find_element(By.ID, "id_applet_my_contacts") self.browser.find_element(By.ID, "id_applet_my_buddies")
self.browser.find_element(By.ID, "id_applet_most_recent_scroll") self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
def test_billboard_my_scrolls_lists_rooms(self): def test_billboard_my_scrolls_lists_rooms(self):
@@ -278,7 +278,7 @@ class BillboardAppletsTest(FunctionalTest):
lambda: self.browser.find_element(By.ID, "id_billboard_applet_menu") lambda: self.browser.find_element(By.ID, "id_billboard_applet_menu")
) )
contacts_cb = menu.find_element( contacts_cb = menu.find_element(
By.CSS_SELECTOR, "input[value='my-contacts']" By.CSS_SELECTOR, "input[value='my-buddies']"
) )
self.browser.execute_script("arguments[0].click()", contacts_cb) self.browser.execute_script("arguments[0].click()", contacts_cb)
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click() menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
@@ -286,7 +286,7 @@ class BillboardAppletsTest(FunctionalTest):
# Contacts is hidden; Most Recent Scroll + My Scrolls keep their content (bug #2) # Contacts is hidden; Most Recent Scroll + My Scrolls keep their content (bug #2)
self.wait_for( self.wait_for(
lambda: self.assertEqual( lambda: self.assertEqual(
self.browser.find_elements(By.ID, "id_applet_my_contacts"), self.browser.find_elements(By.ID, "id_applet_my_buddies"),
[], [],
) )
) )
@@ -305,7 +305,7 @@ class BillboardAppletsTest(FunctionalTest):
) )
# The freshly-rendered menu must reflect DB state (Contacts unchecked) # The freshly-rendered menu must reflect DB state (Contacts unchecked)
contacts_cb = menu.find_element( contacts_cb = menu.find_element(
By.CSS_SELECTOR, "input[value='my-contacts']" By.CSS_SELECTOR, "input[value='my-buddies']"
) )
self.assertFalse(contacts_cb.is_selected()) self.assertFalse(contacts_cb.is_selected())
most_recent_scroll_cb = menu.find_element( most_recent_scroll_cb = menu.find_element(
@@ -322,7 +322,7 @@ class BillboardAppletsTest(FunctionalTest):
) )
) )
self.assertEqual( self.assertEqual(
self.browser.find_elements(By.ID, "id_applet_my_contacts"), self.browser.find_elements(By.ID, "id_applet_my_buddies"),
[], [],
) )
@@ -332,7 +332,7 @@ class BillboardAppletsTest(FunctionalTest):
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls") lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
) )
self.assertEqual( self.assertEqual(
self.browser.find_elements(By.ID, "id_applet_my_contacts"), self.browser.find_elements(By.ID, "id_applet_my_buddies"),
[], [],
) )
self.assertEqual( self.assertEqual(

View File

@@ -134,7 +134,7 @@ class PronounsAppletFlowTest(FunctionalTest):
# Billboard applets — page renders blank without these # Billboard applets — page renders blank without these
for slug, name, cols, rows in [ for slug, name, cols, rows in [
("my-scrolls", "My Scrolls", 4, 3), ("my-scrolls", "My Scrolls", 4, 3),
("my-contacts", "Contacts", 4, 3), ("my-buddies", "My Buddies", 4, 3),
("most-recent-scroll", "Most Recent Scroll", 8, 6), ("most-recent-scroll", "Most Recent Scroll", 8, 6),
]: ]:
Applet.objects.get_or_create( Applet.objects.get_or_create(

View File

@@ -0,0 +1,75 @@
"""FT for the My Buddies page — buddy btn + slide-out add flow.
Phase 1 of the buddies sprint: explicit add via my_buddies.html. Phase 2
will layer autocomplete (sky-place-style top-3 username suggestions) and
implicit auto-add on post-share / gate-invite.
"""
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from apps.lyric.models import User
from .base import FunctionalTest
class MyBuddiesPageTest(FunctionalTest):
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="me@test.io", username="me")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.create_pre_authenticated_session("me@test.io")
def test_renders_existing_buddies(self):
"""Pre-existing buddies show up as entries on first render."""
self.gamer.buddies.add(self.alice)
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
entry = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".buddy-entry .buddy-name")
)
self.assertEqual(entry.text, "alice")
def test_empty_state_when_no_buddies(self):
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
empty = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".buddy-entry--empty")
)
self.assertIn("No buddies yet", empty.text)
def test_add_buddy_via_buddy_btn_appends_entry(self):
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient.send_keys("alice@test.io")
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm")
ok.click()
# New entry appears w. alice's username (not the bare email)
self.wait_for(lambda: self.assertEqual(
self.browser.find_element(
By.CSS_SELECTOR, f".buddy-entry[data-buddy-id='{self.alice.id}'] .buddy-name"
).text,
"alice",
))
# Server-side persisted
self.wait_for(lambda: self.assertIn(
self.alice, list(self.gamer.buddies.all())
))
def test_add_unregistered_email_is_silent_noop(self):
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient.send_keys("ghost@test.io")
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm")
ok.click()
# Wait for the panel close (a positive signal the request landed)
self.wait_for(lambda: self.assertNotIn(
"active", btn.get_attribute("class")
))
# No entries beyond the empty-state row
entries = self.browser.find_elements(By.CSS_SELECTOR, ".buddy-entry:not(.buddy-entry--empty)")
self.assertEqual(len(entries), 0)

View File

@@ -35,13 +35,15 @@
html:has(body.page-billboard), html:has(body.page-billboard),
html:has(body.page-billscroll), html:has(body.page-billscroll),
html:has(body.page-billpost) { html:has(body.page-billpost),
html:has(body.page-billbuddies) {
overflow: hidden; overflow: hidden;
} }
body.page-billboard, body.page-billboard,
body.page-billscroll, body.page-billscroll,
body.page-billpost { body.page-billpost,
body.page-billbuddies {
overflow: hidden; overflow: hidden;
.container { .container {
@@ -215,6 +217,58 @@ body.page-billpost {
} }
} }
// ── My Buddies page (aperture list + add-buddy panel) ────────────────────
// Mirrors .post-page's flex-column / overflow / bottom-anchor pattern;
// the add-buddy panel is included from the same _buddy*.scss styling.
.buddies-page {
@extend %billboard-page-base;
display: flex;
flex-direction: column;
padding: 0.75rem;
gap: 0.5rem;
.buddies-header {
flex-shrink: 0;
.buddies-title {
margin: 0 0 0.25rem;
font-weight: bold;
}
}
#id_buddies_list {
list-style: none;
margin: 0;
padding: 0 0.75rem 0 0;
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: flex-end;
.buddy-entry {
padding: 0.4rem 0;
.buddy-name {
font-weight: bold;
opacity: 0.85;
}
&--empty {
opacity: 0.6;
font-style: italic;
}
}
.buddy-entry-buffer {
flex-shrink: 0;
height: 0.25rem;
}
}
}
// ── Billboard applet placement ───────────────────────────────────────────── // ── Billboard applet placement ─────────────────────────────────────────────
// Left column (4-wide): My Scrolls → Contacts → Notes stacked. // Left column (4-wide): My Scrolls → Contacts → Notes stacked.
// Right column (8-wide): Most Recent Scroll spans full height. // Right column (8-wide): Most Recent Scroll spans full height.
@@ -222,13 +276,13 @@ body.page-billpost {
#id_billboard_applets_container { #id_billboard_applets_container {
#id_applet_my_scrolls { grid-column: 1 / span 4; grid-row: 1 / span 3; } #id_applet_my_scrolls { grid-column: 1 / span 4; grid-row: 1 / span 3; }
#id_applet_my_contacts { grid-column: 1 / span 4; grid-row: 4 / span 3; } #id_applet_my_buddies { grid-column: 1 / span 4; grid-row: 4 / span 3; }
#id_applet_notes { grid-column: 1 / span 4; grid-row: 7 / span 4; } #id_applet_notes { grid-column: 1 / span 4; grid-row: 7 / span 4; }
#id_applet_most_recent_scroll { grid-column: 5 / span 8; grid-row: 1 / span 10; } #id_applet_most_recent_scroll { grid-column: 5 / span 8; grid-row: 1 / span 10; }
@container (max-width: 550px) { @container (max-width: 550px) {
#id_applet_my_scrolls, #id_applet_my_scrolls,
#id_applet_my_contacts, #id_applet_my_buddies,
#id_applet_notes, #id_applet_notes,
#id_applet_most_recent_scroll { #id_applet_most_recent_scroll {
grid-column: 1 / span 12; grid-column: 1 / span 12;

View File

@@ -1,7 +1,7 @@
<section <section
id="id_applet_my_contacts" id="id_applet_my_buddies"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
> >
<h2>Contacts</h2> <h2><a href="{% url 'billboard:my_buddies' %}" class="my-buddies-main">My Buddies</a></h2>
{% include "core/_partials/_forthcoming.html" %} {% include "core/_partials/_forthcoming.html" %}
</section> </section>

View File

@@ -0,0 +1,126 @@
{% load static %}
{# ─────────────────────────────────────────────────────────────────────── #}
{# _buddy_add_panel.html — bottom-left handshake btn + slide-out add- #}
{# buddy field. Mirrors _buddy_panel.html (post-share) but POSTs to #}
{# add_buddy and appends to #id_buddies_list instead of #id_post_table. #}
{# Included by my_buddies.html only. #}
{# ─────────────────────────────────────────────────────────────────────── #}
<button id="id_buddy_btn" type="button" aria-label="Add a buddy">
<i class="fa-solid fa-handshake"></i>
</button>
<div id="id_buddy_panel" data-add-url="{% url 'billboard:add_buddy' %}">
<input id="id_recipient"
name="recipient"
type="email"
placeholder="friend@example.com"
autocomplete="off">
<button id="id_buddy_ok" type="button" class="btn btn-confirm">OK</button>
</div>
<script>
(function () {
'use strict';
var btn = document.getElementById('id_buddy_btn');
var panel = document.getElementById('id_buddy_panel');
var input = document.getElementById('id_recipient');
var ok = document.getElementById('id_buddy_ok');
var html = document.documentElement;
if (!btn || !panel || !input || !ok) return;
function _csrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
function _open() {
html.classList.add('buddy-open');
btn.classList.add('active');
setTimeout(function () { input.focus(); }, 60);
}
function _close(opts) {
opts = opts || {};
html.classList.remove('buddy-open');
btn.classList.remove('active');
if (opts.clear !== false) input.value = '';
}
btn.addEventListener('click', function () {
if (html.classList.contains('buddy-open')) {
_close();
} else {
_open();
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && html.classList.contains('buddy-open')) _close();
});
document.addEventListener('click', function (e) {
if (!html.classList.contains('buddy-open')) return;
if (panel.contains(e.target)) return;
if (e.target === btn || btn.contains(e.target)) return;
_close();
});
function _appendBuddyEntry(buddy) {
var list = document.getElementById('id_buddies_list');
if (!list || !buddy) return;
// Skip if already in DOM (server-side dedup ensures M2M idempotence;
// this guards a fast double-click that races the post-add refresh).
if (list.querySelector('[data-buddy-id="' + buddy.id + '"]')) return;
// Drop the empty-state row if present
var empty = list.querySelector('.buddy-entry--empty');
if (empty) empty.remove();
var li = document.createElement('li');
li.className = 'buddy-entry';
li.dataset.buddyId = buddy.id;
var name = document.createElement('span');
name.className = 'buddy-name';
name.textContent = buddy.username;
li.appendChild(name);
var buffer = list.querySelector('.buddy-entry-buffer');
if (buffer) list.insertBefore(li, buffer);
else list.appendChild(li);
}
ok.addEventListener('click', function () {
var email = input.value.trim();
if (!email) return;
var fd = new FormData();
fd.set('recipient', email);
fetch(panel.dataset.addUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'X-CSRFToken': _csrf(),
},
body: fd,
})
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (data) {
if (data.buddy) _appendBuddyEntry(data.buddy);
_close({ clear: true });
})
.catch(function () {
// Privacy-safe response shape — even an unregistered email is
// a 200 w. {buddy: null}. Network/5xx land here; just close.
});
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
ok.click();
}
});
}());
</script>

View File

@@ -0,0 +1,30 @@
{% extends "core/base.html" %}
{% load lyric_extras %}
{% block title_text %}Dashbuddies{% endblock title_text %}
{% block header_text %}<span>Dash</span>buddies{% endblock header_text %}
{% block content %}
<div class="buddies-page">
<header class="buddies-header">
<h3 class="buddies-title">My Buddies</h3>
</header>
<ul id="id_buddies_list" class="buddies-list">
{% for buddy in buddies %}
<li class="buddy-entry" data-buddy-id="{{ buddy.id }}">
<span class="buddy-name">{{ buddy|display_name }}</span>
</li>
{% empty %}
<li class="buddy-entry buddy-entry--empty">No buddies yet.</li>
{% endfor %}
<li class="buddy-entry-buffer" aria-hidden="true"></li>
</ul>
{# Buddy btn (bottom-left) + slide-out add-buddy panel — async POST #}
{# to add_buddy. Mirror of the post.html share buddy btn but distinct #}
{# action (adds to User.buddies M2M) and DOM hooks (#id_buddies_list).#}
{% include "apps/billboard/_partials/_buddy_add_panel.html" %}
</div>
{% endblock content %}