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. #}
+
+
+
+
+
+
+