buds Phase 2: top-3 username|email autocomplete on #id_recipient (post share + my_buds add); implicit symmetric auto-add on share_post (sharer ↔ recipient buds graph); recipient field accepts username OR email — TDD

- 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 <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 23:34:35 -04:00
parent 11ff109d1e
commit eb0369f0b7
9 changed files with 388 additions and 17 deletions

View File

@@ -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:
// <div class="bud-panel-wrap">
// <input id="id_recipient" ...>
// <div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
// </div>
// <script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
// <script>bindBudAutocomplete(
// document.getElementById('id_recipient'),
// document.getElementById('id_bud_suggestions'),
// { searchUrl: '{% url "billboard:search_buds" %}' }
// );</script>
(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 (
'<button type="button" class="bud-suggestion-item" ' +
'data-email="' + _esc(b.email) + '" ' +
'data-username="' + _esc(b.username) + '">' +
_esc(b.username) +
'</button>'
);
}).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();
});
};
}());

View File

@@ -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)

View File

@@ -20,4 +20,5 @@ urlpatterns = [
path("users/<uuid:user_id>/", 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"),
]

View File

@@ -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":

View File

@@ -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"))

View File

@@ -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")

View File

@@ -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;
}
}

View File

@@ -13,12 +13,25 @@
<div id="id_bud_panel" data-add-url="{% url 'billboard:add_bud' %}">
<input id="id_recipient"
name="recipient"
type="email"
placeholder="friend@example.com"
type="text"
placeholder="friend@example.com or username"
autocomplete="off">
<button id="id_bud_ok" type="button" class="btn btn-confirm">OK</button>
</div>
{# Autocomplete suggestions list — sibling of #id_bud_panel because the #}
{# panel has overflow:hidden for its scaleX slide animation. #}
<div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
<script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
<script>
bindBudAutocomplete(
document.getElementById('id_recipient'),
document.getElementById('id_bud_suggestions'),
{ searchUrl: '{% url "billboard:search_buds" %}' }
);
</script>
<script>
(function () {
'use strict';
@@ -64,6 +77,10 @@
if (!html.classList.contains('bud-open')) return;
if (panel.contains(e.target)) return;
if (e.target === btn || btn.contains(e.target)) return;
// Suggestions live outside the panel (panel has overflow:hidden
// for its scaleX slide); a click inside them must NOT close+clear.
var sg = document.getElementById('id_bud_suggestions');
if (sg && sg.contains(e.target)) return;
_close();
});

View File

@@ -19,12 +19,25 @@
data-sharer-name="{% if request.user.is_authenticated %}{{ request.user|display_name }}{% endif %}">
<input id="id_recipient"
name="recipient"
type="email"
placeholder="friend@example.com"
type="text"
placeholder="friend@example.com or username"
autocomplete="off">
<button id="id_bud_ok" type="button" class="btn btn-confirm">OK</button>
</div>
{# Autocomplete suggestions — sibling of #id_bud_panel because the panel #}
{# has overflow:hidden for its scaleX slide animation. #}
<div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
<script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
<script>
bindBudAutocomplete(
document.getElementById('id_recipient'),
document.getElementById('id_bud_suggestions'),
{ searchUrl: '{% url "billboard:search_buds" %}' }
);
</script>
<script>
(function () {
'use strict';
@@ -73,6 +86,11 @@
if (!html.classList.contains('bud-open')) return;
if (panel.contains(e.target)) return;
if (e.target === btn || btn.contains(e.target)) return;
// Autocomplete suggestions sit outside the panel (panel overflow
// is hidden for the scaleX slide). A click inside them must NOT
// close+clear the panel.
var sg = document.getElementById('id_bud_suggestions');
if (sg && sg.contains(e.target)) return;
_close();
});