1 Commits

Author SHA1 Message Date
e27cf391ef Add notification nav, show only replies in short format 2026-04-17 00:29:51 -07:00
10 changed files with 245 additions and 8 deletions

20
deno.lock generated
View File

@@ -11,6 +11,7 @@
"jsr:@std/streams@^1.0.7": "1.0.17",
"jsr:@std/tar@0.1.3": "0.1.3",
"jsr:@zip-js/zip-js@2.7.53": "2.7.53",
"npm:playwright@*": "1.59.1",
"npm:typescript@*": "6.0.2"
},
"jsr": {
@@ -60,6 +61,25 @@
}
},
"npm": {
"fsevents@2.3.2": {
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"os": ["darwin"],
"scripts": true
},
"playwright-core@1.59.1": {
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"bin": true
},
"playwright@1.59.1": {
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dependencies": [
"playwright-core"
],
"optionalDependencies": [
"fsevents"
],
"bin": true
},
"typescript@6.0.2": {
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"bin": true

View File

@@ -292,12 +292,14 @@ function connectWebsocket(request: Request) {
}
const projectRoot = new URL('..', import.meta.url).pathname;
async function devServerWatchFiles() {
const watcher = Deno.watchFs(["../static/", "../src/"]);
for await (const event of watcher) {
if (event.kind === "modify") {
for (const path of event.paths) {
const cachedPath = path.replace(Deno.cwd() + '/..', '')
const cachedPath = path.replace(projectRoot, '/');
filepathResponseCache.delete(cachedPath);
console.log('Purging updated file:', cachedPath)
}

View File

@@ -1,7 +1,7 @@
import { generateID } from "IDUtils";
import { PeerManager, PeerEventTypes } from "PeerManager";
import { Sync } from "Sync";
import { openDatabase, getData, addData, addDataArray, clearData, deleteData, mergeDataArray, getAllData, checkPostIds, getAllIds, getPostsByIds, getPostForUser, getPostById, getRepliesForPost, buildReplyCountMap } from "db";
import { openDatabase, getData, addData, addDataArray, clearData, deleteData, mergeDataArray, getAllData, checkPostIds, getAllIds, getPostsByIds, getPostForUser, getPostById, getRepliesForPost, buildReplyCountMap, getNotificationsForUser } from "db";
import { arrayBufferToBase64, compressString, decompressBuffer, base64ToArrayBuffer } from "dataUtils";
import { log, logID, renderLog, setLogVisibility } from "log"
@@ -934,6 +934,15 @@ export class App {
});
}
hideInfo() {
const infoElement = document.getElementById('info');
if (infoElement && infoElement.style.display !== 'none') {
infoElement.style.display = 'none';
setLogVisibility(false);
this.showLog = false;
}
}
showInfo() {
let infoElement = document.getElementById('info');
@@ -980,6 +989,15 @@ export class App {
let profileButton = this.div('profile-button');
profileButton.addEventListener('click', e => globalThis.location.href = `${globalThis.location.origin}/user/${this.userID}`)
let notificationsButton = this.div('notifications-button');
notificationsButton.addEventListener('click', () => {
this.hideInfo();
navContainer.classList.remove('active');
history.pushState({}, '', '/notifications');
this.getRoute();
this.render();
});
let monitorButton = this.div('monitor_button');
monitorButton.addEventListener('click', async () => {
navContainer.classList.toggle('active');
@@ -1316,7 +1334,7 @@ export class App {
await this.initDB();
window.addEventListener('popstate', () => { this.getRoute(); this.render(); });
window.addEventListener('popstate', () => { this.hideInfo(); this.getRoute(); this.render(); });
this.connectURL = `${document.location.origin}/connect/${this.userID}`;
document.getElementById('connectURL')!.innerHTML = `<a href="${this.connectURL}">connect</a>`;
@@ -1462,6 +1480,11 @@ export class App {
compose.style.display = "none";
break;
}
case App.Route.NOTIFICATIONS: {
this.posts = await getNotificationsForUser(this.userID);
document.getElementById('compose')!.style.display = "none";
break;
}
default: {
console.log.apply(null, log("Render: got a route I didn't understand. Rendering HOME:", this.router.route));
this.posts = await this.loadPostsFromStorage(this.userID) ?? [];
@@ -1472,6 +1495,21 @@ export class App {
if (!contentDiv) {
throw new Error();
}
if (this.router.route === App.Route.NOTIFICATIONS) {
contentDiv.innerHTML = '';
if (this.posts.length === 0) {
contentDiv.innerHTML = '<div style="padding:20px;color:gray">No notifications yet.</div>';
} else {
let first = true;
for (const postRecord of this.posts) {
contentDiv.appendChild(this.renderNotificationItem(postRecord.data, first));
first = false;
}
}
return;
}
if (this.posts.length === 0) {
this.renderWelcome(contentDiv as HTMLDivElement);
return;
@@ -1618,6 +1656,42 @@ export class App {
composeReplyArea.appendChild(previewDiv);
}
renderNotificationItem(post: Post, first: boolean): HTMLElement {
const el = document.createElement('div');
el.style.cssText = 'padding: 8px 10px; cursor: pointer;';
if (!first) el.innerHTML = '<hr>';
const authorDiv = document.createElement('div');
authorDiv.className = 'compose-reply-author';
authorDiv.textContent = `@${post.author} · ${post.post_timestamp.toLocaleDateString()}`;
el.appendChild(authorDiv);
const previewDiv = document.createElement('div');
previewDiv.className = 'compose-reply-preview';
const textDiv = document.createElement('div');
textDiv.className = 'compose-reply-preview-text';
textDiv.style.maxHeight = 'none';
textDiv.innerHTML = this.markedAvailable ? marked.parse(post.text) : post.text;
previewDiv.appendChild(textDiv);
if (post.image_data) {
const img = document.createElement('img');
img.className = 'compose-reply-preview-image';
img.src = URL.createObjectURL(new Blob([post.image_data as ArrayBuffer]));
previewDiv.appendChild(img);
}
el.appendChild(previewDiv);
el.addEventListener('click', () => {
history.pushState({}, '', `/user/${post.author_id}/post/${post.post_id}`);
this.getRoute();
this.render();
});
return el;
}
renderPost(post: Post, first: boolean, replyCount: number = 0) {
if (!(post.hasOwnProperty("text"))) {
throw new Error("Post is malformed!");
@@ -1743,6 +1817,15 @@ export class App {
let path = document.location.pathname;
console.log.apply(null, log("router: path ", path));
if (path === '/notifications') {
this.router.route = App.Route.NOTIFICATIONS;
this.router.userID = '';
this.router.postID = '';
this.router.mediaID = '';
console.log.apply(null, log("router: NOTIFICATIONS"));
return;
}
const regex = "(user/([a-zA-Z0-9\-]+)/?(post/([a-zA-Z0-9\-]+)?/?)?(media/([0-9]+)?)?)|(connect/([a-zA-Z0-9\-]+))";
const match = path.match(new RegExp(regex));
@@ -1793,5 +1876,6 @@ export namespace App {
GROUP = 8,
HOME = 16,
CONNECT = 32,
NOTIFICATIONS = 64,
}
};

View File

@@ -542,6 +542,33 @@ export async function getRepliesForPost(postID: string): Promise<any[]> {
return replies;
}
export async function getNotificationsForUser(userID: string): Promise<any[]> {
const userPostIds = new Set(await getAllIds(userID));
const knownUsers = [...(await indexedDB.databases())]
.map(db => db.name?.replace('user_', '')).filter(Boolean) as string[];
let notifications: any[] = [];
for (const dbUserID of knownUsers) {
try {
const { store } = await getDBTransactionStore(dbUserID);
const index = store.index("postReplyIndex");
const results: any[] = await new Promise((resolve, reject) => {
const req = index.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
for (const r of results) {
if (r.data.reply_to_id && userPostIds.has(r.data.reply_to_id)) {
notifications.push(r);
}
}
} catch (_) {}
}
notifications.sort((a, b) =>
new Date(b.data.post_timestamp).getTime() - new Date(a.data.post_timestamp).getTime()
);
return notifications;
}
export async function getPostsByIds(userID: string, postIDs: string[]) {
const { store } = await getDBTransactionStore(userID);
const index = store.index("postIDIndex");

View File

@@ -1,7 +1,7 @@
import { generateID } from "IDUtils";
import { PeerManager, PeerEventTypes } from "PeerManager";
import { Sync } from "Sync";
import { openDatabase, getData, addData, deleteData, mergeDataArray, getAllData, getPostForUser, getPostById, getRepliesForPost, buildReplyCountMap } from "db";
import { openDatabase, getData, addData, deleteData, mergeDataArray, getAllData, getPostForUser, getPostById, getRepliesForPost, buildReplyCountMap, getNotificationsForUser } from "db";
import { arrayBufferToBase64, compressString, decompressBuffer, base64ToArrayBuffer } from "dataUtils";
import { log, logID, renderLog, setLogVisibility } from "log";
class Post {
@@ -694,6 +694,14 @@ export class App {
correctLevel: QRCode.CorrectLevel.H
});
}
hideInfo() {
const infoElement = document.getElementById('info');
if (infoElement && infoElement.style.display !== 'none') {
infoElement.style.display = 'none';
setLogVisibility(false);
this.showLog = false;
}
}
showInfo() {
let infoElement = document.getElementById('info');
if (infoElement === null) {
@@ -730,6 +738,14 @@ export class App {
homeButton.addEventListener('click', e => globalThis.location.href = `${globalThis.location.origin}/`);
let profileButton = this.div('profile-button');
profileButton.addEventListener('click', e => globalThis.location.href = `${globalThis.location.origin}/user/${this.userID}`);
let notificationsButton = this.div('notifications-button');
notificationsButton.addEventListener('click', () => {
this.hideInfo();
navContainer.classList.remove('active');
history.pushState({}, '', '/notifications');
this.getRoute();
this.render();
});
let monitorButton = this.div('monitor_button');
monitorButton.addEventListener('click', async () => {
navContainer.classList.toggle('active');
@@ -975,7 +991,7 @@ export class App {
this.sync.setArchive(this.isArchivePeer);
this.connect();
await this.initDB();
window.addEventListener('popstate', () => { this.getRoute(); this.render(); });
window.addEventListener('popstate', () => { this.hideInfo(); this.getRoute(); this.render(); });
this.connectURL = `${document.location.origin}/connect/${this.userID}`;
document.getElementById('connectURL').innerHTML = `<a href="${this.connectURL}">connect</a>`;
let time = 0;
@@ -1076,6 +1092,11 @@ export class App {
compose.style.display = "none";
break;
}
case App.Route.NOTIFICATIONS: {
this.posts = await getNotificationsForUser(this.userID);
document.getElementById('compose').style.display = "none";
break;
}
default: {
console.log.apply(null, log("Render: got a route I didn't understand. Rendering HOME:", this.router.route));
this.posts = await this.loadPostsFromStorage(this.userID) ?? [];
@@ -1086,6 +1107,20 @@ export class App {
if (!contentDiv) {
throw new Error();
}
if (this.router.route === App.Route.NOTIFICATIONS) {
contentDiv.innerHTML = '';
if (this.posts.length === 0) {
contentDiv.innerHTML = '<div style="padding:20px;color:gray">No notifications yet.</div>';
}
else {
let first = true;
for (const postRecord of this.posts) {
contentDiv.appendChild(this.renderNotificationItem(postRecord.data, first));
first = false;
}
}
return;
}
if (this.posts.length === 0) {
this.renderWelcome(contentDiv);
return;
@@ -1203,6 +1238,36 @@ export class App {
}
composeReplyArea.appendChild(previewDiv);
}
renderNotificationItem(post, first) {
const el = document.createElement('div');
el.style.cssText = 'padding: 8px 10px; cursor: pointer;';
if (!first)
el.innerHTML = '<hr>';
const authorDiv = document.createElement('div');
authorDiv.className = 'compose-reply-author';
authorDiv.textContent = `@${post.author} · ${post.post_timestamp.toLocaleDateString()}`;
el.appendChild(authorDiv);
const previewDiv = document.createElement('div');
previewDiv.className = 'compose-reply-preview';
const textDiv = document.createElement('div');
textDiv.className = 'compose-reply-preview-text';
textDiv.style.maxHeight = 'none';
textDiv.innerHTML = this.markedAvailable ? marked.parse(post.text) : post.text;
previewDiv.appendChild(textDiv);
if (post.image_data) {
const img = document.createElement('img');
img.className = 'compose-reply-preview-image';
img.src = URL.createObjectURL(new Blob([post.image_data]));
previewDiv.appendChild(img);
}
el.appendChild(previewDiv);
el.addEventListener('click', () => {
history.pushState({}, '', `/user/${post.author_id}/post/${post.post_id}`);
this.getRoute();
this.render();
});
return el;
}
renderPost(post, first, replyCount = 0) {
if (!(post.hasOwnProperty("text"))) {
throw new Error("Post is malformed!");
@@ -1296,6 +1361,14 @@ export class App {
getRoute() {
let path = document.location.pathname;
console.log.apply(null, log("router: path ", path));
if (path === '/notifications') {
this.router.route = App.Route.NOTIFICATIONS;
this.router.userID = '';
this.router.postID = '';
this.router.mediaID = '';
console.log.apply(null, log("router: NOTIFICATIONS"));
return;
}
const regex = "(user/([a-zA-Z0-9\-]+)/?(post/([a-zA-Z0-9\-]+)?/?)?(media/([0-9]+)?)?)|(connect/([a-zA-Z0-9\-]+))";
const match = path.match(new RegExp(regex));
if (match) {
@@ -1342,6 +1415,7 @@ export class App {
Route[Route["GROUP"] = 8] = "GROUP";
Route[Route["HOME"] = 16] = "HOME";
Route[Route["CONNECT"] = 32] = "CONNECT";
Route[Route["NOTIFICATIONS"] = 64] = "NOTIFICATIONS";
})(Route = App.Route || (App.Route = {}));
})(App || (App = {}));
;

File diff suppressed because one or more lines are too long

View File

@@ -433,6 +433,31 @@ export async function getRepliesForPost(postID) {
}
return replies;
}
export async function getNotificationsForUser(userID) {
const userPostIds = new Set(await getAllIds(userID));
const knownUsers = [...(await indexedDB.databases())]
.map(db => db.name?.replace('user_', '')).filter(Boolean);
let notifications = [];
for (const dbUserID of knownUsers) {
try {
const { store } = await getDBTransactionStore(dbUserID);
const index = store.index("postReplyIndex");
const results = await new Promise((resolve, reject) => {
const req = index.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
for (const r of results) {
if (r.data.reply_to_id && userPostIds.has(r.data.reply_to_id)) {
notifications.push(r);
}
}
}
catch (_) { }
}
notifications.sort((a, b) => new Date(b.data.post_timestamp).getTime() - new Date(a.data.post_timestamp).getTime());
return notifications;
}
export async function getPostsByIds(userID, postIDs) {
const { store } = await getDBTransactionStore(userID);
const index = store.index("postIDIndex");

File diff suppressed because one or more lines are too long

View File

@@ -58,6 +58,10 @@
<span class="nav-emoji emoji-fill" role="img" aria-label="Home">🏠</span>
<span class="nav-label">Home</span>
</a>
<a class="nav-item" id="notifications-button">
<span class="nav-emoji emoji-fill" role="img" aria-label="Notifications">🔔</span>
<span class="nav-label">Notifications</span>
</a>
<!-- <a class="nav-item">
<span class="nav-emoji" role="img" aria-label="Search">🔍</span>
<span class="nav-label">Search</span>

View File

@@ -392,6 +392,7 @@ iframe {
max-width: 96px;
max-height: 96px;
object-fit: cover;
object-position: top;
border-radius: 4px;
flex-shrink: 0;
}
@@ -418,7 +419,7 @@ iframe {
/* border-right: 1px solid rgb(60, 60, 60); */
/* transition: width 0.3s; */
overflow: hidden;
width: 130px;
width: 185px;
margin-right: 5px;
}