feat: merge Oasis 0.7.6 upstream — Graphos, E2E, peers/stats, AUTOMATIZACION

Cambios aplicados desde epsylon/oasis 3d46340 (0.7.6):

NUEVO MÓDULO Graphos (mapa interactivo de la red):
- src/views/graphos_view.js (nuevo)

LÓGICA:
- src/backend/nameCache.js (nuevo) — NameAuthor resolver
- src/models/chats_model.js — encriptación E2E
- src/models/calendars_model.js — E2E + calendar invites con códigos
- src/models/maps_model.js — E2E + CLOSED enforcement
- src/models/tribes_model.js — sub-tribe access control (PRESERVA nuestro inviteLog)
- src/models/tribe_crypto.js — soporte E2E
- src/models/main_models.js — refactor (PRESERVA nuestro pub-invite SSB msg)
- src/models/{activity,banking,pads,search,stats,tags,tribes_content}_model.js
- src/backend/backend.js — searchModel constructor + new helpers (errorView, safeRefererRedirect)
- src/backend/blobHandler.js, renderTextWithStyles.js
- src/views/main_views.js — añadido userLink/userLinkLabel + nameCache import (mantiene nuestro hamburger menu)

VISUAL:
- 31 views actualizadas con refactor a userLink helper
- src/views/peers_view.js — tabla con keys clicables
- src/views/stats_view.js — dashboard avanzado
- src/client/assets/styles/style.css — merge (preserva nuestras adiciones QR/mobile)
- Temas desktop: Clear, Dark, Matrix, Purple
- Translations 11 idiomas (ar, de, en, es, eu, fr, hi, it, pt, ru, zh)
- src/configs/{config-manager,oasis-config}, server/SSB_server.js, oasis_client.js

SKIPS (intencionalmente):
- OasisMobile.css del upstream (mantenemos NUESTRO mobile.css y theme)
- main_views.js menu reorganization (mantenemos hamburger nav)
- @xenova/transformers (LLM, no viable mobile)
- node-llama-cpp (build nativo no soportado en arm64 mobile)
- pdfjs-dist (pendiente probar luego)
- AI/embedder.js + AI/routes_index.js (dependen de las libs LLM)

server/package.json: version 0.7.5 → 0.7.6

AUTOMATIZACIÓN:
- Nueva carpeta AUTOMATIZACION/ con 10 archivos:
  - 4 opciones (cron simple, multi-agente, GitHub Actions, webhook)
  - Setup Debian completo paso a paso
  - Scripts bash listos: scout, merger, builder, notify-telegram
  - Prompts listos para los agentes
  - Sección /testing-app para 0asis.net
  - Human-in-the-loop: archivos prohibidos para auto-merge

PENDIENTE: build APK (el bash tool tuvo timeouts; usar comandos
de CONTEXT/cambio_apk_repack.txt manualmente).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
SITO 2026-05-15 19:41:45 +02:00
parent 13161b2158
commit 3a3563f2a0
84 changed files with 5842 additions and 1621 deletions

File diff suppressed because it is too large Load diff

View file

