Skip to content

Commit 4c17510

Browse files
Improve mentions and remove storage config
1 parent a20488c commit 4c17510

6 files changed

Lines changed: 444 additions & 258 deletions

File tree

home/index.html

Lines changed: 151 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,20 @@
3737
.stats b { font-size: 16px; display: block; color: #0f1419; }
3838
.timeline { min-width: 0; }
3939
.composer { padding: 14px; }
40+
.composer { position: relative; }
4041
.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; }
4142
.composer textarea:focus { border-color: var(--twitter-blue); }
4243
.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; }
5044
.count { font-size: 13px; color: var(--muted); }
5145
.count.warn { color: #d93025; font-weight: 700; }
5246
.tweet-btn, .delete-btn { border: none; border-radius: 999px; font-weight: 700; cursor: pointer; }
5347
.tweet-btn { background: var(--twitter-blue); color: #fff; padding: 10px 18px; min-width: 110px; }
5448
.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); }
5554
.feed { margin-top: 14px; display: flex; flex-direction: column; gap: 12px; }
5655
.tweet { padding: 14px; display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 10px; }
5756
.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,10 +68,9 @@
6968
.tweet-text pre { background: #0f1419; color: #e6f6ff; border-radius: 10px; padding: 10px; overflow: auto; margin: 8px 0; }
7069
.tweet-text pre code { background: transparent; padding: 0; color: inherit; }
7170
.tweet-text .mention { color: #1d9bf0; font-weight: 700; }
71+
.tweet-text .mention-link { color: #1d9bf0; font-weight: 700; text-decoration: none; }
7272
.tweet-text a { color: #1d9bf0; text-decoration: none; }
7373
.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; }
7674
.tweet-footer { margin-top: 10px; display: flex; align-items: center; justify-content: space-between; }
7775
.delete-btn { padding: 8px 12px; background: #ffe8e8; color: #b00020; }
7876
.badge { width: 18px; height: 18px; border-radius: 50%; display: inline-grid; place-items: center; color: #fff; font-size: 11px; font-weight: 800; }
@@ -117,12 +115,8 @@
117115
<section class="timeline">
118116
<section class="card composer">
119117
<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>
126120
<div class="composer-actions">
127121
<span class="count" id="char-count">280 characters</span>
128122
<button class="tweet-btn" id="t-send" type="button" disabled>Tweet</button>
@@ -136,8 +130,7 @@
136130
<script type="module">
137131
import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js";
138132
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";
141134

142135
const firebaseConfig = {
143136
apiKey: "AIzaSyCz9fehRDGTBC7EQZehiVMNw7jUSSvzku4",
@@ -152,29 +145,110 @@
152145
const app = initializeApp(firebaseConfig);
153146
const auth = getAuth(app);
154147
const db = getFirestore(app);
155-
const storage = getStorage(app);
156148

157149
const inputEl = document.getElementById("t-input");
158150
const sendEl = document.getElementById("t-send");
159151
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");
163153
const feedEl = document.getElementById("feed");
164154
const statusEl = document.getElementById("status");
165155

166156
let currentUser = null;
167157
let profile = null;
168-
let selectedMedia = null;
169158
const profileCache = new Map();
159+
const mentionCache = new Map();
170160
const likedPostIds = new Set();
161+
let mentionTimer = null;
171162

172163
const verifyColors = { person: "#1d9bf0", org: "#ffd700", gov: "#8b98a5" };
173164

174165
function esc(value) {
175166
return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\"/g, "&quot;").replace(/'/g, "&#39;");
176167
}
177168

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+
178252
function extractMentions(text) {
179253
const found = new Set();
180254
const regex = /(^|\s)@([a-zA-Z0-9_]{3,30})\b/g;
@@ -197,32 +271,6 @@
197271
return html.replace(/\n/g, "<br>");
198272
}
199273

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-
226274
function badgeHtml(verifiedType, verifiedBy) {
227275
if (!verifiedType) return "";
228276
const parts = String(verifiedType).split(";").map((p) => p.trim()).filter(Boolean);
@@ -282,6 +330,27 @@
282330
}));
283331
}
284332

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+
285354
async function loadLikedPostIds() {
286355
const likesQ = query(collection(db, "post_likes"), where("uid", "==", currentUser.uid));
287356
const likesSnap = await getDocs(likesQ);
@@ -323,38 +392,22 @@
323392
async function publishTweet() {
324393
if (!currentUser || !profile) return;
325394
const text = inputEl.value.trim();
326-
if (!text && !selectedMedia) return;
395+
if (!text) return;
327396

328397
sendEl.disabled = true;
329398
statusEl.textContent = "Posting...";
330399
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-
341400
await addDoc(collection(db, "messages"), {
342401
authorUid: currentUser.uid,
343402
authorName: profile.displayName,
344403
authorUsername: profile.username,
345404
authorInitials: profile.avatarInitials,
346405
content: text,
347406
mentions: extractMentions(text),
348-
mediaUrl,
349-
mediaType,
350407
likes: 0,
351408
createdAt: serverTimestamp()
352409
});
353410
inputEl.value = "";
354-
if (selectedMedia) URL.revokeObjectURL(selectedMedia.previewUrl);
355-
selectedMedia = null;
356-
mediaInputEl.value = "";
357-
renderComposerPreview();
358411
updateComposerState();
359412
statusEl.textContent = "";
360413
} catch (error) {
@@ -397,6 +450,7 @@
397450

398451
feedEl.innerHTML = items.map((item) => {
399452
const author = profileCache.get(item.authorUid) || {};
453+
const mentionLookup = mentionCache;
400454
const badge = author.verified && author.verifiedType ? badgeHtml(author.verifiedType, author.verifiedBy || null) : "";
401455
const initials = author.avatarInitials || item.authorInitials || "?";
402456
const avatarContent = author.avatarUrl ? `<img src="${encodeURI(String(author.avatarUrl))}" alt="">` : esc(initials);
@@ -414,8 +468,7 @@
414468
<span class="tweet-handle">@${esc(author.username || item.authorUsername || "node")}</span>
415469
<span class="tweet-time">· ${esc(formatTime(item.createdAt))}</span>
416470
</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>
419472
<div class="tweet-footer">
420473
<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>
421474
${canDelete ? `<button class="delete-btn" onclick="deleteTweet('${esc(item.id)}')">Delete</button>` : ""}
@@ -439,22 +492,16 @@
439492
}
440493

441494
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);
457502
});
503+
inputEl.addEventListener("click", updateMentionSuggestions);
504+
inputEl.addEventListener("blur", () => setTimeout(hideMentionSuggestions, 150));
458505
sendEl.addEventListener("click", publishTweet);
459506

460507
document.getElementById("logout").addEventListener("click", async () => {
@@ -486,9 +533,31 @@
486533
onSnapshot(feedQuery, async (snap) => {
487534
const items = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
488535
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);
489538
renderFeed(items);
490539
}, (error) => { statusEl.textContent = `Feed error: ${error.message}`; });
491540
});
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+
}
492561
</script>
493562
</body>
494563
</html>

nexnet/firebase.json

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
"**/.*",
77
"**/node_modules/**",
88
"firestore.rules",
9-
"firestore.indexes.json",
10-
"storage.rules"
9+
"firestore.indexes.json"
1110
],
1211
"cleanUrls": true,
1312
"trailingSlash": false,
@@ -21,8 +20,5 @@
2120
"firestore": {
2221
"rules": "firestore.rules",
2322
"indexes": "firestore.indexes.json"
24-
},
25-
"storage": {
26-
"rules": "storage.rules"
2723
}
2824
}

0 commit comments

Comments
 (0)