feat: add mention notification creation and user search endpoints across multiple routers

This commit is contained in:
2026-05-12 11:08:38 +00:00
parent 388d4e3af8
commit ff2585b098
17 changed files with 273 additions and 195 deletions
+59 -3
View File
@@ -13,6 +13,7 @@ class Application {
this.initContentRenderer();
this.initCommentReply();
this.initEmojiPickers();
this.initMentionInputs();
}
loadCSS(href) {
@@ -145,14 +146,60 @@ class Application {
if (!searchInput) {
return;
}
const wrap = searchInput.parentElement;
const dropdown = document.createElement("div");
dropdown.className = "search-dropdown";
wrap.appendChild(dropdown);
let debounceTimer = null;
searchInput.addEventListener("input", () => {
clearTimeout(debounceTimer);
const q = searchInput.value.trim();
if (q.length < 1) {
dropdown.innerHTML = "";
dropdown.style.display = "none";
return;
}
debounceTimer = setTimeout(async () => {
try {
const resp = await fetch(`/messages/search?q=${encodeURIComponent(q)}`);
const data = await resp.json();
const results = data.results || [];
if (results.length === 0) {
dropdown.style.display = "none";
return;
}
dropdown.innerHTML = "";
for (const r of results) {
const item = document.createElement("a");
item.className = "search-dropdown-item";
item.href = `/messages?with_uid=${r.uid}`;
item.innerHTML = `<img src="/avatar/multiavatar/${encodeURIComponent(r.username)}?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>${r.username}</span>`;
dropdown.appendChild(item);
}
dropdown.style.display = "block";
} catch (e) {
// silently fail — no suggestions
}
}, 200);
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const query = searchInput.value.trim();
if (query) {
window.location.href = `/messages?search=${encodeURIComponent(query)}`;
const first = dropdown.querySelector(".search-dropdown-item");
if (first) {
window.location.href = first.href;
}
}
});
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target)) {
dropdown.style.display = "none";
}
});
}
initNotificationDismiss() {
@@ -223,6 +270,15 @@ class Application {
});
}
initMentionInputs() {
if (typeof MentionInput === "undefined") return;
document.querySelectorAll("[data-mention]").forEach((el) => {
if (el.dataset.mentionInitialized) return;
el.dataset.mentionInitialized = "true";
new MentionInput(el);
});
}
initEmojiPickers() {
document.querySelectorAll(".comment-form textarea, .emoji-picker-target").forEach((textarea) => {
if (textarea.dataset.emojiInitialized) return;
+32 -2
View File
@@ -68,17 +68,21 @@ class ContentRenderer {
const parts = [];
let lastIndex = 0;
const urlRe = /(https?:\/\/[^\s<]+)/g;
const mentionRe = /(?:^|[\s(])@([a-zA-Z0-9_-]+)/g;
let match;
let mentionMatch;
while ((match = urlRe.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: "text", value: text.substring(lastIndex, match.index) });
const chunk = text.substring(lastIndex, match.index);
this.splitMentions(chunk, parts);
}
parts.push({ type: "url", value: match[0], start: match.index, end: match.index + match[0].length });
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push({ type: "text", value: text.substring(lastIndex) });
const chunk = text.substring(lastIndex);
this.splitMentions(chunk, parts);
}
if (parts.length <= 1 && parts[0]?.type === "text") return;
@@ -87,6 +91,12 @@ class ContentRenderer {
for (const part of parts) {
if (part.type === "text") {
fragment.appendChild(document.createTextNode(part.value));
} else if (part.type === "mention") {
const a = document.createElement("a");
a.href = `/profile/${part.username}`;
a.className = "mention-link";
a.textContent = `@${part.username}`;
fragment.appendChild(a);
} else {
const el = this.urlToEmbed(part.value);
if (el) {
@@ -108,6 +118,26 @@ class ContentRenderer {
return temp.innerHTML;
}
splitMentions(text, parts) {
const mentionRe = /(?:^|[\s(])@([a-zA-Z0-9_-]+)/g;
let m;
let last = 0;
while ((m = mentionRe.exec(text)) !== null) {
if (m.index > last) {
parts.push({ type: "text", value: text.substring(last, m.index) });
}
const prefix = m[0][0];
if (prefix && /\S/.test(prefix)) {
parts.push({ type: "text", value: prefix });
}
parts.push({ type: "mention", username: m[1], value: m[0] });
last = m.index + m[0].length;
}
if (last < text.length) {
parts.push({ type: "text", value: text.substring(last) });
}
}
urlToEmbed(url) {
const ytMatch = url.match(this.youtubeRe);
if (ytMatch) {