From 5f6002aa70d8b03035ab2ba866a14cf9ccf255f9 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 8 May 2026 22:31:42 -0400 Subject: [PATCH] =?UTF-8?q?buddies=20sprint=20phase=201:=20User.buddies=20?= =?UTF-8?q?M2M(self,symm=3DFalse)=20+=20my=5Fbuddies=20aperture=20page=20+?= =?UTF-8?q?=20add=5Fbuddy=20JSON=20endpoint=20+=20buddy=20btn=20slide-out?= =?UTF-8?q?=20=E2=80=94=20TDD;=20My=20Contacts=20applet=20renamed=20?= =?UTF-8?q?=E2=86=92=20My=20Buddies=20(slug=20+=20name=20+=20partial)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) --- .../0006_rename_contacts_to_buddies.py | 41 +++++ .../tests/integrated/test_buddies.py | 143 ++++++++++++++++++ .../billboard/tests/integrated/test_views.py | 14 +- src/apps/billboard/urls.py | 2 + src/apps/billboard/views.py | 40 +++++ .../lyric/migrations/0004_user_buddies.py | 20 +++ src/apps/lyric/models.py | 8 + src/functional_tests/test_billboard.py | 18 +-- src/functional_tests/test_game_kit.py | 2 +- src/functional_tests/test_my_buddies.py | 75 +++++++++ src/static_src/scss/_billboard.scss | 62 +++++++- ...-contacts.html => _applet-my-buddies.html} | 4 +- .../billboard/_partials/_buddy_add_panel.html | 126 +++++++++++++++ src/templates/apps/billboard/my_buddies.html | 30 ++++ 14 files changed, 562 insertions(+), 23 deletions(-) create mode 100644 src/apps/applets/migrations/0006_rename_contacts_to_buddies.py create mode 100644 src/apps/billboard/tests/integrated/test_buddies.py create mode 100644 src/apps/lyric/migrations/0004_user_buddies.py create mode 100644 src/functional_tests/test_my_buddies.py rename src/templates/apps/billboard/_partials/{_applet-my-contacts.html => _applet-my-buddies.html} (58%) create mode 100644 src/templates/apps/billboard/_partials/_buddy_add_panel.html create mode 100644 src/templates/apps/billboard/my_buddies.html diff --git a/src/apps/applets/migrations/0006_rename_contacts_to_buddies.py b/src/apps/applets/migrations/0006_rename_contacts_to_buddies.py new file mode 100644 index 0000000..8f85394 --- /dev/null +++ b/src/apps/applets/migrations/0006_rename_contacts_to_buddies.py @@ -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), + ] diff --git a/src/apps/billboard/tests/integrated/test_buddies.py b/src/apps/billboard/tests/integrated/test_buddies.py new file mode 100644 index 0000000..3ece2f5 --- /dev/null +++ b/src/apps/billboard/tests/integrated/test_buddies.py @@ -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) diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index 6e276f0..0b56438 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -13,7 +13,7 @@ from apps.lyric.models import User def _seed_billboard_applets(): for slug, name, cols, rows in [ ("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), ]: Applet.objects.get_or_create( @@ -37,7 +37,7 @@ class BillboardViewTest(TestCase): self.assertIn("applets", response.context) slugs = [e["applet"].slug for e in response.context["applets"]] self.assertIn("my-scrolls", slugs) - self.assertIn("my-contacts", slugs) + self.assertIn("my-buddies", slugs) self.assertIn("most-recent-scroll", slugs) def test_passes_my_rooms_context(self): @@ -111,7 +111,7 @@ class ToggleBillboardAppletsTest(TestCase): ) self.assertEqual(response.status_code, 302) 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) self.assertFalse(ua.visible) @@ -136,7 +136,7 @@ class ToggleBillboardAppletsTest(TestCase): reverse("billboard:toggle_applets"), {"applets": [ "my-scrolls", - "my-contacts", + "my-buddies", "most-recent-scroll", ]}, HTTP_HX_REQUEST="true", @@ -160,7 +160,7 @@ class ToggleBillboardAppletsTest(TestCase): self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1) def test_second_toggle_preserves_prior_hidden_state(self): - # First toggle: hide Contacts only. + # First toggle: hide My Buddies only. self.client.post( reverse("billboard:toggle_applets"), {"applets": [ @@ -170,7 +170,7 @@ class ToggleBillboardAppletsTest(TestCase): ]}, 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( reverse("billboard:toggle_applets"), {"applets": [ @@ -180,7 +180,7 @@ class ToggleBillboardAppletsTest(TestCase): HTTP_HX_REQUEST="true", ) 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") self.assertFalse( UserApplet.objects.get(user=self.user, applet=contacts).visible diff --git a/src/apps/billboard/urls.py b/src/apps/billboard/urls.py index 7149371..8adea9b 100644 --- a/src/apps/billboard/urls.py +++ b/src/apps/billboard/urls.py @@ -18,4 +18,6 @@ urlpatterns = [ path("post//", views.view_post, name="view_post"), path("post//share-post", views.share_post, name="share_post"), path("users//", views.my_posts, name="my_posts"), + path("my-buddies/", views.my_buddies, name="my_buddies"), + path("buddies/add", views.add_buddy, name="add_buddy"), ] diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 81ff882..d73fab4 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -373,6 +373,46 @@ def share_post(request, post_id): 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="/") def save_scroll_position(request, room_id): if request.method != "POST": diff --git a/src/apps/lyric/migrations/0004_user_buddies.py b/src/apps/lyric/migrations/0004_user_buddies.py new file mode 100644 index 0000000..01d79f9 --- /dev/null +++ b/src/apps/lyric/migrations/0004_user_buddies.py @@ -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", + ), + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index 71a8ba3..dd9baaa 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -113,6 +113,14 @@ class User(AbstractBaseUser): unlocked_decks = models.ManyToManyField( "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( "drama.Note", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", diff --git a/src/functional_tests/test_billboard.py b/src/functional_tests/test_billboard.py index 945e6c5..b0af2e9 100644 --- a/src/functional_tests/test_billboard.py +++ b/src/functional_tests/test_billboard.py @@ -26,7 +26,7 @@ class BillboardScrollTest(FunctionalTest): super().setUp() for slug, name, cols, rows in [ ("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), ]: Applet.objects.get_or_create( @@ -193,7 +193,7 @@ class BillscrollPositionTest(FunctionalTest): class BillboardAppletsTest(FunctionalTest): """ 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): @@ -203,7 +203,7 @@ class BillboardAppletsTest(FunctionalTest): self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder) for slug, name, cols, rows in [ ("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), ]: Applet.objects.get_or_create( @@ -219,7 +219,7 @@ class BillboardAppletsTest(FunctionalTest): self.wait_for( 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") 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") ) 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) 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) self.wait_for( 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) 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()) most_recent_scroll_cb = menu.find_element( @@ -322,7 +322,7 @@ class BillboardAppletsTest(FunctionalTest): ) ) 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") ) self.assertEqual( - self.browser.find_elements(By.ID, "id_applet_my_contacts"), + self.browser.find_elements(By.ID, "id_applet_my_buddies"), [], ) self.assertEqual( diff --git a/src/functional_tests/test_game_kit.py b/src/functional_tests/test_game_kit.py index 45bd841..6cdaccf 100644 --- a/src/functional_tests/test_game_kit.py +++ b/src/functional_tests/test_game_kit.py @@ -134,7 +134,7 @@ class PronounsAppletFlowTest(FunctionalTest): # Billboard applets — page renders blank without these for slug, name, cols, rows in [ ("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), ]: Applet.objects.get_or_create( diff --git a/src/functional_tests/test_my_buddies.py b/src/functional_tests/test_my_buddies.py new file mode 100644 index 0000000..16dd7a9 --- /dev/null +++ b/src/functional_tests/test_my_buddies.py @@ -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) diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss index edd9c8e..e5ffeed 100644 --- a/src/static_src/scss/_billboard.scss +++ b/src/static_src/scss/_billboard.scss @@ -35,13 +35,15 @@ html:has(body.page-billboard), html:has(body.page-billscroll), -html:has(body.page-billpost) { +html:has(body.page-billpost), +html:has(body.page-billbuddies) { overflow: hidden; } body.page-billboard, body.page-billscroll, -body.page-billpost { +body.page-billpost, +body.page-billbuddies { overflow: hidden; .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 ───────────────────────────────────────────── // Left column (4-wide): My Scrolls → Contacts → Notes stacked. // Right column (8-wide): Most Recent Scroll spans full height. @@ -222,13 +276,13 @@ body.page-billpost { #id_billboard_applets_container { #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_most_recent_scroll { grid-column: 5 / span 8; grid-row: 1 / span 10; } @container (max-width: 550px) { #id_applet_my_scrolls, - #id_applet_my_contacts, + #id_applet_my_buddies, #id_applet_notes, #id_applet_most_recent_scroll { grid-column: 1 / span 12; diff --git a/src/templates/apps/billboard/_partials/_applet-my-contacts.html b/src/templates/apps/billboard/_partials/_applet-my-buddies.html similarity index 58% rename from src/templates/apps/billboard/_partials/_applet-my-contacts.html rename to src/templates/apps/billboard/_partials/_applet-my-buddies.html index 40d8591..a1a3c46 100644 --- a/src/templates/apps/billboard/_partials/_applet-my-contacts.html +++ b/src/templates/apps/billboard/_partials/_applet-my-buddies.html @@ -1,7 +1,7 @@
-

Contacts

+

My Buddies

{% include "core/_partials/_forthcoming.html" %}
diff --git a/src/templates/apps/billboard/_partials/_buddy_add_panel.html b/src/templates/apps/billboard/_partials/_buddy_add_panel.html new file mode 100644 index 0000000..218f6e9 --- /dev/null +++ b/src/templates/apps/billboard/_partials/_buddy_add_panel.html @@ -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. #} +{# ─────────────────────────────────────────────────────────────────────── #} + + + +
+ + +
+ + diff --git a/src/templates/apps/billboard/my_buddies.html b/src/templates/apps/billboard/my_buddies.html new file mode 100644 index 0000000..cde229b --- /dev/null +++ b/src/templates/apps/billboard/my_buddies.html @@ -0,0 +1,30 @@ +{% extends "core/base.html" %} +{% load lyric_extras %} + +{% block title_text %}Dashbuddies{% endblock title_text %} +{% block header_text %}Dashbuddies{% endblock header_text %} + + +{% block content %} +
+
+

My Buddies

+
+ +
    + {% for buddy in buddies %} +
  • + {{ buddy|display_name }} +
  • + {% empty %} +
  • No buddies yet.
  • + {% endfor %} + +
+ + {# 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" %} +
+{% endblock content %}