Files
client/www/block.html
T
2026-04-17 16:52:51 -04:00

760 lines
23 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>BLOCK</title>
<link rel="stylesheet" href="./css/client.css" />
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) document.body.classList.add('dark-mode');
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<style>
#divBody {
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
align-content: flex-start !important;
gap: 10px !important;
overflow-y: auto !important;
}
#divBlockControls {
display: grid;
grid-template-columns: 150px 1fr auto auto;
gap: 10px;
align-items: center;
width: 100%;
border: var(--border);
border-radius: var(--border-radius);
padding: 10px;
background: var(--secondary-color);
}
.blockInput,
.blockSelect {
border: var(--border);
border-radius: var(--border-radius);
padding: 8px 10px;
font-size: 90%;
background: var(--secondary-color);
color: var(--primary-color);
min-width: 0;
}
.blockButton {
border: var(--button-border-width) solid var(--button-border-color);
border-radius: var(--button-border-radius);
background: var(--button-background-color);
color: var(--button-color);
padding: 8px 10px;
font-size: 85%;
cursor: pointer;
transition: 0.2s;
white-space: nowrap;
}
.blockButton:hover {
color: var(--button-hover-color);
border-color: var(--button-hover-color);
}
#lblPrivate {
display: flex;
align-items: center;
gap: 6px;
font-size: 80%;
color: var(--muted-color);
user-select: none;
white-space: nowrap;
}
#divBlockStatus {
width: 100%;
font-size: 80%;
color: var(--muted-color);
border: var(--border);
border-radius: var(--border-radius);
padding: 8px 10px;
min-height: 34px;
display: flex;
align-items: center;
background: var(--secondary-color);
}
#divBlockGroups {
width: 100%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 10px;
}
.blockGroup {
border: var(--border);
border-radius: var(--border-radius);
background: var(--secondary-color);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 120px;
}
.blockGroupTitle {
font-size: 85%;
font-weight: bold;
color: var(--primary-color);
border-bottom: var(--border);
padding-bottom: 6px;
}
.blockItem {
border: var(--border);
border-radius: var(--border-radius);
padding: 8px;
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
background: var(--secondary-color);
}
.blockItemMain {
min-width: 0;
display: flex;
gap: 8px;
align-items: center;
flex: 1;
}
.blockAvatar {
width: 34px;
height: 34px;
border-radius: var(--border-radius);
overflow: hidden;
flex: 0 0 34px;
border: var(--border);
background: transparent;
}
.blockAvatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
filter: grayscale(var(--image-grayscale));
transition: filter 0.2s;
}
.blockAvatar img:hover {
filter: grayscale(var(--image-grayscale-hover));
}
.blockText {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.blockPrimary {
font-size: 82%;
color: var(--primary-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.blockSecondary {
font-size: 68%;
color: var(--muted-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.blockBadge {
font-size: 65%;
border: var(--border);
border-radius: var(--border-radius);
padding: 2px 6px;
color: var(--muted-color);
white-space: nowrap;
}
.blockRemove {
border: var(--button-border-width) solid var(--button-border-color);
border-radius: var(--button-border-radius);
background: var(--button-background-color);
color: var(--button-color);
padding: 4px 8px;
font-size: 70%;
cursor: pointer;
white-space: nowrap;
}
.blockRemove:hover {
color: var(--button-hover-color);
border-color: var(--button-hover-color);
}
.blockEmpty {
font-size: 75%;
color: var(--muted-color);
opacity: 0.85;
}
@media (max-width: 800px) {
#divBlockControls {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div id="divSvgHam" class="divHeaderButtons"></div>
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">BLOCK</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<div id="divBody">
<div id="divBlockControls">
<select id="selMuteType" class="blockSelect">
<option value="p">Pubkey (p)</option>
<option value="t">Hashtag (t)</option>
<option value="word">Word (word)</option>
<option value="e">Thread/Event (e)</option>
</select>
<input id="inpMuteValue" class="blockInput" type="text" placeholder="npub/hex pubkey, hashtag, word, or event id" />
<label id="lblPrivate" for="chkPrivateMute">
<input id="chkPrivateMute" type="checkbox" checked />
Private (NIP-44)
</label>
<button id="btnAddMute" class="blockButton">Add Block</button>
</div>
<div id="divBlockStatus">Initializing...</div>
<div id="divBlockGroups"></div>
</div>
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<div id="divSideNav">
<div id="divSideNavHeader"></div>
<div id="divSideNavBody">
<div id="divFiles"></div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">リレー</div>
<div id="divRelayList">Loading relays...</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">v0.0.1</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script type="module">
import {
initNDKPage,
getPubkey,
injectHeaderAvatar,
fetchCachedProfile,
storeProfile,
ndkFetchEvents,
publishEvent,
queryCache,
subscribe,
disconnect,
getVersion,
updateVersionDisplay,
ensureMuteListLoaded,
getMuteList,
getMuteCount,
addMute,
removeMute,
onMuteListChanged
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import { createProfileCache } from './js/profile-cache.mjs';
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[block.html ${VERSION}] Loading...`);
let currentPubkey = null;
let updateIntervalId = null;
let isNavOpen = false;
let hamburgerInstance = null;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
let blockRenderToken = 0;
const divBody = document.getElementById('divBody');
const divSideNav = document.getElementById('divSideNav');
const divSideNavBody = document.getElementById('divSideNavBody');
const divFooterCenter = document.getElementById('divFooterCenter');
const divFooterRight = document.getElementById('divFooterRight');
const selMuteType = document.getElementById('selMuteType');
const inpMuteValue = document.getElementById('inpMuteValue');
const chkPrivateMute = document.getElementById('chkPrivateMute');
const btnAddMute = document.getElementById('btnAddMute');
const divBlockStatus = document.getElementById('divBlockStatus');
const divBlockGroups = document.getElementById('divBlockGroups');
const profileCacheApi = createProfileCache({
fetchCachedProfile,
ndkFetchEvents,
storeProfile,
queryCache
});
function setStatus(msg) {
divBlockStatus.textContent = msg;
}
function shortHex(value = '') {
if (!value || value.length < 16) return value;
return `${value.slice(0, 12)}${value.slice(-8)}`;
}
function normalizeType(type) {
return ['p', 't', 'word', 'e'].includes(type) ? type : 'p';
}
function normalizeMuteValue(type, rawValue) {
const value = String(rawValue || '').trim();
if (!value) throw new Error('Enter a value');
if (type === 'p') {
if (/^[a-f0-9]{64}$/i.test(value)) return value.toLowerCase();
if (value.startsWith('npub1')) {
const decoded = window?.NostrTools?.nip19?.decode?.(value);
if (decoded?.type === 'npub' && typeof decoded.data === 'string' && decoded.data.length === 64) {
return decoded.data.toLowerCase();
}
}
throw new Error('Pubkey must be hex or npub1');
}
if (type === 't') {
const normalized = value.replace(/^#+/, '').trim().toLowerCase();
if (!normalized) throw new Error('Hashtag cannot be empty');
return normalized;
}
if (type === 'word') return value.toLowerCase();
if (type === 'e') {
if (/^[a-f0-9]{64}$/i.test(value)) return value.toLowerCase();
if (value.startsWith('note1')) {
const decoded = window?.NostrTools?.nip19?.decode?.(value);
if (decoded?.type === 'note' && typeof decoded.data === 'string' && decoded.data.length === 64) {
return decoded.data.toLowerCase();
}
}
throw new Error('Thread/Event must be hex event id or note1');
}
throw new Error('Unknown mute type');
}
function labelForType(type) {
if (type === 'p') return 'Pubkeys';
if (type === 't') return 'Hashtags';
if (type === 'word') return 'Words';
if (type === 'e') return 'Threads';
return type;
}
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
isNavOpen = true;
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
divSideNavBody.innerHTML = 'Settings and options coming soon...';
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
}
function closeNav() {
divSideNav.style.width = '0vw';
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
}
function toggleNav() {
if (isNavOpen) closeNav(); else openNav();
}
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
async function updateFooter() {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
divFooterCenter.innerHTML = '';
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[block.html] Error updating footer:', error);
}
}
async function logout() {
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) await window.NOSTR_LOGIN_LITE.logout();
localStorage.clear();
sessionStorage.clear();
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) if (db.name) window.indexedDB.deleteDatabase(db.name);
}
location.reload(true);
}
async function getProfile(pubkey) {
return profileCacheApi.getCachedProfile(pubkey) || await fetchCachedProfile(pubkey) || null;
}
async function renderBlockGroups() {
const renderToken = ++blockRenderToken;
const list = getMuteList();
const groups = [
{ type: 'p', label: 'Pubkeys', publicValues: list.public.p, privateValues: list.private.p },
{ type: 't', label: 'Hashtags', publicValues: list.public.t, privateValues: list.private.t },
{ type: 'word', label: 'Words', publicValues: list.public.word, privateValues: list.private.word },
{ type: 'e', label: 'Threads', publicValues: list.public.e, privateValues: list.private.e }
];
const pubkeysToHydrate = [...new Set([...(list.public.p || []), ...(list.private.p || [])])];
if (pubkeysToHydrate.length) {
subscribe({ kinds: [0], authors: pubkeysToHydrate }, { closeOnEose: true, cacheUsage: 'CACHE_FIRST' });
}
const fragment = document.createDocumentFragment();
for (const group of groups) {
if (renderToken !== blockRenderToken) return;
const wrap = document.createElement('div');
wrap.className = 'blockGroup';
const title = document.createElement('div');
title.className = 'blockGroupTitle';
title.textContent = `${group.label} (${group.publicValues.length + group.privateValues.length})`;
wrap.appendChild(title);
const rows = [
...group.publicValues.map((value) => ({ value, private: false })),
...group.privateValues.map((value) => ({ value, private: true }))
];
if (rows.length === 0) {
const empty = document.createElement('div');
empty.className = 'blockEmpty';
empty.textContent = `No ${group.label.toLowerCase()} muted`;
wrap.appendChild(empty);
fragment.appendChild(wrap);
continue;
}
for (const row of rows) {
if (renderToken !== blockRenderToken) return;
const item = document.createElement('div');
item.className = 'blockItem';
const main = document.createElement('div');
main.className = 'blockItemMain';
if (group.type === 'p') {
const avatarWrap = document.createElement('div');
avatarWrap.className = 'blockAvatar';
const img = document.createElement('img');
img.alt = '';
img.loading = 'lazy';
img.decoding = 'async';
img.referrerPolicy = 'no-referrer';
avatarWrap.appendChild(img);
main.appendChild(avatarWrap);
const profile = await getProfile(row.value);
if (renderToken !== blockRenderToken) return;
const picture = String(profile?.picture || '').trim();
if (picture) {
img.addEventListener('error', () => img.removeAttribute('src'), { once: true });
img.src = picture;
}
}
const text = document.createElement('div');
text.className = 'blockText';
const primary = document.createElement('div');
primary.className = 'blockPrimary';
const secondary = document.createElement('div');
secondary.className = 'blockSecondary';
if (group.type === 'p') {
const profile = await getProfile(row.value);
if (renderToken !== blockRenderToken) return;
primary.textContent = String(profile?.display_name || profile?.name || shortHex(row.value));
secondary.textContent = row.value;
} else if (group.type === 't') {
primary.textContent = `#${row.value}`;
secondary.textContent = row.value;
} else {
primary.textContent = row.value;
secondary.textContent = shortHex(row.value);
}
text.appendChild(primary);
text.appendChild(secondary);
main.appendChild(text);
const right = document.createElement('div');
right.style.display = 'flex';
right.style.alignItems = 'center';
right.style.gap = '8px';
const badge = document.createElement('div');
badge.className = 'blockBadge';
badge.textContent = row.private ? 'private' : 'public';
const btnRemove = document.createElement('button');
btnRemove.className = 'blockRemove';
btnRemove.textContent = 'Remove';
btnRemove.addEventListener('click', async (ev) => {
ev.stopPropagation();
const ok = window.confirm(`Remove ${row.value} from ${labelForType(group.type).toLowerCase()}?`);
if (!ok) return;
try {
setStatus('Removing mute entry...');
await removeMute(group.type, row.value);
setStatus(`Removed from ${labelForType(group.type).toLowerCase()}`);
await renderBlockGroups();
} catch (error) {
setStatus(`Remove failed: ${error?.message || String(error)}`);
}
});
right.appendChild(badge);
right.appendChild(btnRemove);
item.appendChild(main);
item.appendChild(right);
wrap.appendChild(item);
}
fragment.appendChild(wrap);
}
if (renderToken !== blockRenderToken) return;
divBlockGroups.innerHTML = '';
divBlockGroups.appendChild(fragment);
setStatus(`Loaded ${getMuteCount()} muted entries`);
}
async function addMuteFromInput() {
try {
const type = normalizeType(selMuteType.value);
const value = normalizeMuteValue(type, inpMuteValue.value);
const isPrivate = Boolean(chkPrivateMute.checked);
setStatus('Publishing mute list...');
await addMute(type, value, isPrivate);
inpMuteValue.value = '';
setStatus(`Added ${value} to ${labelForType(type).toLowerCase()} (${isPrivate ? 'private' : 'public'})`);
// renderBlockGroups() is triggered by onMuteListChanged listener
} catch (error) {
setStatus(`Add failed: ${error?.message || String(error)}`);
}
}
function wireEvents() {
btnAddMute.addEventListener('click', addMuteFromInput);
inpMuteValue.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') addMuteFromInput();
});
window.addEventListener('ndkEvent', async (event) => {
const evt = event.detail;
if (!evt || !evt.kind) return;
if (evt.kind === 0) {
try {
const parsed = JSON.parse(evt.content || '{}');
profileCacheApi.seedProfileCache(evt.pubkey, parsed);
await renderBlockGroups();
} catch (_e) {
// non-fatal
}
return;
}
if (evt.kind === 10000 && evt.pubkey === currentPubkey) {
try {
await ensureMuteListLoaded({ forceReload: true });
// renderBlockGroups() is triggered by onMuteListChanged listener
} catch (err) {
console.warn('[block.html] Failed to refresh mute list from live event:', err?.message || err);
}
}
});
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity } = event.detail;
setRelayActivityState(relayUrl, activity);
});
onMuteListChanged(() => {
renderBlockGroups().catch((err) => {
console.warn('[block.html] mute-list change render failed:', err);
});
});
}
async function main() {
try {
initHamburgerMenu();
document.getElementById('divSvgHam')?.addEventListener('click', toggleNav);
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
themeToggleButton?.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
document.documentElement.classList.toggle('dark-mode', isDarkMode);
document.body.classList.toggle('dark-mode', isDarkMode);
if (themeToggleHamburger) {
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
});
logoutButton?.addEventListener('click', async () => {
try { await logout(); } catch (error) { console.error(error); }
});
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
setStatus('Loading mute list...');
await ensureMuteListLoaded();
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await updateFooter();
wireEvents();
subscribe({ kinds: [10000], authors: [currentPubkey], limit: 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
await renderBlockGroups();
updateIntervalId = setInterval(updateFooter, 1000);
await updateVersionDisplay();
} catch (error) {
console.error('[block.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align:center;padding:40px;color:var(--accent-color);">${error.message}</div>`;
}
}
main();
</script>
</body>
</html>