@ -80,7 +80,8 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
'.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus',
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
'.svg': 'image/svg+xml', '.bmp': 'image/bmp'
'.svg': 'image/svg+xml', '.bmp': 'image/bmp',
'.torrent': 'application/x-bittorrent'
};
const blob = { name: blobUpload.originalFilename || blobUpload.name || 'file' };
@ -101,7 +102,7 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
blob.mime = 'application/octet-stream';
}
if (blob.mime.startsWith('image/')) {
if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') {
data = await stripImageMetadata(data);
} else if (blob.mime === 'application/pdf') {
data = stripPdfMetadata(data);
@ -120,6 +121,7 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
if (blob.mime.startsWith("video/")) return `\n[video:${blob.name}](${blob.id})`;
if (blob.mime === "application/pdf") return `[pdf:${blob.name}](${blob.id})`;
if (blob.mime === "application/x-bittorrent") return `\n[torrent:${blob.name}](${blob.id})`;
return `\n[${blob.name}](${blob.id})`;
};
@ -213,8 +215,27 @@ const serveBlob = async function (ctx) {
if (ft && ft.mime) mime = ft.mime;
} catch {}
if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) {
const head = buffer.slice(0, 128).toString('ascii');
if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent';
}
if (mime === 'application/octet-stream' || mime === 'text/plain' || mime === 'application/xml' || mime === 'text/xml') {
let head = buffer.slice(0, 512).toString('utf8');
if (head.charCodeAt(0) === 0xFEFF) head = head.slice(1);
const trimmed = head.replace(/^\s+/, '').toLowerCase();
if (trimmed.startsWith('<?xml') || trimmed.startsWith('<svg')) {
if (trimmed.includes('<svg')) mime = 'image/svg+xml';
}
}
const isSvg = mime === 'image/svg+xml';
const qName = ctx.query.name ? String(ctx.query.name).replace(/["\r\n\\]/g, '').trim() : '';
const safeRaw = String(raw).replace(/["\r\n\\]/g, '');
const filename = qName || (mime === 'application/x-bittorrent' ? 'download.torrent' : safeRaw);
const disposition = isSvg ? 'attachment' : 'inline';
ctx.type = mime;
ctx.set('Content-Disposition', `inline; filename="${raw}"`);
ctx.set('Content-Disposition', `${disposition}; filename="${filename}"`);
ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
const range = ctx.headers.range;

View file

@ -0,0 +1,20 @@
const cache = new Map()
const get = (feedId) => {
if (!feedId) return null
const entry = cache.get(String(feedId))
return entry ? entry.name : null
}
const set = (feedId, name, ts) => {
if (!feedId || typeof name !== 'string' || !name) return
const id = String(feedId)
const t = Number(ts) || 0
const prev = cache.get(id)
if (!prev || (prev.ts || 0) <= t) cache.set(id, { name, ts: t })
}
const has = (feedId) => !!feedId && cache.has(String(feedId))
const size = () => cache.size
module.exports = { get, set, has, size }

View file

@ -9,13 +9,57 @@ function getI18n() {
}
}
function renderTextPreview(text, maxLength = 220) {
if (!text) return ''
let preview = String(text)
preview = preview
.replace(/```[\s\S]*?```/g, '')
.replace(/^>.*$/gm, '')
.replace(/^#{1,6}\s*/gm, '')
.replace(/^- /gm, '')
.replace(/^\d+\. /gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[.*?\]\(.*?\)/g, '')
.replace(/\n+/g, ' ')
.trim()
if (preview.length > maxLength) {
preview = preview.slice(0, maxLength) + '...'
}
return preview
}
function renderTextWithStyles(text) {
if (!text) return ''
const i18n = getI18n()
return String(text)
let html = String(text)
html = html
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
html = html
.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
.replace(/^> (.*)$/gim, '<blockquote>$1</blockquote>')
.replace(/^---$/gim, '<hr/>')
.replace(/^### (.*)$/gim, '<h3>$1</h3>')
.replace(/^## (.*)$/gim, '<h2>$1</h2>')
.replace(/^# (.*)$/gim, '<h1>$1</h1>')
html = html
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
.replace(/`([^`]+)`/gim, '<code>$1</code>')
html = html
.replace(/!\[([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) =>
`<img src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" alt="${alt}" class="post-image" />`
)
@ -28,21 +72,68 @@ function renderTextWithStyles(text) {
.replace(/\[pdf:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, name, blob) =>
`<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
)
html = html
.replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) =>
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${name}</a>`
)
.replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) =>
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${id}</a>`
)
const escAttr = (s) => String(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;')
html = html
.replace(/#(\w+)/g, (_, tag) =>
`<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${tag}</a>`
`<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${escAttr(tag)}</a>`
)
.replace(/(https?:\/\/[^\s]+)/g, url =>
`<a href="${url}" target="_blank" class="styled-link">${url}</a>`
.replace(/(https?:\/\/[^\s"'<>]+)/g, url =>
`<a href="${escAttr(url)}" target="_blank" class="styled-link">${escAttr(url)}</a>`
)
.replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email =>
`<a href="mailto:${email}" class="styled-link">${email}</a>`
`<a href="mailto:${escAttr(email)}" class="styled-link">${escAttr(email)}</a>`
)
const lines = html.split('\n')
let result = ''
let inUL = false
let inOL = false
for (let line of lines) {
if (/^- /.test(line)) {
if (!inUL) {
result += '<ul>'
inUL = true
}
result += `<li>${line.replace(/^- /, '')}</li>`
continue
}
if (/^\d+\. /.test(line)) {
if (!inOL) {
result += '<ol>'
inOL = true
}
result += `<li>${line.replace(/^\d+\. /, '')}</li>`
continue
}
if (inUL) {
result += '</ul>'
inUL = false
}
if (inOL) {
result += '</ol>'
inOL = false
}
result += line + '<br>'
}
if (inUL) result += '</ul>'
if (inOL) result += '</ol>'
return result
}
module.exports = { renderTextWithStyles }
module.exports = { renderTextWithStyles, renderTextPreview }

View file

@ -1022,30 +1022,37 @@ pre, code {
}
.actpager {
margin-top: 0 !important;
margin: 0 !important;
}
.actpager-frame {
gap: 8px !important;
padding: 6px 0 !important;
gap: 4px !important;
padding: 0 !important;
}
.actpager-cell {
gap: 10px !important;
gap: 6px !important;
}
.actpager-cell .filter-btn {
padding: 6px 8px !important;
min-height: 32px !important;
font-size: 0.78rem !important;
}
.actpager-controls {
margin-top: 4px !important;
margin: 0 !important;
}
.actpager-arrows {
gap: 40px !important;
gap: 32px !important;
padding: 0 !important;
}
.actpager-arrow {
width: 32px !important;
height: 32px !important;
font-size: 1.3rem !important;
width: 28px !important;
height: 28px !important;
font-size: 1.15rem !important;
background: transparent !important;
border: 1px solid #555 !important;
}

View file

@ -240,6 +240,7 @@ nav ul li a:hover {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
cursor: pointer;
padding: 0.5rem 0.75rem;
font-weight: 600;
@ -255,6 +256,20 @@ nav ul li a:hover {
box-sizing: border-box;
}
.oasis-nav-header .emoji {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4em;
flex-shrink: 0;
text-align: center;
line-height: 1;
}
.oasis-nav-header .nav-text {
flex: 1;
}
.oasis-nav-header:hover {
background: rgba(255, 255, 255, 0.05);
color: #ffd36a;
@ -295,6 +310,7 @@ nav ul li a:hover {
.oasis-nav-list li a {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 1.25rem 0.35rem 1.5rem;
font-size: 0.85rem;
text-decoration: none;
@ -307,7 +323,18 @@ nav ul li a:hover {
}
.oasis-nav-list .emoji {
margin-right: 0.4rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4em;
flex-shrink: 0;
text-align: center;
line-height: 1;
}
.oasis-nav-list .nav-text {
display: inline-block;
line-height: 1.2;
}
.oasis-header-marquee {
@ -459,6 +486,7 @@ nav ul li a:hover {
.top-bar-left,
.top-bar-mid,
.top-bar-center,
.top-bar-right {
display: flex;
align-items: center;
@ -474,10 +502,78 @@ nav ul li a:hover {
justify-content: center;
}
.top-bar-center {
flex: 1;
justify-content: center;
min-width: 0;
}
.top-bar-right {
justify-content: flex-end;
}
.ai-ask-form {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
max-width: 720px;
}
.ai-ask-form .ai-ask-input,
.ai-ask-form .ai-ask-btn {
box-sizing: border-box;
margin: 0;
height: 32px;
border: 1px solid rgba(255, 180, 0, 0.6);
border-radius: 4px;
padding: 0 0.75rem;
font-size: 0.8rem;
font-family: inherit;
font-weight: 600;
line-height: 1;
background: transparent;
color: #ffb400;
text-transform: none;
letter-spacing: normal;
}
.ai-ask-form .ai-ask-input {
flex: 1;
min-width: 0;
outline: none;
transition: border-color 0.15s ease, background 0.15s ease;
}
.ai-ask-form .ai-ask-input::placeholder {
color: rgba(255, 180, 0, 0.55);
}
.ai-ask-form .ai-ask-input:focus {
border-color: #ffa300;
background: rgba(255, 255, 255, 0.05);
}
.ai-ask-form .ai-ask-btn {
flex-shrink: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
padding: 0 0.85rem;
}
.ai-ask-form .ai-ask-btn:hover {
background: rgba(255, 180, 0, 0.15);
border-color: #ffa300;
color: #ffa300;
}
@media (max-width: 768px) {
.top-bar-center { display: none; }
}
.top-bar-left nav ul,
.top-bar-mid nav ul,
.top-bar-right nav ul {
@ -1038,6 +1134,9 @@ button.create-button:hover {
color: #ffa300;
}
.error-page-message{margin:8px 0 16px;white-space:pre-wrap;word-break:break-word}
.error-page-actions{margin-top:16px}
.tags-container {
display: flex;
flex-wrap: wrap;
@ -3122,7 +3221,9 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
.pledge-box input[type="number"]:focus{ border-color:#3a475c; }
.card-field{display:flex;align-items:baseline;gap:6px;margin-bottom:4px}
.card-field{display:flex;align-items:baseline;gap:6px;margin-bottom:4px;flex-wrap:wrap}
.card-field>.card-label{white-space:nowrap;flex-shrink:0}
.card-field>.card-value{min-width:0;word-break:break-word;overflow-wrap:anywhere;flex:1 1 auto}
.card-field-stacked{flex-direction:column;align-items:flex-start;gap:2px}
.card-field-stacked .card-value{white-space:pre-wrap;word-break:break-word}
.card-field-rich{flex-direction:column;align-items:flex-start}
@ -3276,8 +3377,8 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
.carbon-bar-network{background:#2ecc71 !important}
.carbon-bar-mine{background:#3498db !important}
.carbon-bar-max{background:#555 !important;border:none !important}
.carbon-bar-note{font-size:13px;color:#888;margin:6px 0 2px}
.carbon-bar-formula{font-size:12px;color:#999;margin:2px 0}
.carbon-bar-note{font-size:13px;color:#ffd700;margin:6px 0 2px}
.carbon-bar-formula{font-size:12px;color:#ffd700;margin:2px 0 10px}
/* parliament */
.cycle-info {
@ -3726,6 +3827,26 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
align-self: flex-end;
}
.tribe-parent-box {
margin-bottom: 12px;
text-align: center;
}
.tribe-parent-box h2 {
margin: 0 0 8px 0;
}
.tribe-parent-link {
display: block;
}
.tribe-parent-image {
width: 100%;
max-width: 200px;
border-radius: 8px;
border: 2px solid #444;
}
.tribe-card-meta-row {
display: flex;
gap: .5em;
@ -4123,50 +4244,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
align-self: flex-end;
}
.no-border{border:none}
.tribe-card-padded{padding:12px 16px}
.tribe-content-list-spaced{display:flex;flex-direction:column;gap:16px}
.tribe-banner{padding:12px 16px;margin-bottom:16px;text-align:center}
.bold{font-weight:bold}
.inline-form{display:inline}
.bd-type-post{border-color:#3498db}.bd-type-post .block-diagram-ruler{border-bottom-color:#3498db}
.bd-type-vote,.bd-type-votes,.bd-type-pixelia,.bd-type-parliamentTerm,.bd-type-parliamentProposal,.bd-type-parliamentLaw,.bd-type-parliamentCandidature,.bd-type-parliamentRevocation{border-color:#9b59b6}
.bd-type-vote .block-diagram-ruler,.bd-type-votes .block-diagram-ruler,.bd-type-pixelia .block-diagram-ruler,.bd-type-parliamentTerm .block-diagram-ruler,.bd-type-parliamentProposal .block-diagram-ruler,.bd-type-parliamentLaw .block-diagram-ruler,.bd-type-parliamentCandidature .block-diagram-ruler,.bd-type-parliamentRevocation .block-diagram-ruler{border-bottom-color:#9b59b6}
.bd-type-about,.bd-type-forum,.bd-type-curriculum{border-color:#1abc9c}
.bd-type-about .block-diagram-ruler,.bd-type-forum .block-diagram-ruler,.bd-type-curriculum .block-diagram-ruler{border-bottom-color:#1abc9c}
.bd-type-contact{border-color:#16a085}.bd-type-contact .block-diagram-ruler{border-bottom-color:#16a085}
.bd-type-pub,.bd-type-project,.bd-type-pad,.bd-type-map,.bd-type-mapMarker{border-color:#2ecc71}
.bd-type-pub .block-diagram-ruler,.bd-type-project .block-diagram-ruler,.bd-type-pad .block-diagram-ruler,.bd-type-map .block-diagram-ruler,.bd-type-mapMarker .block-diagram-ruler{border-bottom-color:#2ecc71}
.bd-type-tribe,.bd-type-market,.bd-type-shop,.bd-type-shopProduct{border-color:#e67e22}
.bd-type-tribe .block-diagram-ruler,.bd-type-market .block-diagram-ruler,.bd-type-shop .block-diagram-ruler,.bd-type-shopProduct .block-diagram-ruler{border-bottom-color:#e67e22}
.bd-type-event,.bd-type-transfer,.bd-type-calendar{border-color:#e74c3c}
.bd-type-event .block-diagram-ruler,.bd-type-transfer .block-diagram-ruler,.bd-type-calendar .block-diagram-ruler{border-bottom-color:#e74c3c}
.bd-type-task,.bd-type-banking,.bd-type-bankWallet,.bd-type-bankClaim,.bd-type-gameScore{border-color:#f39c12}
.bd-type-task .block-diagram-ruler,.bd-type-banking .block-diagram-ruler,.bd-type-bankWallet .block-diagram-ruler,.bd-type-bankClaim .block-diagram-ruler,.bd-type-gameScore .block-diagram-ruler{border-bottom-color:#f39c12}
.bd-type-report,.bd-type-courtsCase,.bd-type-courtsEvidence,.bd-type-courtsAnswer,.bd-type-courtsVerdict,.bd-type-courtsSettlement,.bd-type-courtsNomination{border-color:#c0392b}
.bd-type-report .block-diagram-ruler,.bd-type-courtsCase .block-diagram-ruler,.bd-type-courtsEvidence .block-diagram-ruler,.bd-type-courtsAnswer .block-diagram-ruler,.bd-type-courtsVerdict .block-diagram-ruler,.bd-type-courtsSettlement .block-diagram-ruler,.bd-type-courtsNomination .block-diagram-ruler{border-bottom-color:#c0392b}
.bd-type-image,.bd-type-job,.bd-type-aiExchange,.bd-type-chat{border-color:#3498db}
.bd-type-image .block-diagram-ruler,.bd-type-job .block-diagram-ruler,.bd-type-aiExchange .block-diagram-ruler,.bd-type-chat .block-diagram-ruler{border-bottom-color:#3498db}
.bd-type-audio{border-color:#8e44ad}.bd-type-audio .block-diagram-ruler{border-bottom-color:#8e44ad}
.bd-type-video{border-color:#d35400}.bd-type-video .block-diagram-ruler{border-bottom-color:#d35400}
.bd-type-document{border-color:#27ae60}.bd-type-document .block-diagram-ruler{border-bottom-color:#27ae60}
.bd-type-bookmark{border-color:#f1c40f}.bd-type-bookmark .block-diagram-ruler{border-bottom-color:#f1c40f}
.bd-type-feed,.bd-type-tombstone{border-color:#95a5a6}
.bd-type-feed .block-diagram-ruler,.bd-type-tombstone .block-diagram-ruler{border-bottom-color:#95a5a6}
.stats-link{color:#007bff;text-decoration:none}
.stats-link-break{color:#007bff;text-decoration:none;word-break:break-all}
.stats-muted-555{color:#555}
.stats-muted-888{color:#888}
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px}
.stats-section-h{font-size:18px;color:#555;margin:8px 0;font-weight:600}
.stats-list-reset{list-style-type:none;padding:0;margin:0}
.stats-mb-16{margin-bottom:16px}
.stats-w-100{width:100%}
.stats-table{width:100%;border-collapse:collapse}
.stats-table-mt8{width:100%;border-collapse:collapse;margin-top:8px}
.stats-h-row{font-size:18px;color:#555;margin:8px 0}
.comment-submit-btn {
width: auto;
max-width: 200px;
@ -5033,89 +5110,107 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
.logs-detail-header{color:#aaa;font-family:monospace;margin-bottom:12px;word-break:break-all}
.logs-detail-text{white-space:pre-wrap;word-break:break-word;color:#ddd;margin:12px 0}
.logs-detail-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}
.no-border{border:none}
.tribe-card-padded{padding:12px 16px}
.tribe-content-list-spaced{display:flex;flex-direction:column;gap:16px}
.tribe-banner{padding:12px 16px;margin-bottom:16px;text-align:center}
.bold{font-weight:bold}
.inline-form{display:inline}
.bd-type-post{border-color:#3498db}.bd-type-post .block-diagram-ruler{border-bottom-color:#3498db}
.bd-type-vote,.bd-type-votes,.bd-type-pixelia,.bd-type-parliamentTerm,.bd-type-parliamentProposal,.bd-type-parliamentLaw,.bd-type-parliamentCandidature,.bd-type-parliamentRevocation{border-color:#9b59b6}
.bd-type-vote .block-diagram-ruler,.bd-type-votes .block-diagram-ruler,.bd-type-pixelia .block-diagram-ruler,.bd-type-parliamentTerm .block-diagram-ruler,.bd-type-parliamentProposal .block-diagram-ruler,.bd-type-parliamentLaw .block-diagram-ruler,.bd-type-parliamentCandidature .block-diagram-ruler,.bd-type-parliamentRevocation .block-diagram-ruler{border-bottom-color:#9b59b6}
.bd-type-about,.bd-type-forum,.bd-type-curriculum{border-color:#1abc9c}
.bd-type-about .block-diagram-ruler,.bd-type-forum .block-diagram-ruler,.bd-type-curriculum .block-diagram-ruler{border-bottom-color:#1abc9c}
.bd-type-contact{border-color:#16a085}.bd-type-contact .block-diagram-ruler{border-bottom-color:#16a085}
.bd-type-pub,.bd-type-project,.bd-type-pad,.bd-type-map,.bd-type-mapMarker{border-color:#2ecc71}
.bd-type-pub .block-diagram-ruler,.bd-type-project .block-diagram-ruler,.bd-type-pad .block-diagram-ruler,.bd-type-map .block-diagram-ruler,.bd-type-mapMarker .block-diagram-ruler{border-bottom-color:#2ecc71}
.bd-type-tribe,.bd-type-market,.bd-type-shop,.bd-type-shopProduct{border-color:#e67e22}
.bd-type-tribe .block-diagram-ruler,.bd-type-market .block-diagram-ruler,.bd-type-shop .block-diagram-ruler,.bd-type-shopProduct .block-diagram-ruler{border-bottom-color:#e67e22}
.bd-type-event,.bd-type-transfer,.bd-type-calendar{border-color:#e74c3c}
.bd-type-event .block-diagram-ruler,.bd-type-transfer .block-diagram-ruler,.bd-type-calendar .block-diagram-ruler{border-bottom-color:#e74c3c}
.bd-type-task,.bd-type-banking,.bd-type-bankWallet,.bd-type-bankClaim,.bd-type-gameScore{border-color:#f39c12}
.bd-type-task .block-diagram-ruler,.bd-type-banking .block-diagram-ruler,.bd-type-bankWallet .block-diagram-ruler,.bd-type-bankClaim .block-diagram-ruler,.bd-type-gameScore .block-diagram-ruler{border-bottom-color:#f39c12}
.bd-type-report,.bd-type-courtsCase,.bd-type-courtsEvidence,.bd-type-courtsAnswer,.bd-type-courtsVerdict,.bd-type-courtsSettlement,.bd-type-courtsNomination{border-color:#c0392b}
.bd-type-report .block-diagram-ruler,.bd-type-courtsCase .block-diagram-ruler,.bd-type-courtsEvidence .block-diagram-ruler,.bd-type-courtsAnswer .block-diagram-ruler,.bd-type-courtsVerdict .block-diagram-ruler,.bd-type-courtsSettlement .block-diagram-ruler,.bd-type-courtsNomination .block-diagram-ruler{border-bottom-color:#c0392b}
.bd-type-image,.bd-type-job,.bd-type-aiExchange,.bd-type-chat{border-color:#3498db}
.bd-type-image .block-diagram-ruler,.bd-type-job .block-diagram-ruler,.bd-type-aiExchange .block-diagram-ruler,.bd-type-chat .block-diagram-ruler{border-bottom-color:#3498db}
.bd-type-audio{border-color:#8e44ad}.bd-type-audio .block-diagram-ruler{border-bottom-color:#8e44ad}
.bd-type-video{border-color:#d35400}.bd-type-video .block-diagram-ruler{border-bottom-color:#d35400}
.bd-type-document{border-color:#27ae60}.bd-type-document .block-diagram-ruler{border-bottom-color:#27ae60}
.bd-type-bookmark{border-color:#f1c40f}.bd-type-bookmark .block-diagram-ruler{border-bottom-color:#f1c40f}
.bd-type-feed,.bd-type-tombstone{border-color:#95a5a6}
.bd-type-feed .block-diagram-ruler,.bd-type-tombstone .block-diagram-ruler{border-bottom-color:#95a5a6}
.stats-link{color:#ffa500;text-decoration:none}
.stats-link-break{color:#ffa500;text-decoration:none;word-break:break-all}
.stats-muted{color:#aaa}
.stats-strong{color:#ffd700;font-weight:600}
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px}
.stats-mode-row{justify-content:flex-start;margin-bottom:24px}
.stats-section-h{font-size:18px;color:#ffa500;margin:8px 0;font-weight:600}
.stats-list-reset{list-style-type:none;padding:0;margin:0}
.stats-mb-16{margin-bottom:16px}
.stats-w-100{width:100%}
.stats-table{width:100%;border-collapse:collapse}
.stats-table-mt8{width:100%;border-collapse:collapse;margin-top:8px}
.stats-h-row{font-size:15px;color:#ddd;margin:6px 0;display:flex;gap:8px;align-items:baseline}
.stats-card{background:none;padding:0;border:none;border-radius:0;margin-bottom:16px;box-shadow:none}
.stats-card .block-info-table{table-layout:fixed}
.stats-card .block-info-table .card-label{width:70%}
.stats-card .block-info-table .card-value{width:30%;text-align:right;word-break:break-word}
.invites-pubs-table{table-layout:fixed}
.invites-pubs-table col.invites-col-host,.invites-pubs-table td:nth-child(1){width:22%}
.invites-pubs-table td:nth-child(2){width:8%}
.invites-pubs-table td:nth-child(3){width:40%;word-break:break-all}
.invites-pubs-table td:nth-child(4){width:30%}
.invites-pubs-table tr:first-child td{white-space:nowrap}
.stats-block{background:none;padding:0;border:none;border-radius:0;margin-bottom:16px}
.stats-block h2{margin:0 0 10px;font-size:16px;color:#ffa500;font-weight:600}
.stats-block ul{margin:6px 0 0;padding-left:18px}
.stats-block ul li{margin:3px 0;color:#ddd;font-size:14px}
.stats-block table.stats-table th,.stats-block table.stats-table-mt8 th{background:#272727;color:#ffa500;text-align:left;padding:6px 8px;font-size:13px}
.stats-block table.stats-table td,.stats-block table.stats-table-mt8 td{padding:5px 8px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:13px}
.stats-block table.stats-table tr:last-child td,.stats-block table.stats-table-mt8 tr:last-child td{border-bottom:none}
.stats-pill{display:inline-block;padding:2px 8px;border-radius:10px;background:#2a2a2a;border:1px solid #3a3a3a;color:#ffd700;font-size:12px;margin:2px 4px 2px 0}
.stats-toplist{margin:0;padding:0;list-style:none}
.stats-toplist li{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px solid #2a2a2a}
.stats-toplist li:last-child{border-bottom:none}
.stats-toplist .stats-bar-track{flex:1;background:#2a2a2a;border-radius:4px;height:8px;overflow:hidden}
.stats-toplist .stats-bar-fill{background:#ffa500;height:100%}
.stats-toplist .stats-toplist-name{flex:0 0 auto;color:#ddd;font-size:13px;min-width:120px}
.stats-toplist .stats-toplist-num{flex:0 0 auto;color:#ffd700;font-size:13px;min-width:36px;text-align:right}
.peer-key{word-break:break-all;font-size:12px}
.graphos-canvas{width:100%;max-width:1100px;margin:12px auto;padding:8px;background:transparent}
.graphos-svg{width:100%;height:auto;min-height:480px;display:block}
.graphos-edge{stroke:#666;stroke-width:1;opacity:.55}
.graphos-edge-online{stroke:#2ecc71;stroke-width:2;opacity:.9}
.graphos-edge-discovered{stroke:#ffd700;stroke-width:1.5;opacity:.75}
.graphos-edge-unknown{stroke:#888;stroke-width:1;stroke-dasharray:4 4;opacity:.55}
.graphos-node-circle{stroke:#1a1a1a;stroke-width:2;transition:stroke-width .15s,filter .15s}
.graphos-node-circle-me{fill:#ffa500;stroke:#cc7700;stroke-width:3}
.graphos-node-circle-online{fill:#2ecc71;stroke:#1e8449}
.graphos-node-circle-discovered{fill:#ffd700;stroke:#b8860b}
.graphos-node-circle-unknown{fill:#888;stroke:#555}
.graphos-node-link{cursor:pointer;outline:none}
.graphos-node-link:hover .graphos-node-circle{stroke-width:4;filter:brightness(1.15)}
.graphos-node-link:hover .graphos-node-label{font-weight:600}
.graphos-node-label{font-size:12px;fill:#ddd;text-anchor:middle;pointer-events:none}
.graphos-node-label-me{font-weight:600;fill:#ffa500}
.graphos-legend{display:flex;gap:18px;flex-wrap:wrap;align-items:center;font-size:13px;color:#ddd;margin:0 0 8px}
.graphos-legend-item{display:flex;align-items:center;gap:6px}
.graphos-legend-dot{display:inline-block;width:12px;height:12px;border-radius:50%;border:2px solid #1a1a1a}
.graphos-legend-dot.graphos-node-circle-me{background:#ffa500;border-color:#cc7700}
.graphos-legend-dot.graphos-node-circle-online{background:#2ecc71;border-color:#1e8449}
.graphos-legend-dot.graphos-node-circle-discovered{background:#ffd700;border-color:#b8860b}
.graphos-legend-dot.graphos-node-circle-unknown{background:#888;border-color:#555}
.stats-kpi{padding:8px 4px;display:flex;flex-direction:column;gap:4px;background:transparent;border:none}
.stats-kpi-label{color:#ffa500;font-size:12px;font-weight:600;letter-spacing:.3px}
.stats-kpi-value{color:#ffd700;font-weight:600;font-size:22px;line-height:1.1;word-break:break-word}
.stats-kpi-bar{margin-top:6px}
.stats-activity-totals{display:flex;gap:24px;flex-wrap:wrap;color:#ddd;font-size:13px;margin-top:8px}
.stats-activity-totals strong{color:#ffd700}
.stats-w-0{width:0%}.stats-w-5{width:5%}.stats-w-10{width:10%}.stats-w-15{width:15%}.stats-w-20{width:20%}.stats-w-25{width:25%}.stats-w-30{width:30%}.stats-w-35{width:35%}.stats-w-40{width:40%}.stats-w-45{width:45%}.stats-w-50{width:50%}.stats-w-55{width:55%}.stats-w-60{width:60%}.stats-w-65{width:65%}.stats-w-70{width:70%}.stats-w-75{width:75%}.stats-w-80{width:80%}.stats-w-85{width:85%}.stats-w-90{width:90%}.stats-w-95{width:95%}.stats-w-100{width:100%}
.sidebar-panel {
display: contents;
}
.sidebar-left { order: 1; }
.main-column { order: 2; }
.sidebar-right { order: 3; }
.panel-quicklinks { display: none; }
.qr-code svg {
max-width: 200px;
width: 100%;
height: auto;
display: block;
margin: 8px auto;
}
.qr-code-inline svg {
max-width: 140px;
margin: 4px 0;
}
.qr-code-profile svg {
max-width: 160px;
margin: 8px auto;
}
.user-id-qr {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 100%;
margin-top: 8px;
}
.user-id-label {
font-size: 10px;
font-family: monospace;
word-break: break-all;
opacity: 0.6;
text-align: center;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.invite-code-text {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
word-break: break-all;
padding: 8px 12px;
border-radius: 6px;
letter-spacing: 0.5px;
user-select: all;
}
.invite-page {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 16px;
max-width: 480px;
margin: 0 auto;
}
.invite-log-block {
margin-top: 8px;
padding: 6px 8px;
border-left: 3px solid #27ae60;
background: rgba(39,174,96,0.06);
border-radius: 0 4px 4px 0;
}
.invite-status--pending { color: #e67e22; font-weight: bold; }
.invite-status--used { color: #27ae60; font-weight: bold; }
.invite-code-cell {
font-family: monospace;
font-size: 11px;
}
.invite-key {
font-size: 11px;
word-break: break-all;
}
.trending-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
/* === Mobile additions (QR share + mobile menu hide on desktop) === */
.qr-share-details {
margin: 8px 0;
width: 100%;

View file

@ -394,6 +394,17 @@ a.user-link:focus {
background: #ccc !important;
}
.stats-kpi-label { color: #2D2D2D !important; }
.stats-kpi-value { color: #007BFF !important; }
.carbon-bar-note, .carbon-bar-formula { color: #007BFF !important; }
.graphos-node-label { fill: #2C2C2C !important; }
.graphos-node-label-me { fill: #cc7700 !important; }
.graphos-legend { color: #2C2C2C !important; }
.graphos-edge { stroke: #BBB !important; }
.graphos-edge-discovered { stroke: #b8860b !important; }
.graphos-edge-unknown { stroke: #999 !important; }
.graphos-node-circle-discovered { fill: #ffd700 !important; stroke: #b8860b !important; }
.graphos-legend-dot.graphos-node-circle-discovered { background: #ffd700 !important; border-color: #b8860b !important; }
/* Blockexplorer */
.blockchain-view { background-color: #F4F4F4 !important; color: #2C2C2C !important; }
.block { background: #FFFFFF !important; box-shadow: 0 2px 12px rgba(0,0,0,0.06) !important; }

View file

@ -312,6 +312,12 @@ a.user-link:focus {
}
/* Blockexplorer */
.stats-kpi-label { color: #ffa300 !important; }
.stats-kpi-value { color: #FFD700 !important; }
.carbon-bar-note, .carbon-bar-formula { color: #FFD700 !important; }
.graphos-node-label { fill: #ddd !important; }
.graphos-node-label-me { fill: #ffa500 !important; }
.graphos-legend { color: #ddd !important; }
.blockchain-view { background-color: #191b20 !important; color: #FFD700 !important; }
.block { background: #23242a !important; }
.block:hover { box-shadow: 0 8px 32px rgba(35,40,50,0.18) !important; }

View file

@ -405,6 +405,13 @@ a.user-link:focus {
color: #00FF00 !important;
}
.stats-kpi-label { color: #00FF00 !important; }
.stats-kpi-value { color: #00FF00 !important; }
.carbon-bar-note, .carbon-bar-formula { color: #00FF00 !important; }
.graphos-node-label { fill: #00FF00 !important; }
.graphos-node-label-me { fill: #ffa500 !important; font-weight: bold !important; }
.graphos-legend { color: #00FF00 !important; }
.graphos-edge { stroke: #003300 !important; }
/* Blockexplorer */
.blockchain-view { background-color: #000000 !important; color: #00FF00 !important; }
.block { background: #1A1A1A !important; border: 1px solid #00FF00 !important; }

View file

@ -440,6 +440,12 @@ a.user-link:focus {
color: #9B1C96 !important;
}
.stats-kpi-label { color: #B86ADE !important; }
.stats-kpi-value { color: #FFEEDB !important; }
.carbon-bar-note, .carbon-bar-formula { color: #FFEEDB !important; }
.graphos-node-label { fill: #FFEEDB !important; }
.graphos-node-label-me { fill: #ffa500 !important; }
.graphos-legend { color: #FFEEDB !important; }
/* Blockexplorer */
.blockchain-view { background-color: #2D0B47 !important; color: #FFEEDB !important; }
.block { background: #3C1360 !important; border: 1px solid #B86ADE !important; }

View file

@ -38,6 +38,11 @@ module.exports = {
],
profile: "الصورة الرمزية",
inhabitants: "السكان",
peersReplicatedFeeds: "الموجزات المنسوخة",
graphos: "غرافوس",
graphosDescription: "خريطة تفاعلية للشبكة من حولك.",
graphosYou: "أنت",
graphosTotalNodes: "إجمالي العقد",
manualMode: "الوضع اليدوي",
mentions: "الإشارات",
mentionsDescription: [
@ -2148,6 +2153,7 @@ module.exports = {
bankAddressInvalid: 'عنوان غير صالح',
bankAddressDeleted: 'تم حذف العنوان',
bankAddressNotFound: 'لم يتم العثور على العنوان',
bankAddressForbidden: 'يمكنك فقط تعيين عنوان الدفع الخاص بك',
bankAddressTotal: 'إجمالي العناوين',
bankAddressSearch: 'ابحث عن @ساكن أو عنوان',
bankAddressActions: 'الإجراءات',
@ -2753,6 +2759,11 @@ module.exports = {
fileTooLargeTitle: "الملف كبير جدًا",
fileTooLargeMessage: "يتجاوز الملف الحجم الأقصى المسموح به (50 ميغابايت). يرجى اختيار ملف أصغر.",
goBack: "رجوع",
errorPageTitle: "حدث خطأ",
aiNavPlaceholder: "إلى أين تريد الذهاب؟",
aiNavDisabled: "تعطيل التنقل بالذكاء الاصطناعي.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "وحدة لإجراء استعلامات بلغة طبيعية حول محتوى الشبكة.",
directConnect: "اتصال مباشر",
directConnectDescription: "اتصل مباشرة بقرين عن طريق إدخال عنوان IP والمنفذ والمفتاح العام. سيتم إضافة القرين كاتصال مُتابَع.",
peerHost: "IP / اسم المضيف",
@ -2948,6 +2959,8 @@ module.exports = {
gamesBackToGames: "العودة إلى الألعاب",
modulesGamesLabel: "الألعاب",
modulesGamesDescription: "وحدة لاكتشاف وتشغيل بعض الألعاب.",
modulesGraphosLabel: "غرافوس",
modulesGraphosDescription: "وحدة لاستكشاف الشبكة كخريطة تفاعلية للعقد.",
gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "جوزة هند بعيون تقفز فوق أشجار النخيل وتجمع ECOins.",
gamesTheFlowTitle: "ECOinflow",
@ -3017,6 +3030,9 @@ module.exports = {
calendarCreated: "تم الإنشاء",
calendarAuthor: "المؤلف",
calendarJoin: "انضمام",
calendarGenerateInvite: "إنشاء دعوة",
calendarInviteCodePlaceholder: "أدخل رمز الدعوة...",
calendarValidateInvite: "التحقق من الرمز",
calendarJoined: "منضم",
calendarAddDate: "إضافة تاريخ",
calendarAddNote: "إضافة ملاحظة",

View file

@ -37,7 +37,12 @@ module.exports = {
" von Bewohnern, die du unterstützt (inkl. Multiversum), nach Aktualität sortiert.",
],
profile: "Avatar",
inhabitants: "Bewohner",
inhabitants: "Bewohner",
peersReplicatedFeeds: "Replizierte Feeds",
graphos: "Graphos",
graphosDescription: "Interaktive Karte des Netzwerks um dich herum.",
graphosYou: "Du",
graphosTotalNodes: "Knoten insgesamt",
manualMode: "Manueller Modus",
mentions: "Erwähnungen",
mentionsDescription: [
@ -2147,6 +2152,7 @@ module.exports = {
bankAddressInvalid: 'Ungültige Adresse',
bankAddressDeleted: 'Adresse gelöscht',
bankAddressNotFound: 'Adresse nicht gefunden',
bankAddressForbidden: 'Du kannst nur deine eigene Auszahlungsadresse festlegen',
bankAddressTotal: 'Adressen gesamt',
bankAddressSearch: 'Nach @Bewohner oder Adresse suchen',
bankAddressActions: 'Aktionen',
@ -2752,6 +2758,11 @@ module.exports = {
fileTooLargeTitle: "Datei zu groß",
fileTooLargeMessage: "Die Datei überschreitet die maximal erlaubte Größe (50 MB). Bitte wähle eine kleinere Datei.",
goBack: "Zurück",
errorPageTitle: "Etwas ist schiefgelaufen",
aiNavPlaceholder: "Wohin möchtest du gehen?",
aiNavDisabled: "KI-Navigation ist deaktiviert.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Modul für natürlichsprachliche Anfragen zum Inhalt des Netzwerks.",
directConnect: "Direkte Verbindung",
directConnectDescription: "Verbinde dich direkt mit einem Peer, indem du IP-Adresse, Port und öffentlichen Schlüssel eingibst.",
peerHost: "IP / Hostname",
@ -2891,6 +2902,8 @@ module.exports = {
gamesBackToGames: "Zurück zu Spielen",
modulesGamesLabel: "Spiele",
modulesGamesDescription: "Modul zum Entdecken und Spielen von Spielen.",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "Modul, um das Netzwerk als interaktive Karte der Knoten zu erkunden.",
gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "Eine Kokosnuss mit Augen, die über Palmen springt und ECOins sammelt.",
gamesTheFlowTitle: "ECOinflow",
@ -3013,6 +3026,9 @@ module.exports = {
calendarCreated: "Erstellt",
calendarAuthor: "Autor",
calendarJoin: "Beitreten",
calendarGenerateInvite: "Einladung erstellen",
calendarInviteCodePlaceholder: "Einladungscode eingeben...",
calendarValidateInvite: "Code prüfen",
calendarJoined: "Beigetreten",
calendarAddDate: "Datum hinzufügen",
calendarAddNote: "Notiz hinzufügen",

View file

@ -37,7 +37,12 @@ module.exports = {
" from inhabitants you support (included from multiverse), sorted by recency.",
],
profile: "Avatar",
inhabitants: "Inhabitants",
inhabitants: "Inhabitants",
peersReplicatedFeeds: "Replicated feeds",
graphos: "Graphos",
graphosDescription: "Interactive map of the network around you.",
graphosYou: "You",
graphosTotalNodes: "Total nodes",
manualMode: "Manual Mode",
mentions: "Mentions",
mentionsDescription: [
@ -2153,6 +2158,7 @@ module.exports = {
bankAddressInvalid: 'Invalid address',
bankAddressDeleted: 'Address deleted',
bankAddressNotFound: 'Address not found',
bankAddressForbidden: 'You can only set your own payout address',
bankAddressTotal: 'Total Addresses',
bankAddressSearch: 'Search @inhabitant or address',
bankAddressActions: 'Actions',
@ -2758,6 +2764,11 @@ module.exports = {
fileTooLargeTitle: "File too large",
fileTooLargeMessage: "The file exceeds the maximum allowed size (50 MB). Please select a smaller file.",
goBack: "Go back",
errorPageTitle: "Something went wrong",
aiNavPlaceholder: "Where do you want to go?",
aiNavDisabled: "AI navigation is disabled.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Module for natural-language queries about the network's content.",
directConnect: "Direct Connect",
directConnectDescription: "Connect directly to a peer by entering their IP address, port and public key. The peer will be added as a followed connection.",
peerHost: "IP / Hostname",
@ -2921,6 +2932,9 @@ module.exports = {
calendarCreated: "Created",
calendarAuthor: "Author",
calendarJoin: "Join Calendar",
calendarGenerateInvite: "Generate invite",
calendarInviteCodePlaceholder: "Enter invite code...",
calendarValidateInvite: "Validate code",
calendarJoined: "Joined",
calendarAddDate: "Add Date",
calendarAddNote: "Add Note",
@ -3024,6 +3038,8 @@ module.exports = {
gamesBackToGames: "Back to Games",
modulesGamesLabel: "Games",
modulesGamesDescription: "Module to discover and play some games.",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "Module to explore the network as an interactive map of peers.",
gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "A coconut with eyes jumping over palm trees and collecting ECOins. How far can you go?",
gamesTheFlowTitle: "ECOinflow",

View file

@ -38,6 +38,11 @@ module.exports = {
],
profile: "Avatar",
inhabitants: "Habitantes",
peersReplicatedFeeds: "Feeds replicados",
graphos: "Graphos",
graphosDescription: "Mapa interactivo de la red alrededor de ti.",
graphosYou: "Tú",
graphosTotalNodes: "Nodos totales",
manualMode: "Modo Manual",
mentions: "Menciones",
mentionsDescription: [
@ -2151,6 +2156,7 @@ module.exports = {
bankAddressInvalid: 'Dirección no válida',
bankAddressDeleted: 'Dirección eliminada',
bankAddressNotFound: 'Dirección no encontrada',
bankAddressForbidden: 'Solo puedes configurar tu propia dirección de cobro',
bankAddressTotal: 'Total de Direcciones',
bankAddressSearch: 'Buscar @habitante o dirección',
bankAddressActions: 'Acciones',
@ -2757,6 +2763,11 @@ module.exports = {
fileTooLargeTitle: "Archivo demasiado grande",
fileTooLargeMessage: "El archivo supera el tamaño máximo permitido (50 MB). Por favor, selecciona un archivo más pequeño.",
goBack: "Volver",
errorPageTitle: "Ha ocurrido un error",
aiNavPlaceholder: "¿A dónde quieres ir?",
aiNavDisabled: "La navegación con IA está desactivada.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Módulo para realizar peticiones en lenguaje natural sobre el contenido de la red.",
directConnect: "Conexión Directa",
directConnectDescription: "Conéctate directamente a un nodo introduciendo su dirección IP, puerto y clave pública. El nodo se añadirá como conexión seguida.",
peerHost: "IP / Nombre de host",
@ -2922,6 +2933,9 @@ module.exports = {
calendarCreated: "Creado",
calendarAuthor: "Autor",
calendarJoin: "Unirse al Calendario",
calendarGenerateInvite: "Generar invitación",
calendarInviteCodePlaceholder: "Introduce código...",
calendarValidateInvite: "Validar código",
calendarJoined: "Unido",
calendarAddDate: "Añadir Fecha",
calendarAddNote: "Añadir Nota",
@ -3025,6 +3039,8 @@ module.exports = {
gamesBackToGames: "Volver a Juegos",
modulesGamesLabel: "Juegos",
modulesGamesDescription: "Módulo para descubrir y jugar algunos juegos.",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "Módulo para explorar la red como un mapa interactivo de pares.",
gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "Un coco con ojos saltando palmeras y coleccionando ECOins. ¿Hasta dónde puedes llegar?",
gamesTheFlowTitle: "ECOinflow",

View file

@ -37,7 +37,12 @@ module.exports = {
" eta laguntzen dituzun bizilagunenak, gaurkotasunaren arabera antolatuta. Hautatu edozein bidalketaren ordu-marka hari osoa ikusteko.",
],
profile: "Abatarra",
inhabitants: "Bizilagunak",
inhabitants: "Bizilagunak",
peersReplicatedFeeds: "Errepikatutako jarioak",
graphos: "Graphos",
graphosDescription: "Sarearen mapa interaktiboa zure inguruan.",
graphosYou: "Zu",
graphosTotalNodes: "Nodo guztiak",
manualMode: "Eskuzko modua",
mentions: "Aipamenak",
mentionsDescription: [
@ -2118,6 +2123,7 @@ module.exports = {
bankAddressInvalid: 'Helbide baliogabea',
bankAddressDeleted: 'Helbidea ezabatuta',
bankAddressNotFound: 'Helbiderik ez da aurkitu',
bankAddressForbidden: 'Zure ordainketa-helbidea soilik konfigura dezakezu',
bankAddressTotal: 'Guztira',
bankAddressSearch: 'Erabiltzailea edo helbidea bilatu',
bankAddressActions: 'Ekintzak',
@ -2724,6 +2730,11 @@ module.exports = {
fileTooLargeTitle: "Fitxategia handiegia",
fileTooLargeMessage: "Fitxategiak onartutako gehienezko tamaina gainditzen du (50 MB). Mesedez, hautatu fitxategi txikiago bat.",
goBack: "Itzuli",
errorPageTitle: "Zerbait gaizki joan da",
aiNavPlaceholder: "Nora joan nahi duzu?",
aiNavDisabled: "AI nabigazioa desgaituta dago.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Sareko edukiari buruzko hizkuntza naturalezko galderak egiteko modulua.",
directConnect: "Zuzeneko Konexioa",
directConnectDescription: "Konektatu zuzenean parekide batera bere IP helbidea, portua eta gako publikoa sartuz. Parekidea jarraipen-konexio gisa gehituko da.",
peerHost: "IP / Ostalari-izena",
@ -2918,6 +2929,8 @@ module.exports = {
gamesBackToGames: "Jokoetara itzuli",
modulesGamesLabel: "Jokoak",
modulesGamesDescription: "Jokoak aurkitzeko eta jolasteko modulua.",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "Sarea nodoen mapa interaktibo gisa esploratzeko modulua.",
gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "Begiak dituen koko bat palmondoen gainetik jauzika ECOins biltzen.",
gamesTheFlowTitle: "ECOinflow",
@ -2987,6 +3000,9 @@ module.exports = {
calendarCreated: "Sortua",
calendarAuthor: "Egilea",
calendarJoin: "Batu",
calendarGenerateInvite: "Sortu gonbidapena",
calendarInviteCodePlaceholder: "Sartu gonbidapen-kodea...",
calendarValidateInvite: "Egiaztatu kodea",
calendarJoined: "Batuta",
calendarAddDate: "Data gehitu",
calendarAddNote: "Oharra gehitu",

View file

@ -38,6 +38,11 @@ module.exports = {
],
profile: "Avatar",
inhabitants: "Habitants",
peersReplicatedFeeds: "Flux répliqués",
graphos: "Graphos",
graphosDescription: "Carte interactive du réseau autour de toi.",
graphosYou: "Toi",
graphosTotalNodes: "Nœuds totaux",
manualMode: "Mode Manuel",
mentions: "Mentions",
mentionsDescription: [
@ -2143,6 +2148,7 @@ module.exports = {
bankAddressInvalid: 'Adresse non valide',
bankAddressDeleted: 'Adresse supprimée',
bankAddressNotFound: 'Adresse introuvable',
bankAddressForbidden: 'Vous ne pouvez configurer que votre propre adresse de paiement',
bankAddressTotal: 'Total des adresses',
bankAddressSearch: 'Rechercher @habitant ou adresse',
bankAddressActions: 'Actions',
@ -2749,6 +2755,11 @@ module.exports = {
fileTooLargeTitle: "Fichier trop volumineux",
fileTooLargeMessage: "Le fichier dépasse la taille maximale autorisée (50 Mo). Veuillez sélectionner un fichier plus petit.",
goBack: "Retour",
errorPageTitle: "Une erreur est survenue",
aiNavPlaceholder: "Où veux-tu aller ?",
aiNavDisabled: "La navigation par IA est désactivée.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Module pour des requêtes en langage naturel sur le contenu du réseau.",
directConnect: "Connexion Directe",
directConnectDescription: "Connectez-vous directement à un pair en saisissant son adresse IP, port et clé publique. Le pair sera ajouté comme connexion suivie.",
peerHost: "IP / Nom d'hôte",
@ -2946,6 +2957,8 @@ module.exports = {
gamesBackToGames: "Retour aux Jeux",
modulesGamesLabel: "Jeux",
modulesGamesDescription: "Module pour découvrir et jouer à des jeux.",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "Module pour explorer le réseau comme une carte interactive des nœuds.",
gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "Une noix de coco avec des yeux qui saute par-dessus des palmiers en collectant des ECOins.",
gamesTheFlowTitle: "ECOinflow",
@ -3015,6 +3028,9 @@ module.exports = {
calendarCreated: "Créé",
calendarAuthor: "Auteur",
calendarJoin: "Rejoindre",
calendarGenerateInvite: "Générer une invitation",
calendarInviteCodePlaceholder: "Entrez le code d'invitation...",
calendarValidateInvite: "Valider le code",
calendarJoined: "Rejoint",
calendarAddDate: "Ajouter une date",
calendarAddNote: "Ajouter une note",

View file

@ -38,6 +38,11 @@ module.exports = {
],
profile: "अवतार",
inhabitants: "निवासी",
peersReplicatedFeeds: "प्रतिकृत फ़ीड",
graphos: "ग्राफोस",
graphosDescription: "आपके आसपास के नेटवर्क का इंटरैक्टिव मानचित्र।",
graphosYou: "आप",
graphosTotalNodes: "कुल नोड्स",
manualMode: "मैनुअल मोड",
mentions: "उल्लेख",
mentionsDescription: [
@ -2148,6 +2153,7 @@ module.exports = {
bankAddressInvalid: 'अमान्य पता',
bankAddressDeleted: 'पता हटाया गया',
bankAddressNotFound: 'पता नहीं मिला',
bankAddressForbidden: 'आप केवल अपना भुगतान पता सेट कर सकते हैं',
bankAddressTotal: 'कुल पते',
bankAddressSearch: '@निवासी या पता खोजें',
bankAddressActions: 'कार्रवाई',
@ -2753,6 +2759,11 @@ module.exports = {
fileTooLargeTitle: "फ़ाइल बहुत बड़ी",
fileTooLargeMessage: "फ़ाइल अधिकतम अनुमत आकार (50 MB) से अधिक है। कृपया एक छोटी फ़ाइल चुनें।",
goBack: "वापस जाएँ",
errorPageTitle: "कुछ गलत हो गया",
aiNavPlaceholder: "आप कहाँ जाना चाहते हैं?",
aiNavDisabled: "AI नेविगेशन अक्षम है।",
modulesAINavLabel: "AINav",
modulesAINavDescription: "नेटवर्क की सामग्री पर प्राकृतिक भाषा में प्रश्न करने का मॉड्यूल।",
directConnect: "सीधा कनेक्शन",
directConnectDescription: "किसी पीयर से सीधे कनेक्ट करने के लिए उनका IP पता, पोर्ट और सार्वजनिक कुंजी दर्ज करें। पीयर को एक अनुसरित कनेक्शन के रूप में जोड़ा जाएगा।",
peerHost: "IP / होस्टनाम",
@ -2948,6 +2959,8 @@ module.exports = {
gamesBackToGames: "खेलों पर वापस जाएं",
modulesGamesLabel: "खेल",
modulesGamesDescription: "कुछ खेल खोजने और खेलने का मॉड्यूल।",
modulesGraphosLabel: "ग्राफोस",
modulesGraphosDescription: "नोड्स के इंटरैक्टिव मानचित्र के रूप में नेटवर्क का अन्वेषण करने का मॉड्यूल।",
gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "आंखों वाला एक नारियल ताड़ के पेड़ों के ऊपर कूदता है और ECOins इकट्ठा करता है।",
gamesTheFlowTitle: "ECOinflow",
@ -3017,6 +3030,9 @@ module.exports = {
calendarCreated: "बनाया गया",
calendarAuthor: "लेखक",
calendarJoin: "जुड़ें",
calendarGenerateInvite: "आमंत्रण बनाएं",
calendarInviteCodePlaceholder: "आमंत्रण कोड दर्ज करें...",
calendarValidateInvite: "कोड सत्यापित करें",
calendarJoined: "जुड़े हुए",
calendarAddDate: "तारीख जोड़ें",
calendarAddNote: "नोट जोड़ें",

View file

@ -37,7 +37,12 @@ module.exports = {
" dagli abitanti che supporti (incluso dal multiverso), ordinati per data.",
],
profile: "Avatar",
inhabitants: "Abitanti",
inhabitants: "Abitanti",
peersReplicatedFeeds: "Feed replicati",
graphos: "Graphos",
graphosDescription: "Mappa interattiva della rete attorno a te.",
graphosYou: "Tu",
graphosTotalNodes: "Nodi totali",
manualMode: "Modalità manuale",
mentions: "Menzioni",
mentionsDescription: [
@ -2148,6 +2153,7 @@ module.exports = {
bankAddressInvalid: 'Indirizzo non valido',
bankAddressDeleted: 'Indirizzo eliminato',
bankAddressNotFound: 'Indirizzo non trovato',
bankAddressForbidden: 'Puoi impostare solo il tuo indirizzo di pagamento',
bankAddressTotal: 'Indirizzi totali',
bankAddressSearch: 'Cerca @abitante o indirizzo',
bankAddressActions: 'Azioni',
@ -2753,6 +2759,11 @@ module.exports = {
fileTooLargeTitle: "File troppo grande",
fileTooLargeMessage: "Il file supera la dimensione massima consentita (50 MB). Seleziona un file più piccolo.",
goBack: "Torna indietro",
errorPageTitle: "Si è verificato un errore",
aiNavPlaceholder: "Dove vuoi andare?",
aiNavDisabled: "La navigazione con IA è disattivata.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Modulo per query in linguaggio naturale sui contenuti della rete.",
directConnect: "Connessione diretta",
directConnectDescription: "Connettiti direttamente a un peer inserendo indirizzo IP, porta e chiave pubblica.",
peerHost: "IP / Nome host",
@ -2949,6 +2960,8 @@ module.exports = {
gamesBackToGames: "Torna ai Giochi",
modulesGamesLabel: "Giochi",
modulesGamesDescription: "Modulo per scoprire e giocare ad alcuni giochi.",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "Modulo per esplorare la rete come una mappa interattiva dei nodi.",
gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "Una noce di cocco con occhi che salta palme e raccoglie ECOins.",
gamesTheFlowTitle: "ECOinflow",
@ -3018,6 +3031,9 @@ module.exports = {
calendarCreated: "Creato",
calendarAuthor: "Autore",
calendarJoin: "Partecipa",
calendarGenerateInvite: "Genera invito",
calendarInviteCodePlaceholder: "Inserisci il codice di invito...",
calendarValidateInvite: "Convalida codice",
calendarJoined: "Iscritto",
calendarAddDate: "Aggiungi data",
calendarAddNote: "Aggiungi nota",

View file

@ -37,7 +37,12 @@ module.exports = {
" dos habitantes que apoias (incluindo do multiverso), ordenadas por data.",
],
profile: "Avatar",
inhabitants: "Habitantes",
inhabitants: "Habitantes",
peersReplicatedFeeds: "Feeds replicados",
graphos: "Graphos",
graphosDescription: "Mapa interativo da rede ao seu redor.",
graphosYou: "Você",
graphosTotalNodes: "Total de nós",
manualMode: "Modo manual",
mentions: "Menções",
mentionsDescription: [
@ -2148,6 +2153,7 @@ module.exports = {
bankAddressInvalid: 'Invalid address',
bankAddressDeleted: 'Address deleted',
bankAddressNotFound: 'Address not found',
bankAddressForbidden: 'Só podes configurar o teu próprio endereço de cobrança',
bankAddressTotal: 'Total Addresses',
bankAddressSearch: 'Search @inhabitant or address',
bankAddressActions: 'Actions',
@ -2753,6 +2759,11 @@ module.exports = {
fileTooLargeTitle: "Ficheiro demasiado grande",
fileTooLargeMessage: "O ficheiro excede o tamanho máximo permitido (50 MB). Por favor, seleciona um ficheiro mais pequeno.",
goBack: "Voltar",
errorPageTitle: "Algo correu mal",
aiNavPlaceholder: "Para onde queres ir?",
aiNavDisabled: "A navegação por IA está desativada.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Módulo para consultas em linguagem natural sobre o conteúdo da rede.",
directConnect: "Ligação direta",
directConnectDescription: "Conecta-te diretamente a um par inserindo o endereço IP, porta e chave pública.",
peerHost: "IP / Nome do anfitrião",
@ -2949,6 +2960,8 @@ module.exports = {
gamesBackToGames: "Voltar aos Jogos",
modulesGamesLabel: "Jogos",
modulesGamesDescription: "Módulo para descobrir e jogar alguns jogos.",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "Módulo para explorar a rede como um mapa interativo de nós.",
gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "Um coco com olhos a saltar palmeiras e a colecionar ECOins.",
gamesTheFlowTitle: "ECOinflow",
@ -3018,6 +3031,9 @@ module.exports = {
calendarCreated: "Criado",
calendarAuthor: "Autor",
calendarJoin: "Participar",
calendarGenerateInvite: "Gerar convite",
calendarInviteCodePlaceholder: "Digite o código de convite...",
calendarValidateInvite: "Validar código",
calendarJoined: "Inscrito",
calendarAddDate: "Adicionar data",
calendarAddNote: "Adicionar nota",

View file

@ -38,6 +38,11 @@ module.exports = {
],
profile: "Аватар",
inhabitants: "Жители",
peersReplicatedFeeds: "Реплицированные ленты",
graphos: "Графос",
graphosDescription: "Интерактивная карта сети вокруг вас.",
graphosYou: "Вы",
graphosTotalNodes: "Всего узлов",
manualMode: "Ручной режим",
mentions: "Упоминания",
mentionsDescription: [
@ -2113,6 +2118,7 @@ module.exports = {
bankAddressInvalid: "Недействительный адрес",
bankAddressDeleted: "Адрес удалён",
bankAddressNotFound: "Адрес не найден",
bankAddressForbidden: "Вы можете задать только свой собственный адрес для выплат",
bankAddressTotal: "Всего адресов",
bankAddressSearch: "Поиск @жителя или адреса",
bankAddressActions: "Действия",
@ -2707,6 +2713,11 @@ module.exports = {
fileTooLargeTitle: "Файл слишком большой",
fileTooLargeMessage: "Файл превышает максимально допустимый размер (50 МБ). Пожалуйста, выберите файл меньшего размера.",
goBack: "Назад",
errorPageTitle: "Что-то пошло не так",
aiNavPlaceholder: "Куда вы хотите перейти?",
aiNavDisabled: "Навигация с ИИ отключена.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Модуль для запросов на естественном языке к содержимому сети.",
directConnect: "Прямое подключение",
directConnectDescription: "Подключитесь напрямую к узлу, введя его IP-адрес, порт и публичный ключ. Узел будет добавлен как отслеживаемое соединение.",
peerHost: "IP / Имя хоста",
@ -2911,6 +2922,8 @@ module.exports = {
gamesBackToGames: "Назад к играм",
modulesGamesLabel: "Игры",
modulesGamesDescription: "Модуль для открытия и игры в игры.",
modulesGraphosLabel: "Графос",
modulesGraphosDescription: "Модуль для исследования сети в виде интерактивной карты узлов.",
gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "Кокос с глазами прыгает через пальмы и собирает ECOins.",
gamesTheFlowTitle: "ECOinflow",
@ -2980,6 +2993,9 @@ module.exports = {
calendarCreated: "Создан",
calendarAuthor: "Автор",
calendarJoin: "Присоединиться",
calendarGenerateInvite: "Создать приглашение",
calendarInviteCodePlaceholder: "Введите код приглашения...",
calendarValidateInvite: "Проверить код",
calendarJoined: "Участник",
calendarAddDate: "Добавить дату",
calendarAddNote: "Добавить заметку",

View file

@ -38,6 +38,11 @@ module.exports = {
],
profile: "头像",
inhabitants: "居民",
peersReplicatedFeeds: "复制的 Feed",
graphos: "Graphos",
graphosDescription: "你周围网络的互动地图。",
graphosYou: "你",
graphosTotalNodes: "节点总数",
manualMode: "手动模式",
mentions: "提及",
mentionsDescription: [
@ -2149,6 +2154,7 @@ module.exports = {
bankAddressInvalid: '无效地址',
bankAddressDeleted: '地址已删除',
bankAddressNotFound: '未找到地址',
bankAddressForbidden: '您只能设置自己的收款地址',
bankAddressTotal: '地址总数',
bankAddressSearch: '搜索 @居民或地址',
bankAddressActions: '操作',
@ -2754,6 +2760,11 @@ module.exports = {
fileTooLargeTitle: "文件过大",
fileTooLargeMessage: "文件超过了允许的最大大小50 MB。请选择较小的文件。",
goBack: "返回",
errorPageTitle: "出现了问题",
aiNavPlaceholder: "你想去哪里?",
aiNavDisabled: "AI 导航已停用。",
modulesAINavLabel: "AINav",
modulesAINavDescription: "用于以自然语言查询网络内容的模块。",
directConnect: "直接连接",
directConnectDescription: "通过输入对方的 IP 地址、端口和公钥直接连接到节点。该节点将被添加为已关注的连接。",
peerHost: "IP / 主机名",
@ -2949,6 +2960,8 @@ module.exports = {
gamesBackToGames: "返回游戏",
modulesGamesLabel: "游戏",
modulesGamesDescription: "用于发现和玩游戏的模块。",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "将网络作为节点交互式地图浏览的模块。",
gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "一颗有眼睛的椰子跳过棕榈树收集ECOins。",
gamesTheFlowTitle: "ECOinflow",
@ -3018,6 +3031,9 @@ module.exports = {
calendarCreated: "已创建",
calendarAuthor: "作者",
calendarJoin: "加入",
calendarGenerateInvite: "生成邀请",
calendarInviteCodePlaceholder: "输入邀请码...",
calendarValidateInvite: "验证邀请码",
calendarJoined: "已加入",
calendarAddDate: "添加日期",
calendarAddNote: "添加笔记",

View file

@ -23,7 +23,7 @@ const cli = (presets, defaultConfigFile) =>
})
.options("offline", {
describe:
"Don't try to connect to scuttlebutt peers or pubs. This can be changed on the 'settings' page while Oasis is running.",
"Don't try to connect to Oasis peers or pubs. This can be changed on the 'settings' page while Oasis is running.",
default: _.get(presets, "offline", false),
type: "boolean",
})
@ -54,31 +54,6 @@ const cli = (presets, defaultConfigFile) =>
default: _.get(presets, "debug", false),
type: "boolean",
})
.options("theme", {
describe: "The theme to use, if a theme hasn't been set in the cookies",
default: _.get(presets, "theme", "classic-light"),
type: "string",
})
.options("wallet-url", {
describe: "The URL of the remote ECOin wallet",
default: _.get(presets, "walletUrl", "http://localhost:7474"),
type: "string",
})
.options("wallet-user", {
describe: "The username of the remote ECOin wallet",
default: _.get(presets, "walletUser", "ecoinrpc"),
type: "string",
})
.options("wallet-pass", {
describe: "The password of the remote ECOin wallet",
default: _.get(presets, "walletPass", "ecoinrpc"),
type: "string",
})
.options("wallet-fee", {
describe: "The fee to pay for ECOin transactions",
default: _.get(presets, "walletFee", "0.01"),
type: "string",
})
.epilog(`The defaults can be configured in ${defaultConfigFile}.`).argv;
module.exports = { cli };

View file

@ -6,53 +6,55 @@ const configFilePath = path.join(__dirname, 'oasis-config.json');
if (!fs.existsSync(configFilePath)) {
const defaultConfig = {
"themes": {
"current": "OasisMobile"
"current": "Dark-SNH"
},
"modules": {
"popularMod": "on",
"topicsMod": "off",
"summariesMod": "off",
"latestMod": "on",
"threadsMod": "on",
"multiverseMod": "on",
"invitesMod": "on",
"walletMod": "off",
"legacyMod": "off",
"cipherMod": "on",
"bookmarksMod": "on",
"videosMod": "on",
"docsMod": "on",
"audiosMod": "on",
"tagsMod": "on",
"imagesMod": "on",
"trendingMod": "on",
"eventsMod": "on",
"tasksMod": "off",
"marketMod": "on",
"votesMod": "on",
"tribesMod": "on",
"reportsMod": "off",
"opinionsMod": "on",
"transfersMod": "on",
"feedMod": "on",
"pixeliaMod": "off",
"agendaMod": "on",
"aiMod": "off",
"forumMod": "off",
"jobsMod": "on",
"projectsMod": "on",
"bankingMod": "on",
"parliamentMod": "off",
"courtsMod": "off",
"favoritesMod": "off",
"padsMod": "on",
"calendarsMod": "on",
"gamesMod": "on",
"shopsMod": "on",
"logsMod": "on",
"mapsMod": "on",
"chatsMod": "on",
"torrentsMod": "on"
"popularMod": "on",
"topicsMod": "on",
"summariesMod": "on",
"latestMod": "on",
"threadsMod": "on",
"multiverseMod": "on",
"invitesMod": "on",
"walletMod": "on",
"legacyMod": "on",
"cipherMod": "on",
"bookmarksMod": "on",
"videosMod": "on",
"docsMod": "on",
"audiosMod": "on",
"tagsMod": "on",
"imagesMod": "on",
"trendingMod": "on",
"eventsMod": "on",
"tasksMod": "on",
"marketMod": "on",
"votesMod": "on",
"tribesMod": "on",
"reportsMod": "on",
"opinionsMod": "on",
"padsMod": "on",
"calendarsMod": "on",
"transfersMod": "on",
"feedMod": "on",
"pixeliaMod": "on",
"agendaMod": "on",
"aiMod": "on",
"aiNavMod": "on",
"forumMod": "on",
"gamesMod": "on",
"jobsMod": "on",
"shopsMod": "on",
"projectsMod": "on",
"bankingMod": "on",
"parliamentMod": "on",
"courtsMod": "on",
"favoritesMod": "on",
"logsMod": "on",
"mapsMod": "on",
"chatsMod": "on",
"torrentsMod": "on",
"graphosMod": "on"
},
"wallet": {
"url": "http://localhost:7474",

View file

@ -1,17 +1,17 @@
{
"themes": {
"current": "OasisMobile"
"current": "Dark-SNH"
},
"modules": {
"popularMod": "on",
"topicsMod": "off",
"summariesMod": "off",
"topicsMod": "on",
"summariesMod": "on",
"latestMod": "on",
"threadsMod": "on",
"multiverseMod": "on",
"invitesMod": "on",
"walletMod": "off",
"legacyMod": "off",
"walletMod": "on",
"legacyMod": "on",
"cipherMod": "on",
"bookmarksMod": "on",
"videosMod": "on",
@ -21,32 +21,34 @@
"imagesMod": "on",
"trendingMod": "on",
"eventsMod": "on",
"tasksMod": "off",
"tasksMod": "on",
"marketMod": "on",
"votesMod": "on",
"tribesMod": "on",
"reportsMod": "off",
"reportsMod": "on",
"opinionsMod": "on",
"transfersMod": "on",
"feedMod": "on",
"pixeliaMod": "off",
"agendaMod": "on",
"aiMod": "off",
"forumMod": "off",
"jobsMod": "on",
"projectsMod": "on",
"bankingMod": "on",
"parliamentMod": "off",
"courtsMod": "off",
"favoritesMod": "off",
"padsMod": "on",
"calendarsMod": "on",
"transfersMod": "on",
"feedMod": "on",
"pixeliaMod": "on",
"agendaMod": "on",
"aiMod": "on",
"aiNavMod": "on",
"forumMod": "on",
"gamesMod": "on",
"jobsMod": "on",
"shopsMod": "on",
"projectsMod": "on",
"bankingMod": "on",
"parliamentMod": "on",
"courtsMod": "on",
"favoritesMod": "on",
"logsMod": "on",
"mapsMod": "on",
"chatsMod": "on",
"torrentsMod": "on"
"torrentsMod": "on",
"graphosMod": "on"
},
"wallet": {
"url": "http://localhost:7474",
@ -55,9 +57,7 @@
"fee": "5"
},
"walletPub": {
"url": "",
"user": "",
"pass": ""
"pubId": ""
},
"ai": {
"prompt": "Provide an informative and precise response."
@ -69,4 +69,4 @@
"language": "en",
"wish": "whole",
"pmVisibility": "whole"
}
}

View file

@ -1,7 +1,30 @@
const pull = require('../server/node_modules/pull-stream');
const ssbRef = require('../server/node_modules/ssb-ref');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeFeedId = (v) => {
if (typeof v === 'string' && ssbRef.isFeed(v)) return v;
if (v && typeof v === 'object' && typeof v.link === 'string' && ssbRef.isFeed(v.link)) return v.link;
return null;
};
const isContentSane = (c) => {
if (!c || typeof c !== 'object') return false;
if (c.type === 'contact') return !!safeFeedId(c.contact);
if (c.type === 'about') {
if (c.about === undefined) return true;
if (typeof c.about === 'string' && ssbRef.isFeed(c.about)) return true;
return false;
}
if (c.type === 'pub') {
const addr = c.address;
if (!addr || typeof addr !== 'object') return false;
return typeof addr.key === 'string' && ssbRef.isFeed(addr.key);
}
return true;
};
const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_');
const ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD'];
const ORDER_PROJECT = ['CANCELLED','PAUSED','ACTIVE','COMPLETED'];
@ -21,6 +44,10 @@ function inferType(c = {}) {
if (c.type === 'courts_nom_vote') return 'courtsNominationVote';
if (c.type === 'courts_public_pref') return 'courtsPublicPref';
if (c.type === 'courts_mediators') return 'courtsMediators';
if (c.type === 'map') return 'map';
if (c.type === 'mapMarker') return 'mapMarker';
if (c.type === 'chat') return 'chat';
if (c.type === 'chatMessage') return 'chatMessage';
if (c.type === 'vote' && c.vote && typeof c.vote.link === 'string') {
const br = Array.isArray(c.branch) ? c.branch : [];
if (br.includes(c.vote.link) && Number(c.vote.value) === 1) return 'spread';
@ -70,8 +97,10 @@ module.exports = ({ cooler }) => {
const c = v?.content;
if (!c?.type) continue;
if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue }
if (!isContentSane(c)) continue;
const ts = v?.timestamp || Number(c?.timestamp || 0) || (c?.updatedAt ? Date.parse(c.updatedAt) : 0) || 0;
idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: c });
const normalized = c.type === 'contact' ? { ...c, contact: safeFeedId(c.contact) } : c;
idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: normalized });
rawById.set(k, msg);
if (c.replaces) parentOf.set(k, c.replaces);
}
@ -220,148 +249,6 @@ module.exports = ({ cooler }) => {
}
}
if (type === 'tribe') {
const baseId = tip.id;
const baseTitle = (tip.content && tip.content.title) || '';
const isAnonymous = tip.content && typeof tip.content.isAnonymous === 'boolean' ? tip.content.isAnonymous : false;
const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
const toSet = (xs) => new Set(uniq(xs));
const excerpt2 = (s, max = 220) => {
const t = String(s || '').replace(/\s+/g, ' ').trim();
return t.length > max ? t.slice(0, max - 1) + '…' : t;
};
const feedMap = (feed) => {
const m = new Map();
for (const it of (Array.isArray(feed) ? feed : [])) {
if (!it || typeof it !== 'object') continue;
const id = typeof it.id === 'string' || typeof it.id === 'number' ? String(it.id) : '';
if (!id) continue;
m.set(id, it);
}
return m;
};
const sorted = arr
.filter(a => a.type === 'tribe' && a.content && typeof a.content === 'object')
.sort((a, b) => (a.ts || 0) - (b.ts || 0));
let prev = null;
for (const ev of sorted) {
if (!prev) { prev = ev; continue; }
const prevMembers = toSet(prev.content.members);
const curMembers = toSet(ev.content.members);
const added = Array.from(curMembers).filter(x => !prevMembers.has(x));
const removed = Array.from(prevMembers).filter(x => !curMembers.has(x));
for (const member of added) {
const overlayId = `${ev.id}:tribeJoin:${member}`;
idToAction.set(overlayId, {
id: overlayId,
author: member,
ts: ev.ts,
type: 'tribeJoin',
content: { type: 'tribeJoin', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member }
});
idToTipId.set(overlayId, overlayId);
}
for (const member of removed) {
const overlayId = `${ev.id}:tribeLeave:${member}`;
idToAction.set(overlayId, {
id: overlayId,
author: member,
ts: ev.ts,
type: 'tribeLeave',
content: { type: 'tribeLeave', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member }
});
idToTipId.set(overlayId, overlayId);
}
const prevFeed = feedMap(prev.content.feed);
const curFeed = feedMap(ev.content.feed);
for (const [fid, item] of curFeed.entries()) {
if (prevFeed.has(fid)) continue;
const feedAuthor = (item && typeof item.author === 'string' && item.author.trim().length) ? item.author : ev.author;
const overlayId = `${ev.id}:tribeFeedPost:${fid}:${feedAuthor}`;
idToAction.set(overlayId, {
id: overlayId,
author: feedAuthor,
ts: ev.ts,
type: 'tribeFeedPost',
content: {
type: 'tribeFeedPost',
tribeId: baseId,
tribeTitle: baseTitle,
isAnonymous,
feedId: fid,
date: item.date || ev.ts,
text: excerpt2(item.message || '')
}
});
idToTipId.set(overlayId, overlayId);
}
for (const [fid, curItem] of curFeed.entries()) {
const prevItem = prevFeed.get(fid);
if (!prevItem) continue;
const pInh = toSet(prevItem.refeeds_inhabitants);
const cInh = toSet(curItem.refeeds_inhabitants);
const newInh = Array.from(cInh).filter(x => !pInh.has(x));
const curRefeeds = Number(curItem.refeeds || 0);
const prevRefeeds = Number(prevItem.refeeds || 0);
const postText = excerpt2(curItem.message || '');
if (newInh.length) {
for (const who of newInh) {
const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`;
idToAction.set(overlayId, {
id: overlayId,
author: who,
ts: ev.ts,
type: 'tribeFeedRefeed',
content: {
type: 'tribeFeedRefeed',
tribeId: baseId,
tribeTitle: baseTitle,
isAnonymous,
feedId: fid,
text: postText
}
});
idToTipId.set(overlayId, overlayId);
}
} else if (curRefeeds > prevRefeeds && ev.author) {
const who = ev.author;
const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`;
idToAction.set(overlayId, {
id: overlayId,
author: who,
ts: ev.ts,
type: 'tribeFeedRefeed',
content: {
type: 'tribeFeedRefeed',
tribeId: baseId,
tribeTitle: baseTitle,
isAnonymous,
feedId: fid,
text: postText
}
});
idToTipId.set(overlayId, overlayId);
}
}
prev = ev;
}
}
continue;
}
@ -429,12 +316,13 @@ module.exports = ({ cooler }) => {
a.content.rootTitle = rootAction?.content?.title || a.content.rootTitle || '';
a.content.rootKey = rootId || a.content.rootKey || '';
}
latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id });
const actionRoot = rootOf(a.id);
latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id, rootId: actionRoot !== a.id ? actionRoot : null });
}
let deduped = latest.filter(a => !a.tipId || a.tipId === a.id || (a.type === 'tribe' && !parentOf.has(a.id)));
const mediaTypes = new Set(['image','video','audio','document','bookmark']);
const mediaTypes = new Set(['image','video','audio','document','bookmark','map']);
const perAuthorUnique = new Set(['karmaScore']);
const byKey = new Map();
const norm = s => String(s || '').trim().toLowerCase();
@ -488,14 +376,34 @@ module.exports = ({ cooler }) => {
deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
const tribeInternalTypes = new Set(['tribeLeave', 'tribeFeedPost', 'tribeFeedRefeed', 'tribe-content']);
const isAllowedTribeActivity = (a) => !tribeInternalTypes.has(a.type);
const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log']);
const isAllowedTribeActivity = (a) => {
if (tribeInternalTypes.has(a.type)) return false;
const c = a.content || {};
if (c.tribeId) return false;
if (a.type === 'tribe') {
if (c.isAnonymous === true) return false;
const isInitial = !c.replaces;
if (!isInitial) return false;
}
return true;
};
const isVisible = (a) => {
if (hiddenTypes.has(a.type)) return false;
if (a.type === 'pad' && (a.content || {}).status !== 'OPEN') return false;
if (a.type === 'chat' && (a.content || {}).status !== 'OPEN') return false;
if (a.type === 'calendar' && (a.content || {}).status !== 'OPEN') return false;
if (a.type === 'event' && String((a.content || {}).isPublic || '').toLowerCase() === 'private' && (a.content || {}).organizer !== userId && !(Array.isArray((a.content || {}).attendees) && (a.content || {}).attendees.includes(userId))) return false;
if (a.type === 'task' && String((a.content || {}).isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray((a.content || {}).assignees) && (a.content || {}).assignees.includes(userId))) return false;
return true;
};
let out;
if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a));
else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a)) }
else if (filter === 'all') out = deduped.filter(isAllowedTribeActivity);
else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim');
if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) }
else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim' || a.type === 'ubiclaimresult');
else if (filter === 'karma') out = deduped.filter(a => a.type === 'karmaScore');
else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');
@ -510,6 +418,10 @@ module.exports = ({ cooler }) => {
});
else if (filter === 'task')
out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
else if (filter === 'gameScore') out = deduped.filter(a => a.type === 'gameScore');
else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN');
else if (filter === 'chat') out = deduped.filter(a => a.type === 'chat' && (a.content || {}).status === 'OPEN');
else if (filter === 'calendar') out = deduped.filter(a => a.type === 'calendar' && (a.content || {}).status === 'OPEN');
else out = deduped.filter(a => a.type === filter);
out.sort((a, b) => (b.ts || 0) - (a.ts || 0));

View file

@ -713,35 +713,12 @@ async function getLastPublishedTimestamp(userId) {
if (!pubId) throw new Error("no_pub_configured");
const alreadyClaimed = await hasClaimedThisMonth(uid);
if (alreadyClaimed) throw new Error("already_claimed");
const karmaScore = await getUserEngagementScore(uid);
const wMin = DEFAULT_RULES.caps.w_min;
const wMax = DEFAULT_RULES.caps.w_max;
const capUser = DEFAULT_RULES.caps.cap_user_epoch;
const userW = clamp(1 + karmaScore / 100, wMin, wMax);
const amount = Number(Math.max(1, Math.min(capUser * (userW / wMax), capUser)).toFixed(6));
const ssb = await openSsb();
if (!ssb || !ssb.publish) throw new Error("ssb_unavailable");
const now = new Date().toISOString();
const transferContent = {
type: "transfer",
from: pubId,
to: uid,
concept: `UBI ${epochId} ${uid}`,
amount: String(amount),
createdAt: now,
updatedAt: now,
deadline: null,
confirmedBy: [pubId],
status: "UNCONFIRMED",
tags: ["UBI", "PENDING"],
opinions: {},
opinions_inhabitants: []
};
const transferRes = await new Promise((resolve, reject) => ssb.publish(transferContent, (err, res) => err ? reject(err) : resolve(res)));
const transferId = transferRes?.key || "";
const claimContent = { type: "ubiClaim", pubId, amount, epochId, claimedAt: now, transferId };
const claimContent = { type: "ubiClaim", pubId, epochId, claimedAt: now };
await new Promise((resolve, reject) => ssb.publish(claimContent, (err, res) => err ? reject(err) : resolve(res)));
return { status: "claimed_pending", amount, epochId };
return { status: "claimed_pending", epochId };
}
async function updateAllocationStatus(allocationId, status, txid) {

View file

@ -1,6 +1,8 @@
const pull = require("../server/node_modules/pull-stream")
const crypto = require("crypto")
const { getConfig } = require("../configs/config-manager.js")
const logLimit = getConfig().ssbLogStream?.limit || 1000
const INVITE_CODE_BYTES = 16
const safeText = (v) => String(v || "").trim()
const normalizeTags = (raw) => {
@ -43,7 +45,46 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c
const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c
const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {}
const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {}
const encryptStandalone = (content, rootId) => {
if (!tribeCrypto || !rootId) return content
const key = tribeCrypto.getKey(rootId)
if (!key) return content
return tribeCrypto.encryptContent(content, [key], true)
}
const decryptCalendarRoot = (content, rootId) => {
if (!content || !content.encryptedPayload) return content
if (!tribeCrypto) return content
const keys = tribeCrypto.getKeys(rootId)
if (!keys || !keys.length) return { ...content, _undecryptable: true }
return tribeCrypto.decryptContent(content, keys.map(k => [k]))
}
const decryptIndexNodes = async (idx) => {
if (!tribeCrypto) return
for (const [k, n] of idx.nodes.entries()) {
if (!n.c || !n.c.encryptedPayload) continue
let root = k
while (idx.parent.has(root)) root = idx.parent.get(root)
let dec = null
if (n.c.tribeId && tribesModel) {
try {
const r = await tribeCrypto.decryptFromTribe(n.c, tribesModel)
if (r && !r._undecryptable) dec = r
} catch (_) {}
}
if (!dec) {
const r = decryptCalendarRoot(n.c, root)
if (r && !r._undecryptable) dec = r
}
if (dec) {
idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } })
} else {
idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } })
}
}
}
const buildIndex = (messages) => {
const tomb = new Set()
@ -95,6 +136,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
tags: Array.isArray(c.tags) ? c.tags : [],
author: c.author || node.author,
participants: Array.isArray(c.participants) ? c.participants : [],
invites: Array.isArray(c.invites) ? c.invites : [],
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
tribeId: c.tribeId || null,
@ -143,7 +185,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
if (deadline && new Date(deadline).getTime() <= Date.now()) throw new Error("Deadline must be in the future")
if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future")
let content = {
let plainContent = {
type: "calendar",
title: safeText(title),
status: validStatus,
@ -151,53 +193,97 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
tags: normalizeTags(tags),
author: userId,
participants: [userId],
invites: [],
createdAt: now,
updatedAt: now,
...(tribeId ? { tribeId } : {})
}
content = await encryptIfTribe(content)
let calKey = null
let content = plainContent
if (tribeId) {
content = await encryptIfTribe(plainContent)
} else if (tribeCrypto) {
calKey = tribeCrypto.generateTribeKey()
content = tribeCrypto.encryptContent(plainContent, [calKey], true)
}
const calMsg = await new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
const calendarId = calMsg.key
const dates = expandRecurrence(firstDate, deadline, intervalWeekly, intervalMonthly, intervalYearly)
const allDateMsgs = []
for (const d of dates) {
let dateContent = {
type: "calendarDate",
if (calKey && tribeCrypto) {
tribeCrypto.setKey(calendarId, calKey, 1)
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const boxedKey = tribeCrypto.boxKeyForMember(calKey, userId, ssbKeys)
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: calendarId, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve())
})
} catch (_) {}
if (validStatus === "OPEN") {
try {
const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
const ek = tribeCrypto.encryptForInvite(calKey, pubCode)
const tipId = await this.resolveCurrentId(calendarId)
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
const dec = decryptCalendarRoot(item.content, calendarId)
let updated = {
type: "calendar",
title: dec.title || "",
status: validStatus,
deadline: dec.deadline || "",
tags: Array.isArray(dec.tags) ? dec.tags : [],
author: userId,
participants: [userId],
invites: [{ code: pubCode, ek, gen: 1, public: true }],
createdAt: dec.createdAt,
updatedAt: new Date().toISOString(),
replaces: tipId
}
updated = encryptStandalone(updated, calendarId)
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve()))
} catch (_) {}
}
}
let dateContent = {
type: "calendarDate",
calendarId,
date: new Date(firstDate).toISOString(),
label: safeText(firstDateLabel),
author: userId,
createdAt: new Date().toISOString(),
...(intervalWeekly ? { intervalWeekly: true } : {}),
...(intervalMonthly ? { intervalMonthly: true } : {}),
...(intervalYearly ? { intervalYearly: true } : {}),
...(deadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? { intervalDeadline: deadline } : {}),
...(tribeId ? { tribeId } : {})
}
if (tribeId) dateContent = await encryptIfTribe(dateContent)
else if (calKey) dateContent = tribeCrypto.encryptContent(dateContent, [calKey], true)
const dateMsg = await new Promise((resolve, reject) => {
ssbClient.publish(dateContent, (err, msg) => err ? reject(err) : resolve(msg))
})
if (firstNote && safeText(firstNote)) {
let noteContent = {
type: "calendarNote",
calendarId,
date: d.toISOString(),
label: safeText(firstDateLabel),
dateId: dateMsg.key,
text: safeText(firstNote),
author: userId,
createdAt: new Date().toISOString(),
...(tribeId ? { tribeId } : {})
}
dateContent = await encryptIfTribe(dateContent)
const dateMsg = await new Promise((resolve, reject) => {
ssbClient.publish(dateContent, (err, msg) => err ? reject(err) : resolve(msg))
if (tribeId) noteContent = await encryptIfTribe(noteContent)
else if (calKey) noteContent = tribeCrypto.encryptContent(noteContent, [calKey], true)
await new Promise((resolve, reject) => {
ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
})
allDateMsgs.push(dateMsg)
}
if (firstNote && safeText(firstNote) && allDateMsgs.length > 0) {
for (const dateMsg of allDateMsgs) {
let noteContent = {
type: "calendarNote",
calendarId,
dateId: dateMsg.key,
text: safeText(firstNote),
author: userId,
createdAt: new Date().toISOString(),
...(tribeId ? { tribeId } : {})
}
noteContent = await encryptIfTribe(noteContent)
await new Promise((resolve, reject) => {
ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
})
}
}
return calMsg
@ -205,13 +291,16 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
async updateCalendarById(id, data) {
const tipId = await this.resolveCurrentId(id)
const rootId = await this.resolveRootId(id)
const ssbClient = await openSsb()
const userId = ssbClient.id
const item = await new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, it) => err ? reject(err) : resolve(it))
})
if (!item || !item.content) throw new Error("Calendar not found")
const oldDec = await decryptIfTribe(item.content)
const oldDec = item.content.tribeId
? await decryptIfTribe(item.content)
: decryptCalendarRoot(item.content, rootId)
assertReadable(oldDec, "Calendar")
if ((oldDec.author || item.content.author) !== userId) throw new Error("Not the author")
let updated = {
@ -222,12 +311,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
tags: data.tags !== undefined ? normalizeTags(data.tags) : (Array.isArray(oldDec.tags) ? oldDec.tags : []),
author: oldDec.author || userId,
participants: oldDec.participants || [userId],
invites: Array.isArray(oldDec.invites) ? oldDec.invites : [],
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
createdAt: oldDec.createdAt,
updatedAt: new Date().toISOString(),
replaces: tipId
}
updated = await encryptIfTribe(updated)
if (item.content.tribeId) updated = await encryptIfTribe(updated)
else updated = encryptStandalone(updated, rootId)
const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
@ -242,21 +333,29 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
if (!item || !item.content) throw new Error("Calendar not found")
const dec = await decryptIfTribe(item.content)
assertReadable(dec, "Calendar")
if ((dec.author || item.content.author) !== userId) throw new Error("Not the author")
const contentAuthor = (dec && dec.author) || (typeof item.content === 'object' && item.content.author)
if (contentAuthor !== userId) throw new Error("Not the author")
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
return new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
},
async joinCalendar(calendarId) {
const tipId = await this.resolveCurrentId(calendarId)
const rootId = await this.resolveRootId(calendarId)
const ssbClient = await openSsb()
const userId = ssbClient.id
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
if (!item || !item.content) throw new Error("Calendar not found")
const dec = await decryptIfTribe(item.content)
const dec = item.content.tribeId
? await decryptIfTribe(item.content)
: decryptCalendarRoot(item.content, rootId)
assertReadable(dec, "Calendar")
const participants = Array.isArray(dec.participants) ? dec.participants : []
if (participants.includes(userId)) return
if (tribeCrypto && Array.isArray(dec.invites)) {
const pub = dec.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain))
if (pub) return await this.joinByInvite(pub.code)
}
let updated = {
type: "calendar",
title: dec.title || "",
@ -265,12 +364,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
tags: Array.isArray(dec.tags) ? dec.tags : [],
author: dec.author,
participants: [...participants, userId],
invites: Array.isArray(dec.invites) ? dec.invites : [],
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
createdAt: dec.createdAt,
updatedAt: new Date().toISOString(),
replaces: tipId
}
updated = await encryptIfTribe(updated)
if (item.content.tribeId) updated = await encryptIfTribe(updated)
else updated = encryptStandalone(updated, rootId)
const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
@ -279,11 +380,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
async leaveCalendar(calendarId) {
const tipId = await this.resolveCurrentId(calendarId)
const rootId = await this.resolveRootId(calendarId)
const ssbClient = await openSsb()
const userId = ssbClient.id
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
if (!item || !item.content) throw new Error("Calendar not found")
const dec = await decryptIfTribe(item.content)
const dec = item.content.tribeId
? await decryptIfTribe(item.content)
: decryptCalendarRoot(item.content, rootId)
assertReadable(dec, "Calendar")
if ((dec.author || item.content.author) === userId) throw new Error("Author cannot leave")
const participants = Array.isArray(dec.participants) ? dec.participants : []
@ -296,12 +400,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
tags: Array.isArray(dec.tags) ? dec.tags : [],
author: dec.author,
participants: participants.filter(p => p !== userId),
invites: Array.isArray(dec.invites) ? dec.invites : [],
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
createdAt: dec.createdAt,
updatedAt: new Date().toISOString(),
replaces: tipId
}
updated = await encryptIfTribe(updated)
if (item.content.tribeId) updated = await encryptIfTribe(updated)
else updated = encryptStandalone(updated, rootId)
const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
@ -362,30 +468,33 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
if (cal.status === "CLOSED" && userId !== cal.author) throw new Error("Only the author can add dates to a CLOSED calendar")
if (!date || new Date(date).getTime() <= Date.now()) throw new Error("Date must be in the future")
const deadlineForExpansion = (intervalDeadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)) ? intervalDeadline : cal.deadline
const dates = expandRecurrence(date, deadlineForExpansion, intervalWeekly, intervalMonthly, intervalYearly)
const allMsgs = []
for (const d of dates) {
let dateContent = {
type: "calendarDate",
calendarId: rootId,
date: d.toISOString(),
label: safeText(label),
author: userId,
createdAt: new Date().toISOString(),
...(cal.tribeId ? { tribeId: cal.tribeId } : {})
}
dateContent = await encryptIfTribe(dateContent)
const msg = await new Promise((resolve, reject) => {
ssbClient.publish(dateContent, (err, m) => err ? reject(err) : resolve(m))
})
allMsgs.push(msg)
const hasInterval = hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)
const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : ""
let dateContent = {
type: "calendarDate",
calendarId: rootId,
date: new Date(date).toISOString(),
label: safeText(label),
author: userId,
createdAt: new Date().toISOString(),
...(intervalWeekly ? { intervalWeekly: true } : {}),
...(intervalMonthly ? { intervalMonthly: true } : {}),
...(intervalYearly ? { intervalYearly: true } : {}),
...(ruleDeadline ? { intervalDeadline: ruleDeadline } : {}),
...(cal.tribeId ? { tribeId: cal.tribeId } : {})
}
return allMsgs
if (cal.tribeId) dateContent = await encryptIfTribe(dateContent)
else dateContent = encryptStandalone(dateContent, rootId)
const msg = await new Promise((resolve, reject) => {
ssbClient.publish(dateContent, (err, m) => err ? reject(err) : resolve(m))
})
return [msg]
},
async getDatesForCalendar(calendarId) {
const rootId = await this.resolveRootId(calendarId)
const cal = await this.getCalendarById(rootId)
const calDeadline = cal && cal.deadline ? cal.deadline : ""
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const authorByKey = new Map()
@ -411,14 +520,23 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
dec = r && !r._undecryptable ? r : c
if (r && r._undecryptable) continue
}
dates.push({
const baseEntry = {
key: m.key,
calendarId: dec.calendarId || c.calendarId,
date: dec.date,
label: dec.label || "",
author: dec.author || v.author,
createdAt: dec.createdAt || new Date(v.timestamp || 0).toISOString()
})
}
const hasInterval = !!(dec.intervalWeekly || dec.intervalMonthly || dec.intervalYearly)
const ruleDeadline = dec.intervalDeadline || calDeadline
if (hasInterval && ruleDeadline) {
const occurrences = expandRecurrence(dec.date, ruleDeadline, dec.intervalWeekly, dec.intervalMonthly, dec.intervalYearly)
for (const occ of occurrences) {
dates.push({ ...baseEntry, date: occ.toISOString() })
}
} else {
dates.push({ ...baseEntry, date: dec.date })
}
}
dates.sort((a, b) => new Date(a.date) - new Date(b.date))
return dates
@ -487,7 +605,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
createdAt: new Date().toISOString(),
...(cal.tribeId ? { tribeId: cal.tribeId } : {})
}
noteContent = await encryptIfTribe(noteContent)
if (cal.tribeId) noteContent = await encryptIfTribe(noteContent)
else noteContent = encryptStandalone(noteContent, rootId)
return new Promise((resolve, reject) => {
ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
})
@ -555,7 +674,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
for (const m of messages) {
const c = (m.value || {}).content
if (!c || c.type !== "calendarReminderSent") continue
sentMarkers.add(`${c.calendarId}::${c.dateId}`)
const sig = c.occurrence ? `${c.calendarId}::${c.dateId}::${c.occurrence}` : `${c.calendarId}::${c.dateId}`
sentMarkers.add(sig)
}
const authorByKey = new Map()
@ -569,6 +689,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
}
}
const calendarDeadlines = new Map()
const dueByCalendar = new Map()
for (const m of messages) {
if (tombstoned.has(m.key)) continue
@ -581,21 +702,42 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
if (!r || r._undecryptable) continue
dec = r
}
if (!dec.date || new Date(dec.date).getTime() > now) continue
if (sentMarkers.has(`${c.calendarId}::${m.key}`)) continue
const entry = { key: m.key, calendarId: c.calendarId, date: dec.date, label: dec.label || "" }
const list = dueByCalendar.get(c.calendarId) || []
list.push(entry)
dueByCalendar.set(c.calendarId, list)
if (!dec.date) continue
const calId = c.calendarId
let calDeadline = calendarDeadlines.get(calId)
if (calDeadline === undefined) {
try {
const cc = await this.getCalendarById(calId)
calDeadline = (cc && cc.deadline) || ""
} catch (_) { calDeadline = "" }
calendarDeadlines.set(calId, calDeadline)
}
const hasInterval = !!(dec.intervalWeekly || dec.intervalMonthly || dec.intervalYearly)
const ruleDeadline = dec.intervalDeadline || calDeadline
const occurrences = (hasInterval && ruleDeadline)
? expandRecurrence(dec.date, ruleDeadline, dec.intervalWeekly, dec.intervalMonthly, dec.intervalYearly)
: [new Date(dec.date)]
for (const occ of occurrences) {
if (occ.getTime() > now) continue
const occIso = occ.toISOString()
const sig = hasInterval ? `${calId}::${m.key}::${occIso}` : `${calId}::${m.key}`
if (sentMarkers.has(sig)) continue
const entry = { key: m.key, calendarId: calId, date: occIso, label: dec.label || "", recurring: hasInterval }
const list = dueByCalendar.get(calId) || []
list.push(entry)
dueByCalendar.set(calId, list)
}
}
const publishMarker = (calendarId, dateId) => new Promise((resolve, reject) => {
ssbClient.publish({
const publishMarker = (calendarId, dateId, occurrence) => new Promise((resolve, reject) => {
const payload = {
type: "calendarReminderSent",
calendarId,
dateId,
sentAt: new Date().toISOString()
}, (err) => err ? reject(err) : resolve())
}
if (occurrence) payload.occurrence = occurrence
ssbClient.publish(payload, (err) => err ? reject(err) : resolve())
})
for (const [calendarId, list] of dueByCalendar.entries()) {
@ -623,10 +765,134 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
}
}
for (const dd of list) {
try { await publishMarker(calendarId, dd.key) } catch (_) {}
try { await publishMarker(calendarId, dd.key, dd.recurring ? dd.date : null) } catch (_) {}
}
} catch (_) {}
}
},
async generateInvite(calendarId) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const cal = await this.getCalendarById(calendarId)
if (!cal) throw new Error("Calendar not found")
if (cal.author !== userId) throw new Error("Only the author can generate invites")
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
let invite = code
if (tribeCrypto && !cal.tribeId) {
const ekChain = tribeCrypto.encryptChainForInvite([cal.rootId], code)
if (ekChain) invite = { code, ekChain, gen: tribeCrypto.getGen(cal.rootId) }
}
const tipId = await this.resolveCurrentId(calendarId)
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
const dec = item.content.tribeId
? await decryptIfTribe(item.content)
: decryptCalendarRoot(item.content, cal.rootId)
const invites = [...(Array.isArray(dec.invites) ? dec.invites : []), invite]
let updated = {
type: "calendar",
title: dec.title || "",
status: dec.status || "OPEN",
deadline: dec.deadline || "",
tags: Array.isArray(dec.tags) ? dec.tags : [],
author: dec.author,
participants: Array.isArray(dec.participants) ? dec.participants : [userId],
invites,
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
createdAt: dec.createdAt,
updatedAt: new Date().toISOString(),
replaces: tipId
}
if (item.content.tribeId) updated = await encryptIfTribe(updated)
else updated = encryptStandalone(updated, cal.rootId)
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
return code
},
async joinByInvite(code) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const calendars = await this.listAll()
let matched = null
let matchedInvite = null
for (const cal of calendars) {
const invs = Array.isArray(cal.invites) ? cal.invites : []
for (const inv of invs) {
if (typeof inv === "string" && inv === code) { matched = cal; matchedInvite = inv; break }
if (typeof inv === "object" && inv.code === code) { matched = cal; matchedInvite = inv; break }
}
if (matched) break
}
if (!matched) throw new Error("Invalid or expired invite code")
if (matched.participants.includes(userId)) throw new Error("Already a participant")
let calKey = null
if (tribeCrypto && typeof matchedInvite === "object") {
if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code)
if (Array.isArray(chain) && chain.length) {
for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) {
tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length)
} else if (entry.key) {
tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1)
}
}
calKey = chain[0].key
}
} else if (matchedInvite.ek) {
calKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code)
tribeCrypto.setKey(matched.rootId, calKey, matchedInvite.gen || 1)
}
}
const tipId = await this.resolveCurrentId(matched.rootId)
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
const dec = item.content.tribeId
? await decryptIfTribe(item.content)
: decryptCalendarRoot(item.content, matched.rootId)
const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true
const invites = isPublicInvite
? (Array.isArray(dec.invites) ? dec.invites : [])
: (Array.isArray(dec.invites) ? dec.invites : []).filter(inv => {
if (typeof inv === "string") return inv !== code
return inv.code !== code
})
let updated = {
type: "calendar",
title: dec.title || "",
status: dec.status || "OPEN",
deadline: dec.deadline || "",
tags: Array.isArray(dec.tags) ? dec.tags : [],
author: dec.author,
participants: [...(Array.isArray(dec.participants) ? dec.participants : []), userId],
invites,
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
createdAt: dec.createdAt,
updatedAt: new Date().toISOString(),
replaces: tipId
}
if (item.content.tribeId) updated = await encryptIfTribe(updated)
else updated = encryptStandalone(updated, matched.rootId)
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
if (tribeCrypto && calKey) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const memberKeys = {}
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(calKey, userId, ssbKeys) } catch (_) {}
if (matched.author && matched.author !== userId) {
try { memberKeys[matched.author] = tribeCrypto.boxKeyForMember(calKey, matched.author, ssbKeys) } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: matched.rootId, generation: tribeCrypto.getGen(matched.rootId) || 1, memberKeys }, () => resolve())
})
}
} catch (_) {}
}
return matched.rootId
}
}
}

View file

@ -192,18 +192,47 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
...(tribeId ? { tribeId } : {})
}
if (tribeCrypto && !tribeId) {
const chatKey = tribeCrypto.generateTribeKey()
const result = await new Promise((resolve, reject) => {
if (!tribeCrypto) {
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
tribeCrypto.setKey(result.key, chatKey, 1)
return result
}
return new Promise((resolve, reject) => {
if (tribeId) {
try {
const ancestryIds = await tribesModel.getAncestryChain(tribeId)
const chain = []
for (const rid of ancestryIds || []) {
const k = tribeCrypto.getKey(rid)
if (!k) { chain.length = 0; break }
chain.push(k)
}
if (chain.length) content = tribeCrypto.encryptContent(content, chain, true)
} catch (_) {}
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
}
const chatKey = tribeCrypto.generateTribeKey()
if (st === "OPEN") {
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
const ek = tribeCrypto.encryptForInvite(chatKey, code)
content.invites = [{ code, ek, gen: 1, public: true }]
}
content = tribeCrypto.encryptContent(content, [chatKey], true)
const result = await new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
tribeCrypto.setKey(result.key, chatKey, 1)
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const boxedKey = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys)
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve())
})
} catch (_) {}
return result
},
async updateChatById(id, data, { skipAuthorCheck = false } = {}) {
@ -211,40 +240,60 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
const ssbClient = await openSsb()
const userId = ssbClient.id
const item = await new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => err || !item?.content ? reject(new Error("Chat not found")) : resolve(item))
})
const c = item.content
const rawAuthor = c.author || (c.encryptedPayload ? null : undefined)
if (!skipAuthorCheck && rawAuthor && rawAuthor !== userId) throw new Error("Not the author")
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
let rootId = tipId
while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
const node = { key: tipId, c, author: item.author, ts: item.timestamp || 0 }
const chat = buildChat(node, rootId)
if (!chat) throw new Error("Invalid chat")
let updated = {
type: "chat",
replaces: tipId,
title: data.title !== undefined ? safeText(data.title) : chat.title,
description: data.description !== undefined ? safeText(data.description) : chat.description,
image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : chat.image) : chat.image,
category: data.category !== undefined ? safeText(data.category) : chat.category,
status: data.status !== undefined ? (VALID_STATUS.includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : chat.status) : chat.status,
tags: data.tags !== undefined ? normalizeTags(data.tags) : chat.tags,
members: data.members !== undefined ? safeArr(data.members) : chat.members,
invites: data.invites !== undefined ? safeArr(data.invites) : chat.invites,
author: chat.author,
createdAt: chat.createdAt,
updatedAt: new Date().toISOString(),
...(chat.tribeId ? { tribeId: chat.tribeId } : {})
}
if (tribeCrypto) {
if (chat.tribeId) {
try {
const ancestryIds = await tribesModel.getAncestryChain(chat.tribeId)
const chain = []
for (const rid of ancestryIds || []) {
const k = tribeCrypto.getKey(rid)
if (!k) { chain.length = 0; break }
chain.push(k)
}
if (chain.length) updated = tribeCrypto.encryptContent(updated, chain, true)
} catch (_) {}
} else {
const chatKey = tribeCrypto.getKey(rootId)
if (chatKey) updated = tribeCrypto.encryptContent(updated, [chatKey], true)
}
}
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item?.content) return reject(new Error("Chat not found"))
const c = item.content
const rawAuthor = c.author || (c.encryptedPayload ? null : undefined)
if (!skipAuthorCheck && rawAuthor && rawAuthor !== userId) return reject(new Error("Not the author"))
const rootId = tipId
const messages = []
const node = { key: tipId, c, author: item.author, ts: item.timestamp || 0 }
const chat = buildChat(node, rootId)
if (!chat) return reject(new Error("Invalid chat"))
const updated = {
type: "chat",
replaces: tipId,
title: data.title !== undefined ? safeText(data.title) : chat.title,
description: data.description !== undefined ? safeText(data.description) : chat.description,
image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : chat.image) : chat.image,
category: data.category !== undefined ? safeText(data.category) : chat.category,
status: data.status !== undefined ? (VALID_STATUS.includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : chat.status) : chat.status,
tags: data.tags !== undefined ? normalizeTags(data.tags) : chat.tags,
members: data.members !== undefined ? safeArr(data.members) : chat.members,
invites: data.invites !== undefined ? safeArr(data.invites) : chat.invites,
author: chat.author,
createdAt: chat.createdAt,
updatedAt: new Date().toISOString()
}
ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e1) => {
if (e1) return reject(e1)
ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
})
ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e1) => {
if (e1) return reject(e1)
ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
})
})
},
@ -372,10 +421,15 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
let invite = code
if (tribeCrypto) {
const chatKey = tribeCrypto.getKey(chat.rootId)
if (chatKey) {
const ek = tribeCrypto.encryptForInvite(chatKey, code)
invite = { code, ek, gen: tribeCrypto.getGen(chat.rootId) }
const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code)
if (ekChain) {
invite = { code, ekChain, gen: tribeCrypto.getGen(chat.rootId) }
} else {
const chatKey = tribeCrypto.getKey(chat.rootId)
if (chatKey) {
const ek = tribeCrypto.encryptForInvite(chatKey, code)
invite = { code, ek, gen: tribeCrypto.getGen(chat.rootId) }
}
}
}
@ -414,18 +468,51 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
if (!matchedChat) throw new Error("Invalid or expired invite code")
if (matchedChat.members.includes(userId)) throw new Error("Already a participant")
if (tribeCrypto && typeof matchedInvite === "object" && matchedInvite.ek) {
const chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code)
tribeCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1)
let chatKey = null
if (tribeCrypto && typeof matchedInvite === "object") {
if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code)
if (Array.isArray(chain) && chain.length) {
for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) {
tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length)
} else if (entry.key) {
tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1)
}
}
chatKey = chain[0].key
}
} else if (matchedInvite.ek) {
chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code)
tribeCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1)
}
}
const members = [...matchedChat.members, userId]
const invites = matchedChat.invites.filter(inv => {
const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true
const invites = isPublicInvite ? matchedChat.invites : matchedChat.invites.filter(inv => {
if (typeof inv === "string") return inv !== code
return inv.code !== code
})
await this.updateChatById(matchedChat.key, { members, invites, status: matchedChat.status, title: matchedChat.title, description: matchedChat.description, image: matchedChat.image, category: matchedChat.category, tags: matchedChat.tags }, { skipAuthorCheck: true })
if (tribeCrypto && chatKey) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const memberKeys = {}
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys) } catch (_) {}
if (matchedChat.author && matchedChat.author !== userId) {
try { memberKeys[matchedChat.author] = tribeCrypto.boxKeyForMember(chatKey, matchedChat.author, ssbKeys) } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: matchedChat.rootId, generation: tribeCrypto.getGen(matchedChat.rootId) || 1, memberKeys }, () => resolve())
})
}
} catch (_) {}
}
return matchedChat.key
},
@ -437,17 +524,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
if (chat.status === "CLOSED") throw new Error("Chat is closed")
if (chat.members.includes(userId)) return chat.key
const members = [...chat.members, userId]
if (tribeCrypto) {
const chatKey = tribeCrypto.getKey(chat.rootId)
if (chatKey && ssbClient.keys) {
try {
tribeCrypto.boxKeyForMember(chatKey, userId, ssbClient.keys)
} catch (_) {}
}
if (tribeCrypto && Array.isArray(chat.invites)) {
const pub = chat.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain))
if (pub) return await this.joinByInvite(pub.code)
}
const members = [...chat.members, userId]
await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags }, { skipAuthorCheck: true })
return chat.key
},

View file

@ -14,6 +14,7 @@ const fs = require('fs/promises');
const os = require('os');
const ssbRef = require("../server/node_modules/ssb-ref");
const nameCache = require('../backend/nameCache');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
@ -63,7 +64,7 @@ const publicOnlyFilter = pull.filter(isNotPrivate);
const configure = (...customOptions) =>
Object.assign({}, defaultOptions, ...customOptions);
// peers
// PEERS
const ebtDir = path.join(os.homedir(), '.ssb', 'ebt');
const unfollowedPath = path.join(os.homedir(), '.ssb', 'gossip_unfollowed.json');
@ -117,13 +118,10 @@ const canonicalizePubId = (s) => {
};
const parseRemote = (remote) => {
// net: format (TCP)
let m = /^net:([^:]+):\d+~shs:([^=]+)=/.exec(remote);
if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) };
// ws/wss format (WebSocket)
m = /^wss?:\/\/([^:/]+)(?::\d+)?.*~shs:([^=]+)=/.exec(remote);
if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) };
// Generic: extract ~shs: part from any format
m = /~shs:([^=]+)=/.exec(remote);
if (m) return { host: null, pubId: canonicalizePubId(m[1]) };
return { host: null, pubId: null };
@ -161,7 +159,7 @@ function toLegacyInvite(s) {
return `${m[1]}:${m[2]}:@${key}~${m[4]}`;
}
// core modules
// CORE MODEL
module.exports = ({ cooler, isPublic }) => {
const models = {};
const getAbout = async ({ key, feedId }) => {
@ -290,7 +288,7 @@ module.exports = ({ cooler, isPublic }) => {
);
};
//ABOUT MODEL
// ABOUT MODEL
models.about = {
publicWebHosting: async (feedId) => {
const result = await getAbout({
@ -303,12 +301,16 @@ models.about = {
if (isPublic && (await models.about.publicWebHosting(feedId)) === false) {
return "Redacted";
}
return (
(await getAbout({
key: "name",
feedId,
})) || feedId.slice(1, 1 + 8)
);
const resolved = await getAbout({ key: "name", feedId });
if (resolved) nameCache.set(feedId, resolved, Date.now());
return resolved || feedId.slice(1, 1 + 8);
},
nameSync: (feedId) => {
if (!feedId) return null;
const cached = nameCache.get(feedId);
if (cached) return cached;
const local = feeds_to_name[feedId];
return local && local.name ? local.name : null;
},
named: (name) => {
let found = [];
@ -397,9 +399,11 @@ models.about = {
if (typeof currentEntry == "undefined") {
dirty = true;
feeds_to_name[feed] = newEntry;
nameCache.set(feed, name, ts);
} else if (currentEntry.ts < ts) {
dirty = true;
feeds_to_name[feed] = newEntry;
nameCache.set(feed, name, ts);
}
}, (err) => {
console.error(err);
@ -476,7 +480,7 @@ models.blob = {
}
};
//FRIENDS MODEL
// FRIENDS MODEL
models.friend = {
setRelationship: async ({ feedId, following, blocking }) => {
if (following && blocking) {
@ -566,7 +570,7 @@ models.friend = {
},
};
//META MODEL
// META MODEL
models.meta = {
myFeedId: async () => {
const ssb = await cooler.open();
@ -601,14 +605,97 @@ models.meta = {
pull.take(1),
pull.collect((err, [entries]) => {
if (err) return reject(err);
resolve(entries);
resolve(entries || []);
})
);
});
},
connectedPeers: async () => {
const peers = await models.meta.peers();
return peers.filter(([_, data]) => data.state === "connected");
const ssb = await cooler.open();
const connEntries = await models.meta.peers();
const seen = new Set();
const result = [];
const lookupAddr = (key) => {
for (const [addr, data] of connEntries) {
if (data && data.key === key) return addr;
}
return null;
};
const lookupConnData = (key) => {
for (const [, data] of connEntries) {
if (data && data.key === key) return data;
}
return null;
};
try {
const livePeers = ssb && ssb.peers && typeof ssb.peers === "object" ? ssb.peers : {};
for (const rawKey of Object.keys(livePeers)) {
if (!rawKey || rawKey === ssb.id) continue;
const rpcs = livePeers[rawKey];
if (!Array.isArray(rpcs) || rpcs.length === 0) continue;
const key = canonicalizePubId(rawKey);
if (seen.has(key)) continue;
seen.add(key);
const existing = lookupConnData(key) || {};
const addr = (rpcs[0] && rpcs[0].stream && rpcs[0].stream.address) || lookupAddr(key) || `live:${key}`;
result.push([addr, { ...existing, key, state: "connected", source: "rpc" }]);
}
} catch {}
for (const [addr, data] of connEntries) {
if (!data || data.state !== "connected" || !data.key || seen.has(data.key)) continue;
seen.add(data.key);
result.push([addr, data]);
}
try {
const gp = ssb.gossip && typeof ssb.gossip.peers === "function" ? ssb.gossip.peers() : [];
const RECENT_MS = 30 * 60 * 1000;
const now = Date.now();
for (const p of (gp || [])) {
if (!p || !p.key) continue;
const key = canonicalizePubId(p.key);
if (seen.has(key)) continue;
const isConnected = p.state === "connected";
const recentlyConnected =
!isConnected &&
(p.failure === 0 || p.failure === undefined || p.failure === null) &&
typeof p.stateChange === "number" &&
(now - p.stateChange) < RECENT_MS;
if (!isConnected && !recentlyConnected) continue;
let addr = p.address;
if (!addr && p.host && p.port) {
const core = String(p.key).replace(/^@/, "").replace(/\.ed25519$/, "");
addr = `net:${p.host}:${p.port}~shs:${core}`;
}
if (!addr) continue;
seen.add(key);
result.push([addr, { ...p, key, state: "connected", source: isConnected ? "gossip" : "recent" }]);
}
} catch {}
try {
const myId = ssb.id;
const status = ssb.ebt && typeof ssb.ebt.peerStatus === "function" ? ssb.ebt.peerStatus(myId) : null;
const ebtPeers = (status && status.peers) ? Object.keys(status.peers) : [];
for (const rawKey of ebtPeers) {
if (!rawKey) continue;
const key = canonicalizePubId(rawKey);
if (seen.has(key)) continue;
let addr = lookupAddr(key);
if (!addr) {
const core = String(key).replace(/^@/, "").replace(/\.ed25519$/, "");
addr = `ebt:${core}`;
}
seen.add(key);
result.push([addr, { key, state: "connected", source: "ebt" }]);
}
} catch {}
return result;
},
onlinePeers: async () => {
const entries = await models.meta.connectedPeers();
@ -617,7 +704,6 @@ models.meta = {
discovered: async () => {
const ssb = await cooler.open();
const snapshot = await ssb.conn.dbPeers();
// Read gossip.json to merge announcers data
const gossipPath = path.join(os.homedir(), '.ssb', 'gossip.json');
let gossipMap = new Map();
try {
@ -629,7 +715,6 @@ models.meta = {
}
} catch {}
const allDbPeers = await enrichEntries(snapshot);
// Merge announcers from gossip.json into enriched peers
for (const [, peerData] of allDbPeers) {
if ((!peerData.announcers || peerData.announcers === 0) && gossipMap.has(peerData.key)) {
const gossipEntry = gossipMap.get(peerData.key);
@ -637,15 +722,13 @@ models.meta = {
}
}
const connectedEntries = await models.meta.connectedPeers();
const onlineKeys = new Set(connectedEntries.map(([remote]) => {
const m = /~shs:([^=]+)=/.exec(remote);
if (!m) return null;
let core = m[1].replace(/-/g, '+').replace(/_/g, '/');
if (!core.endsWith('=')) core += '=';
return `@${core}.ed25519`;
}).filter(Boolean));
const discoveredPeers = allDbPeers.filter(([, d]) => !onlineKeys.has(d.key));
const discoveredIds = new Set(allDbPeers.map(([, d]) => d.key));
const onlineKeys = new Set(
connectedEntries
.map(([, d]) => d && d.key ? canonicalizePubId(d.key) : null)
.filter(Boolean)
);
const discoveredPeers = allDbPeers.filter(([, d]) => !onlineKeys.has(canonicalizePubId(d.key)));
const discoveredIds = new Set(allDbPeers.map(([, d]) => canonicalizePubId(d.key)));
const ebtList = await loadPeersFromEbt();
const ebtMap = new Map(ebtList.map(e => [e.pub, e.users]));
const unknownPeers = [];
@ -723,9 +806,7 @@ models.meta = {
const ssb = await cooler.open();
const code = toLegacyInvite(String(invite || ''));
const result = await new Promise((resolve, reject) => {
ssb.invite.accept(code, (err, res) => {
err ? reject(err) : resolve(res);
});
ssb.invite.accept(code, (err, res) => err ? reject(err) : resolve(res));
});
const { host, pubId } = parseRemote(code);
await new Promise((resolve) => {
@ -734,7 +815,7 @@ models.meta = {
pubHost: host || null,
pubKey: pubId || null,
acceptedAt: new Date().toISOString(),
}, (err) => resolve());
}, (_err) => resolve());
});
return result;
},
@ -1954,7 +2035,5 @@ models.vote = {
});
},
};
//return models
return models;
};

View file

@ -1,7 +1,9 @@
const pull = require("../server/node_modules/pull-stream");
const crypto = require("crypto");
const { getConfig } = require("../configs/config-manager.js");
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const INVITE_CODE_BYTES = 16;
const safeArr = (v) => (Array.isArray(v) ? v : []);
@ -25,7 +27,47 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {};
const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {};
const encryptStandalone = (content, rootId) => {
if (!tribeCrypto || !rootId) return content;
const key = tribeCrypto.getKey(rootId);
if (!key) return content;
return tribeCrypto.encryptContent(content, [key], true);
};
const decryptMapRoot = (content, rootId) => {
if (!content || !content.encryptedPayload) return content;
if (!tribeCrypto) return content;
const keys = tribeCrypto.getKeys(rootId);
if (!keys || !keys.length) return { ...content, _undecryptable: true };
return tribeCrypto.decryptContent(content, keys.map(k => [k]));
};
const decryptIndexNodes = async (idx) => {
if (!tribeCrypto) return;
for (const [k, n] of (idx.nodes ? idx.nodes.entries() : [])) {
if (!n.c || !n.c.encryptedPayload) continue;
let root = k;
if (idx.parent) { while (idx.parent.has(root)) root = idx.parent.get(root); }
else if (idx.backward) { while (idx.backward.has(root)) root = idx.backward.get(root); }
let dec = null;
if (n.c.tribeId && tribesModel) {
try {
const r = await tribeCrypto.decryptFromTribe(n.c, tribesModel);
if (r && !r._undecryptable) dec = r;
} catch (_) {}
}
if (!dec) {
const r = decryptMapRoot(n.c, root);
if (r && !r._undecryptable) dec = r;
}
if (dec) {
idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } });
} else {
idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } });
}
}
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
@ -157,6 +199,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
mapType: ALLOWED_MAP_TYPES.has(c.mapType) ? c.mapType : "SINGLE",
tags: safeArr(c.tags),
author: c.author,
members: Array.isArray(c.members) ? c.members : [],
invites: Array.isArray(c.invites) ? c.invites : [],
tribeId: c.tribeId || null,
encrypted: !!undec,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
@ -195,11 +239,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
async createMap(lat, lng, description, mapType, tagsRaw, title, tribeId, markerLabel, image) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tags = normalizeTags(tagsRaw) || [];
const now = new Date().toISOString();
const mType = ALLOWED_MAP_TYPES.has(mapType) ? mapType : "SINGLE";
let content = {
let plainContent = {
type: "map",
title: title || "",
lat: parseFloat(lat) || 0,
@ -207,7 +252,9 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
description: description || "",
markerLabel: markerLabel || "",
mapType: mType,
author: ssbClient.id,
author: userId,
members: [userId],
invites: [],
tags,
...(tribeId ? { tribeId } : {}),
...(image ? { image } : {}),
@ -215,21 +262,71 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
updatedAt: now
};
content = await encryptIfTribe(content);
const shouldEncryptStandalone = !tribeId && tribeCrypto && (mType === "OPEN" || mType === "CLOSED");
let mapKey = null;
let content = plainContent;
if (tribeId) {
content = await encryptIfTribe(plainContent);
} else if (shouldEncryptStandalone) {
mapKey = tribeCrypto.generateTribeKey();
content = tribeCrypto.encryptContent(plainContent, [mapKey], true);
}
return new Promise((resolve, reject) => {
const result = await new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
if (mapKey) {
tribeCrypto.setKey(result.key, mapKey, 1);
try {
const ssbKeys = require("../server/node_modules/ssb-keys");
const boxedKey = tribeCrypto.boxKeyForMember(mapKey, userId, ssbKeys);
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve());
});
} catch (_) {}
if (mType === "OPEN") {
try {
const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex");
const ek = tribeCrypto.encryptForInvite(mapKey, pubCode);
let updated = {
type: "map",
replaces: result.key,
title: plainContent.title,
lat: plainContent.lat,
lng: plainContent.lng,
description: plainContent.description,
markerLabel: plainContent.markerLabel,
mapType: mType,
author: userId,
members: [userId],
invites: [{ code: pubCode, ek, gen: 1, public: true }],
tags,
...(image ? { image } : {}),
createdAt: now,
updatedAt: new Date().toISOString()
};
updated = encryptStandalone(updated, result.key);
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: result.key, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve()));
} catch (_) {}
}
}
return result;
},
async updateMapById(id, lat, lng, description, mapType, tagsRaw, title, image) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const rootId = await this.resolveRootId(id);
const oldMsg = await getMsg(ssbClient, tipId);
if (!oldMsg || oldMsg.content?.type !== "map") throw new Error("Map not found");
const oldDecrypted = await decryptIfTribe(oldMsg.content);
const oldDecrypted = oldMsg.content.tribeId
? await decryptIfTribe(oldMsg.content)
: decryptMapRoot(oldMsg.content, rootId);
assertReadable(oldDecrypted, "Map");
if ((oldDecrypted.author || oldMsg.content.author) !== userId) throw new Error("Not the author");
@ -248,13 +345,19 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
mapType: mType,
tags,
author: oldDecrypted.author || userId,
members: Array.isArray(oldDecrypted.members) ? oldDecrypted.members : [userId],
invites: Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : [],
...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}),
...(image ? { image } : (oldDecrypted.image ? { image: oldDecrypted.image } : {})),
createdAt: oldDecrypted.createdAt,
updatedAt: now
};
updated = await encryptIfTribe(updated);
if (oldMsg.content.tribeId) {
updated = await encryptIfTribe(updated);
} else if (mType !== "SINGLE") {
updated = encryptStandalone(updated, rootId);
}
const result = await new Promise((resolve, reject) => {
ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
@ -303,9 +406,11 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
if (mapType === "CLOSED" && mapAuthor !== userId) throw new Error("Only the map creator can add markers");
const now = new Date().toISOString();
let rootId = tipId;
while (idx.backward && idx.backward.has(rootId)) rootId = idx.backward.get(rootId);
let content = {
type: "mapMarker",
mapId: tipId,
mapId: rootId,
lat: parseFloat(lat) || 0,
lng: parseFloat(lng) || 0,
label: label || "",
@ -315,7 +420,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
};
if (image) content.image = image;
content = await encryptIfTribe(content);
if (node.c.tribeId) {
content = await encryptIfTribe(content);
} else if (tribeCrypto) {
const mapKey = tribeCrypto.getKey(rootId);
if (mapKey) content = tribeCrypto.encryptContent(content, [mapKey], true);
}
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
@ -395,6 +505,150 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
const markerList = safeArr(idx.markers.get(tip)).concat(safeArr(idx.markers.get(root)));
return buildMap(node, root, viewer, markerList);
},
async generateInvite(mapId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const map = await this.getMapById(mapId, userId);
if (!map) throw new Error("Map not found");
if (map.author !== userId) throw new Error("Only the author can generate invites");
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex");
let invite = code;
if (tribeCrypto && !map.tribeId) {
const ekChain = tribeCrypto.encryptChainForInvite([map.rootId || map.key], code);
if (ekChain) invite = { code, ekChain, gen: tribeCrypto.getGen(map.rootId || map.key) || 1 };
}
const tipId = await this.resolveCurrentId(mapId);
const rootId = await this.resolveRootId(mapId);
const oldMsg = await getMsg(ssbClient, tipId);
const oldDecrypted = oldMsg.content.tribeId
? await decryptIfTribe(oldMsg.content)
: decryptMapRoot(oldMsg.content, rootId);
const invites = [...(Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : []), invite];
let updated = {
type: "map",
replaces: tipId,
title: oldDecrypted.title || "",
lat: oldDecrypted.lat,
lng: oldDecrypted.lng,
description: oldDecrypted.description || "",
markerLabel: oldDecrypted.markerLabel || "",
mapType: oldDecrypted.mapType,
tags: Array.isArray(oldDecrypted.tags) ? oldDecrypted.tags : [],
author: oldDecrypted.author,
members: Array.isArray(oldDecrypted.members) ? oldDecrypted.members : [userId],
invites,
...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}),
...(oldDecrypted.image ? { image: oldDecrypted.image } : {}),
createdAt: oldDecrypted.createdAt,
updatedAt: new Date().toISOString()
};
if (oldMsg.content.tribeId) updated = await encryptIfTribe(updated);
else if (oldDecrypted.mapType !== "SINGLE") updated = encryptStandalone(updated, rootId);
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve()));
return code;
},
async joinByInvite(code) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const maps = await this.listAll({ filter: "all", viewerId: userId });
let matched = null;
let matchedInvite = null;
for (const m of maps) {
const invs = Array.isArray(m.invites) ? m.invites : [];
for (const inv of invs) {
if (typeof inv === "string" && inv === code) { matched = m; matchedInvite = inv; break; }
if (typeof inv === "object" && inv.code === code) { matched = m; matchedInvite = inv; break; }
}
if (matched) break;
}
if (!matched) throw new Error("Invalid or expired invite code");
if (Array.isArray(matched.members) && matched.members.includes(userId)) throw new Error("Already a member");
let mapKey = null;
if (tribeCrypto && typeof matchedInvite === "object") {
if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code);
if (Array.isArray(chain) && chain.length) {
for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) {
tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length);
} else if (entry.key) {
tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1);
}
}
mapKey = chain[0].key;
}
} else if (matchedInvite.ek) {
mapKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code);
tribeCrypto.setKey(matched.rootId || matched.key, mapKey, matchedInvite.gen || 1);
}
}
const tipId = await this.resolveCurrentId(matched.rootId || matched.key);
const rootId = await this.resolveRootId(matched.rootId || matched.key);
const oldMsg = await getMsg(ssbClient, tipId);
const oldDecrypted = oldMsg.content.tribeId
? await decryptIfTribe(oldMsg.content)
: decryptMapRoot(oldMsg.content, rootId);
const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true;
const invites = isPublicInvite
? (Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : [])
: (Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : []).filter(inv => {
if (typeof inv === "string") return inv !== code;
return inv.code !== code;
});
let updated = {
type: "map",
replaces: tipId,
title: oldDecrypted.title || "",
lat: oldDecrypted.lat,
lng: oldDecrypted.lng,
description: oldDecrypted.description || "",
markerLabel: oldDecrypted.markerLabel || "",
mapType: oldDecrypted.mapType,
tags: Array.isArray(oldDecrypted.tags) ? oldDecrypted.tags : [],
author: oldDecrypted.author,
members: [...(Array.isArray(oldDecrypted.members) ? oldDecrypted.members : []), userId],
invites,
...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}),
...(oldDecrypted.image ? { image: oldDecrypted.image } : {}),
createdAt: oldDecrypted.createdAt,
updatedAt: new Date().toISOString()
};
if (oldMsg.content.tribeId) updated = await encryptIfTribe(updated);
else if (oldDecrypted.mapType !== "SINGLE") updated = encryptStandalone(updated, rootId);
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve()));
if (tribeCrypto && mapKey) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys");
const memberKeys = {};
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(mapKey, userId, ssbKeys); } catch (_) {}
if (matched.author && matched.author !== userId) {
try { memberKeys[matched.author] = tribeCrypto.boxKeyForMember(mapKey, matched.author, ssbKeys); } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: tribeCrypto.getGen(rootId) || 1, memberKeys }, () => resolve());
});
}
} catch (_) {}
}
return rootId;
},
async joinMap(mapId) {
const userId = (await openSsb()).id;
const map = await this.getMapById(mapId, userId);
if (!map) throw new Error("Map not found");
if (Array.isArray(map.members) && map.members.includes(userId)) return map.rootId || map.key;
if (tribeCrypto && Array.isArray(map.invites)) {
const pub = map.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain));
if (pub) return await this.joinByInvite(pub.code);
}
throw new Error("This map requires an invite code to join");
}
};
};

View file

@ -20,16 +20,41 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
let keyringPath = null
const getKeyring = () => {
let migratedToTribeCrypto = false
const getLegacyKeyringPath = () => {
if (!keyringPath) {
const ssbConfig = require("../server/node_modules/ssb-config/inject")()
keyringPath = path.join(ssbConfig.path, "pad-keys.json")
}
try { return JSON.parse(fs.readFileSync(keyringPath, "utf8")) } catch (e) { return {} }
return keyringPath
}
const migrateLegacyKeyring = () => {
if (migratedToTribeCrypto || !tribeCrypto) { migratedToTribeCrypto = true; return }
migratedToTribeCrypto = true
try {
const p = getLegacyKeyringPath()
if (!fs.existsSync(p)) return
const legacy = JSON.parse(fs.readFileSync(p, "utf8")) || {}
for (const [rootId, keyHex] of Object.entries(legacy)) {
if (rootId && keyHex && !tribeCrypto.getKey(rootId)) {
tribeCrypto.setKey(rootId, keyHex, 1)
}
}
} catch (_) {}
}
const getPadKey = (rootId) => {
migrateLegacyKeyring()
if (tribeCrypto) return tribeCrypto.getKey(rootId) || null
try { return JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8"))[rootId] || null } catch (_) { return null }
}
const setPadKey = (rootId, keyHex) => {
migrateLegacyKeyring()
if (tribeCrypto) { tribeCrypto.setKey(rootId, keyHex, 1); return }
let kr = {}
try { kr = JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8")) } catch (_) {}
kr[rootId] = keyHex
fs.writeFileSync(getLegacyKeyringPath(), JSON.stringify(kr, null, 2), "utf8")
}
const saveKeyring = (kr) => fs.writeFileSync(keyringPath, JSON.stringify(kr, null, 2), "utf8")
const getPadKey = (rootId) => { const kr = getKeyring(); return kr[rootId] || null }
const setPadKey = (rootId, keyHex) => { const kr = getKeyring(); kr[rootId] = keyHex; saveKeyring(kr) }
const encryptField = (text, keyHex) => {
const key = Buffer.from(keyHex, "hex")
@ -225,6 +250,13 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
if (!keyHex) keyHex = crypto.randomBytes(32).toString("hex")
const enc = (text) => encryptField(text, keyHex)
const initialInvites = []
if (validStatus === "OPEN" && !usesTribeKey) {
const pubCode = crypto.randomBytes(INVITE_BYTES).toString("hex")
const ek = encryptForInvite(keyHex, pubCode)
initialInvites.push({ code: pubCode, ek, gen: 1, public: true })
}
const content = {
type: "pad",
title: enc(safeText(title)),
@ -233,17 +265,28 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
tags: enc(normalizeTags(tagsRaw).join(",")),
author: ssbClient.id,
members: [ssbClient.id],
invites: [],
invites: initialInvites,
createdAt: now,
updatedAt: now,
encrypted: true,
...(tribeId ? { tribeId } : {})
}
const userId = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => {
if (err) return reject(err)
if (!usesTribeKey) setPadKey(msg.key, keyHex)
if (!usesTribeKey) {
setPadKey(msg.key, keyHex)
if (tribeCrypto) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const boxedKey = tribeCrypto.boxKeyForMember(keyHex, userId, ssbKeys)
ssbClient.publish({ type: "tribe-keys", tribeId: msg.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve(msg))
return
} catch (_) {}
}
}
resolve(msg)
})
})
@ -452,13 +495,31 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
}
if (!matchedPad) throw new Error("Invalid or expired invite code")
if (matchedPad.members.includes(userId)) throw new Error("Already a member")
let padKey = null
let resolvedRootId = null
if (typeof matchedInvite === "object" && matchedInvite.ek) {
const padKey = decryptFromInvite(matchedInvite.ek, code)
const rootId = await this.resolveRootId(matchedPad.rootId)
setPadKey(rootId, padKey)
padKey = decryptFromInvite(matchedInvite.ek, code)
resolvedRootId = await this.resolveRootId(matchedPad.rootId)
setPadKey(resolvedRootId, padKey)
}
await this.addMemberToPad(matchedPad.rootId, userId)
const invites = matchedPad.invites.filter(inv => {
if (tribeCrypto && padKey && resolvedRootId) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const memberKeys = {}
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(padKey, userId, ssbKeys) } catch (_) {}
if (matchedPad.author && matchedPad.author !== userId) {
try { memberKeys[matchedPad.author] = tribeCrypto.boxKeyForMember(padKey, matchedPad.author, ssbKeys) } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: resolvedRootId, generation: 1, memberKeys }, () => resolve())
})
}
} catch (_) {}
}
const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true
const invites = isPublicInvite ? matchedPad.invites : matchedPad.invites.filter(inv => {
if (typeof inv === "string") return inv !== code
return inv.code !== code
})

View file

@ -3,13 +3,38 @@ const moment = require('../server/node_modules/moment');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler, padsModel }) => {
module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const STANDALONE_ENCRYPTED_TYPES = new Set(['chat', 'pad', 'map', 'calendar']);
const tryDecryptStandalone = (content) => {
if (!tribeCrypto || !content || !content.encryptedPayload) return null;
const rootCandidates = [content.calendarId, content.chatId, content.padId, content.mapId, content.roomId, content.parentId, content.dateId, content.rootId].filter(Boolean);
for (const cand of rootCandidates) {
const keys = tribeCrypto.getKeys(cand);
if (!keys || !keys.length) continue;
try {
const dec = tribeCrypto.decryptContent(content, keys.map(k => [k]));
if (dec && !dec._undecryptable) return dec;
} catch (_) {}
}
return null;
};
const tryDecryptTribe = async (content) => {
if (!tribeCrypto || !tribesModel || !content || !content.encryptedPayload || !content.tribeId) return null;
try {
const dec = await tribeCrypto.decryptFromTribe(content, tribesModel);
if (dec && !dec._undecryptable) return dec;
} catch (_) {}
return null;
};
const searchableTypes = [
'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
'votes', 'report', 'task', 'event', 'bookmark', 'document',
@ -250,6 +275,7 @@ module.exports = ({ cooler, padsModel }) => {
const search = async ({ query, types = [], resultsPerPage = "10" }) => {
const ssbClient = await openSsb();
const viewerId = ssbClient.id;
const queryLower = String(query || '').toLowerCase();
const messages = await new Promise((res, rej) => {
@ -277,6 +303,39 @@ module.exports = ({ cooler, padsModel }) => {
latestByKey.delete(oldId);
}
const viewerTribeIds = new Set();
if (tribesModel && typeof tribesModel.listTribesForViewer === 'function') {
try {
const myTribes = await tribesModel.listTribesForViewer(viewerId);
for (const tr of (myTribes || [])) viewerTribeIds.add(String(tr.rootId || tr.id || tr.key));
} catch (_) {}
}
for (const [k, msg] of Array.from(latestByKey.entries())) {
const c = msg?.value?.content;
if (!c) { latestByKey.delete(k); continue; }
if (c.tribeId && !viewerTribeIds.has(String(c.tribeId))) {
latestByKey.delete(k);
continue;
}
if (c.encryptedPayload) {
let dec = null;
if (c.tribeId) dec = await tryDecryptTribe(c);
if (!dec && STANDALONE_ENCRYPTED_TYPES.has(c.type)) {
const keys = tribeCrypto && tribeCrypto.getKeys ? tribeCrypto.getKeys(k) : [];
if (keys && keys.length) {
try {
const out = tribeCrypto.decryptContent(c, keys.map(kk => [kk]));
if (out && !out._undecryptable) dec = out;
} catch (_) {}
}
}
if (!dec) dec = tryDecryptStandalone(c);
if (!dec) { latestByKey.delete(k); continue; }
msg.value.content = { ...c, ...dec, encryptedPayload: undefined };
}
}
if (padsModel) {
for (const msg of latestByKey.values()) {
const c = msg?.value?.content;

View file

@ -319,17 +319,17 @@ module.exports = ({ cooler }) => {
const allTribesPublic = tribeDedupNodes
.filter(n => n.content?.isAnonymous === false)
.map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key }));
.map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key }));
const allTribes = allTribesPublic.map(t => t.name);
const memberTribesDetailed = tribeDedupNodes
.filter(n => Array.isArray(n.content?.members) && n.content.members.includes(userId))
.map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key }));
.map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key }));
const myPrivateTribesDetailed = tribeDedupNodes
.filter(n => n.content?.isAnonymous !== false && Array.isArray(n.content?.members) && n.content.members.includes(userId))
.map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key }));
.map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key }));
const content = {};
const opinions = {};
@ -446,6 +446,40 @@ module.exports = ({ cooler }) => {
.slice(0, 5)
.map(([id, count]) => ({ id, count }));
const tagCount = new Map();
for (const t of types) {
for (const v of Array.from(tipOf[t].values())) {
const tags = v.content && Array.isArray(v.content.tags) ? v.content.tags : [];
for (const tg of tags) {
const key = String(tg || '').trim();
if (!key) continue;
tagCount.set(key, (tagCount.get(key) || 0) + 1);
}
}
}
const topTags = Array.from(tagCount.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([tag, count]) => ({ tag, count }));
const totalTypeCount = types.reduce((s, t) => s + (Array.from(tipOf[t].values()).length || 0), 0);
const topTypeBlacklist = new Set(['shopProduct','chatMessage','padEntry','calendarDate','calendarNote','log']);
const topTypes = types
.filter(t => !topTypeBlacklist.has(t))
.map(t => ({ type: t, count: Array.from(tipOf[t].values()).length || 0 }))
.filter(o => o.count > 0)
.sort((a, b) => b.count - a.count)
.slice(0, 10);
const myMsgsAll = allMsgs.filter(m => m.value.author === userId);
const myShare = allMsgs.length ? (myMsgsAll.length / allMsgs.length) * 100 : 0;
const avgMsgsPerInhabitant = inhabitants ? allMsgs.length / inhabitants : 0;
const tombstoneRatio = allMsgs.length ? (allMsgs.filter(m => m.value.content.type === 'tombstone').length / allMsgs.length) * 100 : 0;
const totalsTs = allMsgs.map(m => m.value.timestamp || 0).filter(Boolean).sort((a, b) => a - b);
const networkSpanDays = totalsTs.length >= 2 ? (totalsTs[totalsTs.length - 1] - totalsTs[0]) / 86400000 : 0;
const networkMsgsPerDay = networkSpanDays > 0 ? (allMsgs.length / networkSpanDays) : 0;
const addrMap = readAddrMap();
const myAddress = addrMap[userId] || null;
const banking = {
@ -507,8 +541,19 @@ module.exports = ({ cooler }) => {
},
tombstoneKPIs: {
networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length,
ratio: allMsgs.length ? (allMsgs.filter(m => m.value.content.type === 'tombstone').length / allMsgs.length) * 100 : 0
ratio: tombstoneRatio
},
networkKPIs: {
totalMsgs: allMsgs.length,
myMsgs: myMsgsAll.length,
myShare,
avgMsgsPerInhabitant,
networkSpanDays,
networkMsgsPerDay
},
topTags,
topTypes,
totalTypeCount,
banking
};

View file

@ -130,23 +130,23 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
for (const oldId of replacesMap.keys()) latestByKey.delete(oldId);
const anonTribeIds = new Set();
const viewerId = ssbClient.id;
const viewerVisibleTribeIds = new Set();
if (tribesModel) {
const allTribes = await tribesModel.listAll().catch(() => []);
for (const tribe of allTribes) {
if (tribe.isAnonymous === true) anonTribeIds.add(tribe.id);
}
const visibleTribes = await tribesModel.listTribesForViewer(viewerId).catch(() => []);
for (const tribe of visibleTribes) viewerVisibleTribeIds.add(tribe.id);
}
let filtered = Array.from(latestByKey.values()).filter(msg => {
const c = msg?.value?.content;
if (!c || c.type === 'tombstone') return false;
if (tombstoned.has(msg.key)) return false;
if (c.encryptedPayload) return false;
if (!Array.isArray(c.tags) || !c.tags.filter(Boolean).length) return false;
if (c.tribeId && anonTribeIds.has(c.tribeId)) return false;
if (c.tribeId && !viewerVisibleTribeIds.has(c.tribeId)) return false;
if (c.type === 'event' && c.isPublic === 'private') return false;
if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE') return false;
if ((c.type === 'chat' || c.type === 'pad') && c.status === 'INVITE-ONLY') return false;
if ((c.type === 'chat' || c.type === 'pad' || c.type === 'map' || c.type === 'calendar') && c.status === 'INVITE-ONLY' && c.author !== viewerId && !(Array.isArray(c.members) && c.members.includes(viewerId))) return false;
if (c.type === 'shop' && c.visibility === 'CLOSED') return false;
return true;
});

View file

@ -13,10 +13,12 @@ const ENVELOPE_PRESERVE = new Set([
'type', 'tribeId', 'contentType', 'replaces', 'target', 'author',
'createdAt', 'updatedAt', 'encryptedPayload',
'mapId', 'calendarId', 'dateId', 'padId', 'roomId', 'parentId',
'members', 'invites', 'participants',
'_decrypted', '_undecryptable'
]);
const INVITE_SALT = 'SolarNET.HuB';
const INVITE_SALT_LEGACY = 'SolarNET.HuB';
const INVITE_SCRYPT = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
module.exports = (configPath) => {
const keyringPath = path.join(configPath, 'tribe-keys.json');
@ -25,6 +27,7 @@ module.exports = (configPath) => {
const loadKeyring = () => {
try {
keyring = JSON.parse(fs.readFileSync(keyringPath, 'utf8'));
try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
} catch (e) {
if (e.code !== 'ENOENT') throw e;
keyring = {};
@ -34,8 +37,9 @@ module.exports = (configPath) => {
const saveKeyring = () => {
const tmp = keyringPath + '.tmp.' + process.pid + '.' + Date.now();
fs.writeFileSync(tmp, JSON.stringify(keyring, null, 2), 'utf8');
fs.writeFileSync(tmp, JSON.stringify(keyring, null, 2), { encoding: 'utf8', mode: 0o600 });
fs.renameSync(tmp, keyringPath);
try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
};
const generateTribeKey = () => crypto.randomBytes(32).toString('hex');
@ -60,8 +64,30 @@ module.exports = (configPath) => {
saveKeyring();
};
const setKeys = (tribeRootId, keysHex, topGen) => {
if (!Array.isArray(keysHex) || !keysHex.length) return;
const seen = new Set();
const dedup = [];
for (const k of keysHex) { if (k && !seen.has(k)) { seen.add(k); dedup.push(k); } }
keyring[tribeRootId] = { keys: dedup, gen: topGen || dedup.length };
saveKeyring();
};
const mergeKeys = (tribeRootId, incomingKeys, topGen) => {
const entry = keyring[tribeRootId] || { keys: [], gen: 0 };
const seen = new Set(entry.keys);
const merged = [...entry.keys];
for (const k of incomingKeys) {
if (k && !seen.has(k)) { seen.add(k); merged.push(k); }
}
keyring[tribeRootId] = { keys: merged, gen: Math.max(entry.gen || 0, topGen || merged.length) };
saveKeyring();
return keyring[tribeRootId].gen;
};
const addNewKey = (tribeRootId, newKeyHex) => {
const entry = keyring[tribeRootId] || { keys: [], gen: 0 };
if (entry.keys.includes(newKeyHex)) return entry.gen;
entry.keys.unshift(newKeyHex);
entry.gen = (entry.gen || 0) + 1;
keyring[tribeRootId] = entry;
@ -69,65 +95,110 @@ module.exports = (configPath) => {
return entry.gen;
};
const encryptWithKey = (plaintext, keyHex) => {
const canonicalAad = (envelope) => {
if (!envelope) return null;
const fields = ['type', 'tribeId', 'contentType', 'replaces', 'author', 'createdAt'];
const obj = {};
for (const f of fields) if (envelope[f] !== undefined && envelope[f] !== null) obj[f] = envelope[f];
return Buffer.from(JSON.stringify(obj), 'utf8');
};
const encryptWithKey = (plaintext, keyHex, aad) => {
const key = Buffer.from(keyHex, 'hex');
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
if (aad) cipher.setAAD(aad);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return iv.toString('hex') + authTag.toString('hex') + enc.toString('hex');
};
const decryptWithKey = (encrypted, keyHex) => {
const decryptWithKey = (encrypted, keyHex, aad) => {
const key = Buffer.from(keyHex, 'hex');
const iv = Buffer.from(encrypted.slice(0, 24), 'hex');
const authTag = Buffer.from(encrypted.slice(24, 56), 'hex');
const ciphertext = Buffer.from(encrypted.slice(56), 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
if (aad) decipher.setAAD(aad);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
};
const encryptForInvite = (tribeKeyHex, inviteCode) => {
const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32);
const generateInviteSalt = () => crypto.randomBytes(16).toString('hex');
const deriveInviteKey = (inviteCode, salt) => {
if (salt === undefined || salt === null || salt === '') {
return crypto.scryptSync(inviteCode, INVITE_SALT_LEGACY, 32);
}
return crypto.scryptSync(inviteCode, salt, 32, INVITE_SCRYPT);
};
const hashInviteCode = (inviteCode, salt) => {
const s = salt === undefined || salt === null || salt === '' ? INVITE_SALT_LEGACY : salt;
return crypto.createHmac('sha256', s).update(String(inviteCode), 'utf8').digest('hex');
};
const encryptForInvite = (tribeKeyHex, inviteCode, salt) => {
const derived = deriveInviteKey(inviteCode, salt);
return encryptWithKey(tribeKeyHex, derived.toString('hex'));
};
const decryptFromInvite = (encryptedKey, inviteCode) => {
const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32);
const decryptFromInvite = (encryptedKey, inviteCode, salt) => {
const derived = deriveInviteKey(inviteCode, salt);
return decryptWithKey(encryptedKey, derived.toString('hex'));
};
const encryptChainForInvite = (ancestryRootIds, inviteCode) => {
const chain = ancestryRootIds.map(rootId => ({ rootId, key: getKey(rootId), gen: getGen(rootId) }));
if (chain.some(e => !e.key)) return null;
const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32);
const encryptChainForInvite = (ancestryRootIds, inviteCode, salt) => {
const chain = ancestryRootIds.map(rootId => ({
rootId,
key: getKey(rootId),
keys: getKeys(rootId),
gen: getGen(rootId)
}));
if (chain.some(e => !e.key || !Array.isArray(e.keys) || !e.keys.length)) return null;
const derived = deriveInviteKey(inviteCode, salt);
return encryptWithKey(JSON.stringify(chain), derived.toString('hex'));
};
const decryptChainFromInvite = (encryptedPayload, inviteCode) => {
const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32);
const decryptChainFromInvite = (encryptedPayload, inviteCode, salt) => {
const derived = deriveInviteKey(inviteCode, salt);
try {
const json = decryptWithKey(encryptedPayload, derived.toString('hex'));
const parsed = JSON.parse(json);
if (Array.isArray(parsed) && parsed.every(e => e && e.rootId && e.key)) return parsed;
if (Array.isArray(parsed) && parsed.every(e => e && e.rootId && (e.key || (Array.isArray(e.keys) && e.keys.length)))) {
return parsed.map(e => ({
rootId: e.rootId,
key: e.key || (Array.isArray(e.keys) ? e.keys[0] : null),
keys: Array.isArray(e.keys) && e.keys.length ? e.keys : (e.key ? [e.key] : []),
gen: e.gen || 1
}));
}
} catch (_) {}
return null;
};
const encryptChain = (plaintext, keyChain) => {
const inviteMatchesCode = (inv, code) => {
if (typeof inv === 'string') return inv === code;
if (!inv || typeof inv !== 'object') return false;
if (inv.codeHash) return inv.codeHash === hashInviteCode(code, inv.salt);
if (inv.code) return inv.code === code;
return false;
};
const encryptChain = (plaintext, keyChain, aad) => {
let data = plaintext;
for (const keyHex of keyChain) {
data = encryptWithKey(data, keyHex);
const last = keyChain.length - 1;
for (let i = 0; i < keyChain.length; i++) {
data = encryptWithKey(data, keyChain[i], i === last ? aad : undefined);
}
return data;
};
const decryptChain = (encrypted, keyChain) => {
const decryptChain = (encrypted, keyChain, aad) => {
const reversed = [...keyChain].reverse();
let data = encrypted;
for (const keyHex of reversed) {
data = decryptWithKey(data, keyHex);
for (let i = 0; i < reversed.length; i++) {
data = decryptWithKey(data, reversed[i], i === 0 ? aad : undefined);
}
return data;
};
@ -145,20 +216,32 @@ module.exports = (configPath) => {
}
}
const plaintext = JSON.stringify(payload);
const encryptedPayload = encryptChain(plaintext, keyChain);
const result = {};
for (const [k, v] of Object.entries(content)) {
if (customFields ? ENVELOPE_PRESERVE.has(k) : !SENSITIVE_FIELDS.includes(k)) {
result[k] = v;
}
}
const aad = canonicalAad(result);
const encryptedPayload = encryptChain(plaintext, keyChain, aad);
result.encryptedPayload = encryptedPayload;
return result;
};
const decryptContent = (content, keyChainSets) => {
if (!content.encryptedPayload) return content;
const envelope = { ...content };
delete envelope.encryptedPayload;
const aad = canonicalAad(envelope);
for (const keyChain of keyChainSets) {
try {
const plaintext = decryptChain(content.encryptedPayload, keyChain, aad);
const payload = JSON.parse(plaintext);
const result = { ...content };
delete result.encryptedPayload;
Object.assign(result, payload);
return result;
} catch (e) {}
try {
const plaintext = decryptChain(content.encryptedPayload, keyChain);
const payload = JSON.parse(plaintext);
@ -229,10 +312,31 @@ module.exports = (configPath) => {
const decryptFromTribe = async (content, tribesModel) => {
if (!content || !content.encryptedPayload) return content;
const tid = content.tribeId;
if (!tid) return content;
const sets = await resolveKeyChainSets(tid, tribesModel);
if (!sets || !sets.length) return { ...content, _undecryptable: true };
return decryptContent(content, sets);
if (tid) {
let sets = null;
try { sets = await resolveKeyChainSets(tid, tribesModel); } catch (_) {}
if (sets && sets.length) {
const r = decryptContent(content, sets);
if (r && !r._undecryptable) return r;
}
const directKeys = getKeys(tid);
if (directKeys && directKeys.length) {
const r = decryptContent(content, directKeys.map(k => [k]));
if (r && !r._undecryptable) return r;
}
}
const candidateRoots = [
content.calendarId, content.chatId, content.padId,
content.mapId, content.roomId, content.parentId, content.dateId
].filter(Boolean);
for (const rid of candidateRoots) {
const keys = getKeys(rid);
if (keys && keys.length) {
const r = decryptContent(content, keys.map(k => [k]));
if (r && !r._undecryptable) return r;
}
}
return { ...content, _undecryptable: true };
};
const createHelpers = (tribesModel) => ({
@ -267,10 +371,11 @@ module.exports = (configPath) => {
SENSITIVE_FIELDS,
ENVELOPE_PRESERVE,
loadKeyring, saveKeyring,
generateTribeKey, getKey, getKeys, getGen, setKey, addNewKey,
generateTribeKey, getKey, getKeys, getGen, setKey, setKeys, mergeKeys, addNewKey,
encryptWithKey, decryptWithKey,
encryptForInvite, decryptFromInvite,
encryptChainForInvite, decryptChainFromInvite,
generateInviteSalt, hashInviteCode, inviteMatchesCode,
encryptChain, decryptChain,
encryptContent, decryptContent,
boxKeyForMember, unboxKeyFromMember,

View file

@ -1,6 +1,7 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const tribeLogLimit = Math.max(logLimit, 100000);
const VALID_CONTENT_TYPES = ['event', 'task', 'report', 'votation', 'forum', 'forum-reply', 'market', 'job', 'project', 'media', 'feed', 'pixelia'];
const categories = require('../backend/opinion_categories');
@ -30,7 +31,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
const ssbClient = await openSsb();
return new Promise((resolve, reject) =>
pull(
ssbClient.createLogStream({ limit: logLimit }),
ssbClient.createLogStream({ limit: tribeLogLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
)
);

View file

@ -2,6 +2,7 @@ const pull = require('../server/node_modules/pull-stream');
const crypto = require('crypto');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const tribeLogLimit = Math.max(logLimit, 100000);
const INVITE_CODE_BYTES = 16;
const VALID_INVITE_MODES = ['strict', 'open'];
@ -57,7 +58,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const client = await openSsb();
return new Promise((resolve, reject) => {
pull(
client.createLogStream({ limit: logLimit }),
client.createLogStream({ limit: tribeLogLimit }),
pull.collect((err, msgs) => {
if (err) return reject(err);
const tombstones = new Map();
@ -88,6 +89,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
let progress = true;
while (progress) {
progress = false;
const candidatesByReplaces = new Map();
for (const [k, entry] of tribeMsgs.entries()) {
if (tribes.has(k)) continue;
const replaces = entry.content.replaces;
@ -106,10 +108,25 @@ module.exports = ({ cooler, tribeCrypto }) => {
if (!validInvitesDelta(parentEntry.content.invites, entry.content.invites, entry.author, rootAuthor)) continue;
if (!structuralFieldsEqual(parentEntry.content, entry.content)) continue;
}
parent.set(k, replaces);
child.set(replaces, k);
tribes.set(k, entry);
rootByTip.set(k, root);
if (!candidatesByReplaces.has(replaces)) candidatesByReplaces.set(replaces, []);
candidatesByReplaces.get(replaces).push({ k, entry, isRootAuthor, root });
}
for (const [replaces, candidates] of candidatesByReplaces.entries()) {
if (child.has(replaces)) continue;
let winner = candidates[0];
for (let i = 1; i < candidates.length; i++) {
const c = candidates[i];
if (c.isRootAuthor && !winner.isRootAuthor) { winner = c; continue; }
if (winner.isRootAuthor && !c.isRootAuthor) continue;
const wt = winner.entry._ts || 0;
const ct = c.entry._ts || 0;
if (ct < wt) winner = c;
else if (ct === wt && c.k < winner.k) winner = c;
}
parent.set(winner.k, replaces);
child.set(replaces, winner.k);
tribes.set(winner.k, winner.entry);
rootByTip.set(winner.k, winner.root);
progress = true;
}
}
@ -129,7 +146,25 @@ module.exports = ({ cooler, tribeCrypto }) => {
const tip = tipOf(root);
tipByRoot.set(root, tip);
}
tribeIndex = { tribes, tombstoned, parent, child, tipByRoot, rootByTip };
const effectivelyTombstoned = new Set(tombstoned);
let cascadeProgress = true;
while (cascadeProgress) {
cascadeProgress = false;
for (const k of tribes.keys()) {
if (effectivelyTombstoned.has(k)) continue;
const root = rootOf(k);
if (effectivelyTombstoned.has(root)) { effectivelyTombstoned.add(k); cascadeProgress = true; continue; }
const entry = tribes.get(k);
const pid = entry?.content?.parentTribeId;
if (!pid) continue;
const parentRoot = rootOf(pid);
if (effectivelyTombstoned.has(parentRoot) || effectivelyTombstoned.has(pid)) {
effectivelyTombstoned.add(k);
cascadeProgress = true;
}
}
}
tribeIndex = { tribes, tombstoned, effectivelyTombstoned, parent, child, tipByRoot, rootByTip };
tribeIndexTs = Date.now();
resolve(tribeIndex);
})
@ -196,8 +231,16 @@ module.exports = ({ cooler, tribeCrypto }) => {
if (tribeCrypto) {
const ancestryIds = await this.getAncestryChain(tribeId).catch(() => null);
if (Array.isArray(ancestryIds) && ancestryIds.length) {
const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, code);
if (ekChain) invite = { code, ekChain, gen: tribeCrypto.getGen(ancestryIds[0]) };
const salt = tribeCrypto.generateInviteSalt();
const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, code, salt);
if (ekChain) {
invite = {
codeHash: tribeCrypto.hashInviteCode(code, salt),
ekChain,
salt,
gen: tribeCrypto.getGen(ancestryIds[0])
};
}
}
}
const invites = Array.isArray(tribe.invites) ? [...tribe.invites, invite] : [invite];
@ -236,10 +279,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
for (const t of tribes) {
if (!t.invites) continue;
for (const inv of t.invites) {
if (typeof inv === 'string' && inv === code) {
matchedTribe = t; matchedInvite = inv; break;
}
if (typeof inv === 'object' && inv.code === code) {
if (tribeCrypto ? tribeCrypto.inviteMatchesCode(inv, code) : (inv === code || (inv && inv.code === code))) {
matchedTribe = t; matchedInvite = inv; break;
}
}
@ -253,16 +293,23 @@ module.exports = ({ cooler, tribeCrypto }) => {
let storedGen = 1;
let storedRootId = null;
if (tribeCrypto && typeof matchedInvite === 'object') {
const salt = matchedInvite.salt;
if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code);
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, salt);
if (Array.isArray(chain) && chain.length) {
for (const entry of chain) tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1);
for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) {
tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length);
} else if (entry.key) {
tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1);
}
}
storedRootId = chain[0].rootId;
storedTribeKey = chain[0].key;
storedGen = chain[0].gen || 1;
}
} else if (matchedInvite.ek) {
storedTribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code);
storedTribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, salt);
storedRootId = await this.getRootId(matchedTribe.id);
storedGen = matchedInvite.gen || 1;
tribeCrypto.setKey(storedRootId, storedTribeKey, storedGen);
@ -270,8 +317,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
}
const members = [...matchedTribe.members, userId];
const invites = matchedTribe.invites.filter(inv => {
if (tribeCrypto) return !tribeCrypto.inviteMatchesCode(inv, code);
if (typeof inv === 'string') return inv !== code;
return inv.code !== code;
return inv && inv.code !== code;
});
const inviteLog = Array.isArray(matchedTribe.inviteLog) ? matchedTribe.inviteLog.map(entry =>
entry.code === code ? { ...entry, status: 'used', usedBy: userId, usedAt: new Date().toISOString() } : entry
@ -318,14 +366,18 @@ module.exports = ({ cooler, tribeCrypto }) => {
const rootId = await this.getRootId(tribeId);
const currentKey = tribeCrypto.getKey(rootId);
if (!currentKey) return;
const allKeys = tribeCrypto.getKeys(rootId);
const gen = tribeCrypto.getGen(rootId);
const payload = JSON.stringify({ keys: allKeys, gen });
const memberKeys = {};
const memberKeysFull = {};
for (const memberId of toMembers) {
try { memberKeys[memberId] = tribeCrypto.boxKeyForMember(currentKey, memberId, ssbKeys); } catch (_) {}
try { memberKeysFull[memberId] = tribeCrypto.boxKeyForMember(payload, memberId, ssbKeys); } catch (_) {}
}
if (!Object.keys(memberKeys).length) return;
if (!Object.keys(memberKeys).length && !Object.keys(memberKeysFull).length) return;
await new Promise((resolve, reject) => {
ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: gen, memberKeys }, (err, res) => err ? reject(err) : resolve(res));
ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: gen, memberKeys, memberKeysFull }, (err, res) => err ? reject(err) : resolve(res));
});
await this.ensureFollowTribeMembers(tribeId).catch(() => {});
},
@ -341,7 +393,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
if (!currentKey) return;
const gen = tribeCrypto.getGen(rootId);
const msgs = await new Promise((resolve, reject) => {
pull(ssb.createLogStream({ limit: logLimit }), pull.collect((err, m) => err ? reject(err) : resolve(m)));
pull(ssb.createLogStream({ limit: tribeLogLimit }), pull.collect((err, m) => err ? reject(err) : resolve(m)));
});
const distributed = new Set();
for (const m of msgs) {
@ -386,10 +438,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
},
async getTribeById(tribeId) {
const { tribes, tombstoned, child } = await buildTribeIndex();
const { tribes, tombstoned, effectivelyTombstoned, child } = await buildTribeIndex();
let latestId = tribeId;
while (child.has(latestId)) latestId = child.get(latestId);
if (tombstoned.has(latestId)) throw new Error('Tribe not found');
if (tombstoned.has(latestId) || effectivelyTombstoned.has(latestId)) throw new Error('Tribe not found');
const tribe = tribes.get(latestId);
if (!tribe) throw new Error('Tribe not found');
return {
@ -403,6 +455,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
isAnonymous: tribe.content.isAnonymous,
members: Array.isArray(tribe.content.members) ? tribe.content.members : [],
invites: Array.isArray(tribe.content.invites) ? tribe.content.invites : [],
inviteLog: Array.isArray(tribe.content.inviteLog) ? tribe.content.inviteLog : [],
inviteMode: tribe.content.inviteMode || 'strict',
status: tribe.content.status || 'OPEN',
parentTribeId: tribe.content.parentTribeId || null,
@ -410,12 +463,11 @@ module.exports = ({ cooler, tribeCrypto }) => {
createdAt: tribe.content.createdAt,
updatedAt: tribe.content.updatedAt,
author: tribe.content.author,
inviteLog: Array.isArray(tribe.content.inviteLog) ? tribe.content.inviteLog : [],
};
},
async listAll() {
const { tribes, tombstoned, tipByRoot, rootByTip } = await buildTribeIndex();
const { tribes, tombstoned, effectivelyTombstoned, tipByRoot, rootByTip } = await buildTribeIndex();
const resolveParent = (pid) => {
if (!pid) return null;
const root = rootByTip.get(pid) || pid;
@ -424,6 +476,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const items = [];
for (const [root, tip] of tipByRoot) {
if (tombstoned.has(root) || tombstoned.has(tip)) continue;
if (effectivelyTombstoned.has(root) || effectivelyTombstoned.has(tip)) continue;
const entry = tribes.get(tip);
if (!entry) continue;
const c = entry.content;
@ -438,6 +491,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
isAnonymous: c.isAnonymous !== false,
members: Array.isArray(c.members) ? c.members : [],
invites: Array.isArray(c.invites) ? c.invites : [],
inviteLog: Array.isArray(c.inviteLog) ? c.inviteLog : [],
inviteMode: c.inviteMode || 'strict',
status: c.status || 'OPEN',
parentTribeId: resolveParent(c.parentTribeId),
@ -445,7 +499,6 @@ module.exports = ({ cooler, tribeCrypto }) => {
createdAt: c.createdAt,
updatedAt: c.updatedAt,
author: c.author,
inviteLog: Array.isArray(c.inviteLog) ? c.inviteLog : [],
_ts: entry._ts
});
}
@ -495,31 +548,38 @@ module.exports = ({ cooler, tribeCrypto }) => {
if (!oldKey) return;
const newKey = tribeCrypto.generateTribeKey();
const newGen = tribeCrypto.addNewKey(rootId, newKey);
const allKeys = tribeCrypto.getKeys(rootId);
const fullPayload = JSON.stringify({ keys: allKeys, gen: newGen });
const memberKeys = {};
const memberKeysFull = {};
for (const memberId of remainingMembers) {
memberKeys[memberId] = tribeCrypto.boxKeyForMember(newKey, memberId, ssbKeys);
try { memberKeys[memberId] = tribeCrypto.boxKeyForMember(newKey, memberId, ssbKeys); } catch (_) {}
try { memberKeysFull[memberId] = tribeCrypto.boxKeyForMember(fullPayload, memberId, ssbKeys); } catch (_) {}
}
const entries = Object.entries(memberKeys);
const BATCH_SIZE = 20;
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
const batch = Object.fromEntries(entries.slice(i, i + BATCH_SIZE));
const batchSingle = Object.fromEntries(entries.slice(i, i + BATCH_SIZE));
const batchFull = {};
for (const id of Object.keys(batchSingle)) {
if (memberKeysFull[id]) batchFull[id] = memberKeysFull[id];
}
await new Promise((resolve, reject) => {
ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: newGen, memberKeys: batch },
ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: newGen, memberKeys: batchSingle, memberKeysFull: batchFull },
(err, res) => err ? reject(err) : resolve(res));
});
}
const tribe = await this.getTribeById(tribeId);
if (Array.isArray(tribe.invites) && tribe.invites.length > 0) {
const ancestryIds = await this.getAncestryChain(tribeId).catch(() => [rootId]);
const updatedInvites = tribe.invites.map(inv => {
if (typeof inv === 'object' && inv.code) {
const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, inv.code);
if (ekChain) return { code: inv.code, ekChain, gen: newGen };
return { code: inv.code, ek: tribeCrypto.encryptForInvite(newKey, inv.code), gen: newGen };
}
return inv;
const survivingInvites = tribe.invites.map(inv => {
if (typeof inv === 'string') return inv;
if (!inv || typeof inv !== 'object') return inv;
const next = { ...inv, gen: newGen };
delete next.ekChain;
delete next.ek;
return next;
});
await this.updateTribeInvites(tribeId, updatedInvites);
await this.updateTribeInvites(tribeId, survivingInvites);
}
},
@ -530,20 +590,38 @@ module.exports = ({ cooler, tribeCrypto }) => {
const config = require('../server/ssb_config');
const msgs = await new Promise((resolve, reject) => {
pull(
ssb.createLogStream({ limit: logLimit }),
ssb.createLogStream({ limit: tribeLogLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
);
});
const byTribe = new Map();
for (const m of msgs) {
const c = m.value?.content;
if (!c || c.type !== 'tribe-keys') continue;
const myEntry = c.memberKeys && c.memberKeys[ssb.id];
if (!myEntry) continue;
const currentGen = tribeCrypto.getGen(c.tribeId);
if (c.generation <= currentGen) continue;
const newKey = tribeCrypto.unboxKeyFromMember(myEntry, config.keys, ssbKeys);
if (newKey) {
tribeCrypto.addNewKey(c.tribeId, newKey);
if (!c || c.type !== 'tribe-keys' || !c.tribeId) continue;
const fullEntry = c.memberKeysFull && c.memberKeysFull[ssb.id];
const singleEntry = c.memberKeys && c.memberKeys[ssb.id];
if (!fullEntry && !singleEntry) continue;
const list = byTribe.get(c.tribeId) || [];
list.push({ generation: c.generation || 0, fullEntry, singleEntry });
byTribe.set(c.tribeId, list);
}
for (const [tribeId, entries] of byTribe.entries()) {
entries.sort((a, b) => b.generation - a.generation);
const top = entries[0];
const knownGen = tribeCrypto.getGen(tribeId);
if (top.fullEntry) {
try {
const text = tribeCrypto.unboxKeyFromMember(top.fullEntry, config.keys, ssbKeys);
const parsed = text ? JSON.parse(text) : null;
if (parsed && Array.isArray(parsed.keys) && parsed.keys.length) {
tribeCrypto.mergeKeys(tribeId, parsed.keys, parsed.gen || top.generation || knownGen);
continue;
}
} catch (_) {}
}
if (top.singleEntry && top.generation > knownGen) {
const newKey = tribeCrypto.unboxKeyFromMember(top.singleEntry, config.keys, ssbKeys);
if (newKey) tribeCrypto.addNewKey(tribeId, newKey);
}
}
},
@ -562,7 +640,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const myFollows = new Map();
await new Promise((resolve, reject) => {
pull(
ssb.createLogStream({ limit: logLimit }),
ssb.createLogStream({ limit: tribeLogLimit }),
pull.collect((err, msgs) => {
if (err) return reject(err);
for (const m of msgs) {
@ -636,12 +714,19 @@ module.exports = ({ cooler, tribeCrypto }) => {
tribeIndex = null;
},
async listSubTribes(parentId) {
async listSubTribes(parentId, userId) {
const idx = await buildTribeIndex();
const rootOf = (id) => { let cur = id; while (idx.parent.has(cur)) cur = idx.parent.get(cur); return cur; };
const parentRoot = rootOf(parentId);
const all = await this.listAll();
return all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot);
const subs = all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot);
if (!userId) return subs;
const out = [];
for (const sub of subs) {
const ok = await this.canAccessTribe(userId, sub.id).catch(() => false);
if (ok) out.push(sub);
}
return out;
},
async isTribeMember(userId, tribeId) {
@ -661,6 +746,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
try {
const tribe = await this.getTribeById(tribeId);
if (!tribe) return false;
if (tribe.parentTribeId) {
const parentOk = await this.canAccessTribe(userId, tribe.parentTribeId).catch(() => false);
if (!parentOk) return false;
}
if (tribe.author === userId) return true;
if (Array.isArray(tribe.members) && tribe.members.includes(userId)) return true;
const effective = await this.getEffectiveStatus(tribeId);

View file

@ -48,8 +48,31 @@ const manifestFile = path.join(config.path, 'manifest.json');
let server;
const argv = process.argv.slice(2);
const isLockError = (err) => {
if (!err) return false;
if (err.name === 'OpenError') return true;
const msg = String(err.message || '');
return /Resource temporarily unavailable/i.test(msg) && /\.ssb\/.*LOCK/i.test(msg);
};
const handleFatal = (err) => {
if (isLockError(err)) {
console.log('');
console.log('Another Oasis instance is already running on this device. Close the other instance (or kill the process) and try again.');
console.log('');
process.exit(1);
}
throw err;
};
process.on('uncaughtException', handleFatal);
if (argv[0] === 'start') {
server = Server(config);
try {
server = Server(config);
} catch (err) {
handleFatal(err);
}
fs.writeFileSync(manifestFile, JSON.stringify(server.getManifest(), null, 2));
const { cmdAliases } = require('../client/cli-cmd-aliases');
@ -91,7 +114,14 @@ if (argv[0] === 'start') {
}
const { printMetadata, colors } = require('./ssb_metadata');
printMetadata('OASIS Server Only', colors.cyan);
printMetadata('OASIS Server Only', colors.cyan, null);
setTimeout(async () => {
try {
const bankingModel = require('../models/banking_model.js')({});
await bankingModel.ensureSelfAddressPublished();
} catch (_) {}
}, 5000);
}

View file

@ -1,6 +1,6 @@
{
"name": "@krakenslab/oasis",
"version": "0.7.5",
"version": "0.7.6",
"description": "Oasis Social Networking Project Utopia",
"repository": {
"type": "git",

View file

@ -16,7 +16,7 @@ const {
option
} = require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views");
const { template, i18n, userLink} = require("./main_views");
const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl")
@ -100,7 +100,10 @@ const renderAudioOwnerActions = (filter, audioObj, params = {}) => {
};
const renderAudioCommentsSection = (audioId, comments = [], returnTo = null) => {
const list = safeArr(comments);
const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
const commentsCount = list.length;
return div(
@ -221,7 +224,7 @@ const renderAudioList = (audios, filter, params = {}) => {
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(audioObj.author)}`, class: "user-link" }, `${audioObj.author}`),
userLink(audioObj.author),
showUpdated
? span(
{ class: "votations-comment-date" },
@ -416,7 +419,7 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(audioObj.author)}`, class: "user-link" }, `${audioObj.author}`),
userLink(audioObj.author),
showUpdated
? span(
{ class: "votations-comment-date" },

View file

@ -1,7 +1,7 @@
const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option } =
require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views");
const { template, i18n, userLink} = require("./main_views");
const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl");
@ -59,7 +59,10 @@ const renderBookmarkActions = (filter, bookmark, params = {}) => {
};
const renderBookmarkCommentsSection = (bookmarkId, rootId, comments = [], returnTo = null) => {
const list = safeArr(comments);
const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
const commentsCount = list.length;
return div(
@ -220,7 +223,7 @@ const renderBookmarkList = (filteredBookmarks, filter, params = {}) => {
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(bookmark.author)}`, class: "user-link" }, `${bookmark.author}`),
userLink(bookmark.author),
showUpdated
? span(
{ class: "votations-comment-date" },
@ -453,7 +456,7 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(bookmark.author)}`, class: "user-link" }, `${bookmark.author}`),
userLink(bookmark.author),
showUpdated
? span(
{ class: "votations-comment-date" },

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views")
const { template, i18n, userLink} = require("./main_views")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl")
@ -131,9 +131,7 @@ const renderMessage = (msg, chatAuthor) => {
const isSelf = String(msg.author) === String(userId)
const dateStr = moment(msg.createdAt).format("YYYY/MM/DD HH:mm")
const shortId = msg.author ? "@" + msg.author.slice(1, 9) + "\u2026" : "?"
const authorLink = msg.author
? a({ href: `/author/${encodeURIComponent(msg.author)}`, class: "user-link" }, shortId)
: span("?")
const authorLink = msg.author ? userLink(msg.author) : span("?")
const imageNode = msg.image ? renderMediaBlob(msg.image, null, { class: "chat-message-image" }) : null
@ -237,7 +235,7 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
),
isRestrictedInviteOnly ? null : tr(
td({ class: "tribe-info-value", colspan: "4" },
a({ href: `/author/${encodeURIComponent(chat.author)}`, class: "user-link" }, chat.author)
userLink(chat.author)
)
),
tr(
@ -328,9 +326,12 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
)
: null,
div({ class: "chat-messages-list" },
msgList.length
? msgList.map(msg => renderMessage(msg, chat.author))
: p({ class: "chat-no-messages" }, i18n.chatNoMessages)
(() => {
const visible = msgList.filter(msg => (msg.text && String(msg.text).trim()) || msg.image)
return visible.length
? visible.map(msg => renderMessage(msg, chat.author))
: p({ class: "chat-no-messages" }, i18n.chatNoMessages)
})()
)
)

View file

@ -1,6 +1,6 @@
const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, ul, li, img } = require('../server/node_modules/hyperaxe');
const moment = require('../server/node_modules/moment');
const { template, i18n } = require('./main_views');
const { template, i18n, userLink } = require('./main_views');
const fmt = (d) => moment(d).format('YYYY-MM-DD HH:mm:ss');
@ -341,21 +341,8 @@ const shortId = (id) => {
return `${s.slice(0, 6)}${s.slice(-4)}`;
};
const UserLinkCompact = (id) =>
id
? a(
{ class: 'user-link', href: `/author/${encodeURIComponent(id)}` },
shortId(id)
)
: span('');
const UserLinkFull = (id) =>
id
? a(
{ class: 'user-link', href: `/author/${encodeURIComponent(id)}` },
id
)
: span('');
const UserLinkCompact = (id) => id ? userLink(id) : span('');
const UserLinkFull = (id) => id ? userLink(id) : span('');
const renderRichTextNodes = (raw) => {
const text = String(raw || '');
@ -438,20 +425,14 @@ const CaseCard = (c) => {
const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) =>
span(
{ class: 'mediator' },
a(
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
shortId(mId)
),
userLink(mId),
idx < mediatorsAccuser.length - 1 ? span(', ') : null
)
);
const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) =>
span(
{ class: 'mediator' },
a(
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
shortId(mId)
),
userLink(mId),
idx < mediatorsRespondent.length - 1 ? span(', ') : null
)
);
@ -637,10 +618,7 @@ const MyCaseCard = (c) => {
const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) =>
span(
{},
a(
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
mId
),
userLink(mId),
idx < mediatorsAccuser.length - 1 ? span(', ') : null
)
);
@ -648,10 +626,7 @@ const MyCaseCard = (c) => {
const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) =>
span(
{},
a(
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
mId
),
userLink(mId),
idx < mediatorsRespondent.length - 1 ? span(', ') : null
)
);
@ -945,12 +920,7 @@ const NominationsTable = (nominations = [], currentUserId = '') => {
currentUserId &&
String(n.judgeId || '') === String(currentUserId || '');
return tr(
td(
a(
{ class: 'user-link', href: `/author/${encodeURIComponent(n.judgeId)}` },
n.judgeId
)
),
td(userLink(n.judgeId)),
td(String(n.supports || 0)),
td(fmt(n.createdAt)),
td(
@ -1081,20 +1051,14 @@ const CaseDetailsBlock = (c) => {
const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) =>
span(
{ class: 'mediator' },
a(
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
shortId(mId)
),
userLink(mId),
idx < mediatorsAccuser.length - 1 ? span(', ') : null
)
);
const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) =>
span(
{ class: 'mediator' },
a(
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
shortId(mId)
),
userLink(mId),
idx < mediatorsRespondent.length - 1 ? span(', ') : null
)
);

View file

@ -1,5 +1,5 @@
const { form, button, div, h2, p, section, textarea, label, input, br, img, a, select, option } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require('./main_views');
const { template, i18n, userLink} = require('./main_views');
const { renderUrl } = require('../backend/renderUrl');
const generateCVBox = (label, content, className) => {
@ -156,7 +156,7 @@ exports.cvView = async (cv) => {
})
: null,
cv.name ? h2(`${cv.name}`) : null,
cv.contact ? p(a({ class: "user-link", href: `/author/${encodeURIComponent(cv.contact)}` }, cv.contact)) : null,
cv.contact ? p(userLink(cv.contact)) : null,
cv.description ? p(...renderUrl(`${cv.description}`)) : null,
(cv.personalSkills && cv.personalSkills.length)
? div(

View file

@ -2,7 +2,7 @@ const { form, button, div, h2, p, section, input, label, br, a, span, textarea,
require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment");
const { template, i18n } = require("./main_views");
const { template, i18n, userLink} = require("./main_views");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl");
const opinionCategories = require("../backend/opinion_categories");
@ -74,7 +74,10 @@ const renderDocumentActions = (filter, doc, params = {}) => {
};
const renderDocumentCommentsSection = (documentKey, rootId, comments = [], returnTo = null) => {
const list = safeArr(comments);
const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
const commentsCount = list.length;
return div(
@ -203,7 +206,7 @@ const renderDocumentList = (documents, filter, params = {}) => {
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(doc.author)}`, class: "user-link" }, `${doc.author}`),
userLink(doc.author),
showUpdated
? span(
{ class: "votations-comment-date" },
@ -408,7 +411,7 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params =
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(doc.author)}`, class: "user-link" }, `${doc.author}`),
userLink(doc.author),
showUpdated
? span(
{ class: "votations-comment-date" },

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views");
const { template, i18n, userLink} = require("./main_views");
const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl");
@ -163,10 +163,15 @@ const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
comments && comments.length
(() => {
const visibleComments = (comments || []).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
return visibleComments.length
? div(
{ class: "comments-list" },
comments.map((c) => {
visibleComments.map((c) => {
const author = c.value && c.value.author ? c.value.author : "";
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
@ -192,7 +197,8 @@ const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all
);
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet);
})()
);
};
@ -235,7 +241,7 @@ const renderEventItem = (e, filter) => {
p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(e.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(e.organizer)}`, class: "user-link" }, `${e.organizer}`)
userLink(e.organizer)
)
);
};
@ -472,7 +478,7 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => {
attendees.length
? attendees
.filter(Boolean)
.map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)])
.map((id, i) => [i > 0 ? ", " : "", userLink(id)])
.flat()
: i18n.noAttendees
)
@ -481,7 +487,7 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => {
p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(event.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(event.organizer)}`, class: "user-link" }, `${event.organizer}`)
userLink(event.organizer)
)
),
renderEventCommentsSection(event.id, comments, currentFilter)

View file

@ -1,6 +1,6 @@
const { form, button, div, h2, p, section, input, a, span, img } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views");
const { template, i18n, userLink} = require("./main_views");
const moment = require("../server/node_modules/moment");
const { renderUrl } = require("../backend/renderUrl");
@ -90,7 +90,7 @@ const renderFavoriteCard = (item, filter) => {
p(
{ class: "card-footer" },
absDate ? span({ class: "date-link" }, `${absDate} ${i18n.performed} `) : "",
item.author ? a({ href: `/author/${encodeURIComponent(item.author)}`, class: "user-link" }, `${item.author}`) : ""
item.author ? userLink(item.author) : ""
)
);
};

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, span, textarea, br, input, h1, label } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views");
const { template, i18n, userLink } = require("./main_views");
const { config } = require("../server/SSB_server.js");
const { renderTextWithStyles } = require("../backend/renderTextWithStyles");
const opinionCategories = require("../backend/opinion_categories");
@ -193,7 +193,7 @@ const renderFeedCard = (feed) => {
p(
{ class: "card-footer" },
span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(authorId)}`, class: "user-link" }, `${authorId}`),
userLink(authorId),
content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
)
)
@ -361,7 +361,7 @@ exports.singleFeedView = (feed, comments = []) => {
p(
{ class: "card-footer" },
span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(authorId)}`, class: "user-link" }, authorId),
userLink(authorId),
content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
)
)

View file

@ -3,7 +3,7 @@ const {
input, label, br, select, option, h2, textarea
} = require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment");
const { template, i18n } = require('./main_views');
const { template, i18n, userLink } = require('./main_views');
const { config } = require('../server/SSB_server.js');
const { renderUrl } = require('../backend/renderUrl');
const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
@ -100,6 +100,7 @@ const renderForumForm = () =>
const renderThread = (nodes, level = 0, forumId) => {
if (!Array.isArray(nodes)) return [];
return [...nodes]
.filter(m => (m && m.text && String(m.text).trim()) || (m && Array.isArray(m.children) && m.children.length))
.sort((a, b) =>
wilsonScore(b.positiveVotes, b.negativeVotes)
- wilsonScore(a.positiveVotes, a.negativeVotes)
@ -117,11 +118,7 @@ const renderThread = (nodes, level = 0, forumId) => {
div({ class: 'comment-header' },
span({ class: 'date-link' },
`${moment(m.timestamp).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
a({
href: `/author/${encodeURIComponent(m.author)}`,
class: 'user-link',
style: 'margin-left:12px;'
}, m.author),
userLink(m.author),
div({ class: 'comment-votes' },
span({ class: 'votes-count' }, `▲: ${m.positiveVotes || 0}`),
span({ class: 'votes-count', style: 'margin-left:12px;' },
@ -164,10 +161,13 @@ const renderThread = (nodes, level = 0, forumId) => {
});
};
const renderForumList = (forums, currentFilter) =>
div({ class: 'forum-list' },
Array.isArray(forums) && forums.length
? forums.map(f =>
const renderForumList = (forums, currentFilter) => {
const visibleForums = (Array.isArray(forums) ? forums : []).filter(f =>
(f && f.title && String(f.title).trim()) || (f && f.text && String(f.text).trim())
)
return div({ class: 'forum-list' },
visibleForums.length
? visibleForums.map(f =>
div({ class: 'forum-card' },
div({ class: 'forum-score-col' },
renderVotes(f.key, f.score, f.key)
@ -203,11 +203,7 @@ const renderForumList = (forums, currentFilter) =>
div({ class: 'forum-footer' },
span({ class: 'date-link' },
`${moment(f.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
a({
href: `/author/${encodeURIComponent(f.author)}`,
class: 'user-link',
style: 'margin-left:12px;'
}, f.author)
userLink(f.author)
),
currentFilter === 'mine' && f.author === userId
? div({ class: 'forum-owner-actions' },
@ -226,6 +222,7 @@ const renderForumList = (forums, currentFilter) =>
)
: p(i18n.noForums)
);
}
exports.forumView = async (forums, currentFilter) => {
const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{});
@ -312,11 +309,7 @@ exports.singleForumView = async (forum, messagesData, currentFilter) => {
div({ class: 'forum-footer' },
span({ class: 'date-link' },
`${moment(forum.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
a({
href: `/author/${encodeURIComponent(forum.author)}`,
class: 'user-link',
style: 'margin-left:12px;'
}, forum.author)
userLink(forum.author)
),
div({
class: 'forum-body',

View file

@ -1,5 +1,5 @@
const { div, h2, h3, p, section, form, input, button, a, img, table, tr, td, th, span, iframe } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require('./main_views');
const { template, i18n, userLink} = require('./main_views');
const moment = require("../server/node_modules/moment");
const getGames = () => [
@ -44,7 +44,7 @@ const renderHallOfFame = (hall) => {
...hall[game.id].map((entry, idx) =>
tr(
td(String(idx + 1)),
td(a({ href: `/author/${encodeURIComponent(entry.author)}`, class: 'user-link' }, entry.author)),
td(userLink(entry.author)),
td({ class: idx === 0 ? 'score-first' : '' }, String(entry.score)),
td(entry.ts ? moment(entry.ts).format('YYYY-MM-DD') : '\u2014')
)
@ -124,7 +124,7 @@ exports.gamesView = (filter = 'all', hall = null) => {
p({ class: 'game-card-desc game-desc-yellow' }, game.desc()),
topScore
? p({ class: 'game-top-score' },
a({ href: `/author/${encodeURIComponent(topScore.author)}`, class: 'user-link' }, topScore.author),
userLink(topScore.author),
span({ class: 'game-new-record-label' }, ' - ' + (i18n.gamesNewRecord || 'New Record') + ': '),
String(topScore.score)
)

View file

@ -0,0 +1,129 @@
const h = require("../server/node_modules/hyperaxe");
const { div, h2, p, section, button, form, input, span } = h;
const { template, i18n } = require('./main_views');
const TAU = Math.PI * 2;
const escAttr = (s) => String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const escText = (s) => String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const adaptiveLayout = (n) => {
if (n <= 1) return { rRatio: 0.22, nodeR: 30, labelGap: 18 };
if (n <= 3) return { rRatio: 0.28, nodeR: 26, labelGap: 16 };
if (n <= 6) return { rRatio: 0.34, nodeR: 22, labelGap: 14 };
if (n <= 12) return { rRatio: 0.40, nodeR: 18, labelGap: 14 };
return { rRatio: 0.44, nodeR: 14, labelGap: 12 };
};
const buildGraphSvg = (me, peers) => {
const W = 900;
const H = 600;
const cx = W / 2;
const cy = H / 2;
const N = Math.max(1, peers.length);
const { rRatio, nodeR, labelGap } = adaptiveLayout(N);
const r = Math.min(W, H) * rRatio;
const meR = nodeR + 6;
const positions = peers.map((peer, i) => {
const angle = (i / N) * TAU - Math.PI / 2;
return {
peer,
x: cx + r * Math.cos(angle),
y: cy + r * Math.sin(angle)
};
});
const edges = positions.map(({ peer, x, y }) =>
`<line x1="${cx}" y1="${cy}" x2="${x.toFixed(2)}" y2="${y.toFixed(2)}" class="graphos-edge graphos-edge-${peer.kind}" />`
).join('');
const nodes = positions.map(({ peer, x, y }) => {
const xs = x.toFixed(2);
const ys = y.toFixed(2);
const labelY = (y + nodeR + labelGap).toFixed(2);
const href = `/author/${escAttr(encodeURIComponent(peer.key))}`;
const name = escText(peer.name);
return `<a href="${href}" class="graphos-node-link">`
+ `<g class="graphos-node graphos-node-${peer.kind}">`
+ `<title>${name} (${peer.kind})</title>`
+ `<circle cx="${xs}" cy="${ys}" r="${nodeR}" class="graphos-node-circle graphos-node-circle-${peer.kind}" />`
+ `<text x="${xs}" y="${labelY}" text-anchor="middle" class="graphos-node-label">${name}</text>`
+ `</g></a>`;
}).join('');
const meLabelY = (cy + meR + labelGap + 2).toFixed(2);
const meHref = `/author/${escAttr(encodeURIComponent(me.key))}`;
const meName = escText(me.name);
const center = `<a href="${meHref}" class="graphos-node-link">`
+ `<g class="graphos-node graphos-node-me">`
+ `<title>${meName} (you)</title>`
+ `<circle cx="${cx}" cy="${cy}" r="${meR}" class="graphos-node-circle graphos-node-circle-me" />`
+ `<text x="${cx}" y="${meLabelY}" text-anchor="middle" class="graphos-node-label graphos-node-label-me">${meName}</text>`
+ `</g></a>`;
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" class="graphos-svg">`
+ edges + nodes + center
+ `</svg>`;
};
const kpi = (label, value) => div({ class: 'stats-kpi' },
div({ class: 'stats-kpi-label' }, label),
div({ class: 'stats-kpi-value' }, String(value))
);
const legendItem = (kind, label) =>
span({ class: 'graphos-legend-item' },
span({ class: `graphos-legend-dot graphos-node-circle-${kind}` }),
span(label)
);
exports.graphosView = ({ filter, me, peers, kpis }) => {
const title = i18n.graphos || 'Graphos';
const description = i18n.graphosDescription || 'Interactive map of the network around you.';
const modes = ['ALL', 'MINE'];
return template(
title,
section(
div({ class: 'tags-header' },
h2(title),
p(description)
),
div({ class: 'mode-buttons stats-mode-row' },
modes.map(m =>
form({ method: 'GET', action: '/graphos' },
input({ type: 'hidden', name: 'filter', value: m }),
button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, i18n[m + 'Button'])
)
)
),
div({ class: 'graphos-legend' },
legendItem('me', i18n.graphosYou || 'You'),
legendItem('online', i18n.online || 'Online'),
filter !== 'MINE' ? legendItem('discovered', i18n.discovered || 'Discovered') : null,
filter !== 'MINE' ? legendItem('unknown', i18n.unknown || 'Unknown') : null
),
div({ class: 'graphos-canvas', innerHTML: buildGraphSvg(me, peers) }),
div({ class: 'stats-block' },
div({ class: 'stats-grid' },
kpi(i18n.graphosTotalNodes || 'Total nodes', kpis.total),
kpi(i18n.online || 'Online', kpis.online),
filter !== 'MINE' ? kpi(i18n.discovered || 'Discovered', kpis.discovered) : null,
filter !== 'MINE' ? kpi(i18n.unknown || 'Unknown', kpis.unknown) : null
)
)
)
);
};

View file

@ -2,7 +2,7 @@ const { form, button, div, h2, p, section, input, label, br, a, img, span, texta
require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment");
const { template, i18n } = require("./main_views");
const { template, i18n, userLink} = require("./main_views");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl")
const { renderMapLocationVisitLabel } = require("./maps_view");
@ -160,7 +160,7 @@ const renderImageList = (images, filter, params = {}) => {
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(imgObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(imgObj.author)}`, class: "user-link" }, `${imgObj.author}`),
userLink(imgObj.author),
showUpdated
? span(
{ class: "votations-comment-date" },
@ -255,7 +255,10 @@ const renderLightbox = (images) =>
});
const renderImageCommentsSection = (imageKey, comments = [], returnTo = null) => {
const list = safeArr(comments);
const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
const commentsCount = list.length;
return div(
@ -475,7 +478,7 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(imageObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(imageObj.author)}`, class: "user-link" }, `${imageObj.author}`),
userLink(imageObj.author),
showUpdated
? span(
{ class: "votations-comment-date" },

View file

@ -1,5 +1,5 @@
const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, progress, video, audio } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views")
const { template, i18n, userLink} = require("./main_views")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl")
@ -268,7 +268,7 @@ const renderJobList = (jobs, filter, params = {}) => {
p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(job.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(job.author)}`, class: "user-link" }, job.author),
userLink(job.author),
renderUpdatedLabel(job.createdAt, job.updatedAt)
)
)
@ -388,7 +388,7 @@ const renderCVList = (inhabitants) =>
div(
{ class: "inhabitant-details" },
user.description ? p(...renderUrl(user.description)) : null,
p(a({ class: "user-link", href: `/author/${encodeURIComponent(user.id)}` }, user.id)),
p(userLink(user.id)),
div(
{ class: "cv-actions" },
form({ method: "GET", action: `/inhabitant/${encodeURIComponent(user.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.inhabitantviewDetails)),
@ -481,7 +481,10 @@ exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => {
}
const renderJobCommentsSection = (jobId, returnTo, comments = []) => {
const list = safeArr(comments)
const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text
return t && String(t).trim()
})
const commentsCount = list.length
return div(
@ -582,7 +585,7 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {})
p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(job.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(job.author)}`, class: "user-link" }, job.author),
userLink(job.author),
renderUpdatedLabel(job.createdAt, job.updatedAt)
)
),

View file

@ -28,6 +28,23 @@ const { a, article, br, body, button, details, div, em, footer, form, h1, h2, h3
const lodash = require("../server/node_modules/lodash");
const markdown = require("./markdown");
const { sanitizeHtml } = require('../backend/sanitizeHtml');
const nameCache = require('../backend/nameCache');
const userLinkLabel = (feedId, knownName) => {
const id = String(feedId || '');
if (!id) return '';
const name = (knownName && String(knownName).trim()) || nameCache.get(id);
if (name && name.length) return '@' + name;
return id;
};
const userLink = (feedId, knownName) => {
if (!feedId) return null;
return a({ class: 'user-link', href: `/author/${encodeURIComponent(feedId)}` }, userLinkLabel(feedId, knownName));
};
exports.userLink = userLink;
exports.userLinkLabel = userLinkLabel;
const i18nBase = require("../client/assets/translations/i18n");
let selectedLanguage = "en";

View file

@ -2,7 +2,7 @@ const { form, button, div, h2, h3, p, section, input, label, br, a, span, textar
require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment");
const { template, i18n } = require("./main_views");
const { template, i18n, userLink} = require("./main_views");
const { config } = require("../server/SSB_server.js");
const { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, latLngToPx, pxToLatLng, MAP_W, MAP_H, getMaxTileZoom } = require("../maps/map_renderer");
const { sanitizeHtml } = require('../backend/sanitizeHtml');
@ -313,7 +313,7 @@ const renderMarkersList = (markers, mapObj) => {
span({ class: "map-marker-dot" }, "ꔌ"),
span({ class: "map-marker-coords" }, `${(typeof mk.lat === 'number' ? mk.lat : 0).toFixed(4)}, ${(typeof mk.lng === 'number' ? mk.lng : 0).toFixed(4)}`),
span({ class: "map-marker-meta" },
a({ href: `/author/${encodeURIComponent(mk.author)}`, class: "user-link" }, mk.author),
userLink(mk.author),
` · ${moment(mk.createdAt).fromNow()}`))
])));
};
@ -351,7 +351,7 @@ const renderMapCard = (mapObj, filter, params = {}) => {
p({ class: "card-footer" },
span({ class: "date-link" }, moment(mapObj.createdAt).fromNow()),
span(" · "),
a({ href: `/author/${encodeURIComponent(mapObj.author)}`, class: "user-link" }, mapObj.author))));
userLink(mapObj.author))));
};
const renderMapList = (maps, filter, params = {}) =>
@ -433,7 +433,7 @@ exports.singleMapView = async (mapObj, filter = "all", params = {}) => {
br(),
p({ class: "card-footer" },
span({ class: "date-link" }, `${moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(mapObj.author)}`, class: "user-link" }, mapObj.author),
userLink(mapObj.author),
mapObj.updatedAt && mapObj.updatedAt !== mapObj.createdAt
? span({ class: "votations-comment-date" }, ` · ${i18n.mapUpdatedAt}: ${moment(mapObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`)
: null),

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views")
const { template, i18n, userLink} = require("./main_views")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl")
@ -138,10 +138,15 @@ const renderMarketCommentsSection = (itemId, returnTo, comments = []) => {
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
comments && comments.length
(() => {
const visibleComments = (comments || []).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text
return t && String(t).trim()
})
return visibleComments.length
? div(
{ class: "comments-list" },
comments.map((c) => {
visibleComments.map((c) => {
const author = c.value && c.value.author ? c.value.author : ""
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
@ -166,6 +171,7 @@ const renderMarketCommentsSection = (itemId, returnTo, comments = []) => {
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
})()
)
}
@ -678,7 +684,7 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
renderCardField(`${i18n.marketItemIncludesShipping}:`, `${item.includesShipping ? i18n.YESLabel : i18n.NOLabel}`),
renderMapEmbedWithZoom(params.mapData, item.mapUrl, `/market/${encodeURIComponent(item.id)}`, params.zoom),
item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, `${moment(item.deadline).format("YYYY/MM/DD HH:mm:ss")}`) : null,
renderCardFieldRich(`${i18n.marketItemSeller}:`, [a({ class: "user-link", href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)])
renderCardFieldRich(`${i18n.marketItemSeller}:`, [userLink(item.seller)])
),
item.item_type === "auction"
? div(

View file

@ -7,6 +7,7 @@ const modulesView = () => {
const modules = [
{ name: 'agenda', label: i18n.modulesAgendaLabel, description: i18n.modulesAgendaDescription },
{ name: 'ai', label: i18n.modulesAILabel, description: i18n.modulesAIDescription },
{ name: 'aiNav', label: i18n.modulesAINavLabel, description: i18n.modulesAINavDescription },
{ name: 'audios', label: i18n.modulesAudiosLabel, description: i18n.modulesAudiosDescription },
{ name: 'banking', label: i18n.modulesBankingLabel, description: i18n.modulesBankingDescription },
{ name: 'bookmarks', label: i18n.modulesBookmarksLabel, description: i18n.modulesBookmarksDescription },
@ -20,6 +21,7 @@ const modulesView = () => {
{ name: 'feed', label: i18n.modulesFeedLabel, description: i18n.modulesFeedDescription },
{ name: 'forum', label: i18n.modulesForumLabel, description: i18n.modulesForumDescription },
{ name: 'games', label: i18n.modulesGamesLabel, description: i18n.modulesGamesDescription },
{ name: 'graphos', label: i18n.modulesGraphosLabel, description: i18n.modulesGraphosDescription },
{ name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription },
{ name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
{ name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription },
@ -86,7 +88,7 @@ const modulesView = () => {
full: modules.map(m => m.name)
};
const presetButtons = div({ class: 'preset-group mode-buttons', style: 'margin-bottom:16px;' },
const presetButtons = div({ class: 'preset-group', style: 'display:flex;gap:8px;flex-wrap:nowrap;margin-bottom:16px;' },
Object.entries(PRESETS).map(([key, mods]) => {
const presetLabel = (i18n[`modulesPreset_${key}`] || key).toUpperCase();
const isActive = modules.every(m => mods.includes(m.name) === (moduleStates[`${m.name}Mod`] === 'on'));

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, img, video: videoHyperaxe, audio: audioHyperaxe, input, table, tr, th, td, br, span } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require('./main_views');
const { template, i18n, userLink} = require('./main_views');
const { config } = require('../server/SSB_server.js');
const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
const { renderUrl } = require('../backend/renderUrl');
@ -215,11 +215,11 @@ const renderContentHtml = (content, key) => {
),
div({ class: 'card-field' },
span({ class: 'card-label' }, i18n.from + ':'),
span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(content.from)}`, target: "_blank" }, content.from))
span({ class: 'card-value' }, userLink(content.from))
),
div({ class: 'card-field' },
span({ class: 'card-label' }, i18n.to + ':'),
span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(content.to)}`, target: "_blank" }, content.to))
span({ class: 'card-value' }, userLink(content.to))
),
h2({ class: 'card-field' },
span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
@ -273,7 +273,7 @@ exports.opinionsView = (items, filter) => {
contentHtml,
p({ class: 'card-footer' },
span({ class: 'date-link' }, `${created} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(item.value.author)}`, class: 'user-link' }, item.value.author)
userLink(item.value.author)
),
(() => {
const entries = voteEntries.filter(([, v]) => v > 0);

View file

@ -1,5 +1,5 @@
const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views")
const { template, i18n, userLink} = require("./main_views")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
@ -226,7 +226,7 @@ exports.singlePadView = async (pad, entries, params) => {
),
table({ class: "tribe-info-table" },
tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY-MM-DD"))),
isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value", colspan: "4" }, a({ href: `/author/${encodeURIComponent(pad.author)}`, class: "user-link" }, pad.author))),
isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value", colspan: "4" }, userLink(pad.author))),
tr(td({ class: "tribe-info-label" }, i18n.padStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(pad.status, padClosed))),
isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
),
@ -296,15 +296,16 @@ exports.singlePadView = async (pad, entries, params) => {
)
: p(i18n.padNoEntries || "No entries yet.")
const versionList = entries.length > 0
const visibleEntries = entries.filter(e => e.text && String(e.text).trim())
const versionList = visibleEntries.length > 0
? div({ class: "pad-version-list" },
h4(i18n.padVersionHistory || "Version History"),
...entries.slice().reverse().map((e, idx) =>
...visibleEntries.slice().reverse().map((e, idx) =>
div({ class: "pad-version-item" },
span({ class: "pad-version-date" }, moment(e.createdAt).format("YYYY-MM-DD HH:mm")),
span({ class: "pad-version-author" },
span({ class: "pad-author-swatch " + memberColorClass(pad.members, e.author) }),
a({ href: `/author/${encodeURIComponent(e.author)}`, class: "user-link" }, "@" + e.author.slice(1, 9) + "\u2026")
userLink(e.author)
),
a({ href: `/pads/${encodeURIComponent(pad.rootId)}?version=${encodeURIComponent(e.key || idx)}`, class: "pad-version-link" }, i18n.padVersionView || "View")
)

View file

@ -1,6 +1,6 @@
const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, ul, li, img } = require('../server/node_modules/hyperaxe');
const moment = require("../server/node_modules/moment");
const { template, i18n } = require('./main_views');
const { template, i18n, userLink} = require('./main_views');
const TERM_DAYS = 60;
@ -131,7 +131,7 @@ const GovernmentCard = (g, meta) => {
const actorLink =
g.powerType === 'tribe'
? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId)
: a({ class: 'user-link', href: `/author/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId);
: userLink(g.powerId, g.powerTitle);
const actorBio = meta && meta.bio ? meta.bio : '';
const memberIds = Array.isArray(g.membersList) ? g.membersList : (Array.isArray(g.members) ? g.members : []);
const membersRow =
@ -143,7 +143,7 @@ const GovernmentCard = (g, meta) => {
div(
span({ class: 'card-label' }, (i18n.parliamentMembers + ': ').toUpperCase()),
memberIds && memberIds.length
? ul({ class: 'parliament-members-list' }, ...memberIds.map(id => li(a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id))))
? ul({ class: 'parliament-members-list' }, ...memberIds.map(id => li(userLink(id))))
: span({ class: 'card-value' }, String(g.members || 0))
)
)
@ -247,7 +247,7 @@ const CandidatureStats = (cands, govCard, leaderMeta) => {
const winLbl = (i18n.parliamentWinningCandidature || i18n.parliamentCurrentLeader || 'WINNING CANDIDATURE').toUpperCase();
const idLink = leader
? (leader.targetType === 'inhabitant'
? a({ class: 'user-link', href: `/author/${encodeURIComponent(leader.targetId)}` }, leader.targetId)
? userLink(leader.targetId)
: a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(leader.targetId)}?` }, leader.targetTitle || leader.targetId))
: null;
return div(
@ -287,7 +287,7 @@ const CandidaturesTable = (candidatures) => {
const rows = (candidatures || []).map(c => {
const idLink =
c.targetType === 'inhabitant'
? p(a({ class: 'user-link break-all', href: `/author/${encodeURIComponent(c.targetId)}` }, c.targetId))
? p(userLink(c.targetId))
: p(a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(c.targetId)}?` }, c.targetTitle || c.targetId));
return tr(
td(idLink),
@ -349,7 +349,7 @@ const ProposalsList = (proposals) => {
div(
{ class: 'card-field' },
span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '),
span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer))
span({ class: 'card-value' }, userLink(pItem.proposer))
),
div(
{ class: 'card-field' },
@ -419,7 +419,7 @@ const FutureLawsList = (rows) => {
br(),
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)),
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))),
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer))),
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(pItem.proposer))),
h2(pItem.title || ''),
p(pItem.description || '')
)
@ -480,7 +480,7 @@ const RevocationsList = (revocations) => {
span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '),
span(
{ class: 'card-value' },
a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer)
userLink(pItem.proposer)
)
),
div(
@ -551,7 +551,7 @@ const FutureRevocationsList = (rows) => {
br(),
pItem.method ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)) : null,
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))),
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer))),
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(pItem.proposer))),
h2(pItem.title || pItem.lawTitle || ''),
p(pItem.reasons || '')
)
@ -605,7 +605,7 @@ const LawsList = (laws) => {
br(),
div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()), span({ class: 'card-value' }, l.method)),
div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawEnacted + ': ').toUpperCase()), span({ class: 'card-value' }, fmt(l.enactedAt))),
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(l.proposer)}` }, l.proposer))),
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(l.proposer))),
h2(l.question || ''),
p(l.description || ''),
showMetricsFlag ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesNeeded + ': ').toUpperCase()), span({ class: 'card-value' }, String(needed))) : null,
@ -666,7 +666,7 @@ const HistoricalList = (rows, metasByKey = {}) => {
span({ class: 'card-value' },
g.powerType === 'tribe'
? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId)
: a({ class: 'user-link', href: `/author/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId)
: userLink(g.powerId, g.powerTitle)
)
) : null,
(g.method !== 'ANARCHY')
@ -785,7 +785,7 @@ const LeadersList = (leaders, metas = {}, candidatures = []) => {
const avatar = meta.avatarUrl ? img({ src: meta.avatarUrl, alt: '', class: 'leader-table__avatar' }) : null;
const link = l.powerType === 'tribe'
? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(l.powerId)}` }, l.powerTitle || l.powerId)
: a({ class: 'user-link', href: `/author/${encodeURIComponent(l.powerId)}` }, l.powerTitle || l.powerId);
: userLink(l.powerId, l.powerTitle);
const leaderCell = div({ class: 'leader-cell' }, avatar, link);
return tr(
td(leaderCell),

View file

@ -23,10 +23,10 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
const { name, users, key } = peer;
const peerUrl = `/author/${encodeURIComponent(key)}`;
const filteredUsers = (users || []).filter(u => u.id !== key);
const userCount = filteredUsers.length || peer.announcers || 0;
const userCount = filteredUsers.length;
return tr(
td(a({ href: peerUrl, class: "user-link" }, name || key.slice(0, 20) + '…')),
td(span({ style: 'word-break:break-all;font-size:12px;color:#888;' }, key)),
td(a({ href: peerUrl, class: 'user-link peer-key' }, key)),
td(String(userCount))
);
};
@ -35,19 +35,9 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
const dedupDiscovered = deduplicatePeers(discoveredPeers);
const dedupUnknown = deduplicatePeers(unknownPeers);
const countPeers = (list) => {
let usersTotal = 0;
for (const item of list) {
const peerKey = item[1].key;
const users = (item[1].users || []).filter(u => u.id !== peerKey);
usersTotal += users.length || item[1].announcers || 0;
}
return list.length + usersTotal;
};
const onlineCount = countPeers(dedupOnline);
const discoveredCount = countPeers(dedupDiscovered);
const unknownCount = countPeers(dedupUnknown);
const onlineCount = dedupOnline.length;
const discoveredCount = dedupDiscovered.length;
const unknownCount = dedupUnknown.length;
const renderPeerTable = (peers) => {
if (peers.length === 0) return p(i18n.noConnections || i18n.noDiscovered);
@ -55,7 +45,7 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
tr(
td({ class: 'card-label' }, i18n.peerHost || 'Pub'),
td({ class: 'card-label' }, 'Key'),
td({ class: 'card-label' }, i18n.inhabitants || 'Inhabitants')
td({ class: 'card-label' }, i18n.peersReplicatedFeeds || 'Replicated feeds')
),
...peers.map(renderPeerRow)
);

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, form, input, label, select, option, button, table, tr, td, hr, ul, li, a, br } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require('./main_views');
const { template, i18n, userLink} = require('./main_views');
exports.pixeliaView = (pixelArt, errorMessage) => {
const title = i18n.pixeliaTitle;
@ -81,7 +81,7 @@ exports.pixeliaView = (pixelArt, errorMessage) => {
h2(i18n.contributorsTitle),
ul(
...contributors.map(author =>
li(a({ class: 'user-link', href: `/author/${encodeURIComponent(author)}` }, author))
li(userLink(author))
)
)
) : null

View file

@ -1,5 +1,5 @@
const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, ul, li, table, thead, tbody, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views")
const { template, i18n, userLink} = require("./main_views")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl")
@ -142,7 +142,7 @@ const renderFollowers = (project) => {
return div(
{ class: "followers-block" },
h2(i18n.projectFollowersTitle),
ul(show.map((uid) => li(a({ href: `/author/${encodeURIComponent(uid)}`, class: "user-link" }, uid)))),
ul(show.map((uid) => li(userLink(uid)))),
followers.length > show.length ? p(`+${followers.length - show.length} ${i18n.projectMore}`) : null
)
}
@ -171,7 +171,7 @@ const renderBackers = (project, filter) => {
...backers.slice(0, 8).map((b) =>
tr(
td(b.at ? moment(b.at).format("YYYY/MM/DD HH:mm") : ""),
td(a({ href: `/author/${encodeURIComponent(b.userId)}`, class: "user-link" }, b.userId)),
td(userLink(b.userId)),
td(`${b.amount} ECO`)
)
)
@ -285,7 +285,7 @@ const renderMilestonesAndBounties = (project, filter, editable) => {
),
safeText(b.description) ? p(...renderUrl(b.description)) : null,
renderCardField(i18n.projectBountyStatus + ":", statusText),
b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", a({ href: `/author/${encodeURIComponent(b.claimedBy)}`, class: "user-link" }, b.claimedBy)) : null,
b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", userLink(b.claimedBy)) : null,
!editable && !b.done && !b.claimedBy && project.author !== userId
? form(
{ method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
@ -344,7 +344,7 @@ const renderMilestonesAndBounties = (project, filter, editable) => {
),
safeText(b.description) ? p(...renderUrl(b.description)) : null,
renderCardField(i18n.projectBountyStatus + ":", statusText),
b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", a({ href: `/author/${encodeURIComponent(b.claimedBy)}`, class: "user-link" }, b.claimedBy)) : null,
b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", userLink(b.claimedBy)) : null,
!editable && !b.done && !b.claimedBy && project.author !== userId
? form(
{ method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
@ -588,7 +588,7 @@ const renderProjectList = (projects, filter) => {
div(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(pr.author)}`, class: "user-link" }, pr.author)
userLink(pr.author)
)
)
})
@ -745,7 +745,7 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => {
div(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(pr.author)}`, class: "user-link" }, pr.author)
userLink(pr.author)
)
),
div(
@ -760,23 +760,29 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => {
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
comments && comments.length
(() => {
const visibleComments = (comments || []).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text
return t && String(t).trim()
})
return visibleComments.length
? div(
{ class: "comments-list" },
comments.map((c) => {
visibleComments.map((c) => {
const author = c?.value?.author || ""
const ts = c?.value?.timestamp || c?.timestamp
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
const relDate = ts ? moment(ts).fromNow() : ""
return div(
{ class: "comment-card" },
div({ class: "comment-header" }, a({ href: `/author/${encodeURIComponent(author)}`, class: "user-link" }, author)),
div({ class: "comment-header" }, userLink(author)),
div({ class: "comment-date" }, span({ title: absDate }, relDate)),
div({ class: "comment-body" }, ...renderUrl(c?.value?.content?.text || ""))
)
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
})()
)
)
}

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, textarea, br, input, img, span, label, select, option, video, audio } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views");
const { template, i18n, userLink} = require("./main_views");
const { config } = require("../server/SSB_server.js");
const moment = require("../server/node_modules/moment");
const { renderUrl } = require("../backend/renderUrl");
@ -215,10 +215,15 @@ const renderReportCommentsSection = (reportId, comments = []) => {
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
comments && comments.length
(() => {
const visibleComments = (comments || []).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
return visibleComments.length
? div(
{ class: "comments-list" },
comments.map((c) => {
visibleComments.map((c) => {
const author = c.value && c.value.author ? c.value.author : "";
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
@ -244,7 +249,8 @@ const renderReportCommentsSection = (reportId, comments = []) => {
);
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet);
})()
);
};
@ -404,7 +410,7 @@ const renderReportCard = (report, userId, currentFilter = "all") => {
p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
a({ class: "user-link", href: `/author/${encodeURIComponent(report.author)}` }, report.author)
userLink(report.author)
)
);
};
@ -659,7 +665,7 @@ exports.singleReportView = async (report, filter, comments = []) => {
p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
a({ class: "user-link", href: `/author/${encodeURIComponent(report.author)}` }, report.author)
userLink(report.author)
)
),
renderReportCommentsSection(report.id, comments)

View file

@ -1,5 +1,5 @@
const { form, button, div, h2, p, section, input, select, option, img, audio: audioHyperaxe, video: videoHyperaxe, table, hr, hd, br, td, tr, th, a, span } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require('./main_views');
const { template, i18n, userLink} = require('./main_views');
const moment = require("../server/node_modules/moment");
const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
const { renderUrl } = require('../backend/renderUrl');
@ -120,7 +120,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'about':
return div({ class: 'search-about' },
content.name ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.name + ':'), span({ class: 'card-value' }, content.name)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.description + ':'), span({ class: 'card-value' }, content.description)) : null,
content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.description + ':'), span({ class: 'card-value' }, content.description)) : null,
content.image ? img({ src: `/image/64/${encodeURIComponent(content.image)}` }) : null
);
case 'feed': {
@ -190,23 +190,15 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
);
case 'tribe':
return div({ class: 'search-tribe' },
content.title ? h2(content.title) : null,
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
(() => { const s = String(content.image || '').trim().replace(/&amp;/g, '&'); const m = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); const src = m ? m[1] : s; return src.startsWith('&') ? img({ src: `/blob/${encodeURIComponent(src)}`, class: 'feed-image' }) : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }); })(),
br(),
content.description ? content.description : null,
br(),br(),
div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' },
content.location ? p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLocationLabel.toUpperCase()}: `, ...renderUrl(content.location)) : null,
p({ style: 'color:#9aa3b2;' }, `${i18n.tribeIsAnonymousLabel}: ${content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
content.inviteMode ? p({ style: 'color:#9aa3b2;' }, `${i18n.tribeModeLabel}: ${content.inviteMode.toUpperCase()}`) : null,
p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLARPLabel}: ${content.isLARP ? i18n.tribeYes : i18n.tribeNo}`)
),
content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, ...renderUrl(content.description))) : null,
content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLocationLabel + ':'), span({ class: 'card-value' }, ...renderUrl(content.location))) : null,
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeIsAnonymousLabel + ':'), span({ class: 'card-value' }, content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)),
content.inviteMode ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeModeLabel + ':'), span({ class: 'card-value' }, String(content.inviteMode).toUpperCase())) : null,
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLARPLabel + ':'), span({ class: 'card-value' }, content.isLARP ? i18n.tribeYes : i18n.tribeNo)),
Array.isArray(content.members)
? div({},
div({ class: 'card-field' },
h2(`${i18n.tribeMembersCount}: ${content.members.length}`),
)
)
? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeMembersCount + ':'), span({ class: 'card-value' }, String(content.members.length)))
: null,
content.tags && content.tags.length
? div({ class: 'card-tags' }, content.tags.map(tag =>
@ -274,8 +266,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
);
case 'torrent':
return div({ class: 'search-torrent' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, content.title)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || 'Description') + ':'), span({ class: 'card-value' }, content.description)) : null,
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || i18n.title) + ':'), span({ class: 'card-value' }, content.title)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || i18n.searchDescription) + ':'), span({ class: 'card-value' }, content.description)) : null,
content.size ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentSizeLabel || 'Size') + ':'), span({ class: 'card-value' }, String(content.size))) : null,
content.tags && content.tags.length
? div({ class: 'card-tags' }, content.tags.map(tag =>
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
@ -292,7 +285,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
br(),
blobImg(content.image),
br(),
content.seller ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemSeller + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.seller)}` }, content.seller))) : null,
content.seller ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemSeller + ':'), span({ class: 'card-value' }, userLink(content.seller))) : null,
content.stock ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock || 'N/A')) : null,
content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriceLabel + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null,
content.condition ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.condition)) : null,
@ -325,8 +318,8 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
);
case 'bookmark':
return div({ class: 'search-bookmark' },
content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, br(),
content.url ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkUrlLabel + ':'), span({ class: 'card-value' }, a({ href: content.url, target: '_blank' }, content.url))) : null,br(),
content.url ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkUrlLabel + ':'), span({ class: 'card-value' }, a({ href: content.url, target: '_blank' }, content.url))) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null,
content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkCategory + ':'), span({ class: 'card-value' }, content.category)) : null,
content.lastVisit ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'), span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString())) : null,
content.tags && content.tags.length
@ -376,8 +369,8 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersStatus + ':'), span({ class: 'card-value' }, content.status)) : null,
content.amount ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersAmount + ':'), span({ class: 'card-value' }, content.amount)) : null,
br(),
content.from ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersFrom + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.from)}` }, content.from))) : null,
content.to ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersTo + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.to)}` }, content.to))) : null,
content.from ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersFrom + ':'), span({ class: 'card-value' }, userLink(content.from))) : null,
content.to ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersTo + ':'), span({ class: 'card-value' }, userLink(content.to))) : null,
br(),
content.confirmedBy && content.confirmedBy.length
? h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConfirmations + ':'), span({ class: 'card-value' }, content.confirmedBy.length))
@ -457,8 +450,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
);
case 'forum':
return div({ class: 'search-forum' },
content.root ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title || '')) : div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title || '')),
content.text ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.text)) : null
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchCategoryLabel + ':'), span({ class: 'card-value' }, content.category)) : null,
content.text ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.text)) : null
);
case 'vote':
return div({ class: 'search-vote-link' },
@ -527,16 +521,6 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
)
) : null
);
case 'torrent':
return div({ class: 'search-torrent' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, content.title)) : null,
content.size ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentSizeLabel || 'Size') + ':'), span({ class: 'card-value' }, String(content.size))) : null,
content.tags && content.tags.length
? div({ class: 'card-tags' }, content.tags.map(tag =>
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
))
: null
);
case 'map':
return div({ class: 'search-map' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
@ -604,7 +588,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
author
? p({ class: 'card-footer' },
span({ class: 'date-link' }, `${created} ${i18n.performed} `),
a({ href: authorUrl, class: 'user-link' }, `${author}`)
(authorUrl && authorUrl !== '#' && authorUrl.startsWith('/author/'))
? userLink(decodeURIComponent(authorUrl.replace(/^\/author\//, '')))
: a({ href: authorUrl, class: 'user-link' }, `${author}`)
): null,
]);
})

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, progress, video, table, tr, td } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views")
const { template, i18n, userLink} = require("./main_views")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl")
@ -327,7 +327,7 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para
td({ class: "tribe-info-value", colspan: "3" }, new Date(shop.createdAt).toLocaleString())
),
tr(
td({ class: "tribe-info-value", colspan: "4" }, a({ class: "user-link", href: `/author/${encodeURIComponent(shop.author)}` }, shop.author))
td({ class: "tribe-info-value", colspan: "4" }, userLink(shop.author))
),
shop.location ? tr(
td({ class: "tribe-info-label" }, i18n.shopLocation),
@ -458,7 +458,7 @@ exports.singleProductView = async (product, shop, comments = [], params = {}) =>
p({ class: "card-footer" },
span({ class: "date-link" }, moment(product.createdAt).format("YYYY-MM-DD HH:mm")),
" ",
a({ href: `/author/${encodeURIComponent(product.author)}`, class: "user-link" }, product.author)
userLink(product.author)
),
!isAuthor && safeArr(product.buyers).includes(userId) && !safeArr(product.opinions_inhabitants).includes(userId)
? div({ class: "voting-buttons transfer-voting-buttons" },

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, input, ul, li, a, h3, span, strong, table, tr, td, th } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require('./main_views');
const { template, i18n, userLink } = require('./main_views');
Object.assign(i18n, {
statsChat: "Chats",
@ -26,6 +26,11 @@ Object.assign(i18n, {
const C = (stats, t) => Number((stats && stats.content && stats.content[t]) || 0);
const O = (stats, t) => Number((stats && stats.opinions && stats.opinions[t]) || 0);
const wClass = (pct) => {
const n = Math.max(0, Math.min(100, Math.round((pct || 0) / 5) * 5));
return `stats-w-${n}`;
};
exports.statsView = (stats, filter) => {
const title = i18n.statsTitle;
const description = i18n.statsDescription;
@ -86,8 +91,369 @@ exports.statsView = (stats, filter) => {
};
const totalContent = types.filter(t => t !== 'karmaScore').reduce((sum, t) => sum + C(stats, t), 0);
const totalOpinions = types.reduce((sum, t) => sum + O(stats, t), 0);
const blockStyle = 'padding:16px;border:1px solid #ddd;border-radius:8px;margin-bottom:24px;';
const headerStyle = 'background-color:#f8f9fa; padding:24px; border-radius:8px; border:1px solid #e0e0e0; box-shadow:0 2px 8px rgba(0,0,0,0.1);';
const fmtNum = (n) => {
if (typeof n !== 'number' || !isFinite(n)) return '0';
if (Math.abs(n) >= 100) return n.toFixed(0);
if (Math.abs(n) >= 10) return n.toFixed(1);
return n.toFixed(2);
};
const kpi = (label, value) => div({ class: 'stats-kpi' },
div({ class: 'stats-kpi-label' }, label),
div({ class: 'stats-kpi-value' }, String(value))
);
const kpiBar = (label, value, pct) => {
const n = Math.max(0, Math.min(100, Number(pct) || 0));
return div({ class: 'stats-kpi' },
div({ class: 'stats-kpi-label' }, label),
div({ class: 'stats-kpi-value' }, String(value)),
n > 0
? div({ class: 'stats-bar-track stats-kpi-bar' },
div({ class: `stats-bar-fill ${wClass(n)}` })
)
: null
);
};
const kpiGrid = (...tiles) => div({ class: 'stats-grid' }, tiles.filter(Boolean));
const renderTopList = (items, getName, getCount, max) => {
if (!items || !items.length) return p({ class: 'no-content' }, i18n.no_results || 'No data');
const m = Math.max(1, max || items[0] && getCount(items[0]) || 1);
return ul({ class: 'stats-toplist' },
...items.map(it => {
const cnt = getCount(it);
const pct = (cnt / m) * 100;
return li(
span({ class: 'stats-toplist-name' }, getName(it)),
div({ class: 'stats-bar-track' },
div({ class: `stats-bar-fill ${wClass(pct)}` })
),
span({ class: 'stats-toplist-num' }, String(cnt))
);
})
);
};
const carbonChart = (() => {
const parseSize = (s) => {
if (!s) return 0;
const m = String(s).match(/([\d.]+)\s*(GB|MB|KB|B)/i);
if (!m) return 0;
const v = parseFloat(m[1]);
const u = m[2].toUpperCase();
if (u === 'GB') return v * 1024;
if (u === 'MB') return v;
if (u === 'KB') return v / 1024;
return v / (1024 * 1024);
};
const blobsMB = parseSize(stats.statsBlobsSize);
const chainMB = parseSize(stats.statsBlockchainSize);
const totalMB = blobsMB + chainMB;
const kWhPerMB = 0.0002;
const gCO2PerKWh = 475;
const networkCO2 = parseFloat((totalMB * kWhPerMB * gCO2PerKWh).toFixed(2));
const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1;
const userCO2 = parseFloat((networkCO2 / Math.max(1, inhabitants)).toFixed(2));
const maxAnnualCO2 = 500;
if (filter === 'MINE') {
const pct = networkCO2 > 0 ? Math.min(100, (userCO2 / networkCO2) * 100) : 0;
return div({ class: 'carbon-chart' },
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonUser || 'Your footprint'),
span(`${userCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(pct)}` })
),
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonNetwork || 'Network total'),
span(`${networkCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
),
p({ class: 'carbon-bar-note' }, strong(`${pct.toFixed(1)}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'}`),
p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
);
}
if (filter === 'TOMBSTONE') {
const tombCount = stats.tombstoneKPIs?.networkTombstoneCount || 0;
const avgTombBytes = 500;
const tombMB = (tombCount * avgTombBytes) / (1024 * 1024);
const tombCO2 = parseFloat((tombMB * kWhPerMB * gCO2PerKWh).toFixed(4));
const tombPct = networkCO2 > 0 ? Math.min(100, (tombCO2 / networkCO2) * 100) : 0;
return div({ class: 'carbon-chart' },
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonTombstone || 'Tombstoning footprint'),
span(`${tombCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(tombPct)}` })
),
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonNetwork || 'Network total'),
span(`${networkCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
),
p({ class: 'carbon-bar-note' }, strong(`${tombPct.toFixed(1)}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'} (${tombCount} tombstones × ~${avgTombBytes} bytes)`),
p({ class: 'carbon-bar-formula' }, 'Based on estimated tombstone message size ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
);
}
const pct = Math.min(100, (networkCO2 / maxAnnualCO2) * 100);
return div({ class: 'carbon-chart' },
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonNetwork || 'Network footprint'),
span(`${networkCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: `carbon-bar-fill carbon-bar-network ${wClass(pct)}` })
),
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonMaxAnnual || 'Annual max estimate'),
span(`${maxAnnualCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-max stats-w-100' })
),
p({ class: 'carbon-bar-note' }, strong(`${pct.toFixed(1)}%`), ` ${i18n.statsCarbonOfEstMax || 'of estimated max capacity'}`),
p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
);
})();
const headerCard = div({ class: 'stats-card' },
table({ class: 'block-info-table' },
tr(td({ class: 'card-label' }, i18n.statsCreatedAt), td({ class: 'card-value' }, stats.createdAt)),
tr(td({ class: 'card-label' }, 'ID'), td({ class: 'card-value' }, userLink(stats.id))),
tr(td({ class: 'card-label' }, i18n.statsBlobsSize), td({ class: 'card-value' }, stats.statsBlobsSize)),
tr(td({ class: 'card-label' }, i18n.statsBlockchainSize), td({ class: 'card-value' }, stats.statsBlockchainSize)),
tr(td({ class: 'card-label' }, i18n.statsSize), td({ class: 'card-value' }, stats.folderSize))
)
);
const totalInhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 0;
const networkKPIs = stats.networkKPIs || {};
const topStrip = div({ class: 'stats-block' },
kpiGrid(
kpi(i18n.bankingUserEngagementScore, C(stats, 'karmaScore')),
kpi(i18n.statsUsersTitle, totalInhabitants),
kpi(i18n.statsTotalMsgs || 'Total messages', networkKPIs.totalMsgs || 0),
kpi(i18n.statsLogsTitle || 'Logs', stats?.logsCount || 0),
kpi(i18n.statsAITraining, C(stats, 'aiExchange') || 0),
kpi(i18n.statsPUBs, stats.pubsCount || 0)
)
);
const carbonCard = div({ class: 'stats-card' },
h3({ class: 'stats-section-h' }, i18n.statsCarbonFootprintTitle || 'Carbon Footprint'),
carbonChart
);
const bankingCard = div({ class: 'stats-card' },
h3({ class: 'stats-section-h' }, i18n.statsBankingTitle),
table({ class: 'block-info-table' },
tr(td({ class: 'card-label' }, i18n.statsEcoWalletLabel), td({ class: 'card-value' }, a({ href: '/wallet', class: 'stats-link-break' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured))),
tr(td({ class: 'card-label' }, i18n.statsTotalEcoAddresses), td({ class: 'card-value' }, String(stats?.banking?.totalAddresses || 0)))
)
);
const networkBlock = div({ class: 'stats-block' },
h2(i18n.statsNetworkKPIsTitle || 'Network KPIs'),
kpiGrid(
filter === 'MINE'
? kpi(i18n.statsMyShare || 'Your share of the network', `${fmtNum(networkKPIs.myShare || 0)}%`)
: null,
kpi(i18n.statsAvgPerInhabitant || 'Avg per inhabitant', fmtNum(networkKPIs.avgMsgsPerInhabitant || 0)),
kpi(i18n.statsMsgsPerDay || 'Messages/day (lifetime)', fmtNum(networkKPIs.networkMsgsPerDay || 0)),
kpi(i18n.statsNetworkSpan || 'Network span', `${fmtNum(networkKPIs.networkSpanDays || 0)} d`),
kpi(i18n.statsTombstoneRatioLabel || 'Tombstone ratio', `${fmtNum(stats.tombstoneKPIs?.ratio || 0)}%`)
)
);
const activityBlock = (() => {
const rows = Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : [];
const max = Math.max(1, ...rows.map(r => Number(r.count) || 0));
return div({ class: 'stats-block' },
h2(i18n.statsActivity7d),
rows.length
? ul({ class: 'stats-toplist' },
...rows.map(row => {
const cnt = Number(row.count) || 0;
const pct = (cnt / max) * 100;
return li(
span({ class: 'stats-toplist-name' }, row.day),
div({ class: 'stats-bar-track' },
div({ class: `stats-bar-fill ${wClass(pct)}` })
),
span({ class: 'stats-toplist-num' }, String(cnt))
);
})
)
: p({ class: 'no-content' }, i18n.no_results || 'No data'),
div({ class: 'stats-activity-totals' },
span(`${i18n.statsActivity7dTotal}: `, strong(String(stats.activity?.daily7Total || 0))),
span(`${i18n.statsActivity30dTotal}: `, strong(String(stats.activity?.daily30Total || 0)))
)
);
})();
const topTypes = Array.isArray(stats.topTypes) ? stats.topTypes : [];
const topTypesBlock = topTypes.length ? div({ class: 'stats-block' },
h2(i18n.statsTopTypesTitle || 'Top Content Types'),
renderTopList(
topTypes,
it => labels[it.type] || it.type,
it => it.count,
topTypes[0] ? topTypes[0].count : 1
)
) : null;
const topTags = Array.isArray(stats.topTags) ? stats.topTags : [];
const topTagsBlock = topTags.length ? div({ class: 'stats-block' },
h2(i18n.statsTopTagsTitle || 'Top Tags'),
div({ class: 'stats-mb-16' },
topTags.map(t => a({ class: 'stats-pill', href: `/search?query=%23${encodeURIComponent(t.tag)}` }, `#${t.tag} (${t.count})`))
)
) : null;
const marketBlock = div({ class: 'stats-block' },
h2(i18n.statsMarketTitle),
kpiGrid(
kpi(i18n.statsMarketTotal, stats.marketKPIs?.total || 0),
kpi(i18n.statsMarketForSale, stats.marketKPIs?.forSale || 0),
kpi(i18n.statsMarketReserved, stats.marketKPIs?.reserved || 0),
kpi(i18n.statsMarketClosed, stats.marketKPIs?.closed || 0),
kpi(i18n.statsMarketSold, stats.marketKPIs?.sold || 0)
)
);
const projectsBlock = div({ class: 'stats-block' },
h2(i18n.statsProjectsTitle),
kpiGrid(
kpi(i18n.statsProjectsTotal, stats.projectsKPIs?.total || 0),
kpi(i18n.statsProjectsActive, stats.projectsKPIs?.active || 0),
kpi(i18n.statsProjectsCompleted, stats.projectsKPIs?.completed || 0),
kpi(i18n.statsProjectsPaused, stats.projectsKPIs?.paused || 0),
kpi(i18n.statsProjectsCancelled, stats.projectsKPIs?.cancelled || 0),
kpi(i18n.statsProjectsGoalTotal, `${stats.projectsKPIs?.ecoGoalTotal || 0} ECO`),
kpi(i18n.statsProjectsPledgedTotal, `${stats.projectsKPIs?.ecoPledgedTotal || 0} ECO`)
)
);
const allTribesPublic = Array.isArray(stats.allTribesPublic) ? stats.allTribesPublic : [];
const memberTribesDetailed = Array.isArray(stats.memberTribesDetailed) ? stats.memberTribesDetailed : [];
const myPrivateTribesDetailed = Array.isArray(stats.myPrivateTribesDetailed) ? stats.myPrivateTribesDetailed : [];
const buildContentTiles = () => {
const tiles = [];
types.filter(t => t !== 'karmaScore' && t !== 'shopProduct' && t !== 'padEntry' && t !== 'chatMessage' && t !== 'calendarDate' && t !== 'calendarNote').forEach(t => {
const cnt = C(stats, t);
if (cnt <= 0) return;
tiles.push(kpi(labels[t], cnt));
if (t === 'shop') tiles.push(kpi(labels.shopProduct, C(stats, 'shopProduct')));
else if (t === 'pad') tiles.push(kpi(labels.padEntry, C(stats, 'padEntry')));
else if (t === 'chat') tiles.push(kpi(labels.chatMessage, C(stats, 'chatMessage')));
else if (t === 'calendar') {
tiles.push(kpi(labels.calendarDate, C(stats, 'calendarDate')));
tiles.push(kpi(labels.calendarNote, C(stats, 'calendarNote')));
} else if (t === 'tribe') {
tiles.push(kpi(i18n.statsPublic, stats.tribePublicCount || 0));
tiles.push(kpi(i18n.statsPrivate, stats.tribePrivateCount || 0));
}
});
return tiles;
};
const buildOpinionTiles = () =>
types.map(t => O(stats, t) > 0 ? kpi(labels[t], O(stats, t)) : null).filter(Boolean);
const tribeListBlock = (label, list) => div({ class: 'stats-block' },
h2(`${label}: ${list.length}`),
list.length
? table({ class: 'stats-table-mt8' },
...list.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name))))
)
: p({ class: 'no-content' }, i18n.no_results || 'No data')
);
const allMode = filter === 'ALL'
? div({ class: 'stats-container' }, [
networkBlock,
activityBlock,
topTypesBlock,
topTagsBlock,
div({ class: 'stats-block' },
h2(i18n.statsNetworkContent),
kpiGrid(
kpi(i18n.statsDiscoveredTribes, allTribesPublic.length),
kpi(i18n.statsPrivateDiscoveredTribes, stats.tribePrivateCount || 0),
kpi(i18n.statsDiscoveredForum, C(stats, 'forum')),
kpi(i18n.statsDiscoveredTransfer, C(stats, 'transfer'))
)
),
tribeListBlock(i18n.statsDiscoveredTribes, allTribesPublic),
marketBlock,
projectsBlock,
div({ class: 'stats-block' },
h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`),
kpiGrid(...buildOpinionTiles())
),
div({ class: 'stats-block' },
h2(`${i18n.statsNetworkContent}: ${totalContent}`),
kpiGrid(...buildContentTiles())
)
])
: null;
const mineMode = filter === 'MINE'
? div({ class: 'stats-container' }, [
networkBlock,
activityBlock,
topTypesBlock,
topTagsBlock,
div({ class: 'stats-block' },
h2(i18n.statsYourContent || i18n.statsNetworkContent),
kpiGrid(
kpi(i18n.statsDiscoveredTribes, memberTribesDetailed.length),
kpi(i18n.statsPrivateDiscoveredTribes, myPrivateTribesDetailed.length),
kpi(i18n.statsYourForum, C(stats, 'forum')),
kpi(i18n.statsYourTransfer, C(stats, 'transfer'))
)
),
tribeListBlock(i18n.statsDiscoveredTribes, memberTribesDetailed),
myPrivateTribesDetailed.length
? tribeListBlock(i18n.statsPrivateDiscoveredTribes, myPrivateTribesDetailed)
: null,
marketBlock,
projectsBlock,
div({ class: 'stats-block' },
h2(`${i18n.statsYourOpinions}: ${totalOpinions}`),
kpiGrid(...buildOpinionTiles())
),
div({ class: 'stats-block' },
h2(`${i18n.statsYourContent}: ${totalContent}`),
kpiGrid(...buildContentTiles())
)
])
: null;
const tombMode = filter === 'TOMBSTONE'
? div({ class: 'stats-container' }, [
div({ class: 'stats-block' },
kpiGrid(
kpi(i18n.TOMBSTONEButton, stats.userTombstoneCount || 0),
kpi(i18n.statsTombstoneRatio, `${(stats.tombstoneKPIs?.ratio || 0).toFixed(2)}%`)
)
)
])
: null;
return template(
title,
section(
@ -95,7 +461,7 @@ exports.statsView = (stats, filter) => {
h2(title),
p(description)
),
div({ class: 'mode-buttons stats-grid' },
div({ class: 'mode-buttons stats-mode-row' },
modes.map(m =>
form({ method: 'GET', action: '/stats' },
input({ type: 'hidden', name: 'filter', value: m }),
@ -104,311 +470,14 @@ exports.statsView = (stats, filter) => {
)
),
section(
div({ style: headerStyle },
h3({ class: 'stats-h-row' }, `${i18n.statsCreatedAt}: `, span({ class: 'stats-muted-888' }, stats.createdAt)),
h3({ class: 'stats-section-h' },
a({ class: "user-link", href: `/author/${encodeURIComponent(stats.id)}`, class: 'stats-link' }, stats.id)
),
div({ class: 'stats-mb-16' },
ul({ class: 'stats-list-reset' },
li({ class: 'stats-h-row' }, `${i18n.statsBlobsSize}: `, span({ class: 'stats-muted-888' }, stats.statsBlobsSize)),
li({ class: 'stats-h-row' }, `${i18n.statsBlockchainSize}: `, span({ class: 'stats-muted-888' }, stats.statsBlockchainSize)),
li({ class: 'stats-h-row' }, strong(`${i18n.statsSize}: `, span({ class: 'stats-muted-888' }, span({ class: 'stats-muted-555' }, stats.folderSize))))
)
)
),
div({ class: "stats-karma-block" }, h3(`${i18n.bankingUserEngagementScore}: ${C(stats, 'karmaScore')}`)),
div({ style: headerStyle },
h3(i18n.statsCarbonFootprintTitle || 'Carbon Footprint'),
(() => {
const parseSize = (s) => {
if (!s) return 0;
const m = String(s).match(/([\d.]+)\s*(GB|MB|KB|B)/i);
if (!m) return 0;
const v = parseFloat(m[1]);
const u = m[2].toUpperCase();
if (u === 'GB') return v * 1024;
if (u === 'MB') return v;
if (u === 'KB') return v / 1024;
return v / (1024 * 1024);
};
const blobsMB = parseSize(stats.statsBlobsSize);
const chainMB = parseSize(stats.statsBlockchainSize);
const totalMB = blobsMB + chainMB;
const kWhPerMB = 0.0002;
const gCO2PerKWh = 475;
const networkCO2 = parseFloat((totalMB * kWhPerMB * gCO2PerKWh).toFixed(2));
const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1;
const userCO2 = parseFloat((networkCO2 / Math.max(1, inhabitants)).toFixed(2));
const maxAnnualCO2 = 500;
if (filter === 'MINE') {
const pct = networkCO2 > 0 ? Math.min(100, (userCO2 / networkCO2) * 100).toFixed(1) : '0.0';
return div({ class: 'carbon-chart' },
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonUser || 'Your footprint'),
span(`${userCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-mine', style: `width:${pct}%;` })
),
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonNetwork || 'Network total'),
span(`${networkCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
),
p({ class: 'carbon-bar-note' }, strong(`${pct}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'}`),
p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
);
}
if (filter === 'TOMBSTONE') {
const tombCount = stats.tombstoneKPIs?.networkTombstoneCount || 0;
const avgTombBytes = 500;
const tombMB = (tombCount * avgTombBytes) / (1024 * 1024);
const tombCO2 = parseFloat((tombMB * kWhPerMB * gCO2PerKWh).toFixed(4));
const tombPct = networkCO2 > 0 ? Math.min(100, (tombCO2 / networkCO2) * 100).toFixed(1) : '0.0';
return div({ class: 'carbon-chart' },
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonTombstone || 'Tombstoning footprint'),
span(`${tombCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-mine', style: `width:${tombPct}%;` })
),
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonNetwork || 'Network total'),
span(`${networkCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
),
p({ class: 'carbon-bar-note' }, strong(`${tombPct}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'} (${tombCount} tombstones × ~${avgTombBytes} bytes)`),
p({ class: 'carbon-bar-formula' }, 'Based on estimated tombstone message size ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
);
}
const pct = Math.min(100, (networkCO2 / maxAnnualCO2) * 100).toFixed(1);
return div({ class: 'carbon-chart' },
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonNetwork || 'Network footprint'),
span(`${networkCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-network', style: `width:${pct}%;` })
),
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonMaxAnnual || 'Annual max estimate'),
span(`${maxAnnualCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-max stats-w-100' })
),
p({ class: 'carbon-bar-note' }, strong(`${pct}%`), ` ${i18n.statsCarbonOfEstMax || 'of estimated max capacity'}`),
p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
);
})()
),
div({ style: headerStyle },
h3({ class: 'stats-section-h' }, i18n.statsBankingTitle),
ul({ class: 'stats-list-reset' },
li({ class: 'stats-h-row' }, `${i18n.statsEcoWalletLabel}: `, a({ href: '/wallet', class: 'stats-link-break' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured)),
li({ class: 'stats-h-row' }, `${i18n.statsTotalEcoAddresses}: `, span({ class: 'stats-muted-888' }, String(stats?.banking?.totalAddresses || 0)))
)
),
div({ style: headerStyle },
h3({ class: 'stats-section-h' }, i18n.statsLogsTitle || 'Logs'),
ul({ class: 'stats-list-reset' },
li({ class: 'stats-h-row' }, `${i18n.statsLogsEntries || 'Entries'}: `, span({ class: 'stats-muted-888' }, String(stats?.logsCount || 0)))
)
),
div({ style: headerStyle },
h3({ class: 'stats-section-h' }, i18n.statsAITraining),
ul({ class: 'stats-list-reset' },
li({ class: 'stats-h-row' }, `${i18n.statsAIExchanges}: `, span({ class: 'stats-muted-888' }, String(C(stats, 'aiExchange') || 0)))
)
),
div({ style: headerStyle }, h3(`${i18n.statsPUBs}: ${String(stats.pubsCount || 0)}`)),
filter === 'ALL'
? div({ class: 'stats-container' }, [
div({ style: blockStyle },
h2(i18n.statsActivity7d),
table({ class: 'stats-table' },
tr(th(i18n.day), th(i18n.messages)),
...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row => tr(td(row.day), td(String(row.count))))
),
p(`${i18n.statsActivity7dTotal}: ${stats.activity?.daily7Total || 0}`),
p(`${i18n.statsActivity30dTotal}: ${stats.activity?.daily30Total || 0}`)
),
div({ style: blockStyle },
h2(`${i18n.statsDiscoveredTribes}: ${stats.allTribesPublic.length}`),
table({ class: 'stats-table-mt8' },
...stats.allTribesPublic.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name))))
)
),
div({ style: blockStyle },
h2(`${i18n.statsPrivateDiscoveredTribes}: ${stats.tribePrivateCount || 0}`)
),
div({ style: blockStyle }, h2(`${i18n.statsUsersTitle}: ${stats.usersKPIs?.totalInhabitants || stats.inhabitants || 0}`)),
div({ style: blockStyle }, h2(`${i18n.statsDiscoveredForum}: ${C(stats, 'forum')}`)),
div({ style: blockStyle }, h2(`${i18n.statsDiscoveredTransfer}: ${C(stats, 'transfer')}`)),
div({ style: blockStyle },
h2(i18n.statsMarketTitle),
ul([
li(`${i18n.statsMarketTotal}: ${stats.marketKPIs?.total || 0}`),
li(`${i18n.statsMarketForSale}: ${stats.marketKPIs?.forSale || 0}`),
li(`${i18n.statsMarketReserved}: ${stats.marketKPIs?.reserved || 0}`),
li(`${i18n.statsMarketClosed}: ${stats.marketKPIs?.closed || 0}`),
li(`${i18n.statsMarketSold}: ${stats.marketKPIs?.sold || 0}`),
li(`${i18n.statsMarketRevenue}: ${((stats.marketKPIs?.revenueECO || 0)).toFixed(6)} ECO`),
li(`${i18n.statsMarketAvgSoldPrice}: ${((stats.marketKPIs?.avgSoldPrice || 0)).toFixed(6)} ECO`)
])
),
div({ style: blockStyle },
h2(i18n.statsProjectsTitle),
ul([
li(`${i18n.statsProjectsTotal}: ${stats.projectsKPIs?.total || 0}`),
li(`${i18n.statsProjectsActive}: ${stats.projectsKPIs?.active || 0}`),
li(`${i18n.statsProjectsCompleted}: ${stats.projectsKPIs?.completed || 0}`),
li(`${i18n.statsProjectsPaused}: ${stats.projectsKPIs?.paused || 0}`),
li(`${i18n.statsProjectsCancelled}: ${stats.projectsKPIs?.cancelled || 0}`),
li(`${i18n.statsProjectsGoalTotal}: ${(stats.projectsKPIs?.ecoGoalTotal || 0)} ECO`),
li(`${i18n.statsProjectsPledgedTotal}: ${(stats.projectsKPIs?.ecoPledgedTotal || 0)} ECO`),
li(`${i18n.statsProjectsSuccessRate}: ${((stats.projectsKPIs?.successRate || 0)).toFixed(1)}%`),
li(`${i18n.statsProjectsAvgProgress}: ${((stats.projectsKPIs?.avgProgress || 0)).toFixed(1)}%`),
li(`${i18n.statsProjectsMedianProgress}: ${((stats.projectsKPIs?.medianProgress || 0)).toFixed(1)}%`),
li(`${i18n.statsProjectsActiveFundingAvg}: ${((stats.projectsKPIs?.activeFundingAvg || 0)).toFixed(1)}%`)
])
),
div({ style: blockStyle },
h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`),
ul(types.map(t => O(stats, t) > 0 ? li(`${labels[t]}: ${O(stats, t)}`) : null).filter(Boolean))
),
div({ style: blockStyle },
h2(`${i18n.statsNetworkContent}: ${totalContent}`),
ul(
types.filter(t => t !== 'karmaScore' && t !== 'shopProduct' && t !== 'padEntry' && t !== 'chatMessage').map(t => {
if (C(stats, t) <= 0) return null;
if (t === 'shop') return li(
span(`${labels[t]}: ${C(stats, t)}`),
ul([li(`${labels.shopProduct}: ${C(stats, 'shopProduct')}`)])
);
if (t === 'pad') return li(
span(`${labels[t]}: ${C(stats, t)}`),
ul([li(`${labels.padEntry}: ${C(stats, 'padEntry')}`)])
);
if (t === 'chat') return li(
span(`${labels[t]}: ${C(stats, t)}`),
ul([li(`${labels.chatMessage}: ${C(stats, 'chatMessage')}`)])
);
if (t !== 'tribe') return li(`${labels[t]}: ${C(stats, t)}`);
return li(
span(`${labels[t]}: ${C(stats, t)}`),
ul([
li(`${i18n.statsPublic}: ${stats.tribePublicCount || 0}`),
li(`${i18n.statsPrivate}: ${stats.tribePrivateCount || 0}`)
])
);
}).filter(Boolean)
)
)
])
: filter === 'MINE'
? div({ class: 'stats-container' }, [
div({ style: blockStyle },
h2(i18n.statsActivity7d),
table({ class: 'stats-table' },
tr(th(i18n.day), th(i18n.messages)),
...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row => tr(td(row.day), td(String(row.count))))
),
p(`${i18n.statsActivity7dTotal}: ${stats.activity?.daily7Total || 0}`),
p(`${i18n.statsActivity30dTotal}: ${stats.activity?.daily30Total || 0}`)
),
div({ style: blockStyle },
h2(`${i18n.statsDiscoveredTribes}: ${stats.memberTribesDetailed.length}`),
table({ class: 'stats-table-mt8' },
...stats.memberTribesDetailed.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name))))
)
),
Array.isArray(stats.myPrivateTribesDetailed) && stats.myPrivateTribesDetailed.length
? div({ style: blockStyle },
h2(`${i18n.statsPrivateDiscoveredTribes}: ${stats.myPrivateTribesDetailed.length}`),
table({ class: 'stats-table-mt8' },
...stats.myPrivateTribesDetailed.map(tp => tr(td(a({ href: `/tribe/${encodeURIComponent(tp.id)}`, class: 'tribe-link' }, tp.name))))
)
)
: null,
div({ style: blockStyle }, h2(`${i18n.statsYourForum}: ${C(stats, 'forum')}`)),
div({ style: blockStyle }, h2(`${i18n.statsYourTransfer}: ${C(stats, 'transfer')}`)),
div({ style: blockStyle },
h2(i18n.statsMarketTitle),
ul([
li(`${i18n.statsMarketTotal}: ${stats.marketKPIs?.total || 0}`),
li(`${i18n.statsMarketForSale}: ${stats.marketKPIs?.forSale || 0}`),
li(`${i18n.statsMarketReserved}: ${stats.marketKPIs?.reserved || 0}`),
li(`${i18n.statsMarketClosed}: ${stats.marketKPIs?.closed || 0}`),
li(`${i18n.statsMarketSold}: ${stats.marketKPIs?.sold || 0}`),
li(`${i18n.statsMarketRevenue}: ${((stats.marketKPIs?.revenueECO || 0)).toFixed(6)} ECO`),
li(`${i18n.statsMarketAvgSoldPrice}: ${((stats.marketKPIs?.avgSoldPrice || 0)).toFixed(6)} ECO`)
])
),
div({ style: blockStyle },
h2(i18n.statsProjectsTitle),
ul([
li(`${i18n.statsProjectsTotal}: ${stats.projectsKPIs?.total || 0}`),
li(`${i18n.statsProjectsActive}: ${stats.projectsKPIs?.active || 0}`),
li(`${i18n.statsProjectsCompleted}: ${stats.projectsKPIs?.completed || 0}`),
li(`${i18n.statsProjectsPaused}: ${stats.projectsKPIs?.paused || 0}`),
li(`${i18n.statsProjectsCancelled}: ${stats.projectsKPIs?.cancelled || 0}`),
li(`${i18n.statsProjectsGoalTotal}: ${(stats.projectsKPIs?.ecoGoalTotal || 0)} ECO`),
li(`${i18n.statsProjectsPledgedTotal}: ${(stats.projectsKPIs?.ecoPledgedTotal || 0)} ECO`),
li(`${i18n.statsProjectsSuccessRate}: ${((stats.projectsKPIs?.successRate || 0)).toFixed(1)}%`),
li(`${i18n.statsProjectsAvgProgress}: ${((stats.projectsKPIs?.avgProgress || 0)).toFixed(1)}%`),
li(`${i18n.statsProjectsMedianProgress}: ${((stats.projectsKPIs?.medianProgress || 0)).toFixed(1)}%`),
li(`${i18n.statsProjectsActiveFundingAvg}: ${((stats.projectsKPIs?.activeFundingAvg || 0)).toFixed(1)}%`)
])
),
div({ style: blockStyle },
h2(`${i18n.statsYourOpinions}: ${totalOpinions}`),
ul(types.map(t => O(stats, t) > 0 ? li(`${labels[t]}: ${O(stats, t)}`) : null).filter(Boolean))
),
div({ style: blockStyle },
h2(`${i18n.statsYourContent}: ${totalContent}`),
ul(
types.filter(t => t !== 'karmaScore' && t !== 'shopProduct').map(t => {
if (C(stats, t) <= 0) return null;
if (t === 'shop') return li(
span(`${labels[t]}: ${C(stats, t)}`),
ul([li(`${labels.shopProduct}: ${C(stats, 'shopProduct')}`)])
);
if (t !== 'tribe') return li(`${labels[t]}: ${C(stats, t)}`);
return li(
span(`${labels[t]}: ${C(stats, t)}`),
ul([
li(`${i18n.statsPublic}: ${stats.tribePublicCount || 0}`),
li(`${i18n.statsPrivate}: ${stats.tribePrivateCount || 0}`),
...(Array.isArray(stats.myPrivateTribesDetailed) && stats.myPrivateTribesDetailed.length
? [
li(i18n.statsPrivateDiscoveredTribes),
...stats.myPrivateTribesDetailed.map(tp =>
li(a({ href: `/tribe/${encodeURIComponent(tp.id)}`, class: 'tribe-link' }, tp.name))
)
]
: [])
])
);
}).filter(Boolean)
)
)
])
: div({ class: 'stats-container' }, [
div({ style: blockStyle },
h2(`${i18n.TOMBSTONEButton}: ${stats.userTombstoneCount}`),
h2(`${i18n.statsTombstoneRatio.toUpperCase()}: ${((stats.tombstoneKPIs?.ratio || 0)).toFixed(2)}%`)
)
])
topStrip,
headerCard,
bankingCard,
carbonCard,
allMode,
mineMode,
tombMode
)
)
);
};

View file

@ -1,6 +1,6 @@
const { div, h2, p, section, button, form, input, select, option, a, br, textarea, label, span } = require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment");
const { template, i18n } = require("./main_views");
const { template, i18n, userLink} = require("./main_views");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl");
@ -162,10 +162,15 @@ const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all")
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
comments && comments.length
(() => {
const visibleComments = (comments || []).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
return visibleComments.length
? div(
{ class: "comments-list" },
comments.map((c) => {
visibleComments.map((c) => {
const author = c.value && c.value.author ? c.value.author : "";
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
@ -191,7 +196,8 @@ const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all")
);
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet);
})()
);
};
@ -221,7 +227,7 @@ const renderTaskItem = (task, filter) => {
span(
{ class: "card-value" },
assignees.length
? assignees.map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
? assignees.map((id, i) => [i > 0 ? ", " : "", userLink(id)]).flat()
: i18n.noAssignees
)
),
@ -242,7 +248,7 @@ const renderTaskItem = (task, filter) => {
p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(task.author)}`, class: "user-link" }, `${task.author}`)
userLink(task.author)
)
);
};
@ -447,7 +453,7 @@ exports.singleTaskView = async (task, filter, comments = []) => {
span(
{ class: "card-value" },
assignees.length
? assignees.map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
? assignees.map((id, i) => [i > 0 ? ", " : "", userLink(id)]).flat()
: i18n.noAssignees
)
),
@ -455,7 +461,7 @@ exports.singleTaskView = async (task, filter, comments = []) => {
p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(task.author)}`, class: "user-link" }, `${task.author}`)
userLink(task.author)
)
),
renderTaskCommentsSection(task.id, comments, currentFilter)

View file

@ -19,7 +19,7 @@ const {
td
} = require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views");
const { template, i18n, userLink} = require("./main_views");
const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl");
@ -180,7 +180,7 @@ const renderTorrentTable = (torrents, filter, params = {}) => {
torrents.map((t) =>
tr(
td(moment(t.createdAt).format("YYYY/MM/DD HH:mm")),
td(a({ href: `/author/${encodeURIComponent(t.author)}`, class: "user-link" }, t.author)),
td(userLink(t.author)),
td(t.title || ""),
td(formatSize(t.size)),
td(
@ -375,7 +375,7 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(torrentObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(torrentObj.author)}`, class: "user-link" }, `${torrentObj.author}`),
userLink(torrentObj.author),
showUpdated
? span(
{ class: "votations-comment-date" },

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, input, br, span, label, select, option, progress } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views")
const { template, i18n, userLink} = require("./main_views")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
const opinionCategories = require("../backend/opinion_categories")
@ -188,7 +188,7 @@ const generateTransferCard = (transfer, filter, params = {}) => {
p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(transfer.from)}`, class: "user-link" }, `${transfer.from}`),
userLink(transfer.from),
renderUpdatedLabel(transfer.createdAt, transfer.updatedAt)
)
)
@ -397,8 +397,8 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
div(
{ class: "card-section transfer" },
topbar ? topbar : null,
renderCardField(`${i18n.transfersFrom}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.from)}` }, transfer.from)),
renderCardField(`${i18n.transfersTo}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.to)}` }, transfer.to)),
renderCardField(`${i18n.transfersFrom}:`, userLink(transfer.from)),
renderCardField(`${i18n.transfersTo}:`, userLink(transfer.to)),
br,
div({ class: "transfer-amount-highlight" }, renderCardField(`${i18n.transfersAmount}:`, `${fmtAmount(transfer.amount)} ECO`)),
renderCardField(`${i18n.transfersConcept}:`, transfer.concept || ""),
@ -420,7 +420,7 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(transfer.from)}`, class: "user-link" }, `${transfer.from}`),
userLink(transfer.from),
renderUpdatedLabel(transfer.createdAt, transfer.updatedAt)
),
div(

View file

@ -17,7 +17,7 @@ const {
} = require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment");
const { template, i18n } = require("./main_views");
const { template, i18n, userLink} = require("./main_views");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl")
const { renderMapLocationVisitLabel } = require("./maps_view");
@ -116,7 +116,10 @@ const renderVideoOwnerActions = (filter, videoObj, params = {}) => {
};
const renderVideoCommentsSection = (videoId, comments = [], returnTo = null) => {
const list = safeArr(comments);
const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
const commentsCount = list.length;
return div(
@ -231,7 +234,7 @@ const renderVideoList = (videos, filter, params = {}) => {
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(videoObj.author)}`, class: "user-link" }, `${videoObj.author}`),
userLink(videoObj.author),
showUpdated
? span(
{ class: "votations-comment-date" },
@ -423,7 +426,7 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(videoObj.author)}`, class: "user-link" }, `${videoObj.author}`),
userLink(videoObj.author),
showUpdated
? span(
{ class: "votations-comment-date" },

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, label, span } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views");
const { template, i18n, userLink} = require("./main_views");
const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js");
const opinionCategories = require("../backend/opinion_categories");
@ -230,7 +230,7 @@ const renderVoteCard = (v, voteOptions, firstRow, secondRow, mode, activeFilter)
p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(v.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(v.createdBy)}`, class: "user-link" }, `${v.createdBy}`)
userLink(v.createdBy)
),
renderOpinionsBar(v, returnTo)
);