From eb0369f0b759080cb5a6f67ebd9aa61e45946e0b Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 8 May 2026 23:34:35 -0400 Subject: [PATCH] =?UTF-8?q?buds=20Phase=202:=20top-3=20username|email=20au?= =?UTF-8?q?tocomplete=20on=20#id=5Frecipient=20(post=20share=20+=20my=5Fbu?= =?UTF-8?q?ds=20add);=20implicit=20symmetric=20auto-add=20on=20share=5Fpos?= =?UTF-8?q?t=20(sharer=20=E2=86=94=20recipient=20buds=20graph);=20recipien?= =?UTF-8?q?t=20field=20accepts=20username=20OR=20email=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - billboard.views.search_buds(GET /billboard/buds/search?q=...) — top-3 prefix match against request.user.buds via Q(username__istartswith) | Q(email__istartswith). Returns {buds: [{id, username, email}]}. Privacy: only the user's own buds are searched, no leak of strangers. - _resolve_recipient(raw) helper resolves a free-form recipient (email if "@" present, else username, both case-insensitive). Wired into add_bud + share_post so #id_recipient accepts either form. - share_post implicit auto-add (per-spec): when recipient is registered + first-time-shared, both directions of buds M2M get the link — request.user.buds.add(recipient) AND recipient.buds.add(request.user). Idempotent, no auto-add on reshare/self/unregistered. - new bud-autocomplete.js shared module (apps/billboard/static/apps/billboard/) — bindBudAutocomplete(input, suggestionsEl, {searchUrl}). Mirrors sky.html birth-place picker: 250ms debounced fetch from MIN_CHARS=1, click-to-fill, Escape closes, click-outside closes, late-response drop. e.stopPropagation on suggestion-click so the bud-panel's outside-click handler doesn't fire and clear the input. - SCSS .bud-suggestions / .bud-suggestion-item mirrors .sky-suggestions but position:fixed bottom:4rem (aligned above the bud panel, with overflow:hidden on the panel forcing the dropdown to live as a sibling rather than a child). Landscape breakpoints clear the navbar/footer 4rem sidebars, 8rem at min-width 1800px. - both _bud_panel.html (post share) + _bud_add_panel.html (my_buds add) get the suggestions div sibling + script tags. Each panel's existing document click-outside handler now skips the suggestions container so a click inside doesn't close+clear. type="email" → type="text" since usernames are accepted; placeholder "friend@example.com or username". - new test classes in test_buds.py: SearchBudsViewTest (6 — prefix match, cap-3, email prefix, non-bud leakproof, empty-q, anon redirect) + SharePostImplicitAutoAddTest (4 — sharer.buds += recipient, recipient.buds += sharer, username-typed share, unregistered no-add) + AddBudViewTest.test_add_resolves_username_too. test_my_buds.py FT adds test_autocomplete_suggests_buds_by_username_prefix. test_sharing.py placeholder assertion updated to "friend@example.com or username". - 852 ITs (+11) + 5 my_buds FTs green. Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) --- .../static/apps/billboard/bud-autocomplete.js | 115 ++++++++++++++++++ .../billboard/tests/integrated/test_buds.py | 98 +++++++++++++++ src/apps/billboard/urls.py | 1 + src/apps/billboard/views.py | 70 +++++++++-- src/functional_tests/test_my_buds.py | 25 ++++ src/functional_tests/test_sharing.py | 2 +- src/static_src/scss/_bud.scss | 51 ++++++++ .../billboard/_partials/_bud_add_panel.html | 21 +++- .../apps/billboard/_partials/_bud_panel.html | 22 +++- 9 files changed, 388 insertions(+), 17 deletions(-) create mode 100644 src/apps/billboard/static/apps/billboard/bud-autocomplete.js diff --git a/src/apps/billboard/static/apps/billboard/bud-autocomplete.js b/src/apps/billboard/static/apps/billboard/bud-autocomplete.js new file mode 100644 index 0000000..46e2114 --- /dev/null +++ b/src/apps/billboard/static/apps/billboard/bud-autocomplete.js @@ -0,0 +1,115 @@ +// Bud-list autocomplete for #id_recipient inputs (post share panel + my_buds +// add panel). Mirrors the sky.html birth-place picker pattern: debounced +// fetch on input, top-3 suggestions rendered as buttons, click-to-fill, +// Escape closes, click-outside closes. No keyboard arrow/Enter cycling. +// +// Usage: +//
+// +// +//
+// +// + +(function () { + 'use strict'; + + var DEBOUNCE_MS = 250; + var MIN_CHARS = 1; + + function _esc(s) { + var d = document.createElement('div'); + d.textContent = s == null ? '' : s; + return d.innerHTML; + } + + window.bindBudAutocomplete = function (input, suggestions, options) { + if (!input || !suggestions || !options || !options.searchUrl) return; + + var debounceTimer = null; + var lastQuery = ''; + + function _hide() { + suggestions.hidden = true; + suggestions.innerHTML = ''; + } + + function _render(buds) { + if (!buds || !buds.length) { + _hide(); + return; + } + suggestions.innerHTML = buds.map(function (b) { + // data-email + data-username so the click handler can fill the + // input with whichever the user originally typed (email if they + // started with `@`, else username). + return ( + '' + ); + }).join(''); + suggestions.hidden = false; + } + + function _fetch(q) { + var url = options.searchUrl + '?q=' + encodeURIComponent(q); + fetch(url, { + credentials: 'same-origin', + headers: { 'Accept': 'application/json' }, + }) + .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); }) + .then(function (data) { + // Drop late responses if the user has typed past this query. + if (input.value.trim() !== q) return; + _render(data.buds || []); + }) + .catch(function () { _hide(); }); + } + + input.addEventListener('input', function () { + var q = input.value.trim(); + lastQuery = q; + if (q.length < MIN_CHARS) { _hide(); return; } + clearTimeout(debounceTimer); + debounceTimer = setTimeout(function () { _fetch(q); }, DEBOUNCE_MS); + }); + + input.addEventListener('keydown', function (e) { + if (e.key === 'Escape') _hide(); + }); + + suggestions.addEventListener('click', function (e) { + var btn = e.target.closest('.bud-suggestion-item'); + if (!btn) return; + // Stop propagation so the bud-panel's document-level click- + // outside handler doesn't fire and close+clear the panel — + // _hide() about to detach the target makes a `sg.contains(e.target)` + // check at the document level unreliable. + e.stopPropagation(); + // Fill w. whichever form the user was typing (email vs username). + // If the input value already contains '@', prefer email; else + // prefer username. This keeps the OK-submit semantics consistent + // w. what the user intended. + var typed = input.value.trim(); + input.value = typed.indexOf('@') !== -1 + ? btn.dataset.email + : btn.dataset.username; + _hide(); + input.focus(); + }); + + document.addEventListener('click', function (e) { + if (suggestions.hidden) return; + if (suggestions.contains(e.target)) return; + if (e.target === input) return; + _hide(); + }); + }; +}()); diff --git a/src/apps/billboard/tests/integrated/test_buds.py b/src/apps/billboard/tests/integrated/test_buds.py index 5046345..e92d4e7 100644 --- a/src/apps/billboard/tests/integrated/test_buds.py +++ b/src/apps/billboard/tests/integrated/test_buds.py @@ -141,3 +141,101 @@ class AddBudViewTest(TestCase): def test_get_returns_405(self): response = self.client.get(reverse("billboard:add_bud")) self.assertEqual(response.status_code, 405) + + def test_add_resolves_username_too_not_just_email(self): + """Phase 2: recipient field accepts usernames as well as emails.""" + alice = User.objects.create(email="alice@test.io", username="alice") + response = self.client.post( + reverse("billboard:add_bud"), + data={"recipient": "alice"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["bud"]["username"], "alice") + self.assertIn(alice, self.user.buds.all()) + + +class SearchBudsViewTest(TestCase): + """Top-3 prefix-match autocomplete endpoint backing #id_recipient.""" + + def setUp(self): + self.user = User.objects.create(email="me@test.io", username="me") + self.client.force_login(self.user) + self.alice = User.objects.create(email="alice@test.io", username="alice") + self.albert = User.objects.create(email="albert@test.io", username="albert") + self.alvin = User.objects.create(email="alvin@test.io", username="alvin") + self.bob = User.objects.create(email="bob@test.io", username="bob") + self.user.buds.add(self.alice, self.albert, self.alvin, self.bob) + + def test_username_prefix_match(self): + response = self.client.get(reverse("billboard:search_buds"), {"q": "al"}) + usernames = [b["username"] for b in response.json()["buds"]] + # alice, albert, alvin all start with "al" — exactly 3 (cap) + self.assertEqual(len(usernames), 3) + self.assertIn("alice", usernames) + self.assertIn("albert", usernames) + self.assertIn("alvin", usernames) + self.assertNotIn("bob", usernames) + + def test_caps_at_three_results(self): + d = User.objects.create(email="alfred@test.io", username="alfred") + self.user.buds.add(d) + response = self.client.get(reverse("billboard:search_buds"), {"q": "al"}) + self.assertEqual(len(response.json()["buds"]), 3) + + def test_email_prefix_also_matches(self): + response = self.client.get(reverse("billboard:search_buds"), {"q": "bob@"}) + usernames = [b["username"] for b in response.json()["buds"]] + self.assertIn("bob", usernames) + + def test_does_not_leak_non_buds(self): + """Non-buds (other registered users) don't appear in suggestions.""" + User.objects.create(email="stranger@test.io", username="stranger") + response = self.client.get(reverse("billboard:search_buds"), {"q": "str"}) + self.assertEqual(response.json()["buds"], []) + + def test_empty_q_returns_empty_list(self): + response = self.client.get(reverse("billboard:search_buds"), {"q": ""}) + self.assertEqual(response.json()["buds"], []) + + def test_anon_redirects(self): + self.client.logout() + response = self.client.get(reverse("billboard:search_buds"), {"q": "al"}) + self.assertEqual(response.status_code, 302) + + +class SharePostImplicitAutoAddTest(TestCase): + """Per-spec: when a share lands a recipient on Post.shared_with, the + sharer + recipient mutually auto-add each other to their buds lists.""" + + def setUp(self): + from apps.billboard.models import Post + self.sharer = User.objects.create(email="sharer@test.io", username="sharer") + self.client.force_login(self.sharer) + self.alice = User.objects.create(email="alice@test.io", username="alice") + self.post = Post.objects.create(owner=self.sharer) + + def _share(self, recipient): + return self.client.post( + reverse("billboard:share_post", args=[self.post.id]), + data={"recipient": recipient}, + HTTP_ACCEPT="application/json", + ) + + def test_share_adds_recipient_to_sharer_buds(self): + self._share("alice@test.io") + self.assertIn(self.alice, self.sharer.buds.all()) + + def test_share_adds_sharer_to_recipient_buds(self): + """Symmetric on shared events — recipient also gets the sharer.""" + self._share("alice@test.io") + self.assertIn(self.sharer, self.alice.buds.all()) + + def test_share_with_username_also_auto_adds(self): + self._share("alice") + self.assertIn(self.alice, self.sharer.buds.all()) + self.assertIn(self.sharer, self.alice.buds.all()) + + def test_unregistered_recipient_does_not_auto_add(self): + """Privacy: unregistered email doesn't touch the buds graph.""" + self._share("ghost@test.io") + self.assertEqual(self.sharer.buds.count(), 0) diff --git a/src/apps/billboard/urls.py b/src/apps/billboard/urls.py index e21a836..5035368 100644 --- a/src/apps/billboard/urls.py +++ b/src/apps/billboard/urls.py @@ -20,4 +20,5 @@ urlpatterns = [ path("users//", views.my_posts, name="my_posts"), path("my-buds/", views.my_buds, name="my_buds"), path("buds/add", views.add_bud, name="add_bud"), + path("buds/search", views.search_buds, name="search_buds"), ] diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index bdc2de1..4655355 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -311,12 +311,12 @@ def share_post(request, post_id): our_post = Post.objects.get(id=post_id) is_ajax = "application/json" in request.headers.get("Accept", "") - recipient_email = request.POST.get("recipient", "") - recipient = None - try: - recipient = User.objects.get(email=recipient_email) - except User.DoesNotExist: - pass + # Recipient may be email OR username — _resolve_recipient handles both + # (email if "@" present, else username lookup). The raw value is kept + # for the Line text since users see what they typed in the per-line + # rendering (post-refresh + optimistic JS append). + recipient_email = (request.POST.get("recipient") or "").strip() + recipient = _resolve_recipient(recipient_email) # Sharer-tries-to-share-with-themselves: silent no-op (existing behavior). if recipient is not None and recipient == request.user: @@ -332,6 +332,12 @@ def share_post(request, post_id): if recipient is not None and not is_reshare: our_post.shared_with.add(recipient) + # Implicit auto-add to the buds graph — symmetric on shared events + # (per-spec): a share-event implies a mutual social link. + # `add()` is idempotent on M2M, no need to pre-check membership. + if request.user.is_authenticated: + request.user.buds.add(recipient) + recipient.buds.add(request.user) line = None brief = None @@ -390,20 +396,34 @@ def my_buds(request): }) +def _resolve_recipient(raw): + """Resolve a free-form recipient (email OR username) to a User, or None. + Email match takes precedence — if the input contains '@' we don't even + try the username lookup, so a username that happens to match an email + user's local part doesn't get coerced. Used by add_bud + share_post.""" + raw = (raw or "").strip() + if not raw: + return None + if "@" in raw: + try: + return User.objects.get(email__iexact=raw) + except User.DoesNotExist: + return None + try: + return User.objects.get(username__iexact=raw) + except User.DoesNotExist: + return None + + @login_required(login_url="/") def add_bud(request): if request.method != "POST": from django.http import HttpResponseNotAllowed return HttpResponseNotAllowed(["POST"]) - email = (request.POST.get("recipient") or "").strip() + candidate = _resolve_recipient(request.POST.get("recipient")) bud = 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.buds.all(): request.user.buds.add(candidate) @@ -416,6 +436,32 @@ def add_bud(request): return JsonResponse({"bud": bud}) +@login_required(login_url="/") +def search_buds(request): + """Top-3 prefix-match autocomplete pool for #id_recipient inputs. + Pulls only from request.user.buds — buds that haven't been added yet + don't appear in the autocomplete (privacy-by-default; new buds enter + the list via explicit add or implicit auto-add on share/invite). + Matches case-insensitive on either username or email prefix.""" + from django.db.models import Q + q = (request.GET.get("q") or "").strip() + if not q: + return JsonResponse({"buds": []}) + matches = ( + request.user.buds + .filter(Q(username__istartswith=q) | Q(email__istartswith=q)) + .order_by("username", "email")[:3] + ) + return JsonResponse({"buds": [ + { + "id": str(b.id), + "username": b.username or b.email, + "email": b.email, + } + for b in matches + ]}) + + @login_required(login_url="/") def save_scroll_position(request, room_id): if request.method != "POST": diff --git a/src/functional_tests/test_my_buds.py b/src/functional_tests/test_my_buds.py index 8158bd9..4068a57 100644 --- a/src/functional_tests/test_my_buds.py +++ b/src/functional_tests/test_my_buds.py @@ -57,6 +57,31 @@ class MyBudsPageTest(FunctionalTest): self.alice, list(self.gamer.buds.all()) )) + def test_autocomplete_suggests_buds_by_username_prefix(self): + """Phase 2: typing in #id_recipient pulls top-3 prefix matches from + request.user.buds and renders them as .bud-suggestion-item buttons. + Click → input.value fills with the bud's username (or email if the + user typed an `@` already).""" + self.gamer.buds.add(self.alice) + bob = User.objects.create(email="bob@test.io", username="bob") + self.gamer.buds.add(bob) + + self.browser.get(self.live_server_url + "/billboard/my-buds/") + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) + btn.click() + recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) + recipient.send_keys("al") + + suggestions = self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_bud_suggestions .bud-suggestion-item" + )) + self.assertEqual(suggestions.text.strip(), "alice") + + suggestions.click() + self.wait_for(lambda: self.assertEqual( + recipient.get_attribute("value"), "alice" + )) + def test_add_unregistered_email_is_silent_noop(self): self.browser.get(self.live_server_url + "/billboard/my-buds/") btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) diff --git a/src/functional_tests/test_sharing.py b/src/functional_tests/test_sharing.py index 89a5145..173662c 100644 --- a/src/functional_tests/test_sharing.py +++ b/src/functional_tests/test_sharing.py @@ -41,7 +41,7 @@ class SharingTest(FunctionalTest): share_box = post_page.get_share_box() self.assertEqual( share_box.get_attribute("placeholder"), - "friend@example.com", + "friend@example.com or username", ) post_page.share_post_with("alice@test.io") diff --git a/src/static_src/scss/_bud.scss b/src/static_src/scss/_bud.scss index 436a7ef..84c1c44 100644 --- a/src/static_src/scss/_bud.scss +++ b/src/static_src/scss/_bud.scss @@ -118,3 +118,54 @@ html:has(#id_kit_bag_dialog[open]) #id_bud_btn { opacity: 0; pointer-events: none; } + +// ── Bud autocomplete suggestions (mirror of sky-place birth picker) ── +// Sibling of #id_bud_panel (which has overflow:hidden for the scaleX +// slide animation, so the suggestions can't be a child or they'd clip). +// Position-fixed above the panel; same left/right inset as the panel +// at each breakpoint so the dropdown lines up. +.bud-suggestions { + position: fixed; + bottom: 4rem; // panel bottom (0.5rem) + height (3rem) + gap (0.5rem) + left: 1.5rem; + right: 1.5rem; + z-index: 320; // above the panel itself + background: rgba(var(--priUser), 1); + border: 0.1rem solid rgba(var(--terUser), 0.3); + border-radius: 0.3rem; + overflow-y: auto; + max-height: 10rem; + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4); + + @media (orientation: landscape) { + left: calc(4rem + 0.5rem); + right: calc(4rem + 0.5rem); + } + + @media (orientation: landscape) and (min-width: 1800px) { + left: calc(8rem + 0.5rem); + right: calc(8rem + 0.5rem); + } +} + +.bud-suggestion-item { + display: block; + width: 100%; + padding: 0.4rem 0.6rem; + text-align: left; + background: none; + border: none; + border-bottom: 0.05rem solid rgba(var(--terUser), 0.1); + font-size: 0.85rem; + color: rgba(var(--ninUser), 0.85); + cursor: pointer; + line-height: 1.35; + + &:last-child { border-bottom: none; } + + &:hover, &:focus { + background: rgba(var(--terUser), 0.12); + color: rgba(var(--ninUser), 1); + outline: none; + } +} diff --git a/src/templates/apps/billboard/_partials/_bud_add_panel.html b/src/templates/apps/billboard/_partials/_bud_add_panel.html index 9ad69e8..8f7cf9a 100644 --- a/src/templates/apps/billboard/_partials/_bud_add_panel.html +++ b/src/templates/apps/billboard/_partials/_bud_add_panel.html @@ -13,12 +13,25 @@
+{# Autocomplete suggestions list — sibling of #id_bud_panel because the #} +{# panel has overflow:hidden for its scaleX slide animation. #} + + + + + + +