Yōten: A social platform for tracking the essential points of your language learning yoten.app
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: add actor typeahead in comment section

Signed-off-by: brookjeynes <me@brookjeynes.dev>

author
brookjeynes
committer
Tangled
date (May 25, 2026, 12:25 AM +0300) commit 7326dadf parent dd94922d change-id ymnnsuyt
+273 -182
+11 -9
internal/server/ui/components/discussion/discussion.templ
··· 18 18 > 19 19 <input type="hidden" name="study_session_uri" value={ params.StudySessionURI }/> 20 20 <div class="mt-2"> 21 - <textarea 22 - x-model="text" 23 - id="comment" 24 - name="comment" 25 - placeholder="Share your thoughts about this study session..." 26 - class="input w-full" 27 - maxLength="256" 28 - rows="3" 29 - ></textarea> 21 + <actor-typeahead mode="mention"> 22 + <textarea 23 + x-model="text" 24 + id="comment" 25 + name="comment" 26 + placeholder="Share your thoughts about this study session..." 27 + class="input w-full" 28 + maxLength="256" 29 + rows="3" 30 + ></textarea> 31 + </actor-typeahead> 30 32 <div class="flex justify-between mt-2"> 31 33 <div class="text-sm text-text-muted"> 32 34 <span x-text="text.length"></span> / 256
+13 -11
internal/server/ui/components/edit-comment/edit-comment.templ
··· 11 11 x-init="text = $el.querySelector('textarea').value; lucide.createIcons();" 12 12 > 13 13 <div class="mt-2"> 14 - <textarea 15 - x-model="text" 16 - id="comment" 17 - name="comment" 18 - placeholder="Share your thoughts about this study session..." 19 - class="input w-full" 20 - maxLength="256" 21 - rows="3" 22 - > 23 - { params.Comment.Body } 24 - </textarea> 14 + <actor-typeahead mode="mention"> 15 + <textarea 16 + x-model="text" 17 + id="comment" 18 + name="comment" 19 + placeholder="Share your thoughts about this study session..." 20 + class="input w-full" 21 + maxLength="256" 22 + rows="3" 23 + > 24 + { params.Comment.Body } 25 + </textarea> 26 + </actor-typeahead> 25 27 <div class="flex justify-between mt-2"> 26 28 <div class="text-sm text-text-muted"> 27 29 <span x-text="text.length"></span> / 256
+11 -9
internal/server/ui/components/new-reply/new-reply.templ
··· 12 12 <input type="hidden" name="parent_uri" value={ params.ParentURI }/> 13 13 <input type="hidden" name="study_session_uri" value={ params.StudySessionURI }/> 14 14 <div class="mt-2"> 15 - <textarea 16 - x-model="text" 17 - id="comment" 18 - name="comment" 19 - placeholder="Share your thoughts about this study session..." 20 - class="input w-full" 21 - maxLength="256" 22 - rows="3" 23 - ></textarea> 15 + <actor-typeahead mode="mention"> 16 + <textarea 17 + x-model="text" 18 + id="comment" 19 + name="comment" 20 + placeholder="Share your thoughts about this study session..." 21 + class="input w-full" 22 + maxLength="256" 23 + rows="3" 24 + ></textarea> 25 + </actor-typeahead> 24 26 <div class="flex justify-between mt-2"> 25 27 <div class="text-sm text-text-muted"> 26 28 <span x-text="text.length"></span> / 256
+1
internal/server/ui/layouts/base/base.templ
··· 24 24 @footer.Footer() 25 25 </body> 26 26 <script type="module" src="/static/js/base.js"></script> 27 + <script type="module" src="/static/js/actor-typeahead.js"></script> 27 28 </html> 28 29 }
-1
internal/server/ui/views/login/login.templ
··· 223 223 </a> 224 224 </div> 225 225 </div> 226 - <script type="module" src="/static/js/actor-typeahead.js"></script> 227 226 } 228 227 }
+236 -151
static/actor-typeahead.js
··· 3 3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 4 * 5 5 * Copyright (c) 2026 Jake Lazaroff 6 + * Modifications Copyright (c) 2026 Brook Jeynes 6 7 * 7 8 * Repository: https://tangled.org/jakelazaroff.com/actor-typeahead 8 9 */ ··· 48 49 border-radius: var(--radius-inherited); 49 50 box-shadow: 0 6px 6px -4px rgb(from var(--color-shadow-inherited) r g b / 20%); 50 51 padding: var(--padding-menu-inherited); 52 + z-index: 50; 51 53 } 52 54 53 55 .menu:empty { ··· 112 114 * @param {T} tmpl 113 115 */ 114 116 function clone(tmpl) { 115 - return /** @type {T} */ (tmpl.cloneNode(true)); 117 + return /** @type {T} */ (tmpl.cloneNode(true)); 118 + } 119 + 120 + /** 121 + * Scan backwards from the cursor to find an active @mention query. 122 + * Returns { start, query } where start is the index of the @ character, 123 + * or null if there is no active mention at the cursor. 124 + * 125 + * @param {string} value 126 + * @param {number} cursorPos 127 + * @returns {{ start: number, query: string } | null} 128 + */ 129 + function findMentionQuery(value, cursorPos) { 130 + for (let i = cursorPos - 1; i >= 0; i--) { 131 + const ch = value[i]; 132 + if (ch === "@") { 133 + // only trigger if @ is at the start or preceded by whitespace 134 + if (i === 0 || /\s/.test(value[i - 1])) { 135 + const query = value.slice(i + 1, cursorPos); 136 + // require at least one character and no whitespace in the query 137 + if (query.length > 0 && !/\s/.test(query)) { 138 + return { start: i, query }; 139 + } 140 + } 141 + 142 + return null; 143 + } 144 + 145 + // whitespace before finding @ means no active mention 146 + if (/\s/.test(ch)) return null; 147 + } 148 + return null; 116 149 } 117 150 118 151 /** 119 152 * @attribute {string} [host] - The host to which to make the typeahead API call. 120 153 * @attribute {number} [rows] - The maximum number of rows to display in the dropdown. 154 + * @attribute {"mention"} [mode] - Set to "mention" to enable @-trigger mode for <textarea> elements. 121 155 * 122 156 * @csspart menu - The dropdown menu. 123 157 * @csspart user - The user row. ··· 125 159 * @csspart img - The user avatar image. 126 160 * @csspart handle - The user handle text. 127 161 * 128 - * @slot - The <input> tag to progressively enhance. 162 + * @slot - The <input> or (in mention mode) <textarea> tag to progressively enhance. 129 163 * 130 164 * @cssprop --color-background - Controls the color of the dropdown background. 131 165 * @cssprop --color-border - Controls the color of the dropdown border. ··· 140 174 * @tag actor-typeahead 141 175 */ 142 176 export default class ActorTypeahead extends HTMLElement { 143 - static tag = "actor-typeahead"; 177 + static tag = "actor-typeahead"; 144 178 145 - static define(tag = this.tag) { 146 - this.tag = tag; 179 + static define(tag = this.tag) { 180 + this.tag = tag; 147 181 148 - const name = customElements.getName(this); 149 - if (name && name !== tag) return console.warn(`${this.name} already defined as <${name}>!`); 182 + const name = customElements.getName(this); 183 + if (name && name !== tag) return console.warn(`${this.name} already defined as <${name}>!`); 150 184 151 - const ce = customElements.get(tag); 152 - if (ce && ce !== this) return console.warn(`<${tag}> already defined as ${ce.name}!`); 185 + const ce = customElements.get(tag); 186 + if (ce && ce !== this) return console.warn(`<${tag}> already defined as ${ce.name}!`); 153 187 154 - customElements.define(tag, this); 155 - } 188 + customElements.define(tag, this); 189 + } 156 190 157 - static { 158 - const tag = new URL(import.meta.url).searchParams.get("tag") || this.tag; 159 - if (tag !== "none") this.define(tag); 160 - } 191 + static { 192 + const tag = new URL(import.meta.url).searchParams.get("tag") || this.tag; 193 + if (tag !== "none") this.define(tag); 194 + } 161 195 162 - #shadow = this.attachShadow({ mode: "closed" }); 196 + #shadow = this.attachShadow({ mode: "closed" }); 163 197 164 - /** @type {Array<{ handle: string; avatar: string }>} */ 165 - #actors = []; 166 - #index = -1; 167 - #pressed = false; 198 + /** @type {Array<{ handle: string; avatar: string }>} */ 199 + #actors = []; 200 + #index = -1; 201 + #pressed = false; 202 + #mentionStart = -1; 168 203 169 - constructor() { 170 - super(); 204 + constructor() { 205 + super(); 171 206 172 - this.#shadow.append(clone(template).content); 173 - this.#render(); 174 - this.addEventListener("input", this); 175 - this.addEventListener("focusout", this); 176 - this.addEventListener("keydown", this); 177 - this.#shadow.addEventListener("pointerdown", this); 178 - this.#shadow.addEventListener("pointerup", this); 179 - this.#shadow.addEventListener("click", this); 180 - } 207 + this.#shadow.append(clone(template).content); 208 + this.#render(); 209 + this.addEventListener("input", this); 210 + this.addEventListener("focusout", this); 211 + this.addEventListener("keydown", this); 212 + this.#shadow.addEventListener("pointerdown", this); 213 + this.#shadow.addEventListener("pointerup", this); 214 + this.#shadow.addEventListener("click", this); 215 + } 181 216 182 - get #rows() { 183 - const rows = Number.parseInt(this.getAttribute("rows") ?? ""); 217 + get #rows() { 218 + const rows = Number.parseInt(this.getAttribute("rows") ?? ""); 219 + 220 + if (Number.isNaN(rows)) return 5; 221 + return rows; 222 + } 223 + 224 + /** @param {Event} evt */ 225 + handleEvent(evt) { 226 + switch (evt.type) { 227 + case "input": 228 + this.#oninput(/** @type {InputEvent & { target: HTMLInputElement }} */(evt)); 229 + break; 230 + 231 + case "keydown": 232 + this.#onkeydown(/** @type {KeyboardEvent} */(evt)); 233 + break; 234 + 235 + case "focusout": 236 + this.#onfocusout(evt); 237 + break; 238 + 239 + case "pointerdown": 240 + this.#onpointerdown(/** @type {PointerEvent & { target: HTMLElement }} */(evt)); 241 + break; 242 + 243 + case "pointerup": 244 + this.#onpointerup(/** @type {PointerEvent & { target: HTMLElement }} */(evt)); 245 + break; 246 + } 247 + } 248 + 249 + /** @param {KeyboardEvent} evt */ 250 + #onkeydown(evt) { 251 + // in mention mode only intercept when the dropdown is actually open 252 + if (this.#isMention && this.#actors.length === 0) return; 253 + 254 + switch (evt.key) { 255 + case "ArrowDown": 256 + evt.preventDefault(); 257 + this.#index = Math.min(this.#index + 1, this.#rows - 1); 258 + this.#render(); 259 + break; 260 + 261 + case "PageDown": 262 + evt.preventDefault(); 263 + this.#index = this.#rows - 1; 264 + this.#render(); 265 + break; 184 266 185 - if (Number.isNaN(rows)) return 5; 186 - return rows; 187 - } 267 + case "ArrowUp": 268 + evt.preventDefault(); 269 + this.#index = Math.max(this.#index - 1, 0); 270 + this.#render(); 271 + break; 188 272 189 - /** @param {Event} evt */ 190 - handleEvent(evt) { 191 - switch (evt.type) { 192 - case "input": 193 - this.#oninput(/** @type {InputEvent & { target: HTMLInputElement }} */(evt)); 194 - break; 273 + case "PageUp": 274 + evt.preventDefault(); 275 + this.#index = 0; 276 + this.#render(); 277 + break; 195 278 196 - case "keydown": 197 - this.#onkeydown(/** @type {KeyboardEvent} */(evt)); 198 - break; 279 + case "Escape": 280 + evt.preventDefault(); 281 + this.#actors = []; 282 + this.#index = -1; 283 + this.#render(); 284 + break; 199 285 200 - case "focusout": 201 - this.#onfocusout(evt); 202 - break; 286 + case "Enter": 287 + // in mention mode, only intercept Enter when an item is actually selected 288 + if (this.#isMention && this.#index < 0) break; 203 289 204 - case "pointerdown": 205 - this.#onpointerdown(/** @type {PointerEvent & { target: HTMLElement }} */(evt)); 206 - break; 290 + evt.preventDefault(); 291 + this.#shadow 292 + .querySelectorAll("button") 293 + [this.#index]?.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })); 294 + break; 295 + } 296 + } 297 + 298 + /** @param {InputEvent & { target: HTMLInputElement | HTMLTextAreaElement }} evt */ 299 + async #oninput(evt) { 300 + let query; 207 301 208 - case "pointerup": 209 - this.#onpointerup(/** @type {PointerEvent & { target: HTMLElement }} */(evt)); 210 - break; 211 - } 212 - } 302 + if (this.#isMention) { 303 + const textarea = /** @type {HTMLTextAreaElement} */ (evt.target); 304 + const cursor = textarea.selectionStart ?? 0; 305 + const mention = findMentionQuery(textarea.value, cursor); 306 + if (!mention) { 307 + this.#actors = []; 308 + this.#render(); 309 + return; 310 + } 311 + this.#mentionStart = mention.start; 312 + query = mention.query; 313 + } else { 314 + query = /** @type {HTMLInputElement} */ (evt.target)?.value; 315 + if (!query) { 316 + this.#actors = []; 317 + this.#render(); 318 + return; 319 + } 320 + } 213 321 214 - /** @param {KeyboardEvent} evt */ 215 - #onkeydown(evt) { 216 - switch (evt.key) { 217 - case "ArrowDown": 218 - evt.preventDefault(); 219 - this.#index = Math.min(this.#index + 1, this.#rows - 1); 220 - this.#render(); 221 - break; 322 + const host = this.getAttribute("host") ?? "https://public.api.bsky.app"; 323 + const url = new URL("xrpc/app.bsky.actor.searchActorsTypeahead", host); 324 + url.searchParams.set("q", query); 325 + url.searchParams.set("limit", `${this.#rows}`); 222 326 223 - case "PageDown": 224 - evt.preventDefault(); 225 - this.#index = this.#rows - 1; 226 - this.#render(); 227 - break; 327 + const res = await fetch(url); 328 + const json = await res.json(); 329 + this.#actors = json.actors; 330 + this.#index = -1; 331 + this.#render(); 332 + } 228 333 229 - case "ArrowUp": 230 - evt.preventDefault(); 231 - this.#index = Math.max(this.#index - 1, 0); 232 - this.#render(); 233 - break; 334 + /** @param {Event} evt */ 335 + async #onfocusout(evt) { 336 + if (this.#pressed) return; 234 337 235 - case "PageUp": 236 - evt.preventDefault(); 237 - this.#index = 0; 238 - this.#render(); 239 - break; 338 + this.#actors = []; 339 + this.#index = -1; 340 + this.#render(); 341 + } 240 342 241 - case "Escape": 242 - evt.preventDefault(); 243 - this.#actors = []; 244 - this.#index = -1; 245 - this.#render(); 246 - break; 343 + #render() { 344 + const fragment = document.createDocumentFragment(); 345 + let i = -1; 346 + for (const actor of this.#actors) { 347 + const li = clone(user).content; 247 348 248 - case "Enter": 249 - evt.preventDefault(); 250 - this.#shadow 251 - .querySelectorAll("button") 252 - [this.#index]?.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })); 253 - break; 254 - } 255 - } 349 + const button = li.querySelector("button"); 350 + if (button) { 351 + button.dataset.handle = actor.handle; 352 + if (++i === this.#index) button.dataset.active = "true"; 353 + } 256 354 257 - /** @param {InputEvent & { target: HTMLInputElement }} evt */ 258 - async #oninput(evt) { 259 - const query = evt.target?.value; 260 - if (!query) { 261 - this.#actors = []; 262 - this.#render(); 263 - return; 264 - } 355 + const avatar = li.querySelector("img"); 356 + if (avatar && actor.avatar) avatar.src = actor.avatar; 265 357 266 - const host = this.getAttribute("host") ?? "https://public.api.bsky.app"; 267 - const url = new URL("xrpc/app.bsky.actor.searchActorsTypeahead", host); 268 - url.searchParams.set("q", query); 269 - url.searchParams.set("limit", `${this.#rows}`); 358 + const handle = li.querySelector(".handle"); 359 + if (handle) handle.textContent = actor.handle; 270 360 271 - const res = await fetch(url); 272 - const json = await res.json(); 273 - this.#actors = json.actors; 274 - this.#index = -1; 275 - this.#render(); 276 - } 361 + fragment.append(li); 362 + } 277 363 278 - /** @param {Event} evt */ 279 - async #onfocusout(evt) { 280 - if (this.#pressed) return; 364 + this.#shadow.querySelector(".menu")?.replaceChildren(...fragment.children); 365 + } 281 366 282 - this.#actors = []; 283 - this.#index = -1; 284 - this.#render(); 285 - } 367 + /** @param {PointerEvent} evt */ 368 + #onpointerdown(evt) { 369 + this.#pressed = true; 370 + } 286 371 287 - #render() { 288 - const fragment = document.createDocumentFragment(); 289 - let i = -1; 290 - for (const actor of this.#actors) { 291 - const li = clone(user).content; 372 + /** @param {PointerEvent & { target: HTMLElement }} evt */ 373 + #onpointerup(evt) { 374 + this.#pressed = false; 292 375 293 - const button = li.querySelector("button"); 294 - if (button) { 295 - button.dataset.handle = actor.handle; 296 - if (++i === this.#index) button.dataset.active = "true"; 297 - } 376 + const button = evt.target?.closest("button"); 377 + if (!button) return; 298 378 299 - const avatar = li.querySelector("img"); 300 - if (avatar && actor.avatar) avatar.src = actor.avatar; 379 + const handle = button.dataset.handle || ""; 301 380 302 - const handle = li.querySelector(".handle"); 303 - if (handle) handle.textContent = actor.handle; 381 + if (this.#isMention) { 382 + const textarea = this.querySelector("textarea"); 383 + if (!textarea || this.#mentionStart < 0) return; 304 384 305 - fragment.append(li); 306 - } 385 + // splice @handle with trailing space into the textarea value, 386 + // replacing from the @ character up to the current cursor position. 387 + const cursorBefore = textarea.selectionStart ?? textarea.value.length; 388 + const before = textarea.value.slice(0, this.#mentionStart); 389 + const after = textarea.value.slice(cursorBefore); 390 + const insertion = `@${handle} `; 391 + textarea.value = before + insertion + after; 307 392 308 - this.#shadow.querySelector(".menu")?.replaceChildren(...fragment.children); 309 - } 393 + const newCursor = this.#mentionStart + insertion.length; 394 + textarea.setSelectionRange(newCursor, newCursor); 310 395 311 - /** @param {PointerEvent} evt */ 312 - #onpointerdown(evt) { 313 - this.#pressed = true; 314 - } 396 + textarea.dispatchEvent(new Event("input", { bubbles: true })); 315 397 316 - /** @param {PointerEvent & { target: HTMLElement }} evt */ 317 - #onpointerup(evt) { 318 - this.#pressed = false; 398 + textarea.focus(); 399 + this.#mentionStart = -1; 400 + } else { 401 + this.querySelector("input")?.focus(); 319 402 320 - this.querySelector("input")?.focus(); 403 + const input = this.querySelector("input"); 404 + if (!input) return; 405 + input.value = handle; 406 + } 321 407 322 - const button = evt.target?.closest("button"); 323 - const input = this.querySelector("input"); 324 - if (!input || !button) return; 408 + this.#actors = []; 409 + this.#render(); 410 + } 325 411 326 - input.value = button.dataset.handle || ""; 327 - this.#actors = []; 328 - this.#render(); 329 - } 412 + get #isMention() { 413 + return this.getAttribute("mode") === "mention"; 414 + } 330 415 }
+1 -1
static/htmx-toaster.min.js
··· 13 13 // OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 14 // 15 15 // Original code available here: https://github.com/terziev1/htmx-toaster 16 - // Modified by Brook Jeynes 16 + // Modifications Copyright (c) 2026 Brook Jeynes 17 17 18 18 const template = document.createElement("template"); 19 19 template.innerHTML = `