|
37 | 37 | .stats b { font-size: 16px; display: block; color: #0f1419; } |
38 | 38 | .timeline { min-width: 0; } |
39 | 39 | .composer { padding: 14px; } |
| 40 | + .composer { position: relative; } |
40 | 41 | .composer textarea { width: 100%; border: 1px solid var(--border); border-radius: 12px; min-height: 120px; resize: vertical; padding: 12px; font-size: 16px; font-family: inherit; outline: none; transition: border-color 0.2s ease; } |
41 | 42 | .composer textarea:focus { border-color: var(--twitter-blue); } |
42 | 43 | .composer-actions { margin-top: 10px; display: flex; align-items: center; justify-content: space-between; } |
43 | | - .composer-tools { margin-top: 10px; display: flex; align-items: center; gap: 10px; } |
44 | | - .tool-btn { border: 1px solid var(--border); border-radius: 999px; padding: 7px 12px; background: #eef3f8; color: #284661; font-weight: 700; cursor: pointer; } |
45 | | - .tool-btn:hover { background: #dbecfa; color: #1d9bf0; } |
46 | | - .composer-preview { margin-top: 10px; border: 1px solid var(--border); border-radius: 12px; padding: 10px; background: #f8fbfd; } |
47 | | - .composer-preview img, .composer-preview video { width: 100%; max-height: 280px; object-fit: cover; border-radius: 10px; } |
48 | | - .preview-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } |
49 | | - .remove-media { border: none; border-radius: 999px; padding: 6px 10px; background: #ffe8e8; color: #b00020; font-weight: 700; cursor: pointer; } |
50 | 44 | .count { font-size: 13px; color: var(--muted); } |
51 | 45 | .count.warn { color: #d93025; font-weight: 700; } |
52 | 46 | .tweet-btn, .delete-btn { border: none; border-radius: 999px; font-weight: 700; cursor: pointer; } |
53 | 47 | .tweet-btn { background: var(--twitter-blue); color: #fff; padding: 10px 18px; min-width: 110px; } |
54 | 48 | .tweet-btn:disabled { opacity: 0.6; cursor: not-allowed; } |
| 49 | + .mention-suggestions { position: absolute; left: 14px; right: 14px; top: 132px; z-index: 20; background: #fff; border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 18px 40px rgba(15, 20, 25, 0.16); overflow: hidden; } |
| 50 | + .mention-suggestion { width: 100%; display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: none; background: #fff; cursor: pointer; text-align: left; } |
| 51 | + .mention-suggestion:hover { background: #f3f8fc; } |
| 52 | + .mention-suggestion .name { font-weight: 800; color: #0f1419; } |
| 53 | + .mention-suggestion .handle { font-size: 12px; color: var(--muted); } |
55 | 54 | .feed { margin-top: 14px; display: flex; flex-direction: column; gap: 12px; } |
56 | 55 | .tweet { padding: 14px; display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 10px; } |
57 | 56 | .tweet-avatar { width: 48px; height: 48px; border-radius: 50%; background: #d9e2e8; display: grid; place-items: center; font-weight: 700; color: #40556a; text-decoration: none; overflow: hidden; } |
|
69 | 68 | .tweet-text pre { background: #0f1419; color: #e6f6ff; border-radius: 10px; padding: 10px; overflow: auto; margin: 8px 0; } |
70 | 69 | .tweet-text pre code { background: transparent; padding: 0; color: inherit; } |
71 | 70 | .tweet-text .mention { color: #1d9bf0; font-weight: 700; } |
| 71 | + .tweet-text .mention-link { color: #1d9bf0; font-weight: 700; text-decoration: none; } |
72 | 72 | .tweet-text a { color: #1d9bf0; text-decoration: none; } |
73 | 73 | .tweet-text a:hover { text-decoration: underline; } |
74 | | - .tweet-media { margin-top: 10px; border-radius: 12px; border: 1px solid var(--border); overflow: hidden; background: #0f1419; } |
75 | | - .tweet-media img, .tweet-media video { width: 100%; max-height: 380px; object-fit: cover; display: block; } |
76 | 74 | .tweet-footer { margin-top: 10px; display: flex; align-items: center; justify-content: space-between; } |
77 | 75 | .delete-btn { padding: 8px 12px; background: #ffe8e8; color: #b00020; } |
78 | 76 | .badge { width: 18px; height: 18px; border-radius: 50%; display: inline-grid; place-items: center; color: #fff; font-size: 11px; font-weight: 800; } |
|
117 | 115 | <section class="timeline"> |
118 | 116 | <section class="card composer"> |
119 | 117 | <textarea id="t-input" maxlength="280" placeholder="What's happening?"></textarea> |
120 | | - <div class="composer-tools"> |
121 | | - <button class="tool-btn" id="media-pick" type="button">Add image/video</button> |
122 | | - <input id="media-input" type="file" accept="image/*,video/*" hidden> |
123 | | - <span class="meta">Markdown + @mentions supported</span> |
124 | | - </div> |
125 | | - <div class="composer-preview" id="composer-preview" hidden></div> |
| 118 | + <div id="mention-suggestions" class="mention-suggestions" hidden></div> |
| 119 | + <div class="meta" style="margin-top:10px;">Markdown + @mentions supported</div> |
126 | 120 | <div class="composer-actions"> |
127 | 121 | <span class="count" id="char-count">280 characters</span> |
128 | 122 | <button class="tweet-btn" id="t-send" type="button" disabled>Tweet</button> |
|
136 | 130 | <script type="module"> |
137 | 131 | import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js"; |
138 | 132 | import { getAuth, onAuthStateChanged, signOut } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js"; |
139 | | - import { getFirestore, collection, addDoc, query, orderBy, onSnapshot, serverTimestamp, doc, getDoc, setDoc, deleteDoc, where, getDocs, limit, writeBatch, increment } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js"; |
140 | | - import { getStorage, ref, uploadBytes, getDownloadURL } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-storage.js"; |
| 133 | + import { getFirestore, collection, addDoc, query, orderBy, onSnapshot, serverTimestamp, doc, getDoc, setDoc, deleteDoc, where, getDocs, limit, writeBatch, increment, startAt, endAt } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js"; |
141 | 134 |
|
142 | 135 | const firebaseConfig = { |
143 | 136 | apiKey: "AIzaSyCz9fehRDGTBC7EQZehiVMNw7jUSSvzku4", |
|
152 | 145 | const app = initializeApp(firebaseConfig); |
153 | 146 | const auth = getAuth(app); |
154 | 147 | const db = getFirestore(app); |
155 | | - const storage = getStorage(app); |
156 | 148 |
|
157 | 149 | const inputEl = document.getElementById("t-input"); |
158 | 150 | const sendEl = document.getElementById("t-send"); |
159 | 151 | const countEl = document.getElementById("char-count"); |
160 | | - const mediaPickEl = document.getElementById("media-pick"); |
161 | | - const mediaInputEl = document.getElementById("media-input"); |
162 | | - const composerPreviewEl = document.getElementById("composer-preview"); |
| 152 | + const mentionSuggestionsEl = document.getElementById("mention-suggestions"); |
163 | 153 | const feedEl = document.getElementById("feed"); |
164 | 154 | const statusEl = document.getElementById("status"); |
165 | 155 |
|
166 | 156 | let currentUser = null; |
167 | 157 | let profile = null; |
168 | | - let selectedMedia = null; |
169 | 158 | const profileCache = new Map(); |
| 159 | + const mentionCache = new Map(); |
170 | 160 | const likedPostIds = new Set(); |
| 161 | + let mentionTimer = null; |
171 | 162 |
|
172 | 163 | const verifyColors = { person: "#1d9bf0", org: "#ffd700", gov: "#8b98a5" }; |
173 | 164 |
|
174 | 165 | function esc(value) { |
175 | 166 | return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\"/g, """).replace(/'/g, "'"); |
176 | 167 | } |
177 | 168 |
|
| 169 | + function getMentionToken(value, cursorPos) { |
| 170 | + const before = value.slice(0, cursorPos); |
| 171 | + const match = before.match(/(?:^|\s)@([a-zA-Z0-9_]*)$/); |
| 172 | + return match ? match[1] : ""; |
| 173 | + } |
| 174 | + |
| 175 | + function hideMentionSuggestions() { |
| 176 | + mentionSuggestionsEl.hidden = true; |
| 177 | + mentionSuggestionsEl.innerHTML = ""; |
| 178 | + } |
| 179 | + |
| 180 | + function insertMention(username) { |
| 181 | + const start = inputEl.selectionStart; |
| 182 | + const end = inputEl.selectionEnd; |
| 183 | + const before = inputEl.value.slice(0, start); |
| 184 | + const after = inputEl.value.slice(end); |
| 185 | + const tokenStart = before.lastIndexOf("@"); |
| 186 | + if (tokenStart < 0) return; |
| 187 | + inputEl.value = `${before.slice(0, tokenStart)}@${username} ${after}`; |
| 188 | + inputEl.focus(); |
| 189 | + const caret = tokenStart + username.length + 2; |
| 190 | + inputEl.setSelectionRange(caret, caret); |
| 191 | + updateComposerState(); |
| 192 | + hideMentionSuggestions(); |
| 193 | + } |
| 194 | + |
| 195 | + async function fetchMentionSuggestions(prefix) { |
| 196 | + const clean = prefix.trim().toLowerCase(); |
| 197 | + if (!clean) return []; |
| 198 | + const usersQ = query( |
| 199 | + collection(db, "users"), |
| 200 | + orderBy("username"), |
| 201 | + startAt(clean), |
| 202 | + endAt(`${clean}\uf8ff`), |
| 203 | + limit(5) |
| 204 | + ); |
| 205 | + const snap = await getDocs(usersQ); |
| 206 | + return snap.docs.map((d) => ({ uid: d.id, ...d.data() })); |
| 207 | + } |
| 208 | + |
| 209 | + function renderMentionSuggestions(users) { |
| 210 | + if (!users.length) { |
| 211 | + hideMentionSuggestions(); |
| 212 | + return; |
| 213 | + } |
| 214 | + |
| 215 | + mentionSuggestionsEl.innerHTML = users.map((user) => { |
| 216 | + const name = user.displayName || user.username || "User"; |
| 217 | + const handle = `@${user.username || "node"}`; |
| 218 | + return ` |
| 219 | + <button type="button" class="mention-suggestion" data-username="${esc(user.username || "")}"> |
| 220 | + <div> |
| 221 | + <div class="name">${esc(name)}</div> |
| 222 | + <div class="handle">${esc(handle)}</div> |
| 223 | + </div> |
| 224 | + </button> |
| 225 | + `; |
| 226 | + }).join(""); |
| 227 | + mentionSuggestionsEl.hidden = false; |
| 228 | + |
| 229 | + mentionSuggestionsEl.querySelectorAll("[data-username]").forEach((btn) => { |
| 230 | + btn.addEventListener("mousedown", (event) => { |
| 231 | + event.preventDefault(); |
| 232 | + insertMention(btn.getAttribute("data-username")); |
| 233 | + }); |
| 234 | + }); |
| 235 | + } |
| 236 | + |
| 237 | + async function updateMentionSuggestions() { |
| 238 | + const token = getMentionToken(inputEl.value, inputEl.selectionStart || 0); |
| 239 | + if (!token) { |
| 240 | + hideMentionSuggestions(); |
| 241 | + return; |
| 242 | + } |
| 243 | + |
| 244 | + try { |
| 245 | + const users = await fetchMentionSuggestions(token); |
| 246 | + renderMentionSuggestions(users.filter((user) => (user.username || "").toLowerCase().startsWith(token.toLowerCase()))); |
| 247 | + } catch (_) { |
| 248 | + hideMentionSuggestions(); |
| 249 | + } |
| 250 | + } |
| 251 | + |
178 | 252 | function extractMentions(text) { |
179 | 253 | const found = new Set(); |
180 | 254 | const regex = /(^|\s)@([a-zA-Z0-9_]{3,30})\b/g; |
|
197 | 271 | return html.replace(/\n/g, "<br>"); |
198 | 272 | } |
199 | 273 |
|
200 | | - function renderComposerPreview() { |
201 | | - if (!selectedMedia) { |
202 | | - composerPreviewEl.hidden = true; |
203 | | - composerPreviewEl.innerHTML = ""; |
204 | | - return; |
205 | | - } |
206 | | - const isImage = selectedMedia.file.type.startsWith("image/"); |
207 | | - const mediaTag = isImage |
208 | | - ? `<img src="${esc(selectedMedia.previewUrl)}" alt="preview">` |
209 | | - : `<video src="${esc(selectedMedia.previewUrl)}" controls></video>`; |
210 | | - composerPreviewEl.innerHTML = ` |
211 | | - <div class="preview-row"> |
212 | | - <strong>${esc(selectedMedia.file.name)}</strong> |
213 | | - <button class="remove-media" id="remove-media" type="button">Remove</button> |
214 | | - </div> |
215 | | - ${mediaTag} |
216 | | - `; |
217 | | - composerPreviewEl.hidden = false; |
218 | | - document.getElementById("remove-media").addEventListener("click", () => { |
219 | | - URL.revokeObjectURL(selectedMedia.previewUrl); |
220 | | - selectedMedia = null; |
221 | | - mediaInputEl.value = ""; |
222 | | - renderComposerPreview(); |
223 | | - }); |
224 | | - } |
225 | | - |
226 | 274 | function badgeHtml(verifiedType, verifiedBy) { |
227 | 275 | if (!verifiedType) return ""; |
228 | 276 | const parts = String(verifiedType).split(";").map((p) => p.trim()).filter(Boolean); |
|
282 | 330 | })); |
283 | 331 | } |
284 | 332 |
|
| 333 | + async function fetchMentionProfiles(usernames) { |
| 334 | + const missing = usernames |
| 335 | + .map((name) => name.toLowerCase()) |
| 336 | + .filter((name) => name && !mentionCache.has(name)); |
| 337 | + if (!missing.length) return; |
| 338 | + |
| 339 | + const batches = []; |
| 340 | + for (let i = 0; i < missing.length; i += 10) { |
| 341 | + batches.push(missing.slice(i, i + 10)); |
| 342 | + } |
| 343 | + |
| 344 | + await Promise.all(batches.map(async (batch) => { |
| 345 | + const q = query(collection(db, "users"), where("username", "in", batch)); |
| 346 | + const snap = await getDocs(q); |
| 347 | + snap.forEach((d) => { |
| 348 | + const data = d.data(); |
| 349 | + if (data.username) mentionCache.set(String(data.username).toLowerCase(), { uid: d.id, ...data }); |
| 350 | + }); |
| 351 | + })); |
| 352 | + } |
| 353 | + |
285 | 354 | async function loadLikedPostIds() { |
286 | 355 | const likesQ = query(collection(db, "post_likes"), where("uid", "==", currentUser.uid)); |
287 | 356 | const likesSnap = await getDocs(likesQ); |
|
323 | 392 | async function publishTweet() { |
324 | 393 | if (!currentUser || !profile) return; |
325 | 394 | const text = inputEl.value.trim(); |
326 | | - if (!text && !selectedMedia) return; |
| 395 | + if (!text) return; |
327 | 396 |
|
328 | 397 | sendEl.disabled = true; |
329 | 398 | statusEl.textContent = "Posting..."; |
330 | 399 | try { |
331 | | - let mediaUrl = ""; |
332 | | - let mediaType = ""; |
333 | | - if (selectedMedia) { |
334 | | - const safeName = selectedMedia.file.name.replace(/[^a-zA-Z0-9._-]/g, "_"); |
335 | | - const mediaRef = ref(storage, `messages/${currentUser.uid}/${Date.now()}_${safeName}`); |
336 | | - await uploadBytes(mediaRef, selectedMedia.file); |
337 | | - mediaUrl = await getDownloadURL(mediaRef); |
338 | | - mediaType = selectedMedia.file.type || "application/octet-stream"; |
339 | | - } |
340 | | - |
341 | 400 | await addDoc(collection(db, "messages"), { |
342 | 401 | authorUid: currentUser.uid, |
343 | 402 | authorName: profile.displayName, |
344 | 403 | authorUsername: profile.username, |
345 | 404 | authorInitials: profile.avatarInitials, |
346 | 405 | content: text, |
347 | 406 | mentions: extractMentions(text), |
348 | | - mediaUrl, |
349 | | - mediaType, |
350 | 407 | likes: 0, |
351 | 408 | createdAt: serverTimestamp() |
352 | 409 | }); |
353 | 410 | inputEl.value = ""; |
354 | | - if (selectedMedia) URL.revokeObjectURL(selectedMedia.previewUrl); |
355 | | - selectedMedia = null; |
356 | | - mediaInputEl.value = ""; |
357 | | - renderComposerPreview(); |
358 | 411 | updateComposerState(); |
359 | 412 | statusEl.textContent = ""; |
360 | 413 | } catch (error) { |
|
397 | 450 |
|
398 | 451 | feedEl.innerHTML = items.map((item) => { |
399 | 452 | const author = profileCache.get(item.authorUid) || {}; |
| 453 | + const mentionLookup = mentionCache; |
400 | 454 | const badge = author.verified && author.verifiedType ? badgeHtml(author.verifiedType, author.verifiedBy || null) : ""; |
401 | 455 | const initials = author.avatarInitials || item.authorInitials || "?"; |
402 | 456 | const avatarContent = author.avatarUrl ? `<img src="${encodeURI(String(author.avatarUrl))}" alt="">` : esc(initials); |
|
414 | 468 | <span class="tweet-handle">@${esc(author.username || item.authorUsername || "node")}</span> |
415 | 469 | <span class="tweet-time">· ${esc(formatTime(item.createdAt))}</span> |
416 | 470 | </div> |
417 | | - <div class="tweet-text">${renderMarkdown(item.content || "")}</div> |
418 | | - ${(item.mediaUrl && item.mediaType) ? `<div class="tweet-media">${item.mediaType.startsWith("image/") ? `<img src="${esc(item.mediaUrl)}" alt="Attachment">` : `<video controls src="${esc(item.mediaUrl)}"></video>`}</div>` : ""} |
| 471 | + <div class="tweet-text">${renderMarkdown(item.content || "", mentionLookup)}</div> |
419 | 472 | <div class="tweet-footer"> |
420 | 473 | <button class="delete-btn" style="background:${liked ? '#dbf0ff' : '#eef3f8'};color:${liked ? '#1d9bf0' : '#32506a'}" onclick="void(0)" data-like-id="${esc(item.id)}">Like ${Number(item.likes || 0)}</button> |
421 | 474 | ${canDelete ? `<button class="delete-btn" onclick="deleteTweet('${esc(item.id)}')">Delete</button>` : ""} |
|
439 | 492 | } |
440 | 493 |
|
441 | 494 | inputEl.addEventListener("input", updateComposerState); |
442 | | - mediaPickEl.addEventListener("click", () => mediaInputEl.click()); |
443 | | - mediaInputEl.addEventListener("change", () => { |
444 | | - const file = mediaInputEl.files?.[0]; |
445 | | - if (!file) return; |
446 | | - const maxSize = file.type.startsWith("video/") ? 20 * 1024 * 1024 : 8 * 1024 * 1024; |
447 | | - if (file.size > maxSize) { |
448 | | - statusEl.textContent = file.type.startsWith("video/") |
449 | | - ? "Video too large (max 20 MB)." |
450 | | - : "Image too large (max 8 MB)."; |
451 | | - mediaInputEl.value = ""; |
452 | | - return; |
453 | | - } |
454 | | - if (selectedMedia) URL.revokeObjectURL(selectedMedia.previewUrl); |
455 | | - selectedMedia = { file, previewUrl: URL.createObjectURL(file) }; |
456 | | - renderComposerPreview(); |
| 495 | + inputEl.addEventListener("input", () => { |
| 496 | + clearTimeout(mentionTimer); |
| 497 | + mentionTimer = setTimeout(updateMentionSuggestions, 180); |
| 498 | + }); |
| 499 | + inputEl.addEventListener("keyup", () => { |
| 500 | + clearTimeout(mentionTimer); |
| 501 | + mentionTimer = setTimeout(updateMentionSuggestions, 180); |
457 | 502 | }); |
| 503 | + inputEl.addEventListener("click", updateMentionSuggestions); |
| 504 | + inputEl.addEventListener("blur", () => setTimeout(hideMentionSuggestions, 150)); |
458 | 505 | sendEl.addEventListener("click", publishTweet); |
459 | 506 |
|
460 | 507 | document.getElementById("logout").addEventListener("click", async () => { |
|
486 | 533 | onSnapshot(feedQuery, async (snap) => { |
487 | 534 | const items = snap.docs.map((d) => ({ id: d.id, ...d.data() })); |
488 | 535 | await fetchProfiles([...new Set(items.map((item) => item.authorUid).filter(Boolean))]); |
| 536 | + const mentionUsernames = [...new Set(items.flatMap((item) => item.mentions && item.mentions.length ? item.mentions : extractMentions(item.content || "")))]; |
| 537 | + await fetchMentionProfiles(mentionUsernames); |
489 | 538 | renderFeed(items); |
490 | 539 | }, (error) => { statusEl.textContent = `Feed error: ${error.message}`; }); |
491 | 540 | }); |
| 541 | + |
| 542 | + function renderMarkdown(text, mentionLookup = mentionCache) { |
| 543 | + let html = esc(text || ""); |
| 544 | + html = html.replace(/```([\s\S]*?)```/g, (_, code) => `<pre><code>${code}</code></pre>`); |
| 545 | + html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>'); |
| 546 | + html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>'); |
| 547 | + html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>'); |
| 548 | + html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); |
| 549 | + html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); |
| 550 | + html = html.replace(/`([^`]+)`/g, '<code>$1</code>'); |
| 551 | + html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>'); |
| 552 | + html = html.replace(/(^|[^\w])@([a-zA-Z0-9_]{3,30})\b/g, (full, prefix, username) => { |
| 553 | + const user = mentionLookup.get(username.toLowerCase()); |
| 554 | + if (user?.uid) { |
| 555 | + return `${prefix}<a class="mention-link" href="/profile/?uid=${encodeURIComponent(user.uid)}">@${user.username}</a>`; |
| 556 | + } |
| 557 | + return `${prefix}<span class="mention">@${username}</span>`; |
| 558 | + }); |
| 559 | + return html.replace(/\n/g, '<br>'); |
| 560 | + } |
492 | 561 | </script> |
493 | 562 | </body> |
494 | 563 | </html> |
0 commit comments