upstream: integrar Oasis 1.3.3
Diferencia 1.3.1 -> 1.3.3 del codigo de la APK oficial aplicada a tres bandas encima de la rama: karma en el perfil, salas con directo, fotos selladas, visibilidad de menciones, telefono por stream en Android y las traducciones nuevas. Sin dependencias nuevas. OasisMobile.css sigue siendo el del fork; del oficial solo se traen al final las reglas de las piezas nuevas (karma, directo a pantalla completa, salas en vivo, graphos).
This commit is contained in:
parent
bc64565e9c
commit
948b6460d9
95 changed files with 3613 additions and 1356 deletions
|
|
@ -13,7 +13,7 @@
|
|||
>
|
||||
> **Los fallos de esta rama se reportan aquí, nunca a epsylon.**
|
||||
|
||||
Versión base: **Oasis 1.3.1** · Rama: `PRUEBAS` ·
|
||||
Versión base: **Oasis 1.3.3** · Rama: `PRUEBAS` ·
|
||||
[Documentación completa](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki) ·
|
||||
[Versión de escritorio](https://gitea.laenre.net/hacklab/OASIS_LINUX)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ android {
|
|||
targetSdk = 35
|
||||
// el pipeline los pasa por entorno; a mano valen los de aqui
|
||||
versionCode = (System.getenv("OASIS_VERSION_CODE") ?: "1").toInt()
|
||||
versionName = System.getenv("OASIS_VERSION_NAME") ?: "1.3.1"
|
||||
versionName = System.getenv("OASIS_VERSION_NAME") ?: "1.3.3"
|
||||
ndk { abiFilters += listOf("arm64-v8a") }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ fi
|
|||
# --- 2) backend + dependencias ---------------------------------------------
|
||||
# El node_modules del movil esta podado: son ~137 MB frente a los 1,4 GB de una
|
||||
# instalacion de escritorio. En vez de reinstalarlo, se reutiliza el del APK base:
|
||||
# tiene que ser la APK oficial de la MISMA version que src/ (ahora 1.3.1): desde la
|
||||
# tiene que ser la APK oficial de la MISMA version que src/ (ahora 1.3.3): desde la
|
||||
# 1.1 el backend usa ssb-db2 y el node_modules de una 0.9.x ya no le sirve.
|
||||
BASE_ZIP=""
|
||||
if [ -n "$SRC" ] && [ -f "$SRC" ]; then
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@krakenslab/oasis",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.3",
|
||||
"description": "Oasis - Social Networking Utopia",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -162,8 +162,12 @@ async function listExchanges() {
|
|||
|
||||
const netVotes = (votes, key) => { const per = votes.get(key); if (!per) return 0; let n = 0; for (const v of per.values()) n += v; return n }
|
||||
|
||||
async function rankExchanges(question, { embed, cosine, k = 5 } = {}) {
|
||||
const { exchanges, votes } = await listExchanges()
|
||||
async function rankExchanges(question, { embed, cosine, k = 5, excludeAuthors = null } = {}) {
|
||||
const listed = await listExchanges()
|
||||
const votes = listed.votes
|
||||
const exchanges = excludeAuthors instanceof Set && excludeAuthors.size
|
||||
? listed.exchanges.filter(ex => !excludeAuthors.has(String(ex.author)))
|
||||
: listed.exchanges
|
||||
if (!exchanges.length) return []
|
||||
const scored = []
|
||||
if (typeof embed === 'function' && typeof cosine === 'function') {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -222,7 +222,9 @@ const oggMime = (buffer) => {
|
|||
return 'video/ogg';
|
||||
};
|
||||
|
||||
const storeUploadedFile = async function (blobUpload) {
|
||||
const sealedRef = (sealed, mime, name) => `/blob/sealed/${encodeURIComponent(sealed.manifestBlobId)}?k=${encodeURIComponent(sealed.key)}&t=${encodeURIComponent(mime)}&n=${encodeURIComponent(name)}`;
|
||||
|
||||
const storeUploadedFile = async function (blobUpload, seal = null) {
|
||||
if (!blobUpload || !blobUpload.filepath) return null;
|
||||
|
||||
let data = await promisesFs.readFile(blobUpload.filepath);
|
||||
|
|
@ -270,14 +272,19 @@ const storeUploadedFile = async function (blobUpload) {
|
|||
data = stripPdfMetadata(data);
|
||||
}
|
||||
|
||||
const ssbClient = await cooler.open();
|
||||
|
||||
blob.id = await new Promise((resolve, reject) => {
|
||||
pull(
|
||||
pull.values([data]),
|
||||
ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref)))
|
||||
);
|
||||
});
|
||||
if (typeof seal === 'function') {
|
||||
await promisesFs.writeFile(blobUpload.filepath, data);
|
||||
const sealed = await seal({ filepath: blobUpload.filepath, size: data.length, name: blob.name, mime: blob.mime });
|
||||
blob.id = sealedRef(sealed, blob.mime, blob.name);
|
||||
} else {
|
||||
const ssbClient = await cooler.open();
|
||||
blob.id = await new Promise((resolve, reject) => {
|
||||
pull(
|
||||
pull.values([data]),
|
||||
ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref)))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (blob.mime.startsWith("image/")) return `\n`;
|
||||
if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
|
||||
|
|
@ -288,20 +295,20 @@ const storeUploadedFile = async function (blobUpload) {
|
|||
return `\n[${blob.name}](${blob.id})`;
|
||||
};
|
||||
|
||||
const handleBlobUpload = async function (ctx, fileFieldName) {
|
||||
const handleBlobUpload = async function (ctx, fileFieldName, seal = null) {
|
||||
if (!ctx.request.files || !ctx.request.files[fileFieldName]) return null;
|
||||
const entry = ctx.request.files[fileFieldName];
|
||||
const first = Array.isArray(entry) ? entry[0] : entry;
|
||||
return storeUploadedFile(first);
|
||||
return storeUploadedFile(first, seal);
|
||||
};
|
||||
|
||||
const handleBlobUploads = async function (ctx, fileFieldName, max = 8) {
|
||||
const handleBlobUploads = async function (ctx, fileFieldName, max = 8, seal = null) {
|
||||
if (!ctx.request.files || !ctx.request.files[fileFieldName]) return [];
|
||||
const entry = ctx.request.files[fileFieldName];
|
||||
const files = (Array.isArray(entry) ? entry : [entry]).slice(0, Math.max(0, max));
|
||||
const out = [];
|
||||
for (const file of files) {
|
||||
const stored = await storeUploadedFile(file);
|
||||
const stored = await storeUploadedFile(file, seal);
|
||||
if (stored) out.push(stored);
|
||||
}
|
||||
return out;
|
||||
|
|
|
|||
|
|
@ -46,12 +46,21 @@ const all = [...positive, ...constructive, ...moderation];
|
|||
all.positive = positive;
|
||||
all.constructive = constructive;
|
||||
all.moderation = moderation;
|
||||
const karmaNeutral = ["adultOnly", "nsfw", "triggering", "duplicate", "offTopic", "needsSources"];
|
||||
const karmaModeration = [...moderation.filter(c => !karmaNeutral.includes(c)), "propaganda"];
|
||||
all.karma = {
|
||||
positive,
|
||||
neutral: karmaNeutral,
|
||||
constructive: constructive.filter(c => !karmaNeutral.includes(c) && !karmaModeration.includes(c)),
|
||||
moderation: karmaModeration
|
||||
};
|
||||
all.messageTypes = {
|
||||
bookmark: 'bookmarkOpinion',
|
||||
votes: 'votesOpinion',
|
||||
poll: 'pollOpinion',
|
||||
transfer: 'transferOpinion',
|
||||
feed: 'feedOpinion',
|
||||
post: 'blogOpinion',
|
||||
image: 'imageOpinion',
|
||||
audio: 'audioOpinion',
|
||||
video: 'videoOpinion',
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ const isMutual = (relationship) => !!(relationship && relationship.following &&
|
|||
|
||||
const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => {
|
||||
if (recipientId && viewerId && recipientId === viewerId) return true;
|
||||
if (pmVisibility !== 'mutuals') return true;
|
||||
return isMutual(relationship);
|
||||
if (pmVisibility === 'mutuals') return isMutual(relationship);
|
||||
if (pmVisibility === 'following') return !!(relationship && relationship.following);
|
||||
return true;
|
||||
};
|
||||
|
||||
const announceSeenPath = () => {
|
||||
|
|
|
|||
|
|
@ -26,12 +26,12 @@ const UNDERLINE_RE = /(?<![A-Za-z0-9])__(?!\s)((?:[^_\n]|_(?!_))+?)(?<!\s)__(?![
|
|||
const STRIKE_RE = /~~(?!\s)([^~\n]+?)(?<!\s)~~/g;
|
||||
const ITALIC_RE = /\*(?!\s)((?:[^*\n]|\*\*)+?)(?<!\s)\*(?!\*)/g;
|
||||
const INLINE_CODE_RE = /`([^`\n]+)`/g;
|
||||
const BLOB_IMAGE_RE = /!\[([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256)\s*\)/g;
|
||||
const BLOB_VIDEO_RE = /\[video:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256)\s*\)/g;
|
||||
const BLOB_AUDIO_RE = /\[audio:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256)\s*\)/g;
|
||||
const BLOB_PDF_RE = /\[pdf:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256)\s*\)/g;
|
||||
const BLOB_TORRENT_RE = /\[torrent:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256)\s*\)/g;
|
||||
const BLOB_FILE_RE = /(?<!!)\[(?!(?:video|audio|pdf|torrent):)([^\]\n]{1,160})\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
|
||||
const BLOB_IMAGE_RE = /!\[([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||
const BLOB_VIDEO_RE = /\[video:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||
const BLOB_AUDIO_RE = /\[audio:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||
const BLOB_PDF_RE = /\[pdf:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||
const BLOB_TORRENT_RE = /\[torrent:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||
const BLOB_FILE_RE = /(?<!!)\[(?!(?:video|audio|pdf|torrent):)([^\]\n]{1,160})\]\(\s*(&[^)\s]+\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||
const MD_MENTION_RE = /\[@([^\]\[\n]{1,120})\]\(@?([A-Za-z0-9+/=.\-]{1,120}\.ed25519)\)/g;
|
||||
const RAW_MENTION_RE = /@([A-Za-z0-9+/=.\-]+\.ed25519)/g;
|
||||
const BARE_MENTION_RE = /(^|[^\w\u0080-\uFFFF\[\]/@])@([\w\u0080-\uFFFF.\-]{1,64})(?=$|[^\w\u0080-\uFFFF/+=])/g;
|
||||
|
|
@ -141,7 +141,7 @@ function renderStyledText(value, opts = {}) {
|
|||
filtered.push(m);
|
||||
lastEnd = m.index + m.length;
|
||||
}
|
||||
const blobHref = (id) => `${opts.blobPrefix || '/blob/'}${encodeURIComponent(id)}`;
|
||||
const blobHref = (id) => String(id).startsWith('/blob/sealed/') ? String(id) : `${opts.blobPrefix || '/blob/'}${encodeURIComponent(id)}`;
|
||||
const linksOn = opts.links !== false;
|
||||
const internalOn = opts.internalLinks !== false;
|
||||
const plain = (value) => opts.plainUrlClass ? span({ class: opts.plainUrlClass }, value) : value;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
const SAFE_SRC = /^\/(?:blob|image)\/[^\s"'<>]+$/i;
|
||||
const SAFE_HREF = /^(?:https?:\/\/|\/(?!\/)|#|mailto:)/i;
|
||||
const SAFE_CLASS = /^(?:rt-[a-z0-9-]+|styled-link|mention|tag-link|zoom-link|pm-quote|wiki-link(?:-missing)?|internal-link(?:-id|-kind)?|lightbox(?:-[a-z]+)?|post-(?:audio|image|pdf|video))$/;
|
||||
const SAFE_CLASS = /^(?:rt-[a-z0-9-]+|styled-link|mention|tag-link|tag-inline|zoom-link|pm-quote|wiki-link(?:-missing)?|internal-link(?:-id|-kind)?|lightbox(?:-[a-z]+)?|post-(?:audio|image|pdf|video))$/;
|
||||
let purifier = null;
|
||||
const getPurify = () => {
|
||||
if (purifier) return purifier;
|
||||
|
|
|
|||
BIN
src/client/assets/images/ux-rooms.png
Normal file
BIN
src/client/assets/images/ux-rooms.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
|
|
@ -842,6 +842,7 @@ a.create-button {
|
|||
|
||||
.peers-list .block-info-table,
|
||||
.peers-technical-block .block-info-table,
|
||||
.viewer-hidden-block .block-info-table,
|
||||
.parliament-rows-table,
|
||||
.gov-overview {
|
||||
display: block !important;
|
||||
|
|
@ -853,6 +854,7 @@ a.create-button {
|
|||
}
|
||||
.peers-list .block-info-table tbody,
|
||||
.peers-technical-block .block-info-table tbody,
|
||||
.viewer-hidden-block .block-info-table tbody,
|
||||
.parliament-rows-table tbody,
|
||||
.gov-overview tbody {
|
||||
display: block !important;
|
||||
|
|
@ -861,11 +863,13 @@ a.create-button {
|
|||
.gov-overview thead,
|
||||
.parliament-rows-table thead { display: none !important; }
|
||||
.peers-list .block-info-table tr:first-child,
|
||||
.peers-technical-block .block-info-table tr:first-child {
|
||||
.peers-technical-block .block-info-table tr:first-child,
|
||||
.viewer-hidden-block .block-info-table tr:first-child {
|
||||
display: none !important;
|
||||
}
|
||||
.peers-list .block-info-table tr,
|
||||
.peers-technical-block .block-info-table tr,
|
||||
.viewer-hidden-block .block-info-table tr,
|
||||
.parliament-rows-table tbody tr,
|
||||
.gov-overview tbody tr {
|
||||
display: block !important;
|
||||
|
|
@ -879,6 +883,7 @@ a.create-button {
|
|||
}
|
||||
.peers-list .block-info-table td,
|
||||
.peers-technical-block .block-info-table td,
|
||||
.viewer-hidden-block .block-info-table td,
|
||||
.parliament-rows-table tbody td,
|
||||
.gov-overview tbody td {
|
||||
display: flex !important;
|
||||
|
|
@ -895,6 +900,7 @@ a.create-button {
|
|||
}
|
||||
.peers-list .block-info-table td::before,
|
||||
.peers-technical-block .block-info-table td::before,
|
||||
.viewer-hidden-block .block-info-table td::before,
|
||||
.parliament-rows-table tbody td::before,
|
||||
.gov-overview tbody td::before {
|
||||
content: attr(data-label);
|
||||
|
|
@ -908,8 +914,10 @@ a.create-button {
|
|||
white-space: nowrap !important;
|
||||
word-break: normal !important;
|
||||
}
|
||||
.peers-technical-block .block-info-table td[data-label=""] { justify-content: center !important; }
|
||||
.peers-technical-block .block-info-table td[data-label=""]::before { content: none !important; }
|
||||
.peers-technical-block .block-info-table td[data-label=""],
|
||||
.viewer-hidden-block .block-info-table td[data-label=""] { justify-content: center !important; }
|
||||
.peers-technical-block .block-info-table td[data-label=""]::before,
|
||||
.viewer-hidden-block .block-info-table td[data-label=""]::before { content: none !important; }
|
||||
.peers-list .block-info-table td .peer-key,
|
||||
.peers-technical-block .block-info-table td .peer-key { text-align: right !important; }
|
||||
.phone-history td.phone-dir,
|
||||
|
|
|
|||
|
|
@ -1042,6 +1042,11 @@ article img {
|
|||
.feed-container .post-image{max-width:100%;width:auto;max-height:480px;height:auto}
|
||||
.feed-container .post-video,.feed-container .video-container video{width:100%;max-width:720px;max-height:480px;height:auto}
|
||||
.feed-container .post-audio,.feed-container .audio-container audio{width:100%}
|
||||
.feed-detail-card .post-image{width:auto;max-height:480px;margin-left:auto;margin-right:auto}
|
||||
.feed-detail-card .post-video{max-height:480px;margin-left:auto;margin-right:auto}
|
||||
.feed-detail-card .post-audio{width:100%}
|
||||
.feed-detail-card .zoom-link:not(.zoom-inline){display:block;text-align:center}
|
||||
.feed-detail-card .zoom-link:not(.zoom-inline) img{display:inline-block}
|
||||
|
||||
.fediverse-network{border-radius:8px;padding:18px 22px;margin-top:10px;display:flex;flex-direction:column;gap:16px}
|
||||
.fediverse-network > p,.fediverse-network form > p{margin:0}
|
||||
|
|
@ -1521,6 +1526,7 @@ button.create-button:hover {
|
|||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.karma-line-main{display:block;line-height:1.25}.profile-sensors-box .karma-line.karma-line-stacked,.inhabitant-left .karma-line.karma-line-stacked,.inhabitant-karma-ubi .karma-line.karma-line-stacked,.karma-line.karma-line-stacked{display:flex;flex-direction:column;align-items:center;gap:1px}.karma-received-row{display:flex;justify-content:center;align-items:center;gap:14px;margin-top:0;line-height:1.25}.karma-received{display:inline-flex;align-items:center;gap:4px;font-weight:600;white-space:nowrap;line-height:1}.karma-arrow{font-size:.85em;line-height:1;display:inline-block}.karma-received-up{color:#2ecc71}.karma-received-down{color:#e74c3c}.karma-value-positive{color:#2ecc71 !important}.karma-value-neutral{color:#ffd700 !important}.karma-value-negative{color:#e74c3c !important}
|
||||
.module-stats-line .stat-label-green{color:#2ecc71}
|
||||
.module-stats-line .stat-label-orange{color:#f39c12}
|
||||
.module-stats-line .stat-label-red{color:#e74c3c}
|
||||
|
|
@ -2508,6 +2514,11 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.feed-main a.tag-link,
|
||||
.feed-main a.tag-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.votes-wrapper {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
|
|
@ -6005,6 +6016,12 @@ a:hover .map-cluster-dot { fill: #ffffff; }
|
|||
.chat-pin-toggle{position:absolute;top:4px;right:6px}
|
||||
.chat-has-pin-toggle{padding-right:38px}
|
||||
.chat-pinned-link{margin-right:8px}
|
||||
.btn-singleview .btn-icon{display:flex;align-items:center;justify-content:center;width:1em;height:1em;line-height:0}
|
||||
.btn-singleview .btn-icon svg{display:block;width:100%;height:100%}
|
||||
.btn-singleview .btn-count{margin-left:5px;line-height:1;font-size:.9em}
|
||||
.btn-singleview:has(> .btn-icon:only-child){width:36px;min-width:36px;height:36px;padding:0;flex-shrink:0}
|
||||
.btn-singleview.btn-content{background:#ffa300;color:#1e1f23;border:1px solid #ffa300}
|
||||
.btn-singleview.btn-content:hover{background:#FFD700;color:#1e1f23;border-color:#FFD700}
|
||||
.btn-singleview.btn-delete{color:#ff6b6b !important;border-color:rgba(255,107,107,.55) !important}
|
||||
.btn-singleview.btn-delete:hover{background:rgba(255,107,107,.15) !important}
|
||||
.feed-opinions-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:transparent !important;border:0 !important;padding:0 !important;margin:6px 0;box-shadow:none}
|
||||
|
|
@ -6209,8 +6226,8 @@ details[open] > .school-lesson-summary .school-lesson-summary-title::after{conte
|
|||
.torrent-table{width:100%;border-collapse:collapse;margin-top:12px}
|
||||
.torrent-table th,.torrent-table td{padding:8px 12px;text-align:left}
|
||||
.torrent-table td:has(.filter-btn){width:1%;white-space:nowrap}
|
||||
.torrent-table td:nth-child(3){max-width:380px;overflow-wrap:anywhere}
|
||||
.torrent-table td:nth-child(4),.torrent-table td:nth-child(6){white-space:nowrap;width:1%}
|
||||
.torrent-table td:nth-child(2){width:auto;overflow-wrap:anywhere}
|
||||
.torrent-table th:nth-child(1),.torrent-table td:nth-child(1),.torrent-table td:nth-child(3),.torrent-table td:nth-child(4),.torrent-table td:nth-child(5){white-space:nowrap;width:1%}
|
||||
.torrent-table td .filter-btn{margin:0}
|
||||
.torrent-table th{background:#1a1a1a;color:#FFA500;font-weight:bold}
|
||||
.torrent-download{margin:12px 0;background:transparent;border:0;padding:0;box-shadow:none}
|
||||
|
|
@ -6318,6 +6335,7 @@ details[open] > .school-lesson-summary .school-lesson-summary-title::after{conte
|
|||
.stats-toplist .stats-toplist-num{flex:0 0 auto;color:#FFDD44;font-size:13px;min-width:36px;text-align:right}
|
||||
.peer-key{word-break:break-all;font-size:12px}
|
||||
.peers-list .block-info-table{table-layout:fixed;width:100%}
|
||||
.viewer-hidden-block .block-info-table{width:100%}.viewer-hidden-block .block-info-table td{overflow-wrap:anywhere}.viewer-hidden-block .block-info-table td:nth-child(2),.viewer-hidden-block .block-info-table td:nth-child(3){white-space:nowrap;width:1%}
|
||||
.peers-list .block-info-table td{overflow-wrap:anywhere}
|
||||
.peers-list .block-info-table td:nth-child(1){width:18%}
|
||||
.peers-list .block-info-table td:nth-child(2){width:47%}
|
||||
|
|
@ -6326,6 +6344,8 @@ details[open] > .school-lesson-summary .school-lesson-summary-title::after{conte
|
|||
.peers-list .block-info-table td:nth-child(5){width:15%}
|
||||
.peers-technical-block{margin-top:16px}
|
||||
.peers-conn-actions{margin-bottom:12px}
|
||||
.activity-toolbar-row .peers-conn-actions{margin:0;padding:0;border:0;background:transparent;box-shadow:none;display:flex;gap:6px;flex-wrap:wrap;margin-left:auto}
|
||||
.activity-toolbar-row .peers-conn-actions form{margin:0}
|
||||
.invites-pubs-actions{margin:12px 0}
|
||||
.larp-cycle-banner{display:flex;flex-wrap:wrap;align-items:center;gap:8px;background:#1A1A1A;border:1px solid #FFA500;border-radius:6px;padding:8px 14px;margin:12px 0;font-family:monospace}
|
||||
.larp-post-submit{width:auto;display:inline-block;padding:6px 18px;align-self:flex-start}
|
||||
|
|
@ -8088,7 +8108,6 @@ details[open] > .chat-poll-summary::after { content: " \25B4"; }
|
|||
.blockexplorer-search-actions input[type="text"],
|
||||
.logs-toolbar input[type="text"],
|
||||
.search-filters-row input[type="text"],
|
||||
.tribe-search-form input[type="text"],
|
||||
.filter-box input[type="text"],
|
||||
.ui-toolbar input[type="text"] {
|
||||
margin: 0;
|
||||
|
|
@ -8327,7 +8346,9 @@ details[open] > .chat-poll-summary::after { content: " \25B4"; }
|
|||
.school-progress-failed{font-size:0.85em;color:#e57373;cursor:help}
|
||||
.school-lessons .school-lesson,.school-exams .school-lesson{background:transparent !important;border:0 !important;box-shadow:none;padding:0 !important;margin:0 0 16px 0}
|
||||
.school-exam-question{background:transparent !important;border:0 !important;padding:0 !important;box-shadow:none;margin:0 0 14px 0}
|
||||
.top-bar-center .ai-ask-form{flex:1 1 auto;min-width:220px}
|
||||
.top-bar-center .ai-ask-form{flex:1 1 auto;min-width:0}
|
||||
.top-bar-center:not(.top-bar-center-ainav){container-type:inline-size}
|
||||
@container (max-width:180px){.ai-ask-row .ai-ask-form{display:none}}
|
||||
.ai-suggestion-inline{display:inline-flex;align-items:center;gap:6px;background:transparent !important;border:0 !important;padding:0 !important;margin:0 0 0 12px !important;box-shadow:none;font-size:.85em;white-space:nowrap;flex:0 1 auto;min-width:0;overflow:hidden}
|
||||
.ai-suggestion-inline .update-banner-link{display:inline-block;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}
|
||||
.ai-suggestion-inline .welcome-banner-close{margin:0;padding:0;background:transparent;border:0;box-shadow:none;display:inline}
|
||||
|
|
@ -8694,7 +8715,7 @@ label.wallet-create-transfer input[type="checkbox"]{width:auto !important;flex:0
|
|||
.peertube-description{white-space:pre-wrap;word-break:break-word}
|
||||
.peertube-nostream p{margin:8px 0 0}
|
||||
.torrent-table td{vertical-align:middle}
|
||||
.torrent-spread-cell{text-align:center;white-space:nowrap}
|
||||
.torrent-spread-cell,.torrent-table th.torrent-spread-cell,.torrent-table td.torrent-spread-cell{text-align:center;white-space:nowrap}
|
||||
.torrent-spread-cell .spread-form{display:inline-block;margin:0;vertical-align:middle}
|
||||
.torrent-spread-cell .btn-singleview{margin:0;vertical-align:middle}
|
||||
.torrent-detail-section{margin:0 0 18px}
|
||||
|
|
@ -8743,6 +8764,7 @@ label.wallet-create-transfer input[type="checkbox"]{width:auto !important;flex:0
|
|||
.room-peer .phone-action-form .tribe-action-btn{padding:2px 8px;font-size:11px}
|
||||
.phone-call-peers{margin:8px 0 0 0}
|
||||
.block-info-table .pm-exposition-chip{white-space:nowrap}
|
||||
.torrent-table td > .pm-exposition-chip-link,.torrent-table td > .pm-exposition-chip,.file-table td > .pm-exposition-chip-link,.file-table td > .pm-exposition-chip{margin-left:10px;vertical-align:middle;position:relative;top:-3px}
|
||||
.peers-technical-block .inline-form .filter-btn{white-space:nowrap}
|
||||
.peers-technical-block .peer-port{white-space:nowrap}
|
||||
.peers-technical-block .peer-host{overflow-wrap:normal;word-break:normal}
|
||||
|
|
@ -8836,7 +8858,7 @@ label.wallet-create-transfer input[type="checkbox"]{width:auto !important;flex:0
|
|||
.oasis-pager-btn:hover,.oasis-pager-btn.active{border-color:#FFD700;color:#FFD700;text-decoration:none}
|
||||
.card-title-link{color:inherit;text-decoration:none}
|
||||
.card-title-link:hover{text-decoration:underline}
|
||||
.graphos-zoom-row{display:flex;justify-content:flex-end;margin:0 0 8px;padding:0;border:0;background:none;box-shadow:none}
|
||||
.graphos-statsbar{margin:12px 0 14px}.graphos-canvas{position:relative}.graphos-canvas-svg{margin:0;padding:0;border:0 !important;background:transparent !important;box-shadow:none}.graphos-canvas .graphos-zoom-btn{position:absolute;top:8px;right:8px;margin:0;white-space:nowrap;z-index:1}.graphos-kpi .stats-kpi-label{text-transform:uppercase;white-space:nowrap}.graphos-kpi .stats-kpi-value{font-weight:bold}
|
||||
.graphos-lightbox .graphos-canvas-zoom{width:96vw;height:92vh;overflow:auto;background:#111;border:1px solid #2a2a2a;border-radius:8px;box-sizing:border-box;padding:0;margin:0}
|
||||
.graphos-lightbox .graphos-zoom-stage,.graphos-lightbox .graphos-zoom-svg{padding:0;margin:0;border:0;border-radius:0;background:none;box-shadow:none}
|
||||
.graphos-lightbox .graphos-svg{width:100%;height:auto;max-height:calc(92vh - 4px);min-height:0;display:block}
|
||||
|
|
@ -8848,24 +8870,46 @@ label.wallet-create-transfer input[type="checkbox"]{width:auto !important;flex:0
|
|||
.stats-block .tag-table,.stats-card .tag-table{width:100%;border-collapse:collapse;table-layout:fixed}
|
||||
.stats-block .tag-table th:first-child,.stats-block .tag-table td:first-child,.stats-card .tag-table th:first-child,.stats-card .tag-table td:first-child{width:70%}
|
||||
.stats-block .tag-table th:last-child,.stats-block .tag-table td:last-child,.stats-card .tag-table th:last-child,.stats-card .tag-table td:last-child{width:30%;text-align:right}
|
||||
.video-grid{display:flex;flex-wrap:wrap;gap:8px;margin:18px 0 0;padding:0;border:0;background:transparent;box-shadow:none}
|
||||
.video-tile{display:flex;flex-direction:column;gap:0;padding:0;margin:0;width:320px;max-width:100%;border:1px solid #333;border-radius:6px;overflow:hidden;background:#000}
|
||||
.video-grid{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin:18px 0 0;padding:0;border:0;background:transparent;box-shadow:none}
|
||||
.video-tile{display:flex;flex-direction:column;gap:0;padding:0;margin:0;width:420px;max-width:100%;border:1px solid #333;border-radius:6px;overflow:hidden;background:#000}
|
||||
.video-grid-solo .video-tile{width:800px}
|
||||
.video-feed{display:block;width:100%;aspect-ratio:4/3;object-fit:cover;background:#000}
|
||||
.video-tile .user-link,.video-tile-name{display:block;margin:0;padding:4px 8px;font-size:12px;text-align:left;background:#1a1a1a}
|
||||
.video-tile-me .video-feed{transform:scaleX(-1)}
|
||||
.room-peer-cam{flex:0 0 auto;color:#ffa300;font-size:11px;font-weight:700;border:1px solid #ffa300;border-radius:4px;padding:1px 6px}
|
||||
.room-cam-btn-on{background:#ffa500;color:#000}
|
||||
.video-grid-one{flex-direction:column;align-items:flex-start}
|
||||
.video-feed-grid{width:auto;max-width:100%;aspect-ratio:auto;border:1px solid #333;border-radius:6px}
|
||||
.tribe-action-btn.room-cam-btn-on{background:#ffa500 !important;color:#000 !important;border-color:#ffa500 !important}
|
||||
.room-stream-chip{color:#ffa300;font-size:11px;font-weight:700;border:1px solid #ffa300;border-radius:4px;padding:1px 6px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.room-public-warning{margin:8px 0 0;font-size:12px;color:#ffa300}
|
||||
.video-tile-film{color:#ffa300}
|
||||
.room-stream-form{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;position:relative}
|
||||
.room-live-controls>.room-stream-form{flex:0 1 auto;max-width:100%}
|
||||
.room-stream-input{position:absolute;left:0;top:0;width:1px;height:1px;opacity:0;pointer-events:none;clip-path:inset(50%)}
|
||||
.room-stream-pick{cursor:pointer}
|
||||
.room-stream-input:invalid ~ .room-stream-go,.room-stream-input:valid ~ .room-stream-pick{display:none}
|
||||
.room-stream-go{order:2}
|
||||
.room-stream-form .room-stream-input:valid{position:static !important;width:auto !important;height:auto !important;max-width:160px !important;opacity:1 !important;pointer-events:auto !important;clip-path:none !important;order:1;margin:0 !important;padding:1px 6px !important;border:1px solid #ffa300 !important;border-radius:4px !important;background:transparent !important;color:#ffa300 !important;font-size:11px !important;font-weight:700;line-height:1.6;cursor:pointer}
|
||||
.room-stream-input::file-selector-button{display:none}
|
||||
.room-stream-input::-webkit-file-upload-button{display:none}
|
||||
.stream-picker-list{list-style:none;margin:10px 0 0;padding:0;display:flex;flex-direction:column;gap:6px}
|
||||
.stream-picker-item{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;padding:6px 10px;border:1px solid rgba(128,128,128,.4);border-radius:6px}
|
||||
.stream-picker-link{display:flex;align-items:center;gap:8px;min-width:0;text-decoration:none}
|
||||
.stream-picker-link .btn-icon{display:flex;flex:0 0 auto;width:18px;height:18px}
|
||||
.stream-picker-link .btn-icon svg{display:block;width:100%;height:100%}
|
||||
.stream-picker-name{overflow-wrap:anywhere}
|
||||
.stream-picker-size{flex:0 0 auto;font-size:12px;opacity:.7;white-space:nowrap}
|
||||
.stream-picker-item .phone-action-form{flex:0 0 auto;margin:0}
|
||||
.video-grid-one{flex-direction:column;align-items:center}
|
||||
.video-feed-grid{width:100%;max-width:100%;aspect-ratio:auto;border:1px solid #333;border-radius:6px}
|
||||
.video-grid-solo .video-feed-grid{max-width:800px;margin:0 auto}
|
||||
.video-grid-names{display:flex;flex-wrap:wrap;gap:6px;padding:0;margin:0;border:0;background:transparent;box-shadow:none}
|
||||
.video-grid-names .user-link,.video-grid-names .video-tile-name{padding:4px 8px;font-size:12px;background:#1a1a1a;border-radius:4px}
|
||||
.file-table{width:100%;border-collapse:collapse;margin-top:12px}
|
||||
.file-table th,.file-table td{padding:8px 12px;text-align:left;vertical-align:middle}
|
||||
.file-table th{background:#1a1a1a;color:#FFA500;font-weight:bold}
|
||||
.file-table td:nth-child(1),.file-table td:nth-child(4),.file-table td:nth-child(5),.file-table td:nth-child(6),.file-table td:nth-child(7){white-space:nowrap;width:1%}
|
||||
.file-table td:nth-child(3){overflow-wrap:anywhere}
|
||||
.file-table th:nth-child(1),.file-table td:nth-child(1),.file-table td:nth-child(3),.file-table td:nth-child(4),.file-table td:nth-child(5),.file-table td:nth-child(6){white-space:nowrap;width:1%}
|
||||
.file-table td:nth-child(2){width:auto;overflow-wrap:anywhere}
|
||||
.file-table td .filter-btn{margin:0}
|
||||
.file-table td.torrent-spread-cell{text-align:center}
|
||||
.file-table th.torrent-spread-cell,.file-table td.torrent-spread-cell{text-align:center}
|
||||
.forum-inline-reply{padding:0;margin:6px 0 0;border:0;background:transparent;box-shadow:none}
|
||||
.forum-inline-reply-toggle{cursor:pointer;display:inline-block;padding:3px 10px;border:1px solid #ffa500;border-radius:10px;background:#1a1a1a;color:#ffa500;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;list-style:none;width:max-content}
|
||||
.forum-inline-reply-toggle::-webkit-details-marker{display:none}
|
||||
|
|
@ -8874,3 +8918,9 @@ label.wallet-create-transfer input[type="checkbox"]{width:auto !important;flex:0
|
|||
.video-tile-me .lightbox-image{transform:scaleX(-1)}
|
||||
.phone-call-panel .live-lightbox{background:rgba(0,0,0,.85)}
|
||||
.live-lightbox .lightbox-image{width:min(80vw,880px);height:auto;max-width:none;max-height:80vh;object-fit:contain;cursor:zoom-out}
|
||||
.live-fullscreen-btn{position:absolute;top:15px;right:112px;width:42px;height:42px;display:flex;align-items:center;justify-content:center;box-sizing:border-box;background:rgba(0,0,0,.6);border-radius:50%;color:#fff;text-decoration:none}
|
||||
.live-fullscreen-btn .btn-icon{display:flex;width:20px;height:20px}
|
||||
.live-fullscreen-btn .btn-icon svg{display:block;width:100%;height:100%}
|
||||
.live-lightbox.live-fullscreen{background:#000;z-index:10050}
|
||||
.live-fullscreen .lightbox-image-link{display:flex;align-items:center;justify-content:center;width:100vw;height:100vh}
|
||||
.live-fullscreen .lightbox-image{width:100vw;height:100vh;max-width:100vw;max-height:100vh;object-fit:contain;border-radius:0}
|
||||
|
|
|
|||
|
|
@ -318,8 +318,8 @@ a.user-link:focus, button.user-link:focus {
|
|||
.btn-singleview:hover { background: #E0E0E0 !important; color: #FF8F00 !important; }
|
||||
.btn-spread-on, .content-actions .btn-pin-on { background: #FF6F00 !important; color: #FFFFFF !important; }
|
||||
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #FF8F00 !important; color: #FFFFFF !important; }
|
||||
.content-actions .btn-singleview.btn-content { border: 1px solid #FF6F00 !important; }
|
||||
.content-actions .btn-singleview.btn-content:hover { background: rgba(255, 111, 0, .18) !important; border-color: #FF6F00 !important; }
|
||||
.btn-singleview.btn-content { background: #FF6F00 !important; color: #F0F0F0 !important; border: 1px solid #FF6F00 !important; }
|
||||
.btn-singleview.btn-content:hover { background: #FF8F00 !important; color: #FFFFFF !important; border-color: #FF8F00 !important; }
|
||||
.btn-back { background: #FF6F00 !important; color: #FFFFFF !important; }
|
||||
.btn-back:hover { background: #FF8F00 !important; color: #FFFFFF !important; }
|
||||
.block-info-table td, .pm-info-table td { border-color: #E0E0E0 !important; }
|
||||
|
|
@ -344,6 +344,7 @@ a.user-link:focus, button.user-link:focus {
|
|||
.tribe-members-count { color: #FF6F00 !important; }
|
||||
.tribe-card-actions { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
|
||||
.tribe-action-btn { border-color: #FF6F00 !important; color: #FF6F00 !important; }
|
||||
button.tribe-action-btn { background: transparent !important; border: 1px solid #FF6F00 !important; }
|
||||
.tribe-action-btn:hover { background: #FF6F00 !important; color: #fff !important; }
|
||||
.tribe-subtribe-link { background: #F0F0F0 !important; border-color: #E0E0E0 !important; color: #FF6F00 !important; }
|
||||
.tribe-thumb-link { border-color: #E0E0E0 !important; }
|
||||
|
|
|
|||
|
|
@ -318,8 +318,8 @@ a.user-link:focus, button.user-link:focus {
|
|||
.btn-singleview:hover { background: #2d2e34 !important; color: #FFD700 !important; }
|
||||
.btn-spread-on, .content-actions .btn-pin-on { background: #ffa300 !important; color: #000000 !important; }
|
||||
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #ffb733 !important; color: #000000 !important; }
|
||||
.content-actions .btn-singleview.btn-content { border: 1px solid #ffa300 !important; }
|
||||
.content-actions .btn-singleview.btn-content:hover { background: rgba(255, 163, 0, .18) !important; border-color: #ffa300 !important; }
|
||||
.btn-singleview.btn-content { background: #ffa300 !important; color: #1e1f23 !important; border: 1px solid #ffa300 !important; }
|
||||
.btn-singleview.btn-content:hover { background: #FFD700 !important; color: #1e1f23 !important; border-color: #FFD700 !important; }
|
||||
.btn-back { background: #21232b !important; color: #ffa300 !important; }
|
||||
.btn-back:hover { background: #292b36 !important; color: #FFD700 !important; }
|
||||
.block-info-table td, .pm-info-table td { border-color: #444 !important; }
|
||||
|
|
|
|||
|
|
@ -320,8 +320,8 @@ a.user-link:focus, button.user-link:focus {
|
|||
.btn-singleview:hover { background: #00FF00 !important; color: #000000 !important; }
|
||||
.btn-spread-on, .content-actions .btn-pin-on { background: #00FF00 !important; color: #000000 !important; }
|
||||
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #66FF66 !important; color: #000000 !important; }
|
||||
.content-actions .btn-singleview.btn-content { border: 1px solid #00FF00 !important; }
|
||||
.content-actions .btn-singleview.btn-content:hover { background: rgba(0, 255, 0, .18) !important; border-color: #00FF00 !important; }
|
||||
.btn-singleview.btn-content { background: #00FF00 !important; color: #000000 !important; border: 1px solid #00FF00 !important; }
|
||||
.btn-singleview.btn-content:hover { background: #000000 !important; color: #00FF00 !important; border-color: #00FF00 !important; }
|
||||
.btn-back { background: #000000 !important; color: #00FF00 !important; border: 1px solid #00FF00 !important; }
|
||||
.btn-back:hover { background: #00FF00 !important; color: #000000 !important; }
|
||||
.block-info-table td, .pm-info-table td { border-color: #00FF00 !important; }
|
||||
|
|
|
|||
|
|
@ -1064,3 +1064,116 @@ body:has(.oasis-nav-side-toggle) { padding-top: 84px !important; }
|
|||
.oasis-nav-side-toggle ~ .header .top-bar-left nav,
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-right nav,
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-left a.logo-icon { display: none !important; }
|
||||
|
||||
/* ==========================================================================
|
||||
Upstream 1.3.3: piezas nuevas (karma del perfil, directo a pantalla completa,
|
||||
salas en vivo, graphos). Copiado tal cual del tema oficial: el resto de ese
|
||||
tema es otro aspecto y no se trae.
|
||||
========================================================================== */
|
||||
.main-column .live-lightbox.live-fullscreen {
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
z-index: 10050 !important;
|
||||
background: #000 !important;
|
||||
}
|
||||
.main-column .live-lightbox .live-fullscreen-btn {
|
||||
top: 8px !important;
|
||||
right: 64px !important;
|
||||
z-index: 2 !important;
|
||||
color: #FFD700 !important;
|
||||
}
|
||||
|
||||
.profile-sensors-box span.karma-line.karma-line-stacked {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
gap: 1px !important;
|
||||
}
|
||||
.profile-sensors-box span.karma-line .karma-line-main {
|
||||
display: block !important;
|
||||
line-height: 1.25 !important;
|
||||
}
|
||||
.profile-sensors-box span.karma-line .karma-received-row {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
gap: 14px !important;
|
||||
margin-top: 0 !important;
|
||||
line-height: 1.25 !important;
|
||||
}
|
||||
.profile-sensors-box span.karma-line .karma-received {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
.main-column .room-live-panel .room-live-head {
|
||||
gap: 6px !important;
|
||||
}
|
||||
.main-column .room-live-head .room-live-controls {
|
||||
gap: 6px !important;
|
||||
}
|
||||
.main-column .room-live-controls .tribe-action-btn {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
min-height: 0 !important;
|
||||
height: auto !important;
|
||||
padding: 5px 10px !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 1.2 !important;
|
||||
letter-spacing: 0 !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
.main-column .room-live-controls .room-stream-input:invalid ~ .room-stream-go,
|
||||
.main-column .room-live-controls .room-stream-input:valid ~ .room-stream-pick {
|
||||
display: none !important;
|
||||
}
|
||||
.main-column .room-stream-chip {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
gap: 4px !important;
|
||||
max-width: 100% !important;
|
||||
padding: 2px 10px !important;
|
||||
border-radius: 12px !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 1.4 !important;
|
||||
background: rgba(255, 163, 0, 0.1) !important;
|
||||
}
|
||||
.main-column .room-live-panel .video-grid {
|
||||
margin: 10px 0 !important;
|
||||
}
|
||||
.main-column .room-live-panel #room-video {
|
||||
scroll-margin-top: 80px !important;
|
||||
}
|
||||
.main-column .room-live-panel .phone-contacts {
|
||||
gap: 4px !important;
|
||||
}
|
||||
.main-column .phone-contact.room-peer {
|
||||
min-height: 0 !important;
|
||||
padding: 4px 6px !important;
|
||||
gap: 6px !important;
|
||||
}
|
||||
.main-column .phone-contact.room-peer .user-link {
|
||||
padding: 4px 10px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
.main-column .phone-contact.room-peer :is(.room-peer-you, .room-peer-cam, .room-peer-muted, .room-peer-hand) {
|
||||
padding: 1px 5px !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
.main-column .phone-contact.room-peer :is(button, .tribe-action-btn) {
|
||||
min-height: 0 !important;
|
||||
padding: 3px 8px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
.main-column .live-lightbox.live-fullscreen {
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
}
|
||||
.main-column .video-grid-solo .video-tile { width: 100% !important; }
|
||||
.main-column .inhabitants-grid .inhabitant-card { padding-bottom: 12px !important; }
|
||||
|
|
|
|||
|
|
@ -355,8 +355,8 @@ a.user-link:focus, button.user-link:focus {
|
|||
.btn-singleview:hover { background: #5A1A85 !important; color: #FFD600 !important; }
|
||||
.btn-spread-on, .content-actions .btn-pin-on { background: #B86ADE !important; color: #2A0E42 !important; }
|
||||
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #CE8CE9 !important; color: #2A0E42 !important; }
|
||||
.content-actions .btn-singleview.btn-content { border: 1px solid #B86ADE !important; }
|
||||
.content-actions .btn-singleview.btn-content:hover { background: rgba(184, 106, 222, .18) !important; border-color: #B86ADE !important; }
|
||||
.btn-singleview.btn-content { background: #B86ADE !important; color: #4B1A72 !important; border: 1px solid #B86ADE !important; }
|
||||
.btn-singleview.btn-content:hover { background: #FFD600 !important; color: #4B1A72 !important; border-color: #FFD600 !important; }
|
||||
.btn-back { background: #A34AD8 !important; color: #FFFFFF !important; }
|
||||
.btn-back:hover { background: #751E9F !important; color: #FFFFFF !important; }
|
||||
.block-info-table td, .pm-info-table td { border-color: #B86ADE !important; }
|
||||
|
|
@ -381,6 +381,7 @@ a.user-link:focus, button.user-link:focus {
|
|||
.tribe-members-count { color: #FFD600 !important; }
|
||||
.tribe-card-actions { border-color: #B86ADE !important; background: #2D0B47 !important; }
|
||||
.tribe-action-btn { border-color: #B86ADE !important; color: #B86ADE !important; }
|
||||
button.tribe-action-btn { background: transparent !important; }
|
||||
.tribe-action-btn:hover { background: #B86ADE !important; color: #000 !important; }
|
||||
.tribe-subtribe-link { background: #4B1A72 !important; border-color: #B86ADE !important; color: #FFD600 !important; }
|
||||
.tribe-thumb-link { border-color: #B86ADE !important; }
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ module.exports = {
|
|||
peersTorColumn: "TOR",
|
||||
peersTorYes: "يمكن الوصول إليه عبر Tor",
|
||||
peersTorNo: "لا يمكن الوصول إليه عبر Tor",
|
||||
peersOasisId: "Oasis ID",
|
||||
peersOasisId: "ID",
|
||||
peersOasisVersion: "الإصدار",
|
||||
graphos: "غرافوس",
|
||||
graphosDescription: "خريطة تفاعلية للشبكة من حولك.",
|
||||
|
|
@ -602,6 +602,21 @@ module.exports = {
|
|||
peerVersionUnannounced: "لا يعلن عن إصداره: Oasis قديم",
|
||||
peersTorMissing: "هذا الـPUB لا يسمح إلا بالاتصالات عبر TOR.",
|
||||
peerConnectionsTitle: "الاتصالات",
|
||||
peersOnlineHint: "العقد المتصلة بك الآن.",
|
||||
peersDiscoveredHint: "عقد ذات عنوان معروف غير متصلة حاليًا.",
|
||||
peersUnknownHint: "عقد في شبكتك الاجتماعية بدون عنوان معروف.",
|
||||
peersConnectionsHint: "العناوين التي يمكن لهذه العقدة الاتصال بها وحالة كل اتصال.",
|
||||
peersTabLan: "الشبكة المحلية",
|
||||
peersTabHealth: "الحالة",
|
||||
peersHealthHint: "حال كل pub أو نظير معروف، داخل الشبكة المحلية وخارجها.",
|
||||
peersHealthReachable: "يمكن الوصول إليه",
|
||||
peersHealthFailing: "يفشل",
|
||||
peersHealthFailures: "الإخفاقات",
|
||||
peersFailingSince: "يفشل منذ",
|
||||
peersHealthForget: "نسيان غير القابلة للوصول",
|
||||
peersLanHint: "العقد التي تم العثور عليها في شبكتك المحلية.",
|
||||
peersLanLastSeen: "آخر ظهور",
|
||||
peersLanEmpty: "لم يُعثر على أي عقدة في الشبكة المحلية بعد.",
|
||||
online: "متصل",
|
||||
offline: "غير متصل",
|
||||
discovered: 'مُكتشَف',
|
||||
|
|
@ -1650,8 +1665,6 @@ module.exports = {
|
|||
dataTermsTitle: "الكلمات المفتاحية",
|
||||
dataTermsHint: "المصطلحات التي تظهر في أكثر المحتويات.",
|
||||
dataConnections: "تطابقات مرتبطة",
|
||||
dataTopicTitle: "كل ما يتعلق بـ",
|
||||
dataTopicHint: "ما يربطه الخوارزم بهذا الموضوع",
|
||||
dataNoMatches: "لا توجد بيانات متقاطعة لعرضها.",
|
||||
dataNoProfile: "انشر محتوى أو اكتب سيرتك الذاتية ليتعلم الخوارزم ما يهمك.",
|
||||
pollsDescription: "وحدة لسؤال الشبكة وعدّ الإجابات.",
|
||||
|
|
@ -2204,9 +2217,7 @@ module.exports = {
|
|||
tribeTrendingEngagement: "التفاعل",
|
||||
tribeTopCategory: "الأكثر تصويتًا",
|
||||
tribeTagsEmpty: "لم يتم العثور على وسوم.",
|
||||
tribeSearchPlaceholder: "ابحث في محتوى القبيلة...",
|
||||
tribeSearchEmpty: "لم يتم العثور على نتائج.",
|
||||
tribeSearchResults: "النتائج",
|
||||
tribeSearchMinChars: "أدخل حرفين على الأقل للبحث.",
|
||||
agendaTitle: "الأجندة",
|
||||
agendaDescription: "هنا يمكنك العثور على جميع العناصر المسندة إليك.",
|
||||
|
|
@ -3835,6 +3846,7 @@ module.exports = {
|
|||
roomUpdate: "تحديث",
|
||||
roomDelete: "حذف",
|
||||
roomClose: "إغلاق",
|
||||
roomReopen: "إعادة فتح",
|
||||
roomCreateSectionTitle: "إنشاء غرفة",
|
||||
roomUpdateSectionTitle: "تحديث الغرفة",
|
||||
roomTitlePlaceholder: "عنوان الغرفة",
|
||||
|
|
@ -3853,6 +3865,15 @@ module.exports = {
|
|||
roomMuted: "مكتوم",
|
||||
roomRec: "تسجيل الصوت",
|
||||
roomRecStop: "إيقاف التسجيل",
|
||||
roomStreamFile: "بثّ",
|
||||
roomStreamStop: "إيقاف",
|
||||
roomStreaming: "جارٍ البث",
|
||||
liveFullscreen: "ملء الشاشة",
|
||||
roomStreamPickTitle: "اختر فيديو",
|
||||
roomStreamPickHint: "مقاطع الفيديو على هذا الجهاز. يُبث الفيديو الذي تختاره من مكانه دون نسخه أو نشره.",
|
||||
roomStreamGo: "بثّه في الغرفة",
|
||||
roomStreamNotFound: "هذا الفيديو ليس في مجلداتك.",
|
||||
roomPublicWarning: "تُبث هذه الغرفة على الويب العام: يمكن لأي شخص رؤية وسماع ما يحدث فيها.",
|
||||
roomRecording: "REC",
|
||||
roomNotificationsOn: "ON",
|
||||
roomNotificationsOff: "OFF",
|
||||
|
|
@ -4333,7 +4354,7 @@ module.exports = {
|
|||
phoneHistoryTitle: "السجل",
|
||||
phoneLatestTitle: "آخر المكالمات",
|
||||
phoneCreateButton: "مكالمة جديدة",
|
||||
phoneLimitsHint: "حتى 7 من السكان لكل مكالمة؛ أكثر من ساكن واحد يجعلها مكالمة مشتركة.",
|
||||
phoneLimitsHint: "حتى {n} من السكان لكل مكالمة؛ أكثر من ساكن واحد يجعلها مكالمة مشتركة.",
|
||||
phoneErrorPub: "لا يمكن الاتصال بخادم pub.",
|
||||
phoneJointCall: "مكالمة مشتركة",
|
||||
phoneWith: "مع",
|
||||
|
|
@ -4478,6 +4499,22 @@ module.exports = {
|
|||
settingsPmVisibilityDesc: "قم بتكوين مستوى التعرض للرسائل الخاصة في الشبكة.",
|
||||
settingsPmVisibilityWhole: "الكون المتعدد",
|
||||
settingsPmVisibilityMutuals: "الدعم المتبادل فقط",
|
||||
mute: "كتم",
|
||||
unmute: "إلغاء الكتم",
|
||||
relationshipMuted: "مكتوم من قبلك",
|
||||
hideContent: "إخفاء عني",
|
||||
unhideContent: "إظهار مجددًا",
|
||||
hiddenByYou: "مخفي من قبلك",
|
||||
settingsTabContent: "المحتوى",
|
||||
settingsHiddenTitle: "المحتوى المخفي",
|
||||
settingsHiddenDesc: "السكان الذين كتمتهم والمحتوى الذي أخفيته.",
|
||||
settingsMutedLabel: "السكان المكتومون",
|
||||
settingsHiddenLabel: "المحتوى المخفي",
|
||||
settingsMutedEmpty: "لا أحد مكتوم.",
|
||||
settingsHiddenEmpty: "لا شيء مخفي.",
|
||||
settingsPmVisibilityFollowing: "فقط من أتابعهم",
|
||||
settingsMentionsVisibilityTitle: "الإشارات",
|
||||
settingsMentionsVisibilityDesc: "اختر من تصلك إشاراته.",
|
||||
pmMutualNotice: "الإرسال حاجز UX. الاستقبال فلتر انتباه.",
|
||||
inhabitantsPendingFollowsTitle: "طلبات الدعم المعلقة",
|
||||
inhabitantsPendingAccept: "قبول",
|
||||
|
|
@ -4610,7 +4647,7 @@ module.exports = {
|
|||
bankRulesFloor: "الحد الأدنى لكل مستخدم (إجمالي)",
|
||||
bankRulesGraceDays: "فترة السماح للمطالبة",
|
||||
bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))",
|
||||
bankRulesKarmaNote: "تُشتق الكارما من أفعالك (المنشورات، والأصوات، والمشاركة في البرلمان/المحاكم، وغير ذلك) مع معامل تناقص بمرور الوقت، مطروحًا منها غرامات الكربون التي تولّدها تغذيتك.",
|
||||
bankRulesKarmaNote: "تُشتق الكارما من أفعالك (المنشورات، والأصوات، والمشاركة في البرلمان/المحاكم، وغير ذلك) مع معامل تناقص بمرور الوقت، مطروحًا منها غرامات الكربون التي تولّدها تغذيتك. ما يتلقاه محتواك يُحتسب أيضًا: الآراء والأصوات والتفاعلات والتشغيلات الإيجابية تضيف كارما، والآراء أو الأصوات السلبية تخصمها عندما يتفق عدة سكان.",
|
||||
bankRulesKarmaTitle: "الكارما (درجة تفاعل المستخدم)",
|
||||
bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)",
|
||||
bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)",
|
||||
|
|
@ -4696,7 +4733,7 @@ module.exports = {
|
|||
larpRulesTestEntryText: "في اختبار WILL يمنح كل خيار وزنًا لبيت أو أكثر؛ وعند الإرسال يجمع النظام النقاط ويضمّك تلقائيًا إلى البيت الأعلى نقاطًا.",
|
||||
larpWillTestHint: "بمجرد الانتهاء، ستُسند إليك البيت الذي يتطابق بشكل أفضل مع إجاباتك. تتم معالجة إجاباتك الفردية محلياً ولا يتم تخزينها أبداً — يُنشر في خلاصتك فقط نتيجة تعيين البيت.",
|
||||
larpWillTestTitle: "اختبار WILL",
|
||||
settingsLanDesc: "الإعلان بشكل دوري عن هذا النظير لمثيلات Oasis الأخرى على نفس الشبكة المحلية. تعطيل لإيقاف بث UDP.",
|
||||
settingsLanDesc: "الإعلان بشكل دوري عن هذا النظير لمثيلات Oasis الأخرى على نفس الشبكة المحلية.",
|
||||
settingsLanEnable: "تفعيل بث الشبكة المحلية",
|
||||
settingsLanTitle: "بث الشبكة المحلية",
|
||||
settingsReplicationTitle: "النسخ",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ module.exports = {
|
|||
peersTorColumn: "TOR",
|
||||
peersTorYes: "Accessible per Tor",
|
||||
peersTorNo: "No accessible per Tor",
|
||||
peersOasisId: "Oasis ID",
|
||||
peersOasisId: "ID",
|
||||
peersOasisVersion: "Versió",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "Mapa interactiu de la xarxa al teu voltant.",
|
||||
|
|
@ -594,6 +594,21 @@ module.exports = {
|
|||
peerVersionUnannounced: "No anuncia la seva versió: un Oasis antic",
|
||||
peersTorMissing: "Aquest PUB només permet connexions des de TOR.",
|
||||
peerConnectionsTitle: "Connexions",
|
||||
peersOnlineHint: "Nodes connectats amb tu ara mateix.",
|
||||
peersDiscoveredHint: "Nodes amb adreça coneguda que no estan connectats ara.",
|
||||
peersUnknownHint: "Nodes del teu graf social sense adreça coneguda.",
|
||||
peersConnectionsHint: "Adreces a les quals pot connectar aquest node i l'estat de cada connexió.",
|
||||
peersTabLan: "LAN",
|
||||
peersTabHealth: "Salut",
|
||||
peersHealthHint: "Com està cada pub o node conegut, a la xarxa local i fora.",
|
||||
peersHealthReachable: "Accessible",
|
||||
peersHealthFailing: "Fallant",
|
||||
peersHealthFailures: "Errors",
|
||||
peersFailingSince: "Falla des de",
|
||||
peersHealthForget: "Oblidar inaccessibles",
|
||||
peersLanHint: "Nodes trobats a la teva xarxa local.",
|
||||
peersLanLastSeen: "Vist per última vegada",
|
||||
peersLanEmpty: "Encara no s'ha trobat cap node a la xarxa local.",
|
||||
online: "Connectat",
|
||||
offline: "Desconnectat",
|
||||
discovered: "Descoberts",
|
||||
|
|
@ -1639,8 +1654,6 @@ module.exports = {
|
|||
dataTermsTitle: "Paraules clau",
|
||||
dataTermsHint: "Els termes que apareixen en més continguts.",
|
||||
dataConnections: "Coincidències enllaçades",
|
||||
dataTopicTitle: "Tot el relacionat amb",
|
||||
dataTopicHint: "El que l'algorisme relaciona amb aquest tema",
|
||||
dataNoMatches: "No hi ha dades creuades per mostrar.",
|
||||
dataNoProfile: "Publica contingut o escriu el teu CV perquè l'algorisme aprengui què t'interessa.",
|
||||
pollsDescription: "Mòdul per preguntar a la xarxa i comptar les respostes.",
|
||||
|
|
@ -2194,9 +2207,7 @@ module.exports = {
|
|||
tribeTrendingEngagement: "interacció",
|
||||
tribeTopCategory: "Més votat",
|
||||
tribeTagsEmpty: "No s'han trobat etiquetes.",
|
||||
tribeSearchPlaceholder: "Cercar contingut de la tribu...",
|
||||
tribeSearchEmpty: "Sense resultats.",
|
||||
tribeSearchResults: "Resultats",
|
||||
tribeSearchMinChars: "Introdueix almenys 2 caràcters per cercar.",
|
||||
agendaTitle: "Agenda",
|
||||
agendaDescription: "Aquí pots trobar tots els teus elements assignats.",
|
||||
|
|
@ -3833,6 +3844,7 @@ module.exports = {
|
|||
roomUpdate: "Actualitzar",
|
||||
roomDelete: "Eliminar",
|
||||
roomClose: "Tancar",
|
||||
roomReopen: "Reobrir",
|
||||
roomCreateSectionTitle: "Crear sala",
|
||||
roomUpdateSectionTitle: "Actualitzar sala",
|
||||
roomTitlePlaceholder: "Títol de la sala",
|
||||
|
|
@ -3851,6 +3863,15 @@ module.exports = {
|
|||
roomMuted: "silenciat",
|
||||
roomRec: "Gravar àudio",
|
||||
roomRecStop: "Atura la gravació",
|
||||
roomStreamFile: "Emet",
|
||||
roomStreamStop: "Atura",
|
||||
roomStreaming: "Emetent",
|
||||
liveFullscreen: "Pantalla completa",
|
||||
roomStreamPickTitle: "Tria un vídeo",
|
||||
roomStreamPickHint: "Vídeos d'aquest dispositiu. El que triïs s'emet des d'on és, sense copiar-lo ni publicar-lo.",
|
||||
roomStreamGo: "Emet a la sala",
|
||||
roomStreamNotFound: "Aquest vídeo no és a les teves carpetes.",
|
||||
roomPublicWarning: "Aquesta sala es retransmet a la web pública: qualsevol pot veure i sentir el que hi passa.",
|
||||
roomRecording: "REC",
|
||||
roomNotificationsOn: "ON",
|
||||
roomNotificationsOff: "OFF",
|
||||
|
|
@ -4332,7 +4353,7 @@ module.exports = {
|
|||
phoneHistoryTitle: "Historial",
|
||||
phoneLatestTitle: "Últimes trucades",
|
||||
phoneCreateButton: "Nova trucada",
|
||||
phoneLimitsHint: "Fins a 7 habitants per trucada; amb més d'un és una trucada conjunta.",
|
||||
phoneLimitsHint: "Fins a {n} habitants per trucada; amb més d'un és una trucada conjunta.",
|
||||
phoneErrorPub: "No es pot trucar a un pub.",
|
||||
phoneJointCall: "Trucada conjunta",
|
||||
phoneWith: "Amb",
|
||||
|
|
@ -4477,6 +4498,22 @@ module.exports = {
|
|||
settingsPmVisibilityDesc: "Configura el nivell d'exposició a missatges privats que vols tenir a la xarxa.",
|
||||
settingsPmVisibilityWhole: "Multivers",
|
||||
settingsPmVisibilityMutuals: "Només suport mutu",
|
||||
mute: "Silenciar",
|
||||
unmute: "Deixar de silenciar",
|
||||
relationshipMuted: "Silenciat per tu",
|
||||
hideContent: "Amagar per a mi",
|
||||
unhideContent: "Mostrar de nou",
|
||||
hiddenByYou: "Amagat per tu",
|
||||
settingsTabContent: "Contingut",
|
||||
settingsHiddenTitle: "Contingut amagat",
|
||||
settingsHiddenDesc: "Habitants que has silenciat i contingut que has amagat.",
|
||||
settingsMutedLabel: "Habitants silenciats",
|
||||
settingsHiddenLabel: "Contingut amagat",
|
||||
settingsMutedEmpty: "Ningú silenciat.",
|
||||
settingsHiddenEmpty: "Res amagat.",
|
||||
settingsPmVisibilityFollowing: "Només gent que segueixo",
|
||||
settingsMentionsVisibilityTitle: "Mencions",
|
||||
settingsMentionsVisibilityDesc: "Tria de qui t'arriben les mencions.",
|
||||
pmMutualNotice: "L'enviament és una barrera d'UX. La recepció és un filtre d'atenció (el xifratge ja està replicat).",
|
||||
inhabitantsPendingFollowsTitle: "Sol·licituds de suport pendents",
|
||||
inhabitantsPendingAccept: "Acceptar",
|
||||
|
|
@ -4583,7 +4620,7 @@ module.exports = {
|
|||
bankRulesEcoTaxFormula: "Taxa ECO(usuari) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2",
|
||||
bankRulesEcoTaxNote: "L'impost ECO es dedueix de la RBU bruta quan es calcula l'assignació de l'època. Els ECO deduïts no es redistribueixen en aquesta època — es queden al saldo del pub i alimenten el fons de la pròxima època a través dels límits reserveMin / alpha.",
|
||||
bankRulesKarmaFormula: "karma = log10(1 + actions) + reputation",
|
||||
bankRulesKarmaNote: "El karma es deriva de les teves accions (publicacions, vots, participació al parlament/tribunals, etc.) amb un factor de decaïment temporal, menys els grams de carboni que genera el teu feed.",
|
||||
bankRulesKarmaNote: "El karma es deriva de les teves accions (publicacions, vots, participació al parlament/tribunals, etc.) amb un factor de decaïment temporal, menys els grams de carboni que genera el teu feed. El que rep el teu contingut també compta: les opinions, vots, reaccions i reproduccions positives sumen karma, i les opinions o vots negatius el resten quan diversos habitants coincideixen.",
|
||||
bankRulesNetFormula: "net_UBI = floor + max(0, gross_UBI − floor) − ECO Tax − ARCH Tax",
|
||||
bankRulesPoolFormula: "pool = min(pubBal − reserveMin, capPerEpoch, alpha · pubBal)",
|
||||
bankRulesShareFormula: "share = clamp(1 + karma/100, w_min, w_max) / sum(weights)",
|
||||
|
|
@ -4618,7 +4655,7 @@ module.exports = {
|
|||
larpRulesTestEntryText: "Al test WILL cada opció pondera una o més cases; en enviar-lo, el sistema suma les puntuacions i t'admet automàticament a la casa amb més punts.",
|
||||
larpWillTestHint: "Un cop completat, se t'assignarà la casa que més s'ajusti a les teves respostes. Les teves respostes individuals es processen localment i no s'emmagatzemen mai — només es publica al teu feed l'assignació de casa resultant.",
|
||||
larpWillTestTitle: "Test WILL",
|
||||
settingsLanDesc: "Anuncia periòdicament aquest node a altres instàncies d'Oasis de la mateixa xarxa local. Desactiva-ho per aturar les difusions UDP.",
|
||||
settingsLanDesc: "Anuncia periòdicament aquest node a altres instàncies d'Oasis de la mateixa xarxa local.",
|
||||
settingsLanEnable: "Activar la difusió LAN",
|
||||
settingsLanTitle: "Difusió LAN",
|
||||
settingsReplicationTitle: "Replicació",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ module.exports = {
|
|||
peersTorColumn: "TOR",
|
||||
peersTorYes: "Über Tor erreichbar",
|
||||
peersTorNo: "Nicht über Tor erreichbar",
|
||||
peersOasisId: "Oasis ID",
|
||||
peersOasisId: "ID",
|
||||
peersOasisVersion: "Version",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "Interaktive Karte des Netzwerks um dich herum.",
|
||||
|
|
@ -598,6 +598,21 @@ module.exports = {
|
|||
peerVersionUnannounced: "Gibt seine Version nicht bekannt: ein altes Oasis",
|
||||
peersTorMissing: "Dieser PUB erlaubt nur Verbindungen über TOR.",
|
||||
peerConnectionsTitle: "Verbindungen",
|
||||
peersOnlineHint: "Knoten, die gerade mit dir verbunden sind.",
|
||||
peersDiscoveredHint: "Knoten mit bekannter Adresse, die gerade nicht verbunden sind.",
|
||||
peersUnknownHint: "Knoten in deinem sozialen Graphen ohne bekannte Adresse.",
|
||||
peersConnectionsHint: "Adressen, die dieser Knoten anwählen kann, und der Zustand jeder Verbindung.",
|
||||
peersTabLan: "LAN",
|
||||
peersTabHealth: "Zustand",
|
||||
peersHealthHint: "Wie es jedem bekannten Pub oder Peer geht, im lokalen Netz und darüber hinaus.",
|
||||
peersHealthReachable: "Erreichbar",
|
||||
peersHealthFailing: "Fehlschlagend",
|
||||
peersHealthFailures: "Fehlschläge",
|
||||
peersFailingSince: "Fehlschlagend seit",
|
||||
peersHealthForget: "Unerreichbare vergessen",
|
||||
peersLanHint: "Knoten, die in deinem lokalen Netz gefunden wurden.",
|
||||
peersLanLastSeen: "Zuletzt gesehen",
|
||||
peersLanEmpty: "Noch kein Knoten im lokalen Netz gefunden.",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
discovered: 'Entdeckt',
|
||||
|
|
@ -1644,8 +1659,6 @@ module.exports = {
|
|||
dataTermsTitle: "Schlagwörter",
|
||||
dataTermsHint: "Die Begriffe, die in den meisten Inhalten vorkommen.",
|
||||
dataConnections: "Verknüpfte Treffer",
|
||||
dataTopicTitle: "Alles zu",
|
||||
dataTopicHint: "Was der Algorithmus mit diesem Thema verbindet",
|
||||
dataNoMatches: "Keine Querdaten vorhanden.",
|
||||
dataNoProfile: "Veröffentliche Inhalte oder schreib deinen Lebenslauf, damit der Algorithmus lernt, was dich interessiert.",
|
||||
pollsDescription: "Modul, um das Netzwerk zu fragen und Antworten zu zählen.",
|
||||
|
|
@ -2201,9 +2214,7 @@ module.exports = {
|
|||
tribeTrendingEngagement: "Beteiligung",
|
||||
tribeTopCategory: "Meistgewählt",
|
||||
tribeTagsEmpty: "Keine Tags gefunden.",
|
||||
tribeSearchPlaceholder: "Stamminhalt durchsuchen...",
|
||||
tribeSearchEmpty: "Keine Ergebnisse gefunden.",
|
||||
tribeSearchResults: "Ergebnisse",
|
||||
tribeSearchMinChars: "Mindestens 2 Zeichen eingeben, um zu suchen.",
|
||||
agendaTitle: "Agenda",
|
||||
agendaDescription: "Hier findest du alle dir zugewiesenen Einträge.",
|
||||
|
|
@ -3833,6 +3844,7 @@ module.exports = {
|
|||
roomUpdate: "Aktualisieren",
|
||||
roomDelete: "Löschen",
|
||||
roomClose: "Schließen",
|
||||
roomReopen: "Wieder öffnen",
|
||||
roomCreateSectionTitle: "Raum erstellen",
|
||||
roomUpdateSectionTitle: "Raum aktualisieren",
|
||||
roomTitlePlaceholder: "Titel des Raums",
|
||||
|
|
@ -3851,6 +3863,15 @@ module.exports = {
|
|||
roomMuted: "stumm",
|
||||
roomRec: "Audio aufnehmen",
|
||||
roomRecStop: "Aufnahme beenden",
|
||||
roomStreamFile: "Streamen",
|
||||
roomStreamStop: "Stoppen",
|
||||
roomStreaming: "Wird gestreamt",
|
||||
liveFullscreen: "Vollbild",
|
||||
roomStreamPickTitle: "Video auswählen",
|
||||
roomStreamPickHint: "Videos auf diesem Gerät. Das gewählte wird von seinem Speicherort aus gestreamt, ohne es zu kopieren oder zu veröffentlichen.",
|
||||
roomStreamGo: "Im Raum abspielen",
|
||||
roomStreamNotFound: "Dieses Video ist nicht in deinen Ordnern.",
|
||||
roomPublicWarning: "Dieser Raum wird im öffentlichen Web übertragen: Jeder kann sehen und hören, was darin passiert.",
|
||||
roomRecording: "REC",
|
||||
roomNotificationsOn: "ON",
|
||||
roomNotificationsOff: "OFF",
|
||||
|
|
@ -4328,7 +4349,7 @@ module.exports = {
|
|||
phoneHistoryTitle: "Verlauf",
|
||||
phoneLatestTitle: "Letzte Anrufe",
|
||||
phoneCreateButton: "Neuer Anruf",
|
||||
phoneLimitsHint: "Bis zu 7 Bewohner pro Anruf; mit mehr als einem ist es ein gemeinsamer Anruf.",
|
||||
phoneLimitsHint: "Bis zu {n} Bewohner pro Anruf; mit mehr als einem ist es ein gemeinsamer Anruf.",
|
||||
phoneErrorPub: "Pubs können nicht angerufen werden.",
|
||||
phoneJointCall: "Gemeinsamer Anruf",
|
||||
phoneWith: "Mit",
|
||||
|
|
@ -4473,6 +4494,22 @@ module.exports = {
|
|||
settingsPmVisibilityDesc: "Konfiguriere dein Niveau der Offenlegung für private Nachrichten.",
|
||||
settingsPmVisibilityWhole: "Multiversum",
|
||||
settingsPmVisibilityMutuals: "Nur gegenseitige Unterstützung",
|
||||
mute: "Stummschalten",
|
||||
unmute: "Stummschaltung aufheben",
|
||||
relationshipMuted: "Von dir stummgeschaltet",
|
||||
hideContent: "Für mich ausblenden",
|
||||
unhideContent: "Wieder anzeigen",
|
||||
hiddenByYou: "Von dir ausgeblendet",
|
||||
settingsTabContent: "Inhalt",
|
||||
settingsHiddenTitle: "Ausgeblendete Inhalte",
|
||||
settingsHiddenDesc: "Stummgeschaltete Bewohner und ausgeblendete Inhalte.",
|
||||
settingsMutedLabel: "Stummgeschaltete Bewohner",
|
||||
settingsHiddenLabel: "Ausgeblendete Inhalte",
|
||||
settingsMutedEmpty: "Niemand stummgeschaltet.",
|
||||
settingsHiddenEmpty: "Nichts ausgeblendet.",
|
||||
settingsPmVisibilityFollowing: "Nur Leute, denen ich folge",
|
||||
settingsMentionsVisibilityTitle: "Erwähnungen",
|
||||
settingsMentionsVisibilityDesc: "Wähle, wessen Erwähnungen dich erreichen.",
|
||||
pmMutualNotice: "Ausgang ist ein UX-Schutz. Eingang ist ein Aufmerksamkeitsfilter.",
|
||||
inhabitantsPendingFollowsTitle: "Ausstehende Unterstützungsanfragen",
|
||||
inhabitantsPendingAccept: "Akzeptieren",
|
||||
|
|
@ -4579,7 +4616,7 @@ module.exports = {
|
|||
bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2",
|
||||
bankRulesEcoTaxNote: "Die ECO-Steuer wird vom Brutto-UBI abgezogen, wenn die Zuteilung der Epoche berechnet wird. Die abgezogenen ECO werden in dieser Epoche nicht umverteilt — sie bleiben im Pub-Guthaben und speisen den Pool der nächsten Epoche über die Obergrenzen reserveMin / alpha.",
|
||||
bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))",
|
||||
bankRulesKarmaNote: "Karma ergibt sich aus deinen Aktionen (Beiträge, Stimmen, Beteiligung an Parlament/Gerichten usw.) mit einem zeitlichen Abklingfaktor, abzüglich der von deinem Feed erzeugten Gramm CO2.",
|
||||
bankRulesKarmaNote: "Karma ergibt sich aus deinen Aktionen (Beiträge, Stimmen, Beteiligung an Parlament/Gerichten usw.) mit einem zeitlichen Abklingfaktor, abzüglich der von deinem Feed erzeugten Gramm CO2. Was deine Inhalte erhalten, zählt ebenfalls: positive Meinungen, Stimmen, Reaktionen und Wiedergaben geben Karma, negative Meinungen oder Stimmen ziehen es ab, sobald mehrere Bewohner übereinstimmen.",
|
||||
bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)",
|
||||
bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)",
|
||||
bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)",
|
||||
|
|
@ -4613,7 +4650,7 @@ module.exports = {
|
|||
larpRulesTestEntryText: "Im WILL-Test gewichtet jede Option ein oder mehrere Häuser; beim Absenden zählt das System die Punkte und nimmt dich automatisch im Haus mit der höchsten Punktzahl auf.",
|
||||
larpWillTestHint: "Nach Abschluss wirst du dem Haus zugewiesen, das am besten zu deinen Antworten passt. Deine einzelnen Antworten werden lokal verarbeitet und nie gespeichert — nur die resultierende Hauszuweisung wird in deinem Feed veröffentlicht.",
|
||||
larpWillTestTitle: "WILL-Test",
|
||||
settingsLanDesc: "Diesen Peer periodisch an andere Oasis-Instanzen im selben lokalen Netzwerk ankündigen. Deaktivieren, um UDP-Broadcasts zu stoppen.",
|
||||
settingsLanDesc: "Diesen Peer periodisch an andere Oasis-Instanzen im selben lokalen Netzwerk ankündigen.",
|
||||
settingsLanEnable: "LAN-Broadcasting aktivieren",
|
||||
settingsLanTitle: "LAN-Broadcasting",
|
||||
settingsReplicationTitle: "Replikation",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ module.exports = {
|
|||
peersTorColumn: "TOR",
|
||||
peersTorYes: "Reachable over Tor",
|
||||
peersTorNo: "Not reachable over Tor",
|
||||
peersOasisId: "Oasis ID",
|
||||
peersOasisId: "ID",
|
||||
peersOasisVersion: "Version",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "Interactive map of the network around you.",
|
||||
|
|
@ -500,7 +500,7 @@ module.exports = {
|
|||
ssbLogStreamDescription: "Configure the message limit for Blockchain streams.",
|
||||
logWindowChip: "Last {n} blockchain streams",
|
||||
settingsLanTitle: "LAN Broadcasting",
|
||||
settingsLanDesc: "Periodically announce this peer to other Oasis instances on the same local network. Disable to stop UDP broadcasts.",
|
||||
settingsLanDesc: "Periodically announce this peer to other Oasis instances on the same local network.",
|
||||
settingsReplicationTitle: "Replication",
|
||||
settingsReplicationDesc: "Configure the number of hops your peer follows out from your own feed when replicating content.",
|
||||
settingsReplicationHopsLabel: "Hops",
|
||||
|
|
@ -626,6 +626,21 @@ module.exports = {
|
|||
peerVersionUnannounced: "It does not announce its version: an old Oasis",
|
||||
peersTorMissing: "This PUB only allows connections through TOR.",
|
||||
peerConnectionsTitle: "Connections",
|
||||
peersOnlineHint: "Nodes connected to you right now.",
|
||||
peersDiscoveredHint: "Nodes with a known address that are not connected right now.",
|
||||
peersUnknownHint: "Nodes in your social graph with no known address.",
|
||||
peersConnectionsHint: "Addresses this node can dial and the state of each connection.",
|
||||
peersTabLan: "LAN",
|
||||
peersTabHealth: "Health",
|
||||
peersHealthHint: "How each known pub or peer is doing, on the local network and beyond.",
|
||||
peersHealthReachable: "Reachable",
|
||||
peersHealthFailing: "Failing",
|
||||
peersHealthFailures: "Failures",
|
||||
peersFailingSince: "Failing since",
|
||||
peersHealthForget: "Forget unreachable",
|
||||
peersLanHint: "Nodes met on your local network.",
|
||||
peersLanLastSeen: "Last seen",
|
||||
peersLanEmpty: "No nodes met on the local network yet.",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
discovered: 'Discovered',
|
||||
|
|
@ -1134,7 +1149,7 @@ module.exports = {
|
|||
bankRulesWeightFormula: "w = clamp(1 + karma/100, w_min, w_max)",
|
||||
bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)",
|
||||
bankRulesKarmaTitle: "Karma (user engagement score)",
|
||||
bankRulesKarmaNote: "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed.",
|
||||
bankRulesKarmaNote: "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed. What your content receives counts too: positive opinions, votes, reactions and plays add karma, and negative opinions or votes subtract it once several inhabitants agree.",
|
||||
bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))",
|
||||
bankRulesEcoTaxTitle: "ECO Tax",
|
||||
bankRulesEcoTaxRate: "Rate",
|
||||
|
|
@ -1765,8 +1780,6 @@ module.exports = {
|
|||
dataTermsTitle: "Keywords",
|
||||
dataTermsHint: "The terms that appear across the most content.",
|
||||
dataConnections: "Linked matches",
|
||||
dataTopicTitle: "Everything related to",
|
||||
dataTopicHint: "What the algorithm relates to this topic",
|
||||
dataNoMatches: "No cross-data to show.",
|
||||
dataNoProfile: "Publish content or write your CV so the algorithm can learn what interests you.",
|
||||
pollsDescription: "Module to ask the network and count the answers.",
|
||||
|
|
@ -2461,9 +2474,7 @@ module.exports = {
|
|||
tribeTrendingEngagement: "engagement",
|
||||
tribeTopCategory: "More Voted",
|
||||
tribeTagsEmpty: "No tags found.",
|
||||
tribeSearchPlaceholder: "Search tribe content...",
|
||||
tribeSearchEmpty: "No results found.",
|
||||
tribeSearchResults: "Results",
|
||||
tribeSearchMinChars: "Enter at least 2 characters to search.",
|
||||
agendaTitle: "Agenda",
|
||||
agendaDescription: "Here you can find all your assigned items.",
|
||||
|
|
@ -4089,6 +4100,7 @@ module.exports = {
|
|||
roomUpdate: "Update",
|
||||
roomDelete: "Delete",
|
||||
roomClose: "Close",
|
||||
roomReopen: "Reopen",
|
||||
roomCreateSectionTitle: "Create Room",
|
||||
roomUpdateSectionTitle: "Update Room",
|
||||
roomTitlePlaceholder: "Room title",
|
||||
|
|
@ -4107,6 +4119,15 @@ module.exports = {
|
|||
roomMuted: "muted",
|
||||
roomRec: "Record audio",
|
||||
roomRecStop: "Stop recording",
|
||||
roomStreamFile: "Stream",
|
||||
roomStreamStop: "Stop",
|
||||
roomStreaming: "Streaming",
|
||||
liveFullscreen: "Full screen",
|
||||
roomStreamPickTitle: "Choose a video",
|
||||
roomStreamPickHint: "Videos on this device. The one you choose is streamed from where it is, without copying or publishing it.",
|
||||
roomStreamGo: "Play in the room",
|
||||
roomStreamNotFound: "That video is not in your folders.",
|
||||
roomPublicWarning: "This room is broadcast on the public web: anyone can see and hear what happens in it.",
|
||||
roomRecording: "REC",
|
||||
roomNotificationsOn: "ON",
|
||||
roomNotificationsOff: "OFF",
|
||||
|
|
@ -4649,7 +4670,7 @@ module.exports = {
|
|||
phoneHistoryTitle: "History",
|
||||
phoneLatestTitle: "Latest Calls",
|
||||
phoneCreateButton: "New call",
|
||||
phoneLimitsHint: "Up to 7 inhabitants per call; more than one makes a joint call.",
|
||||
phoneLimitsHint: "Up to {n} inhabitants per call; more than one makes a joint call.",
|
||||
phoneErrorPub: "Pubs cannot be called.",
|
||||
phoneJointCall: "Joint call",
|
||||
phoneWith: "With",
|
||||
|
|
@ -4794,6 +4815,22 @@ module.exports = {
|
|||
settingsPmVisibilityDesc: "Configure the level of exposition to private messages that you want to have in the network.",
|
||||
settingsPmVisibilityWhole: "Multiverse",
|
||||
settingsPmVisibilityMutuals: "Only mutual-support",
|
||||
mute: "Mute",
|
||||
unmute: "Unmute",
|
||||
relationshipMuted: "Muted by you",
|
||||
hideContent: "Hide for me",
|
||||
unhideContent: "Show again",
|
||||
hiddenByYou: "Hidden by you",
|
||||
settingsTabContent: "Content",
|
||||
settingsHiddenTitle: "Hidden Content",
|
||||
settingsHiddenDesc: "Inhabitants you muted and content you hid.",
|
||||
settingsMutedLabel: "Muted inhabitants",
|
||||
settingsHiddenLabel: "Hidden content",
|
||||
settingsMutedEmpty: "Nobody muted.",
|
||||
settingsHiddenEmpty: "Nothing hidden.",
|
||||
settingsPmVisibilityFollowing: "Only people I follow",
|
||||
settingsMentionsVisibilityTitle: "Mentions",
|
||||
settingsMentionsVisibilityDesc: "Choose whose mentions reach you.",
|
||||
pmMutualNotice: "Outbound is a UX guardrail. Inbound is an attention filter (ciphertext is still replicated).",
|
||||
inhabitantsPendingFollowsTitle: "Pending follow requests",
|
||||
inhabitantsPendingAccept: "Accept",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ module.exports = {
|
|||
peersTorColumn: "TOR",
|
||||
peersTorYes: "Accesible por Tor",
|
||||
peersTorNo: "No accesible por Tor",
|
||||
peersOasisId: "Oasis ID",
|
||||
peersOasisId: "ID",
|
||||
peersOasisVersion: "Versión",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "Mapa interactivo de la red alrededor de ti.",
|
||||
|
|
@ -594,6 +594,21 @@ module.exports = {
|
|||
peerVersionUnannounced: "No anuncia su versión: un Oasis antiguo",
|
||||
peersTorMissing: "Este PUB solamente permite conexiones desde TOR.",
|
||||
peerConnectionsTitle: "Conexiones",
|
||||
peersOnlineHint: "Nodos conectados contigo ahora mismo.",
|
||||
peersDiscoveredHint: "Nodos con dirección conocida que no están conectados ahora.",
|
||||
peersUnknownHint: "Nodos de tu grafo social sin dirección conocida.",
|
||||
peersConnectionsHint: "Direcciones a las que puede conectar este nodo y el estado de cada conexión.",
|
||||
peersTabLan: "LAN",
|
||||
peersTabHealth: "Salud",
|
||||
peersHealthHint: "Cómo está cada pub o nodo conocido, en la red local y fuera de ella.",
|
||||
peersHealthReachable: "Alcanzable",
|
||||
peersHealthFailing: "Fallando",
|
||||
peersHealthFailures: "Fallos",
|
||||
peersFailingSince: "Falla desde",
|
||||
peersHealthForget: "Olvidar inalcanzables",
|
||||
peersLanHint: "Nodos encontrados en tu red local.",
|
||||
peersLanLastSeen: "Visto por última vez",
|
||||
peersLanEmpty: "Aún no se ha encontrado ningún nodo en la red local.",
|
||||
online: "Conectado",
|
||||
offline: "Desconectado",
|
||||
discovered: 'Descubiertos',
|
||||
|
|
@ -1639,8 +1654,6 @@ module.exports = {
|
|||
dataTermsTitle: "Palabras clave",
|
||||
dataTermsHint: "Los términos que aparecen en más contenidos.",
|
||||
dataConnections: "Coincidencias enlazadas",
|
||||
dataTopicTitle: "Todo lo relacionado con",
|
||||
dataTopicHint: "Lo que el algoritmo relaciona con este tema",
|
||||
dataNoMatches: "No hay datos cruzados que mostrar.",
|
||||
dataNoProfile: "Publica contenido o escribe tu CV para que el algoritmo aprenda qué te interesa.",
|
||||
pollsDescription: "Módulo para preguntar a la red y contar las respuestas.",
|
||||
|
|
@ -2194,9 +2207,7 @@ module.exports = {
|
|||
tribeTrendingEngagement: "interacción",
|
||||
tribeTopCategory: "Más votado",
|
||||
tribeTagsEmpty: "No se encontraron etiquetas.",
|
||||
tribeSearchPlaceholder: "Buscar contenido de la tribu...",
|
||||
tribeSearchEmpty: "Sin resultados.",
|
||||
tribeSearchResults: "Resultados",
|
||||
tribeSearchMinChars: "Introduce al menos 2 caracteres para buscar.",
|
||||
agendaTitle: "Agenda",
|
||||
agendaDescription: "Aquí puedes encontrar todos tus elementos asignados.",
|
||||
|
|
@ -3833,6 +3844,7 @@ module.exports = {
|
|||
roomUpdate: "Actualizar",
|
||||
roomDelete: "Eliminar",
|
||||
roomClose: "Cerrar",
|
||||
roomReopen: "Reabrir",
|
||||
roomCreateSectionTitle: "Crear sala",
|
||||
roomUpdateSectionTitle: "Actualizar sala",
|
||||
roomTitlePlaceholder: "Título de la sala",
|
||||
|
|
@ -3851,6 +3863,15 @@ module.exports = {
|
|||
roomMuted: "silenciado",
|
||||
roomRec: "Grabar audio",
|
||||
roomRecStop: "Parar grabación",
|
||||
roomStreamFile: "Emitir",
|
||||
roomStreamStop: "Parar",
|
||||
roomStreaming: "Emitiendo",
|
||||
liveFullscreen: "Pantalla completa",
|
||||
roomStreamPickTitle: "Elige un vídeo",
|
||||
roomStreamPickHint: "Vídeos de este dispositivo. El que elijas se emite desde donde está, sin copiarlo ni publicarlo.",
|
||||
roomStreamGo: "Emitir en la sala",
|
||||
roomStreamNotFound: "Ese vídeo no está en tus carpetas.",
|
||||
roomPublicWarning: "Esta sala se retransmite en la web pública: cualquiera puede ver y oír lo que pasa en ella.",
|
||||
roomRecording: "REC",
|
||||
roomNotificationsOn: "ON",
|
||||
roomNotificationsOff: "OFF",
|
||||
|
|
@ -4332,7 +4353,7 @@ module.exports = {
|
|||
phoneHistoryTitle: "Historial",
|
||||
phoneLatestTitle: "Últimas llamadas",
|
||||
phoneCreateButton: "Nueva llamada",
|
||||
phoneLimitsHint: "Hasta 7 habitantes por llamada; con más de uno es una llamada conjunta.",
|
||||
phoneLimitsHint: "Hasta {n} habitantes por llamada; con más de uno es una llamada conjunta.",
|
||||
phoneErrorPub: "No se puede llamar a un pub.",
|
||||
phoneJointCall: "Llamada conjunta",
|
||||
phoneWith: "Con",
|
||||
|
|
@ -4477,6 +4498,22 @@ module.exports = {
|
|||
settingsPmVisibilityDesc: "Configura el nivel de exposición a mensajes privados que deseas tener en la red.",
|
||||
settingsPmVisibilityWhole: "Multiverso",
|
||||
settingsPmVisibilityMutuals: "Solo soporte mutuo",
|
||||
mute: "Silenciar",
|
||||
unmute: "Dejar de silenciar",
|
||||
relationshipMuted: "Silenciado por ti",
|
||||
hideContent: "Ocultar para mí",
|
||||
unhideContent: "Mostrar de nuevo",
|
||||
hiddenByYou: "Ocultado por ti",
|
||||
settingsTabContent: "Contenido",
|
||||
settingsHiddenTitle: "Contenido oculto",
|
||||
settingsHiddenDesc: "Habitantes que has silenciado y contenido que has ocultado.",
|
||||
settingsMutedLabel: "Habitantes silenciados",
|
||||
settingsHiddenLabel: "Contenido oculto",
|
||||
settingsMutedEmpty: "Nadie silenciado.",
|
||||
settingsHiddenEmpty: "Nada oculto.",
|
||||
settingsPmVisibilityFollowing: "Solo gente a la que sigo",
|
||||
settingsMentionsVisibilityTitle: "Menciones",
|
||||
settingsMentionsVisibilityDesc: "Elige de quién te llegan las menciones.",
|
||||
pmMutualNotice: "El envío es una barrera de UX. La recepción es un filtro de atención (el cifrado ya está replicado).",
|
||||
inhabitantsPendingFollowsTitle: "Solicitudes de apoyo pendientes",
|
||||
inhabitantsPendingAccept: "Aceptar",
|
||||
|
|
@ -4583,7 +4620,7 @@ module.exports = {
|
|||
bankRulesEcoTaxFormula: "ECO Tax(habitante) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2",
|
||||
bankRulesEcoTaxNote: "El impuesto ECO se deduce de la RBU bruta cuando se calcula la asignación de la época. Los ECO deducidos no se redistribuyen en esta época — permanecen en el saldo del pub y alimentan el fondo de la próxima época a través de los topes reserveMin / alpha.",
|
||||
bankRulesKarmaFormula: "karma = log10(1 + actions) + reputation",
|
||||
bankRulesKarmaNote: "El karma se deriva de tus acciones (publicaciones, votos, participación en el parlamento/tribunales, etc.) con un factor de decaimiento temporal, menos los gramos de carbono que genera tu feed.",
|
||||
bankRulesKarmaNote: "El karma se deriva de tus acciones (publicaciones, votos, participación en el parlamento/tribunales, etc.) con un factor de decaimiento temporal, menos los gramos de carbono que genera tu feed. Lo que recibe tu contenido también cuenta: las opiniones, votos, reacciones y reproducciones positivas suman karma, y las opiniones o votos negativos lo restan cuando varios habitantes coinciden.",
|
||||
bankRulesNetFormula: "net_UBI = floor + max(0, gross_UBI − floor) − ECO Tax − ARCH Tax",
|
||||
bankRulesPoolFormula: "pool = min(pubBal − reserveMin, capPerEpoch, alpha · pubBal)",
|
||||
bankRulesShareFormula: "share = clamp(1 + karma/100, w_min, w_max) / sum(weights)",
|
||||
|
|
@ -4618,7 +4655,7 @@ module.exports = {
|
|||
larpRulesTestEntryText: "En el test WILL cada opción pondera una o más casas; al enviarlo, el sistema suma las puntuaciones y te admite automáticamente en la casa con más puntos.",
|
||||
larpWillTestHint: "Una vez completado, se te asignará la casa que más se ajuste a tus respuestas. Tus respuestas individuales se procesan localmente y nunca se almacenan — solo se publica en tu feed la asignación de casa resultante.",
|
||||
larpWillTestTitle: "Test WILL",
|
||||
settingsLanDesc: "Anuncia periódicamente este peer a otras instancias de Oasis en la misma red local. Desactivar para detener las difusiones UDP.",
|
||||
settingsLanDesc: "Anuncia periódicamente este peer a otras instancias de Oasis en la misma red local.",
|
||||
settingsLanEnable: "Activar difusión LAN",
|
||||
settingsLanTitle: "Difusión LAN",
|
||||
settingsReplicationTitle: "Replicación",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ module.exports = {
|
|||
peersTorColumn: "TOR",
|
||||
peersTorYes: "Tor bidez irisgarria",
|
||||
peersTorNo: "Ez da Tor bidez irisgarria",
|
||||
peersOasisId: "Oasis ID",
|
||||
peersOasisId: "ID",
|
||||
peersOasisVersion: "Bertsioa",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "Sarearen mapa interaktiboa zure inguruan.",
|
||||
|
|
@ -596,6 +596,21 @@ module.exports = {
|
|||
peerVersionUnannounced: "Ez du bere bertsioa iragartzen: Oasis zahar bat",
|
||||
peersTorMissing: "PUB honek TOR bidezko konexioak soilik onartzen ditu.",
|
||||
peerConnectionsTitle: "Konexioak",
|
||||
peersOnlineHint: "Orain zurekin konektatuta dauden nodoak.",
|
||||
peersDiscoveredHint: "Helbide ezaguna duten baina orain konektatuta ez dauden nodoak.",
|
||||
peersUnknownHint: "Zure grafo sozialeko nodoak, helbide ezagunik gabe.",
|
||||
peersConnectionsHint: "Nodo honek dei ditzakeen helbideak eta konexio bakoitzaren egoera.",
|
||||
peersTabLan: "LAN",
|
||||
peersTabHealth: "Osasuna",
|
||||
peersHealthHint: "Nola dagoen pub edo nodo ezagun bakoitza, sare lokalean eta kanpoan.",
|
||||
peersHealthReachable: "Eskuragarri",
|
||||
peersHealthFailing: "Huts egiten",
|
||||
peersHealthFailures: "Hutsak",
|
||||
peersFailingSince: "Huts egiten noiztik",
|
||||
peersHealthForget: "Ahaztu eskuraezinak",
|
||||
peersLanHint: "Zure sare lokalean aurkitutako nodoak.",
|
||||
peersLanLastSeen: "Azkenekoz ikusia",
|
||||
peersLanEmpty: "Oraindik ez da nodorik aurkitu sare lokalean.",
|
||||
online: "Linean",
|
||||
offline: "Lineaz kanpo",
|
||||
discovered: 'Aurkituak',
|
||||
|
|
@ -1680,8 +1695,6 @@ module.exports = {
|
|||
dataTermsTitle: "Gako-hitzak",
|
||||
dataTermsHint: "Eduki gehienetan agertzen diren terminoak.",
|
||||
dataConnections: "Lotutako bat-etortzeak",
|
||||
dataTopicTitle: "Honekin lotutako guztia:",
|
||||
dataTopicHint: "Algoritmoak gai honekin lotzen duena",
|
||||
dataNoMatches: "Ez dago datu gurutzaturik erakusteko.",
|
||||
dataNoProfile: "Argitaratu edukia edo idatzi zure CVa, algoritmoak zure interesak ikas ditzan.",
|
||||
pollsDescription: "Sareari galdetzeko eta erantzunak zenbatzeko modulua.",
|
||||
|
|
@ -2198,9 +2211,7 @@ module.exports = {
|
|||
tribeTrendingEngagement: "parte-hartzea",
|
||||
tribeTopCategory: "Gehien bozkatua",
|
||||
tribeTagsEmpty: "Ez da etiketarik aurkitu.",
|
||||
tribeSearchPlaceholder: "Bilatu tribuaren edukian...",
|
||||
tribeSearchEmpty: "Emaitzarik ez.",
|
||||
tribeSearchResults: "Emaitzak",
|
||||
tribeSearchMinChars: "Idatzi gutxienez 2 karaktere bilatzeko.",
|
||||
agendaTitle: "Agenda",
|
||||
agendaDescription: "Eskeituta dauzkazun elementu guztiak aurki ditzakezu hemen.",
|
||||
|
|
@ -3833,6 +3844,7 @@ module.exports = {
|
|||
roomUpdate: "Eguneratu",
|
||||
roomDelete: "Ezabatu",
|
||||
roomClose: "Itxi",
|
||||
roomReopen: "Berriro ireki",
|
||||
roomCreateSectionTitle: "Sortu gela",
|
||||
roomUpdateSectionTitle: "Eguneratu gela",
|
||||
roomTitlePlaceholder: "Gelaren izenburua",
|
||||
|
|
@ -3851,6 +3863,15 @@ module.exports = {
|
|||
roomMuted: "isilduta",
|
||||
roomRec: "Audioa grabatu",
|
||||
roomRecStop: "Grabazioa gelditu",
|
||||
roomStreamFile: "Igorri",
|
||||
roomStreamStop: "Gelditu",
|
||||
roomStreaming: "Igortzen",
|
||||
liveFullscreen: "Pantaila osoa",
|
||||
roomStreamPickTitle: "Aukeratu bideo bat",
|
||||
roomStreamPickHint: "Gailu honetako bideoak. Aukeratzen duzuna dagoen tokitik igortzen da, kopiatu edo argitaratu gabe.",
|
||||
roomStreamGo: "Gelan igorri",
|
||||
roomStreamNotFound: "Bideo hori ez dago zure karpetetan.",
|
||||
roomPublicWarning: "Gela hau web publikoan igortzen da: edonork ikus eta entzun dezake bertan gertatzen dena.",
|
||||
roomRecording: "REC",
|
||||
roomNotificationsOn: "ON",
|
||||
roomNotificationsOff: "OFF",
|
||||
|
|
@ -4331,7 +4352,7 @@ module.exports = {
|
|||
phoneHistoryTitle: "Historia",
|
||||
phoneLatestTitle: "Azken deiak",
|
||||
phoneCreateButton: "Dei berria",
|
||||
phoneLimitsHint: "Dei bakoitzeko 7 bizilagun gehienez; bat baino gehiagorekin dei bateratua da.",
|
||||
phoneLimitsHint: "Dei bakoitzeko {n} bizilagun gehienez; bat baino gehiagorekin dei bateratua da.",
|
||||
phoneErrorPub: "Ezin zaio pub bati deitu.",
|
||||
phoneJointCall: "Dei bateratua",
|
||||
phoneWith: "Norekin",
|
||||
|
|
@ -4476,6 +4497,22 @@ module.exports = {
|
|||
settingsPmVisibilityDesc: "Konfiguratu sarean izan nahi duzun mezu pribatuen esposizio maila.",
|
||||
settingsPmVisibilityWhole: "Multibertsoa",
|
||||
settingsPmVisibilityMutuals: "Elkar laguntza soilik",
|
||||
mute: "Isilarazi",
|
||||
unmute: "Isilarazteari utzi",
|
||||
relationshipMuted: "Zuk isilarazia",
|
||||
hideContent: "Niretzat ezkutatu",
|
||||
unhideContent: "Berriro erakutsi",
|
||||
hiddenByYou: "Zuk ezkutatua",
|
||||
settingsTabContent: "Edukia",
|
||||
settingsHiddenTitle: "Ezkutatutako edukia",
|
||||
settingsHiddenDesc: "Isilarazi dituzun biztanleak eta ezkutatu duzun edukia.",
|
||||
settingsMutedLabel: "Isilarazitako biztanleak",
|
||||
settingsHiddenLabel: "Ezkutatutako edukia",
|
||||
settingsMutedEmpty: "Inor ez isilarazita.",
|
||||
settingsHiddenEmpty: "Ezer ez ezkutatuta.",
|
||||
settingsPmVisibilityFollowing: "Jarraitzen dudan jendea bakarrik",
|
||||
settingsMentionsVisibilityTitle: "Aipamenak",
|
||||
settingsMentionsVisibilityDesc: "Aukeratu noren aipamenak iristen zaizkizun.",
|
||||
pmMutualNotice: "Bidalketa UX babes bat da. Jasotzea arreta-iragazkia.",
|
||||
inhabitantsPendingFollowsTitle: "Onespen zain dauden eskaerak",
|
||||
inhabitantsPendingAccept: "Onartu",
|
||||
|
|
@ -4608,7 +4645,7 @@ module.exports = {
|
|||
bankRulesFloor: "Erabiltzaile bakoitzeko gutxienekoa (gordina)",
|
||||
bankRulesGraceDays: "Eskaeraren grazia-epea",
|
||||
bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))",
|
||||
bankRulesKarmaNote: "Karma zure ekintzetatik ateratzen da (argitalpenak, botoak, parlamentuan/epaitegietan parte hartzea, etab.), denborarekin ahultzen den faktore batekin, zure jarioak sortutako karbono-gramoak kenduta.",
|
||||
bankRulesKarmaNote: "Karma zure ekintzetatik ateratzen da (argitalpenak, botoak, parlamentuan/epaitegietan parte hartzea, etab.), denborarekin ahultzen den faktore batekin, zure jarioak sortutako karbono-gramoak kenduta. Zure edukiak jasotzen duenak ere balio du: iritzi, boto, erreakzio eta erreprodukzio positiboek karma gehitzen dute, eta iritzi edo boto negatiboek kentzen dute hainbat biztanle ados daudenean.",
|
||||
bankRulesKarmaTitle: "Karma (erabiltzailearen konpromiso-puntuazioa)",
|
||||
bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)",
|
||||
bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)",
|
||||
|
|
@ -4697,7 +4734,7 @@ module.exports = {
|
|||
larpRulesTestEntryText: "WILL testean aukera bakoitzak etxe bat edo gehiago haztatzen ditu; bidaltzean, sistemak puntuazioak batzen ditu eta puntu gehien dituen etxean onartzen zaitu automatikoki.",
|
||||
larpWillTestHint: "Behin osatutakoan, zure erantzunekin ondoen bat datorren etxera esleituko zaitu. Zure erantzun indibidualak lokalki prozesatzen dira eta inoiz ez dira gordetzen — soilik etxe-esleipenaren emaitza argitaratzen da zure jarioan.",
|
||||
larpWillTestTitle: "WILL proba",
|
||||
settingsLanDesc: "Aldian behin iragarri pareko hau sare lokal bereko beste Oasis instantziei. Desgaitu UDP igorpenak gelditzeko.",
|
||||
settingsLanDesc: "Aldian behin iragarri pareko hau sare lokal bereko beste Oasis instantziei.",
|
||||
settingsLanEnable: "Gaitu LAN igorpena",
|
||||
settingsLanTitle: "LAN igorpena",
|
||||
settingsReplicationTitle: "Erreplikazioa",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ module.exports = {
|
|||
peersTorColumn: "TOR",
|
||||
peersTorYes: "Joignable via Tor",
|
||||
peersTorNo: "Non joignable via Tor",
|
||||
peersOasisId: "Oasis ID",
|
||||
peersOasisId: "ID",
|
||||
peersOasisVersion: "Version",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "Carte interactive du réseau autour de toi.",
|
||||
|
|
@ -594,6 +594,21 @@ module.exports = {
|
|||
peerVersionUnannounced: "N'annonce pas sa version : un ancien Oasis",
|
||||
peersTorMissing: "Ce PUB n'accepte que des connexions via TOR.",
|
||||
peerConnectionsTitle: "Connexions",
|
||||
peersOnlineHint: "Nœuds connectés à vous en ce moment.",
|
||||
peersDiscoveredHint: "Nœuds avec une adresse connue, non connectés pour l'instant.",
|
||||
peersUnknownHint: "Nœuds de votre graphe social sans adresse connue.",
|
||||
peersConnectionsHint: "Adresses que ce nœud peut joindre et l'état de chaque connexion.",
|
||||
peersTabLan: "LAN",
|
||||
peersTabHealth: "Santé",
|
||||
peersHealthHint: "L'état de chaque pub ou pair connu, sur le réseau local et au-delà.",
|
||||
peersHealthReachable: "Joignable",
|
||||
peersHealthFailing: "En échec",
|
||||
peersHealthFailures: "Échecs",
|
||||
peersFailingSince: "En échec depuis",
|
||||
peersHealthForget: "Oublier les injoignables",
|
||||
peersLanHint: "Nœuds rencontrés sur votre réseau local.",
|
||||
peersLanLastSeen: "Vu pour la dernière fois",
|
||||
peersLanEmpty: "Aucun nœud rencontré sur le réseau local pour l'instant.",
|
||||
online: "En ligne",
|
||||
offline: "Hors ligne",
|
||||
discovered: 'Découverts',
|
||||
|
|
@ -1638,8 +1653,6 @@ module.exports = {
|
|||
dataTermsTitle: "Mots-clés",
|
||||
dataTermsHint: "Les termes présents dans le plus de contenus.",
|
||||
dataConnections: "Correspondances liées",
|
||||
dataTopicTitle: "Tout ce qui touche à",
|
||||
dataTopicHint: "Ce que l'algorithme relie à ce sujet",
|
||||
dataNoMatches: "Aucune donnée croisée à afficher.",
|
||||
dataNoProfile: "Publiez du contenu ou rédigez votre CV pour que l'algorithme apprenne ce qui vous intéresse.",
|
||||
pollsDescription: "Module pour interroger le réseau et compter les réponses.",
|
||||
|
|
@ -2187,9 +2200,7 @@ module.exports = {
|
|||
tribeTrendingEngagement: "engagement",
|
||||
tribeTopCategory: "Plus voté",
|
||||
tribeTagsEmpty: "Aucun tag trouvé.",
|
||||
tribeSearchPlaceholder: "Rechercher dans la tribu...",
|
||||
tribeSearchEmpty: "Aucun résultat.",
|
||||
tribeSearchResults: "Résultats",
|
||||
tribeSearchMinChars: "Entrez au moins 2 caractères pour rechercher.",
|
||||
agendaTitle: "Agenda",
|
||||
agendaDescription: "Ici vous pouvez trouver tous vos éléments assignés.",
|
||||
|
|
@ -3825,6 +3836,7 @@ module.exports = {
|
|||
roomUpdate: "Modifier",
|
||||
roomDelete: "Supprimer",
|
||||
roomClose: "Fermer",
|
||||
roomReopen: "Rouvrir",
|
||||
roomCreateSectionTitle: "Créer un salon",
|
||||
roomUpdateSectionTitle: "Modifier le salon",
|
||||
roomTitlePlaceholder: "Titre du salon",
|
||||
|
|
@ -3843,6 +3855,15 @@ module.exports = {
|
|||
roomMuted: "micro coupé",
|
||||
roomRec: "Enregistrer l'audio",
|
||||
roomRecStop: "Arrêter l'enregistrement",
|
||||
roomStreamFile: "Diffuser",
|
||||
roomStreamStop: "Arrêter",
|
||||
roomStreaming: "En diffusion",
|
||||
liveFullscreen: "Plein écran",
|
||||
roomStreamPickTitle: "Choisir une vidéo",
|
||||
roomStreamPickHint: "Vidéos de cet appareil. Celle que vous choisissez est diffusée depuis son emplacement, sans être copiée ni publiée.",
|
||||
roomStreamGo: "Diffuser dans le salon",
|
||||
roomStreamNotFound: "Cette vidéo n'est pas dans vos dossiers.",
|
||||
roomPublicWarning: "Ce salon est diffusé sur le web public : tout le monde peut voir et entendre ce qui s'y passe.",
|
||||
roomRecording: "REC",
|
||||
roomNotificationsOn: "ON",
|
||||
roomNotificationsOff: "OFF",
|
||||
|
|
@ -4323,7 +4344,7 @@ module.exports = {
|
|||
phoneHistoryTitle: "Historique",
|
||||
phoneLatestTitle: "Derniers appels",
|
||||
phoneCreateButton: "Nouvel appel",
|
||||
phoneLimitsHint: "Jusqu'à 7 habitants par appel ; à plus d'un, c'est un appel conjoint.",
|
||||
phoneLimitsHint: "Jusqu'à {n} habitants par appel ; à plus d'un, c'est un appel conjoint.",
|
||||
phoneErrorPub: "Impossible d'appeler un pub.",
|
||||
phoneJointCall: "Appel conjoint",
|
||||
phoneWith: "Avec",
|
||||
|
|
@ -4468,6 +4489,22 @@ module.exports = {
|
|||
settingsPmVisibilityDesc: "Configurez votre niveau d'exposition aux messages privés.",
|
||||
settingsPmVisibilityWhole: "Multivers",
|
||||
settingsPmVisibilityMutuals: "Soutien mutuel uniquement",
|
||||
mute: "Mettre en sourdine",
|
||||
unmute: "Réactiver",
|
||||
relationshipMuted: "En sourdine pour vous",
|
||||
hideContent: "Masquer pour moi",
|
||||
unhideContent: "Afficher à nouveau",
|
||||
hiddenByYou: "Masqué par vous",
|
||||
settingsTabContent: "Contenu",
|
||||
settingsHiddenTitle: "Contenu masqué",
|
||||
settingsHiddenDesc: "Habitants mis en sourdine et contenus masqués.",
|
||||
settingsMutedLabel: "Habitants en sourdine",
|
||||
settingsHiddenLabel: "Contenu masqué",
|
||||
settingsMutedEmpty: "Personne en sourdine.",
|
||||
settingsHiddenEmpty: "Rien de masqué.",
|
||||
settingsPmVisibilityFollowing: "Seulement les personnes que je suis",
|
||||
settingsMentionsVisibilityTitle: "Mentions",
|
||||
settingsMentionsVisibilityDesc: "Choisissez de qui vous recevez des mentions.",
|
||||
pmMutualNotice: "L'envoi est un garde-fou UX. La réception est un filtre d'attention.",
|
||||
inhabitantsPendingFollowsTitle: "Demandes de soutien en attente",
|
||||
inhabitantsPendingAccept: "Accepter",
|
||||
|
|
@ -4574,7 +4611,7 @@ module.exports = {
|
|||
bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2",
|
||||
bankRulesEcoTaxNote: "La taxe ECO est déduite de la RBU brute lors du calcul de l'allocation de l'époque. Les ECO déduits ne sont pas redistribués pendant cette époque — ils restent dans le solde du pub et alimentent le fonds de l'époque suivante via les plafonds reserveMin / alpha.",
|
||||
bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))",
|
||||
bankRulesKarmaNote: "Le karma est calculé à partir de vos actions (publications, votes, participation au parlement/aux tribunaux, etc.) avec un facteur de décroissance temporelle, moins les grammes de carbone générés par votre fil.",
|
||||
bankRulesKarmaNote: "Le karma est calculé à partir de vos actions (publications, votes, participation au parlement/aux tribunaux, etc.) avec un facteur de décroissance temporelle, moins les grammes de carbone générés par votre fil. Ce que reçoit votre contenu compte aussi : les avis, votes, réactions et écoutes positifs ajoutent du karma, et les avis ou votes négatifs en retirent dès que plusieurs habitants sont d'accord.",
|
||||
bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)",
|
||||
bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)",
|
||||
bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)",
|
||||
|
|
@ -4617,7 +4654,7 @@ module.exports = {
|
|||
larpRulesTestEntryText: "Dans le test WILL, chaque option pondère une ou plusieurs maisons ; à l'envoi, le système additionne les scores et vous admet automatiquement dans la maison la mieux notée.",
|
||||
larpWillTestHint: "Une fois terminé, on t'assignera la maison qui correspond le mieux à tes réponses. Tes réponses individuelles sont traitées localement et ne sont jamais stockées — seule l'attribution de maison résultante est publiée dans ton flux.",
|
||||
larpWillTestTitle: "Test WILL",
|
||||
settingsLanDesc: "Annoncer périodiquement ce pair aux autres instances d'Oasis sur le même réseau local. Désactiver pour arrêter les diffusions UDP.",
|
||||
settingsLanDesc: "Annoncer périodiquement ce pair aux autres instances d'Oasis sur le même réseau local.",
|
||||
settingsLanEnable: "Activer la diffusion LAN",
|
||||
settingsLanTitle: "Diffusion LAN",
|
||||
settingsReplicationTitle: "Réplication",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ module.exports = {
|
|||
peersTorColumn: "TOR",
|
||||
peersTorYes: "Accesible por Tor",
|
||||
peersTorNo: "Non accesible por Tor",
|
||||
peersOasisId: "Oasis ID",
|
||||
peersOasisId: "ID",
|
||||
peersOasisVersion: "Versión",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "Mapa interactivo da rede ao teu redor.",
|
||||
|
|
@ -594,6 +594,21 @@ module.exports = {
|
|||
peerVersionUnannounced: "Non anuncia a súa versión: un Oasis antigo",
|
||||
peersTorMissing: "Este PUB só permite conexións dende TOR.",
|
||||
peerConnectionsTitle: "Conexións",
|
||||
peersOnlineHint: "Nodos conectados contigo agora mesmo.",
|
||||
peersDiscoveredHint: "Nodos con enderezo coñecido que non están conectados agora.",
|
||||
peersUnknownHint: "Nodos do teu grafo social sen enderezo coñecido.",
|
||||
peersConnectionsHint: "Enderezos aos que pode conectar este nodo e o estado de cada conexión.",
|
||||
peersTabLan: "LAN",
|
||||
peersTabHealth: "Saúde",
|
||||
peersHealthHint: "Como está cada pub ou nodo coñecido, na rede local e fóra dela.",
|
||||
peersHealthReachable: "Alcanzable",
|
||||
peersHealthFailing: "Fallando",
|
||||
peersHealthFailures: "Fallos",
|
||||
peersFailingSince: "Falla desde",
|
||||
peersHealthForget: "Esquecer inalcanzables",
|
||||
peersLanHint: "Nodos atopados na túa rede local.",
|
||||
peersLanLastSeen: "Visto por última vez",
|
||||
peersLanEmpty: "Aínda non se atopou ningún nodo na rede local.",
|
||||
online: "Conectado",
|
||||
offline: "Desconectado",
|
||||
discovered: "Descubertos",
|
||||
|
|
@ -1639,8 +1654,6 @@ module.exports = {
|
|||
dataTermsTitle: "Palabras clave",
|
||||
dataTermsHint: "Os termos que aparecen en máis contidos.",
|
||||
dataConnections: "Coincidencias ligadas",
|
||||
dataTopicTitle: "Todo o relacionado con",
|
||||
dataTopicHint: "O que o algoritmo relaciona con este tema",
|
||||
dataNoMatches: "Non hai datos cruzados que amosar.",
|
||||
dataNoProfile: "Publica contido ou escribe o teu CV para que o algoritmo aprenda que che interesa.",
|
||||
pollsDescription: "Módulo para preguntarlle á rede e contar as respostas.",
|
||||
|
|
@ -2194,9 +2207,7 @@ module.exports = {
|
|||
tribeTrendingEngagement: "interacción",
|
||||
tribeTopCategory: "Máis votado",
|
||||
tribeTagsEmpty: "Non se atoparon etiquetas.",
|
||||
tribeSearchPlaceholder: "Buscar contido da tribo...",
|
||||
tribeSearchEmpty: "Sen resultados.",
|
||||
tribeSearchResults: "Resultados",
|
||||
tribeSearchMinChars: "Introduce polo menos 2 caracteres para buscar.",
|
||||
agendaTitle: "Axenda",
|
||||
agendaDescription: "Aquí podes atopar todos os teus elementos asignados.",
|
||||
|
|
@ -3833,6 +3844,7 @@ module.exports = {
|
|||
roomUpdate: "Actualizar",
|
||||
roomDelete: "Eliminar",
|
||||
roomClose: "Pechar",
|
||||
roomReopen: "Reabrir",
|
||||
roomCreateSectionTitle: "Crear sala",
|
||||
roomUpdateSectionTitle: "Actualizar sala",
|
||||
roomTitlePlaceholder: "Título da sala",
|
||||
|
|
@ -3851,6 +3863,15 @@ module.exports = {
|
|||
roomMuted: "silenciado",
|
||||
roomRec: "Gravar audio",
|
||||
roomRecStop: "Parar a gravación",
|
||||
roomStreamFile: "Emitir",
|
||||
roomStreamStop: "Parar",
|
||||
roomStreaming: "Emitindo",
|
||||
liveFullscreen: "Pantalla completa",
|
||||
roomStreamPickTitle: "Escolle un vídeo",
|
||||
roomStreamPickHint: "Vídeos deste dispositivo. O que escollas emítese desde onde está, sen copialo nin publicalo.",
|
||||
roomStreamGo: "Emitir na sala",
|
||||
roomStreamNotFound: "Ese vídeo non está nos teus cartafoles.",
|
||||
roomPublicWarning: "Esta sala retransmítese na web pública: calquera pode ver e oír o que pasa nela.",
|
||||
roomRecording: "REC",
|
||||
roomNotificationsOn: "ON",
|
||||
roomNotificationsOff: "OFF",
|
||||
|
|
@ -4332,7 +4353,7 @@ module.exports = {
|
|||
phoneHistoryTitle: "Historial",
|
||||
phoneLatestTitle: "Últimas chamadas",
|
||||
phoneCreateButton: "Nova chamada",
|
||||
phoneLimitsHint: "Ata 7 habitantes por chamada; con máis dun é unha chamada conxunta.",
|
||||
phoneLimitsHint: "Ata {n} habitantes por chamada; con máis dun é unha chamada conxunta.",
|
||||
phoneErrorPub: "Non se pode chamar a un pub.",
|
||||
phoneJointCall: "Chamada conxunta",
|
||||
phoneWith: "Con",
|
||||
|
|
@ -4477,6 +4498,22 @@ module.exports = {
|
|||
settingsPmVisibilityDesc: "Configura o nivel de exposición a mensaxes privadas que queres ter na rede.",
|
||||
settingsPmVisibilityWhole: "Multiverso",
|
||||
settingsPmVisibilityMutuals: "Só apoio mutuo",
|
||||
mute: "Silenciar",
|
||||
unmute: "Deixar de silenciar",
|
||||
relationshipMuted: "Silenciado por ti",
|
||||
hideContent: "Agochar para min",
|
||||
unhideContent: "Mostrar de novo",
|
||||
hiddenByYou: "Agochado por ti",
|
||||
settingsTabContent: "Contido",
|
||||
settingsHiddenTitle: "Contido agochado",
|
||||
settingsHiddenDesc: "Habitantes que silenciaches e contido que agochaches.",
|
||||
settingsMutedLabel: "Habitantes silenciados",
|
||||
settingsHiddenLabel: "Contido agochado",
|
||||
settingsMutedEmpty: "Ninguén silenciado.",
|
||||
settingsHiddenEmpty: "Nada agochado.",
|
||||
settingsPmVisibilityFollowing: "Só xente á que sigo",
|
||||
settingsMentionsVisibilityTitle: "Mencións",
|
||||
settingsMentionsVisibilityDesc: "Escolle de quen che chegan as mencións.",
|
||||
pmMutualNotice: "O envío é unha barreira de UX. A recepción é un filtro de atención (o cifrado xa está replicado).",
|
||||
inhabitantsPendingFollowsTitle: "Solicitudes de apoio pendentes",
|
||||
inhabitantsPendingAccept: "Aceptar",
|
||||
|
|
@ -4583,7 +4620,7 @@ module.exports = {
|
|||
bankRulesEcoTaxFormula: "Taxa ECO(usuario) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2",
|
||||
bankRulesEcoTaxNote: "O imposto ECO dedúcese da RBU bruta cando se calcula a asignación da época. Os ECO deducidos non se redistribúen nesta época — quedan no saldo do pub e alimentan o fondo da próxima época a través dos límites reserveMin / alpha.",
|
||||
bankRulesKarmaFormula: "karma = log10(1 + actions) + reputation",
|
||||
bankRulesKarmaNote: "O karma derívase das túas accións (publicacións, votos, participación no parlamento/tribunais, etc.) cun factor de decaemento temporal, menos os gramos de carbono que xera o teu feed.",
|
||||
bankRulesKarmaNote: "O karma derívase das túas accións (publicacións, votos, participación no parlamento/tribunais, etc.) cun factor de decaemento temporal, menos os gramos de carbono que xera o teu feed. O que recibe o teu contido tamén conta: as opinións, votos, reaccións e reproducións positivas suman karma, e as opinións ou votos negativos réstano cando varios habitantes coinciden.",
|
||||
bankRulesNetFormula: "net_UBI = floor + max(0, gross_UBI − floor) − ECO Tax − ARCH Tax",
|
||||
bankRulesPoolFormula: "pool = min(pubBal − reserveMin, capPerEpoch, alpha · pubBal)",
|
||||
bankRulesShareFormula: "share = clamp(1 + karma/100, w_min, w_max) / sum(weights)",
|
||||
|
|
@ -4618,7 +4655,7 @@ module.exports = {
|
|||
larpRulesTestEntryText: "No test WILL cada opción pondera unha ou máis casas; ao envialo, o sistema suma as puntuacións e admíteche automaticamente na casa con máis puntos.",
|
||||
larpWillTestHint: "Unha vez completado, asignaráseche a casa que máis se axuste ás túas respostas. As túas respostas individuais procésanse localmente e nunca se almacenan — só se publica no teu feed a asignación de casa resultante.",
|
||||
larpWillTestTitle: "Test WILL",
|
||||
settingsLanDesc: "Anuncia periodicamente este nodo a outras instancias de Oasis na mesma rede local. Desactívao para deter as difusións UDP.",
|
||||
settingsLanDesc: "Anuncia periodicamente este nodo a outras instancias de Oasis na mesma rede local.",
|
||||
settingsLanEnable: "Activar a difusión LAN",
|
||||
settingsLanTitle: "Difusión LAN",
|
||||
settingsReplicationTitle: "Replicación",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ module.exports = {
|
|||
peersTorColumn: "TOR",
|
||||
peersTorYes: "Tor से पहुंचा जा सकता है",
|
||||
peersTorNo: "Tor से नहीं पहुंचा जा सकता",
|
||||
peersOasisId: "Oasis ID",
|
||||
peersOasisId: "ID",
|
||||
peersOasisVersion: "संस्करण",
|
||||
graphos: "ग्राफोस",
|
||||
graphosDescription: "आपके आसपास के नेटवर्क का इंटरैक्टिव मानचित्र।",
|
||||
|
|
@ -602,6 +602,21 @@ module.exports = {
|
|||
peerVersionUnannounced: "अपना संस्करण घोषित नहीं करता: पुराना Oasis",
|
||||
peersTorMissing: "यह PUB केवल TOR से कनेक्शन की अनुमति देता है।",
|
||||
peerConnectionsTitle: "कनेक्शन",
|
||||
peersOnlineHint: "इस समय आपसे जुड़े नोड।",
|
||||
peersDiscoveredHint: "ज्ञात पते वाले नोड जो अभी जुड़े नहीं हैं।",
|
||||
peersUnknownHint: "आपके सामाजिक ग्राफ़ के नोड जिनका पता ज्ञात नहीं है।",
|
||||
peersConnectionsHint: "वे पते जिनसे यह नोड जुड़ सकता है और हर कनेक्शन की स्थिति।",
|
||||
peersTabLan: "LAN",
|
||||
peersTabHealth: "स्वास्थ्य",
|
||||
peersHealthHint: "हर ज्ञात pub या नोड की स्थिति, स्थानीय नेटवर्क में और उसके बाहर।",
|
||||
peersHealthReachable: "पहुँच योग्य",
|
||||
peersHealthFailing: "विफल हो रहा",
|
||||
peersHealthFailures: "विफलताएँ",
|
||||
peersFailingSince: "कब से विफल",
|
||||
peersHealthForget: "अप्राप्य को भूलें",
|
||||
peersLanHint: "आपके स्थानीय नेटवर्क में मिले नोड।",
|
||||
peersLanLastSeen: "अंतिम बार देखा गया",
|
||||
peersLanEmpty: "स्थानीय नेटवर्क में अभी कोई नोड नहीं मिला।",
|
||||
online: "ऑनलाइन",
|
||||
offline: "ऑफ़लाइन",
|
||||
discovered: 'खोजे गए',
|
||||
|
|
@ -1650,8 +1665,6 @@ module.exports = {
|
|||
dataTermsTitle: "मुख्य शब्द",
|
||||
dataTermsHint: "वे शब्द जो सबसे अधिक सामग्री में आते हैं।",
|
||||
dataConnections: "जुड़े मिलान",
|
||||
dataTopicTitle: "इससे संबंधित सब कुछ:",
|
||||
dataTopicHint: "एल्गोरिद्म इस विषय से क्या जोड़ता है",
|
||||
dataNoMatches: "दिखाने के लिए क्रॉस-डेटा नहीं।",
|
||||
dataNoProfile: "सामग्री प्रकाशित करें या अपना CV लिखें ताकि एल्गोरिद्म जान सके कि आपकी रुचि किसमें है।",
|
||||
pollsDescription: "नेटवर्क से पूछने और उत्तर गिनने का मॉड्यूल।",
|
||||
|
|
@ -2204,9 +2217,7 @@ module.exports = {
|
|||
tribeTrendingEngagement: "सहभागिता",
|
||||
tribeTopCategory: "अधिक मतदान",
|
||||
tribeTagsEmpty: "कोई टैग नहीं मिला।",
|
||||
tribeSearchPlaceholder: "जनजाति सामग्री खोजें...",
|
||||
tribeSearchEmpty: "कोई परिणाम नहीं मिला।",
|
||||
tribeSearchResults: "परिणाम",
|
||||
tribeSearchMinChars: "खोजने के लिए कम से कम 2 अक्षर दर्ज करें।",
|
||||
agendaTitle: "एजेंडा",
|
||||
agendaDescription: "यहाँ आप अपने सभी सौंपे गए आइटम पा सकते हैं।",
|
||||
|
|
@ -3835,6 +3846,7 @@ module.exports = {
|
|||
roomUpdate: "अपडेट करें",
|
||||
roomDelete: "हटाएं",
|
||||
roomClose: "बंद करें",
|
||||
roomReopen: "फिर से खोलें",
|
||||
roomCreateSectionTitle: "रूम बनाएं",
|
||||
roomUpdateSectionTitle: "रूम अपडेट करें",
|
||||
roomTitlePlaceholder: "रूम का शीर्षक",
|
||||
|
|
@ -3853,6 +3865,15 @@ module.exports = {
|
|||
roomMuted: "म्यूट",
|
||||
roomRec: "ऑडियो रिकॉर्ड करें",
|
||||
roomRecStop: "रिकॉर्डिंग रोकें",
|
||||
roomStreamFile: "स्ट्रीम करें",
|
||||
roomStreamStop: "रोकें",
|
||||
roomStreaming: "स्ट्रीम हो रहा है",
|
||||
liveFullscreen: "पूर्ण स्क्रीन",
|
||||
roomStreamPickTitle: "वीडियो चुनें",
|
||||
roomStreamPickHint: "इस डिवाइस के वीडियो। आप जो चुनते हैं वह अपनी जगह से ही स्ट्रीम होता है, बिना कॉपी या प्रकाशित किए।",
|
||||
roomStreamGo: "रूम में चलाएँ",
|
||||
roomStreamNotFound: "यह वीडियो आपके फ़ोल्डरों में नहीं है।",
|
||||
roomPublicWarning: "यह रूम सार्वजनिक वेब पर प्रसारित होता है: इसमें जो होता है उसे कोई भी देख और सुन सकता है।",
|
||||
roomRecording: "REC",
|
||||
roomNotificationsOn: "ON",
|
||||
roomNotificationsOff: "OFF",
|
||||
|
|
@ -4333,7 +4354,7 @@ module.exports = {
|
|||
phoneHistoryTitle: "इतिहास",
|
||||
phoneLatestTitle: "हाल की कॉल",
|
||||
phoneCreateButton: "नई कॉल",
|
||||
phoneLimitsHint: "प्रति कॉल अधिकतम 7 निवासी; एक से अधिक होने पर यह संयुक्त कॉल है।",
|
||||
phoneLimitsHint: "प्रति कॉल अधिकतम {n} निवासी; एक से अधिक होने पर यह संयुक्त कॉल है।",
|
||||
phoneErrorPub: "पब को कॉल नहीं किया जा सकता।",
|
||||
phoneJointCall: "संयुक्त कॉल",
|
||||
phoneWith: "किसके साथ",
|
||||
|
|
@ -4478,6 +4499,22 @@ module.exports = {
|
|||
settingsPmVisibilityDesc: "नेटवर्क में निजी संदेश एक्सपोज़र स्तर कॉन्फ़िगर करें।",
|
||||
settingsPmVisibilityWhole: "मल्टीवर्स",
|
||||
settingsPmVisibilityMutuals: "केवल पारस्परिक समर्थन",
|
||||
mute: "म्यूट करें",
|
||||
unmute: "म्यूट हटाएँ",
|
||||
relationshipMuted: "आपके द्वारा म्यूट",
|
||||
hideContent: "मेरे लिए छिपाएँ",
|
||||
unhideContent: "फिर से दिखाएँ",
|
||||
hiddenByYou: "आपके द्वारा छिपाया गया",
|
||||
settingsTabContent: "सामग्री",
|
||||
settingsHiddenTitle: "छिपी हुई सामग्री",
|
||||
settingsHiddenDesc: "जिन निवासियों को आपने म्यूट किया और जो सामग्री आपने छिपाई।",
|
||||
settingsMutedLabel: "म्यूट किए गए निवासी",
|
||||
settingsHiddenLabel: "छिपाई गई सामग्री",
|
||||
settingsMutedEmpty: "कोई म्यूट नहीं।",
|
||||
settingsHiddenEmpty: "कुछ भी छिपा नहीं।",
|
||||
settingsPmVisibilityFollowing: "केवल वे लोग जिन्हें मैं फ़ॉलो करता हूँ",
|
||||
settingsMentionsVisibilityTitle: "उल्लेख",
|
||||
settingsMentionsVisibilityDesc: "चुनें कि किसके उल्लेख आप तक पहुँचें।",
|
||||
pmMutualNotice: "आउटबाउंड UX गार्डरेल है। इनबाउंड ध्यान फ़िल्टर है।",
|
||||
inhabitantsPendingFollowsTitle: "लंबित समर्थन अनुरोध",
|
||||
inhabitantsPendingAccept: "स्वीकार",
|
||||
|
|
@ -4610,7 +4647,7 @@ module.exports = {
|
|||
bankRulesFloor: "प्रति उपयोगकर्ता न्यूनतम (सकल)",
|
||||
bankRulesGraceDays: "दावे की अनुग्रह अवधि",
|
||||
bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))",
|
||||
bankRulesKarmaNote: "कर्म आपकी गतिविधियों (पोस्ट, वोट, संसद/अदालतों में भागीदारी आदि) से समय के साथ घटने वाले गुणक सहित निकाला जाता है, जिसमें से आपकी फ़ीड से पैदा हुए कार्बन ग्राम घटा दिए जाते हैं।",
|
||||
bankRulesKarmaNote: "कर्म आपकी गतिविधियों (पोस्ट, वोट, संसद/अदालतों में भागीदारी आदि) से समय के साथ घटने वाले गुणक सहित निकाला जाता है, जिसमें से आपकी फ़ीड से पैदा हुए कार्बन ग्राम घटा दिए जाते हैं। आपकी सामग्री को जो मिलता है वह भी गिना जाता है: सकारात्मक राय, वोट, प्रतिक्रियाएँ और प्ले कर्म बढ़ाते हैं, और जब कई निवासी सहमत होते हैं तो नकारात्मक राय या वोट उसे घटाते हैं।",
|
||||
bankRulesKarmaTitle: "कर्म (उपयोगकर्ता सहभागिता स्कोर)",
|
||||
bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)",
|
||||
bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)",
|
||||
|
|
@ -4696,7 +4733,7 @@ module.exports = {
|
|||
larpRulesTestEntryText: "WILL परीक्षण में हर विकल्प एक या अधिक घरों को भार देता है; भेजने पर सिस्टम अंक जोड़ता है और सबसे अधिक अंक वाले घर में आपको स्वतः प्रवेश देता है।",
|
||||
larpWillTestHint: "पूरा होने पर, आपको वह गृह सौंपा जाएगा जो आपके उत्तरों के साथ सबसे अच्छा मेल खाता है। आपके व्यक्तिगत उत्तरों को स्थानीय रूप से संसाधित किया जाता है और कभी संग्रहीत नहीं किया जाता — केवल परिणामी गृह असाइनमेंट आपकी फ़ीड पर प्रकाशित होता है।",
|
||||
larpWillTestTitle: "WILL परीक्षण",
|
||||
settingsLanDesc: "इस पीयर को समय-समय पर उसी स्थानीय नेटवर्क में अन्य Oasis इंस्टेंस को घोषित करें। UDP प्रसारण रोकने के लिए अक्षम करें।",
|
||||
settingsLanDesc: "इस पीयर को समय-समय पर उसी स्थानीय नेटवर्क में अन्य Oasis इंस्टेंस को घोषित करें।",
|
||||
settingsLanEnable: "LAN प्रसारण सक्षम करें",
|
||||
settingsLanTitle: "LAN प्रसारण",
|
||||
settingsReplicationTitle: "प्रतिकृति",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ module.exports = {
|
|||
peersTorColumn: "TOR",
|
||||
peersTorYes: "Raggiungibile tramite Tor",
|
||||
peersTorNo: "Non raggiungibile tramite Tor",
|
||||
peersOasisId: "Oasis ID",
|
||||
peersOasisId: "ID",
|
||||
peersOasisVersion: "Versione",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "Mappa interattiva della rete attorno a te.",
|
||||
|
|
@ -600,6 +600,21 @@ module.exports = {
|
|||
peerVersionUnannounced: "Non annuncia la sua versione: un Oasis vecchio",
|
||||
peersTorMissing: "Questo PUB accetta solo connessioni tramite TOR.",
|
||||
peerConnectionsTitle: "Connessioni",
|
||||
peersOnlineHint: "Nodi connessi con te in questo momento.",
|
||||
peersDiscoveredHint: "Nodi con indirizzo noto che non sono connessi ora.",
|
||||
peersUnknownHint: "Nodi del tuo grafo sociale senza indirizzo noto.",
|
||||
peersConnectionsHint: "Indirizzi che questo nodo può contattare e lo stato di ogni connessione.",
|
||||
peersTabLan: "LAN",
|
||||
peersTabHealth: "Salute",
|
||||
peersHealthHint: "Come sta ogni pub o nodo conosciuto, nella rete locale e oltre.",
|
||||
peersHealthReachable: "Raggiungibile",
|
||||
peersHealthFailing: "In errore",
|
||||
peersHealthFailures: "Errori",
|
||||
peersFailingSince: "In errore da",
|
||||
peersHealthForget: "Dimentica irraggiungibili",
|
||||
peersLanHint: "Nodi incontrati nella tua rete locale.",
|
||||
peersLanLastSeen: "Visto l'ultima volta",
|
||||
peersLanEmpty: "Nessun nodo incontrato nella rete locale per ora.",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
discovered: 'Scoperti',
|
||||
|
|
@ -1648,8 +1663,6 @@ module.exports = {
|
|||
dataTermsTitle: "Parole chiave",
|
||||
dataTermsHint: "I termini che compaiono nel maggior numero di contenuti.",
|
||||
dataConnections: "Corrispondenze collegate",
|
||||
dataTopicTitle: "Tutto ciò che riguarda",
|
||||
dataTopicHint: "Ciò che l'algoritmo collega a questo tema",
|
||||
dataNoMatches: "Nessun dato incrociato da mostrare.",
|
||||
dataNoProfile: "Pubblica contenuti o scrivi il tuo CV così l'algoritmo impara cosa ti interessa.",
|
||||
pollsDescription: "Modulo per interrogare la rete e contare le risposte.",
|
||||
|
|
@ -2202,9 +2215,7 @@ module.exports = {
|
|||
tribeTrendingEngagement: "coinvolgimento",
|
||||
tribeTopCategory: "Più votato",
|
||||
tribeTagsEmpty: "Nessun tag trovato.",
|
||||
tribeSearchPlaceholder: "Cerca nei contenuti della tribù...",
|
||||
tribeSearchEmpty: "Nessun risultato trovato.",
|
||||
tribeSearchResults: "Risultati",
|
||||
tribeSearchMinChars: "Inserisci almeno 2 caratteri per cercare.",
|
||||
agendaTitle: "Agenda",
|
||||
agendaDescription: "Qui trovi tutti gli elementi assegnati a te.",
|
||||
|
|
@ -3834,6 +3845,7 @@ module.exports = {
|
|||
roomUpdate: "Aggiorna",
|
||||
roomDelete: "Elimina",
|
||||
roomClose: "Chiudi",
|
||||
roomReopen: "Riapri",
|
||||
roomCreateSectionTitle: "Crea stanza",
|
||||
roomUpdateSectionTitle: "Aggiorna stanza",
|
||||
roomTitlePlaceholder: "Titolo della stanza",
|
||||
|
|
@ -3852,6 +3864,15 @@ module.exports = {
|
|||
roomMuted: "microfono disattivato",
|
||||
roomRec: "Registra audio",
|
||||
roomRecStop: "Ferma la registrazione",
|
||||
roomStreamFile: "Trasmetti",
|
||||
roomStreamStop: "Ferma",
|
||||
roomStreaming: "In trasmissione",
|
||||
liveFullscreen: "Schermo intero",
|
||||
roomStreamPickTitle: "Scegli un video",
|
||||
roomStreamPickHint: "Video di questo dispositivo. Quello che scegli viene trasmesso da dove si trova, senza copiarlo né pubblicarlo.",
|
||||
roomStreamGo: "Trasmetti nella stanza",
|
||||
roomStreamNotFound: "Quel video non è nelle tue cartelle.",
|
||||
roomPublicWarning: "Questa stanza è trasmessa sul web pubblico: chiunque può vedere e sentire ciò che vi accade.",
|
||||
roomRecording: "REC",
|
||||
roomNotificationsOn: "ON",
|
||||
roomNotificationsOff: "OFF",
|
||||
|
|
@ -4332,7 +4353,7 @@ module.exports = {
|
|||
phoneHistoryTitle: "Cronologia",
|
||||
phoneLatestTitle: "Ultime chiamate",
|
||||
phoneCreateButton: "Nuova chiamata",
|
||||
phoneLimitsHint: "Fino a 7 abitanti per chiamata; con più di uno è una chiamata congiunta.",
|
||||
phoneLimitsHint: "Fino a {n} abitanti per chiamata; con più di uno è una chiamata congiunta.",
|
||||
phoneErrorPub: "Non si può chiamare un pub.",
|
||||
phoneJointCall: "Chiamata congiunta",
|
||||
phoneWith: "Con",
|
||||
|
|
@ -4477,6 +4498,22 @@ module.exports = {
|
|||
settingsPmVisibilityDesc: "Configura il livello di esposizione ai messaggi privati.",
|
||||
settingsPmVisibilityWhole: "Multiverso",
|
||||
settingsPmVisibilityMutuals: "Solo supporto reciproco",
|
||||
mute: "Silenzia",
|
||||
unmute: "Riattiva",
|
||||
relationshipMuted: "Silenziato da te",
|
||||
hideContent: "Nascondi per me",
|
||||
unhideContent: "Mostra di nuovo",
|
||||
hiddenByYou: "Nascosto da te",
|
||||
settingsTabContent: "Contenuti",
|
||||
settingsHiddenTitle: "Contenuti nascosti",
|
||||
settingsHiddenDesc: "Abitanti silenziati e contenuti nascosti.",
|
||||
settingsMutedLabel: "Abitanti silenziati",
|
||||
settingsHiddenLabel: "Contenuti nascosti",
|
||||
settingsMutedEmpty: "Nessuno silenziato.",
|
||||
settingsHiddenEmpty: "Niente di nascosto.",
|
||||
settingsPmVisibilityFollowing: "Solo persone che seguo",
|
||||
settingsMentionsVisibilityTitle: "Menzioni",
|
||||
settingsMentionsVisibilityDesc: "Scegli da chi ricevere le menzioni.",
|
||||
pmMutualNotice: "L'invio è un guardrail UX. La ricezione è un filtro di attenzione.",
|
||||
inhabitantsPendingFollowsTitle: "Richieste di supporto in attesa",
|
||||
inhabitantsPendingAccept: "Accetta",
|
||||
|
|
@ -4583,7 +4620,7 @@ module.exports = {
|
|||
bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2",
|
||||
bankRulesEcoTaxNote: "La tassa ECO viene dedotta dal RBU lordo quando si calcola l'assegnazione dell'epoca. Gli ECO dedotti non vengono ridistribuiti in questa epoca — restano nel saldo del pub e alimentano il fondo dell'epoca successiva tramite i limiti reserveMin / alpha.",
|
||||
bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))",
|
||||
bankRulesKarmaNote: "Il karma deriva dalle tue azioni (post, voti, partecipazione a parlamento/tribunali, ecc.) con un fattore di decadimento nel tempo, meno i grammi di carbonio generati dal tuo feed.",
|
||||
bankRulesKarmaNote: "Il karma deriva dalle tue azioni (post, voti, partecipazione a parlamento/tribunali, ecc.) con un fattore di decadimento nel tempo, meno i grammi di carbonio generati dal tuo feed. Conta anche ciò che ricevono i tuoi contenuti: opinioni, voti, reazioni e riproduzioni positive aggiungono karma, mentre opinioni o voti negativi lo tolgono quando più abitanti concordano.",
|
||||
bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)",
|
||||
bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)",
|
||||
bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)",
|
||||
|
|
@ -4618,7 +4655,7 @@ module.exports = {
|
|||
larpRulesTestEntryText: "Nel test WILL ogni opzione pondera una o più case; all'invio il sistema somma i punteggi e ti ammette automaticamente nella casa con il punteggio più alto.",
|
||||
larpWillTestHint: "Una volta completato, ti verrà assegnata la casa che meglio corrisponde alle tue risposte. Le tue risposte individuali vengono elaborate localmente e mai memorizzate — solo l'assegnazione di casa risultante viene pubblicata nel tuo feed.",
|
||||
larpWillTestTitle: "Test WILL",
|
||||
settingsLanDesc: "Annuncia periodicamente questo peer ad altre istanze di Oasis sulla stessa rete locale. Disabilita per interrompere le trasmissioni UDP.",
|
||||
settingsLanDesc: "Annuncia periodicamente questo peer ad altre istanze di Oasis sulla stessa rete locale.",
|
||||
settingsLanEnable: "Abilita trasmissione LAN",
|
||||
settingsLanTitle: "Trasmissione LAN",
|
||||
settingsReplicationTitle: "Replicazione",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ module.exports = {
|
|||
peersTorColumn: "TOR",
|
||||
peersTorYes: "Acessível por Tor",
|
||||
peersTorNo: "Não acessível por Tor",
|
||||
peersOasisId: "Oasis ID",
|
||||
peersOasisId: "ID",
|
||||
peersOasisVersion: "Versão",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "Mapa interativo da rede ao seu redor.",
|
||||
|
|
@ -600,6 +600,21 @@ module.exports = {
|
|||
peerVersionUnannounced: "Não anuncia a sua versão: um Oasis antigo",
|
||||
peersTorMissing: "Este PUB só permite ligações através do TOR.",
|
||||
peerConnectionsTitle: "Ligações",
|
||||
peersOnlineHint: "Nós ligados a ti neste momento.",
|
||||
peersDiscoveredHint: "Nós com endereço conhecido que não estão ligados agora.",
|
||||
peersUnknownHint: "Nós do teu grafo social sem endereço conhecido.",
|
||||
peersConnectionsHint: "Endereços a que este nó pode ligar e o estado de cada ligação.",
|
||||
peersTabLan: "LAN",
|
||||
peersTabHealth: "Saúde",
|
||||
peersHealthHint: "Como está cada pub ou nó conhecido, na rede local e fora dela.",
|
||||
peersHealthReachable: "Alcançável",
|
||||
peersHealthFailing: "A falhar",
|
||||
peersHealthFailures: "Falhas",
|
||||
peersFailingSince: "A falhar desde",
|
||||
peersHealthForget: "Esquecer inalcançáveis",
|
||||
peersLanHint: "Nós encontrados na tua rede local.",
|
||||
peersLanLastSeen: "Visto pela última vez",
|
||||
peersLanEmpty: "Ainda não foi encontrado nenhum nó na rede local.",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
discovered: 'Descobertos',
|
||||
|
|
@ -1648,8 +1663,6 @@ module.exports = {
|
|||
dataTermsTitle: "Palavras-chave",
|
||||
dataTermsHint: "Os termos que aparecem em mais conteúdos.",
|
||||
dataConnections: "Correspondências ligadas",
|
||||
dataTopicTitle: "Tudo o que se relaciona com",
|
||||
dataTopicHint: "O que o algoritmo relaciona com este tema",
|
||||
dataNoMatches: "Sem dados cruzados para mostrar.",
|
||||
dataNoProfile: "Publica conteúdo ou escreve o teu CV para que o algoritmo aprenda o que te interessa.",
|
||||
pollsDescription: "Módulo para perguntar à rede e contar as respostas.",
|
||||
|
|
@ -2202,9 +2215,7 @@ module.exports = {
|
|||
tribeTrendingEngagement: "envolvimento",
|
||||
tribeTopCategory: "Mais votado",
|
||||
tribeTagsEmpty: "Nenhum tag encontrado.",
|
||||
tribeSearchPlaceholder: "Pesquisar conteúdo da tribo...",
|
||||
tribeSearchEmpty: "Nenhum resultado encontrado.",
|
||||
tribeSearchResults: "Resultados",
|
||||
tribeSearchMinChars: "Introduz pelo menos 2 caracteres para pesquisar.",
|
||||
agendaTitle: "Agenda",
|
||||
agendaDescription: "Aqui podes encontrar todos os teus itens atribuídos.",
|
||||
|
|
@ -3834,6 +3845,7 @@ module.exports = {
|
|||
roomUpdate: "Atualizar",
|
||||
roomDelete: "Eliminar",
|
||||
roomClose: "Fechar",
|
||||
roomReopen: "Reabrir",
|
||||
roomCreateSectionTitle: "Criar sala",
|
||||
roomUpdateSectionTitle: "Atualizar sala",
|
||||
roomTitlePlaceholder: "Título da sala",
|
||||
|
|
@ -3852,6 +3864,15 @@ module.exports = {
|
|||
roomMuted: "silenciado",
|
||||
roomRec: "Gravar áudio",
|
||||
roomRecStop: "Parar a gravação",
|
||||
roomStreamFile: "Transmitir",
|
||||
roomStreamStop: "Parar",
|
||||
roomStreaming: "A transmitir",
|
||||
liveFullscreen: "Ecrã inteiro",
|
||||
roomStreamPickTitle: "Escolhe um vídeo",
|
||||
roomStreamPickHint: "Vídeos deste dispositivo. O que escolheres é transmitido a partir de onde está, sem ser copiado nem publicado.",
|
||||
roomStreamGo: "Transmitir na sala",
|
||||
roomStreamNotFound: "Esse vídeo não está nas tuas pastas.",
|
||||
roomPublicWarning: "Esta sala é transmitida na web pública: qualquer pessoa pode ver e ouvir o que nela acontece.",
|
||||
roomRecording: "REC",
|
||||
roomNotificationsOn: "ON",
|
||||
roomNotificationsOff: "OFF",
|
||||
|
|
@ -4332,7 +4353,7 @@ module.exports = {
|
|||
phoneHistoryTitle: "Histórico",
|
||||
phoneLatestTitle: "Últimas chamadas",
|
||||
phoneCreateButton: "Nova chamada",
|
||||
phoneLimitsHint: "Até 7 habitantes por chamada; com mais de um é uma chamada conjunta.",
|
||||
phoneLimitsHint: "Até {n} habitantes por chamada; com mais de um é uma chamada conjunta.",
|
||||
phoneErrorPub: "Não é possível ligar para um pub.",
|
||||
phoneJointCall: "Chamada conjunta",
|
||||
phoneWith: "Com",
|
||||
|
|
@ -4477,6 +4498,22 @@ module.exports = {
|
|||
settingsPmVisibilityDesc: "Configure seu nível de exposição a mensagens privadas.",
|
||||
settingsPmVisibilityWhole: "Multiverso",
|
||||
settingsPmVisibilityMutuals: "Só apoio mútuo",
|
||||
mute: "Silenciar",
|
||||
unmute: "Deixar de silenciar",
|
||||
relationshipMuted: "Silenciado por ti",
|
||||
hideContent: "Ocultar para mim",
|
||||
unhideContent: "Mostrar de novo",
|
||||
hiddenByYou: "Ocultado por ti",
|
||||
settingsTabContent: "Conteúdo",
|
||||
settingsHiddenTitle: "Conteúdo oculto",
|
||||
settingsHiddenDesc: "Habitantes que silenciaste e conteúdo que ocultaste.",
|
||||
settingsMutedLabel: "Habitantes silenciados",
|
||||
settingsHiddenLabel: "Conteúdo oculto",
|
||||
settingsMutedEmpty: "Ninguém silenciado.",
|
||||
settingsHiddenEmpty: "Nada oculto.",
|
||||
settingsPmVisibilityFollowing: "Só pessoas que sigo",
|
||||
settingsMentionsVisibilityTitle: "Menções",
|
||||
settingsMentionsVisibilityDesc: "Escolhe de quem te chegam as menções.",
|
||||
pmMutualNotice: "O envio é uma barreira de UX. O recebimento é um filtro de atenção.",
|
||||
inhabitantsPendingFollowsTitle: "Pedidos de apoio pendentes",
|
||||
inhabitantsPendingAccept: "Aceitar",
|
||||
|
|
@ -4595,7 +4632,7 @@ module.exports = {
|
|||
bankRulesFloor: "Mínimo por utilizador (bruto)",
|
||||
bankRulesGraceDays: "Período de carência da reivindicação",
|
||||
bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))",
|
||||
bankRulesKarmaNote: "O karma deriva das tuas ações (publicações, votos, participação no parlamento/tribunais, etc.) com um fator de decaimento temporal, menos os gramas de carbono gerados pelo teu feed.",
|
||||
bankRulesKarmaNote: "O karma deriva das tuas ações (publicações, votos, participação no parlamento/tribunais, etc.) com um fator de decaimento temporal, menos os gramas de carbono gerados pelo teu feed. O que o teu conteúdo recebe também conta: opiniões, votos, reações e reproduções positivas somam karma, e opiniões ou votos negativos subtraem-no quando vários habitantes coincidem.",
|
||||
bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)",
|
||||
bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)",
|
||||
bankRulesReserveMin: "Reserva mínima (mantida no saldo do pub)",
|
||||
|
|
@ -4633,7 +4670,7 @@ module.exports = {
|
|||
larpRulesTestEntryText: "No teste WILL cada opção pondera uma ou mais casas; ao enviar, o sistema soma as pontuações e admite-te automaticamente na casa com mais pontos.",
|
||||
larpWillTestHint: "Uma vez concluído, ser-te-á atribuída a casa que melhor corresponde às tuas respostas. As tuas respostas individuais são processadas localmente e nunca armazenadas — apenas a atribuição de casa resultante é publicada no teu feed.",
|
||||
larpWillTestTitle: "Teste WILL",
|
||||
settingsLanDesc: "Anuncia periodicamente este peer a outras instâncias do Oasis na mesma rede local. Desativar para parar as difusões UDP.",
|
||||
settingsLanDesc: "Anuncia periodicamente este peer a outras instâncias do Oasis na mesma rede local.",
|
||||
settingsLanEnable: "Ativar difusão LAN",
|
||||
settingsLanTitle: "Difusão LAN",
|
||||
settingsReplicationTitle: "Replicação",
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ module.exports = {
|
|||
peersTorColumn: "TOR",
|
||||
peersTorYes: "Доступен через Tor",
|
||||
peersTorNo: "Недоступен через Tor",
|
||||
peersOasisId: "Oasis ID",
|
||||
peersOasisId: "ID",
|
||||
peersOasisVersion: "Версия",
|
||||
graphos: "Графос",
|
||||
graphosDescription: "Интерактивная карта сети вокруг вас.",
|
||||
|
|
@ -611,6 +611,21 @@ module.exports = {
|
|||
peerVersionUnannounced: "Не сообщает свою версию: старый Oasis",
|
||||
peersTorMissing: "Этот PUB принимает подключения только через TOR.",
|
||||
peerConnectionsTitle: "Подключения",
|
||||
peersOnlineHint: "Узлы, подключённые к вам прямо сейчас.",
|
||||
peersDiscoveredHint: "Узлы с известным адресом, не подключённые сейчас.",
|
||||
peersUnknownHint: "Узлы вашего социального графа без известного адреса.",
|
||||
peersConnectionsHint: "Адреса, к которым может подключаться этот узел, и состояние каждого соединения.",
|
||||
peersTabLan: "LAN",
|
||||
peersTabHealth: "Состояние",
|
||||
peersHealthHint: "Как дела у каждого известного паба или узла, в локальной сети и за её пределами.",
|
||||
peersHealthReachable: "Доступен",
|
||||
peersHealthFailing: "Сбоит",
|
||||
peersHealthFailures: "Сбои",
|
||||
peersFailingSince: "Сбоит с",
|
||||
peersHealthForget: "Забыть недоступные",
|
||||
peersLanHint: "Узлы, найденные в вашей локальной сети.",
|
||||
peersLanLastSeen: "Последний раз замечен",
|
||||
peersLanEmpty: "В локальной сети пока не найдено ни одного узла.",
|
||||
online: "В сети",
|
||||
offline: "Не в сети",
|
||||
discovered: 'Обнаружен',
|
||||
|
|
@ -1658,8 +1673,6 @@ module.exports = {
|
|||
dataTermsTitle: "Ключевые слова",
|
||||
dataTermsHint: "Термины, встречающиеся в наибольшем числе материалов.",
|
||||
dataConnections: "Связанные совпадения",
|
||||
dataTopicTitle: "Всё, что связано с",
|
||||
dataTopicHint: "Что алгоритм связывает с этой темой",
|
||||
dataNoMatches: "Нет перекрёстных данных.",
|
||||
dataNoProfile: "Публикуйте контент или заполните CV, чтобы алгоритм узнал, что вам интересно.",
|
||||
pollsDescription: "Модуль, чтобы спросить сеть и подсчитать ответы.",
|
||||
|
|
@ -2218,9 +2231,7 @@ module.exports = {
|
|||
tribeTrendingEngagement: "вовлечённость",
|
||||
tribeTopCategory: "Больше голосов",
|
||||
tribeTagsEmpty: "Тегов не найдено.",
|
||||
tribeSearchPlaceholder: "Поиск контента племени...",
|
||||
tribeSearchEmpty: "Результаты не найдены.",
|
||||
tribeSearchResults: "Результаты",
|
||||
tribeSearchMinChars: "Введите не менее 2 символов для поиска.",
|
||||
agendaTitle: "Повестка дня",
|
||||
agendaDescription: "Здесь вы можете найти все назначенные вам элементы.",
|
||||
|
|
@ -3835,6 +3846,7 @@ module.exports = {
|
|||
roomUpdate: "Обновить",
|
||||
roomDelete: "Удалить",
|
||||
roomClose: "Закрыть",
|
||||
roomReopen: "Открыть снова",
|
||||
roomCreateSectionTitle: "Создать комнату",
|
||||
roomUpdateSectionTitle: "Обновить комнату",
|
||||
roomTitlePlaceholder: "Название комнаты",
|
||||
|
|
@ -3853,6 +3865,15 @@ module.exports = {
|
|||
roomMuted: "микрофон выключен",
|
||||
roomRec: "Записать аудио",
|
||||
roomRecStop: "Остановить запись",
|
||||
roomStreamFile: "Транслировать",
|
||||
roomStreamStop: "Остановить",
|
||||
roomStreaming: "Идёт трансляция",
|
||||
liveFullscreen: "Во весь экран",
|
||||
roomStreamPickTitle: "Выберите видео",
|
||||
roomStreamPickHint: "Видео на этом устройстве. Выбранное транслируется прямо с места хранения, без копирования и публикации.",
|
||||
roomStreamGo: "Показать в комнате",
|
||||
roomStreamNotFound: "Этого видео нет в ваших папках.",
|
||||
roomPublicWarning: "Эта комната транслируется в открытом интернете: любой может видеть и слышать, что в ней происходит.",
|
||||
roomRecording: "REC",
|
||||
roomNotificationsOn: "ON",
|
||||
roomNotificationsOff: "OFF",
|
||||
|
|
@ -4333,7 +4354,7 @@ module.exports = {
|
|||
phoneHistoryTitle: "История",
|
||||
phoneLatestTitle: "Последние звонки",
|
||||
phoneCreateButton: "Новый звонок",
|
||||
phoneLimitsHint: "До 7 жителей на звонок; если больше одного — это совместный звонок.",
|
||||
phoneLimitsHint: "До {n} жителей на звонок; если больше одного — это совместный звонок.",
|
||||
phoneErrorPub: "Пабам звонить нельзя.",
|
||||
phoneJointCall: "Совместный звонок",
|
||||
phoneWith: "С кем",
|
||||
|
|
@ -4478,6 +4499,22 @@ module.exports = {
|
|||
settingsPmVisibilityDesc: "Настройте уровень открытости личных сообщений.",
|
||||
settingsPmVisibilityWhole: "Мультивселенная",
|
||||
settingsPmVisibilityMutuals: "Только взаимная поддержка",
|
||||
mute: "Заглушить",
|
||||
unmute: "Снять заглушение",
|
||||
relationshipMuted: "Заглушён вами",
|
||||
hideContent: "Скрыть для меня",
|
||||
unhideContent: "Показать снова",
|
||||
hiddenByYou: "Скрыто вами",
|
||||
settingsTabContent: "Контент",
|
||||
settingsHiddenTitle: "Скрытый контент",
|
||||
settingsHiddenDesc: "Заглушённые вами жители и скрытый вами контент.",
|
||||
settingsMutedLabel: "Заглушённые жители",
|
||||
settingsHiddenLabel: "Скрытый контент",
|
||||
settingsMutedEmpty: "Никто не заглушён.",
|
||||
settingsHiddenEmpty: "Ничего не скрыто.",
|
||||
settingsPmVisibilityFollowing: "Только те, на кого я подписан",
|
||||
settingsMentionsVisibilityTitle: "Упоминания",
|
||||
settingsMentionsVisibilityDesc: "Выберите, чьи упоминания доходят до вас.",
|
||||
pmMutualNotice: "Исходящие — защита UX. Входящие — фильтр внимания.",
|
||||
inhabitantsPendingFollowsTitle: "Ожидающие запросы на поддержку",
|
||||
inhabitantsPendingAccept: "Принять",
|
||||
|
|
@ -4610,7 +4647,7 @@ module.exports = {
|
|||
bankRulesFloor: "Минимум на пользователя (брутто)",
|
||||
bankRulesGraceDays: "Льготный период для заявки",
|
||||
bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))",
|
||||
bankRulesKarmaNote: "Карма вычисляется по вашим действиям (публикации, голоса, участие в парламенте/судах и т. д.) с коэффициентом затухания во времени, за вычетом граммов углерода, созданных вашей лентой.",
|
||||
bankRulesKarmaNote: "Карма вычисляется по вашим действиям (публикации, голоса, участие в парламенте/судах и т. д.) с коэффициентом затухания во времени, за вычетом граммов углерода, созданных вашей лентой. То, что получает ваш контент, тоже учитывается: положительные мнения, голоса, реакции и прослушивания добавляют карму, а отрицательные мнения или голоса отнимают её, когда несколько жителей согласны.",
|
||||
bankRulesKarmaTitle: "Карма (оценка вовлечённости)",
|
||||
bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)",
|
||||
bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)",
|
||||
|
|
@ -4696,7 +4733,7 @@ module.exports = {
|
|||
larpRulesTestEntryText: "В тесте WILL каждый вариант даёт вес одному или нескольким домам; при отправке система суммирует баллы и автоматически принимает вас в дом с наибольшим счётом.",
|
||||
larpWillTestHint: "После завершения вам будет назначен дом, который лучше всего соответствует вашим ответам. Ваши индивидуальные ответы обрабатываются локально и никогда не сохраняются — в ваш канал публикуется только результат назначения дома.",
|
||||
larpWillTestTitle: "WILL-тест",
|
||||
settingsLanDesc: "Периодически анонсировать этот узел другим экземплярам Oasis в той же локальной сети. Отключите, чтобы остановить UDP-вещание.",
|
||||
settingsLanDesc: "Периодически анонсировать этот узел другим экземплярам Oasis в той же локальной сети.",
|
||||
settingsLanEnable: "Включить LAN-вещание",
|
||||
settingsLanTitle: "LAN-вещание",
|
||||
settingsReplicationTitle: "Репликация",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ module.exports = {
|
|||
peersTorColumn: "TOR",
|
||||
peersTorYes: "可通过 Tor 访问",
|
||||
peersTorNo: "无法通过 Tor 访问",
|
||||
peersOasisId: "Oasis ID",
|
||||
peersOasisId: "ID",
|
||||
peersOasisVersion: "版本",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "你周围网络的互动地图。",
|
||||
|
|
@ -602,6 +602,21 @@ module.exports = {
|
|||
peerVersionUnannounced: "未公布其版本:旧版 Oasis",
|
||||
peersTorMissing: "此 PUB 仅允许通过 TOR 连接。",
|
||||
peerConnectionsTitle: "连接",
|
||||
peersOnlineHint: "当前与你相连的节点。",
|
||||
peersDiscoveredHint: "地址已知但当前未连接的节点。",
|
||||
peersUnknownHint: "你的社交图谱中没有已知地址的节点。",
|
||||
peersConnectionsHint: "本节点可以拨号的地址及每个连接的状态。",
|
||||
peersTabLan: "局域网",
|
||||
peersTabHealth: "健康",
|
||||
peersHealthHint: "每个已知 pub 或节点的状态,包括局域网内外。",
|
||||
peersHealthReachable: "可达",
|
||||
peersHealthFailing: "失败中",
|
||||
peersHealthFailures: "失败次数",
|
||||
peersFailingSince: "失败开始于",
|
||||
peersHealthForget: "忘记不可达节点",
|
||||
peersLanHint: "在你的局域网中发现的节点。",
|
||||
peersLanLastSeen: "最后出现",
|
||||
peersLanEmpty: "局域网中尚未发现任何节点。",
|
||||
online: "在线",
|
||||
offline: "离线",
|
||||
discovered: '已发现',
|
||||
|
|
@ -1651,8 +1666,6 @@ module.exports = {
|
|||
dataTermsTitle: "关键词",
|
||||
dataTermsHint: "出现在最多内容中的词条。",
|
||||
dataConnections: "关联匹配",
|
||||
dataTopicTitle: "与此相关的一切:",
|
||||
dataTopicHint: "算法与该主题关联的内容",
|
||||
dataNoMatches: "没有可显示的交叉数据。",
|
||||
dataNoProfile: "发布内容或填写简历,让算法了解你的兴趣。",
|
||||
pollsDescription: "用于向网络提问并统计答案的模块。",
|
||||
|
|
@ -2205,9 +2218,7 @@ module.exports = {
|
|||
tribeTrendingEngagement: "参与度",
|
||||
tribeTopCategory: "最多投票",
|
||||
tribeTagsEmpty: "未找到标签。",
|
||||
tribeSearchPlaceholder: "搜索部落内容...",
|
||||
tribeSearchEmpty: "未找到结果。",
|
||||
tribeSearchResults: "结果",
|
||||
tribeSearchMinChars: "请输入至少2个字符进行搜索。",
|
||||
agendaTitle: "日程",
|
||||
agendaDescription: "在这里你可以找到所有分配给你的项目。",
|
||||
|
|
@ -3836,6 +3847,7 @@ module.exports = {
|
|||
roomUpdate: "更新",
|
||||
roomDelete: "删除",
|
||||
roomClose: "关闭",
|
||||
roomReopen: "重新开放",
|
||||
roomCreateSectionTitle: "创建房间",
|
||||
roomUpdateSectionTitle: "更新房间",
|
||||
roomTitlePlaceholder: "房间标题",
|
||||
|
|
@ -3854,6 +3866,15 @@ module.exports = {
|
|||
roomMuted: "已静音",
|
||||
roomRec: "录制音频",
|
||||
roomRecStop: "停止录音",
|
||||
roomStreamFile: "播放",
|
||||
roomStreamStop: "停止",
|
||||
roomStreaming: "正在播放",
|
||||
liveFullscreen: "全屏",
|
||||
roomStreamPickTitle: "选择视频",
|
||||
roomStreamPickHint: "此设备上的视频。所选视频将从其所在位置直接播放,不会被复制或发布。",
|
||||
roomStreamGo: "在房间中播放",
|
||||
roomStreamNotFound: "你的文件夹中没有这个视频。",
|
||||
roomPublicWarning: "此房间在公共网络上直播:任何人都能看到和听到其中发生的一切。",
|
||||
roomRecording: "REC",
|
||||
roomNotificationsOn: "ON",
|
||||
roomNotificationsOff: "OFF",
|
||||
|
|
@ -4334,7 +4355,7 @@ module.exports = {
|
|||
phoneHistoryTitle: "历史记录",
|
||||
phoneLatestTitle: "最近通话",
|
||||
phoneCreateButton: "新通话",
|
||||
phoneLimitsHint: "每次通话最多 7 位居民;多于一位即为联合通话。",
|
||||
phoneLimitsHint: "每次通话最多 {n} 位居民;多于一位即为联合通话。",
|
||||
phoneErrorPub: "不能呼叫 pub。",
|
||||
phoneJointCall: "联合通话",
|
||||
phoneWith: "对象",
|
||||
|
|
@ -4479,6 +4500,22 @@ module.exports = {
|
|||
settingsPmVisibilityDesc: "配置您希望在网络中的私信曝光级别。",
|
||||
settingsPmVisibilityWhole: "多元宇宙",
|
||||
settingsPmVisibilityMutuals: "仅限互相支持",
|
||||
mute: "静音",
|
||||
unmute: "取消静音",
|
||||
relationshipMuted: "已被你静音",
|
||||
hideContent: "对我隐藏",
|
||||
unhideContent: "重新显示",
|
||||
hiddenByYou: "已被你隐藏",
|
||||
settingsTabContent: "内容",
|
||||
settingsHiddenTitle: "隐藏的内容",
|
||||
settingsHiddenDesc: "你静音的居民和你隐藏的内容。",
|
||||
settingsMutedLabel: "已静音的居民",
|
||||
settingsHiddenLabel: "已隐藏的内容",
|
||||
settingsMutedEmpty: "没有静音任何人。",
|
||||
settingsHiddenEmpty: "没有隐藏任何内容。",
|
||||
settingsPmVisibilityFollowing: "仅限我关注的人",
|
||||
settingsMentionsVisibilityTitle: "提及",
|
||||
settingsMentionsVisibilityDesc: "选择谁的提及可以送达你。",
|
||||
pmMutualNotice: "出站是 UX 保护。入站是关注过滤器。",
|
||||
inhabitantsPendingFollowsTitle: "待处理的支持请求",
|
||||
inhabitantsPendingAccept: "接受",
|
||||
|
|
@ -4611,7 +4648,7 @@ module.exports = {
|
|||
bankRulesFloor: "每位用户的保底(总额)",
|
||||
bankRulesGraceDays: "领取宽限期",
|
||||
bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))",
|
||||
bankRulesKarmaNote: "业力来自你的行为(发帖、投票、参与议会/法庭等),带有随时间衰减的系数,并减去你的动态所产生的碳克数。",
|
||||
bankRulesKarmaNote: "业力来自你的行为(发帖、投票、参与议会/法庭等),带有随时间衰减的系数,并减去你的动态所产生的碳克数。 你的内容收到的反馈同样计入:正面的评价、投票、反应和播放会增加业力,当多位居民一致给出负面评价或投票时则会扣减。",
|
||||
bankRulesKarmaTitle: "业力(用户参与度评分)",
|
||||
bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)",
|
||||
bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)",
|
||||
|
|
@ -4697,7 +4734,7 @@ module.exports = {
|
|||
larpRulesTestEntryText: "在 WILL 测试中,每个选项会为一个或多个家族加权;提交后系统统计得分,并自动把你录入得分最高的家族。",
|
||||
larpWillTestHint: "完成后,你将被分配到与你的回答最匹配的家族。你的单个回答在本地处理,从不存储 — 只有最终的家族分配会发布到你的信息流。",
|
||||
larpWillTestTitle: "WILL 测试",
|
||||
settingsLanDesc: "定期向同一局域网中的其他 Oasis 实例广播此节点。禁用以停止 UDP 广播。",
|
||||
settingsLanDesc: "定期向同一局域网中的其他 Oasis 实例广播此节点。",
|
||||
settingsLanEnable: "启用局域网广播",
|
||||
settingsLanTitle: "局域网广播",
|
||||
settingsReplicationTitle: "复制",
|
||||
|
|
|
|||
|
|
@ -161,13 +161,14 @@ const readConfig = () => {
|
|||
const configData = fs.readFileSync(configFilePath);
|
||||
const cfg = JSON.parse(configData);
|
||||
if (!['whole', 'mutuals', 'only-lan', 'local'].includes(cfg.wish)) cfg.wish = 'whole';
|
||||
if (cfg.pmVisibility !== 'whole' && cfg.pmVisibility !== 'mutuals') cfg.pmVisibility = 'whole';
|
||||
if (!['whole', 'mutuals', 'following'].includes(cfg.pmVisibility)) cfg.pmVisibility = 'whole';
|
||||
if (!['whole', 'mutuals', 'following'].includes(cfg.mentionsVisibility)) cfg.mentionsVisibility = 'whole';
|
||||
if (!cfg.phone || typeof cfg.phone !== 'object') cfg.phone = {};
|
||||
cfg.phone = { visibility: cfg.phone.visibility === 'whole' ? 'whole' : 'mutuals', dnd: cfg.phone.dnd === true, relay: cfg.phone.relay !== false, video: cfg.phone.video !== false, ...(typeof cfg.phone.videoDevice === 'string' && cfg.phone.videoDevice.trim() ? { videoDevice: cfg.phone.videoDevice.trim() } : {}) };
|
||||
if (typeof cfg.ux === 'string') cfg.ux = { current: cfg.ux };
|
||||
if (!cfg.ux || typeof cfg.ux !== 'object') cfg.ux = { current: 'blocks' };
|
||||
if (cfg.ux.current === 'menus') cfg.ux.current = 'blocks';
|
||||
if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav' && cfg.ux.current !== 'chats' && cfg.ux.current !== 'feed' && cfg.ux.current !== 'phone') cfg.ux.current = 'blocks';
|
||||
if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav' && cfg.ux.current !== 'chats' && cfg.ux.current !== 'feed' && cfg.ux.current !== 'phone' && cfg.ux.current !== 'rooms') cfg.ux.current = 'blocks';
|
||||
if (cfg.ux.current === 'ainav' && cfg.modules && cfg.modules.aiNavMod !== 'on') cfg.ux.current = 'blocks';
|
||||
if (cfg.ux.current === 'chats' && cfg.modules && cfg.modules.chatsMod !== 'on') cfg.ux.current = 'blocks';
|
||||
|
||||
|
|
@ -182,6 +183,7 @@ const readConfig = () => {
|
|||
delete cfg.bottomBarPin;
|
||||
if (cfg.ux.current === 'phone' && cfg.modules && cfg.modules.phoneMod === 'off') cfg.ux.current = 'blocks';
|
||||
if (cfg.ux.current === 'feed' && cfg.modules && cfg.modules.feedMod === 'off') cfg.ux.current = 'blocks';
|
||||
if (cfg.ux.current === 'rooms' && cfg.modules && cfg.modules.roomsMod === 'off') cfg.ux.current = 'blocks';
|
||||
if (!cfg.blobCache || typeof cfg.blobCache !== 'object' || !Number.isFinite(Number(cfg.blobCache.maxMB))) cfg.blobCache = { maxMB: 0 };
|
||||
if (!Number.isFinite(Number(cfg.blobCache.pubMaxMB))) cfg.blobCache.pubMaxMB = 0;
|
||||
if (cfg.modules && typeof cfg.modules === 'object') {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const FOLDERS = {
|
|||
'content_favorites.json': 'content',
|
||||
'blob-access.json': 'content',
|
||||
'follow_state.json': 'content',
|
||||
'viewer_hidden.json': 'content',
|
||||
'invites-used.json': 'content',
|
||||
'agenda-config.json': 'content',
|
||||
'AI-history.json': 'ai',
|
||||
|
|
|
|||
|
|
@ -432,10 +432,6 @@ const UBI_MAX_ATTEMPTS = 6;
|
|||
const UBI_RETRY_AFTER_MS = 60 * 60 * 1000;
|
||||
const isUbiPayoutConcept = (c) => UBI_PAYMENT_PREFIXES.some(p => String(c || "").startsWith(p));
|
||||
const TXID_RE = /\b([0-9a-f]{64})\b/i;
|
||||
const OWN_SCORE_CAP = 200;
|
||||
const RECEIVED_AUTHOR_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const NEGATIVE_OPINIONS = new Set(["boring", "spam", "propaganda"]);
|
||||
const ACTIONS_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
function resolveUserId(maybeId) {
|
||||
const s = String(maybeId || "").trim();
|
||||
|
|
@ -443,7 +439,7 @@ function resolveUserId(maybeId) {
|
|||
return config?.keys?.id || "";
|
||||
}
|
||||
|
||||
let FEED_SRC = "none";
|
||||
|
||||
|
||||
module.exports = ({ services } = {}) => {
|
||||
const transfersRepo = {
|
||||
|
|
@ -477,6 +473,9 @@ module.exports = ({ services } = {}) => {
|
|||
return ssbInstance;
|
||||
}
|
||||
|
||||
const karma = require("./karma_model")({ services, ranges: { normalizeRange, widenRange, filterByRange } });
|
||||
const { listAllActions, fetchUserActions, scoreFromActions, getCarbonGramsForUser, getUserEngagementScore, getLastKarmaScore, listKarmaHistory } = karma;
|
||||
|
||||
const BANKING_TYPES = ["wallet", "karmaScore", "ubiClaim", "ubiClaimResult", "ubiAllocation", "ubiRefuse", "pubAvailability", "tombstone"];
|
||||
|
||||
async function scanLogStream() {
|
||||
|
|
@ -612,316 +611,6 @@ module.exports = ({ services } = {}) => {
|
|||
return strip(A) === strip(B);
|
||||
}
|
||||
|
||||
function inferType(c = {}) {
|
||||
if (c.vote) return "vote";
|
||||
if (c.votes) return "votes";
|
||||
if (c.address && c.coin === "ECO" && c.type === "wallet") return "bankWallet";
|
||||
if (c.type === "ubiClaimResult" && c.txid && c.epochId) return "ubiClaimResult";
|
||||
if (typeof c.amount !== "undefined" && c.epochId && c.allocationId) return "bankClaim";
|
||||
if (typeof c.item_type !== "undefined" && typeof c.status !== "undefined") return "market";
|
||||
if (typeof c.goal !== "undefined" && typeof c.progress !== "undefined") return "project";
|
||||
if (typeof c.members !== "undefined" && typeof c.isAnonymous !== "undefined") return "tribe";
|
||||
if (typeof c.date !== "undefined" && typeof c.location !== "undefined") return "event";
|
||||
if (typeof c.priority !== "undefined" && typeof c.status !== "undefined" && c.title) return "task";
|
||||
if (typeof c.confirmations !== "undefined" && typeof c.severity !== "undefined") return "report";
|
||||
if (typeof c.job_type !== "undefined" && typeof c.status !== "undefined") return "job";
|
||||
if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "audio") return "audio";
|
||||
if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "video") return "video";
|
||||
if (typeof c.url !== "undefined" && c.title && c.key) return "document";
|
||||
if (typeof c.text !== "undefined" && typeof c.refeeds !== "undefined") return "feed";
|
||||
if (typeof c.text !== "undefined" && typeof c.contentWarning !== "undefined") return "post";
|
||||
if (typeof c.contact !== "undefined") return "contact";
|
||||
if (typeof c.about !== "undefined") return "about";
|
||||
if (typeof c.concept !== "undefined" && typeof c.amount !== "undefined" && c.status) return "transfer";
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeType(a) {
|
||||
const t = a.type || a.content?.type || inferType(a.content) || "";
|
||||
return String(t).toLowerCase();
|
||||
}
|
||||
|
||||
function priorityBump(p) {
|
||||
const s = String(p || "").toUpperCase();
|
||||
if (s === "HIGH") return 3;
|
||||
if (s === "MEDIUM") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function severityBump(s) {
|
||||
const x = String(s || "").toUpperCase();
|
||||
if (x === "CRITICAL") return 6;
|
||||
if (x === "HIGH") return 4;
|
||||
if (x === "MEDIUM") return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function scoreMarket(c) {
|
||||
const st = String(c.status || "").toUpperCase();
|
||||
let s = 5;
|
||||
if (st === "SOLD") s += 8;
|
||||
else if (st === "ACTIVE") s += 3;
|
||||
const bids = Array.isArray(c.auctions_poll) ? c.auctions_poll.length : 0;
|
||||
s += Math.min(10, bids);
|
||||
return s;
|
||||
}
|
||||
|
||||
function scoreProject(c) {
|
||||
const st = String(c.status || "ACTIVE").toUpperCase();
|
||||
const prog = Number(c.progress || 0);
|
||||
let s = 8 + Math.min(10, prog / 10);
|
||||
if (st === "FUNDED") s += 10;
|
||||
return s;
|
||||
}
|
||||
|
||||
function calculateOpinionScore(content) {
|
||||
const cats = content?.opinions || {};
|
||||
let s = 0;
|
||||
for (const k in cats) {
|
||||
if (!Object.prototype.hasOwnProperty.call(cats, k)) continue;
|
||||
if (k === "interesting" || k === "inspiring") s += 5;
|
||||
else if (k === "boring" || k === "spam" || k === "propaganda") s -= 3;
|
||||
else s += 1;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
let actionsCache = null;
|
||||
async function listAllActions() {
|
||||
const ssb = await openSsb();
|
||||
if (!ssb) {
|
||||
FEED_SRC = "none";
|
||||
return [];
|
||||
}
|
||||
const ownSeq = await ownFeedSeq(ssb);
|
||||
if (actionsCache && actionsCache.ownSeq === ownSeq && Date.now() - actionsCache.ts < ACTIONS_CACHE_TTL_MS) return actionsCache.value;
|
||||
const msgs = await readTyped(ssb, CONTENT_TYPES, { limit: Math.max(getLogLimit(), 20000) });
|
||||
FEED_SRC = "messagesByType";
|
||||
const value = msgs.map(m => {
|
||||
const v = m.value || {};
|
||||
const c = v.content || {};
|
||||
return {
|
||||
id: v.key || m.key,
|
||||
author: v.author,
|
||||
type: (c.type || "").toLowerCase(),
|
||||
value: v,
|
||||
content: c,
|
||||
receivedAt: Number(m.timestamp) || 0
|
||||
};
|
||||
});
|
||||
actionsCache = { ts: Date.now(), ownSeq, value };
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function ownFeedSeq(ssb) {
|
||||
return new Promise((res) => {
|
||||
try {
|
||||
pull(
|
||||
ssb.createUserStream({ id: ssb.id, reverse: true, limit: 1 }),
|
||||
pull.collect((err, msgs) => res(err || !msgs || !msgs.length ? 0 : (msgs[0].value && msgs[0].value.sequence) || 0))
|
||||
);
|
||||
} catch (_) { res(0); }
|
||||
});
|
||||
}
|
||||
|
||||
async function publishKarmaScore(userId, karmaScore) {
|
||||
const ssb = await openSsb();
|
||||
if (!ssb || !ssb.publish) return false;
|
||||
if (!(await ownFeedSeq(ssb))) return false;
|
||||
const timestamp = new Date().toISOString();
|
||||
const content = { type: "karmaScore", karmaScore, userId, timestamp };
|
||||
return new Promise((resolve, reject) => {
|
||||
ssb.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchUserActions(userId) {
|
||||
const me = resolveUserId(userId);
|
||||
const actions = await listAllActions();
|
||||
const authored = actions.filter(a =>
|
||||
(a.author && a.author === me) || (a.value?.author && a.value.author === me)
|
||||
);
|
||||
if (authored.length) return authored;
|
||||
return actions.filter(a => {
|
||||
const c = a.content || {};
|
||||
const fields = [c.author, c.organizer, c.seller, c.about, c.contact];
|
||||
return fields.some(f => f && f === me);
|
||||
});
|
||||
}
|
||||
|
||||
function basePointsFor(action) {
|
||||
const t = normalizeType(action);
|
||||
const c = action.content || {};
|
||||
const rawType = String(c.type || "").toLowerCase();
|
||||
if (t === "post") return 10;
|
||||
else if (t === "comment") return 5;
|
||||
else if (t === "like") return 2;
|
||||
else if (t === "image") return 8;
|
||||
else if (t === "video") return 12;
|
||||
else if (t === "audio") return 8;
|
||||
else if (t === "document") return 6;
|
||||
else if (t === "bookmark") return 2;
|
||||
else if (t === "feed") return 6;
|
||||
else if (t === "forum") return (c.root ? 5 : 10);
|
||||
else if (t === "vote") return (3 + calculateOpinionScore(c));
|
||||
else if (t === "votes") return Math.min(10, Number(c.totalVotes || 0));
|
||||
else if (t === "market") return scoreMarket(c);
|
||||
else if (t === "project") return scoreProject(c);
|
||||
else if (t === "tribe") return (6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0));
|
||||
else if (t === "event") return (4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0));
|
||||
else if (t === "task") return (3 + priorityBump(c.priority));
|
||||
else if (t === "report") return (4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity));
|
||||
else if (t === "curriculum") return 5;
|
||||
else if (t === "wikipage") return (c.replaces ? 2 : 6);
|
||||
else if (t === "emergency") return (c.replaces ? 1 : 5);
|
||||
else if (t === "emergencyconfirm") return 1;
|
||||
else if (t === "emergencyupdate") return 2;
|
||||
else if (t === "mailinglist") return (c.replaces ? 1 : 3);
|
||||
else if (t === "logisticsroute") return (c.replaces ? 1 : 4);
|
||||
else if (t === "logisticsrating") return 1;
|
||||
else if (t === "podcast") return (c.replaces ? 1 : 5);
|
||||
else if (t === "podcastepisode") return (c.replaces ? 1 : 4);
|
||||
else if (t === "podcastopinion" || t === "podcastplay" || t === "campaignopinion" || t === "logisticsopinion") return 1;
|
||||
else if (t === "campaign") return (c.replaces ? 1 : 5);
|
||||
else if (t === "campaignsignature") return 1;
|
||||
else if (t === "campaignupdate") return 2;
|
||||
else if (t === "aiexchange") return (Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0);
|
||||
else if (t === "job") return (4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0));
|
||||
else if (t === "transfer") return 1;
|
||||
else if (t === "about") return (1 + ((c.visibilityPrefs && c.visibilityPrefs.fediverseHandle) ? 8 : 0));
|
||||
else if (t === "contact") return 1;
|
||||
else if (t === "pub") return 1;
|
||||
else if (t === "parliamentcandidature" || rawType === "parliamentcandidature") return 12;
|
||||
else if (t === "parliamentterm" || rawType === "parliamentterm") return 25;
|
||||
else if (t === "parliamentproposal" || rawType === "parliamentproposal") return 8;
|
||||
else if (t === "parliamentlaw" || rawType === "parliamentlaw") return 16;
|
||||
else if (t === "parliamentrevocation" || rawType === "parliamentrevocation") return 10;
|
||||
else if (t === "courts_case" || t === "courtscase" || rawType === "courts_case") return 4;
|
||||
else if (t === "courts_evidence" || t === "courtsevidence" || rawType === "courts_evidence") return 3;
|
||||
else if (t === "courts_answer" || t === "courtsanswer" || rawType === "courts_answer") return 4;
|
||||
else if (t === "courts_verdict" || t === "courtsverdict" || rawType === "courts_verdict") return 10;
|
||||
else if (t === "courts_settlement" || t === "courtssettlement" || rawType === "courts_settlement") return 8;
|
||||
else if (t === "courts_nomination" || t === "courtsnomination" || rawType === "courts_nomination") return 6;
|
||||
else if (t === "courts_nom_vote" || t === "courtsnomvote" || rawType === "courts_nom_vote") return 3;
|
||||
else if (t === "courts_public_pref" || t === "courtspublicpref" || rawType === "courts_public_pref") return 1;
|
||||
else if (t === "courts_mediators" || t === "courtsmediators" || rawType === "courts_mediators") return 6;
|
||||
else if (t === "courts_open_support" || t === "courtsopensupport" || rawType === "courts_open_support") return 2;
|
||||
else if (t === "courts_verdict_vote" || t === "courtsverdictvote" || rawType === "courts_verdict_vote") return 3;
|
||||
else if (t === "courts_judge_assign" || t === "courtsjudgeassign" || rawType === "courts_judge_assign") return 5;
|
||||
else if (t === "larpjoinhouse") return 15;
|
||||
else if (t === "larphousepost") return 6;
|
||||
else if (t === "larptestattempt") return 3;
|
||||
else if (t === "torrent") return 6;
|
||||
else if (t === "file") return 6;
|
||||
else if (t === "shop" || t === "shopproduct") return 6;
|
||||
else if (t === "shop-purchase") return 2;
|
||||
else if (t === "pad" || t === "padentry") return 3;
|
||||
else if (t === "room") return 3;
|
||||
else if (t === "roommember") return 1;
|
||||
else if (t === "calendar" || t === "calendarnote" || t === "calendardate") return 3;
|
||||
else if (t === "chat") return 1;
|
||||
else if (t === "gamescore") return 2;
|
||||
else if (t === "pixelia") return 2;
|
||||
else if (rawType === "industry") return 8;
|
||||
else if (rawType === "industryblueprint") return 6;
|
||||
else if (rawType === "industrybuild") return 8;
|
||||
else if (rawType === "industrycontribution") return 4;
|
||||
else if (rawType === "industryallocation") return 10;
|
||||
else if (rawType === "industryvote") return 2;
|
||||
else if (rawType === "industrymember") return 2;
|
||||
else if (rawType === "industryopinion") return 1;
|
||||
else if (rawType === "schoolcourse") return 10;
|
||||
else if (rawType === "schoollesson") return 6;
|
||||
else if (rawType === "schoolenroll") return 3;
|
||||
else if (rawType === "schoolcertificate") return 12;
|
||||
else if (rawType === "schoolexam") return 6;
|
||||
else if (rawType === "schoolprogress") return 1;
|
||||
else if (rawType === "schoolopinion") return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function decayFor(ts, nowMs) {
|
||||
const ageDays = ts ? (nowMs - ts) / 86400000 : Infinity;
|
||||
return ageDays <= 30 ? 1.0 : ageDays <= 90 ? 0.5 : 0.25;
|
||||
}
|
||||
|
||||
function scoreFromActions(actions) {
|
||||
let score = 0;
|
||||
const nowMs = Date.now();
|
||||
const perTypeDay = new Map();
|
||||
for (const action of actions) {
|
||||
const pts = basePointsFor(action);
|
||||
if (!pts) continue;
|
||||
const ts = action.value?.timestamp;
|
||||
const dayKey = `${normalizeType(action)}:${ts ? new Date(ts).toISOString().slice(0, 10) : "?"}`;
|
||||
const n = (perTypeDay.get(dayKey) || 0) + 1;
|
||||
perTypeDay.set(dayKey, n);
|
||||
score += pts * decayFor(ts, nowMs) / n;
|
||||
}
|
||||
return Math.max(0, Math.round(Math.min(OWN_SCORE_CAP, score)));
|
||||
}
|
||||
|
||||
function receivedScoreFor(userId, actions) {
|
||||
const nowMs = Date.now();
|
||||
const mine = new Set();
|
||||
const firstTs = new Map();
|
||||
for (const a of actions) {
|
||||
if (!a.author) continue;
|
||||
const ts = Number(a.receivedAt) || 0;
|
||||
if (ts && (!firstTs.has(a.author) || ts < firstTs.get(a.author))) firstTs.set(a.author, ts);
|
||||
if (a.author === userId && a.id) mine.add(a.id);
|
||||
}
|
||||
const seen = new Set();
|
||||
const perTarget = new Map();
|
||||
let score = 0;
|
||||
const add = (author, kind, target, pts, ts, capPerTarget = Infinity) => {
|
||||
const k = `${kind}:${author}:${target}`;
|
||||
if (seen.has(k)) return;
|
||||
const n = perTarget.get(`${kind}:${target}`) || 0;
|
||||
if (n >= capPerTarget) return;
|
||||
seen.add(k);
|
||||
perTarget.set(`${kind}:${target}`, n + 1);
|
||||
score += pts * decayFor(ts, nowMs);
|
||||
};
|
||||
for (const a of actions) {
|
||||
const b = a.author;
|
||||
if (!b || b === userId) continue;
|
||||
if (!firstTs.has(b) || nowMs - firstTs.get(b) < RECEIVED_AUTHOR_MIN_AGE_MS) continue;
|
||||
const c = a.content || {};
|
||||
const type = String(c.type || "");
|
||||
const ts = a.value?.timestamp;
|
||||
if (/Opinion$/.test(type) && type !== "pollOpinion" && mine.has(c.target)) add(b, "opinion", c.target, NEGATIVE_OPINIONS.has(String(c.category || "").toLowerCase()) ? -3 : 3, ts);
|
||||
else if (type === "vote" && c.vote && mine.has(c.vote.link)) add(b, "opinion", c.vote.link, Number(c.vote.value) > 0 ? 3 : -3, ts);
|
||||
else if (type === "feed-action" && c.action === "vote" && mine.has(c.root || c.target)) add(b, "opinion", c.root || c.target, 3, ts);
|
||||
else if (type === "feed-action" && c.action === "comment" && mine.has(c.root)) add(b, "comment", c.root, 2, ts, 10);
|
||||
else if (type === "feed-action" && c.action === "refeed" && mine.has(c.root)) add(b, "refeed", c.root, 2, ts, 10);
|
||||
else if (type === "post" && c.root && mine.has(c.root)) add(b, "comment", c.root, 2, ts, 10);
|
||||
else if (type === "contact" && c.following === true && c.contact === userId) add(b, "follow", userId, 2, ts);
|
||||
else if (type === "marketPurchase" && mine.has(c.target)) add(b, "purchase", c.target, 5, ts);
|
||||
else if (type === "shop-purchase" && c.seller === userId) add(b, "purchase", c.productId || a.id, 5, ts);
|
||||
else if (type === "schoolEnroll" && c.value === true && mine.has(c.courseId)) add(b, "enroll", c.courseId, 5, ts);
|
||||
else if (type === "transferConfirm" && mine.has(c.target)) add(b, "confirm", c.target, 3, ts);
|
||||
}
|
||||
return Math.max(0, Math.round(score));
|
||||
}
|
||||
|
||||
async function getCarbonGramsForUser(userId) {
|
||||
const ssb = await openSsb();
|
||||
if (!ssb || !userId) return 0;
|
||||
return new Promise((resolve) => {
|
||||
let bytes = 0;
|
||||
pull(
|
||||
ssb.createUserStream({ id: userId }),
|
||||
pull.drain(
|
||||
(m) => { try { bytes += Buffer.byteLength(JSON.stringify(m && m.value), 'utf8'); } catch (_) {} },
|
||||
() => resolve((bytes / (1024 * 1024)) * 0.095)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function getBytesForUser(userId) {
|
||||
const ssb = await openSsb();
|
||||
if (!ssb || !userId) return 0;
|
||||
|
|
@ -1134,85 +823,6 @@ async function getSchoolBalance(userId) {
|
|||
return { received: Number(received.toFixed(6)), lifetime: Number(lifetime.toFixed(6)), net: Number(received.toFixed(6)) };
|
||||
}
|
||||
|
||||
async function getUserEngagementScore(userId) {
|
||||
const ssb = await openSsb();
|
||||
const uid = resolveUserId(userId);
|
||||
const actions = await fetchUserActions(uid);
|
||||
const all = await listAllActions();
|
||||
const rawKarma = scoreFromActions(actions) + receivedScoreFor(uid, all);
|
||||
const carbonGrams = await getCarbonGramsForUser(uid).catch(() => 0);
|
||||
const karmaScore = Math.max(0, Math.round(rawKarma - carbonGrams));
|
||||
|
||||
const prev = await getLastKarmaScore(uid);
|
||||
const lastPublishedTimestamp = await getLastPublishedTimestamp(uid);
|
||||
|
||||
const isSelf = idsEqual(uid, ssb.id);
|
||||
const hasSSB = !!(ssb && ssb.publish);
|
||||
|
||||
const changed = (prev === null) || (karmaScore !== prev);
|
||||
const nowMs = Date.now();
|
||||
const lastMs = lastPublishedTimestamp ? new Date(lastPublishedTimestamp).getTime() : 0;
|
||||
const cooldownOk = (nowMs - lastMs) >= 24 * 60 * 60 * 1000;
|
||||
|
||||
if (isSelf && hasSSB && changed && cooldownOk) {
|
||||
await publishKarmaScore(uid, karmaScore);
|
||||
}
|
||||
return karmaScore;
|
||||
}
|
||||
|
||||
async function getLastKarmaScore(userId) {
|
||||
const ssb = await openSsb();
|
||||
if (!ssb) return null;
|
||||
return new Promise((resolve) => {
|
||||
const source = ssb.messagesByType
|
||||
? ssb.messagesByType({ type: "karmaScore", reverse: true })
|
||||
: ssb.createLogStream && ssb.createLogStream({ reverse: true });
|
||||
if (!source) return resolve(null);
|
||||
pull(
|
||||
source,
|
||||
pull.filter(msg => {
|
||||
const v = msg.value || msg;
|
||||
const c = v.content || {};
|
||||
return c && c.type === "karmaScore" && c.userId === userId;
|
||||
}),
|
||||
pull.take(1),
|
||||
pull.collect((err, arr) => {
|
||||
if (err || !arr || !arr.length) return resolve(null);
|
||||
const v = arr[0].value || arr[0];
|
||||
const c = v.content || {};
|
||||
resolve(Number(c.karmaScore) || 0);
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function getLastPublishedTimestamp(userId) {
|
||||
const ssb = await openSsb();
|
||||
if (!ssb) return new Date(0).toISOString();
|
||||
const fallback = new Date(0).toISOString();
|
||||
return new Promise((resolve) => {
|
||||
const source = ssb.messagesByType
|
||||
? ssb.messagesByType({ type: "karmaScore", reverse: true })
|
||||
: ssb.createLogStream && ssb.createLogStream({ reverse: true });
|
||||
if (!source) return resolve(fallback);
|
||||
pull(
|
||||
source,
|
||||
pull.filter(msg => {
|
||||
const v = msg.value || msg;
|
||||
const c = v.content || {};
|
||||
return c && c.type === "karmaScore" && c.userId === userId;
|
||||
}),
|
||||
pull.take(1),
|
||||
pull.collect((err, arr) => {
|
||||
if (err || !arr || !arr.length) return resolve(fallback);
|
||||
const v = arr[0].value || arr[0];
|
||||
const c = v.content || {};
|
||||
resolve(c.timestamp || fallback);
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function computePoolVars(pubBal, rules) {
|
||||
const alphaCap = (rules.alpha ?? DEFAULT_RULES.alpha) * pubBal;
|
||||
const available = Math.max(0, pubBal - (rules.reserveMin ?? DEFAULT_RULES.reserveMin));
|
||||
|
|
@ -1820,20 +1430,6 @@ async function getLastPublishedTimestamp(userId) {
|
|||
return { points: filterByRange(all, effective).slice(-240), hasAny: all.length >= 2, range: effective };
|
||||
}
|
||||
|
||||
async function listKarmaHistory(userId, range = "all", explicit = true) {
|
||||
const ssb = await openSsb();
|
||||
if (!ssb) return { points: [], hasAny: false, range: normalizeRange(range) };
|
||||
const uid = resolveUserId(userId);
|
||||
const msgs = await readTyped(ssb, ["karmaScore"], { limit: Math.max(getLogLimit(), 5000) });
|
||||
const all = msgs
|
||||
.filter(m => m.value && m.value.author === uid && m.value.content && m.value.content.type === "karmaScore")
|
||||
.map(m => ({ ts: Date.parse(m.value.content.timestamp || "") || Number(m.value.timestamp) || 0, score: Number(m.value.content.karmaScore) || 0 }))
|
||||
.filter(x => x.ts > 0)
|
||||
.sort((a, b) => a.ts - b.ts);
|
||||
const effective = explicit ? normalizeRange(range) : widenRange(all, range);
|
||||
return { points: filterByRange(all, effective).slice(-120), hasAny: all.length >= 2, range: effective };
|
||||
}
|
||||
|
||||
async function listUbiPubs() {
|
||||
const latestByPub = await scanUbiAnnouncements();
|
||||
const trusted = await trustedPubIds();
|
||||
|
|
@ -2197,6 +1793,8 @@ async function getLastPublishedTimestamp(userId) {
|
|||
async function getBankingData(userId) {
|
||||
const ecoValue = await perRequest("ecoinValue", () => calculateEcoinValue());
|
||||
const karmaScore = await getUserEngagementScore(userId);
|
||||
const received = await karma.getKarmaBreakdown(userId);
|
||||
const karmaReceived = { positive: received.positive, negative: received.negative };
|
||||
let estimatedUBI = 0;
|
||||
try {
|
||||
const pubBal = isPubNode() ? await perRequest("pubBalance", () => safeGetBalance("pub")) : 0;
|
||||
|
|
@ -2227,6 +1825,7 @@ async function getLastPublishedTimestamp(userId) {
|
|||
return {
|
||||
ecoValue,
|
||||
karmaScore,
|
||||
karmaReceived,
|
||||
estimatedUBI,
|
||||
estimatedUBIBeforeTax: Number(estimatedUBIBeforeTax.toFixed(6)),
|
||||
userEcoinTax: Number(userEcoinTax.toFixed(6)),
|
||||
|
|
@ -2636,6 +2235,7 @@ async function getLastPublishedTimestamp(userId) {
|
|||
computeEpoch,
|
||||
executeEpoch,
|
||||
getUserEngagementScore,
|
||||
getKarmaReceived: async (userId) => { const r = karma.receivedBreakdownFor(String(userId || config.keys.id), await listAllActions()); return { positive: r.positive, negative: r.negative }; },
|
||||
publishBankClaim,
|
||||
publishUbiAllocation,
|
||||
publishUbiClaim,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ module.exports = ({ cooler, isPublic = false }) => {
|
|||
text: longText.joinText(c.text || '', c.chunks, longText.lookupIn(idx.chunks), node.author),
|
||||
mentions: Array.isArray(c.mentions) ? c.mentions : [],
|
||||
allowComments: c.allowComments !== false,
|
||||
tags: Array.isArray(c.tags) ? c.tags.map(t => String(t || '').trim().replace(/^#+/, '')).filter(Boolean) : [],
|
||||
createdAt: new Date(node.ts).toISOString(),
|
||||
commentCount: idx.commentsByRoot.get(node.key) || 0,
|
||||
opinions: op.counts,
|
||||
|
|
@ -99,7 +100,8 @@ module.exports = ({ cooler, isPublic = false }) => {
|
|||
|
||||
const q = String(opts.q || '').trim().toLowerCase();
|
||||
if (q) out = out.filter(b =>
|
||||
b.text.toLowerCase().includes(q) || b.subject.toLowerCase().includes(q));
|
||||
b.text.toLowerCase().includes(q) || b.subject.toLowerCase().includes(q) ||
|
||||
b.tags.some(t => t.toLowerCase().includes(q.replace(/^#/, ''))));
|
||||
|
||||
if (f === 'TOP') {
|
||||
out.sort((a, b) => (opinionScore(b) + b.commentCount) - (opinionScore(a) + a.commentCount) ||
|
||||
|
|
@ -118,7 +120,7 @@ module.exports = ({ cooler, isPublic = false }) => {
|
|||
return blog;
|
||||
},
|
||||
|
||||
async createBlog({ text, subject = '', mentions = [], allowComments = true } = {}) {
|
||||
async createBlog({ text, subject = '', mentions = [], allowComments = true, tags = [] } = {}) {
|
||||
if (isPublic) throw new Error('Not available in public mode');
|
||||
const body = String(text || '').trim();
|
||||
if (!body) throw new Error('Blog text is required');
|
||||
|
|
@ -128,6 +130,7 @@ module.exports = ({ cooler, isPublic = false }) => {
|
|||
type: 'post',
|
||||
text: body,
|
||||
allowComments: allowComments !== false,
|
||||
...(Array.isArray(tags) && tags.length ? { tags: Array.from(new Set(tags.map(t => String(t || '').trim().replace(/^#+/, '')).filter(Boolean))) } : {}),
|
||||
...(Array.isArray(mentions) && mentions.length ? { mentions } : {}),
|
||||
...(String(subject || '').trim() ? { contentWarning: String(subject).trim() } : {})
|
||||
}, 'text', { publish });
|
||||
|
|
|
|||
|
|
@ -367,11 +367,15 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
if (c.type !== "chatMessage") return null
|
||||
|
||||
let text = c.text || ""
|
||||
let image = c.image || null
|
||||
let mimeType = typeof c.mimeType === "string" ? c.mimeType : null
|
||||
if (tribeCrypto && c.encryptedText) {
|
||||
const candidateKeys = [...tribeKeys, ...lookupKeys(chatRootId)]
|
||||
for (const keyHex of candidateKeys) {
|
||||
try {
|
||||
text = tribeCrypto.decryptWithKey(c.encryptedText, keyHex)
|
||||
if (c.encryptedImage) image = tribeCrypto.decryptWithKey(c.encryptedImage, keyHex)
|
||||
if (c.encryptedMime) mimeType = tribeCrypto.decryptWithKey(c.encryptedMime, keyHex)
|
||||
break
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -381,8 +385,8 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
key: node.key,
|
||||
chatId: c.chatId || "",
|
||||
text,
|
||||
image: c.image || null,
|
||||
mimeType: typeof c.mimeType === "string" ? c.mimeType : null,
|
||||
image,
|
||||
mimeType,
|
||||
replyTo: typeof c.replyTo === "string" ? c.replyTo : null,
|
||||
author: node.author,
|
||||
createdAt: c.createdAt || new Date(node.ts).toISOString()
|
||||
|
|
@ -962,6 +966,14 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
if (!encKey) encKey = lookupKey(chat.rootId)
|
||||
if (!encKey) throw new Error(`Missing chat key for ${chat.rootId} — cannot send message`)
|
||||
content.encryptedText = tribeCrypto.encryptWithKey(safeText(text), encKey)
|
||||
if (content.image) {
|
||||
content.encryptedImage = tribeCrypto.encryptWithKey(String(content.image), encKey)
|
||||
delete content.image
|
||||
if (content.mimeType) {
|
||||
content.encryptedMime = tribeCrypto.encryptWithKey(String(content.mimeType), encKey)
|
||||
delete content.mimeType
|
||||
}
|
||||
}
|
||||
if (chat.tribeId) content.tribeId = chat.tribeId
|
||||
} else {
|
||||
content.text = safeText(text)
|
||||
|
|
|
|||
|
|
@ -40,9 +40,15 @@ const KINDS = {
|
|||
mailing: { type: 'mailingList', href: (id) => `/mailing/${encodeURIComponent(id)}` },
|
||||
logistics: { type: 'logisticsRoute', href: (id) => `/logistics/${encodeURIComponent(id)}` },
|
||||
podcasts: { type: 'podcast', href: (id) => `/podcasts/${encodeURIComponent(id)}` },
|
||||
campaigns: { type: 'campaign', href: (id) => `/campaigns/${encodeURIComponent(id)}` }
|
||||
campaigns: { type: 'campaign', href: (id) => `/campaigns/${encodeURIComponent(id)}` },
|
||||
blogs: { type: 'post', href: (id) => `/blogs/${encodeURIComponent(id)}` }
|
||||
};
|
||||
|
||||
const isRootBlogPost = (c) =>
|
||||
!!c && c.type === 'post' && typeof c.text === 'string' &&
|
||||
!c.root && !c.fork && !c.branch && !c.about && !c.target &&
|
||||
c.private !== true && !Array.isArray(c.recps);
|
||||
|
||||
const KIND_BY_TYPE = Object.fromEntries(Object.entries(KINDS).map(([k, v]) => [v.type, k]));
|
||||
|
||||
const cvSkills = (c) => [
|
||||
|
|
@ -103,7 +109,7 @@ const opinionsTotalOf = (c) => {
|
|||
const excerpt = (s) => String(s || '').replace(/!?\[[^\]]*\]\([^)]*\)/g, ' ').replace(/[#*_>`~|]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 90);
|
||||
const titleOf = (kind, c, author) => {
|
||||
if (kind === 'inhabitants') return c.name || '';
|
||||
return c.title || c.name || c.question || c.concept || c.subject || excerpt(typeof c.text === 'string' ? c.text : '') || excerpt(typeof c.description === 'string' ? c.description : '') || '';
|
||||
return c.title || c.name || c.question || c.concept || c.subject || c.contentWarning || excerpt(typeof c.text === 'string' ? c.text : '') || excerpt(typeof c.description === 'string' ? c.description : '') || '';
|
||||
};
|
||||
|
||||
const jaccard = (a, b) => {
|
||||
|
|
@ -147,6 +153,7 @@ module.exports = ({ cooler, favoriteIdsFor = null }) => {
|
|||
continue;
|
||||
}
|
||||
if (c.tribeId && c.type !== 'tribe') continue;
|
||||
if (c.type === 'post' && !isRootBlogPost(c)) continue;
|
||||
const kind = KIND_BY_TYPE[c.type];
|
||||
if (!kind) continue;
|
||||
versions.push(versionEntry(m));
|
||||
|
|
@ -360,12 +367,20 @@ module.exports = ({ cooler, favoriteIdsFor = null }) => {
|
|||
out.push({ ...strip(n), score, common, connections: common.length, reasons });
|
||||
}
|
||||
|
||||
if (opts.hidden && (opts.hidden.authors || opts.hidden.ids)) {
|
||||
const ha = opts.hidden.authors instanceof Set ? opts.hidden.authors : new Set();
|
||||
const hi = opts.hidden.ids instanceof Set ? opts.hidden.ids : new Set();
|
||||
out = out.filter(s => !ha.has(String(s.author)) && !hi.has(String(s.id)));
|
||||
}
|
||||
const anyMatches = out.length > 0;
|
||||
const kindsAvail = {};
|
||||
for (const n of use) {
|
||||
if (String(n.author) !== String(viewerId)) kindsAvail[n.kind] = true;
|
||||
}
|
||||
|
||||
const kindsMatched = {};
|
||||
for (const s of out) kindsMatched[s.kind] = true;
|
||||
|
||||
if (f === 'RECENT') {
|
||||
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||
out = out.filter(s => s.ts >= cutoff);
|
||||
|
|
@ -393,6 +408,7 @@ module.exports = ({ cooler, favoriteIdsFor = null }) => {
|
|||
hasProfile: myTermSet.size > 0,
|
||||
myTerms: [...myTermSet],
|
||||
kindsAvail,
|
||||
kindsMatched,
|
||||
anyMatches,
|
||||
reasonsAvail,
|
||||
reason: reasonsAvail.includes(reason) ? reason : ''
|
||||
|
|
|
|||
591
src/models/karma_model.js
Normal file
591
src/models/karma_model.js
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
const pull = require("../server/node_modules/pull-stream");
|
||||
const { readTyped, CONTENT_TYPES } = require("./typed_log");
|
||||
const { getConfig } = require("../configs/config-manager.js");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
|
||||
function getLogLimit() {
|
||||
return getConfig().ssbLogStream?.limit || 1000;
|
||||
}
|
||||
|
||||
const OWN_SCORE_CAP = 200;
|
||||
const RECEIVED_AUTHOR_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const opinionCategories = require("../backend/opinion_categories");
|
||||
const CONSTRUCTIVE_OPINIONS = new Set(opinionCategories.karma.constructive);
|
||||
const MODERATION_OPINIONS = new Set(opinionCategories.karma.moderation);
|
||||
const NEUTRAL_OPINIONS = new Set(opinionCategories.karma.neutral);
|
||||
const NEGATIVE_MIN_VOTERS = 3;
|
||||
const KARMA_PERIOD_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const NEGATIVE_PATTERN_MIN_VOTERS = 5;
|
||||
const NEGATIVE_PATTERN_POINTS = 2;
|
||||
const POSITIVE_PER_VOTER_CAP = 10;
|
||||
const TRUST_MIN_FOLLOWERS = 2;
|
||||
const REACTIONS_PER_MESSAGE_CAP = 10;
|
||||
const PLAYS_PER_EPISODE_CAP = 20;
|
||||
const ACTIONS_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
function resolveUserId(maybeId) {
|
||||
const s = String(maybeId || "").trim();
|
||||
if (s) return s;
|
||||
return config?.keys?.id || "";
|
||||
}
|
||||
|
||||
function idsEqual(a, b) {
|
||||
if (!a || !b) return false;
|
||||
const A = String(a).trim();
|
||||
const B = String(b).trim();
|
||||
if (A === B) return true;
|
||||
const strip = s => s.replace(/^@/, "").replace(/\.ed25519$/, "");
|
||||
return strip(A) === strip(B);
|
||||
}
|
||||
|
||||
function inferType(c = {}) {
|
||||
if (c.vote) return "vote";
|
||||
if (c.votes) return "votes";
|
||||
if (c.address && c.coin === "ECO" && c.type === "wallet") return "bankWallet";
|
||||
if (c.type === "ubiClaimResult" && c.txid && c.epochId) return "ubiClaimResult";
|
||||
if (typeof c.amount !== "undefined" && c.epochId && c.allocationId) return "bankClaim";
|
||||
if (typeof c.item_type !== "undefined" && typeof c.status !== "undefined") return "market";
|
||||
if (typeof c.goal !== "undefined" && typeof c.progress !== "undefined") return "project";
|
||||
if (typeof c.members !== "undefined" && typeof c.isAnonymous !== "undefined") return "tribe";
|
||||
if (typeof c.date !== "undefined" && typeof c.location !== "undefined") return "event";
|
||||
if (typeof c.priority !== "undefined" && typeof c.status !== "undefined" && c.title) return "task";
|
||||
if (typeof c.confirmations !== "undefined" && typeof c.severity !== "undefined") return "report";
|
||||
if (typeof c.job_type !== "undefined" && typeof c.status !== "undefined") return "job";
|
||||
if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "audio") return "audio";
|
||||
if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "video") return "video";
|
||||
if (typeof c.url !== "undefined" && c.title && c.key) return "document";
|
||||
if (typeof c.text !== "undefined" && typeof c.refeeds !== "undefined") return "feed";
|
||||
if (typeof c.text !== "undefined" && typeof c.contentWarning !== "undefined") return "post";
|
||||
if (typeof c.contact !== "undefined") return "contact";
|
||||
if (typeof c.about !== "undefined") return "about";
|
||||
if (typeof c.concept !== "undefined" && typeof c.amount !== "undefined" && c.status) return "transfer";
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeType(a) {
|
||||
const t = a.type || a.content?.type || inferType(a.content) || "";
|
||||
return String(t).toLowerCase();
|
||||
}
|
||||
|
||||
function priorityBump(p) {
|
||||
const s = String(p || "").toUpperCase();
|
||||
if (s === "HIGH") return 3;
|
||||
if (s === "MEDIUM") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function severityBump(s) {
|
||||
const x = String(s || "").toUpperCase();
|
||||
if (x === "CRITICAL") return 6;
|
||||
if (x === "HIGH") return 4;
|
||||
if (x === "MEDIUM") return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function scoreMarket(c) {
|
||||
const st = String(c.status || "").toUpperCase();
|
||||
let s = 5;
|
||||
if (st === "SOLD") s += 8;
|
||||
else if (st === "ACTIVE") s += 3;
|
||||
const bids = Array.isArray(c.auctions_poll) ? c.auctions_poll.length : 0;
|
||||
s += Math.min(10, bids);
|
||||
return s;
|
||||
}
|
||||
|
||||
function scoreProject(c) {
|
||||
const st = String(c.status || "ACTIVE").toUpperCase();
|
||||
const prog = Number(c.progress || 0);
|
||||
let s = 8 + Math.min(10, prog / 10);
|
||||
if (st === "FUNDED") s += 10;
|
||||
return s;
|
||||
}
|
||||
|
||||
function calculateOpinionScore(content) {
|
||||
const cats = content?.opinions || {};
|
||||
let s = 0;
|
||||
for (const k in cats) {
|
||||
if (!Object.prototype.hasOwnProperty.call(cats, k)) continue;
|
||||
if (NEUTRAL_OPINIONS.has(k)) continue;
|
||||
if (k === "interesting" || k === "inspiring") s += 5;
|
||||
else if (MODERATION_OPINIONS.has(k)) s -= 5;
|
||||
else if (CONSTRUCTIVE_OPINIONS.has(k)) s -= 3;
|
||||
else s += 1;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function basePointsFor(action) {
|
||||
const t = normalizeType(action);
|
||||
const c = action.content || {};
|
||||
const rawType = String(c.type || "").toLowerCase();
|
||||
if (t === "post") return 10;
|
||||
else if (t === "comment") return 5;
|
||||
else if (t === "like") return 2;
|
||||
else if (t === "image") return 8;
|
||||
else if (t === "video") return 12;
|
||||
else if (t === "audio") return 8;
|
||||
else if (t === "document") return 6;
|
||||
else if (t === "bookmark") return 2;
|
||||
else if (t === "feed") return 6;
|
||||
else if (t === "forum") return (c.root ? 5 : 10);
|
||||
else if (t === "vote") return (3 + calculateOpinionScore(c));
|
||||
else if (t === "votes") return Math.min(10, Number(c.totalVotes || 0));
|
||||
else if (t === "market") return scoreMarket(c);
|
||||
else if (t === "project") return scoreProject(c);
|
||||
else if (t === "tribe") return (6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0));
|
||||
else if (t === "event") return (4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0));
|
||||
else if (t === "task") return (3 + priorityBump(c.priority));
|
||||
else if (t === "report") return (4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity));
|
||||
else if (t === "curriculum") return 5;
|
||||
else if (t === "wikipage") return (c.replaces ? 2 : 6);
|
||||
else if (t === "emergency") return (c.replaces ? 1 : 5);
|
||||
else if (t === "emergencyconfirm") return 1;
|
||||
else if (t === "emergencyupdate") return 2;
|
||||
else if (t === "mailinglist") return (c.replaces ? 1 : 3);
|
||||
else if (t === "logisticsroute") return (c.replaces ? 1 : 4);
|
||||
else if (t === "logisticsrating") return 1;
|
||||
else if (t === "podcast") return (c.replaces ? 1 : 5);
|
||||
else if (t === "podcastepisode") return (c.replaces ? 1 : 4);
|
||||
else if (t === "podcastopinion" || t === "podcastplay" || t === "campaignopinion" || t === "logisticsopinion") return 1;
|
||||
else if (t === "campaign") return (c.replaces ? 1 : 5);
|
||||
else if (t === "campaignsignature") return 1;
|
||||
else if (t === "campaignupdate") return 2;
|
||||
else if (t === "aiexchange") return (Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0);
|
||||
else if (t === "job") return (4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0));
|
||||
else if (t === "transfer") return 1;
|
||||
else if (t === "about") return (1 + ((c.visibilityPrefs && c.visibilityPrefs.fediverseHandle) ? 8 : 0));
|
||||
else if (t === "contact") return 1;
|
||||
else if (t === "pub") return 1;
|
||||
else if (t === "parliamentcandidature" || rawType === "parliamentcandidature") return 12;
|
||||
else if (t === "parliamentterm" || rawType === "parliamentterm") return 25;
|
||||
else if (t === "parliamentproposal" || rawType === "parliamentproposal") return 8;
|
||||
else if (t === "parliamentlaw" || rawType === "parliamentlaw") return 16;
|
||||
else if (t === "parliamentrevocation" || rawType === "parliamentrevocation") return 10;
|
||||
else if (t === "courts_case" || t === "courtscase" || rawType === "courts_case") return 4;
|
||||
else if (t === "courts_evidence" || t === "courtsevidence" || rawType === "courts_evidence") return 3;
|
||||
else if (t === "courts_answer" || t === "courtsanswer" || rawType === "courts_answer") return 4;
|
||||
else if (t === "courts_verdict" || t === "courtsverdict" || rawType === "courts_verdict") return 10;
|
||||
else if (t === "courts_settlement" || t === "courtssettlement" || rawType === "courts_settlement") return 8;
|
||||
else if (t === "courts_nomination" || t === "courtsnomination" || rawType === "courts_nomination") return 6;
|
||||
else if (t === "courts_nom_vote" || t === "courtsnomvote" || rawType === "courts_nom_vote") return 3;
|
||||
else if (t === "courts_public_pref" || t === "courtspublicpref" || rawType === "courts_public_pref") return 1;
|
||||
else if (t === "courts_mediators" || t === "courtsmediators" || rawType === "courts_mediators") return 6;
|
||||
else if (t === "courts_open_support" || t === "courtsopensupport" || rawType === "courts_open_support") return 2;
|
||||
else if (t === "courts_verdict_vote" || t === "courtsverdictvote" || rawType === "courts_verdict_vote") return 3;
|
||||
else if (t === "courts_judge_assign" || t === "courtsjudgeassign" || rawType === "courts_judge_assign") return 5;
|
||||
else if (t === "larpjoinhouse") return 15;
|
||||
else if (t === "larphousepost") return 6;
|
||||
else if (t === "larptestattempt") return 3;
|
||||
else if (t === "torrent") return 6;
|
||||
else if (t === "file") return 6;
|
||||
else if (t === "shop" || t === "shopproduct") return 6;
|
||||
else if (t === "shop-purchase") return 2;
|
||||
else if (t === "pad" || t === "padentry") return 3;
|
||||
else if (t === "room") return 3;
|
||||
else if (t === "roommember") return 1;
|
||||
else if (t === "calendar" || t === "calendarnote" || t === "calendardate") return 3;
|
||||
else if (t === "chat") return 1;
|
||||
else if (t === "gamescore") return 2;
|
||||
else if (t === "pixelia") return 2;
|
||||
else if (rawType === "industry") return 8;
|
||||
else if (rawType === "industryblueprint") return 6;
|
||||
else if (rawType === "industrybuild") return 8;
|
||||
else if (rawType === "industrycontribution") return 4;
|
||||
else if (rawType === "industryallocation") return 10;
|
||||
else if (rawType === "industryvote") return 2;
|
||||
else if (rawType === "industrymember") return 2;
|
||||
else if (rawType === "industryopinion") return 1;
|
||||
else if (rawType === "schoolcourse") return 10;
|
||||
else if (rawType === "schoollesson") return 6;
|
||||
else if (rawType === "schoolenroll") return 3;
|
||||
else if (rawType === "schoolcertificate") return 12;
|
||||
else if (rawType === "schoolexam") return 6;
|
||||
else if (rawType === "schoolprogress") return 1;
|
||||
else if (rawType === "schoolopinion") return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function decayFor(ts, nowMs) {
|
||||
const ageDays = ts ? (nowMs - ts) / 86400000 : Infinity;
|
||||
return ageDays <= 30 ? 1.0 : ageDays <= 90 ? 0.5 : 0.25;
|
||||
}
|
||||
|
||||
function scoreFromActions(actions) {
|
||||
let score = 0;
|
||||
const nowMs = Date.now();
|
||||
const perTypeDay = new Map();
|
||||
for (const action of actions) {
|
||||
const pts = basePointsFor(action);
|
||||
if (!pts) continue;
|
||||
const ts = action.value?.timestamp;
|
||||
const dayKey = `${normalizeType(action)}:${ts ? new Date(ts).toISOString().slice(0, 10) : "?"}`;
|
||||
const n = (perTypeDay.get(dayKey) || 0) + 1;
|
||||
perTypeDay.set(dayKey, n);
|
||||
score += pts * decayFor(ts, nowMs) / n;
|
||||
}
|
||||
return Math.max(0, Math.round(Math.min(OWN_SCORE_CAP, score)));
|
||||
}
|
||||
|
||||
function receivedBreakdownFor(userId, actions) {
|
||||
const nowMs = Date.now();
|
||||
const mine = new Set();
|
||||
const firstTs = new Map();
|
||||
const follows = new Map();
|
||||
for (const a of actions) {
|
||||
if (!a.author) continue;
|
||||
const ts = Number(a.receivedAt) || 0;
|
||||
if (ts && (!firstTs.has(a.author) || ts < firstTs.get(a.author))) firstTs.set(a.author, ts);
|
||||
if (a.author === userId && a.id) mine.add(a.id);
|
||||
const c = a.content || {};
|
||||
if (c.type === "contact" && typeof c.contact === "string" && c.contact !== a.author) {
|
||||
const k = `${a.author}|${c.contact}`;
|
||||
const at = Number(a.value?.timestamp) || ts;
|
||||
const prev = follows.get(k);
|
||||
if (!prev || at >= prev.at) follows.set(k, { on: c.following === true && c.blocking !== true, at });
|
||||
}
|
||||
}
|
||||
const followersOf = new Map();
|
||||
for (const [k, f] of follows) {
|
||||
if (!f.on) continue;
|
||||
const [from, to] = k.split("|");
|
||||
if (!followersOf.has(to)) followersOf.set(to, new Set());
|
||||
followersOf.get(to).add(from);
|
||||
}
|
||||
const known = (id) => firstTs.has(id) && nowMs - firstTs.get(id) >= RECEIVED_AUTHOR_MIN_AGE_MS;
|
||||
const trusted = (voter, sameSide) => {
|
||||
let n = 0;
|
||||
for (const f of followersOf.get(voter) || []) {
|
||||
if (f === userId || sameSide.has(f) || !known(f)) continue;
|
||||
if (++n >= TRUST_MIN_FOLLOWERS) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const seen = new Set();
|
||||
const perTarget = new Map();
|
||||
const negatives = new Map();
|
||||
const votes = new Map();
|
||||
const reactions = new Map();
|
||||
const credits = [];
|
||||
let positive = 0;
|
||||
let negative = 0;
|
||||
const add = (author, kind, target, pts, ts, capPerTarget = Infinity) => {
|
||||
const k = `${kind}:${author}:${target}`;
|
||||
if (seen.has(k)) return;
|
||||
const n = perTarget.get(`${kind}:${target}`) || 0;
|
||||
if (n >= capPerTarget) return;
|
||||
seen.add(k);
|
||||
perTarget.set(`${kind}:${target}`, n + 1);
|
||||
credits.push({ voter: author, pts, ts: Number(ts) || 0 });
|
||||
};
|
||||
const addNegative = (author, target, pts, ts) => {
|
||||
const k = `neg:${author}:${target}`;
|
||||
if (seen.has(k)) return;
|
||||
seen.add(k);
|
||||
if (!negatives.has(target)) negatives.set(target, []);
|
||||
negatives.get(target).push({ voter: author, pts, ts: Number(ts) || 0 });
|
||||
};
|
||||
for (const a of actions) {
|
||||
const b = a.author;
|
||||
if (!b || b === userId) continue;
|
||||
if (!firstTs.has(b) || nowMs - firstTs.get(b) < RECEIVED_AUTHOR_MIN_AGE_MS) continue;
|
||||
const c = a.content || {};
|
||||
const type = String(c.type || "");
|
||||
const ts = a.value?.timestamp;
|
||||
if (/Opinion$/.test(type) && type !== "pollOpinion" && mine.has(c.target)) {
|
||||
const cat = String(c.category || "");
|
||||
if (NEUTRAL_OPINIONS.has(cat)) continue;
|
||||
if (MODERATION_OPINIONS.has(cat)) addNegative(b, c.target, -5, ts);
|
||||
else if (CONSTRUCTIVE_OPINIONS.has(cat)) addNegative(b, c.target, -3, ts);
|
||||
else add(b, "opinion", c.target, 3, ts);
|
||||
}
|
||||
else if (type === "vote" && c.vote && mine.has(c.vote.link)) {
|
||||
const k = `${b}:${c.vote.link}`;
|
||||
const prev = votes.get(k);
|
||||
if (!prev || (Number(ts) || 0) >= prev.ts) votes.set(k, { author: b, target: c.vote.link, value: Number(c.vote.value) || 0, ts: Number(ts) || 0 });
|
||||
}
|
||||
else if (type === "feed-action" && c.action === "vote" && mine.has(c.root || c.target)) add(b, "opinion", c.root || c.target, 3, ts);
|
||||
else if (type === "feed-action" && c.action === "comment" && mine.has(c.root)) add(b, "comment", c.root, 2, ts, 10);
|
||||
else if (type === "feed-action" && c.action === "refeed" && mine.has(c.root)) add(b, "refeed", c.root, 2, ts, 10);
|
||||
else if (type === "post" && c.root && mine.has(c.root)) add(b, "comment", c.root, 2, ts, 10);
|
||||
else if (type === "contact" && c.following === true && c.contact === userId) add(b, "follow", userId, 2, ts);
|
||||
else if (type === "chatReaction" && mine.has(c.target)) {
|
||||
const k = `${b}:${c.target}:${c.emoji}`;
|
||||
const prev = reactions.get(k);
|
||||
if (!prev || (Number(ts) || 0) >= prev.ts) reactions.set(k, { author: b, target: c.target, emoji: c.emoji, on: c.on !== false, ts: Number(ts) || 0 });
|
||||
}
|
||||
else if (type === "podcastPlay" && mine.has(c.target)) add(b, "play", c.target, 1, ts, PLAYS_PER_EPISODE_CAP);
|
||||
else if (type === "marketPurchase" && mine.has(c.target)) add(b, "purchase", c.target, 5, ts);
|
||||
else if (type === "shop-purchase" && c.seller === userId) add(b, "purchase", c.productId || a.id, 5, ts);
|
||||
else if (type === "schoolEnroll" && c.value === true && mine.has(c.courseId)) add(b, "enroll", c.courseId, 5, ts);
|
||||
else if (type === "transferConfirm" && mine.has(c.target)) add(b, "confirm", c.target, 3, ts);
|
||||
}
|
||||
for (const r of reactions.values()) {
|
||||
if (!r.on) continue;
|
||||
if (r.emoji === "down") addNegative(r.author, r.target, -3, r.ts);
|
||||
else add(r.author, "reaction", r.target, 1, r.ts, REACTIONS_PER_MESSAGE_CAP);
|
||||
}
|
||||
for (const v of votes.values()) {
|
||||
if (v.value > 0) add(v.author, "opinion", v.target, 3, v.ts);
|
||||
else if (v.value < 0) addNegative(v.author, v.target, -3, v.ts);
|
||||
}
|
||||
const vouched = new Map();
|
||||
const isTrusted = (voter, side, cache) => {
|
||||
if (!cache.has(voter)) cache.set(voter, trusted(voter, side));
|
||||
return cache.get(voter);
|
||||
};
|
||||
const usedByVoter = new Map();
|
||||
for (const c of credits.sort((x, y) => x.ts - y.ts)) {
|
||||
if (!isTrusted(c.voter, new Set(), vouched)) continue;
|
||||
const k = `${c.voter}:${Math.floor(c.ts / KARMA_PERIOD_MS)}`;
|
||||
const used = usedByVoter.get(k) || 0;
|
||||
const take = Math.min(c.pts, POSITIVE_PER_VOTER_CAP - used);
|
||||
if (take <= 0) continue;
|
||||
usedByVoter.set(k, used + take);
|
||||
positive += take * decayFor(c.ts, nowMs);
|
||||
}
|
||||
const blaming = new Set();
|
||||
for (const list of negatives.values()) for (const n of list) blaming.add(n.voter);
|
||||
const vouchedAgainst = new Map();
|
||||
const pattern = new Map();
|
||||
for (const all of negatives.values()) {
|
||||
const list = all.filter(n => isTrusted(n.voter, blaming, vouchedAgainst));
|
||||
if (list.length >= NEGATIVE_MIN_VOTERS) {
|
||||
for (const n of list) negative += Math.abs(n.pts) * decayFor(n.ts, nowMs);
|
||||
continue;
|
||||
}
|
||||
for (const n of list) {
|
||||
if (!n.ts || nowMs - n.ts > KARMA_PERIOD_MS) continue;
|
||||
if ((pattern.get(n.voter) || 0) < n.ts) pattern.set(n.voter, n.ts);
|
||||
}
|
||||
}
|
||||
if (pattern.size >= NEGATIVE_PATTERN_MIN_VOTERS) {
|
||||
for (const ts of pattern.values()) negative += NEGATIVE_PATTERN_POINTS * decayFor(ts, nowMs);
|
||||
}
|
||||
positive = Math.round(positive);
|
||||
negative = Math.round(negative);
|
||||
return { score: positive - negative, positive, negative };
|
||||
}
|
||||
|
||||
function receivedScoreFor(userId, actions) {
|
||||
return receivedBreakdownFor(userId, actions).score;
|
||||
}
|
||||
|
||||
module.exports = ({ services, ranges } = {}) => {
|
||||
const normalizeRange = ranges && ranges.normalizeRange ? ranges.normalizeRange : (r) => String(r || "all");
|
||||
const widenRange = ranges && ranges.widenRange ? ranges.widenRange : (arr, r) => normalizeRange(r);
|
||||
const filterByRange = ranges && ranges.filterByRange ? ranges.filterByRange : (arr) => arr;
|
||||
|
||||
let ssbInstance;
|
||||
async function openSsb() {
|
||||
if (ssbInstance) return ssbInstance;
|
||||
if (services?.cooler?.open) ssbInstance = await services.cooler.open();
|
||||
else if (global.ssb) ssbInstance = global.ssb;
|
||||
else {
|
||||
try {
|
||||
const srv = require("../server/SSB_server.js");
|
||||
ssbInstance = srv?.ssb || srv?.server || srv?.default || null;
|
||||
} catch (_) {
|
||||
ssbInstance = null;
|
||||
}
|
||||
}
|
||||
return ssbInstance;
|
||||
}
|
||||
|
||||
let actionsCache = null;
|
||||
async function listAllActions() {
|
||||
const ssb = await openSsb();
|
||||
if (!ssb) {
|
||||
return [];
|
||||
}
|
||||
const ownSeq = await ownFeedSeq(ssb);
|
||||
if (actionsCache && actionsCache.ownSeq === ownSeq && Date.now() - actionsCache.ts < ACTIONS_CACHE_TTL_MS) return actionsCache.value;
|
||||
const msgs = await readTyped(ssb, CONTENT_TYPES, { limit: Math.max(getLogLimit(), 20000) });
|
||||
const value = msgs.map(m => {
|
||||
const v = m.value || {};
|
||||
const c = v.content || {};
|
||||
return {
|
||||
id: v.key || m.key,
|
||||
author: v.author,
|
||||
type: (c.type || "").toLowerCase(),
|
||||
value: v,
|
||||
content: c,
|
||||
receivedAt: Number(m.timestamp) || 0
|
||||
};
|
||||
});
|
||||
actionsCache = { ts: Date.now(), ownSeq, value };
|
||||
return value;
|
||||
}
|
||||
|
||||
async function ownFeedSeq(ssb) {
|
||||
return new Promise((res) => {
|
||||
try {
|
||||
pull(
|
||||
ssb.createUserStream({ id: ssb.id, reverse: true, limit: 1 }),
|
||||
pull.collect((err, msgs) => res(err || !msgs || !msgs.length ? 0 : (msgs[0].value && msgs[0].value.sequence) || 0))
|
||||
);
|
||||
} catch (_) { res(0); }
|
||||
});
|
||||
}
|
||||
|
||||
async function publishKarmaScore(userId, karmaScore) {
|
||||
const ssb = await openSsb();
|
||||
if (!ssb || !ssb.publish) return false;
|
||||
if (!(await ownFeedSeq(ssb))) return false;
|
||||
const timestamp = new Date().toISOString();
|
||||
const content = { type: "karmaScore", karmaScore, userId, timestamp };
|
||||
return new Promise((resolve, reject) => {
|
||||
ssb.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchUserActions(userId) {
|
||||
const me = resolveUserId(userId);
|
||||
const actions = await listAllActions();
|
||||
const authored = actions.filter(a =>
|
||||
(a.author && a.author === me) || (a.value?.author && a.value.author === me)
|
||||
);
|
||||
if (authored.length) return authored;
|
||||
return actions.filter(a => {
|
||||
const c = a.content || {};
|
||||
const fields = [c.author, c.organizer, c.seller, c.about, c.contact];
|
||||
return fields.some(f => f && f === me);
|
||||
});
|
||||
}
|
||||
|
||||
async function getCarbonGramsForUser(userId) {
|
||||
const ssb = await openSsb();
|
||||
if (!ssb || !userId) return 0;
|
||||
return new Promise((resolve) => {
|
||||
let bytes = 0;
|
||||
pull(
|
||||
ssb.createUserStream({ id: userId }),
|
||||
pull.drain(
|
||||
(m) => { try { bytes += Buffer.byteLength(JSON.stringify(m && m.value), 'utf8'); } catch (_) {} },
|
||||
() => resolve((bytes / (1024 * 1024)) * 0.095)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function getUserEngagementScore(userId) {
|
||||
const ssb = await openSsb();
|
||||
const uid = resolveUserId(userId);
|
||||
const actions = await fetchUserActions(uid);
|
||||
const all = await listAllActions();
|
||||
const rawKarma = scoreFromActions(actions) + receivedScoreFor(uid, all);
|
||||
const carbonGrams = await getCarbonGramsForUser(uid).catch(() => 0);
|
||||
const karmaScore = Math.max(0, Math.round(rawKarma - carbonGrams));
|
||||
|
||||
const prev = await getLastKarmaScore(uid);
|
||||
const lastPublishedTimestamp = await getLastPublishedTimestamp(uid);
|
||||
|
||||
const isSelf = idsEqual(uid, ssb.id);
|
||||
const hasSSB = !!(ssb && ssb.publish);
|
||||
|
||||
const changed = (prev === null) || (karmaScore !== prev);
|
||||
const nowMs = Date.now();
|
||||
const lastMs = lastPublishedTimestamp ? new Date(lastPublishedTimestamp).getTime() : 0;
|
||||
const cooldownOk = (nowMs - lastMs) >= 24 * 60 * 60 * 1000;
|
||||
|
||||
if (isSelf && hasSSB && changed && cooldownOk) {
|
||||
await publishKarmaScore(uid, karmaScore);
|
||||
}
|
||||
return karmaScore;
|
||||
}
|
||||
|
||||
async function getLastKarmaScore(userId) {
|
||||
const ssb = await openSsb();
|
||||
if (!ssb) return null;
|
||||
return new Promise((resolve) => {
|
||||
const source = ssb.messagesByType
|
||||
? ssb.messagesByType({ type: "karmaScore", reverse: true })
|
||||
: ssb.createLogStream && ssb.createLogStream({ reverse: true });
|
||||
if (!source) return resolve(null);
|
||||
pull(
|
||||
source,
|
||||
pull.filter(msg => {
|
||||
const v = msg.value || msg;
|
||||
const c = v.content || {};
|
||||
return c && c.type === "karmaScore" && c.userId === userId;
|
||||
}),
|
||||
pull.take(1),
|
||||
pull.collect((err, arr) => {
|
||||
if (err || !arr || !arr.length) return resolve(null);
|
||||
const v = arr[0].value || arr[0];
|
||||
const c = v.content || {};
|
||||
resolve(Number(c.karmaScore) || 0);
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function getLastPublishedTimestamp(userId) {
|
||||
const ssb = await openSsb();
|
||||
if (!ssb) return new Date(0).toISOString();
|
||||
const fallback = new Date(0).toISOString();
|
||||
return new Promise((resolve) => {
|
||||
const source = ssb.messagesByType
|
||||
? ssb.messagesByType({ type: "karmaScore", reverse: true })
|
||||
: ssb.createLogStream && ssb.createLogStream({ reverse: true });
|
||||
if (!source) return resolve(fallback);
|
||||
pull(
|
||||
source,
|
||||
pull.filter(msg => {
|
||||
const v = msg.value || msg;
|
||||
const c = v.content || {};
|
||||
return c && c.type === "karmaScore" && c.userId === userId;
|
||||
}),
|
||||
pull.take(1),
|
||||
pull.collect((err, arr) => {
|
||||
if (err || !arr || !arr.length) return resolve(fallback);
|
||||
const v = arr[0].value || arr[0];
|
||||
const c = v.content || {};
|
||||
resolve(c.timestamp || fallback);
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function getKarmaBreakdown(userId) {
|
||||
const uid = resolveUserId(userId);
|
||||
const received = receivedBreakdownFor(uid, await listAllActions());
|
||||
return { karmaScore: await getUserEngagementScore(uid), positive: received.positive, negative: received.negative };
|
||||
}
|
||||
|
||||
async function listKarmaHistory(userId, range = "all", explicit = true) {
|
||||
const ssb = await openSsb();
|
||||
if (!ssb) return { points: [], hasAny: false, range: normalizeRange(range) };
|
||||
const uid = resolveUserId(userId);
|
||||
const msgs = await readTyped(ssb, ["karmaScore"], { limit: Math.max(getLogLimit(), 5000) });
|
||||
const all = msgs
|
||||
.filter(m => m.value && m.value.author === uid && m.value.content && m.value.content.type === "karmaScore")
|
||||
.map(m => ({ ts: Date.parse(m.value.content.timestamp || "") || Number(m.value.timestamp) || 0, score: Number(m.value.content.karmaScore) || 0 }))
|
||||
.filter(x => x.ts > 0)
|
||||
.sort((a, b) => a.ts - b.ts);
|
||||
const effective = explicit ? normalizeRange(range) : widenRange(all, range);
|
||||
return { points: filterByRange(all, effective).slice(-120), hasAny: all.length >= 2, range: effective };
|
||||
}
|
||||
|
||||
return {
|
||||
listAllActions,
|
||||
fetchUserActions,
|
||||
scoreFromActions,
|
||||
receivedBreakdownFor,
|
||||
receivedScoreFor,
|
||||
getCarbonGramsForUser,
|
||||
getUserEngagementScore,
|
||||
getKarmaBreakdown,
|
||||
getLastKarmaScore,
|
||||
getLastPublishedTimestamp,
|
||||
publishKarmaScore,
|
||||
listKarmaHistory,
|
||||
basePointsFor,
|
||||
decayFor,
|
||||
NEGATIVE_MIN_VOTERS,
|
||||
NEGATIVE_PATTERN_MIN_VOTERS,
|
||||
NEGATIVE_PATTERN_POINTS,
|
||||
POSITIVE_PER_VOTER_CAP,
|
||||
KARMA_PERIOD_MS,
|
||||
TRUST_MIN_FOLLOWERS
|
||||
};
|
||||
};
|
||||
|
|
@ -624,6 +624,12 @@ models.blob = {
|
|||
};
|
||||
|
||||
models.friend = {
|
||||
blockedIds: async () => {
|
||||
const ssb = await cooler.open();
|
||||
const graph = await new Promise((resolve) => ssb.friends.graph((err, g) => resolve(err || !g ? {} : g)));
|
||||
const mine = graph[ssb.id] || {};
|
||||
return new Set(Object.keys(mine).filter(feed => Math.round(mine[feed]) === -1));
|
||||
},
|
||||
setRelationship: async ({ feedId, following, blocking }) => {
|
||||
if (following && blocking) {
|
||||
throw new Error("Cannot follow and block at the same time");
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ const { getConfig } = require('../configs/config-manager.js');
|
|||
const categories = require('../backend/opinion_categories');
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { readTyped, CONTENT_TYPES, requestScope } = require('./typed_log');
|
||||
const longText = require('../backend/long_text');
|
||||
const isRootBlogPost = (c) =>
|
||||
!!c && c.type === 'post' && typeof c.text === 'string' &&
|
||||
!c.root && !c.fork && !c.branch && !c.about && !c.target &&
|
||||
c.private !== true && !Array.isArray(c.recps);
|
||||
|
||||
const { buildVoteTally } = require('../backend/vote_tally');
|
||||
const { versionGroups, versionEntry } = require('../backend/dedupe');
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
|
|
@ -24,13 +30,14 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
const validTypes = [
|
||||
'bookmark', 'votes', 'transfer',
|
||||
'feed', 'image', 'audio', 'video', 'document', 'torrent', 'file',
|
||||
'feed', 'post', 'image', 'audio', 'video', 'document', 'torrent', 'file',
|
||||
'industry', 'project', 'report', 'task', 'event', 'shopProduct', 'housing', 'market', 'schoolCourse',
|
||||
'podcast', 'podcastEpisode', 'campaign', 'logisticsRoute'
|
||||
];
|
||||
|
||||
const getPreview = c => {
|
||||
if (c.type === 'bookmark' && c.bookmark) return `🔖 ${c.bookmark}`;
|
||||
if (c.type === 'post') return c.contentWarning || c.text || '';
|
||||
return c.text || c.description || c.title || c.name || '';
|
||||
};
|
||||
|
||||
|
|
@ -83,6 +90,18 @@ module.exports = ({ cooler }) => {
|
|||
if (c.type === 'feed' && !byId.has(key)) {
|
||||
byId.set(key, { key, value: { ...msg.value, content: c, preview: getPreview(c) } });
|
||||
}
|
||||
if (isRootBlogPost(c) && !byId.has(key)) {
|
||||
byId.set(key, { key, value: { ...msg.value, content: c, preview: getPreview(c) } });
|
||||
}
|
||||
}
|
||||
|
||||
const chunkLookup = longText.lookupIn(longText.indexChunks(messages));
|
||||
for (const [key, m] of Array.from(byId.entries())) {
|
||||
const c = m.value?.content;
|
||||
if (c?.type === 'post' && longText.hasChunks(c)) {
|
||||
const content = longText.resolveField(c, 'text', m.value.author, chunkLookup);
|
||||
byId.set(key, { key, value: { ...m.value, content, preview: getPreview(content) } });
|
||||
}
|
||||
}
|
||||
|
||||
const vg = versionGroups(Array.from(byId.values()).map(versionEntry));
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ const isDead = ({ key, failures = 0, now = Date.now() } = {}) => {
|
|||
return Math.max(observed, Number(failures) * RETRY_CAP_MS) >= DEAD_AFTER_MS;
|
||||
};
|
||||
|
||||
const failingSince = (key) => Number((load()[canonicalKey(key)] || {}).failingSince) || 0;
|
||||
|
||||
const addressOf = (item) => {
|
||||
const m = Array.isArray(item) ? String(item[0] || '').match(/^(?:net|onion):([^:~]+):(\d+)/) : null;
|
||||
if (m) return `${m[1]}:${m[2]}`;
|
||||
|
|
@ -100,4 +102,4 @@ const NET_REASON_KEYS = {
|
|||
notfound: 'peerErrNotFound', unreachable: 'peerErrUnreachable', timeout: 'peerErrTimeout', dropped: 'peerErrDropped', other: 'peerErrOther', silent: 'peerErrSilent'
|
||||
};
|
||||
|
||||
module.exports = { canonicalKey, observe, isDead, failuresByKey, classifyNetError, NET_REASON_KEYS, DEAD_AFTER_MS };
|
||||
module.exports = { canonicalKey, observe, isDead, failingSince, failuresByKey, classifyNetError, NET_REASON_KEYS, DEAD_AFTER_MS };
|
||||
|
|
|
|||
|
|
@ -27,18 +27,41 @@ const resolvePixels = (messages) => {
|
|||
}
|
||||
|
||||
const replaced = new Set();
|
||||
for (const { m, coordinateKey } of byKey.values()) {
|
||||
const parentOf = (k) => {
|
||||
const { m, coordinateKey } = byKey.get(k);
|
||||
const prev = byKey.get(m.value.content.replaces);
|
||||
if (prev && prev.coordinateKey === coordinateKey) replaced.add(prev.m.key);
|
||||
return prev && prev.coordinateKey === coordinateKey ? prev.m.key : null;
|
||||
};
|
||||
for (const k of byKey.keys()) {
|
||||
const parent = parentOf(k);
|
||||
if (parent) replaced.add(parent);
|
||||
}
|
||||
|
||||
const depthOf = new Map();
|
||||
const depth = (key) => {
|
||||
const chain = [];
|
||||
const inChain = new Set();
|
||||
let k = key;
|
||||
while (k && !depthOf.has(k) && !inChain.has(k)) {
|
||||
chain.push(k);
|
||||
inChain.add(k);
|
||||
k = parentOf(k);
|
||||
}
|
||||
let d = k && depthOf.has(k) ? depthOf.get(k) + 1 : 0;
|
||||
for (let i = chain.length - 1; i >= 0; i--) depthOf.set(chain[i], d++);
|
||||
return depthOf.get(key);
|
||||
};
|
||||
|
||||
const latest = new Map();
|
||||
for (const [k, { m, coordinateKey }] of byKey) {
|
||||
if (replaced.has(k) || tombstoned.has(k)) continue;
|
||||
const cur = latest.get(coordinateKey);
|
||||
if (!cur) { latest.set(coordinateKey, m); continue; }
|
||||
const d = depth(k);
|
||||
const curD = depth(cur.key);
|
||||
const ts = m.value.timestamp || 0;
|
||||
const curTs = cur ? cur.value.timestamp || 0 : 0;
|
||||
if (!cur || ts > curTs || (ts === curTs && k > cur.key)) latest.set(coordinateKey, m);
|
||||
const curTs = cur.value.timestamp || 0;
|
||||
if (d > curD || (d === curD && (ts > curTs || (ts === curTs && k > cur.key)))) latest.set(coordinateKey, m);
|
||||
}
|
||||
|
||||
return latest;
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ const normalizeTags = (raw) => {
|
|||
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
|
||||
}
|
||||
const normalizeStatus = (v) => (STATUSES.includes(String(v || "").toUpperCase()) ? String(v).toUpperCase() : "OPEN")
|
||||
const blobRef = (v) => (BLOB_ID.test(safeText(v)) ? safeText(v) : "")
|
||||
const SEALED_REF = /^\/blob\/sealed\/%26[A-Za-z0-9%]+\.sha256\?k=[0-9a-f]{64}&t=[^\s"<>]*$/
|
||||
const blobRef = (v) => (BLOB_ID.test(safeText(v)) || SEALED_REF.test(safeText(v)) ? safeText(v) : "")
|
||||
const asP = (fn, ...args) => new Promise((resolve, reject) => fn(...args, (err, v) => err ? reject(err) : resolve(v)))
|
||||
const rpcValue = (fn, ...args) => new Promise((resolve) => {
|
||||
try {
|
||||
|
|
@ -352,6 +353,8 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => {
|
|||
return { title: "", description: "", image: "", tags: [], rid: "", _undec: true }
|
||||
}
|
||||
|
||||
const roomTypeOf = (c) => c.status === "INVITE-ONLY" || (c.status === "CLOSED" && c.encrypted === true && !c.tribeId) ? "INVITE-ONLY" : "OPEN"
|
||||
|
||||
const buildRoom = (node, rootId, tribeKeys, members) => {
|
||||
const c = node.c || {}
|
||||
if (c.type !== "room") return null
|
||||
|
|
@ -368,7 +371,7 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => {
|
|||
image: f.image,
|
||||
tags: f.tags,
|
||||
status: ["OPEN", "INVITE-ONLY", "CLOSED"].includes(c.status) ? c.status : "OPEN",
|
||||
type: c.status === "INVITE-ONLY" || (c.status === "CLOSED" && c.encrypted === true && !c.tribeId) ? "INVITE-ONLY" : "OPEN",
|
||||
type: roomTypeOf(c),
|
||||
author,
|
||||
members: Array.isArray(members) ? members : (Array.isArray(c.members) ? c.members : []),
|
||||
invites: Array.isArray(c.invites) ? c.invites : [],
|
||||
|
|
@ -555,6 +558,10 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => {
|
|||
return republish(id, (c) => ({ ...c, status: "CLOSED" }))
|
||||
},
|
||||
|
||||
async reopenRoomById(id) {
|
||||
return republish(id, (c) => ({ ...c, status: roomTypeOf(c) }))
|
||||
},
|
||||
|
||||
async setRoomLine(id, line) {
|
||||
return republish(id, (c) => ({ ...c, line: line === "SWITCHBOARD" ? "SWITCHBOARD" : "DND" }))
|
||||
},
|
||||
|
|
@ -782,6 +789,38 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => {
|
|||
return asP(ph.camera, !!flag)
|
||||
},
|
||||
|
||||
async stream(opts) {
|
||||
const ph = await phone()
|
||||
if (!ph || typeof ph.stream !== "function") return null
|
||||
return asP(ph.stream, opts || {})
|
||||
},
|
||||
|
||||
async viewerJoin(room) {
|
||||
const ph = await phone()
|
||||
if (!ph || typeof ph.viewerJoin !== "function") return null
|
||||
const params = await this.liveParams(room)
|
||||
if (!params || params.secrets) return null
|
||||
return asP(ph.viewerJoin, params)
|
||||
},
|
||||
|
||||
async viewerLeave(rid) {
|
||||
const ph = await phone()
|
||||
if (!ph || typeof ph.viewerLeave !== "function") return null
|
||||
return asP(ph.viewerLeave, { rid: String(rid || "") })
|
||||
},
|
||||
|
||||
async viewerRooms() {
|
||||
const ph = await phone()
|
||||
if (!ph || typeof ph.viewerRooms !== "function") return []
|
||||
const list = await rpcValue(ph.viewerRooms)
|
||||
return Array.isArray(list) ? list : []
|
||||
},
|
||||
|
||||
async publicFeed(rid, kind) {
|
||||
const ph = await phone()
|
||||
return ph && typeof ph.publicFeed === "function" ? ph.publicFeed({ rid: String(rid || ""), kind: kind === "sound" ? "sound" : "grid" }) : null
|
||||
},
|
||||
|
||||
async recStart() {
|
||||
const ph = await phone()
|
||||
if (!ph || typeof ph.roomRecStart !== "function") return null
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ const { getConfig } = require('../configs/config-manager.js');
|
|||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { readTyped, CONTENT_TYPES } = require('./typed_log');
|
||||
const { versionGroups, versionEntry } = require('../backend/dedupe');
|
||||
const opinionCategories = require('../backend/opinion_categories');
|
||||
const CONSTRUCTIVE_OPINIONS = new Set(opinionCategories.karma.constructive);
|
||||
const MODERATION_OPINIONS = new Set(opinionCategories.karma.moderation);
|
||||
const NEUTRAL_OPINIONS = new Set(opinionCategories.karma.neutral);
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
const DISK_USAGE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
|
|
@ -223,8 +227,10 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
let s = 0;
|
||||
for (const k in cats) {
|
||||
if (!Object.prototype.hasOwnProperty.call(cats, k)) continue;
|
||||
if (NEUTRAL_OPINIONS.has(k)) continue;
|
||||
if (k === 'interesting' || k === 'inspiring') s += 5;
|
||||
else if (k === 'boring' || k === 'spam' || k === 'propaganda') s -= 3;
|
||||
else if (MODERATION_OPINIONS.has(k)) s -= 5;
|
||||
else if (CONSTRUCTIVE_OPINIONS.has(k)) s -= 3;
|
||||
else s += 1;
|
||||
}
|
||||
return s;
|
||||
|
|
|
|||
|
|
@ -107,8 +107,10 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
|
|||
};
|
||||
|
||||
return {
|
||||
async listTags(filter = 'all', search = '') {
|
||||
async listTags(filter = 'all', search = '', opts = {}) {
|
||||
const ssbClient = await openSsb();
|
||||
const hiddenAuthors = opts.hiddenAuthors instanceof Set ? opts.hiddenAuthors : null;
|
||||
const hiddenIds = opts.hiddenIds instanceof Set ? opts.hiddenIds : null;
|
||||
|
||||
const messages = await readTyped(ssbClient, CONTENT_TYPES, { limit: logLimit, withWindow: true });
|
||||
|
||||
|
|
@ -140,6 +142,8 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
|
|||
let filtered = Array.from(latestByKey.values()).filter(msg => {
|
||||
const c = msg?.value?.content;
|
||||
if (!c || c.type === 'tombstone') return false;
|
||||
if (hiddenAuthors && hiddenAuthors.has(String(msg?.value?.author || ''))) return false;
|
||||
if (hiddenIds && hiddenIds.has(msg.key)) 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;
|
||||
|
|
@ -185,6 +189,8 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
|
|||
const viewerId = ssbClient2.id;
|
||||
const pads = await padsModel.listAll({ filter: 'all', viewerId }).catch(() => []);
|
||||
for (const pad of pads) {
|
||||
if (hiddenAuthors && hiddenAuthors.has(String(pad.author || ''))) continue;
|
||||
if (hiddenIds && (hiddenIds.has(pad.id) || hiddenIds.has(pad.key) || hiddenIds.has(pad.rootId))) continue;
|
||||
if (pad.status === 'INVITE-ONLY' && pad.author !== viewerId && !(Array.isArray(pad.members) && pad.members.includes(viewerId))) continue;
|
||||
if (!Array.isArray(pad.tags)) continue;
|
||||
const uniquePadTags = new Set(pad.tags.map(tagKey).filter(Boolean));
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
|||
const opinionCategories = require('../backend/opinion_categories');
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { readTyped, CONTENT_TYPES, requestScope } = require('./typed_log');
|
||||
const longText = require('../backend/long_text');
|
||||
const isRootBlogPost = (c) =>
|
||||
!!c && c.type === 'post' && typeof c.text === 'string' &&
|
||||
!c.root && !c.fork && !c.branch && !c.about && !c.target &&
|
||||
c.private !== true && !Array.isArray(c.recps);
|
||||
|
||||
const { buildVoteTally } = require('../backend/vote_tally');
|
||||
const { versionGroups, versionEntry } = require('../backend/dedupe');
|
||||
|
||||
|
|
@ -21,7 +27,7 @@ module.exports = ({ cooler }) => {
|
|||
};
|
||||
|
||||
const types = [
|
||||
'bookmark', 'votes', 'poll', 'feed',
|
||||
'bookmark', 'votes', 'poll', 'feed', 'post',
|
||||
'image', 'audio', 'video', 'document', 'torrent', 'file', 'transfer',
|
||||
'industry', 'project', 'report', 'task', 'event', 'shopProduct', 'housing', 'market', 'schoolCourse',
|
||||
'podcast', 'podcastEpisode', 'campaign', 'logisticsRoute'
|
||||
|
|
@ -52,6 +58,15 @@ module.exports = ({ cooler }) => {
|
|||
if (c.opinions && !['task', 'event', 'report'].includes(c.type)) itemsById.set(k, m);
|
||||
|
||||
if (c.type === 'feed' && !itemsById.has(k)) itemsById.set(k, m);
|
||||
if (isRootBlogPost(c) && !itemsById.has(k)) itemsById.set(k, m);
|
||||
}
|
||||
|
||||
const chunkLookup = longText.lookupIn(longText.indexChunks(messages));
|
||||
for (const [k, m] of Array.from(itemsById.entries())) {
|
||||
const c = m.value?.content;
|
||||
if (c?.type === 'post' && longText.hasChunks(c)) {
|
||||
itemsById.set(k, { ...m, value: { ...m.value, content: longText.resolveField(c, 'text', m.value.author, chunkLookup) } });
|
||||
}
|
||||
}
|
||||
|
||||
const vg = versionGroups(Array.from(itemsById.values()).map(versionEntry));
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const longText = require('../backend/long_text');
|
|||
const { versionGroups } = require('../backend/dedupe');
|
||||
const TRIBE_LOG_TYPES = ['tribe', 'tribe-msg', 'tribe-keys-distrib', 'tribe-invite-msg', 'tribe-invite-tombstone', 'tribe-open-invite-tombstone', 'tombstone', 'contact'];
|
||||
|
||||
const VALID_CONTENT_TYPES = ['event', 'task', 'report', 'votation', 'forum', 'forum-reply', 'market', 'job', 'project', 'media', 'feed', 'pixelia'];
|
||||
const VALID_CONTENT_TYPES = ['event', 'task', 'report', 'votation', 'forum', 'forum-reply', 'market', 'job', 'project', 'media', 'feed'];
|
||||
const categories = require('../backend/opinion_categories');
|
||||
const VALID_STATUSES = ['OPEN', 'CLOSED', 'IN-PROGRESS'];
|
||||
const VALID_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
|
||||
|
|
|
|||
|
|
@ -21,9 +21,74 @@ const loadState = () => {
|
|||
};
|
||||
const saveState = (s) => writeJson(statePath, s);
|
||||
|
||||
const hiddenPath = require('../configs/state-manager').statePath('viewer_hidden.json');
|
||||
let hiddenState = null;
|
||||
const loadHidden = () => {
|
||||
if (!hiddenState) {
|
||||
const raw = readJson(hiddenPath, {});
|
||||
const muted = Array.isArray(raw && raw.muted) ? raw.muted : [];
|
||||
const hidden = Array.isArray(raw && raw.hidden) ? raw.hidden : [];
|
||||
hiddenState = {
|
||||
muted: muted.map(x => (typeof x === 'string' ? { id: x, at: 0 } : x)).filter(x => x && typeof x.id === 'string'),
|
||||
hidden: hidden.filter(x => x && typeof x.id === 'string')
|
||||
};
|
||||
}
|
||||
return hiddenState;
|
||||
};
|
||||
const saveHidden = () => writeJson(hiddenPath, loadHidden());
|
||||
|
||||
const mutedList = () => loadHidden().muted.slice();
|
||||
const mutedIds = () => new Set(loadHidden().muted.map(x => x.id));
|
||||
const isMuted = (id) => !!id && loadHidden().muted.some(x => x.id === id);
|
||||
const mute = (id) => {
|
||||
const s = loadHidden();
|
||||
if (!id || s.muted.some(x => x.id === id)) return false;
|
||||
s.muted.push({ id, at: Date.now() });
|
||||
saveHidden();
|
||||
return true;
|
||||
};
|
||||
const unmute = (id) => {
|
||||
const s = loadHidden();
|
||||
const before = s.muted.length;
|
||||
s.muted = s.muted.filter(x => x.id !== id);
|
||||
if (s.muted.length !== before) saveHidden();
|
||||
return s.muted.length !== before;
|
||||
};
|
||||
|
||||
const hiddenList = () => loadHidden().hidden.slice();
|
||||
const hiddenIds = () => new Set(loadHidden().hidden.map(x => x.id));
|
||||
const isHidden = (id) => !!id && loadHidden().hidden.some(x => x.id === id);
|
||||
const hideContent = ({ id, href = '', label = '' } = {}) => {
|
||||
const s = loadHidden();
|
||||
if (!id || s.hidden.some(x => x.id === id)) return false;
|
||||
s.hidden.push({ id, href: String(href || '').slice(0, 300), label: String(label || '').slice(0, 120), at: Date.now() });
|
||||
saveHidden();
|
||||
return true;
|
||||
};
|
||||
const unhideContent = (id) => {
|
||||
const s = loadHidden();
|
||||
const before = s.hidden.length;
|
||||
s.hidden = s.hidden.filter(x => x.id !== id);
|
||||
if (s.hidden.length !== before) saveHidden();
|
||||
return s.hidden.length !== before;
|
||||
};
|
||||
|
||||
const idsOfItem = (item) => {
|
||||
if (!item || typeof item !== 'object') return [];
|
||||
return [item.id, item.key, item.rootId, item.favId].filter(v => typeof v === 'string');
|
||||
};
|
||||
const isItemHidden = (item, authorOf, { authors, ids } = {}) => {
|
||||
if (!item) return false;
|
||||
const a = typeof authorOf === 'function' ? authorOf(item) : null;
|
||||
if (a && authors && authors.has(String(a))) return true;
|
||||
if (ids && ids.size && idsOfItem(item).some(id => ids.has(id))) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const lanPeersPath = require('../configs/state-manager').statePath('lan-peers.json');
|
||||
const FEED_ID = /^@[A-Za-z0-9+/]{43}=\.ed25519$/;
|
||||
let lanKnown = null;
|
||||
const LAN_SEEN_STEP_MS = 60 * 1000;
|
||||
let lanSeen = null;
|
||||
|
||||
const canonicalFeedId = (key) => {
|
||||
let k = String(key || '').trim();
|
||||
|
|
@ -62,22 +127,41 @@ const lanKeysFromConn = (entries) => {
|
|||
return out;
|
||||
};
|
||||
|
||||
const lanPeers = () => {
|
||||
if (!lanKnown) {
|
||||
const lanPeersSeen = () => {
|
||||
if (!lanSeen) {
|
||||
const list = readJson(lanPeersPath, []);
|
||||
lanKnown = new Set((Array.isArray(list) ? list : []).map(canonicalFeedId).filter(Boolean));
|
||||
lanSeen = new Map();
|
||||
for (const item of (Array.isArray(list) ? list : [])) {
|
||||
const id = canonicalFeedId(typeof item === 'string' ? item : item && item.key);
|
||||
if (id) lanSeen.set(id, Number(item && item.seenAt) || 0);
|
||||
}
|
||||
}
|
||||
return lanKnown;
|
||||
return lanSeen;
|
||||
};
|
||||
|
||||
const rememberLanPeers = (keys) => {
|
||||
const set = lanPeers();
|
||||
const lanPeers = () => new Set(lanPeersSeen().keys());
|
||||
|
||||
const rememberLanPeers = (keys, now = Date.now()) => {
|
||||
const seen = lanPeersSeen();
|
||||
let changed = false;
|
||||
for (const k of keys || []) {
|
||||
const id = canonicalFeedId(k);
|
||||
if (id && !set.has(id)) { set.add(id); changed = true; }
|
||||
if (!id) continue;
|
||||
const prev = seen.get(id);
|
||||
if (prev === undefined || now - prev >= LAN_SEEN_STEP_MS) { seen.set(id, now); changed = true; }
|
||||
}
|
||||
if (changed) writeJson(lanPeersPath, [...set]);
|
||||
if (changed) writeJson(lanPeersPath, [...seen].map(([key, seenAt]) => ({ key, seenAt })));
|
||||
return changed;
|
||||
};
|
||||
|
||||
const forgetLanPeers = (keys) => {
|
||||
const seen = lanPeersSeen();
|
||||
let changed = false;
|
||||
for (const k of keys || []) {
|
||||
const id = canonicalFeedId(k);
|
||||
if (id && seen.delete(id)) changed = true;
|
||||
}
|
||||
if (changed) writeJson(lanPeersPath, [...seen].map(([key, seenAt]) => ({ key, seenAt })));
|
||||
return changed;
|
||||
};
|
||||
|
||||
|
|
@ -104,7 +188,8 @@ const filterByWish = async (items, { wish, viewer, authorOf: author, isOwn, isMu
|
|||
|
||||
const wishMutualsOnly = () => getConfig().wish === 'mutuals';
|
||||
const pmMutualsOnly = () => getConfig().pmVisibility === 'mutuals';
|
||||
const isFrictionActive = () => wishMutualsOnly() || pmMutualsOnly();
|
||||
const pmFollowingOnly = () => getConfig().pmVisibility === 'following';
|
||||
const isFrictionActive = () => wishMutualsOnly() || pmMutualsOnly() || pmFollowingOnly();
|
||||
|
||||
const listPending = () => loadState().pending;
|
||||
const enqueuePending = (followerId, extra = {}) => {
|
||||
|
|
@ -183,8 +268,13 @@ const applyMutualSupportFilter = async (items, viewerId, friendModel) => {
|
|||
};
|
||||
|
||||
const canSendPmTo = async (viewerId, recipientId, friendModel) => {
|
||||
if (!pmMutualsOnly()) return { allowed: true };
|
||||
if (viewerId === recipientId) return { allowed: true };
|
||||
if (pmFollowingOnly()) {
|
||||
let rel = null;
|
||||
try { rel = friendModel && typeof friendModel.getRelationship === 'function' ? await friendModel.getRelationship(recipientId) : null; } catch (_) { rel = null; }
|
||||
return rel && rel.following ? { allowed: true } : { allowed: false, reason: 'not-following' };
|
||||
}
|
||||
if (!pmMutualsOnly()) return { allowed: true };
|
||||
const isMutual = makeMutualCache(friendModel);
|
||||
if (await isMutual(recipientId)) return { allowed: true };
|
||||
return { allowed: false, reason: 'non-mutual' };
|
||||
|
|
@ -194,6 +284,7 @@ module.exports = {
|
|||
COOLDOWN_MS,
|
||||
wishMutualsOnly,
|
||||
pmMutualsOnly,
|
||||
pmFollowingOnly,
|
||||
isFrictionActive,
|
||||
listPending,
|
||||
enqueuePending,
|
||||
|
|
@ -211,6 +302,19 @@ module.exports = {
|
|||
isPrivateHost,
|
||||
lanKeysFromConn,
|
||||
lanPeers,
|
||||
lanPeersSeen,
|
||||
rememberLanPeers,
|
||||
forgetLanPeers,
|
||||
filterByWish,
|
||||
mutedList,
|
||||
mutedIds,
|
||||
isMuted,
|
||||
mute,
|
||||
unmute,
|
||||
hiddenList,
|
||||
hiddenIds,
|
||||
isHidden,
|
||||
hideContent,
|
||||
unhideContent,
|
||||
isItemHidden,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ const Server = SecretStack({ caps })
|
|||
.use(require('ssb-unix-socket'))
|
||||
.use(require('ssb-no-auth'))
|
||||
.use(require('./snapshot_plugin'))
|
||||
.use((() => { const pm = require('./phone_module'); try { if (process.env.OASIS_MOBILE === '1') { pm.setAudioFactory(require('./phone_audio_android')); pm.setVideoFactory(require('./phone_video_android')); } } catch (_) {} return pm; })())
|
||||
.use((() => { const pm = require('./phone_module'); try { if (process.env.OASIS_MOBILE === '1') { pm.setAudioFactory(require('./phone_audio_android')); pm.setVideoFactory(require('./phone_video_android')); pm.setStreamFactory(require('./phone_stream_android')); } } catch (_) {} return pm; })())
|
||||
.use(require('./network_pause'));
|
||||
|
||||
if (!config.pub) {
|
||||
|
|
|
|||
2
src/server/package-lock.json
generated
2
src/server/package-lock.json
generated
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@krakenslab/oasis",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@krakenslab/oasis",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.3",
|
||||
"description": "Oasis - Social Networking Utopia",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const RING_PER_WINDOW = 20;
|
|||
const SEEN_MAX = 5000;
|
||||
const HUB_ROOMS_MAX = 256;
|
||||
const HUB_RIDS_PER_ROOM = 4;
|
||||
const GROUP_MAX = 7;
|
||||
const GROUP_MAX = 10;
|
||||
const MIX_QUEUE = 10;
|
||||
const ROOM_MAX = 50;
|
||||
const ROOM_QUIET_AT = 10;
|
||||
|
|
@ -59,12 +59,18 @@ const K_VIDEO = 4;
|
|||
const VIDEO_CHUNK = 8 * 1024;
|
||||
const VIDEO_PAD = 1024;
|
||||
const VIDEO_HDR = 6;
|
||||
const VIDEO_FILM = 0x80;
|
||||
const VIDEO_FRAME_MAX = 256 * 1024;
|
||||
const VIDEO_FPS = 8;
|
||||
const VIDEO_SIZE = { w: 320, h: 240 };
|
||||
const STREAM_SIZE = { w: 640, h: 480 };
|
||||
const GRID_TILE_LARGE = { w: 640, h: 480 };
|
||||
const GRID_LARGE_MAX = 4;
|
||||
const ROOM_VIDEO_CHUNK_EXTRA = 45;
|
||||
const VIDEO_HOLD_MS = 3000;
|
||||
const ROOM_VIDEO_FRAME_MAX = 10 * 1024;
|
||||
const ROOM_VIDEO_BYTES_PER_S = 200 * 1024;
|
||||
const ROOM_VIDEO_SEND_MAX = Math.floor(ROOM_VIDEO_BYTES_PER_S * 0.9);
|
||||
const VAD_MIN = 200;
|
||||
const VAD_FLOOR_MAX = 1500;
|
||||
const VAD_HANG = 15;
|
||||
|
|
@ -215,7 +221,71 @@ const systemVideo = (server, device) => {
|
|||
};
|
||||
let videoFactory = systemVideo;
|
||||
|
||||
const videoChunks = (seq, jpeg) => {
|
||||
const hasBin = (bin) => (path.isAbsolute(String(bin || '')) ? fs.existsSync(bin) : onPath(bin));
|
||||
const gstQuote = (s) => `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
const STREAM_BACKENDS = [
|
||||
{
|
||||
bin: () => process.env.OASIS_FFMPEG || 'ffmpeg',
|
||||
retryWithoutAudio: true,
|
||||
args: (file, withAudio) => [
|
||||
'-loglevel', 'quiet', '-protocol_whitelist', 'file,crypto', '-re', '-i', file,
|
||||
'-map', '0:v:0', '-vf', `fps=${VIDEO_FPS},scale=${STREAM_SIZE.w}:${STREAM_SIZE.h}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${STREAM_SIZE.w}:${STREAM_SIZE.h}:(ow-iw)/2:(oh-ih)/2`,
|
||||
'-c:v', 'mjpeg', '-q:v', '9', '-f', 'image2pipe', 'pipe:1',
|
||||
...(withAudio ? ['-map', '0:a:0', '-ac', '1', '-ar', String(RATE), '-f', 's16le', 'pipe:3'] : [])
|
||||
]
|
||||
},
|
||||
{
|
||||
bin: () => process.env.OASIS_GST_LAUNCH || 'gst-launch-1.0',
|
||||
args: (file) => [
|
||||
'-q', 'filesrc', `location=${gstQuote(file)}`, '!', 'decodebin', 'name=d',
|
||||
'd.', '!', 'queue', '!', 'videoconvert', '!', 'videoscale', 'add-borders=true', '!', `video/x-raw,width=${STREAM_SIZE.w},height=${STREAM_SIZE.h},pixel-aspect-ratio=1/1`, '!', 'videorate', '!', `video/x-raw,framerate=${VIDEO_FPS}/1`, '!', 'jpegenc', 'quality=50', '!', 'fdsink', 'fd=1', 'sync=true',
|
||||
'd.', '!', 'queue', '!', 'audioconvert', '!', 'audioresample', '!', `audio/x-raw,format=S16LE,rate=${RATE},channels=1`, '!', 'fdsink', 'fd=3', 'sync=true', 'async=false'
|
||||
]
|
||||
}
|
||||
];
|
||||
const STREAM_STALL_MS = 5000;
|
||||
const STREAM_START_MS = 20000;
|
||||
const systemStream = () => {
|
||||
const backend = STREAM_BACKENDS.find(b => hasBin(b.bin()));
|
||||
if (!backend) return null;
|
||||
return {
|
||||
open(file, { onJpeg, onPcm, onEnd }) {
|
||||
let proc = null;
|
||||
let frames = 0;
|
||||
let lastAt = 0;
|
||||
let ended = false;
|
||||
const startedAt = Date.now();
|
||||
const watch = setInterval(() => {
|
||||
const now = Date.now();
|
||||
if (frames ? now - lastAt > STREAM_STALL_MS : now - startedAt > STREAM_START_MS) finish();
|
||||
}, 1000);
|
||||
if (watch.unref) watch.unref();
|
||||
const halt = () => { ended = true; clearInterval(watch); try { if (proc) proc.kill('SIGTERM'); } catch (_) {} };
|
||||
const finish = () => { if (ended) return; halt(); onEnd(); };
|
||||
const run = (withAudio) => {
|
||||
const p = spawn(backend.bin(), backend.args(file, withAudio), { stdio: ['ignore', 'pipe', 'ignore', 'pipe'] });
|
||||
proc = p;
|
||||
p.on('error', () => {});
|
||||
p.stdout.on('error', () => {});
|
||||
p.stdout.on('data', jpegSplitter((jpeg) => { if (ended || proc !== p) return; frames++; lastAt = Date.now(); onJpeg(jpeg); }));
|
||||
if (p.stdio[3]) {
|
||||
p.stdio[3].on('error', () => {});
|
||||
p.stdio[3].on('data', (pcm) => { if (!ended && proc === p) onPcm(pcm); });
|
||||
}
|
||||
p.on('close', () => {
|
||||
if (ended || proc !== p) return;
|
||||
if (withAudio && !frames && backend.retryWithoutAudio) return run(false);
|
||||
finish();
|
||||
});
|
||||
};
|
||||
run(true);
|
||||
return { stop() { if (!ended) halt(); } };
|
||||
}
|
||||
};
|
||||
};
|
||||
let streamFactory = systemStream;
|
||||
|
||||
const videoChunks = (seq, jpeg, track = 0) => {
|
||||
const n = Math.ceil(jpeg.length / VIDEO_CHUNK);
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
|
|
@ -223,7 +293,7 @@ const videoChunks = (seq, jpeg) => {
|
|||
const plain = Buffer.alloc(Math.ceil((VIDEO_HDR + data.length) / VIDEO_PAD) * VIDEO_PAD);
|
||||
plain.writeUInt16BE(seq & 0xffff, 0);
|
||||
plain[2] = i;
|
||||
plain[3] = n;
|
||||
plain[3] = n | (track ? VIDEO_FILM : 0);
|
||||
plain.writeUInt16BE(data.length, 4);
|
||||
data.copy(plain, VIDEO_HDR);
|
||||
nodeCrypto.randomFillSync(plain, VIDEO_HDR + data.length);
|
||||
|
|
@ -231,12 +301,13 @@ const videoChunks = (seq, jpeg) => {
|
|||
}
|
||||
return out;
|
||||
};
|
||||
const videoOff = () => { const plain = Buffer.alloc(VIDEO_PAD); nodeCrypto.randomFillSync(plain, VIDEO_HDR); return plain; };
|
||||
const videoOff = (track = 0) => { const plain = Buffer.alloc(VIDEO_PAD); plain[3] = track ? VIDEO_FILM : 0; nodeCrypto.randomFillSync(plain, VIDEO_HDR); return plain; };
|
||||
const videoTrack = (plain) => (Buffer.isBuffer(plain) && plain.length >= VIDEO_HDR && (plain[3] & VIDEO_FILM) ? 1 : 0);
|
||||
const videoAssembler = () => {
|
||||
let cur = null;
|
||||
return (plain) => {
|
||||
if (!Buffer.isBuffer(plain) || plain.length < VIDEO_HDR) return null;
|
||||
const seq = plain.readUInt16BE(0), idx = plain[2], n = plain[3], len = plain.readUInt16BE(4);
|
||||
const seq = plain.readUInt16BE(0), idx = plain[2], n = plain[3] & ~VIDEO_FILM, len = plain.readUInt16BE(4);
|
||||
if (!n) { cur = null; return 'off'; }
|
||||
if (idx >= n || VIDEO_HDR + len > plain.length || n * VIDEO_CHUNK > VIDEO_FRAME_MAX) return null;
|
||||
if (!cur || cur.seq !== seq) cur = { seq, n, parts: new Array(n), got: 0 };
|
||||
|
|
@ -418,7 +489,8 @@ module.exports = {
|
|||
version: '1.0.0',
|
||||
manifest: {
|
||||
ring: 'async', answer: 'async', hangup: 'async', audio: 'duplex', relay: 'async', relayAudio: 'duplex', video: 'duplex', relayVideo: 'duplex',
|
||||
call: 'async', accept: 'async', reject: 'async', end: 'async', mute: 'async', silence: 'async', dismiss: 'async', camera: 'async', videoFrames: 'source',
|
||||
call: 'async', accept: 'async', reject: 'async', end: 'async', mute: 'async', silence: 'async', dismiss: 'async', camera: 'async', stream: 'async', videoFrames: 'source',
|
||||
viewerJoin: 'async', viewerLeave: 'async', viewerRooms: 'sync', publicFeed: 'source',
|
||||
recordStop: 'async', recordCancel: 'async',
|
||||
roomHub: 'duplex', roomInfo: 'async', roomAdmits: 'async', roomJoin: 'async', roomLeave: 'async', roomMute: 'async', roomHand: 'async', roomCount: 'async',
|
||||
roomRecStart: 'async', roomRecStop: 'async', roomNotify: 'async', roomClearEvents: 'async',
|
||||
|
|
@ -453,6 +525,7 @@ module.exports = {
|
|||
const roomMax = Number(policy().roomMax) > 0 ? Math.min(ROOM_MAX, Number(policy().roomMax)) : ROOM_MAX;
|
||||
const roomQuietAt = Number(policy().roomQuietAt) > 0 ? Number(policy().roomQuietAt) : ROOM_QUIET_AT;
|
||||
const video = videoFactory(server, policy().videoDevice);
|
||||
const streamer = streamFactory(server);
|
||||
const videoAllowed = () => policy().video !== false;
|
||||
const videoOn = () => !!video && videoAllowed();
|
||||
const codecsOffered = () => [...(OpusScript && policy().opus !== false ? ['opus'] : []), ...(videoOn() ? ['mjpeg'] : [])];
|
||||
|
|
@ -568,11 +641,14 @@ module.exports = {
|
|||
};
|
||||
|
||||
const MOBILE_STATE = path.join(__dirname, '..', '..', '.phone_state.json');
|
||||
const writeMobileState = (s) => {
|
||||
let mobileCall = null;
|
||||
let mobileRoom = null;
|
||||
const writeMobileState = () => {
|
||||
if (process.env.OASIS_MOBILE !== '1') return;
|
||||
try { fs.writeFileSync(MOBILE_STATE, JSON.stringify(s || null)); } catch (_) {}
|
||||
const s = mobileCall || mobileRoom ? { ...(mobileCall || {}), room: mobileRoom ? { phase: mobileRoom.phase, cam: !!mobileRoom.cam } : null } : null;
|
||||
try { fs.writeFileSync(MOBILE_STATE, JSON.stringify(s)); } catch (_) {}
|
||||
};
|
||||
writeMobileState(null);
|
||||
writeMobileState();
|
||||
const emit = (ev) => { for (const p of subscribers) p.push(ev); };
|
||||
const snapshot = () => call ? {
|
||||
id: call.id, peer: call.peer, dir: call.dir, phase: call.phase,
|
||||
|
|
@ -582,7 +658,7 @@ module.exports = {
|
|||
video: !!call.video, cam: !!call.cam, peerCam: !!call.peerCam, videoAvailable: videoOn(),
|
||||
...(call.group ? { group: true, peers: call.legs.map(l => ({ id: l.peer, phase: l.phase, reached: !!l.reached, silenced: silenced.has(l.peer) })) } : {})
|
||||
} : null;
|
||||
const changed = () => { const s = snapshot(); writeMobileState(s); emit({ type: 'state', state: s }); };
|
||||
const changed = () => { const s = snapshot(); mobileCall = s; writeMobileState(); emit({ type: 'state', state: s }); };
|
||||
const stopTone = (c) => { if (c.tone) { c.tone.stop(); c.tone = null; } };
|
||||
const stopRecorder = (c) => { clearTimeout(c.recDelay); clearTimeout(c.recWarn); if (c.recorder) { c.recorder.stop(); c.recorder = null; } clearTimeout(c.recTimer); };
|
||||
const startRecording = (c) => {
|
||||
|
|
@ -946,7 +1022,8 @@ module.exports = {
|
|||
if (slot !== from) deliver(m, frame, droppable);
|
||||
}
|
||||
};
|
||||
const hubAttach = (rid, id, out, scope = null) => {
|
||||
const hubHumans = (h) => [...h.members.values()].filter(m => !m.viewer).length;
|
||||
const hubAttach = (rid, id, out, scope = null, viewer = false) => {
|
||||
let r = hubRooms.get(rid);
|
||||
if (!r) {
|
||||
if (hubRooms.size >= HUB_ROOMS_MAX) return null;
|
||||
|
|
@ -960,8 +1037,8 @@ module.exports = {
|
|||
if (r.members.size >= roomMax) return null;
|
||||
let slot = 0;
|
||||
while (r.members.has(slot)) slot++;
|
||||
const peers = [...r.members].map(([s, m]) => ({ slot: s, id: m.id }));
|
||||
const m = { id, out, sent: 0, acked: 0 };
|
||||
const peers = [...r.members].map(([s, m]) => (m.viewer ? { slot: s, id: m.id, viewer: true } : { slot: s, id: m.id }));
|
||||
const m = { id, out, sent: 0, acked: 0, viewer: !!viewer };
|
||||
m.leave = () => {
|
||||
if (r.members.get(slot) !== m) return;
|
||||
r.members.delete(slot);
|
||||
|
|
@ -1002,7 +1079,7 @@ module.exports = {
|
|||
};
|
||||
r.members.set(slot, m);
|
||||
deliver(m, ctrlFrame({ t: 'hello', slot, peers, max: roomMax, video: policy().video !== false }), false);
|
||||
hubBroadcast(r, slot, ctrlFrame({ t: 'join', slot, id }), false);
|
||||
hubBroadcast(r, slot, ctrlFrame(viewer ? { t: 'join', slot, id, viewer: true } : { t: 'join', slot, id }), false);
|
||||
return m;
|
||||
};
|
||||
const hubDuplex = (from, opts) => {
|
||||
|
|
@ -1022,7 +1099,7 @@ module.exports = {
|
|||
}
|
||||
const admit = () => {
|
||||
if (closed) return;
|
||||
member = hubAttach(rid, from, out, roomId ? `${opts.owner}|${roomId}` : null);
|
||||
member = hubAttach(rid, from, out, roomId ? `${opts.owner}|${roomId}` : null, opts.viewer === true);
|
||||
if (!member) refuse('full');
|
||||
};
|
||||
if (opts.owner === server.id || from === server.id) admit();
|
||||
|
|
@ -1030,24 +1107,29 @@ module.exports = {
|
|||
return { source: out, sink };
|
||||
};
|
||||
|
||||
const viewers = new Map();
|
||||
const active = (r) => !!r && (r === room || viewers.get(r.rid) === r);
|
||||
const touched = (r) => { if (r === room) roomChanged(); };
|
||||
const humansIn = (r) => [...r.peers.values()].filter(p => !p.viewer).length;
|
||||
const roomSnapshot = () => {
|
||||
const r = room;
|
||||
if (!r) return null;
|
||||
const now = Date.now();
|
||||
return {
|
||||
rid: r.rid, ref: r.ref, title: r.title, phase: r.phase, joinedAt: r.joinedAt, muted: !!r.muted, hand: !!r.hand, handSince: r.hand ? r.handAt : 0,
|
||||
secure: !!r.kid, count: r.peers.size + 1, max: r.max, speaking: now - r.sentAt < 1000,
|
||||
secure: !!r.kid, count: humansIn(r) + 1, max: r.max, public: [...r.peers.values()].some(p => p.viewer), speaking: now - r.sentAt < 1000,
|
||||
cam: !!r.cam, videoAvailable: videoOn() && r.hubVideo === true, grid: !!sharpLib, gridOrder: gridIds(r),
|
||||
streaming: r.stream ? r.stream.name : null, streamAvailable: fileStreamOn() && r.hubVideo === true,
|
||||
notify: !!r.notify, recording: !!r.rec, recordingSince: r.rec ? r.rec.at : 0, recordingBy: [...r.recPeers],
|
||||
events: r.notify ? r.events.slice(-ROOM_EVENTS_MAX) : [],
|
||||
peers: [...r.peers.values()].map(p => ({ id: p.id, speaking: now - p.heardAt < 1000, muted: now - p.mutedAt < MUTE_TTL_MS, hand: now - (p.handBeat || 0) < MUTE_TTL_MS, handSince: p.handSince || 0, silenced: silenced.has(p.id), recording: r.recPeers.has(p.id), cam: now - (p.frameAt || 0) < VIDEO_HOLD_MS }))
|
||||
peers: [...r.peers.values()].map(p => ({ id: p.id, speaking: now - p.heardAt < 1000, muted: now - p.mutedAt < MUTE_TTL_MS, hand: now - (p.handBeat || 0) < MUTE_TTL_MS, handSince: p.handSince || 0, silenced: silenced.has(p.id), recording: r.recPeers.has(p.id), cam: now - (p.frameAt || 0) < VIDEO_HOLD_MS, film: now - (p.filmAt || 0) < VIDEO_HOLD_MS, viewer: !!p.viewer }))
|
||||
};
|
||||
};
|
||||
const roomChanged = () => emit({ type: 'room', room: roomSnapshot() });
|
||||
const roomChanged = () => { const r = roomSnapshot(); mobileRoom = r; writeMobileState(); emit({ type: 'room', room: r }); };
|
||||
const pushEvent = (r, t, id) => {
|
||||
r.events.push({ t, id, ts: Date.now() });
|
||||
if (r.events.length > ROOM_EVENTS_MAX) r.events.splice(0, r.events.length - ROOM_EVENTS_MAX);
|
||||
roomChanged();
|
||||
touched(r);
|
||||
};
|
||||
const startRoomRec = (r) => {
|
||||
if (!r || r.rec || r.phase !== 'live') return false;
|
||||
|
|
@ -1102,8 +1184,8 @@ module.exports = {
|
|||
const sealed = seal({ rid: r.rid, kid: r.kid, key: b64(key), from: server.id, ts, sig: signParts(keys, ['roomkey', r.rid, r.kid, p.id, ts]) }, p.id);
|
||||
if (sealed) r.out.push(Buffer.concat([Buffer.from([K_KEY, slot]), Buffer.from(sealed)]));
|
||||
};
|
||||
const peerEntry = (id) => ({ id, queue: [], openers: new Map(), heardAt: 0, mutedAt: 0, dec: null, opus: false, greeted: false });
|
||||
const dropPeer = (r, slot) => { const p = r.peers.get(slot); if (p) { if (p.dec) p.dec.free(); clearTimeout(p.vtimer); endFrames(p.id); gridNote(p.id, null); } r.peers.delete(slot); };
|
||||
const peerEntry = (id, viewer = false) => ({ id, viewer: !!viewer, queue: [], openers: new Map(), heardAt: 0, mutedAt: 0, dec: null, opus: false, greeted: false });
|
||||
const dropPeer = (r, slot) => { const p = r.peers.get(slot); if (p) { if (p.dec) p.dec.free(); clearTimeout(p.vtimer); clearTimeout(p.ftimer); endFrames(r.tag + p.id); endFrames(r.tag + filmKey(p.id)); gridNote(r, p.id, null); gridNote(r, filmKey(p.id), null); } r.peers.delete(slot); };
|
||||
const sendCodecs = (r) => { if (r.out && r.sealer && codecsOffered().length) sendRoomData(r, { t: 'codecs', codecs: codecsOffered() }); };
|
||||
const onRoomCtrl = (r, msg) => {
|
||||
if (msg.t === 'hello' && r.slot === null) {
|
||||
|
|
@ -1112,7 +1194,7 @@ module.exports = {
|
|||
r.hubVideo = msg.video === true;
|
||||
for (const p of Array.isArray(msg.peers) ? msg.peers : []) {
|
||||
if (!p || !isFeedId(p.id)) continue;
|
||||
r.peers.set(Number(p.slot), peerEntry(p.id));
|
||||
r.peers.set(Number(p.slot), peerEntry(p.id, p.viewer === true));
|
||||
r.order.push(Number(p.slot));
|
||||
}
|
||||
r.order.push(r.slot);
|
||||
|
|
@ -1123,10 +1205,10 @@ module.exports = {
|
|||
} else if (msg.t === 'join' && isFeedId(msg.id)) {
|
||||
const slot = Number(msg.slot);
|
||||
dropPeer(r, slot);
|
||||
r.peers.set(slot, peerEntry(msg.id));
|
||||
r.peers.set(slot, peerEntry(msg.id, msg.viewer === true));
|
||||
r.order = r.order.filter(s => s !== slot).concat(slot);
|
||||
if (!r.static && keeperSlot(r) === r.slot) sendRoomKey(r, slot);
|
||||
if (r.notify) playChime(audio, 'join');
|
||||
if (r.notify && msg.viewer !== true) playChime(audio, 'join');
|
||||
pushEvent(r, 'join', msg.id);
|
||||
} else if (msg.t === 'leave') {
|
||||
const slot = Number(msg.slot);
|
||||
|
|
@ -1146,7 +1228,7 @@ module.exports = {
|
|||
} else if (msg.t === 'full' || msg.t === 'refused') {
|
||||
if (r.ready) return r.ready(new Error(msg.t));
|
||||
}
|
||||
roomChanged();
|
||||
touched(r);
|
||||
};
|
||||
const onRoomKey = (r, slot, payload) => {
|
||||
const p = r.peers.get(slot);
|
||||
|
|
@ -1157,7 +1239,7 @@ module.exports = {
|
|||
const key = Buffer.from(String(msg.key || ''), 'base64');
|
||||
if (key.length !== 32) return;
|
||||
useKey(r, msg.kid, key);
|
||||
roomChanged();
|
||||
touched(r);
|
||||
};
|
||||
const openRoomFrame = (r, p, payload) => {
|
||||
if (!p || payload.length < 44) return null;
|
||||
|
|
@ -1187,11 +1269,11 @@ module.exports = {
|
|||
const noteMute = (r, p, on, quiet = false) => {
|
||||
if (!!p.muted === on) return;
|
||||
p.muted = on;
|
||||
if (quiet) return roomChanged();
|
||||
if (quiet) return touched(r);
|
||||
const now = Date.now();
|
||||
const announce = now - (p.muteToggleAt || 0) >= REC_TOGGLE_MIN_MS;
|
||||
p.muteToggleAt = now;
|
||||
if (!announce) return roomChanged();
|
||||
if (!announce) return touched(r);
|
||||
if (r.notify) playChime(audio, on ? 'mute' : 'unmute');
|
||||
pushEvent(r, on ? 'mute' : 'unmute', p.id);
|
||||
};
|
||||
|
|
@ -1200,10 +1282,10 @@ module.exports = {
|
|||
p.hand = on;
|
||||
const now = Date.now();
|
||||
p.handSince = on ? (quiet && Number.isFinite(at) && at > 0 ? Math.min(at, now) : now) : 0;
|
||||
if (!on || quiet) return roomChanged();
|
||||
if (!on || quiet) return touched(r);
|
||||
const announce = now - (p.handToggleAt || 0) >= REC_TOGGLE_MIN_MS;
|
||||
p.handToggleAt = now;
|
||||
if (!announce) return roomChanged();
|
||||
if (!announce) return touched(r);
|
||||
if (r.notify) playChime(audio, 'hand');
|
||||
pushEvent(r, 'hand', p.id);
|
||||
};
|
||||
|
|
@ -1226,7 +1308,7 @@ module.exports = {
|
|||
p.recToggleAt = now;
|
||||
if (msg.on) r.recPeers.add(p.id); else r.recPeers.delete(p.id);
|
||||
if (announce) { playChime(audio, msg.on ? 'recStart' : 'recStop'); pushEvent(r, msg.on ? 'recStart' : 'recStop', p.id); }
|
||||
else roomChanged();
|
||||
else touched(r);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.t === 'hand') {
|
||||
|
|
@ -1240,64 +1322,146 @@ module.exports = {
|
|||
};
|
||||
const gridIds = (r) => {
|
||||
const now = Date.now();
|
||||
const ids = r.cam ? [server.id] : [];
|
||||
for (const slot of r.order) { const p = r.peers.get(slot); if (p && now - (p.frameAt || 0) < VIDEO_HOLD_MS) ids.push(p.id); }
|
||||
const ids = [];
|
||||
if (r.cam) ids.push(server.id);
|
||||
if (r.stream) ids.push(filmKey(server.id));
|
||||
for (const slot of r.order) {
|
||||
const p = r.peers.get(slot);
|
||||
if (!p) continue;
|
||||
if (now - (p.frameAt || 0) < VIDEO_HOLD_MS) ids.push(p.id);
|
||||
if (now - (p.filmAt || 0) < VIDEO_HOLD_MS) ids.push(filmKey(p.id));
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
const gridFrames = new Map();
|
||||
let gridBusy = false;
|
||||
let gridDirty = false;
|
||||
let gridTimer = null;
|
||||
const newGrid = () => ({ frames: new Map(), busy: false, dirty: false, timer: null });
|
||||
const composeGrid = async (r) => {
|
||||
const ids = gridIds(r).filter(id => gridFrames.has(id));
|
||||
if (!ids.length) return;
|
||||
const g = r.grid;
|
||||
const ids = gridIds(r).filter(id => g.frames.has(id));
|
||||
if (!ids.length) {
|
||||
if (r.tag && !g.empty) { g.empty = true; const b = await blankFrame(); if (b && active(r)) publishFrame(r.tag + 'grid', b); }
|
||||
return;
|
||||
}
|
||||
g.empty = false;
|
||||
if (ids.length === 1 && ids[0] !== server.id) { if (active(r)) publishFrame(r.tag + 'grid', g.frames.get(ids[0])); return; }
|
||||
const size = ids.length <= GRID_LARGE_MAX ? GRID_TILE_LARGE : VIDEO_SIZE;
|
||||
const cols = Math.ceil(Math.sqrt(ids.length));
|
||||
const rows = Math.ceil(ids.length / cols);
|
||||
const tiles = [];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
try {
|
||||
let tile = sharpLib(gridFrames.get(ids[i])).resize(VIDEO_SIZE.w, VIDEO_SIZE.h, { fit: 'cover' });
|
||||
let tile = sharpLib(g.frames.get(ids[i])).resize(size.w, size.h, { fit: 'contain', background: '#000000' });
|
||||
if (ids[i] === server.id) tile = tile.flop();
|
||||
tiles.push({ input: await tile.toBuffer(), left: (i % cols) * VIDEO_SIZE.w, top: Math.floor(i / cols) * VIDEO_SIZE.h });
|
||||
tiles.push({ input: await tile.toBuffer(), left: (i % cols) * size.w, top: Math.floor(i / cols) * size.h });
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!tiles.length) return;
|
||||
const jpeg = await sharpLib({ create: { width: cols * VIDEO_SIZE.w, height: rows * VIDEO_SIZE.h, channels: 3, background: '#000000' } }).composite(tiles).jpeg({ quality: 70 }).toBuffer();
|
||||
if (room === r) publishFrame('grid', jpeg);
|
||||
const jpeg = await sharpLib({ create: { width: cols * size.w, height: rows * size.h, channels: 3, background: '#000000' } }).composite(tiles).jpeg({ quality: 80 }).toBuffer();
|
||||
if (active(r)) publishFrame(r.tag + 'grid', jpeg);
|
||||
};
|
||||
const gridTick = () => {
|
||||
const r = room;
|
||||
if (!r || !sharpLib || gridBusy || !gridDirty || !frameWatchers.has('grid')) return;
|
||||
gridDirty = false;
|
||||
gridBusy = true;
|
||||
composeGrid(r).catch(() => {}).then(() => { gridBusy = false; });
|
||||
const gridTick = (r) => {
|
||||
const g = r && r.grid;
|
||||
if (!g || !active(r) || !sharpLib || g.busy || !g.dirty || !frameWatchers.has(r.tag + 'grid')) return;
|
||||
g.dirty = false;
|
||||
g.busy = true;
|
||||
composeGrid(r).catch(() => {}).then(() => { g.busy = false; });
|
||||
};
|
||||
const gridNote = (id, jpeg) => { if (jpeg) gridFrames.set(id, jpeg); else gridFrames.delete(id); gridDirty = true; };
|
||||
const stopGrid = () => { clearInterval(gridTimer); gridTimer = null; gridFrames.clear(); gridDirty = false; endFrames('grid'); };
|
||||
const gridNote = (r, id, jpeg) => { if (!r || !r.grid) return; if (jpeg) r.grid.frames.set(id, jpeg); else r.grid.frames.delete(id); r.grid.dirty = true; };
|
||||
const startGrid = (r) => { if (!sharpLib || !r.grid || r.grid.timer) return; r.grid.timer = setInterval(() => gridTick(r), Math.round(1000 / VIDEO_FPS)); if (r.grid.timer.unref) r.grid.timer.unref(); };
|
||||
const stopGrid = (r) => { if (!r || !r.grid) return; clearInterval(r.grid.timer); r.grid.timer = null; r.grid.frames.clear(); r.grid.dirty = false; endFrames(r.tag + 'grid'); };
|
||||
const onRoomVideo = (r, slot, payload) => {
|
||||
const p = r.peers.get(slot);
|
||||
if (!videoAllowed()) return;
|
||||
const plain = openRoomFrame(r, p, payload);
|
||||
if (!plain) return;
|
||||
if (!p.vasm) p.vasm = videoAssembler();
|
||||
const got = p.vasm(plain);
|
||||
if (got === 'off') { clearTimeout(p.vtimer); if (p.frameAt) { p.frameAt = 0; endFrames(p.id); gridNote(p.id, null); roomChanged(); } return; }
|
||||
const film = videoTrack(plain) === 1;
|
||||
const key = film ? filmKey(p.id) : p.id;
|
||||
const at = film ? 'filmAt' : 'frameAt';
|
||||
const timer = film ? 'ftimer' : 'vtimer';
|
||||
const asm = film ? 'fasm' : 'vasm';
|
||||
if (!p[asm]) p[asm] = videoAssembler();
|
||||
const got = p[asm](plain);
|
||||
if (got === 'off') { clearTimeout(p[timer]); if (p[at]) { p[at] = 0; endFrames(r.tag + key); gridNote(r, key, null); touched(r); } return; }
|
||||
if (!got) return;
|
||||
const fresh = !(Date.now() - (p.frameAt || 0) < VIDEO_HOLD_MS);
|
||||
p.frameAt = Date.now();
|
||||
clearTimeout(p.vtimer);
|
||||
p.vtimer = setTimeout(() => { if (room === r) { endFrames(p.id); gridNote(p.id, null); roomChanged(); } }, VIDEO_HOLD_MS);
|
||||
if (p.vtimer.unref) p.vtimer.unref();
|
||||
const hold = setTimeout(() => { if (room !== r || !(Date.now() - (p.frameAt || 0) < VIDEO_HOLD_MS)) return; publishFrame(p.id, got); gridNote(p.id, got); }, p.queue.length * FRAME_MS);
|
||||
const fresh = !(Date.now() - (p[at] || 0) < VIDEO_HOLD_MS);
|
||||
p[at] = Date.now();
|
||||
clearTimeout(p[timer]);
|
||||
p[timer] = setTimeout(() => { if (active(r)) { endFrames(r.tag + key); gridNote(r, key, null); touched(r); } }, VIDEO_HOLD_MS);
|
||||
if (p[timer].unref) p[timer].unref();
|
||||
const hold = setTimeout(() => { if (!active(r) || !(Date.now() - (p[at] || 0) < VIDEO_HOLD_MS)) return; publishFrame(r.tag + key, got); gridNote(r, key, got); }, p.queue.length * FRAME_MS);
|
||||
if (hold.unref) hold.unref();
|
||||
if (fresh) roomChanged();
|
||||
if (fresh) touched(r);
|
||||
};
|
||||
const sendRoomVideo = (r, plain) => {
|
||||
if (!r.sealer || !r.hubVideo) return;
|
||||
r.out.push(Buffer.concat([Buffer.from([K_VIDEO]), Buffer.from(r.kid, 'hex'), r.sealer(plain)]));
|
||||
};
|
||||
const fileStreamOn = () => videoAllowed() && !!streamer;
|
||||
const streamFile = (file) => {
|
||||
const raw = String(file || '');
|
||||
if (!path.isAbsolute(raw)) return null;
|
||||
try { const full = fs.realpathSync(raw); return fs.statSync(full).isFile() ? full : null; } catch (_) { return null; }
|
||||
};
|
||||
const filmKey = (id) => `film:${id}`;
|
||||
const videoBudget = (r, chunks) => {
|
||||
const bytes = chunks.reduce((n, c) => n + c.length + ROOM_VIDEO_CHUNK_EXTRA, 0);
|
||||
const now = Date.now();
|
||||
const sent = r.vsent || (r.vsent = []);
|
||||
while (sent.length && now - sent[0][0] >= 1000) sent.shift();
|
||||
if (sent.reduce((n, e) => n + e[1], 0) + bytes > ROOM_VIDEO_SEND_MAX) return false;
|
||||
sent.push([now, bytes]);
|
||||
return true;
|
||||
};
|
||||
const sendRoomJpeg = (r, seq, jpeg, track = 0) => {
|
||||
const chunks = videoChunks(seq, jpeg, track);
|
||||
if (!videoBudget(r, chunks)) return;
|
||||
for (const plain of chunks) sendRoomVideo(r, plain);
|
||||
};
|
||||
const stopRoomStream = (r, notify = true) => {
|
||||
const s = r && r.stream;
|
||||
if (!s) return false;
|
||||
r.stream = null;
|
||||
try { if (s.handle) s.handle.stop(); } catch (_) {}
|
||||
endFrames('film:me');
|
||||
gridNote(r, filmKey(server.id), null);
|
||||
if (room === r) sendRoomVideo(r, videoOff(1));
|
||||
if (notify) roomChanged();
|
||||
return true;
|
||||
};
|
||||
const startRoomStream = (r, file, name) => {
|
||||
if (!r || r.phase !== 'live' || !r.hubVideo || !fileStreamOn()) return false;
|
||||
stopRoomStream(r, false);
|
||||
const s = { name: String(name || '').slice(0, 120), pcm: Buffer.alloc(0), seq: 0, handle: null };
|
||||
r.stream = s;
|
||||
try {
|
||||
s.handle = streamer.open(file, {
|
||||
onJpeg: (jpeg) => {
|
||||
if (r.stream !== s || room !== r || !Buffer.isBuffer(jpeg) || jpeg.length > VIDEO_FRAME_MAX) return;
|
||||
s.seq = (s.seq + 1) & 0xffff;
|
||||
publishFrame('film:me', jpeg);
|
||||
gridNote(r, filmKey(server.id), jpeg);
|
||||
sendRoomJpeg(r, s.seq, jpeg, 1);
|
||||
},
|
||||
onPcm: (pcm) => {
|
||||
if (r.stream !== s || !pcm || !pcm.length) return;
|
||||
s.pcm = s.pcm.length ? Buffer.concat([s.pcm, pcm]) : Buffer.from(pcm);
|
||||
const drop = (s.pcm.length - RATE * 2) & ~1;
|
||||
if (drop > 0) s.pcm = s.pcm.subarray(drop);
|
||||
},
|
||||
onEnd: () => { if (r.stream === s) stopRoomStream(r); }
|
||||
});
|
||||
} catch (_) { s.handle = null; }
|
||||
if (!s.handle || r.stream !== s) { if (r.stream === s) r.stream = null; return false; }
|
||||
return true;
|
||||
};
|
||||
const streamFrame = (r) => {
|
||||
const s = r.stream;
|
||||
if (!s || s.pcm.length < FRAME_BYTES) return null;
|
||||
const f = s.pcm.subarray(0, FRAME_BYTES);
|
||||
s.pcm = s.pcm.subarray(FRAME_BYTES);
|
||||
return f;
|
||||
};
|
||||
const onRoomFrame = (r, frame) => {
|
||||
if (room !== r) return;
|
||||
if (!active(r)) return;
|
||||
const buf = Buffer.isBuffer(frame) ? frame : Buffer.from(frame || []);
|
||||
if (!buf.length) return;
|
||||
r.received = (r.received || 0) + 1;
|
||||
|
|
@ -1327,6 +1491,8 @@ module.exports = {
|
|||
pending = pending.subarray(FRAME_BYTES);
|
||||
const acc = new Int32Array(samples);
|
||||
for (const p of r.peers.values()) { const f = p.queue.shift(); if (f && !silenced.has(p.id)) mixInto(acc, f); }
|
||||
const film = streamFrame(r);
|
||||
if (film) mixInto(acc, film);
|
||||
player.write(clampPcm(acc));
|
||||
if (r.rec) {
|
||||
const both = Int32Array.from(acc);
|
||||
|
|
@ -1339,11 +1505,14 @@ module.exports = {
|
|||
const voiced = gate(mic);
|
||||
if (r.muted && r.sealer && Date.now() - (r.beaconAt || 0) >= MUTE_BEACON_MS) sendRoomData(r, { t: 'mute', on: true, beacon: true });
|
||||
if (r.hand && r.sealer && Date.now() - (r.handBeaconAt || 0) >= MUTE_BEACON_MS) { sendRoomData(r, { t: 'hand', on: true, at: r.handAt, beacon: true }); r.handBeaconAt = Date.now(); }
|
||||
if (r.muted || !r.sealer || !voiced) continue;
|
||||
const talk = !r.muted && voiced;
|
||||
if (!r.sealer || (!talk && !film)) continue;
|
||||
r.sentAt = Date.now();
|
||||
let send = mic;
|
||||
if (film) { const mix = new Int32Array(samples); mixInto(mix, film); if (talk) mixInto(mix, mic); send = clampPcm(mix); }
|
||||
const opus = codecsOffered().includes('opus') && r.peers.size > 0 && [...r.peers.values()].every(p => p.opus);
|
||||
if (opus && !r.enc) r.enc = makeEncoder('opus');
|
||||
r.out.push(Buffer.concat([Buffer.from([K_AUDIO]), Buffer.from(r.kid, 'hex'), r.sealer(opus ? r.enc.encode(mic) : ulawEncode(mic))]));
|
||||
r.out.push(Buffer.concat([Buffer.from([K_AUDIO]), Buffer.from(r.kid, 'hex'), r.sealer(opus ? r.enc.encode(send) : ulawEncode(send))]));
|
||||
}
|
||||
});
|
||||
r.media = { stop() { capture.stop(); player.stop(); } };
|
||||
|
|
@ -1359,9 +1528,10 @@ module.exports = {
|
|||
stopRoomRec(r);
|
||||
room = null;
|
||||
clearTimeout(r.timer);
|
||||
stopRoomStream(r, false);
|
||||
if (r.cam) { r.cam = false; stopCam(); }
|
||||
for (const p of r.peers.values()) { clearTimeout(p.vtimer); endFrames(p.id); }
|
||||
stopGrid();
|
||||
for (const p of r.peers.values()) { clearTimeout(p.vtimer); clearTimeout(p.ftimer); endFrames(p.id); endFrames(filmKey(p.id)); }
|
||||
stopGrid(r);
|
||||
if (r.media) r.media.stop();
|
||||
if (r.enc) { r.enc.free(); r.enc = null; }
|
||||
for (const p of r.peers.values()) if (p.dec) p.dec.free();
|
||||
|
|
@ -1370,13 +1540,57 @@ module.exports = {
|
|||
if (outcome) emit({ type: 'roomEnded', room: { rid: r.rid, ref: r.ref, title: r.title, joinedAt: r.joinedAt, endedAt: Date.now(), outcome } });
|
||||
roomChanged();
|
||||
};
|
||||
const openHub = (opts, req, attach) => {
|
||||
if (opts.hub === server.id) return attach(hubDuplex(server.id, req));
|
||||
const viaRpc = (rpc) => attach(rpc && rpc.phone && typeof rpc.phone.roomHub === 'function' ? rpc.phone.roomHub(req, () => {}) : null);
|
||||
const live = liveRpc(opts.hub);
|
||||
if (live) return viaRpc(live);
|
||||
if (typeof opts.address === 'string' && opts.address) {
|
||||
try {
|
||||
return server.conn.connect(opts.address, (err, rpc) => {
|
||||
const got = !err && rpc && rpc.phone ? rpc : liveRpc(opts.hub);
|
||||
return got ? viaRpc(got) : reach(opts.hub, viaRpc);
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
reach(opts.hub, viaRpc);
|
||||
};
|
||||
let blank = null;
|
||||
const blankFrame = async () => {
|
||||
if (blank || !sharpLib) return blank;
|
||||
try { blank = await sharpLib({ create: { width: GRID_TILE_LARGE.w, height: GRID_TILE_LARGE.h, channels: 3, background: '#000000' } }).jpeg().toBuffer(); } catch (_) { blank = null; }
|
||||
return blank;
|
||||
};
|
||||
const soundKey = (r) => r.tag + 'sound';
|
||||
const dropViewer = (r) => {
|
||||
if (!r || viewers.get(r.rid) !== r) return;
|
||||
viewers.delete(r.rid);
|
||||
clearTimeout(r.timer);
|
||||
clearInterval(r.mixTimer);
|
||||
stopGrid(r);
|
||||
endFrames(soundKey(r));
|
||||
for (const p of r.peers.values()) { if (p.dec) p.dec.free(); clearTimeout(p.vtimer); clearTimeout(p.ftimer); }
|
||||
try { r.out.push(ctrlFrame({ t: 'bye' })); } catch (_) {}
|
||||
try { r.out.end(); } catch (_) {}
|
||||
};
|
||||
const startViewerMedia = (r) => {
|
||||
const samples = FRAME_BYTES / 2;
|
||||
r.mixTimer = setInterval(() => {
|
||||
if (viewers.get(r.rid) !== r) return;
|
||||
const acc = new Int32Array(samples);
|
||||
for (const p of r.peers.values()) { const f = p.queue.shift(); if (f) mixInto(acc, f); }
|
||||
if (frameWatchers.has(soundKey(r))) publishFrame(soundKey(r), clampPcm(acc));
|
||||
if (r.sealer && Date.now() - (r.beaconAt || 0) >= MUTE_BEACON_MS) sendRoomData(r, { t: 'mute', on: true, beacon: true });
|
||||
}, FRAME_MS);
|
||||
if (r.mixTimer.unref) r.mixTimer.unref();
|
||||
};
|
||||
const staticKey = (rid, secret) => {
|
||||
const key = Buffer.from(sodium.crypto_hash_sha256(Buffer.concat([Buffer.from('oasis-room|'), Buffer.from(rid, 'hex'), Buffer.from(String(secret))])));
|
||||
return { kid: Buffer.from(sodium.crypto_hash_sha256(key)).subarray(0, 4).toString('hex'), key };
|
||||
};
|
||||
|
||||
if (server.close && typeof server.close.hook === 'function') {
|
||||
server.close.hook(function (fn, args) { finish('ended'); leaveRoom(room, 'ended'); stopCam(); return fn.apply(this, args); });
|
||||
server.close.hook(function (fn, args) { finish('ended'); leaveRoom(room, 'ended'); stopCam(); for (const v of [...viewers.values()]) dropViewer(v); return fn.apply(this, args); });
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -1566,12 +1780,12 @@ module.exports = {
|
|||
if (r && r.phase === 'live') {
|
||||
if (on === !!r.cam) return cb(null, roomSnapshot());
|
||||
if (on) {
|
||||
if (!r.hubVideo || !startCam((jpeg, seq) => { if (room !== r) return; gridNote(server.id, jpeg); for (const plain of videoChunks(seq, jpeg)) sendRoomVideo(r, plain); })) return cb(new Error('unavailable'));
|
||||
if (!r.hubVideo || !startCam((jpeg, seq) => { if (room !== r) return; gridNote(r, server.id, jpeg); sendRoomJpeg(r, seq, jpeg, 0); })) return cb(new Error('unavailable'));
|
||||
r.cam = true;
|
||||
} else {
|
||||
r.cam = false;
|
||||
stopCam();
|
||||
gridNote(server.id, null);
|
||||
gridNote(r, server.id, null);
|
||||
sendRoomVideo(r, videoOff());
|
||||
}
|
||||
roomChanged();
|
||||
|
|
@ -1579,13 +1793,78 @@ module.exports = {
|
|||
}
|
||||
cb(new Error('no-call'));
|
||||
},
|
||||
stream(opts, cb) {
|
||||
const o = opts && typeof opts === 'object' ? opts : {};
|
||||
const r = room;
|
||||
if (!r || r.phase !== 'live') return cb(new Error('no-room'));
|
||||
if (o.stop) { stopRoomStream(r); return cb(null, roomSnapshot()); }
|
||||
const file = streamFile(o.file);
|
||||
if (!file) return cb(new Error('invalid'));
|
||||
if (!startRoomStream(r, file, o.name || path.basename(file))) return cb(new Error('unavailable'));
|
||||
roomChanged();
|
||||
cb(null, roomSnapshot());
|
||||
},
|
||||
viewerJoin(opts, cb) {
|
||||
const o = opts && typeof opts === 'object' ? opts : {};
|
||||
const rid = String(o.rid || '');
|
||||
if (!RID.test(rid) || !isFeedId(o.owner) || !isFeedId(o.hub) || typeof o.token !== 'string') return cb(new Error('invalid'));
|
||||
if (viewers.has(rid)) return cb(null, true);
|
||||
if (room && room.rid === rid) return cb(new Error('busy'));
|
||||
const r = {
|
||||
rid, ref: String(o.ref || ''), title: '', hub: o.hub, phase: 'joining', joinedAt: Date.now(), muted: true, slot: null, max: ROOM_MAX,
|
||||
peers: new Map(), order: [], keys: new Map(), kid: null, sealer: null, static: false, sentAt: 0,
|
||||
events: [], notify: false, rec: null, recPeers: new Set(), hand: false, handAt: 0, tag: `pub:${rid}:`, grid: newGrid()
|
||||
};
|
||||
viewers.set(rid, r);
|
||||
r.out = Pushable(() => {});
|
||||
r.ready = (err) => {
|
||||
r.ready = null;
|
||||
clearTimeout(r.timer);
|
||||
if (err) { dropViewer(r); return cb(err); }
|
||||
startViewerMedia(r);
|
||||
startGrid(r);
|
||||
cb(null, true);
|
||||
};
|
||||
r.timer = setTimeout(() => { if (r.ready) r.ready(new Error('unreachable')); }, ROOM_JOIN_MS);
|
||||
const attach = (duplex) => {
|
||||
if (viewers.get(rid) !== r || !r.ready) return closeDuplex(duplex);
|
||||
if (!duplex) return r.ready(new Error('unreachable'));
|
||||
pull(r.out, duplex.sink);
|
||||
pull(duplex.source, pull.drain((frame) => onRoomFrame(r, frame), () => {
|
||||
if (viewers.get(rid) !== r) return;
|
||||
if (r.ready) r.ready(new Error('unreachable'));
|
||||
else dropViewer(r);
|
||||
}));
|
||||
};
|
||||
openHub(o, { rid, owner: o.owner, token: o.token, viewer: true, ...(RID.test(String(o.roomId || '')) ? { roomId: String(o.roomId) } : {}) }, attach);
|
||||
},
|
||||
viewerLeave(opts, cb) {
|
||||
dropViewer(viewers.get(String((opts && opts.rid) || '')));
|
||||
cb(null, true);
|
||||
},
|
||||
viewerRooms() { return [...viewers.values()].filter(r => r.phase === 'live').map(r => r.rid); },
|
||||
publicFeed(opts) {
|
||||
const rid = String((opts && opts.rid) || '');
|
||||
const kind = opts && opts.kind === 'sound' ? 'sound' : 'grid';
|
||||
const r = viewers.get(rid);
|
||||
const target = r ? r.tag + kind : '';
|
||||
const p = Pushable(() => { const set = frameWatchers.get(target); if (set) { set.delete(p); if (!set.size) frameWatchers.delete(target); } });
|
||||
if (!r || r.phase !== 'live' || (kind === 'grid' && !sharpLib)) { p.end(); return p; }
|
||||
if (!frameWatchers.has(target)) frameWatchers.set(target, new Set());
|
||||
frameWatchers.get(target).add(p);
|
||||
if (kind === 'grid') { r.grid.dirty = true; r.grid.empty = false; }
|
||||
return p;
|
||||
},
|
||||
videoFrames(opts) {
|
||||
const target = String((opts && opts.peer) || '');
|
||||
const p = Pushable(() => { const set = frameWatchers.get(target); if (set) { set.delete(p); if (!set.size) frameWatchers.delete(target); } });
|
||||
const now = Date.now();
|
||||
const filmOf = target.startsWith('film:') && target !== 'film:me' ? target.slice(5) : null;
|
||||
const live = target === 'me'
|
||||
? !!cam
|
||||
: target === 'film:me' ? !!(room && room.stream)
|
||||
: target === 'grid' ? !!(room && sharpLib && gridIds(room).length)
|
||||
: filmOf ? !!room && [...room.peers.values()].some(x => x.id === filmOf && now - (x.filmAt || 0) < VIDEO_HOLD_MS)
|
||||
: call ? (call.peer === target && !!call.peerCam)
|
||||
: room ? [...room.peers.values()].some(x => x.id === target && now - (x.frameAt || 0) < VIDEO_HOLD_MS)
|
||||
: false;
|
||||
|
|
@ -1694,7 +1973,7 @@ module.exports = {
|
|||
roomHub(opts) { return hubDuplex(this && this.id, opts); },
|
||||
roomInfo(opts, cb) {
|
||||
const r = hubRooms.get(String((opts && opts.rid) || ''));
|
||||
cb(null, { count: r ? r.members.size : 0, max: roomMax, video: policy().video !== false });
|
||||
cb(null, { count: r ? hubHumans(r) : 0, max: roomMax, video: policy().video !== false });
|
||||
},
|
||||
roomAdmits(opts, cb) {
|
||||
const from = this && this.id;
|
||||
|
|
@ -1705,13 +1984,14 @@ module.exports = {
|
|||
if (!audio) return cb(new Error('unavailable'));
|
||||
if (call || room) return cb(new Error('busy'));
|
||||
const rid = String((opts && opts.rid) || '');
|
||||
if (viewers.has(rid)) dropViewer(viewers.get(rid));
|
||||
if (!RID.test(rid) || !isFeedId(opts.owner) || !isFeedId(opts.hub) || typeof opts.token !== 'string') return cb(new Error('invalid'));
|
||||
const secrets = (Array.isArray(opts.secrets) ? opts.secrets : []).filter(x => typeof x === 'string' && x);
|
||||
const r = {
|
||||
rid, ref: String(opts.ref || ''), title: String(opts.title || '').slice(0, 200), hub: opts.hub,
|
||||
phase: 'joining', joinedAt: Date.now(), muted: false, slot: null, max: ROOM_MAX, peers: new Map(), order: [],
|
||||
keys: new Map(), kid: null, sealer: null, static: secrets.length > 0, sentAt: 0,
|
||||
events: [], notify: true, rec: null, recPeers: new Set(), hand: false, handAt: 0
|
||||
events: [], notify: true, rec: null, recPeers: new Set(), hand: false, handAt: 0, tag: '', grid: newGrid()
|
||||
};
|
||||
if (r.static) {
|
||||
for (const secret of secrets.slice().reverse()) { const k = staticKey(rid, secret); r.keys.set(k.kid, k.key); }
|
||||
|
|
@ -1726,7 +2006,7 @@ module.exports = {
|
|||
clearTimeout(r.timer);
|
||||
if (err) { leaveRoom(r, null); return cb(err); }
|
||||
startRoomMedia(r);
|
||||
if (sharpLib) { gridTimer = setInterval(gridTick, Math.round(1000 / VIDEO_FPS)); if (gridTimer.unref) gridTimer.unref(); }
|
||||
startGrid(r);
|
||||
roomChanged();
|
||||
cb(null, roomSnapshot());
|
||||
};
|
||||
|
|
@ -1741,20 +2021,7 @@ module.exports = {
|
|||
else leaveRoom(r, 'lost');
|
||||
}));
|
||||
};
|
||||
const req = { rid, owner: opts.owner, token: opts.token, ...(RID.test(String(opts.roomId || '')) ? { roomId: String(opts.roomId) } : {}) };
|
||||
if (opts.hub === server.id) return attach(hubDuplex(server.id, req));
|
||||
const viaRpc = (rpc) => attach(rpc && rpc.phone && typeof rpc.phone.roomHub === 'function' ? rpc.phone.roomHub(req, () => {}) : null);
|
||||
const live = liveRpc(opts.hub);
|
||||
if (live) return viaRpc(live);
|
||||
if (typeof opts.address === 'string' && opts.address) {
|
||||
try {
|
||||
return server.conn.connect(opts.address, (err, rpc) => {
|
||||
const got = !err && rpc && rpc.phone ? rpc : liveRpc(opts.hub);
|
||||
return got ? viaRpc(got) : reach(opts.hub, viaRpc);
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
reach(opts.hub, viaRpc);
|
||||
openHub(opts, { rid, owner: opts.owner, token: opts.token, ...(RID.test(String(opts.roomId || '')) ? { roomId: String(opts.roomId) } : {}) }, attach);
|
||||
},
|
||||
roomLeave(cb) {
|
||||
leaveRoom(room, 'left');
|
||||
|
|
@ -1787,8 +2054,8 @@ module.exports = {
|
|||
roomCount(opts, cb) {
|
||||
const rid = String((opts && opts.rid) || '');
|
||||
if (!RID.test(rid) || !isFeedId(opts.hub)) return cb(null, null);
|
||||
if (room && room.rid === rid && room.phase === 'live') return cb(null, { count: room.peers.size + 1, max: room.max });
|
||||
if (opts.hub === server.id) { const h = hubRooms.get(rid); return cb(null, { count: h ? h.members.size : 0, max: roomMax }); }
|
||||
if (room && room.rid === rid && room.phase === 'live') return cb(null, { count: humansIn(room) + 1, max: room.max });
|
||||
if (opts.hub === server.id) { const h = hubRooms.get(rid); return cb(null, { count: h ? hubHumans(h) : 0, max: roomMax }); }
|
||||
const rpc = liveRpc(opts.hub);
|
||||
if (!rpc || typeof rpc.phone.roomInfo !== 'function') return cb(null, null);
|
||||
let done = false;
|
||||
|
|
@ -1840,6 +2107,7 @@ module.exports = {
|
|||
|
||||
module.exports.setAudioFactory = (factory) => { audioFactory = typeof factory === 'function' ? factory : systemAudio; };
|
||||
module.exports.setVideoFactory = (factory) => { videoFactory = typeof factory === 'function' ? factory : systemVideo; };
|
||||
module.exports.setStreamFactory = (factory) => { streamFactory = typeof factory === 'function' ? factory : systemStream; };
|
||||
module.exports.jpegSplitter = jpegSplitter;
|
||||
module.exports.VIDEO_CHUNK = VIDEO_CHUNK;
|
||||
module.exports.VIDEO_HOLD_MS = VIDEO_HOLD_MS;
|
||||
|
|
@ -1853,3 +2121,4 @@ module.exports.RING_PER_WINDOW = RING_PER_WINDOW;
|
|||
module.exports.chimeMs = chimeMs;
|
||||
module.exports.recordingPrefix = recordingPrefix;
|
||||
module.exports.VOICEMAIL_MAX_MS = VOICEMAIL_MAX_MS;
|
||||
module.exports.SOUND_RATE = RATE;
|
||||
|
|
|
|||
53
src/server/phone_stream_android.js
Normal file
53
src/server/phone_stream_android.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
const fs = require('fs');
|
||||
const net = require('net');
|
||||
const path = require('path');
|
||||
|
||||
const MARKER = path.join(__dirname, '..', '..', '.audio_bridge');
|
||||
const FRAME_MAX = 4 * 1024 * 1024;
|
||||
|
||||
const bridgePort = () => {
|
||||
try {
|
||||
const port = Number(String(fs.readFileSync(MARKER, 'utf8')).trim());
|
||||
return Number.isFinite(port) && port > 0 ? port : 0;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = () => ({
|
||||
open(file, { onJpeg, onPcm, onEnd }) {
|
||||
let ended = false;
|
||||
const finish = () => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
try { onEnd(); } catch (_) {}
|
||||
};
|
||||
const port = bridgePort();
|
||||
if (!port) {
|
||||
setImmediate(finish);
|
||||
return { stop() { ended = true; } };
|
||||
}
|
||||
const sock = net.connect(port, '127.0.0.1');
|
||||
let buf = Buffer.alloc(0);
|
||||
sock.setNoDelay(true);
|
||||
sock.on('error', finish);
|
||||
sock.on('close', finish);
|
||||
sock.on('connect', () => sock.write(Buffer.concat([Buffer.from('S'), Buffer.from(`${file}\n`, 'utf8')])));
|
||||
sock.on('data', (chunk) => {
|
||||
buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
|
||||
while (buf.length >= 5) {
|
||||
const len = buf.readUInt32BE(1);
|
||||
if (len > FRAME_MAX) { sock.destroy(); return; }
|
||||
if (buf.length < 5 + len) return;
|
||||
const type = buf[0];
|
||||
const payload = Buffer.from(buf.subarray(5, 5 + len));
|
||||
buf = buf.subarray(5 + len);
|
||||
if (ended) continue;
|
||||
if (type === 0x4a) onJpeg(payload);
|
||||
else if (type === 0x41) onPcm(payload);
|
||||
else if (type === 0x45) { finish(); sock.destroy(); return; }
|
||||
}
|
||||
});
|
||||
return { stop() { ended = true; try { sock.destroy(); } catch (_) {} } };
|
||||
}
|
||||
});
|
||||
|
|
@ -47,9 +47,9 @@ config.blobs.max = 75 * megabyte;
|
|||
config.db2 = { automigrate: false, dangerouslyKillFlumeWhenMigrated: false, ...(config.db2 || {}) };
|
||||
|
||||
const lanOn = (() => { try { return require('../configs/config-manager').getConfig().lanBroadcasting !== false; } catch (_) { return true; } })();
|
||||
const bindHost = config.pub || lanOn ? '0.0.0.0' : '127.0.0.1';
|
||||
const bindHost = config.pub ? '0.0.0.0' : lanOn ? null : '127.0.0.1';
|
||||
for (const entry of (config.connections && config.connections.incoming && config.connections.incoming.net) || []) {
|
||||
if (entry && typeof entry === 'object' && !entry.host) entry.host = bindHost;
|
||||
if (entry && typeof entry === 'object' && !entry.host && bindHost) entry.host = bindHost;
|
||||
}
|
||||
|
||||
config.account = account;
|
||||
|
|
|
|||
|
|
@ -24,16 +24,9 @@ const blobUsageOnDisk = (ssbPath) => {
|
|||
|
||||
let printed = false;
|
||||
let checkedForUpdate = false;
|
||||
let pendingClearnetModules = null;
|
||||
let clearnetReady = false;
|
||||
let pendingWarmup = null;
|
||||
let warmupReady = false;
|
||||
|
||||
function setClearnetModules(modules) {
|
||||
pendingClearnetModules = Array.isArray(modules) ? modules : [];
|
||||
clearnetReady = true;
|
||||
}
|
||||
|
||||
function setWarmupTime(str) {
|
||||
pendingWarmup = str;
|
||||
warmupReady = true;
|
||||
|
|
@ -81,19 +74,6 @@ async function checkForUpdate() {
|
|||
await updater.getRemoteVersion();
|
||||
}
|
||||
|
||||
function waitForClearnet(timeoutMs = 25000) {
|
||||
return new Promise((resolve) => {
|
||||
if (clearnetReady) return resolve(pendingClearnetModules || []);
|
||||
const started = Date.now();
|
||||
const tick = () => {
|
||||
if (clearnetReady) return resolve(pendingClearnetModules || []);
|
||||
if (Date.now() - started >= timeoutMs) return resolve(null);
|
||||
setTimeout(tick, 250);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, httpHost = 'localhost', offline = false, isPublic = false) {
|
||||
if (printed) return;
|
||||
printed = true;
|
||||
|
|
@ -151,8 +131,6 @@ async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, htt
|
|||
console.log(`- Media cache limit: ${cacheMb > 0 ? formatMB(cacheMb * 1024 * 1024) : 'Unlimited'}`);
|
||||
console.log(`- Current usage: ${formatMB(cacheUsage.bytes, 1)} · ${cacheUsage.files} files`);
|
||||
console.log(`- LAN Broadcasting (UDP): ${localDiscovery ? 'enabled' : 'disabled'}`);
|
||||
const clearnetModules = await waitForClearnet();
|
||||
const clearnetStatus = (clearnetModules && clearnetModules.length > 0) ? clearnetModules.join(', ') : 'disabled';
|
||||
const multiverse = (() => {
|
||||
if ((oasisCfg.modules || {}).fediverseMod === 'off') return [];
|
||||
try {
|
||||
|
|
@ -164,12 +142,10 @@ async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, htt
|
|||
].filter(Boolean);
|
||||
} catch (_) { return []; }
|
||||
})();
|
||||
console.log(`- Internet Broadcasting:`);
|
||||
console.log(` - Clearnet: ${clearnetStatus}`);
|
||||
console.log(` - Multiverse: ${multiverse.length ? multiverse.join(', ') : 'disabled'}`);
|
||||
console.log(`- Multiverse: ${multiverse.length ? multiverse.join(', ') : 'disabled'}`);
|
||||
if (hasHttp) {
|
||||
const en = (() => { try { return require('../client/assets/translations/i18n').en || {}; } catch (_) { return {}; } })();
|
||||
const uxNames = { blocks: en.uxModeMenus, ainav: en.uxModeAINav, chats: en.uxModeChats, feed: en.uxModeFeed, phone: en.phoneTitle };
|
||||
const uxNames = { blocks: en.uxModeMenus, ainav: en.uxModeAINav, chats: en.uxModeChats, feed: en.uxModeFeed, phone: en.phoneTitle, rooms: en.roomsTitle };
|
||||
const wishNames = { whole: en.settingsWishWhole, mutuals: en.settingsWishMutuals, 'only-lan': en.settingsWishOnlyLan, local: en.settingsWishLocal };
|
||||
const workflow = (() => {
|
||||
try {
|
||||
|
|
@ -202,7 +178,6 @@ async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, htt
|
|||
|
||||
module.exports = {
|
||||
printMetadata,
|
||||
setClearnetModules,
|
||||
setWarmupTime,
|
||||
colors
|
||||
};
|
||||
|
|
|
|||
|
|
@ -837,7 +837,11 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
)
|
||||
: '',
|
||||
contentWarning ? h2({ class: 'content-warning' }, contentWarning) : '',
|
||||
bodyNode
|
||||
bodyNode,
|
||||
Array.isArray(content?.tags) && content.tags.filter(Boolean).length
|
||||
? div({ class: 'card-tags' }, content.tags.filter(Boolean).map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
|
||||
: ''
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -1883,6 +1887,7 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
),
|
||||
renderContentActions(msgId, detailHref, {
|
||||
author: action.author,
|
||||
contact: type === 'about',
|
||||
reportTitle: (content && (content.title || content.question || content.concept || content.name)) || '',
|
||||
spread: SPREADABLE_TYPES.has(type) ? (spreadMap.get(action.id) || null) : undefined,
|
||||
...(type === 'torrent' || type === 'file' ? { spreadTitle: i18n.seedAction } : {}),
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const {
|
|||
const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } = require("./comments_view");
|
||||
|
||||
const { renderLicenseChip, renderLicenseSelect, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require('./clearnet_view');
|
||||
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions , renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views");
|
||||
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf } = require("./main_views");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderStyledText } = require("../backend/renderStyledText")
|
||||
|
|
@ -61,7 +61,7 @@ const renderAudioPlayer = (audioObj, opts = {}) =>
|
|||
audioObj?.url
|
||||
? div(
|
||||
{ class: "audio-container" },
|
||||
audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(audioObj.url)}`, preload: "metadata" }),
|
||||
audioHyperaxe({ controls: true, src: blobUrlOf(audioObj.url), preload: "metadata" }),
|
||||
opts.skipTranscode ? null : renderTranscodeButton(audioObj)
|
||||
)
|
||||
: p(i18n.audioNoFile);
|
||||
|
|
@ -236,7 +236,6 @@ exports.audioView = async (audios, filter = "all", audioId = null, params = {})
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
...(emptyMod ? [] : [
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterAll).toUpperCase()),
|
||||
|
|
@ -380,7 +379,6 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterAll).toUpperCase()),
|
||||
...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterRecent).toUpperCase())] : []),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const { div, h2, h3, p, section, button, form, a, input, span, pre, table, thead, tbody, tr, td, th, br, strong, label, ul, li } = require("../server/node_modules/hyperaxe");
|
||||
const RAW_HTML = Symbol.for('oasis.rawHtml');
|
||||
const { template, i18n, userLink, formatCarbon, renderStateChip, renderModuleStats, renderWalletChip, paged } = require("../views/main_views");
|
||||
const { template, i18n, userLink, formatCarbon, renderStateChip, renderModuleStats, renderWalletChip, paged, renderIcon } = require("../views/main_views");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
|
||||
const FILTER_LABELS = {
|
||||
|
|
@ -489,7 +489,7 @@ const renderUbiPubs = (pubs, hasValidWallet) => {
|
|||
userLink(p.pubId),
|
||||
p.isThisNode || p.isConnected ? span({ class: "status you" }, p.isThisNode ? i18n.bankUbiPubThisNode : i18n.bankUbiPubConnected) : null,
|
||||
hasValidWallet && p.address
|
||||
? div({ class: "content-actions" }, form({ method: "GET", action: "/banking/fund", class: "content-action-form" }, input({ type: "hidden", name: "pub", value: p.pubId }), button({ type: "submit", class: "btn-singleview btn-donate", title: i18n.bankDonateEco }, "\u2744")))
|
||||
? div({ class: "content-actions" }, form({ method: "GET", action: "/banking/fund", class: "content-action-form" }, input({ type: "hidden", name: "pub", value: p.pubId }), button({ type: "submit", class: "btn-singleview btn-donate", title: i18n.bankDonateEco }, renderIcon("donate"))))
|
||||
: null
|
||||
),
|
||||
div({ class: "bank-summary ubi-pub-summary" },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const { div, h2, h3, p, section, button, form, a, input, span, pre, table, tr, td, strong } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink, paged } = require("../views/main_views");
|
||||
const { template, i18n, userLink, paged, renderIcon } = require("../views/main_views");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
|
||||
const FILTER_LABELS = {
|
||||
|
|
@ -175,7 +175,9 @@ const getViewDetailsAction = (type, block) => {
|
|||
case 'event': return `/events/${encodeURIComponent(block.id)}`;
|
||||
case 'task': return `/tasks/${encodeURIComponent(block.id)}`;
|
||||
case 'about': return `/author/${encodeURIComponent(block.author)}`;
|
||||
case 'post': return `/thread/${encodeURIComponent(block.id)}#${encodeURIComponent(block.id)}`;
|
||||
case 'post': return block.content && typeof block.content.root === 'string'
|
||||
? `/thread/${encodeURIComponent(block.content.root)}#${encodeURIComponent(block.id)}`
|
||||
: `/blogs/${encodeURIComponent(block.id)}`;
|
||||
case 'vote': return `/thread/${encodeURIComponent(block.content.vote.link)}#${encodeURIComponent(block.content.vote.link)}`;
|
||||
case 'contact': return `/inhabitants`;
|
||||
case 'pub': return `/invites`;
|
||||
|
|
@ -424,7 +426,7 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
|
|||
button({ type:'submit', class:'filter-btn' }, `← ${i18n.blockchainBack}`)
|
||||
),
|
||||
!block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
|
||||
a({ href:getViewDetailsAction(block.type, block), class:'btn-singleview btn-content', title:i18n.visitContent }, '↗')
|
||||
a({ href:getViewDetailsAction(block.type, block), class:'btn-singleview btn-content', title:i18n.visitContent }, renderIcon('open'))
|
||||
: (block.isTombstoned || block.isReplaced) ?
|
||||
div({ class: 'deleted-label' },
|
||||
i18n.blockchainContentDeleted || "This content has been deleted."
|
||||
|
|
@ -534,10 +536,10 @@ const renderBlockchainView = (blocks, filter, userId, search = {}, extras = {})
|
|||
.map(block=>
|
||||
div({ class:'block' },
|
||||
div({ class:'block-buttons' },
|
||||
block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails }, '⦿'),
|
||||
block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram }, '⊞'),
|
||||
block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails }, renderIcon('block')),
|
||||
block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram }, renderIcon('datagram')),
|
||||
!block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
|
||||
a({ href:getViewDetailsAction(block.type, block), class:'btn-singleview btn-content', title:i18n.visitContent }, '↗')
|
||||
a({ href:getViewDetailsAction(block.type, block), class:'btn-singleview btn-content', title:i18n.visitContent }, renderIcon('open'))
|
||||
: (block.isTombstoned || block.isReplaced) ?
|
||||
div({ class: 'deleted-label' },
|
||||
i18n.blockchainContentDeleted || "This content has been deleted."
|
||||
|
|
|
|||
|
|
@ -21,9 +21,16 @@ const FILTERS = [
|
|||
|
||||
const safeText = (v) => String(v == null ? "" : v).trim();
|
||||
|
||||
const renderBlogTags = (tags) => {
|
||||
const list = (Array.isArray(tags) ? tags : []).map(t => String(t || "").trim().replace(/^#+/, "")).filter(Boolean);
|
||||
return list.length
|
||||
? div({ class: "card-tags" }, list.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
|
||||
: null;
|
||||
};
|
||||
|
||||
const excerpt = (text, max = 420) => {
|
||||
const s = safeText(text);
|
||||
return s.length > max ? s.slice(0, max) + "…" : s;
|
||||
return s.length > max ? s.slice(0, max).replace(/\s+$/, "") + "…" : s;
|
||||
};
|
||||
|
||||
const renderFilterBar = (filter, q, showSearch = true, total = null, censusList = null) => {
|
||||
|
|
@ -77,6 +84,7 @@ const renderBlogCard = (blog, filter, spreadInfo) => {
|
|||
: null,
|
||||
blog.clearnet === true ? div({ class: "card-chips-row" }, renderReachChip(true, i18n, clearnetItemHref("blog", blog.subject || blog.text, blog.id))) : null,
|
||||
div({ class: "blog-card-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(excerpt(blog.text))) }),
|
||||
renderBlogTags(blog.tags),
|
||||
p({ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${moment(blog.createdAt).format("YYYY/MM/DD HH:mm")}`),
|
||||
userLink(blog.author)
|
||||
|
|
@ -89,13 +97,15 @@ const renderCreateForm = (draft = null) => {
|
|||
const subjectValue = draft ? safeText(draft.subject) : "";
|
||||
const textValue = draft ? String(draft.text || "") : "";
|
||||
const allowComments = draft ? draft.allowComments !== false : true;
|
||||
const tagsValue = draft ? (Array.isArray(draft.tags) ? draft.tags.join(", ") : safeText(draft.tags)) : "";
|
||||
return section(
|
||||
draft
|
||||
? section({ class: "post-preview blog-preview" },
|
||||
div({ class: "preview-content" },
|
||||
h2(i18n.messagePreview),
|
||||
subjectValue ? h2({ class: "tribe-card-title" }, subjectValue) : null,
|
||||
div({ class: "blog-detail-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(textValue)) })
|
||||
div({ class: "blog-detail-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(textValue)) }),
|
||||
renderBlogTags(draft.tags)
|
||||
)
|
||||
)
|
||||
: null,
|
||||
|
|
@ -105,17 +115,19 @@ const renderCreateForm = (draft = null) => {
|
|||
br(),
|
||||
input({ type: "text", name: "subject", id: "subject", maxlength: "150", placeholder: i18n.contentWarningPlaceholder, value: subjectValue }),
|
||||
br(),
|
||||
br(),
|
||||
label({ for: "text" }, i18n.blogMessage),
|
||||
br(),
|
||||
textarea({ required: true, name: "text", id: "text", rows: "8", class: "publish-textarea", maxlength: String(TEXT_CAP), placeholder: i18n.publishWarningPlaceholder }, textValue),
|
||||
br(),
|
||||
br(),
|
||||
label({ for: "blob" }, i18n.blogMedia),
|
||||
br(),
|
||||
input({ type: "file", id: "blob", name: "blob", multiple: true, accept: "image/*,video/*,audio/*,application/pdf" }),
|
||||
br(),
|
||||
br(),
|
||||
label({ for: "tags" }, i18n.tagsLabel || "Tags"),
|
||||
br(),
|
||||
input({ type: "text", name: "tags", id: "tags", placeholder: i18n.tagsPlaceholder || "", value: tagsValue }),
|
||||
br(),
|
||||
div({ class: "blog-allow-comments" },
|
||||
input({ type: "hidden", name: "allowComments", value: "0" }),
|
||||
label(
|
||||
|
|
@ -206,7 +218,8 @@ exports.singleBlogView = async (blog, comments = [], params = {}) => {
|
|||
|
||||
const blogMain = div({ class: "tribe-main" },
|
||||
div({ class: "job-section" },
|
||||
div({ class: "blog-detail-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(blog.text || "")) })
|
||||
div({ class: "blog-detail-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(blog.text || "")) }),
|
||||
renderBlogTags(blog.tags)
|
||||
),
|
||||
renderEngagement(blog.id,
|
||||
renderOpinionsVoting('/blogs/opinions', blog.id, blog.opinions, href, blog.opinions_inhabitants),
|
||||
|
|
|
|||
|
|
@ -227,7 +227,6 @@ exports.bookmarkView = async (bookmarks, filter = "all", bookmarkId = null, para
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/bookmarks", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
...(emptyMod ? [] : [
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.bookmarkFilterAll).toUpperCase()),
|
||||
|
|
@ -369,7 +368,6 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/bookmarks", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.bookmarkFilterAll).toUpperCase()),
|
||||
...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.bookmarkFilterRecent).toUpperCase())] : []),
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ const renderFilters = (filter, q, census, emptyMod) =>
|
|||
section(
|
||||
div({ class: "filters" },
|
||||
form({ method: "GET", action: "/campaigns", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q || "" }),
|
||||
...(emptyMod ? [] : [
|
||||
...chipButtons(BASE_FILTERS, filter, census, filterLabel),
|
||||
...chipButtons(STATUSES, filter, census, statusLabel),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const catLabel = (c) => i18n[catKey(c)] || c
|
|||
|
||||
const blobSrcOf = (value) => {
|
||||
const s = String(value || "").trim()
|
||||
if (s.startsWith("/blob/sealed/")) return s
|
||||
if (s.startsWith("&")) return `/blob/${encodeURIComponent(s)}`
|
||||
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
return mImg ? `/blob/${encodeURIComponent(mImg[1])}` : null
|
||||
|
|
@ -30,9 +31,10 @@ const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => {
|
|||
if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
const s = String(value).trim()
|
||||
if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
if (s.startsWith("/blob/sealed/")) return img({ loading: 'lazy', src: s, ...attrs })
|
||||
if (s.startsWith("&")) return img({ loading: 'lazy', src: `/blob/${encodeURIComponent(s)}`, ...attrs })
|
||||
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mImg) return img({ loading: 'lazy', src: `/blob/${encodeURIComponent(mImg[1])}`, ...attrs })
|
||||
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256|\/blob\/sealed\/[^)\s]+)\s*\)/)
|
||||
if (mImg) return img({ loading: 'lazy', src: mImg[1].startsWith("/blob/sealed/") ? mImg[1] : `/blob/${encodeURIComponent(mImg[1])}`, ...attrs })
|
||||
return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -238,6 +238,47 @@ const renderClearnetUrlBlock = ({ baseUrl = '', path, i18nObj = {} }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const CLEARNET_ZOOM_CSS = `
|
||||
.cn-zoom{display:contents}
|
||||
.cn-zoom img{cursor:zoom-in}
|
||||
.cn-lightbox{display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.9);align-items:center;justify-content:center}
|
||||
.cn-lightbox:target{display:flex}
|
||||
.cn-lightbox-link{display:flex;align-items:center;justify-content:center;width:100%;height:100%;cursor:zoom-out}
|
||||
.cn-lightbox-img{display:block;max-width:92vw;max-height:88vh;object-fit:contain}
|
||||
.cn-lightbox-full{background:#000}
|
||||
.cn-lightbox-full .cn-lightbox-img{width:100vw;height:100vh;max-width:100vw;max-height:100vh}
|
||||
.cn-lightbox-close,.cn-lightbox-fs{position:absolute;top:14px;z-index:1;width:42px;height:42px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;text-decoration:none;font-size:24px;line-height:1}
|
||||
.cn-lightbox-close{right:16px}
|
||||
.cn-lightbox-fs{right:66px}
|
||||
.cn-lightbox-fs svg{display:block;width:20px;height:20px}
|
||||
`;
|
||||
const CN_FULLSCREEN_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 9 4 4 9 4"/><polyline points="15 4 20 4 20 9"/><polyline points="20 15 20 20 15 20"/><polyline points="9 20 4 20 4 15"/></svg>';
|
||||
const cnZoom = (id, src, alt = '', { full = false, imgClass = '' } = {}) => {
|
||||
const zid = `cnz-${String(id).replace(/[^A-Za-z0-9_-]/g, '')}`;
|
||||
const a = escapeHtml(alt);
|
||||
const u = escapeHtml(src);
|
||||
const cls = imgClass ? ` class="${escapeHtml(imgClass)}"` : '';
|
||||
const fs = full ? `<a class="cn-lightbox-fs" href="#${zid}-full" title="${escapeHtml(cnText('liveFullscreen', 'Full screen'))}">${CN_FULLSCREEN_ICON}</a>` : '';
|
||||
return `<a class="cn-zoom" id="${zid}-src" href="#${zid}"><img${cls} src="${u}" alt="${a}"/></a>`
|
||||
+ `<div class="cn-lightbox" id="${zid}"><a class="cn-lightbox-close" href="#${zid}-src">×</a>${fs}<a class="cn-lightbox-link" href="#${zid}-src"><img class="cn-lightbox-img" src="${u}" alt="${a}" loading="lazy"/></a></div>`
|
||||
+ (full ? `<div class="cn-lightbox cn-lightbox-full" id="${zid}-full"><a class="cn-lightbox-close" href="#${zid}">×</a><a class="cn-lightbox-link" href="#${zid}"><img class="cn-lightbox-img" src="${u}" alt="${a}" loading="lazy"/></a></div>` : '');
|
||||
};
|
||||
const CN_ZOOM_SKIP = /\bcn-(lightbox-img|footer-logo)\b/;
|
||||
const zoomClearnetImages = (html) => {
|
||||
let depth = 0;
|
||||
let n = 0;
|
||||
return String(html || '').replace(/<a\b[^>]*>|<\/a\s*>|<img\b[^>]*>/gi, (tag) => {
|
||||
if (/^<a\b/i.test(tag)) { depth++; return tag; }
|
||||
if (/^<\/a/i.test(tag)) { depth = Math.max(0, depth - 1); return tag; }
|
||||
if (depth > 0) return tag;
|
||||
const cls = (tag.match(/\sclass="([^"]*)"/i) || [])[1] || '';
|
||||
const src = (tag.match(/\ssrc="([^"]*)"/i) || [])[1] || '';
|
||||
if (!src || CN_ZOOM_SKIP.test(cls)) return tag;
|
||||
const alt = (tag.match(/\salt="([^"]*)"/i) || [])[1] || '';
|
||||
const zid = `cnz-i${++n}`;
|
||||
return `<a class="cn-zoom" id="${zid}-src" href="#${zid}">${tag}</a><span class="cn-lightbox" id="${zid}"><a class="cn-lightbox-close" href="#${zid}-src">×</a><a class="cn-lightbox-link" href="#${zid}-src"><img class="cn-lightbox-img" src="${src}" alt="${alt}" loading="lazy"/></a></span>`;
|
||||
});
|
||||
};
|
||||
const CLEARNET_TEXT_CSS = `
|
||||
.cn-tags{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0}
|
||||
.cn-hub-priceline{margin:10px 0 0 0}
|
||||
|
|
@ -266,8 +307,11 @@ a.tag-link:hover{text-decoration:underline}
|
|||
.rt-rule{display:block;border-top:1px solid currentColor;opacity:.4;margin:10px 0}
|
||||
.rt-code{font-family:monospace;background:rgba(128,128,128,.18);padding:1px 4px;border-radius:3px;word-break:break-word}
|
||||
.rt-code-block{display:block;font-family:monospace;white-space:pre-wrap;background:rgba(128,128,128,.18);padding:10px;border-radius:5px;margin:8px 0;overflow-x:auto;word-break:break-word}
|
||||
.post-image{display:block;max-width:100%;height:auto;margin:12px 0;border:1px solid var(--border);border-radius:6px}
|
||||
.post-video,.post-audio{display:block;width:100%;max-width:100%;margin:12px 0;border-radius:6px;background:#000}
|
||||
.post-image{display:block;width:auto;max-width:100%;max-height:480px;height:auto;margin:12px auto;border:1px solid var(--border);border-radius:6px}
|
||||
.post-video{display:block;width:100%;max-width:100%;max-height:480px;margin:12px auto;border-radius:6px;background:#000}
|
||||
.post-audio{display:block;width:100%;max-width:100%;margin:12px 0}
|
||||
.cn-media-frame img{max-height:480px;width:auto;margin:0 auto}
|
||||
.cn-media-frame video{max-height:480px}
|
||||
.post-pdf{display:inline-block;margin:8px 0;padding:8px 14px;background:var(--bg-sub);border:1px solid var(--border);border-radius:6px;color:var(--fg);text-decoration:none}
|
||||
.post-pdf:hover{border-color:var(--fg)}
|
||||
`;
|
||||
|
|
@ -346,7 +390,10 @@ footer.cn-footer .cn-footer-logo{width:56px;height:auto;display:block;margin:0 a
|
|||
footer.cn-footer .cn-footer-line{margin:3px 0}
|
||||
`;
|
||||
|
||||
const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null, extraCss = '', body, headerExtra = '', hubFeedId = null }) => {
|
||||
const renderClearnetHubSearch = ({ type = '', query = '' } = {}) =>
|
||||
`<form class="cn-search" method="GET" action="/c">${type ? `<input type="hidden" name="type" value="${escapeHtml(type)}"/>` : ''}<input type="text" name="q" value="${escapeHtml(query || '')}" placeholder="${escapeHtml(cnText('cnSearchPlaceholder', 'Search…'))}" autocomplete="off"/></form>`;
|
||||
|
||||
const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null, extraCss = '', body, headerExtra = null, hubFeedId = null }) => {
|
||||
const safeTitle = escapeHtml(title || 'Oasis');
|
||||
const safeOgTitle = escapeHtml(ogTitle || title || 'Oasis');
|
||||
const safeOgDesc = escapeHtml(ogDescription || '');
|
||||
|
|
@ -368,7 +415,7 @@ const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null
|
|||
<meta name="description" content="${safeOgDesc}"/>
|
||||
<meta name="robots" content="index, follow"/>
|
||||
<link rel="icon" href="/c/assets/images/favicon.svg"/>
|
||||
<style>${baseCss}${CLEARNET_SEARCH_CSS}${CLEARNET_TEXT_CSS}${extraCss}
|
||||
<style>${baseCss}${CLEARNET_SEARCH_CSS}${CLEARNET_TEXT_CSS}${CLEARNET_ZOOM_CSS}${extraCss}
|
||||
.cn-brand-link{display:block;text-decoration:none}
|
||||
|
||||
.cn-brand-link:hover .cn-brand{color:var(--accent)}
|
||||
|
|
@ -386,9 +433,9 @@ const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null
|
|||
<body>
|
||||
<header class="cn-header">
|
||||
${brandBlock}
|
||||
<div class="cn-header-extra">${renderLangSelector(lang)}${propagateLang(headerExtra || '', langOverride)}</div>
|
||||
<div class="cn-header-extra">${renderLangSelector(lang)}${propagateLang(headerExtra == null ? renderClearnetHubSearch() : headerExtra, langOverride)}</div>
|
||||
</header>
|
||||
${propagateLang(stripInternalAnchors(body), langOverride)}
|
||||
${propagateLang(stripInternalAnchors(zoomClearnetImages(body)), langOverride)}
|
||||
<footer class="cn-footer">
|
||||
<a href="https://wiki.solarnethub.com" target="_blank" rel="noopener"><img class="cn-footer-logo" src="/c/assets/images/snh-oasis.jpg" alt="Oasis"/></a>
|
||||
<div class="cn-footer-line">${escapeHtml(cnText('cnSyncedPeers', 'Synced-peers'))}: [ <strong>${Number(sharedState.getSyncedPeerCount ? sharedState.getSyncedPeerCount() : 0) || 0}</strong> ]</div>
|
||||
|
|
@ -550,6 +597,7 @@ const renderClearnetPodcastView = ({ channel }) => {
|
|||
};
|
||||
|
||||
module.exports = {
|
||||
cnZoom,
|
||||
kindLabel,
|
||||
renderTagChips,
|
||||
renderClearnetPodcastView,
|
||||
|
|
@ -575,6 +623,7 @@ module.exports = {
|
|||
renderDoubleEncryptionChip,
|
||||
renderClearnetUrlBlock,
|
||||
renderClearnetSearchForm,
|
||||
renderClearnetHubSearch,
|
||||
renderClearnetPage,
|
||||
renderClearnetNotFound,
|
||||
CLEARNET_PAGER_CSS,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const { div, p, h2, span, a, form, input, textarea, button, label, br, details, summary } = require("../server/node_modules/hyperaxe");
|
||||
const { TEXT_CAP } = require('../backend/long_text');
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { i18n, userLink } = require("./main_views");
|
||||
const { i18n, userLink, renderHideIcon, renderIcon } = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderStyledText } = require("../backend/renderStyledText");
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ const visibleComments = (comments) => (Array.isArray(comments) ? comments : []).
|
|||
return t && String(t).trim();
|
||||
});
|
||||
|
||||
const renderCommentCard = (c, extra = null) => {
|
||||
const renderCommentCard = (c, extra = null, returnTo = null) => {
|
||||
const author = (c && c.value && c.value.author) || "";
|
||||
const ts = (c && c.value && c.value.timestamp) || (c && c.timestamp);
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm") : "";
|
||||
|
|
@ -28,7 +28,13 @@ const renderCommentCard = (c, extra = null) => {
|
|||
return div({ class: own ? "votations-comment-card own-comment" : "votations-comment-card", id: c && c.key ? c.key : undefined },
|
||||
own && c.key
|
||||
? form({ method: "POST", action: `/comments/delete/${encodeURIComponent(c.key)}`, class: "comment-delete-form" },
|
||||
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.chatDelete }, "✕"))
|
||||
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.chatDelete }, renderIcon("close")))
|
||||
: null,
|
||||
!own && c.key
|
||||
? form({ method: "POST", action: `/hide/${encodeURIComponent(c.key)}`, class: "comment-delete-form" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo || (rootId ? `/thread/${encodeURIComponent(rootId)}` : "/activity") }),
|
||||
input({ type: "hidden", name: "label", value: String(text).replace(/\s+/g, " ").trim().slice(0, 80) }),
|
||||
button({ type: "submit", class: "btn-singleview btn-hide", title: i18n.hideContent }, renderHideIcon()))
|
||||
: null,
|
||||
span({ class: "created-at" },
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
|
|
@ -52,7 +58,7 @@ const renderCommentsSection = ({ action, comments = [], returnTo = null, closedN
|
|||
div({ class: "vote-comments-section" },
|
||||
list.length
|
||||
? div({ class: "comments-list" }, ...list.map((c, i) => {
|
||||
const card = renderCommentCard(c, typeof commentExtra === "function" ? commentExtra(c) : null);
|
||||
const card = renderCommentCard(c, typeof commentExtra === "function" ? commentExtra(c) : null, returnTo);
|
||||
return i === list.length - 1 ? div({ id: "comments-latest", class: "comments-latest-anchor" }, card) : card;
|
||||
}))
|
||||
: (closedNote ? null : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const { div, h2, p, section, button, form, a, input, span, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink, renderModuleStats, paged } = require("./main_views");
|
||||
const { template, i18n, userLink, renderModuleStats, paged, renderIcon } = require("./main_views");
|
||||
|
||||
const KIND_LABEL = {
|
||||
inhabitants: () => i18n.dataKindInhabitants,
|
||||
|
|
@ -32,7 +32,8 @@ const KIND_LABEL = {
|
|||
mailing: () => i18n.mailingTitle,
|
||||
logistics: () => i18n.logisticsTitle,
|
||||
podcasts: () => i18n.podcastsTitle,
|
||||
campaigns: () => i18n.campaignsTitle
|
||||
campaigns: () => i18n.campaignsTitle,
|
||||
blogs: () => i18n.blogTitle
|
||||
};
|
||||
|
||||
const REASON_LABEL = {
|
||||
|
|
@ -54,39 +55,50 @@ const FILTER_COLUMNS = [
|
|||
["INHABITANTS", "TRIBES"],
|
||||
["VOTES", "EVENTS", "CALENDARS", "TASKS", "REPORTS", "EMERGENCIES", "MAILING", "CAMPAIGNS"],
|
||||
["MARKET", "HOUSING", "JOBS", "PROJECTS", "INDUSTRY", "LOGISTICS"],
|
||||
["FORUM", "CHATS", "ROOMS", "PADS", "WIKI", "MAPS", "SCHOOL"],
|
||||
["BLOGS", "FORUM", "CHATS", "ROOMS", "PADS", "WIKI", "MAPS", "SCHOOL"],
|
||||
["AUDIOS", "BOOKMARKS", "DOCUMENTS", "FILES", "IMAGES", "TORRENTS", "VIDEOS", "PODCASTS"]
|
||||
];
|
||||
|
||||
const kindLabel = (kind) => (KIND_LABEL[kind] ? KIND_LABEL[kind]() : String(kind || '').toUpperCase());
|
||||
|
||||
const reasonButton = (value, label, current, q) =>
|
||||
const reasonButton = (value, label, current, filter) =>
|
||||
form({ method: "GET", action: "/data" },
|
||||
input({ type: "hidden", name: "q", value: q || "" }),
|
||||
filter && filter !== "ALL" ? input({ type: "hidden", name: "filter", value: filter }) : null,
|
||||
value ? input({ type: "hidden", name: "reason", value }) : null,
|
||||
button({ type: "submit", class: current === value ? "filter-btn active" : "filter-btn" }, String(label).toUpperCase())
|
||||
);
|
||||
|
||||
const filterButton = (value, label, current, reason) =>
|
||||
form({ method: "GET", action: "/data" },
|
||||
input({ type: "hidden", name: "filter", value }),
|
||||
reason ? input({ type: "hidden", name: "reason", value: reason }) : null,
|
||||
button({ type: "submit", class: current === value ? "filter-btn active" : "filter-btn" }, String(label).toUpperCase())
|
||||
);
|
||||
|
||||
const sectionId = (kind) => `data-${String(kind || '').toLowerCase()}`;
|
||||
|
||||
const renderFilters = (q, payload = {}, kindsPresent = []) => {
|
||||
const matches = Array.isArray(payload.matches) ? payload.matches : [];
|
||||
const reason = String(payload.reason || "");
|
||||
if (!matches.length && !String(q || "").trim() && !reason) return null;
|
||||
const filter = String(payload.filter || "ALL").toUpperCase();
|
||||
const isKindFilter = !FILTER_COLUMNS[0].includes(filter);
|
||||
if (!matches.length && !String(q || "").trim() && !reason && !isKindFilter) return null;
|
||||
const avail = (Array.isArray(payload.reasonsAvail) ? payload.reasonsAvail : []).filter(r => REASON_LABEL[r]);
|
||||
const present = new Set(kindsPresent);
|
||||
const columns = FILTER_COLUMNS.slice(1)
|
||||
.map(col => col.filter(k => present.has(k.toLowerCase())))
|
||||
.filter(col => col.length);
|
||||
const columns = [FILTER_COLUMNS[0]].concat(
|
||||
FILTER_COLUMNS.slice(1)
|
||||
.map(col => col.filter(k => present.has(k.toLowerCase())))
|
||||
.filter(col => col.length)
|
||||
);
|
||||
return section(
|
||||
div({ class: "activity-sub-filter" },
|
||||
reasonButton("", i18n.dataFilterAll, reason, q),
|
||||
...avail.map(r => reasonButton(r, REASON_LABEL[r](), reason, q))
|
||||
reasonButton("", i18n.dataFilterAll, reason, filter),
|
||||
...avail.map(r => reasonButton(r, REASON_LABEL[r](), reason, filter))
|
||||
),
|
||||
reason || !columns.length ? null : div({ class: "activity-filter-grid" },
|
||||
div({ class: "activity-filter-grid" },
|
||||
...columns.map(col =>
|
||||
div({ class: "activity-filter-col" },
|
||||
...col.map(k => a({ href: `#${sectionId(k)}`, class: "filter-btn" }, String(kindLabel(k.toLowerCase())).toUpperCase()))
|
||||
...col.map(k => filterButton(k, FILTER_COLUMNS[0].includes(k) ? (i18n[k + "Button"] || k) : kindLabel(k.toLowerCase()), filter, reason))
|
||||
)
|
||||
)
|
||||
),
|
||||
|
|
@ -94,6 +106,7 @@ const renderFilters = (q, payload = {}, kindsPresent = []) => {
|
|||
renderModuleStats(matches.length),
|
||||
form({ method: "GET", action: "/data", class: "filter-box" },
|
||||
reason ? input({ type: "hidden", name: "reason", value: reason }) : null,
|
||||
filter !== "ALL" ? input({ type: "hidden", name: "filter", value: filter }) : null,
|
||||
input({ type: "text", name: "q", value: q || "", placeholder: i18n.dataSearchPlaceholder, class: "filter-box__input" }),
|
||||
div({ class: "filter-box__controls" },
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
|
||||
|
|
@ -165,7 +178,7 @@ const renderMatchRow = (m, isBest) => {
|
|||
span({ class: "data-kind-chip" }, String(kindLabel(m.kind)).toUpperCase()),
|
||||
span({ class: "card-label data-score-label" }, `${isBest ? i18n.dataBestMatch : (i18n.dataCcShort || "CC")}: `),
|
||||
span({ class: isBest ? "data-score-value data-best-score" : "data-score-value" }, `${scorePct(m.score)}%`),
|
||||
a({ href: m.href, class: "btn-singleview btn-content data-side-visit", title: i18n.visitContent }, "↗")
|
||||
a({ href: m.href, class: "btn-singleview btn-content data-side-visit", title: i18n.visitContent }, renderIcon("open"))
|
||||
),
|
||||
a({ href: m.href, class: "data-card-title" }, shortTitle(m.title) || shortId(m.id)),
|
||||
span({ class: "data-card-author" }, userLink(m.author)),
|
||||
|
|
@ -210,12 +223,12 @@ exports.dataView = async (payload = {}) => {
|
|||
return template(
|
||||
i18n.dataTitle,
|
||||
section(div({ class: "tags-header module-header-line" }, h2(i18n.dataTitle), p(i18n.dataDescription))),
|
||||
renderFilters(q, { ...payload, matches }, groups.map(g => g.kind)),
|
||||
renderFilters(q, { ...payload, matches }, payload.kindsMatched && typeof payload.kindsMatched === "object" ? Object.keys(payload.kindsMatched) : groups.map(g => g.kind)),
|
||||
emptyData ? null : (payload.cohesion ? renderCohesion(payload.cohesion) : null),
|
||||
section(
|
||||
emptyData ? null : div({ class: "tags-header" },
|
||||
h2(q ? `${i18n.dataTopicTitle} #${q}` : i18n.dataMatchesTitle),
|
||||
p(q ? i18n.dataTopicHint : i18n.dataMatchesHint)
|
||||
h2(i18n.dataMatchesTitle),
|
||||
p(i18n.dataMatchesHint)
|
||||
),
|
||||
best
|
||||
? div({ class: "data-section data-top-best" },
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink }
|
|||
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { renderLicenseChip, renderLicenseSelect, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require('./clearnet_view');
|
||||
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions , renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views");
|
||||
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf } = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderStyledText } = require("../backend/renderStyledText");
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ const renderDocumentList = exports.renderDocumentList = (documents, filter, para
|
|||
div({ class: "shop-title-row" }, title ? h2(titleHref ? a({ href: titleHref, class: "card-title-link" }, title) : title) : null, doc.tribeOrigin ? renderTribeOriginChip(doc.tribeOrigin) : doc.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('documents', doc.title, doc.key)) : null, renderLicenseChip(doc.license), ...(typeof params.titleChips === "function" ? params.titleChips(doc) : [])),
|
||||
doc.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(doc.lifetime, i18n)) : null,
|
||||
doc?.url
|
||||
? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": `/blob/${encodeURIComponent(doc.url)}` })
|
||||
? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": blobUrlOf(doc.url) })
|
||||
: p(i18n.documentNoFile),
|
||||
...(typeof params.bodyExtra === "function" ? params.bodyExtra(doc) : []),
|
||||
engagement,
|
||||
|
|
@ -180,7 +180,6 @@ exports.documentView = async (documents, filter = "all", documentId = null, para
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/documents", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
...(emptyMod ? [] : [
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.documentFilterAll).toUpperCase()),
|
||||
|
|
@ -285,7 +284,7 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params =
|
|||
const docMain = div({ class: "tribe-main" },
|
||||
detailActions,
|
||||
doc?.url
|
||||
? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": `/blob/${encodeURIComponent(doc.url)}` })
|
||||
? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": blobUrlOf(doc.url) })
|
||||
: p(i18n.documentNoFile),
|
||||
(() => {
|
||||
const createdTs = doc.createdAt ? new Date(doc.createdAt).getTime() : NaN;
|
||||
|
|
@ -321,7 +320,6 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params =
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/documents", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.documentFilterAll).toUpperCase()),
|
||||
...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.documentFilterRecent).toUpperCase())] : []),
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ const renderFilters = (filter, q, census, emptyMod) =>
|
|||
section(
|
||||
div({ class: "filters" },
|
||||
form({ method: "GET", action: "/emergencies", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q || "" }),
|
||||
...(emptyMod ? [] : [
|
||||
...chipButtons(BASE_FILTERS, filter, census, baseLabel),
|
||||
...chipButtons(SEVERITIES, filter, census, sevLabel),
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ const renderFeedCard = exports.renderFeedCard = (feed, spreadMap = null, opts =
|
|||
const signerId = feed.value.author || "";
|
||||
const refeedsNum = Number(content.refeeds || 0) || 0;
|
||||
const commentCount = Number(content.commentCount || 0);
|
||||
const styledNodes = renderStyledText(safeText, { zoomImages: true });
|
||||
const styledNodes = renderStyledText(safeText, { zoomImages: true, ...(typeof opts.hashtagHref === 'function' ? { hashtagHref: opts.hashtagHref } : {}) });
|
||||
const refeedAction = opts.refeedAction || `/feed/refeed/${encodeURIComponent(feed.key)}`;
|
||||
const canRefeed = opts.canRefeed !== false && !(authorId && String(authorId) === String(me));
|
||||
const headerActions = opts.headerActions !== undefined
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ const renderFileSeeds = (t, spreadMap) => {
|
|||
const info = spreadInfoOf(t, spreadMap);
|
||||
return String(info && typeof info.count === "number" ? info.count : (info && Array.isArray(info.voters) ? info.voters.length : 0));
|
||||
};
|
||||
const renderFileSpread = (t, spreadMap) => String(t.author) === String(userId) ? "" : renderSpreadButton(t.key, spreadInfoOf(t, spreadMap), i18n.seedAction, false);
|
||||
const renderFileSpread = (t, spreadMap) => String(t.author) === String(userId) ? "—" : renderSpreadButton(t.key, spreadInfoOf(t, spreadMap), i18n.seedAction, false);
|
||||
|
||||
const renderFileTable = exports.renderFileTable = (files, filter, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
|
|
@ -86,18 +86,16 @@ const renderFileTable = exports.renderFileTable = (files, filter, params = {}) =
|
|||
{ border: "1", class: "file-table" },
|
||||
tr(
|
||||
th(i18n.createdAt || "DATE"),
|
||||
th(i18n.authorLabel || "AUTHOR"),
|
||||
th(i18n.fileTitleLabel || "TITLE"),
|
||||
th(i18n.fileSizeLabel || "SIZE"),
|
||||
th(i18n.seedsLabel),
|
||||
th(i18n.seedAction),
|
||||
th({ class: "torrent-spread-cell" }, i18n.seedsLabel),
|
||||
th({ class: "torrent-spread-cell" }, i18n.seedAction),
|
||||
th("")
|
||||
),
|
||||
files.map((t) =>
|
||||
tr(
|
||||
td({ class: "row-chips" }, t.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('files', t.title, t.key)) : null),
|
||||
td({ "data-label": i18n.createdAt || "DATE" }, moment(t.createdAt).format("YYYY/MM/DD HH:mm")),
|
||||
td({ class: "row-author" }, userLink(t.author)),
|
||||
td({ class: "row-title", "data-label": i18n.fileTitleLabel || "TITLE" }, t.title || ""),
|
||||
td({ "data-label": i18n.fileSizeLabel || "SIZE" }, formatSize(t.size)),
|
||||
td({ class: "torrent-spread-cell", "data-label": i18n.seedsLabel }, renderFileSeeds(t, params.spreadMap)),
|
||||
|
|
@ -226,7 +224,6 @@ exports.filesView = async (files, filter = "all", fileId = null, params = {}) =>
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/files", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
...(emptyMod ? [] : [
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.fileFilterAll).toUpperCase()),
|
||||
|
|
@ -388,7 +385,7 @@ exports.singleFileView = async (fileObj, filter = "all", comments = [], params =
|
|||
: null
|
||||
);
|
||||
})(),
|
||||
renderEngagement(fileObj.key,
|
||||
fileObj.tribeId ? null : renderEngagement(fileObj.key,
|
||||
renderOpinionsVoting('/files/opinions', fileObj.key, fileObj.opinions, returnTo, fileObj.opinions_inhabitants),
|
||||
renderFileCommentsSection(fileObj.key, comments, returnTo)
|
||||
)
|
||||
|
|
@ -405,7 +402,6 @@ exports.singleFileView = async (fileObj, filter = "all", comments = [], params =
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/files", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.fileFilterAll).toUpperCase()),
|
||||
...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.fileFilterRecent).toUpperCase())] : []),
|
||||
|
|
|
|||
|
|
@ -94,9 +94,12 @@ const buildGraphSvg = (me, peers, links = []) => {
|
|||
+ `</svg>`;
|
||||
};
|
||||
|
||||
const zoomableGraph = (id, svg) => [
|
||||
div({ class: 'graphos-zoom-row' }, a({ href: process.env.OASIS_MOBILE === '1' ? `#${id}-focus` : `#${id}`, id: `${id}-src`, class: 'tribe-action-btn graphos-zoom-btn' }, `\u2922 ${i18n.graphosZoom || 'Enlarge'}`)),
|
||||
div({ class: 'graphos-canvas', [RAW_HTML]: svg }),
|
||||
const zoomableGraph = (id, svg, stats = []) => [
|
||||
div({ class: 'graphos-legend graphos-statsbar' }, ...stats.filter(Boolean)),
|
||||
div({ class: 'graphos-canvas' },
|
||||
a({ href: process.env.OASIS_MOBILE === '1' ? `#${id}-focus` : `#${id}`, id: `${id}-src`, class: 'tribe-action-btn graphos-zoom-btn' }, `\u2922 ${i18n.graphosZoom || 'Enlarge'}`),
|
||||
div({ class: 'graphos-canvas-svg', [RAW_HTML]: svg })
|
||||
),
|
||||
div({ id, class: 'lightbox graphos-lightbox' },
|
||||
a({ href: `#${id}-src`, class: 'lightbox-close' }, '\u00d7'),
|
||||
div({ class: 'graphos-canvas-zoom' },
|
||||
|
|
@ -108,7 +111,7 @@ const zoomableGraph = (id, svg) => [
|
|||
)
|
||||
];
|
||||
|
||||
const kpi = (label, value) => div({ class: 'stats-kpi stats-kpi-inline' },
|
||||
const kpi = (label, value) => span({ class: 'graphos-legend-item graphos-kpi' },
|
||||
span({ class: 'stats-kpi-label' }, String(label).toUpperCase()),
|
||||
span({ class: 'stats-kpi-value' }, String(value))
|
||||
);
|
||||
|
|
@ -153,15 +156,12 @@ exports.graphosView = ({ filter, me, peers, links = [], kpis, focus = null, focu
|
|||
capped
|
||||
? p({ class: 'graphos-cap-note' }, `${i18n.graphosShowing || 'Showing'} ${shown} / ${total}`)
|
||||
: null,
|
||||
div({ class: 'stats-block graphos-stats' },
|
||||
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
|
||||
)
|
||||
),
|
||||
...zoomableGraph('graphos-zoom', buildGraphSvg(me, peers, links)),
|
||||
...zoomableGraph('graphos-zoom', buildGraphSvg(me, peers, links), [
|
||||
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
|
||||
]),
|
||||
div({ class: 'graphos-legend' },
|
||||
legendItem('me', i18n.graphosYou || 'You'),
|
||||
legendItem('online', i18n.online || 'Online'),
|
||||
|
|
@ -267,18 +267,15 @@ exports.graphosFederationView = ({ myName, rows = [], links = [], ownVersion = '
|
|||
)
|
||||
)
|
||||
),
|
||||
div({ class: 'stats-block graphos-stats' },
|
||||
div({ class: 'stats-grid' },
|
||||
kpi(i18n.graphosFedPubs, sorted.length),
|
||||
kpi(i18n.peerStateConnected, count(r => r.state === 'connected')),
|
||||
kpi(i18n.graphosFedUnreachable, count(r => r.state === 'unreachable')),
|
||||
kpi(i18n.peerOutdated, count(outdated)),
|
||||
kpi(i18n.peerErrTor, count(r => r.transport === 'tor')),
|
||||
kpi(i18n.graphosFedLinks, links.filter(l => l.mutual).length),
|
||||
kpi(i18n.graphosFedIsolated, count(r => r.isolated))
|
||||
)
|
||||
),
|
||||
...(sorted.length ? zoomableGraph('graphos-fed-zoom', buildFederationSvg(myName || '', sorted, links, ownVersion)) : [p(i18n.graphosFedEmpty)]),
|
||||
...(sorted.length ? zoomableGraph('graphos-fed-zoom', buildFederationSvg(myName || '', sorted, links, ownVersion), [
|
||||
kpi(i18n.graphosFedPubs, sorted.length),
|
||||
kpi(i18n.peerStateConnected, count(r => r.state === 'connected')),
|
||||
kpi(i18n.graphosFedUnreachable, count(r => r.state === 'unreachable')),
|
||||
kpi(i18n.peerOutdated, count(outdated)),
|
||||
kpi(i18n.peerErrTor, count(r => r.transport === 'tor')),
|
||||
kpi(i18n.graphosFedLinks, links.filter(l => l.mutual).length),
|
||||
kpi(i18n.graphosFedIsolated, count(r => r.isolated))
|
||||
]) : [p(i18n.graphosFedEmpty)]),
|
||||
div({ class: 'graphos-legend' },
|
||||
legendItem('me', i18n.graphosYou || 'You'),
|
||||
span({ class: 'graphos-legend-item' }, span({ class: 'graphos-legend-dot graphos-fed-dot-connected' }), span(i18n.peerStateConnected)),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink }
|
|||
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { renderLicenseChip, renderLicenseSelect, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require('./clearnet_view');
|
||||
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderStateChip, renderContentActions , renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views");
|
||||
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderStateChip, renderContentActions, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf } = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderStyledText } = require("../backend/renderStyledText")
|
||||
const { renderMapLocationVisitLabel, renderMapEmbed } = require("./maps_view");
|
||||
|
|
@ -36,7 +36,7 @@ const renderTags = (tags) => {
|
|||
};
|
||||
|
||||
const renderImageMedia = (imgObj, filter, params = {}) => {
|
||||
const src = imgObj?.url ? `/blob/${encodeURIComponent(imgObj.url)}` : "";
|
||||
const src = imgObj?.url ? blobUrlOf(imgObj.url) : "";
|
||||
|
||||
return imgObj?.url
|
||||
? div(
|
||||
|
|
@ -144,7 +144,7 @@ const renderImageForm = (filter, imageId, imageToEdit, params = {}) => {
|
|||
br(),
|
||||
br(),
|
||||
imageToEdit?.url
|
||||
? img({ loading: 'lazy', src: `/blob/${encodeURIComponent(imageToEdit.url)}`, class: "media-preview", alt: imageToEdit?.title || "" })
|
||||
? img({ loading: 'lazy', src: blobUrlOf(imageToEdit.url), class: "media-preview", alt: imageToEdit?.title || "" })
|
||||
: null,
|
||||
label(i18n.imageTitleLabel),
|
||||
br(),
|
||||
|
|
@ -188,7 +188,7 @@ const renderGallery = (images) => {
|
|||
|
||||
const renderLightbox = (images) =>
|
||||
images.map((imgObj) => {
|
||||
const src = imgObj.url ? `/blob/${encodeURIComponent(imgObj.url)}` : "";
|
||||
const src = imgObj.url ? blobUrlOf(imgObj.url) : "";
|
||||
return div(
|
||||
{ id: `img-${encodeURIComponent(imgObj.key)}`, class: "lightbox" },
|
||||
a({ href: `#img-${encodeURIComponent(imgObj.key)}-src`, class: "lightbox-close" }, "×"),
|
||||
|
|
@ -239,7 +239,6 @@ exports.imageView = async (images, filter = "all", imageId = null, params = {})
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/images", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
...(emptyMod ? [] : [
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterAll).toUpperCase()),
|
||||
|
|
@ -351,7 +350,7 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params
|
|||
? div(
|
||||
{ class: "image-container" },
|
||||
img({
|
||||
src: `/blob/${encodeURIComponent(imageObj.url)}`,
|
||||
src: blobUrlOf(imageObj.url),
|
||||
alt: imageObj.title || "",
|
||||
class: "media-preview",
|
||||
loading: "lazy"
|
||||
|
|
@ -392,7 +391,6 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/images", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterAll).toUpperCase()), ...(mediaChip("favorites") ? [button(
|
||||
...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterRecent).toUpperCase())] : []),
|
||||
|
|
|
|||
|
|
@ -160,11 +160,6 @@ const renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured)
|
|||
fediverseHandle: typeof raw.fediverseHandle === 'string' ? raw.fediverseHandle : ''
|
||||
};
|
||||
return div({ class: 'trending-card inhabitants-card' + (isMe ? ' own-content' : '') },
|
||||
div(
|
||||
{ class: 'card-header activity-card-header' },
|
||||
span(),
|
||||
renderContentActions(user.id, `/inhabitant/${encodeURIComponent(user.id)}`, { author: user.id })
|
||||
),
|
||||
div({ class: 'card-section inhabitants-card-body' },
|
||||
div({ class: 'inhabitant-card' },
|
||||
div({ class: 'inhabitant-left' },
|
||||
|
|
@ -175,7 +170,7 @@ const renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured)
|
|||
user.name ? p({ class: 'inhabitant-left-name' }, userLink(user.id, user.name)) : null,
|
||||
...renderUserSensors({
|
||||
isMe, fediverseConfigured, prefs, id: user.id,
|
||||
karmaScore: user.karmaScore, carbonGrams: user.carbonGrams,
|
||||
karmaScore: user.karmaScore, karmaReceived: user.karmaReceived || null, carbonGrams: user.carbonGrams,
|
||||
deviceSource: user.deviceSource, activityBucket: user.lastActivityBucket,
|
||||
gpgFingerprint: user.gpgFingerprint, ecoAddress: user.ecoAddress,
|
||||
estimatedUBI: user.estimatedUBI, lastClaimedDate: user.lastClaimedDate, totalClaimed: user.totalClaimed,
|
||||
|
|
@ -411,7 +406,7 @@ exports.inhabitantsProfileView = (payload, currentUserId, fediverseConfigured) =
|
|||
safe.oasisVersion ? div({ class: 'profile-side-version' }, '🌴 ' + (i18n.oasisVersionLabel || 'Oasis Version') + ': ', strong(safe.oasisVersion)) : null,
|
||||
...renderUserSensors({
|
||||
isMe, fediverseConfigured, prefs, id: id || viewedId,
|
||||
karmaScore, carbonGrams, deviceSource: safe.deviceSource, activityBucket: providedBucket,
|
||||
karmaScore, karmaReceived: safe.karmaReceived || null, carbonGrams, deviceSource: safe.deviceSource, activityBucket: providedBucket,
|
||||
gpgFingerprint, ecoAddress, estimatedUBI, lastClaimedDate, totalClaimed,
|
||||
larpHouse, stats: safe.stats,
|
||||
mutual: !!(safe.relationship && safe.relationship.following && safe.relationship.followsMe)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const { div, h1, h2, h3, p, section, button, form, input, span, img, a, br, table, tr, td, textarea, label, strong, details, summary } = require("../server/node_modules/hyperaxe");
|
||||
const { renderStyledText } = require("../backend/renderStyledText");
|
||||
const { template, i18n, userLink, paged } = require("./main_views");
|
||||
const { template, i18n, userLink, paged, renderIcon } = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
|
||||
const userId = config.keys.id;
|
||||
|
|
@ -209,7 +209,7 @@ const renderPostsBlock = (posts, house, canPost) => {
|
|||
paged(stream).map(entry => div({ class: entry.post.author === userId ? 'larp-post own-comment' : 'larp-post' },
|
||||
entry.post.author === userId && entry.post.id
|
||||
? form({ method: 'POST', action: `/larp/post/delete/${encodeURIComponent(entry.post.id)}`, class: 'comment-delete-form' },
|
||||
button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.chatDelete }, '✕'))
|
||||
button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.chatDelete }, renderIcon('close')))
|
||||
: null,
|
||||
div({ class: 'larp-post-head' },
|
||||
userLink(entry.post.author),
|
||||
|
|
|
|||
|
|
@ -126,7 +126,6 @@ const renderFilters = (filter, q, zone, census, zones, emptyMod) =>
|
|||
section(
|
||||
div({ class: "filters" },
|
||||
form({ method: "GET", action: "/logistics", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q || "" }),
|
||||
input({ type: "hidden", name: "zone", value: zone || "" }),
|
||||
...(emptyMod ? [] : [
|
||||
...chipButtons(BASE_FILTERS, filter, census, filterLabel),
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ const renderFilters = (filter, q, census, emptyMod) =>
|
|||
section(
|
||||
div({ class: "filters" },
|
||||
form({ method: "GET", action: "/mailing", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q || "" }),
|
||||
...(emptyMod ? [] : [
|
||||
...chipButtons(BASE_FILTERS, filter, census, baseLabel),
|
||||
...chipButtons(TYPE_FILTERS, filter, census, typeLabel),
|
||||
|
|
|
|||
|
|
@ -111,18 +111,27 @@ const renderLiveFeed = (src, imgClass) => {
|
|||
a({ href: `#${id}`, id: `${id}-src`, class: 'zoom-link zoom-inline' }, img({ src, alt: '', class: imgClass })),
|
||||
div({ id, class: 'lightbox live-lightbox' },
|
||||
a({ href: `#${id}-src`, class: 'lightbox-close' }, '\u00d7'),
|
||||
a({ href: `#${id}-full`, class: 'live-fullscreen-btn', title: i18n.liveFullscreen }, renderIcon('fullscreen')),
|
||||
a({ href: `#${id}-src`, class: 'lightbox-image-link' }, img({ src, alt: '', class: 'lightbox-image', loading: 'lazy' }))
|
||||
),
|
||||
div({ id: `${id}-full`, class: 'lightbox live-lightbox live-fullscreen' },
|
||||
a({ href: `#${id}`, class: 'lightbox-close' }, '\u00d7'),
|
||||
a({ href: `#${id}`, class: 'lightbox-image-link' }, img({ src, alt: '', class: 'lightbox-image', loading: 'lazy' }))
|
||||
)
|
||||
];
|
||||
};
|
||||
exports.renderLiveFeed = renderLiveFeed;
|
||||
|
||||
const renderVideoGrid = (peers, me) => {
|
||||
const renderVideoGrid = (peers, me, { film = false } = {}) => {
|
||||
const list = (Array.isArray(peers) ? peers : []).filter(p => p && p.id);
|
||||
const filmName = (...label) => span({ class: 'video-tile-name video-tile-film' }, '▶ ', ...label);
|
||||
const tiles = [
|
||||
me ? div({ class: 'video-tile video-tile-me' }, ...renderLiveFeed('/phone/video/me', 'video-feed'), span({ class: 'video-tile-name' }, i18n.roomYou)) : null,
|
||||
...(Array.isArray(peers) ? peers : []).filter(p => p && p.id && p.on).map(p => div({ class: 'video-tile' }, ...renderLiveFeed(`/phone/video/${encodeURIComponent(p.id)}`, 'video-feed'), userLink(p.id)))
|
||||
film ? div({ class: 'video-tile' }, ...renderLiveFeed(`/phone/video/${encodeURIComponent('film:me')}`, 'video-feed'), filmName(i18n.roomYou)) : null,
|
||||
...list.filter(p => p.on).map(p => div({ class: 'video-tile' }, ...renderLiveFeed(`/phone/video/${encodeURIComponent(p.id)}`, 'video-feed'), userLink(p.id))),
|
||||
...list.filter(p => p.film).map(p => div({ class: 'video-tile' }, ...renderLiveFeed(`/phone/video/${encodeURIComponent(`film:${p.id}`)}`, 'video-feed'), filmName(userLink(p.id))))
|
||||
].filter(Boolean);
|
||||
return tiles.length ? div({ class: 'video-grid' }, ...tiles) : null;
|
||||
return tiles.length ? div({ class: tiles.length === 1 ? 'video-grid video-grid-solo' : 'video-grid' }, ...tiles) : null;
|
||||
};
|
||||
exports.renderVideoGrid = renderVideoGrid;
|
||||
|
||||
|
|
@ -148,7 +157,7 @@ const renderSubscriptionBox = ({ target, scope, subscribed, count, isOwner, retu
|
|||
: button({ type: 'submit', class: subscribed ? 'tribe-action-btn danger-btn' : 'tribe-action-btn' }, `${subscribed ? '🔕' : '🔔'} ${String(subscribed ? i18n.subscriptionUnsubscribe : i18n.subscriptionSubscribe).toUpperCase()}`)
|
||||
),
|
||||
showPm && total
|
||||
? a({ href: `/pm?list=${encodeURIComponent(target)}`, class: 'btn-singleview btn-pm', title: i18n.pmCreateButton || 'Write a PM' }, '✉')
|
||||
? a({ href: `/pm?list=${encodeURIComponent(target)}`, class: 'btn-singleview btn-pm', title: i18n.pmCreateButton || 'Write a PM' }, renderIcon('mail'))
|
||||
: null
|
||||
)
|
||||
];
|
||||
|
|
@ -254,7 +263,7 @@ const renderLinkBox = (blockId, viewHref, title) => {
|
|||
const id = `linkbox-${linkBoxSeq}-${String(blockId).replace(/[^a-zA-Z0-9]/g, '').slice(-10)}`;
|
||||
const clearnetHref = clearnetHrefFor(viewHref, blockId, title);
|
||||
return [
|
||||
a({ href: `#${id}`, id: `${id}-src`, class: 'btn-singleview btn-link', title: i18n.copyLinkTitle }, '⚯'),
|
||||
a({ href: `#${id}`, id: `${id}-src`, class: 'btn-singleview btn-link', title: i18n.copyLinkTitle }, renderIcon('link')),
|
||||
span({ id, class: 'lightbox link-lightbox' },
|
||||
a({ href: `#${id}-src`, class: 'lightbox-close' }, '\u00d7'),
|
||||
span({ class: 'link-box' },
|
||||
|
|
@ -297,6 +306,29 @@ const renderFileDownloads = (fileKey, torrentUrl, name, opts = {}) => {
|
|||
};
|
||||
exports.renderFileDownloads = renderFileDownloads;
|
||||
|
||||
const blobUrlOf = (v) => (String(v || '').startsWith('/blob/sealed/') ? String(v) : `/blob/${encodeURIComponent(v)}`);
|
||||
exports.blobUrlOf = blobUrlOf;
|
||||
const ACTION_ICONS = {
|
||||
hide: '<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/><line x1="3" y1="3" x2="21" y2="21"/>',
|
||||
spread: '<path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/>',
|
||||
pin: '<path d="M9 3h6l-1 6 4 4H6l4-4-1-6z"/><line x1="12" y1="13" x2="12" y2="21"/>',
|
||||
report: '<line x1="5" y1="21" x2="5" y2="3"/><path d="M5 4h12l-2.5 4.5L17 13H5"/>',
|
||||
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/>',
|
||||
call: '<path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 5a2 2 0 0 1 2-2z"/>',
|
||||
link: '<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3.2-3.2a4.5 4.5 0 0 0-6.4-6.4L11.8 5.8"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3.2 3.2a4.5 4.5 0 0 0 6.4 6.4l1.4-1.4"/>',
|
||||
block: '<path d="M12 2.5l8.5 4.75v9.5L12 21.5l-8.5-4.75v-9.5z"/><polyline points="3.5 7.25 12 12 20.5 7.25"/><line x1="12" y1="12" x2="12" y2="21.5"/>',
|
||||
datagram: '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/><line x1="3" y1="12" x2="21" y2="12"/>',
|
||||
open: '<path d="M7 17L17 7M8 7h9v9"/>',
|
||||
donate: '<line x1="12" y1="2" x2="12" y2="22"/><line x1="3.3" y1="7" x2="20.7" y2="17"/><line x1="3.3" y1="17" x2="20.7" y2="7"/><polyline points="9.5 4 12 6 14.5 4"/><polyline points="9.5 20 12 18 14.5 20"/>',
|
||||
torrent: '<line x1="7" y1="20" x2="7" y2="4"/><polyline points="3 8 7 4 11 8"/><line x1="17" y1="4" x2="17" y2="20"/><polyline points="13 16 17 20 21 16"/>',
|
||||
close: '<line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/>',
|
||||
film: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="4" x2="7" y2="20"/><line x1="17" y1="4" x2="17" y2="20"/><line x1="3" y1="12" x2="21" y2="12"/>',
|
||||
fullscreen: '<polyline points="4 9 4 4 9 4"/><polyline points="15 4 20 4 20 9"/><polyline points="20 15 20 20 15 20"/><polyline points="9 20 4 20 4 15"/>'
|
||||
};
|
||||
const renderIcon = (name) => span({ class: 'btn-icon', [RAW_HTML]: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ACTION_ICONS[name] || ''}</svg>` });
|
||||
const renderHideIcon = () => renderIcon('hide');
|
||||
exports.renderIcon = renderIcon;
|
||||
exports.renderHideIcon = renderHideIcon;
|
||||
const renderContentActions = (msgId, viewHref, opts = {}) => {
|
||||
const o = (opts && typeof opts === 'object') ? opts : {};
|
||||
const blockId = (typeof msgId === 'string' && msgId.startsWith('%')) ? msgId : null;
|
||||
|
|
@ -306,7 +338,7 @@ const renderContentActions = (msgId, viewHref, opts = {}) => {
|
|||
const pinBtn = o.favKind && blockId
|
||||
? form({ method: 'POST', action: `/${o.favKind}/favorites/${o.isFavorite ? 'remove' : 'add'}/${encodeURIComponent(blockId)}`, class: 'content-action-form' },
|
||||
o.returnTo ? input({ type: 'hidden', name: 'returnTo', value: o.returnTo }) : null,
|
||||
button({ type: 'submit', class: o.isFavorite ? 'btn-singleview btn-pin-on' : 'btn-singleview', title: o.isFavorite ? i18n.favoriteRemove : i18n.favoriteAdd }, o.isFavorite ? '✜' : '✛')
|
||||
button({ type: 'submit', class: o.isFavorite ? 'btn-singleview btn-pin-on' : 'btn-singleview', title: o.isFavorite ? i18n.favoriteRemove : i18n.favoriteAdd }, renderIcon('pin'))
|
||||
)
|
||||
: null;
|
||||
|
||||
|
|
@ -318,17 +350,17 @@ const renderContentActions = (msgId, viewHref, opts = {}) => {
|
|||
if (!src || typeof src.blobId !== 'string' || !src.blobId.startsWith('&')) return null;
|
||||
try { if (getConfig().modules.torrentsMod !== 'on') return null; } catch (_) { return null; }
|
||||
const name = String(src.name || o.reportTitle || '').slice(0, 100);
|
||||
return a({ href: `/torrents?filter=create&fromBlob=${encodeURIComponent(src.blobId)}&name=${encodeURIComponent(name)}`, class: 'btn-singleview btn-torrent', title: i18n.torrentFromContent }, '\u21C5');
|
||||
return a({ href: `/torrents?filter=create&fromBlob=${encodeURIComponent(src.blobId)}&name=${encodeURIComponent(name)}`, class: 'btn-singleview btn-torrent', title: i18n.torrentFromContent }, renderIcon('torrent'));
|
||||
})();
|
||||
|
||||
const chainBtn = blockId && process.env.OASIS_MOBILE !== '1'
|
||||
? a({ href: `/blockexplorer/block/${encodeURIComponent(blockId)}`, class: 'btn-singleview', title: i18n.blockchainViewBlockexplorer }, '⦿')
|
||||
? a({ href: `/blockexplorer/block/${encodeURIComponent(blockId)}`, class: 'btn-singleview', title: i18n.blockchainViewBlockexplorer }, renderIcon('block'))
|
||||
: null;
|
||||
|
||||
const herePath = (listScope() || {}).path;
|
||||
const onItself = (() => { try { return !!herePath && decodeURIComponent(String(viewHref || '').split(/[?#]/)[0]) === decodeURIComponent(String(herePath)); } catch (_) { return false; } })();
|
||||
const contentBtn = viewHref && !onItself
|
||||
? a({ href: viewHref, class: 'btn-singleview btn-content', title: i18n.visitContent }, '↗')
|
||||
? a({ href: viewHref, class: 'btn-singleview btn-content', title: i18n.visitContent }, renderIcon('open'))
|
||||
: null;
|
||||
|
||||
const donateBtn = (() => {
|
||||
|
|
@ -337,32 +369,41 @@ const renderContentActions = (msgId, viewHref, opts = {}) => {
|
|||
const donatable = sharedState.getDonatableAuthors ? sharedState.getDonatableAuthors() : null;
|
||||
if (!donatable || !donatable.has(String(o.author))) return null;
|
||||
const ref = viewHref ? `?returnTo=${encodeURIComponent(viewHref)}` : '';
|
||||
return a({ href: `/wallet/donate/${encodeURIComponent(o.author)}${ref}`, class: 'btn-singleview btn-donate', title: i18n.donateTooltip }, '\u2744');
|
||||
return a({ href: `/wallet/donate/${encodeURIComponent(o.author)}${ref}`, class: 'btn-singleview btn-donate', title: i18n.donateTooltip }, renderIcon('donate'));
|
||||
})();
|
||||
|
||||
const reportHref = blockId
|
||||
? `/reports?filter=create&category=CONTENT&title=${encodeURIComponent(String(o.reportTitle || blockId).slice(0, 120))}&description=${encodeURIComponent(`${viewHref || blockId}`)}`
|
||||
: null;
|
||||
const reportBtn = o.report !== false && reportHref && !(o.author && String(o.author) === String(myId))
|
||||
? a({ href: reportHref, class: 'btn-singleview btn-report', title: i18n.reportContent }, '⚑')
|
||||
? a({ href: reportHref, class: 'btn-singleview btn-report', title: i18n.reportContent }, renderIcon('report'))
|
||||
: null;
|
||||
|
||||
const pmBtn = o.author && String(o.author) !== String(myId)
|
||||
? a({ href: `/pm?recipients=${encodeURIComponent(o.author)}`, class: 'btn-singleview btn-pm', title: i18n.pmContentTooltip }, '✉')
|
||||
const pmBtn = o.contact === true && o.author && String(o.author) !== String(myId)
|
||||
? a({ href: `/pm?recipients=${encodeURIComponent(o.author)}`, class: 'btn-singleview btn-pm', title: i18n.pmContentTooltip }, renderIcon('mail'))
|
||||
: null;
|
||||
const callBtn = o.author && String(o.author) !== String(myId) && phoneOn() && !sharedState.isPubId(o.author)
|
||||
? a({ href: `/phone?filter=create&to=${encodeURIComponent(o.author)}`, class: 'btn-singleview btn-call', title: i18n.phoneCallTooltip }, '✆')
|
||||
const callBtn = o.contact === true && o.author && String(o.author) !== String(myId) && phoneOn() && !sharedState.isPubId(o.author)
|
||||
? a({ href: `/phone?filter=create&to=${encodeURIComponent(o.author)}`, class: 'btn-singleview btn-call', title: i18n.phoneCallTooltip }, renderIcon('call'))
|
||||
: null;
|
||||
|
||||
const deleteBtn = o.deleteAction
|
||||
? form({ method: 'POST', action: o.deleteAction, class: 'content-action-form content-action-delete' },
|
||||
o.returnTo && o.returnTo !== viewHref ? input({ type: 'hidden', name: 'returnTo', value: o.returnTo }) : null,
|
||||
button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.chatDelete }, '✕')
|
||||
button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.chatDelete }, renderIcon('close'))
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!pinBtn && !spreadBtn && !chainBtn && !contentBtn && !reportBtn && !pmBtn && !callBtn && !deleteBtn && !donateBtn && !torrentBtn) return null;
|
||||
return div({ class: 'content-actions' }, deleteBtn, spreadBtn, pinBtn, reportBtn, donateBtn, pmBtn, callBtn, torrentBtn, linkBtn, chainBtn, contentBtn);
|
||||
const hideBtn = blockId && o.hide !== false && !(o.author && String(o.author) === String(myId))
|
||||
? form({ method: 'POST', action: `/hide/${encodeURIComponent(blockId)}`, class: 'content-action-form' },
|
||||
input({ type: 'hidden', name: 'returnTo', value: o.returnTo || viewHref || '/' }),
|
||||
viewHref ? input({ type: 'hidden', name: 'href', value: viewHref }) : null,
|
||||
o.reportTitle ? input({ type: 'hidden', name: 'label', value: String(o.reportTitle).slice(0, 120) }) : null,
|
||||
button({ type: 'submit', class: 'btn-singleview btn-hide', title: i18n.hideContent }, renderHideIcon())
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!pinBtn && !spreadBtn && !chainBtn && !contentBtn && !reportBtn && !pmBtn && !callBtn && !deleteBtn && !donateBtn && !torrentBtn && !hideBtn) return null;
|
||||
return div({ class: 'content-actions' }, deleteBtn, spreadBtn, pinBtn, reportBtn, hideBtn, donateBtn, pmBtn, callBtn, torrentBtn, linkBtn, chainBtn, contentBtn);
|
||||
};
|
||||
exports.renderContentActions = renderContentActions;
|
||||
const CONTENT_FAV_KIND = {
|
||||
|
|
@ -416,8 +457,9 @@ exports.renderDocumentActions = renderDocumentActions;
|
|||
const renderRelationshipBlock = (relationship, feedId) => {
|
||||
const rel = relationship || {};
|
||||
if (rel.me) return span({ class: 'status you' }, i18n.relationshipYou);
|
||||
const muted = (() => { try { return require('../models/viewer_filters').isMuted(feedId); } catch (_) { return false; } })();
|
||||
const actions = [];
|
||||
const icons = { follow: '♥', unfollow: '✕', block: '⊘', unblock: '↺' };
|
||||
const icons = { follow: '♥', unfollow: '✕', block: '⊘', unblock: '↺', mute: '◌', unmute: '●' };
|
||||
const addAction = (action) => actions.push(
|
||||
form({ action: `/${action}/${encodeURIComponent(feedId)}`, method: 'post' },
|
||||
button({ type: 'submit', class: 'filter-btn' }, `${icons[action]} ${i18n[action]}`)
|
||||
|
|
@ -426,7 +468,9 @@ const renderRelationshipBlock = (relationship, feedId) => {
|
|||
if (rel.following) addAction('unfollow');
|
||||
else if (rel.blocking) addAction('unblock');
|
||||
else { addAction('follow'); addAction('block'); }
|
||||
if (!rel.blocking) addAction(muted ? 'unmute' : 'mute');
|
||||
return div({ class: 'relationship-status' },
|
||||
muted ? span({ class: 'status blocked' }, i18n.relationshipMuted) : null,
|
||||
rel.blocking && rel.blockedBy
|
||||
? span({ class: 'status blocked' }, i18n.relationshipMutualBlock)
|
||||
: [
|
||||
|
|
@ -686,7 +730,7 @@ const renderSpreadButton = (msgKey, opts, title = null, showCount = true) => {
|
|||
{ method: 'POST', action: `/spread/${encodeURIComponent(msgKey)}`, class: 'spread-form' },
|
||||
button(
|
||||
{ type: 'submit', class: alreadySpread ? 'btn-singleview btn-spread-on' : 'btn-singleview', title: title || i18n.spreadContent },
|
||||
showCount ? `⟳ ${count}` : '⟳'
|
||||
renderIcon('spread'), showCount ? span({ class: 'btn-count' }, String(count)) : null
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
@ -1946,7 +1990,7 @@ const template = (titlePrefix, ...elements) => {
|
|||
return renderEmergencyBanner(featured, { compact });
|
||||
} catch (_) { return null; }
|
||||
};
|
||||
const uxMode = currentConfig.ux?.current === "ainav" ? "ainav" : currentConfig.ux?.current === "chats" ? "chats" : currentConfig.ux?.current === "feed" ? "feed" : currentConfig.ux?.current === "phone" ? "phone" : "blocks";
|
||||
const uxMode = currentConfig.ux?.current === "ainav" ? "ainav" : currentConfig.ux?.current === "chats" ? "chats" : currentConfig.ux?.current === "feed" ? "feed" : currentConfig.ux?.current === "phone" ? "phone" : currentConfig.ux?.current === "rooms" ? "rooms" : "blocks";
|
||||
const themeLink = link({
|
||||
rel: "stylesheet",
|
||||
href: `/assets/themes/${theme}.css?v=${assetVersion()}`
|
||||
|
|
@ -2039,7 +2083,7 @@ const template = (titlePrefix, ...elements) => {
|
|||
);
|
||||
})(),
|
||||
renderPhoneTopLink(),
|
||||
(uxMode === "chats" || uxMode === "feed" || uxMode === "phone")
|
||||
(uxMode === "chats" || uxMode === "feed" || uxMode === "phone" || uxMode === "rooms")
|
||||
? navLink({ href: "/settings", emoji: "⚙", text: i18n.settings })
|
||||
: null
|
||||
)
|
||||
|
|
@ -2092,7 +2136,7 @@ const template = (titlePrefix, ...elements) => {
|
|||
navLink({ href: "/peers", emoji: "⧖", text: i18n.peers })
|
||||
)
|
||||
)
|
||||
) : (uxMode === "chats" || uxMode === "feed" || uxMode === "phone") ? div(
|
||||
) : (uxMode === "chats" || uxMode === "feed" || uxMode === "phone" || uxMode === "rooms") ? div(
|
||||
{ class: "top-bar-right" },
|
||||
nav(
|
||||
ul(
|
||||
|
|
@ -2151,7 +2195,7 @@ const template = (titlePrefix, ...elements) => {
|
|||
buildPhoneBanner(),
|
||||
buildEmergencyBanner(false),
|
||||
div(
|
||||
{ class: uxMode === "ainav" ? "main-content ainav-only" : uxMode === "chats" ? "main-content chatsux-only" : uxMode === "feed" || uxMode === "phone" ? "main-content chatsux-only" : "main-content" },
|
||||
{ class: uxMode === "ainav" ? "main-content ainav-only" : uxMode === "chats" ? "main-content chatsux-only" : uxMode === "feed" || uxMode === "phone" || uxMode === "rooms" ? "main-content chatsux-only" : "main-content" },
|
||||
uxMode !== "blocks" ? null : div(
|
||||
{ class: "sidebar-left" },
|
||||
nav(
|
||||
|
|
@ -2868,6 +2912,15 @@ const post = ({ msg, aside = false, preview = false, spreadInfo = null }) => {
|
|||
p({ class: "post-text" }, ...renderStyledText(rawText))
|
||||
);
|
||||
}
|
||||
const postTags = String(content.type || '') === 'post' && Array.isArray(content.tags) ? content.tags.filter(Boolean) : [];
|
||||
if (postTags.length) {
|
||||
articleElement = div(
|
||||
articleElement,
|
||||
div({ class: 'card-tags card-tags-mt' },
|
||||
...postTags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
return section(
|
||||
|
|
@ -3158,8 +3211,8 @@ exports.editProfileView = ({ name, description, visibilityPrefs = {}, feedId = '
|
|||
);
|
||||
};
|
||||
|
||||
exports.clearnetBlogView = async ({ msgKey, text, author, authorName, contentWarning, sentAt }) => {
|
||||
const { escapeHtml: esc, renderKindTag, renderClearnetPage, renderRichText } = require('./clearnet_view');
|
||||
exports.clearnetBlogView = async ({ msgKey, text, author, authorName, contentWarning, sentAt, tags = [] }) => {
|
||||
const { escapeHtml: esc, renderKindTag, renderClearnetPage, renderRichText, renderTagChips } = require('./clearnet_view');
|
||||
const rawText = String(text || '');
|
||||
const renderedHtml = sanitizeHtml(renderRichText(rawText));
|
||||
const plainPreview = rawText.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/<[^>]+>/g, '').slice(0, 200);
|
||||
|
|
@ -3189,6 +3242,7 @@ exports.clearnetBlogView = async ({ msgKey, text, author, authorName, contentWar
|
|||
${cw ? `<div class="cn-blog-cw">${cw}</div>` : ''}
|
||||
<hr class="cn-sep"/>
|
||||
<article class="cn-blog-body">${renderedHtml}</article>
|
||||
${renderTagChips(tags)}
|
||||
`;
|
||||
return renderClearnetPage({
|
||||
title: `${titleText} | Oasis`,
|
||||
|
|
@ -3334,7 +3388,7 @@ const buildClearnetHub = ({ items = {}, filterBase, filterType = '', query = '',
|
|||
exports.CLEARNET_HUB_CSS = CLEARNET_HUB_CSS;
|
||||
|
||||
exports.clearnetHubView = async ({ authors = [], items = {}, tribes = [], filterType = '', query = '', page = 1, tribesPage = 1 }) => {
|
||||
const { escapeHtml: esc, renderClearnetPage, blobUrl: cnBlob, CLEARNET_PAGER_CSS, renderClearnetPager } = require('./clearnet_view');
|
||||
const { escapeHtml: esc, renderClearnetPage, renderClearnetHubSearch, blobUrl: cnBlob, CLEARNET_PAGER_CSS, renderClearnetPager } = require('./clearnet_view');
|
||||
const hub = buildClearnetHub({ items, filterBase: '/c', filterType, query, page });
|
||||
const extraCss = CLEARNET_HUB_CSS + CLEARNET_PAGER_CSS + `
|
||||
`;
|
||||
|
|
@ -3361,7 +3415,7 @@ exports.clearnetHubView = async ({ authors = [], items = {}, tribes = [], filter
|
|||
ogDescription: i18n.cnHubDescription,
|
||||
extraCss,
|
||||
body,
|
||||
headerExtra: `<form class="cn-search" method="GET" action="/c">${filterType ? `<input type="hidden" name="type" value="${esc(filterType)}"/>` : ''}<input type="text" name="q" value="${esc(query || '')}" placeholder="${esc(i18n.cnSearchPlaceholder)}" autocomplete="off"/></form>`
|
||||
headerExtra: renderClearnetHubSearch({ type: filterType, query })
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -3516,7 +3570,17 @@ const renderUserSensors = (u, opts = {}) => {
|
|||
})()
|
||||
: null;
|
||||
const items = [];
|
||||
if (show('karma')) items.push(span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(u.karmaScore !== undefined ? u.karmaScore : 0))));
|
||||
const kr = u.karmaReceived && typeof u.karmaReceived === 'object' ? u.karmaReceived : null;
|
||||
const krPos = kr ? Number(kr.positive) || 0 : 0;
|
||||
const krNeg = kr ? Number(kr.negative) || 0 : 0;
|
||||
const krTone = !kr ? '' : krPos > krNeg ? ' karma-value-positive' : krPos < krNeg ? ' karma-value-negative' : ' karma-value-neutral';
|
||||
if (show('karma')) items.push(span({ class: kr ? 'karma-line karma-line-stacked' : 'karma-line' },
|
||||
span({ class: 'karma-line-main' }, `${i18n.bankingUserEngagementScore}: `, strong({ class: 'karma-value' + krTone }, String(u.karmaScore !== undefined ? u.karmaScore : 0))),
|
||||
kr ? span({ class: 'karma-received-row' },
|
||||
span({ class: 'karma-received karma-received-up' }, span({ class: 'karma-arrow' }, '▲'), String(krPos)),
|
||||
span({ class: 'karma-received karma-received-down' }, span({ class: 'karma-arrow' }, '▼'), String(krNeg))
|
||||
) : null
|
||||
));
|
||||
if (show('ecoTax')) items.push(span({ class: 'karma-line eco-tax-line' }, `${i18n.profileVisibilityEcoTax || 'ECO Tax'}: `, strong(fmtCarbon(u.carbonGrams))));
|
||||
if (activityChip) items.push(activityChip);
|
||||
if (deviceChip) items.push(deviceChip);
|
||||
|
|
@ -3587,6 +3651,7 @@ exports.authorView = async ({
|
|||
relationship,
|
||||
ecoAddress,
|
||||
karmaScore = 0,
|
||||
karmaReceived = null,
|
||||
estimatedUBI = 0,
|
||||
lastClaimedDate = null,
|
||||
totalClaimed = 0,
|
||||
|
|
@ -3636,7 +3701,7 @@ exports.authorView = async ({
|
|||
|
||||
const userSensors = renderUserSensors({
|
||||
isMe: isOwnProfile, fediverseConfigured, prefs, id: feedId,
|
||||
karmaScore, carbonGrams, deviceSource, activityBucket: lastActivityBucket,
|
||||
karmaScore, karmaReceived, carbonGrams, deviceSource, activityBucket: lastActivityBucket,
|
||||
gpgFingerprint, ecoAddress, estimatedUBI, lastClaimedDate, totalClaimed,
|
||||
larpHouse, stats,
|
||||
mutual: !!(relationship && relationship.following && relationship.followsMe)
|
||||
|
|
@ -4818,7 +4883,10 @@ exports.privateView = async (messagesInput, filter, decrypted = null, notice = '
|
|||
}
|
||||
|
||||
|
||||
exports.threadView = ({ messages, spreadMap = null }) => {
|
||||
exports.threadView = ({ messages, spreadMap = null, hiddenRoot = false }) => {
|
||||
if (!Array.isArray(messages) || !messages.length) {
|
||||
return template(i18n.hiddenByYou, section(p({ class: 'muted hidden-note' }, i18n.hiddenByYou)));
|
||||
}
|
||||
const rootMessage = messages[0];
|
||||
const rootAuthorName = rootMessage.value.meta.author.name;
|
||||
|
||||
|
|
@ -4829,6 +4897,7 @@ exports.threadView = ({ messages, spreadMap = null }) => {
|
|||
|
||||
const tpl = template(
|
||||
[`@${rootAuthorName}`],
|
||||
hiddenRoot ? p({ class: 'muted hidden-note' }, i18n.hiddenByYou) : null,
|
||||
div(thread(messages, spreadMap))
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const RAW_HTML = Symbol.for('oasis.rawHtml');
|
|||
const { renderStyledText, renderStyledHtml, escapeHtml } = require("../backend/renderStyledText");
|
||||
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton, renderContentActions, renderSpreadEditWarning, renderDocumentActions, renderInviteQrCard, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views");
|
||||
const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton, renderContentActions, renderSpreadEditWarning, renderDocumentActions, renderInviteQrCard, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, renderIcon } = require("./main_views");
|
||||
const { renderEncryptedChip, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require("./clearnet_view");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderMapHtml, resolveView, makeView, fitView, parseView, viewParam, parsePick, MIN_ZOOM, MAX_ZOOM } = require("../maps/map_renderer");
|
||||
|
|
@ -37,8 +37,9 @@ const renderTags = (tags) => {
|
|||
let areaCounter = 0;
|
||||
const lastOf = (v) => (Array.isArray(v) ? v[v.length - 1] : v);
|
||||
const numOrNull = (v) => { const n = parseFloat(lastOf(v)); return isFinite(n) ? n : null; };
|
||||
const isBlob = (v) => !!v && String(v).startsWith("&");
|
||||
const blobSrc = (image, base) => (isBlob(image) ? `${base}/blob/${encodeURIComponent(image)}` : "");
|
||||
const isSealed = (v) => !!v && String(v).startsWith("/blob/sealed/");
|
||||
const isBlob = (v) => !!v && (String(v).startsWith("&") || isSealed(v));
|
||||
const blobSrc = (image, base) => (isSealed(image) ? (base ? "" : String(image)) : (isBlob(image) ? `${base}/blob/${encodeURIComponent(image)}` : ""));
|
||||
|
||||
const buildQuery = (params) => Object.entries(params || {})
|
||||
.filter(([, v]) => v !== undefined && v !== null && String(v) !== "")
|
||||
|
|
@ -174,7 +175,6 @@ const renderMapOwnerActions = (filter, mapObj, params = {}) => {
|
|||
const renderFilters = (filter, q, emptyMod = false, chip = null, search = null) =>
|
||||
div({ class: search ? "filters activity-filter-chips activity-toolbar-row" : "filters" },
|
||||
form({ method: "GET", action: "/maps", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q || "" }),
|
||||
...(emptyMod ? [] : [
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.mapFilterAll).toUpperCase()),
|
||||
...(!chip || chip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.mapFilterRecent).toUpperCase())] : []),
|
||||
|
|
@ -362,7 +362,7 @@ const renderPinPopup = (mk, i, ctx = {}) => {
|
|||
(ctx.centerHref ? `<a class="map-center-link" href="${escapeHtml(ctx.centerHref)}">${escapeHtml(i18n.mapCenterHere)}</a>` : "") +
|
||||
(own ? form({ method: "POST", action: contentDeleteAction('mapMarker', mk.key) },
|
||||
ctx.returnTo ? input({ type: "hidden", name: "returnTo", value: ctx.returnTo }) : null,
|
||||
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.mapDeleteMarker }, "✕")).outerHTML : "") + `</div>`
|
||||
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.mapDeleteMarker }, renderIcon("close"))).outerHTML : "") + `</div>`
|
||||
];
|
||||
return parts.join("");
|
||||
};
|
||||
|
|
@ -393,7 +393,7 @@ const renderMarkersList = (markers, mapObj, ctx = {}) => {
|
|||
centerHref ? a({ href: centerHref, class: "map-center-link" }, i18n.mapCenterHere) : null,
|
||||
own ? form({ method: "POST", action: contentDeleteAction('mapMarker', mk.key) },
|
||||
ctx.returnTo ? input({ type: "hidden", name: "returnTo", value: ctx.returnTo }) : null,
|
||||
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.mapDeleteMarker }, "✕")) : null));
|
||||
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.mapDeleteMarker }, renderIcon("close"))) : null));
|
||||
})));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -531,7 +531,6 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/market", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "minPrice", value: minPrice ?? "" }),
|
||||
input({ type: "hidden", name: "maxPrice", value: maxPrice ?? "" }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const detailHref = (type, key) => {
|
|||
case 'file': return `/files/${encodeURIComponent(key)}`;
|
||||
case 'document': return `/documents/${encodeURIComponent(key)}`;
|
||||
case 'feed': return `/feed/${encodeURIComponent(key)}`;
|
||||
case 'post': return `/blogs/${encodeURIComponent(key)}`;
|
||||
case 'votes': return `/votes/${encodeURIComponent(key)}`;
|
||||
case 'transfer': return `/transfers/${encodeURIComponent(key)}`;
|
||||
case 'industry': return `/industry/${encodeURIComponent(key)}`;
|
||||
|
|
@ -264,6 +265,16 @@ const renderContentHtml = (content, key) => {
|
|||
)
|
||||
);
|
||||
}
|
||||
case 'post':
|
||||
return div({ class: 'opinion-blog' },
|
||||
div({ class: 'card-section blog' },
|
||||
content.contentWarning ? div({ class: 'shop-title-row' }, h2({ class: 'tribe-card-title' }, a({ href: `/blogs/${encodeURIComponent(key)}` }, content.contentWarning))) : "",
|
||||
div({ class: 'blog-card-text', [RAW_HTML]: sanitizeHtml(renderStyledHtml(String(content.text || '').trim().length > 420 ? String(content.text).trim().slice(0, 420).replace(/\s+$/, '') + '…' : String(content.text || '').trim())) }),
|
||||
Array.isArray(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 'feed':
|
||||
return div({ class: 'opinion-feed' },
|
||||
div({ class: 'card-section feed' },
|
||||
|
|
@ -392,12 +403,12 @@ exports.opinionsView = (items, filter, spreadMap = new Map(), q = '', allItems =
|
|||
{ class: 'trending-card opinions-card' + (isOwn ? ' own-content' : '') },
|
||||
div({ class: 'card-header activity-card-header' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-whole' },
|
||||
span({ class: 'pm-exposition-text' }, String(c.type || '').toUpperCase())
|
||||
span({ class: 'pm-exposition-text' }, String(c.type === 'post' ? (i18n.typePost || 'BLOG') : (c.type || '')).toUpperCase())
|
||||
),
|
||||
renderContentActions(key, detailHref(c.type, key), {
|
||||
spread: spreadMap.get(key) || null,
|
||||
author: item.value.author,
|
||||
reportTitle: c.title || c.question || c.concept || c.name || '',
|
||||
reportTitle: c.title || c.question || c.concept || c.name || c.contentWarning || '',
|
||||
...(CONTENT_TORRENTABLE.has(c.type) && typeof c.url === 'string' && c.url.startsWith('&')
|
||||
? { torrentFrom: { blobId: c.url, name: c.title || c.fileName || '' } }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadcastActive = false, technicalPeers = [], versions = {}, staleKeys = [], onionKeys = [], ownVersion = '', lastErrors = {}, paused = false }) => {
|
||||
const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadcastActive = false, technicalPeers = [], versions = {}, staleKeys = [], onionKeys = [], ownVersion = '', lastErrors = {}, paused = false, filter = 'status', advanced = false, health = null, lan = null }) => {
|
||||
const { form, button, div, h2, p, section, a, hr, input, label, br, span, table, tr, td, textarea, wbr } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, renderStateChip } = require('./main_views');
|
||||
const { template, i18n, renderStateChip, renderModuleStats } = require('./main_views');
|
||||
const { NET_REASON_KEYS, canonicalKey } = require('../models/peer_health');
|
||||
const majorMinor = (v) => String(v || '').split('.').slice(0, 2).map(n => parseInt(n, 10) || 0);
|
||||
const isOutdated = (v) => {
|
||||
|
|
@ -10,6 +10,8 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
|
|||
};
|
||||
const stale = new Set(staleKeys || []);
|
||||
const outdatedKey = (k) => isOutdated((versions || {})[k]) || (!(versions || {})[k] && stale.has(k));
|
||||
const knowsVersion = (keys) => keys.some(k => (versions || {})[k] || stale.has(k));
|
||||
const unspecifiedHost = (h) => /^(0\.0\.0\.0|::|\[::\])$/.test(String(h || ''));
|
||||
const versionCell = (k) => {
|
||||
const v = (versions || {})[k];
|
||||
if (!v) return [stale.has(k) ? span({ title: i18n.peerVersionUnannounced }, renderStateChip('closed', null, String(i18n.peerOutdated).toUpperCase())) : '—'];
|
||||
|
|
@ -56,7 +58,7 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
|
|||
};
|
||||
const onion = new Set(onionKeys || []);
|
||||
const overTor = (peerData) => /^onion:/i.test(String(peerData[0] || '')) || /\.onion$/i.test(String((peerData[1] || {}).host || '')) || onion.has(canonicalKey((peerData[1] || {}).key));
|
||||
const renderPeerRow = (peerData) => {
|
||||
const renderPeerRow = (peerData, showVersion) => {
|
||||
const peer = peerData[1];
|
||||
const { name, users, key } = peer;
|
||||
const tor = overTor(peerData);
|
||||
|
|
@ -64,9 +66,9 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
|
|||
const filteredUsers = (users || []).filter(u => u.id !== key);
|
||||
const userCount = Array.isArray(users) ? filteredUsers.length : null;
|
||||
return tr(
|
||||
td({ 'data-label': i18n.peerHost || 'Pub' }, a({ href: peerUrl, class: "user-link", title: name || key }, name ? shortHost(name) : key.slice(0, 20) + '…')),
|
||||
td({ 'data-label': i18n.peerHost || 'Pub' }, unspecifiedHost(name) ? '—' : a({ href: peerUrl, class: "user-link", title: name || key }, name ? shortHost(name) : key.slice(0, 20) + '…')),
|
||||
td({ 'data-label': i18n.peersOasisId || 'Oasis ID' }, a({ href: peerUrl, class: 'user-link peer-key' }, key)),
|
||||
td({ 'data-label': i18n.peersOasisVersion || 'Version' }, ...versionCell(key)),
|
||||
showVersion ? td({ 'data-label': i18n.peersOasisVersion || 'Version' }, ...versionCell(key)) : null,
|
||||
td({ 'data-label': i18n.peersTorColumn }, tor
|
||||
? span({ class: 'ubi-tick-ok', title: i18n.peersTorYes }, '✓')
|
||||
: span({ class: 'peer-tor-no', title: i18n.peersTorNo }, '—')),
|
||||
|
|
@ -84,18 +86,20 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
|
|||
|
||||
const renderPeerTable = (peers, emptyKey) => {
|
||||
if (peers.length === 0) return p(i18n[emptyKey] || i18n.noConnections);
|
||||
const showVersion = knowsVersion(peers.map(pd => pd[1].key));
|
||||
return table({ class: 'block-info-table' },
|
||||
tr(
|
||||
td({ class: 'card-label' }, i18n.peerHost || 'Pub'),
|
||||
td({ class: 'card-label' }, i18n.peersOasisId || 'Oasis ID'),
|
||||
td({ class: 'card-label' }, i18n.peersOasisVersion || 'Version'),
|
||||
showVersion ? td({ class: 'card-label' }, i18n.peersOasisVersion || 'Version') : null,
|
||||
td({ class: 'card-label' }, i18n.peersTorColumn),
|
||||
td({ class: 'card-label' }, i18n.peersReplicatedFeeds || 'Replicated')
|
||||
),
|
||||
...peers.map(renderPeerRow)
|
||||
...peers.map(pd => renderPeerRow(pd, showVersion))
|
||||
);
|
||||
};
|
||||
|
||||
const techVersion = knowsVersion((technicalPeers || []).map(tp => tp.key));
|
||||
const technicalRows = (technicalPeers || []).map(tp => {
|
||||
const k = tp.key || '';
|
||||
const connected = tp.state === 'connected';
|
||||
|
|
@ -105,9 +109,9 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
|
|||
td({ 'data-label': i18n.peersOasisId }, a({ href: `/author/${encodeURIComponent(k)}`, class: 'user-link peer-key' }, k ? k.slice(0, 20) + '…' : '—')),
|
||||
onion.has(canonicalKey(tp.key)) || /\.onion$/i.test(String(tp.host || ''))
|
||||
? td({ 'data-label': i18n.peerAddressLabel, class: 'peer-host' }, renderStateChip('fediverse', null, 'TOR'))
|
||||
: td({ 'data-label': i18n.peerAddressLabel, title: String(tp.host || ''), class: 'peer-host' }, tp.host ? shortHost(tp.host).split(/(?<=\.)/).reduce((acc, part, i) => i ? [...acc, wbr(), part] : [part], []) : '—'),
|
||||
: td({ 'data-label': i18n.peerAddressLabel, title: unspecifiedHost(tp.host) ? '' : String(tp.host || ''), class: 'peer-host' }, tp.host && !unspecifiedHost(tp.host) ? shortHost(tp.host).split(/(?<=\.)/).reduce((acc, part, i) => i ? [...acc, wbr(), part] : [part], []) : '—'),
|
||||
td({ 'data-label': i18n.peerPort, class: 'peer-port' }, String(tp.port || '—')),
|
||||
td({ 'data-label': i18n.peersOasisVersion || 'Version' }, ...versionCell(k)),
|
||||
techVersion ? td({ 'data-label': i18n.peersOasisVersion || 'Version' }, ...versionCell(k)) : null,
|
||||
td({ 'data-label': i18n.peerStateLabel }, stateCell(tp)),
|
||||
td({ 'data-label': i18n.peerLastChangeLabel }, String(tp.stateChange ? new Date(tp.stateChange).toISOString().slice(0, 16).replace('T', ' ') : '—')),
|
||||
td(
|
||||
|
|
@ -120,31 +124,44 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
|
|||
)
|
||||
);
|
||||
});
|
||||
const refreshButton = form({ action: "/peers/refresh", method: "post" }, button({ type: "submit", class: "filter-btn" }, i18n.peerRefresh || 'Refresh'));
|
||||
const pruneButton = form({ action: "/peers/prune", method: "post" }, button({ type: "submit", class: "filter-btn" }, i18n.peerPruneIdle || 'Remove idle'));
|
||||
const exportButton = form({ action: "/peers/export", method: "get" }, button({ type: "submit", class: "filter-btn" }, i18n.peerExport || 'Export'));
|
||||
const withCount = (label, n) => Number(n) > 0 ? `${label} (${n})` : label;
|
||||
const peersToolbar = (tabName, total, segments) => {
|
||||
if (!(Number(total) > 0)) return null;
|
||||
const back = input({ type: "hidden", name: "returnTo", value: `/peers?filter=${tabName}` });
|
||||
return div({ class: 'filters activity-filter-chips activity-toolbar-row' },
|
||||
renderModuleStats(total, segments),
|
||||
div({ class: "conn-actions peers-conn-actions filter-box" },
|
||||
form({ action: "/peers/refresh", method: "post" }, back, button({ type: "submit", class: "filter-btn" }, i18n.peerRefresh || 'Refresh')),
|
||||
form({ action: "/peers/prune", method: "post" }, back, tabName === 'lan' ? input({ type: "hidden", name: "scope", value: "lan" }) : null, button({ type: "submit", class: "filter-btn" }, i18n.peerPruneIdle || 'Remove idle')),
|
||||
form({ action: "/peers/export", method: "get" }, button({ type: "submit", class: "filter-btn" }, i18n.peerExport || 'Export'))
|
||||
)
|
||||
);
|
||||
};
|
||||
const importForm = form(
|
||||
{ action: "/peers/import", method: "post", enctype: "multipart/form-data", class: "peers-import-form" },
|
||||
label({ class: 'peers-import-label' }, i18n.peerImportTitle || 'Import peer list'),
|
||||
br(),
|
||||
textarea({ name: "peerList", rows: "4", placeholder: i18n.peerImportPlaceholder || 'Paste one multiserver address per line…' }),
|
||||
br(),
|
||||
input({ type: "file", name: "peerFile", accept: ".txt,text/plain" }),
|
||||
br(),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.peerImport || 'Import')
|
||||
);
|
||||
|
||||
const peersTechnicalBlock = div({ class: 'tags-header peers-technical-block' },
|
||||
h2(i18n.peerConnectionsTitle || 'Connections'),
|
||||
const peersTechnicalBlock = div({ class: 'peers-technical-block' },
|
||||
peersToolbar('connections', technicalPeers.length, [
|
||||
{ label: i18n.peerStateConnected, count: technicalPeers.filter(tp => tp.state === 'connected').length },
|
||||
{ label: i18n.peerOutdated, count: technicalPeers.filter(tp => outdatedKey(tp.key)).length },
|
||||
{ label: 'TOR', count: technicalPeers.filter(tp => onion.has(canonicalKey(tp.key)) || /\.onion$/i.test(String(tp.host || ''))).length }
|
||||
]),
|
||||
div({ class: "tags-header" }, h2(withCount(i18n.peerConnectionsTitle || 'Connections', technicalPeers.length)), p(i18n.peersConnectionsHint)),
|
||||
div({ class: "peers-pause-row" }, pauseButton, p({ class: paused ? "peers-paused-note" : "peers-pause-hint" }, paused ? i18n.peersPausedNote : i18n.peersPauseHint)),
|
||||
div({ class: "conn-actions peers-conn-actions filters" }, refreshButton, pruneButton, exportButton),
|
||||
technicalPeers.length
|
||||
? table({ class: 'block-info-table' },
|
||||
tr(
|
||||
td({ class: 'card-label' }, i18n.peersOasisId),
|
||||
td({ class: 'card-label' }, i18n.peerAddressLabel),
|
||||
td({ class: 'card-label peer-port' }, i18n.peerPort),
|
||||
td({ class: 'card-label' }, i18n.peersOasisVersion || 'Version'),
|
||||
techVersion ? td({ class: 'card-label' }, i18n.peersOasisVersion || 'Version') : null,
|
||||
td({ class: 'card-label' }, i18n.peerStateLabel),
|
||||
td({ class: 'card-label' }, i18n.peerLastChangeLabel),
|
||||
td({ class: 'card-label' }, '')
|
||||
|
|
@ -155,6 +172,121 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
|
|||
importForm
|
||||
);
|
||||
|
||||
const fmtDate = (ts) => ts ? new Date(ts).toISOString().slice(0, 16).replace('T', ' ') : '—';
|
||||
const healthRows = health && Array.isArray(health.rows) ? health.rows : [];
|
||||
const healthKind = (r) => {
|
||||
if (r.state === 'connected') return 'connected';
|
||||
if (r.dead) return 'unreachable';
|
||||
if ((Number(r.failures) || 0) > 0 || r.reason) return 'failing';
|
||||
return 'reachable';
|
||||
};
|
||||
const healthChip = (r) => {
|
||||
const k = healthKind(r);
|
||||
if (k === 'connected') return renderStateChip('mutuals', null, String(i18n.peerStateConnected).toUpperCase());
|
||||
if (k === 'unreachable') return renderStateChip('closed', null, String(i18n.graphosFedUnreachable).toUpperCase());
|
||||
if (k === 'failing') return renderStateChip('hidden', null, String(i18n[NET_REASON_KEYS[r.reason] || 'peerErrSilent'] || i18n.peerErrOther).toUpperCase());
|
||||
return renderStateChip(outdatedKey(r.key) ? 'whole' : 'mutuals', null, String(i18n.peersHealthReachable).toUpperCase());
|
||||
};
|
||||
const healthCount = (k) => healthRows.filter(r => healthKind(r) === k).length;
|
||||
const deadCount = healthRows.filter(r => r.dead).length;
|
||||
const healthVersion = knowsVersion(healthRows.map(r => r.key));
|
||||
const healthBlock = !healthRows.length ? div({ class: 'peers-list' }, p(i18n.noConnections)) : div({ class: 'peers-technical-block peers-health-block' },
|
||||
peersToolbar('health', healthRows.length, [
|
||||
{ label: i18n.peersHealthReachable, count: healthCount('connected') + healthCount('reachable') },
|
||||
{ label: i18n.peersHealthFailing, count: healthCount('failing') },
|
||||
{ label: i18n.graphosFedUnreachable, count: deadCount },
|
||||
{ label: i18n.peerOutdated, count: healthRows.filter(r => outdatedKey(r.key)).length },
|
||||
{ label: 'TOR', count: healthRows.filter(r => r.tor).length }
|
||||
]),
|
||||
div({ class: "tags-header" }, h2(withCount(i18n.peersTabHealth, healthRows.length)), p(i18n.peersHealthHint)),
|
||||
deadCount
|
||||
? div({ class: "conn-actions peers-conn-actions filters" },
|
||||
form({ action: "/peers/clear-unreachable", method: "post" }, button({ type: "submit", class: "filter-btn" }, i18n.peersHealthForget)))
|
||||
: null,
|
||||
healthRows.length
|
||||
? table({ class: 'block-info-table' },
|
||||
tr(
|
||||
td({ class: 'card-label' }, i18n.peerHost || 'IP'),
|
||||
td({ class: 'card-label' }, i18n.peersOasisId || 'ID'),
|
||||
td({ class: 'card-label' }, i18n.graphosFedTransport),
|
||||
td({ class: 'card-label' }, i18n.peerStateLabel),
|
||||
td({ class: 'card-label' }, i18n.peersHealthFailures),
|
||||
td({ class: 'card-label' }, i18n.peersFailingSince),
|
||||
healthVersion ? td({ class: 'card-label' }, i18n.peersOasisVersion || 'Version') : null
|
||||
),
|
||||
...healthRows.map(r => tr(
|
||||
r.tor
|
||||
? td({ 'data-label': i18n.peerHost || 'IP', class: 'peer-host' }, renderStateChip('fediverse', null, 'TOR'))
|
||||
: td({ 'data-label': i18n.peerHost || 'IP', title: unspecifiedHost(r.host) ? '' : String(r.host || ''), class: 'peer-host' }, r.host && !unspecifiedHost(r.host) ? shortHost(r.host) : '—'),
|
||||
td({ 'data-label': i18n.peersOasisId || 'ID' }, a({ href: `/author/${encodeURIComponent(r.key)}`, class: 'user-link peer-key', title: r.name || r.key }, r.key)),
|
||||
td({ 'data-label': i18n.graphosFedTransport }, r.tor ? renderStateChip('fediverse', null, 'TOR') : renderStateChip('transport', null, String(i18n.graphosFedClearnet).toUpperCase())),
|
||||
td({ 'data-label': i18n.peerStateLabel }, healthChip(r)),
|
||||
td({ 'data-label': i18n.peersHealthFailures }, String(Number(r.failures) || 0)),
|
||||
td({ 'data-label': i18n.peersFailingSince }, r.failingSince ? fmtDate(r.failingSince) : '—'),
|
||||
healthVersion ? td({ 'data-label': i18n.peersOasisVersion || 'Version' }, ...versionCell(r.key)) : null
|
||||
))
|
||||
)
|
||||
: p(i18n.noConnections)
|
||||
);
|
||||
|
||||
const lanRows = lan && Array.isArray(lan.rows) ? lan.rows : [];
|
||||
const lanOn = !!(lan && lan.broadcasting);
|
||||
const lanVersion = knowsVersion(lanRows.map(r => r.key));
|
||||
const lanBlock = div({ class: 'peers-technical-block peers-lan-block' },
|
||||
peersToolbar('lan', lanRows.length, [
|
||||
{ label: i18n.online, count: lanRows.filter(r => r.online).length },
|
||||
{ label: i18n.peerOutdated, count: lanRows.filter(r => outdatedKey(r.key)).length }
|
||||
]),
|
||||
div({ class: "tags-header" }, h2(withCount(i18n.peersTabLan, lanRows.length)), p(i18n.peersLanHint)),
|
||||
div({ class: "peers-pause-row" },
|
||||
p({ class: "peers-pause-hint" }, `${i18n.settingsLanTitle}: `, renderStateChip(lanOn ? 'mutuals' : 'hidden', null, String(lanOn ? i18n.modulesEnabledModulesLabel : i18n.modulesDisabledModulesLabel).toUpperCase()))
|
||||
),
|
||||
lanRows.length
|
||||
? table({ class: 'block-info-table' },
|
||||
tr(
|
||||
td({ class: 'card-label' }, i18n.peersOasisId || 'ID'),
|
||||
td({ class: 'card-label' }, i18n.peerStateLabel),
|
||||
td({ class: 'card-label' }, i18n.peersLanLastSeen),
|
||||
lanVersion ? td({ class: 'card-label' }, i18n.peersOasisVersion || 'Version') : null,
|
||||
td({ class: 'card-label' }, '')
|
||||
),
|
||||
...lanRows.map(r => tr(
|
||||
td({ 'data-label': i18n.peersOasisId || 'ID' }, a({ href: `/author/${encodeURIComponent(r.key)}`, class: 'user-link peer-key', title: r.name || r.key }, r.key)),
|
||||
td({ 'data-label': i18n.peerStateLabel }, r.online ? renderStateChip('mutuals', null, String(i18n.online).toUpperCase()) : renderStateChip('hidden', null, String(i18n.offline).toUpperCase())),
|
||||
td({ 'data-label': i18n.peersLanLastSeen }, fmtDate(r.seenAt)),
|
||||
lanVersion ? td({ 'data-label': i18n.peersOasisVersion || 'Version' }, ...versionCell(r.key)) : null,
|
||||
td(r.online ? null : form({ method: "POST", action: "/peers/lan/forget", class: "inline-form" },
|
||||
input({ type: "hidden", name: "key", value: r.key }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.delete)
|
||||
))
|
||||
))
|
||||
)
|
||||
: p(i18n.peersLanEmpty)
|
||||
);
|
||||
|
||||
const tabs = ['status', 'connections'].concat(advanced ? ['lan', 'health'] : []);
|
||||
const tab = tabs.includes(filter) ? filter : 'status';
|
||||
const tabLabel = { status: i18n.status || 'Status', connections: i18n.peerConnectionsTitle || 'Connections', lan: i18n.peersTabLan, health: i18n.peersTabHealth };
|
||||
const statusToolbar = peersToolbar('status', onlineCount + discoveredCount + unknownCount, [
|
||||
{ label: i18n.online, count: onlineCount },
|
||||
{ label: i18n.discovered, count: discoveredCount },
|
||||
{ label: i18n.unknown, count: unknownCount }
|
||||
]);
|
||||
const statusBlock = (onlineCount + discoveredCount + unknownCount) > 0
|
||||
? div({ class: "peers-list" },
|
||||
statusToolbar,
|
||||
...[
|
||||
[i18n.online, onlineCount, dedupOnline, 'noConnections', i18n.peersOnlineHint],
|
||||
[i18n.discovered, discoveredCount, dedupDiscovered, 'noDiscovered', i18n.peersDiscoveredHint],
|
||||
[i18n.unknown, unknownCount, dedupUnknown, 'noUnknownPeers', i18n.peersUnknownHint]
|
||||
].filter(([, count]) => count > 0).flatMap(([title, count, list, emptyKey, hint], idx) => [
|
||||
idx ? hr() : null,
|
||||
div({ class: "tags-header" }, h2(withCount(title, count)), p(hint)),
|
||||
renderPeerTable(list, emptyKey)
|
||||
])
|
||||
)
|
||||
: div({ class: "peers-list" }, statusToolbar, p(i18n.noConnections));
|
||||
|
||||
return template(
|
||||
i18n.peers,
|
||||
section(
|
||||
|
|
@ -162,20 +294,14 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
|
|||
h2(i18n.peers),
|
||||
p(i18n.peerConnectionsIntro)
|
||||
),
|
||||
(onlineCount + discoveredCount + unknownCount) > 0
|
||||
? div({ class: "peers-list" },
|
||||
div({ class: "tags-header" }, h2(`${i18n.online} (${onlineCount})`)),
|
||||
renderPeerTable(dedupOnline, 'noConnections'),
|
||||
hr(),
|
||||
div({ class: "tags-header" }, h2(`${i18n.discovered} (${discoveredCount})`)),
|
||||
renderPeerTable(dedupDiscovered, 'noDiscovered'),
|
||||
hr(),
|
||||
div({ class: "tags-header" }, h2(`${i18n.unknown} (${unknownCount})`)),
|
||||
renderPeerTable(dedupUnknown, 'noUnknownPeers')
|
||||
)
|
||||
: null,
|
||||
peersTechnicalBlock,
|
||||
p(i18n.connectionActionIntro)
|
||||
div({ class: 'mode-buttons-row' },
|
||||
...tabs.map(t => a({ class: tab === t ? 'filter-btn active' : 'filter-btn', href: `/peers?filter=${t}` }, String(tabLabel[t]).toUpperCase()))
|
||||
),
|
||||
tab === 'status' ? statusBlock : null,
|
||||
tab === 'connections' ? peersTechnicalBlock : null,
|
||||
tab === 'connections' ? p(i18n.connectionActionIntro) : null,
|
||||
tab === 'lan' ? lanBlock : null,
|
||||
tab === 'health' ? healthBlock : null
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
const { div, h2, p, section, button, form, input, span, audio, table, tr, td, th, br, a } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink, userLinkLabel, renderCallButton, liveClock, renderStateChip, renderVideoGrid } = require("./main_views");
|
||||
const { template, i18n, userLink, userLinkLabel, renderCallButton, liveClock, renderStateChip, renderVideoGrid, renderIcon } = require("./main_views");
|
||||
const { renderEncryptedChip } = require("./clearnet_view");
|
||||
const { phoneNumberOf } = require("../models/phone_number");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
|
||||
const PAM_MAX_MS = require('../server/phone_module').VOICEMAIL_MAX_MS;
|
||||
const GROUP_MAX = require('../server/phone_module').GROUP_MAX;
|
||||
|
||||
const clock = (ms) => {
|
||||
const s = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
|
||||
|
|
@ -105,7 +106,7 @@ const NUMBER_TOKEN = /^\d{3}-\d{3}$/;
|
|||
|
||||
const renderCompose = (compose) => {
|
||||
const recipients = Array.isArray(compose.recipients) ? compose.recipients : [];
|
||||
const max = Number(compose.max) || 7;
|
||||
const max = Number(compose.max) || GROUP_MAX;
|
||||
const dial = String(compose.dial || '');
|
||||
const full = recipients.length >= max;
|
||||
const keep = () => [input({ type: 'hidden', name: 'filter', value: 'create' }), input({ type: 'hidden', name: 'to', value: compose.to || '' })];
|
||||
|
|
@ -127,7 +128,7 @@ const renderCompose = (compose) => {
|
|||
key('clear', '✕', 'phone-key phone-key-fn', { title: i18n.phoneDialClear })
|
||||
)
|
||||
),
|
||||
p({ class: 'phone-empty phone-dial-hint' }, i18n.phoneLimitsHint),
|
||||
p({ class: 'phone-empty phone-dial-hint' }, String(i18n.phoneLimitsHint).replace('{n}', String(max))),
|
||||
div({ class: 'phone-recipients' },
|
||||
span({ class: 'card-label' }, `${i18n.phoneDialInhabitants}: ${recipients.length}/${max}`),
|
||||
recipients.length ? div({ class: 'phone-recipient-list' },
|
||||
|
|
@ -183,7 +184,7 @@ const renderRecords = (pams) =>
|
|||
const deleteCalls = (ids) => ids.length
|
||||
? form({ method: 'POST', action: '/phone/delete-shown', class: 'content-action-form phone-row-delete' },
|
||||
...ids.map(id => input({ type: 'hidden', name: 'calls', value: id })),
|
||||
button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.phoneDelete }, '✕')
|
||||
button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.phoneDelete }, renderIcon('close'))
|
||||
)
|
||||
: null;
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ const renderFilters = (filter, q, census, emptyMod) =>
|
|||
section(
|
||||
div({ class: "filters" },
|
||||
form({ method: "GET", action: "/podcasts", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q || "" }),
|
||||
...(emptyMod ? [] : [
|
||||
...chipButtons(BASE_FILTERS, filter, census, filterLabel),
|
||||
...chipButtons(CATEGORIES, filter, census, catLabel)
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ exports.singlePollView = async (poll, comments = [], params = {}) => {
|
|||
: (poll.voters.length
|
||||
? div({ class: "card-assigned-list" }, ...poll.voters.map(v => userLink(v)))
|
||||
: null),
|
||||
renderEngagement(poll.id,
|
||||
poll.tribeId ? null : renderEngagement(poll.id,
|
||||
renderOpinionsVoting('/polls/opinions', poll.id, poll.opinions, href, poll.opinions_inhabitants),
|
||||
renderComments(poll, comments, basePath)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const { div, h2, p, section, button, form, a, span, br, hr, label, textarea, input, select, option, img, table, tr, td, details, summary, ul, li } = require("../server/node_modules/hyperaxe")
|
||||
const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderContentActions, renderInviteQrCard, renderModuleStatsBy, moduleIsEmpty, liveClock, renderPhoneChip, contentDeleteAction, paged, renderVideoGrid, renderLiveFeed } = require("./main_views")
|
||||
const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderContentActions, renderInviteQrCard, renderModuleStatsBy, moduleIsEmpty, liveClock, renderPhoneChip, contentDeleteAction, paged, renderVideoGrid, renderLiveFeed, renderIcon } = require("./main_views")
|
||||
const { renderEncryptedChip, renderTransportChip, renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require("./clearnet_view")
|
||||
const { renderStyledText } = require("../backend/renderStyledText")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
|
|
@ -68,7 +68,7 @@ const renderModeButtons = (currentFilter, emptyMod = false, modesAvail = null) =
|
|||
)
|
||||
)
|
||||
|
||||
const renderCover = (room, cls) => room.image ? img({ loading: "lazy", src: `/blob/${encodeURIComponent(room.image)}`, class: cls, alt: "" }) : null
|
||||
const renderCover = (room, cls) => room.image ? img({ loading: "lazy", src: String(room.image).startsWith("/blob/sealed/") ? room.image : `/blob/${encodeURIComponent(room.image)}`, class: cls, alt: "" }) : null
|
||||
|
||||
const roomIsFull = (occ) => !!occ && occ.count >= ((occ && occ.max) || ROOM_MAX)
|
||||
|
||||
|
|
@ -194,18 +194,6 @@ const renderRecChip = () => span({ class: "room-rec-chip" }, "● " + String(i18
|
|||
const fmtDuration = (ms) => { const s = Math.max(0, Math.floor((Number(ms) || 0) / 1000)); return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}` }
|
||||
const refreshLink = (room) => a({ href: roomHref(room), class: "tribe-action-btn room-refresh-btn" }, String(i18n.liveRefresh).toUpperCase())
|
||||
const renderLiveControls = (room, live) => div({ class: "room-live-controls" },
|
||||
form({ method: "POST", action: "/rooms/notify", class: "phone-action-form" },
|
||||
input({ type: "hidden", name: "on", value: live.notify ? "0" : "1" }),
|
||||
button({ type: "submit", class: live.notify ? "tribe-action-btn" : "tribe-action-btn room-notify-off" }, "🔔 " + String(live.notify ? i18n.roomNotificationsOn : i18n.roomNotificationsOff).toUpperCase())
|
||||
),
|
||||
form({ method: "POST", action: "/rooms/hand", class: "phone-action-form" },
|
||||
input({ type: "hidden", name: "hand", value: live.hand ? "0" : "1" }),
|
||||
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
|
||||
button({ type: "submit", class: live.hand ? "tribe-action-btn room-hand-btn-on" : "tribe-action-btn" }, "✋ " + String(live.hand ? i18n.roomLowerHand : i18n.roomRaiseHand).toUpperCase())
|
||||
),
|
||||
live.notify && safeArr(live.events).length
|
||||
? form({ method: "POST", action: "/rooms/notify/clear", class: "phone-action-form" }, button({ type: "submit", class: "tribe-action-btn" }, String(i18n.roomClearNotices).toUpperCase()))
|
||||
: null,
|
||||
form({ method: "POST", action: "/rooms/mute", class: "phone-action-form" },
|
||||
input({ type: "hidden", name: "mute", value: live.muted ? "0" : "1" }),
|
||||
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
|
||||
|
|
@ -214,14 +202,40 @@ const renderLiveControls = (room, live) => div({ class: "room-live-controls" },
|
|||
live.videoAvailable
|
||||
? form({ method: "POST", action: "/rooms/camera", class: "phone-action-form" },
|
||||
input({ type: "hidden", name: "cam", value: live.cam ? "0" : "1" }),
|
||||
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
|
||||
input({ type: "hidden", name: "returnTo", value: `${roomHref(room)}#room-video` }),
|
||||
button({ type: "submit", class: live.cam ? "tribe-action-btn room-cam-btn-on" : "tribe-action-btn" }, String(live.cam ? i18n.phoneCameraOff : i18n.phoneCameraOn).toUpperCase())
|
||||
)
|
||||
: null,
|
||||
live.streaming != null
|
||||
? form({ method: "POST", action: "/rooms/stream", class: "phone-action-form" },
|
||||
input({ type: "hidden", name: "stop", value: "1" }),
|
||||
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
|
||||
button({ type: "submit", class: "tribe-action-btn room-cam-btn-on" }, String(i18n.roomStreamStop).toUpperCase())
|
||||
)
|
||||
: live.streamAvailable
|
||||
? form({ method: "POST", action: "/rooms/stream", class: "phone-action-form room-stream-form" },
|
||||
input({ type: "hidden", name: "returnTo", value: `${roomHref(room)}#room-video` }),
|
||||
input({ type: "file", id: "room-stream-file", name: "video", accept: "video/*", required: true, class: "room-stream-input" }),
|
||||
label({ for: "room-stream-file", class: "tribe-action-btn room-stream-pick" }, String(i18n.roomStreamFile).toUpperCase()),
|
||||
button({ type: "submit", class: "tribe-action-btn room-cam-btn-on room-stream-go", title: i18n.roomStreamGo }, "▶")
|
||||
)
|
||||
: null,
|
||||
form({ method: "POST", action: "/rooms/hand", class: "phone-action-form" },
|
||||
input({ type: "hidden", name: "hand", value: live.hand ? "0" : "1" }),
|
||||
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
|
||||
button({ type: "submit", class: live.hand ? "tribe-action-btn room-hand-btn-on" : "tribe-action-btn" }, "✋ " + String(live.hand ? i18n.roomLowerHand : i18n.roomRaiseHand).toUpperCase())
|
||||
),
|
||||
live.recording
|
||||
? form({ method: "POST", action: "/rooms/rec/stop", class: "phone-action-form" }, button({ type: "submit", class: "tribe-action-btn danger-btn" }, String(i18n.roomRecStop).toUpperCase()))
|
||||
: form({ method: "POST", action: "/rooms/rec/start", class: "phone-action-form" }, button({ type: "submit", class: "tribe-action-btn" }, String(i18n.roomRec).toUpperCase())),
|
||||
live.recording || safeArr(live.recordingBy).length ? renderRecChip() : null,
|
||||
form({ method: "POST", action: "/rooms/notify", class: "phone-action-form" },
|
||||
input({ type: "hidden", name: "on", value: live.notify ? "0" : "1" }),
|
||||
button({ type: "submit", class: live.notify ? "tribe-action-btn" : "tribe-action-btn room-notify-off" }, "🔔 " + String(live.notify ? i18n.roomNotificationsOn : i18n.roomNotificationsOff).toUpperCase())
|
||||
),
|
||||
live.notify && safeArr(live.events).length
|
||||
? form({ method: "POST", action: "/rooms/notify/clear", class: "phone-action-form" }, button({ type: "submit", class: "tribe-action-btn" }, String(i18n.roomClearNotices).toUpperCase()))
|
||||
: null,
|
||||
refreshLink(room),
|
||||
form({ method: "POST", action: "/rooms/leave", class: "phone-action-form" },
|
||||
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
|
||||
|
|
@ -245,7 +259,7 @@ const renderRecordings = (recordings) => safeArr(recordings).length
|
|||
span({ class: "phone-call-clock" }, fmtDuration(r.durationMs)),
|
||||
a({ href: `/rooms/recordings/${encodeURIComponent(r.name)}`, class: "tribe-action-btn", download: r.name }, String(i18n.fileDownload).toUpperCase()),
|
||||
form({ method: "POST", action: `/rooms/recordings/${encodeURIComponent(r.name)}/delete`, class: "phone-action-form" },
|
||||
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.delete }, "✕"))
|
||||
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.delete }, renderIcon("close")))
|
||||
)))
|
||||
)
|
||||
: null
|
||||
|
|
@ -260,34 +274,44 @@ const renderLivePanel = (room, live, occ, canJoin = false) => {
|
|||
input({ type: "hidden", name: "on", value: on ? "0" : "1" }),
|
||||
button({ type: "submit", class: on ? "tribe-action-btn danger-btn" : "tribe-action-btn" }, String(on ? i18n.phoneUnsilence : i18n.phoneSilence).toUpperCase())
|
||||
)
|
||||
const who = (id, muted, you, silenced = false, recording = false, turn = 0, cam = false) => div({ class: "phone-contact room-peer" + (turn ? " room-peer-raised" : "") },
|
||||
const isPublic = !!live.public || (room.clearnet === true && clearnetEligible(room))
|
||||
const who = (id, muted, you, silenced = false, recording = false, turn = 0, cam = false, film = false, viewer = false) => viewer
|
||||
? div({ class: "phone-contact room-peer" }, userLink(id), renderReachChip(true, i18n))
|
||||
: div({ class: "phone-contact room-peer" + (turn ? " room-peer-raised" : "") },
|
||||
turn ? span({ class: "room-peer-hand" }, `✋ ${turn}`) : null,
|
||||
userLink(id),
|
||||
you ? span({ class: "room-peer-you" }, i18n.roomYou) : null,
|
||||
muted ? span({ class: "room-peer-muted" }, String(i18n.roomMuted).toUpperCase()) : null,
|
||||
cam ? span({ class: "room-peer-cam" }, "CAM") : null,
|
||||
film ? span({ class: "room-peer-cam" }, "▶") : null,
|
||||
recording ? renderRecChip() : null,
|
||||
you ? null : silenceForm(id, silenced)
|
||||
)
|
||||
return div({ class: "phone-call-panel room-live-panel" },
|
||||
head(div({ class: "card-chips-row" }, renderEncryptedChip(i18n), renderLiveChip({ count: live.count, max: live.max }), live.joinedAt ? span({ class: "phone-call-clock" }, liveClock(live.joinedAt)) : null), renderLiveControls(room, live)),
|
||||
head(div({ class: "card-chips-row" }, renderEncryptedChip(i18n), isPublic ? renderReachChip(true, i18n, roomClearnetHref(room)) : null, renderLiveChip({ count: live.count, max: live.max }), live.joinedAt ? span({ class: "phone-call-clock" }, liveClock(live.joinedAt)) : null, live.streaming != null ? span({ class: "room-stream-chip", title: live.streaming || "" }, "▶ " + String(live.streaming || i18n.roomStreaming)) : null), renderLiveControls(room, live)),
|
||||
live.grid
|
||||
? (safeArr(live.gridOrder).length
|
||||
? div({ class: "video-grid video-grid-one" },
|
||||
? div({ id: "room-video", class: safeArr(live.gridOrder).length === 1 ? "video-grid video-grid-one video-grid-solo" : "video-grid video-grid-one" },
|
||||
...renderLiveFeed("/phone/video/grid", "video-feed video-feed-grid"),
|
||||
div({ class: "video-grid-names" }, ...safeArr(live.gridOrder).map(id => id === userId ? span({ class: "video-tile-name" }, i18n.roomYou) : userLink(id)))
|
||||
div({ class: "video-grid-names" }, ...safeArr(live.gridOrder).map(key => {
|
||||
const film = String(key).startsWith("film:")
|
||||
const id = film ? String(key).slice(5) : key
|
||||
const name = id === userId ? i18n.roomYou : userLink(id)
|
||||
return film ? span({ class: "video-tile-name video-tile-film" }, "▶ ", name) : (id === userId ? span({ class: "video-tile-name" }, name) : name)
|
||||
}))
|
||||
)
|
||||
: null)
|
||||
: renderVideoGrid(safeArr(live.peers).map(p => ({ id: p.id, on: !!p.cam })), !!live.cam),
|
||||
: div({ id: "room-video" }, renderVideoGrid(safeArr(live.peers).map(p => ({ id: p.id, on: !!p.cam, film: !!p.film })), !!live.cam, { film: live.streaming != null })),
|
||||
div({ class: "phone-contacts" }, ...handOrder(live).map(e => e.you
|
||||
? who(userId, live.muted, true, false, !!live.recording, e.turn, !!live.cam)
|
||||
: who(e.peer.id, e.peer.muted, false, !!e.peer.silenced, !!e.peer.recording, e.turn, !!e.peer.cam))),
|
||||
? who(userId, live.muted, true, false, !!live.recording, e.turn, !!live.cam, live.streaming != null)
|
||||
: who(e.peer.id, e.peer.muted, false, !!e.peer.silenced, !!e.peer.recording, e.turn, !!e.peer.cam, !!e.peer.film, !!e.peer.viewer))),
|
||||
renderRoomEvents(live)
|
||||
)
|
||||
}
|
||||
const count = occ ? occ.count : 0
|
||||
return div({ class: "phone-block room-live-panel" },
|
||||
head(p({ class: "phone-empty" }, count > 0 ? String(i18n.roomSomeone).replace("{n}", String(count)) : i18n.roomNobody), canJoin ? div({ class: "room-live-controls" }, refreshLink(room), joinForm(room, occ)) : null)
|
||||
head(p({ class: "phone-empty" }, count > 0 ? String(i18n.roomSomeone).replace("{n}", String(count)) : i18n.roomNobody), canJoin ? div({ class: "room-live-controls" }, refreshLink(room), joinForm(room, occ)) : null),
|
||||
canJoin && room.clearnet === true && clearnetEligible(room) ? p({ class: "room-public-warning" }, i18n.roomPublicWarning) : null
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -327,9 +351,13 @@ exports.singleRoomView = async (room, params = {}) => {
|
|||
|
||||
const ownerActions = isAuthor
|
||||
? [
|
||||
room.isClosed ? null : form({ method: "POST", action: `/rooms/close/${encodeURIComponent(room.rootId)}` },
|
||||
button({ type: "submit", class: "update-btn" }, i18n.roomClose)
|
||||
),
|
||||
room.isClosed
|
||||
? form({ method: "POST", action: `/rooms/reopen/${encodeURIComponent(room.rootId)}` },
|
||||
button({ type: "submit", class: "update-btn" }, i18n.roomReopen)
|
||||
)
|
||||
: form({ method: "POST", action: `/rooms/close/${encodeURIComponent(room.rootId)}` },
|
||||
button({ type: "submit", class: "update-btn delete-btn" }, i18n.roomClose)
|
||||
),
|
||||
form({ method: "GET", action: "/rooms" },
|
||||
input({ type: "hidden", name: "filter", value: "edit" }),
|
||||
input({ type: "hidden", name: "id", value: room.rootId }),
|
||||
|
|
@ -423,8 +451,8 @@ exports.renderTribeRoomsSection = (tribe, rooms, occupancy, live, toolbar = null
|
|||
exports.renderRoomItem = renderRoomItem
|
||||
exports.roomStatsBy = (rooms) => renderModuleStatsBy(safeArr(rooms), r => r.isClosed ? "CLOSED" : r.type, [{ value: "OPEN", label: i18n.roomStatusOpen }, { value: "INVITE-ONLY", label: i18n.roomStatusInviteOnly }, { value: "CLOSED", label: i18n.roomStatusClosed }])
|
||||
|
||||
exports.clearnetRoomView = async (room) => {
|
||||
const { escapeHtml: esc, renderKindTag, renderClearnetPage } = require("./clearnet_view")
|
||||
exports.clearnetRoomView = async (room, { live = false, sound = true, base = "" } = {}) => {
|
||||
const { escapeHtml: esc, renderKindTag, renderClearnetPage, cnZoom } = require("./clearnet_view")
|
||||
const name = room.title || i18n.cnKindRoom
|
||||
const extraCss = `
|
||||
.cn-room-title{color:var(--fg);margin:0 0 16px 0;font-size:32px;font-weight:700;word-break:break-word}
|
||||
|
|
@ -432,6 +460,13 @@ exports.clearnetRoomView = async (room) => {
|
|||
.cn-room-number{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--fg);border-radius:10px;padding:4px 12px;font-size:16px;font-weight:600;color:var(--fg);background:var(--bg-sub)}
|
||||
.cn-room-number .pm-exposition-icon{font-size:18px;line-height:1}
|
||||
.cn-room-number .pm-exposition-text{line-height:1;letter-spacing:1px;font-family:monospace}
|
||||
.cn-room-live{display:flex;flex-direction:column;align-items:center;gap:12px;margin-top:24px}
|
||||
.cn-room-live-chip{align-self:flex-start;border:1px solid #2ecc71;border-radius:10px;padding:3px 10px;color:#2ecc71;font-size:13px;font-weight:700;letter-spacing:1px}
|
||||
.cn-room-live .cn-zoom{display:block;width:100%;max-width:960px}
|
||||
.cn-room-grid{width:100%;aspect-ratio:4/3;object-fit:contain;background:#000;border-radius:8px}
|
||||
.cn-room-sound{width:100%;max-width:960px}
|
||||
.cn-room-live .cn-lightbox-img{width:92vw;height:88vh;object-fit:contain}
|
||||
.cn-room-live .cn-lightbox-full .cn-lightbox-img{width:100vw;height:100vh}
|
||||
`
|
||||
const body = `
|
||||
<h1 class="cn-room-title">${esc(name)}</h1>
|
||||
|
|
@ -439,6 +474,11 @@ exports.clearnetRoomView = async (room) => {
|
|||
${renderKindTag("room")}
|
||||
${room.number ? `<span class="pm-exposition-chip pm-exposition-whole cn-room-number"><span class="pm-exposition-icon">✆</span><span class="pm-exposition-text">${esc(room.number)}</span></span>` : ""}
|
||||
</div>
|
||||
${live && base ? `<section class="cn-room-live">
|
||||
<span class="cn-room-live-chip">● ${esc(String(i18n.roomLiveChip || "LIVE").toUpperCase())}</span>
|
||||
${cnZoom("room-live", `${base}/live`, name, { full: true, imgClass: "cn-room-grid" })}
|
||||
${sound ? `<audio class="cn-room-sound" controls preload="none" src="${esc(`${base}/sound`)}"></audio>` : ""}
|
||||
</section>` : ""}
|
||||
`
|
||||
return renderClearnetPage({
|
||||
title: `${name} | Oasis`,
|
||||
|
|
@ -461,3 +501,26 @@ exports.roomModesFromCensus = (census, me, occupancy) => {
|
|||
closed: list.some(x => x.isClosed)
|
||||
}
|
||||
}
|
||||
|
||||
const pickerSize = (bytes) => {
|
||||
const n = Number(bytes) || 0
|
||||
if (n >= 1073741824) return `${(n / 1073741824).toFixed(1)} GB`
|
||||
if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`
|
||||
return `${Math.max(1, Math.round(n / 1024))} KB`
|
||||
}
|
||||
|
||||
exports.roomStreamMatchesView = ({ files, returnTo }) => template(
|
||||
i18n.roomStreamPickTitle,
|
||||
section(
|
||||
div({ class: "tags-header module-header-line" }, h2(i18n.roomStreamPickTitle), p(i18n.roomStreamPickHint)),
|
||||
div({ class: "mode-buttons-row" }, a({ href: returnTo, class: "filter-btn" }, String(i18n.phoneCancel).toUpperCase())),
|
||||
ul({ class: "stream-picker-list" }, ...safeArr(files).map(f => li({ class: "stream-picker-item" },
|
||||
span({ class: "stream-picker-link" }, renderIcon("film"), span({ class: "stream-picker-name" }, f.path), span({ class: "stream-picker-size" }, pickerSize(f.size))),
|
||||
form({ method: "POST", action: "/rooms/stream", class: "phone-action-form" },
|
||||
input({ type: "hidden", name: "file", value: f.path }),
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ type: "submit", class: "tribe-action-btn" }, String(i18n.roomStreamFile).toUpperCase())
|
||||
)
|
||||
)))
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const { form, button, div, h2, p, section, input, label, select, option, img, audio: audioHyperaxe, video: videoHyperaxe, table, hr, hd, br, td, tr, th, a, span } = require("../server/node_modules/hyperaxe");
|
||||
const RAW_HTML = Symbol.for('oasis.rawHtml');
|
||||
const { template, i18n, userLink, renderContentActions, CONTENT_FAV_KIND, CONTENT_SPREADABLE, CONTENT_TORRENTABLE, paged, currentPerPage, LIST_PAGE_SIZES, pageSizeLabel } = require('./main_views');
|
||||
const { template, i18n, userLink, renderContentActions, renderModuleStats, CONTENT_FAV_KIND, CONTENT_SPREADABLE, CONTENT_TORRENTABLE, paged, currentPerPage, LIST_PAGE_SIZES, pageSizeLabel } = require('./main_views');
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { renderStyledHtml, safeExternalHref } = require('../backend/renderStyledText');
|
||||
const { renderStyledText } = require('../backend/renderStyledText');
|
||||
|
|
@ -18,16 +18,51 @@ const industryStatusLabel = (status) => {
|
|||
};
|
||||
|
||||
|
||||
const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = [], hashtag = null, results = {}, spreadMap = null, favIndex = null }) => {
|
||||
const searchInput = input({
|
||||
id: "search_query",
|
||||
name: "query",
|
||||
required: false,
|
||||
type: "search",
|
||||
value: query,
|
||||
placeholder: i18n.searchPlaceholder
|
||||
});
|
||||
const renderSearchForm = ({ action, query = "", queryName = "query", hidden = [] } = {}) => {
|
||||
const searchInput = input({ id: "search_query", name: queryName, required: false, type: "search", value: query, placeholder: i18n.searchPlaceholder });
|
||||
searchInput.setAttribute("minlength", 3);
|
||||
const resultCount = currentPerPage();
|
||||
const resultsPerPageSelect = select(
|
||||
{ id: "results-per-page", name: "perPage", class: "input-select search-select" },
|
||||
LIST_PAGE_SIZES.map(s => option({ value: s, selected: resultCount === s ? "selected" : undefined }, pageSizeLabel(s)))
|
||||
);
|
||||
return form(
|
||||
{ action, method: "GET", class: "search-form" },
|
||||
...hidden.map(([name, value]) => input({ type: "hidden", name, value })),
|
||||
div({ class: "search-filters-row" },
|
||||
table({ class: "search-filters-table" },
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "search_from" }, i18n.searchFromLabel || "From")),
|
||||
td(input({ id: "search_from", type: "datetime-local", name: "from" }))
|
||||
),
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "search_to" }, i18n.searchToLabel || "To")),
|
||||
td(input({ id: "search_to", type: "datetime-local", name: "to" }))
|
||||
),
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "search_inhabitant" }, "Oasis ID")),
|
||||
td(input({ id: "search_inhabitant", type: "text", name: "inhabitant", placeholder: "@...=.ed25519", pattern: "@[A-Za-z0-9+/_\\-]{43}=\\.ed25519", maxlength: 56, class: "search-oasis-id" }))
|
||||
)
|
||||
),
|
||||
table({ class: "search-filters-table" },
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "search_query" }, i18n.searchQueryLabel || "Query")),
|
||||
td(searchInput)
|
||||
),
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "results-per-page" }, i18n.searchPerPageLabel || "Results per page")),
|
||||
td(resultsPerPageSelect)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: "search-submit-row" },
|
||||
button({ type: "submit" }, i18n.searchSubmit)
|
||||
)
|
||||
);
|
||||
};
|
||||
exports.renderSearchForm = renderSearchForm;
|
||||
|
||||
const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = [], hashtag = null, results = {}, spreadMap = null, favIndex = null }) => {
|
||||
|
||||
const contentTypes = [
|
||||
"post", "about", "curriculum", "tribe", "market", "transfer", "feed", "votes",
|
||||
|
|
@ -49,15 +84,6 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
|
|||
)
|
||||
);
|
||||
|
||||
const resultCount = currentPerPage();
|
||||
const resultsPerPageSelect = select(
|
||||
{
|
||||
id: "results-per-page",
|
||||
name: "perPage",
|
||||
class: "input-select search-select"
|
||||
},
|
||||
LIST_PAGE_SIZES.map(s => option({ value: s, selected: resultCount === s ? "selected" : undefined }, pageSizeLabel(s)))
|
||||
);
|
||||
|
||||
const getViewDetailsActionForSearch = (type, contentId, content) => {
|
||||
switch (type) {
|
||||
|
|
@ -75,7 +101,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
|
|||
case 'bookmark': return `/bookmarks/${encodeURIComponent(contentId)}`;
|
||||
case 'event': return `/events/${encodeURIComponent(contentId)}`;
|
||||
case 'task': return `/tasks/${encodeURIComponent(contentId)}`;
|
||||
case 'post': return `/thread/${encodeURIComponent(contentId)}#${encodeURIComponent(contentId)}`;
|
||||
case 'post': return content && typeof content.root === 'string'
|
||||
? `/thread/${encodeURIComponent(content.root)}#${encodeURIComponent(contentId)}`
|
||||
: `/blogs/${encodeURIComponent(contentId)}`;
|
||||
case 'market': return `/market/${encodeURIComponent(contentId)}`;
|
||||
case 'report': return `/reports/${encodeURIComponent(contentId)}`;
|
||||
case 'project': return `/projects/${encodeURIComponent(contentId)}`;
|
||||
|
|
@ -124,8 +152,11 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
|
|||
switch (content.type) {
|
||||
case 'post':
|
||||
return div({ class: 'search-post' },
|
||||
content.contentWarning ? h2({ class: 'card-field' }, span({ class: 'card-value' }, content.contentWarning)) : null,
|
||||
content.text ? div({ class: 'card-field' }, span({ class: 'card-value', [RAW_HTML]: sanitizeHtml(renderStyledHtml(content.text)) })) : null
|
||||
content.contentWarning ? div({ class: 'shop-title-row' }, h2({ class: 'tribe-card-title' }, content.contentWarning)) : null,
|
||||
content.text ? div({ class: 'blog-card-text post-text-clamped', [RAW_HTML]: sanitizeHtml(renderStyledHtml(String(content.text).trim().length > 1000 ? String(content.text).trim().slice(0, 1000).replace(/\s+$/, '') + '…' : String(content.text).trim())) }) : null,
|
||||
Array.isArray(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 'about':
|
||||
return div({ class: 'card-section about' },
|
||||
|
|
@ -596,6 +627,12 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
|
|||
}
|
||||
};
|
||||
|
||||
const typeLabel = (type) => String(i18n['type' + String(type || '').charAt(0).toUpperCase() + String(type || '').slice(1)] || type || '').toUpperCase();
|
||||
const totalResults = Object.values(results).reduce((n, msgs) => n + (Array.isArray(msgs) ? msgs.length : 0), 0);
|
||||
const resultStats = renderModuleStats(totalResults,
|
||||
Object.entries(results)
|
||||
.map(([type, msgs]) => ({ label: typeLabel(type), count: Array.isArray(msgs) ? msgs.length : 0 }))
|
||||
.sort((x, y) => y.count - x.count || x.label.localeCompare(y.label)));
|
||||
const pageGroups = paged(Object.entries(results).flatMap(([key, msgs]) => msgs.map((msg) => [key, msg])))
|
||||
.reduce((acc, [key, msg]) => { (acc[key] = acc[key] || []).push(msg); return acc; }, {});
|
||||
const resultSection = Object.entries(results).length > 0
|
||||
|
|
@ -642,7 +679,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
|
|||
div({ class: 'card-header activity-card-header' },
|
||||
div({ class: 'card-chips-row' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-whole' },
|
||||
span({ class: 'pm-exposition-text' }, String(content.type || '').toUpperCase())
|
||||
span({ class: 'pm-exposition-text' }, String(i18n['type' + String(content.type || '').charAt(0).toUpperCase() + String(content.type || '').slice(1)] || content.type || '').toUpperCase())
|
||||
)
|
||||
),
|
||||
renderContentActions(contentId, getViewDetailsActionForSearch(content.type, contentId, content), {
|
||||
|
|
@ -676,46 +713,18 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
|
|||
: null);
|
||||
|
||||
let html = template(
|
||||
hashtag ? `#${hashtag}` : i18n.searchTitle,
|
||||
i18n.searchTitle,
|
||||
section(
|
||||
div({ class: "tags-header module-header-line" },
|
||||
h2(hashtag ? `#${hashtag}` : i18n.searchTitle),
|
||||
h2(i18n.searchTitle),
|
||||
p(hashtag ? i18n.hashtagDescription : i18n.searchDescriptionLabel)
|
||||
),
|
||||
form(
|
||||
{ action: "/search", method: "GET", class: "search-form" },
|
||||
div({ class: "search-filters-row" },
|
||||
table({ class: "search-filters-table" },
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "search_from" }, i18n.searchFromLabel || "From")),
|
||||
td(input({ id: "search_from", type: "datetime-local", name: "from" }))
|
||||
),
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "search_to" }, i18n.searchToLabel || "To")),
|
||||
td(input({ id: "search_to", type: "datetime-local", name: "to" }))
|
||||
),
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "search_inhabitant" }, "Oasis ID")),
|
||||
td(input({ id: "search_inhabitant", type: "text", name: "inhabitant", placeholder: "@...=.ed25519", pattern: "@[A-Za-z0-9+/_\\-]{43}=\\.ed25519", maxlength: 56, class: "search-oasis-id" }))
|
||||
)
|
||||
),
|
||||
table({ class: "search-filters-table" },
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "search_query" }, i18n.searchQueryLabel || "Query")),
|
||||
td(searchInput)
|
||||
),
|
||||
tr(
|
||||
td({ class: 'card-label' }, label({ for: "results-per-page" }, i18n.searchPerPageLabel || "Results per page")),
|
||||
td(resultsPerPageSelect)
|
||||
)
|
||||
)
|
||||
),
|
||||
div({ class: "search-submit-row" },
|
||||
button({ type: "submit" }, i18n.searchSubmit)
|
||||
)
|
||||
)
|
||||
renderSearchForm({ action: "/search", query })
|
||||
),
|
||||
section(resultSection)
|
||||
section(
|
||||
resultStats ? div({ class: 'filters activity-filter-chips activity-toolbar-row search-stats-row' }, resultStats) : null,
|
||||
resultSection
|
||||
)
|
||||
);
|
||||
|
||||
if (hasDocument) {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ const { form, button, div, h2, h3, p, section, select, option, input, br, a, lab
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const { template, selectedLanguage, i18n, setLanguage, INBOX_BOT_ORDER, inboxBotLabel, THEMES } = require('./main_views');
|
||||
const { template, selectedLanguage, i18n, setLanguage, INBOX_BOT_ORDER, inboxBotLabel, THEMES, userLink } = require('./main_views');
|
||||
const i18nBase = require("../client/assets/translations/i18n");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { WORKFLOWS, currentWorkflow } = require('../models/workflows_model');
|
||||
const html = require("../server/node_modules/hyperaxe");
|
||||
// selector de identidades: la implementacion vive en ./identities_view.js
|
||||
|
|
@ -12,6 +13,8 @@ const { renderVerificationReport, renderRebuildReport } = require('./backup_view
|
|||
|
||||
const snhUrl = "https://wiki.solarnethub.com/socialnet/overview";
|
||||
const BLOB_CACHE_OPTIONS = [512, 1024, 2048, 5120, 10240, 0];
|
||||
const plainLabel = (s) => String(s || '').replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/\s+/g, ' ').trim();
|
||||
const hiddenAt = (at) => at ? moment(at).format("YYYY/MM/DD HH:mm") : "—";
|
||||
const sizeLabel = (bytes) => {
|
||||
let value = Number(bytes) || 0;
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
|
|
@ -44,7 +47,9 @@ const settingsView = (params) => {
|
|||
const walletUser = currentConfig.wallet.user;
|
||||
const walletFee = currentConfig.wallet.fee;
|
||||
const currentWish = ['mutuals', 'only-lan', 'local'].includes(currentConfig.wish) ? currentConfig.wish : 'whole';
|
||||
const currentPmVisibility = currentConfig.pmVisibility === 'mutuals' ? 'mutuals' : 'whole';
|
||||
const currentPmVisibility = ['mutuals', 'following'].includes(currentConfig.pmVisibility) ? currentConfig.pmVisibility : 'whole';
|
||||
const currentMentionsVisibility = ['mutuals', 'following'].includes(currentConfig.mentionsVisibility) ? currentConfig.mentionsVisibility : 'whole';
|
||||
const viewerHidden = (() => { try { const vf = require('../models/viewer_filters'); return { muted: vf.mutedList(), hidden: vf.hiddenList() }; } catch (_) { return { muted: [], hidden: [] }; } })();
|
||||
const currentPhone = currentConfig.phone && typeof currentConfig.phone === 'object' ? currentConfig.phone : {};
|
||||
|
||||
const themeElements = THEMES.map(([value, name]) => option({ value, ...(theme === value ? { selected: true } : {}) }, name));
|
||||
|
|
@ -115,7 +120,7 @@ const settingsView = (params) => {
|
|||
);
|
||||
}
|
||||
|
||||
const filter = params.filter === 'multiverse' && modOn('fediverse') ? 'multiverse' : 'oasis';
|
||||
const filter = params.filter === 'multiverse' && modOn('fediverse') ? 'multiverse' : params.filter === 'content' ? 'content' : 'oasis';
|
||||
const oasisParts = [
|
||||
updatePanel,
|
||||
renderIdentities(require('../server/ssb_config').account),
|
||||
|
|
@ -156,7 +161,8 @@ const settingsView = (params) => {
|
|||
const aiNavEnabled = currentConfig.modules && currentConfig.modules.aiNavMod === 'on';
|
||||
const chatsEnabled = currentConfig.modules && currentConfig.modules.chatsMod === 'on';
|
||||
const phoneEnabled = !!(currentConfig.modules && currentConfig.modules.phoneMod !== 'off');
|
||||
const cur = currentConfig.ux?.current === "ainav" ? "ainav" : currentConfig.ux?.current === "chats" ? "chats" : currentConfig.ux?.current === "feed" ? "feed" : currentConfig.ux?.current === "phone" ? "phone" : "blocks";
|
||||
const roomsEnabled = !!(currentConfig.modules && currentConfig.modules.roomsMod !== 'off');
|
||||
const cur = currentConfig.ux?.current === "ainav" ? "ainav" : currentConfig.ux?.current === "chats" ? "chats" : currentConfig.ux?.current === "feed" ? "feed" : currentConfig.ux?.current === "phone" ? "phone" : currentConfig.ux?.current === "rooms" ? "rooms" : "blocks";
|
||||
const uxCard = (value, title, image) => label({ class: "welcome-ux-option" },
|
||||
input({ type: "radio", name: "ux", value, ...(cur === value ? { checked: true } : {}) }),
|
||||
img({ src: image, class: "welcome-ux-shot", alt: title }),
|
||||
|
|
@ -167,7 +173,8 @@ const settingsView = (params) => {
|
|||
aiNavEnabled ? uxCard("ainav", i18n.uxModeAINav || "AI", "/assets/images/ux-ainav.png") : null,
|
||||
chatsEnabled ? uxCard("chats", i18n.uxModeChats || "Conversations", "/assets/images/ux-chats.png") : null,
|
||||
uxCard("feed", i18n.uxModeFeed || "Microblogging", "/assets/images/ux-feed.png"),
|
||||
phoneEnabled ? uxCard("phone", i18n.phoneTitle || "Phone", "/assets/images/ux-phone.png") : null
|
||||
phoneEnabled ? uxCard("phone", i18n.phoneTitle || "Phone", "/assets/images/ux-phone.png") : null,
|
||||
roomsEnabled ? uxCard("rooms", i18n.roomsTitle || "Rooms", "/assets/images/ux-rooms.png") : null
|
||||
);
|
||||
})(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
|
|
@ -187,22 +194,6 @@ const settingsView = (params) => {
|
|||
)
|
||||
)
|
||||
),
|
||||
section({ id: "wish" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsWishTitle),
|
||||
p(i18n.settingsWishDesc),
|
||||
form(
|
||||
{ action: "/settings/wish", method: "POST" },
|
||||
select({ name: "wish" },
|
||||
option({ value: "whole", ...(currentWish === "whole" ? { selected: true } : {})}, i18n.settingsWishWhole),
|
||||
option({ value: "mutuals", ...(currentWish === "mutuals" ? { selected: true } : {})}, i18n.settingsWishMutuals),
|
||||
option({ value: "only-lan", ...(currentWish === "only-lan" ? { selected: true } : {})}, i18n.settingsWishOnlyLan || "Only LAN"),
|
||||
option({ value: "local", ...(currentWish === "local" ? { selected: true } : {})}, i18n.settingsWishLocal || "Local")
|
||||
), br(), br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
),
|
||||
workflowSection,
|
||||
section({ id: "home-page" },
|
||||
div({ class: "tags-header" },
|
||||
|
|
@ -282,48 +273,10 @@ const settingsView = (params) => {
|
|||
)
|
||||
)
|
||||
),
|
||||
blobCache ? section({ id: "blobcache" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsBlobCacheTitle),
|
||||
p(i18n.settingsBlobCacheDesc),
|
||||
blobCache.usage ? p({ class: "blob-cache-usage" }, strong(`${i18n.settingsBlobCacheUsage}: `), `${sizeLabel(blobCache.usage.bytes)} · ${blobCache.usage.count} ${i18n.settingsBlobCacheFiles}`) : null,
|
||||
blobCache.cleaned !== null ? p({ class: "blob-cache-notice" }, String(i18n.settingsBlobCacheCleaned).replace('{n}', String(blobCache.cleaned)).replace('{size}', sizeLabel(blobCache.freed))) : null,
|
||||
form(
|
||||
{ action: "/settings/blob-cache", method: "POST" },
|
||||
label({ for: "blob_cache_mb" }, i18n.settingsBlobCacheLimit),
|
||||
br(),
|
||||
select({ id: "blob_cache_mb", name: "blob_cache_mb" }, ...BLOB_CACHE_OPTIONS.map(mb => option({ value: String(mb), ...(mb === blobCache.maxMB ? { selected: true } : {}) }, blobCacheOptionLabel(mb)))),
|
||||
br(), br(),
|
||||
button({ type: "submit" }, i18n.saveSettings),
|
||||
blobCache.maxMB > 0 ? button({ type: "submit", formaction: "/settings/blob-cache/collect", class: "blob-cache-clean-btn" }, i18n.settingsBlobCacheCleanNow) : null
|
||||
)
|
||||
)
|
||||
) : null,
|
||||
section({ id: "replication" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsReplicationTitle || 'Replication'),
|
||||
p(i18n.settingsReplicationDesc || 'Configure the number of hops your peer follows out from your own feed when replicating content.'),
|
||||
form(
|
||||
{ action: "/settings/replication", method: "POST" },
|
||||
label({ for: "replication_hops" }, i18n.settingsReplicationHopsLabel || 'Hops'),
|
||||
br(),
|
||||
input({
|
||||
type: "number",
|
||||
id: "replication_hops",
|
||||
name: "hops",
|
||||
min: 0,
|
||||
max: 6,
|
||||
value: currentHops
|
||||
}),
|
||||
br(), br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
),
|
||||
section({ id: "lan" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsLanTitle || 'LAN Broadcasting'),
|
||||
p(i18n.settingsLanDesc || 'Periodically announce this peer to other Oasis instances on the same local network. Disable to stop UDP broadcasts.'),
|
||||
p(i18n.settingsLanDesc || 'Periodically announce this peer to other Oasis instances on the same local network.'),
|
||||
form(
|
||||
{ action: "/settings/lan-broadcasting", method: "POST" },
|
||||
label({ for: "lanBroadcasting", class: "lan-checkbox-label" },
|
||||
|
|
@ -342,20 +295,6 @@ const settingsView = (params) => {
|
|||
)
|
||||
)
|
||||
),
|
||||
section({ id: "pm-visibility" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsPmVisibilityTitle),
|
||||
p(i18n.settingsPmVisibilityDesc),
|
||||
form(
|
||||
{ action: "/settings/pm-visibility", method: "POST" },
|
||||
select({ name: "pmVisibility" },
|
||||
option({ value: "whole", ...(currentPmVisibility === "whole" ? { selected: true } : {})}, i18n.settingsPmVisibilityWhole),
|
||||
option({ value: "mutuals", ...(currentPmVisibility === "mutuals" ? { selected: true } : {})}, i18n.settingsPmVisibilityMutuals)
|
||||
), br(), br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
),
|
||||
modOn('phone') ? section({ id: "phone" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.phoneTitle),
|
||||
|
|
@ -376,30 +315,6 @@ const settingsView = (params) => {
|
|||
)
|
||||
)
|
||||
) : null,
|
||||
modOn('inbox') ? section({ id: "inbox-bots" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsInboxBotsTitle),
|
||||
p(i18n.settingsInboxBotsDesc),
|
||||
form(
|
||||
{ action: "/settings/inbox-bots", method: "POST" },
|
||||
...INBOX_BOT_ORDER.map(bot =>
|
||||
label({ for: `inbox-bot-${bot}`, class: "lan-checkbox-label inbox-bot-label" },
|
||||
input({
|
||||
type: "checkbox",
|
||||
id: `inbox-bot-${bot}`,
|
||||
name: "bots",
|
||||
value: bot,
|
||||
class: "lan-checkbox-input",
|
||||
checked: (Array.isArray(currentConfig.inboxMutedBots) && currentConfig.inboxMutedBots.includes(bot)) ? undefined : true
|
||||
}),
|
||||
span({ class: "lan-checkbox-text" }, inboxBotLabel(bot))
|
||||
)
|
||||
),
|
||||
br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
) : null,
|
||||
modOn('wallet') ? section(
|
||||
{ id: "wallet" },
|
||||
div({ class: "tags-header" },
|
||||
|
|
@ -461,6 +376,156 @@ const settingsView = (params) => {
|
|||
)
|
||||
)
|
||||
];
|
||||
const contentParts = [
|
||||
section({ id: "wish" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsWishTitle),
|
||||
p(i18n.settingsWishDesc),
|
||||
form(
|
||||
{ action: "/settings/wish", method: "POST" },
|
||||
select({ name: "wish" },
|
||||
option({ value: "whole", ...(currentWish === "whole" ? { selected: true } : {})}, i18n.settingsWishWhole),
|
||||
option({ value: "mutuals", ...(currentWish === "mutuals" ? { selected: true } : {})}, i18n.settingsWishMutuals),
|
||||
option({ value: "only-lan", ...(currentWish === "only-lan" ? { selected: true } : {})}, i18n.settingsWishOnlyLan || "Only LAN"),
|
||||
option({ value: "local", ...(currentWish === "local" ? { selected: true } : {})}, i18n.settingsWishLocal || "Local")
|
||||
), br(), br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
),
|
||||
section({ id: "replication" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsReplicationTitle || 'Replication'),
|
||||
p(i18n.settingsReplicationDesc || 'Configure the number of hops your peer follows out from your own feed when replicating content.'),
|
||||
form(
|
||||
{ action: "/settings/replication", method: "POST" },
|
||||
label({ for: "replication_hops" }, i18n.settingsReplicationHopsLabel || 'Hops'),
|
||||
br(),
|
||||
input({
|
||||
type: "number",
|
||||
id: "replication_hops",
|
||||
name: "hops",
|
||||
min: 0,
|
||||
max: 6,
|
||||
value: currentHops
|
||||
}),
|
||||
br(), br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
),
|
||||
blobCache ? section({ id: "blobcache" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsBlobCacheTitle),
|
||||
p(i18n.settingsBlobCacheDesc),
|
||||
blobCache.usage ? p({ class: "blob-cache-usage" }, strong(`${i18n.settingsBlobCacheUsage}: `), `${sizeLabel(blobCache.usage.bytes)} · ${blobCache.usage.count} ${i18n.settingsBlobCacheFiles}`) : null,
|
||||
blobCache.cleaned !== null ? p({ class: "blob-cache-notice" }, String(i18n.settingsBlobCacheCleaned).replace('{n}', String(blobCache.cleaned)).replace('{size}', sizeLabel(blobCache.freed))) : null,
|
||||
form(
|
||||
{ action: "/settings/blob-cache", method: "POST" },
|
||||
label({ for: "blob_cache_mb" }, i18n.settingsBlobCacheLimit),
|
||||
br(),
|
||||
select({ id: "blob_cache_mb", name: "blob_cache_mb" }, ...BLOB_CACHE_OPTIONS.map(mb => option({ value: String(mb), ...(mb === blobCache.maxMB ? { selected: true } : {}) }, blobCacheOptionLabel(mb)))),
|
||||
br(), br(),
|
||||
button({ type: "submit" }, i18n.saveSettings),
|
||||
blobCache.maxMB > 0 ? button({ type: "submit", formaction: "/settings/blob-cache/collect", class: "blob-cache-clean-btn" }, i18n.settingsBlobCacheCleanNow) : null
|
||||
)
|
||||
)
|
||||
) : null,
|
||||
section({ id: "pm-visibility" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsPmVisibilityTitle),
|
||||
p(i18n.settingsPmVisibilityDesc),
|
||||
form(
|
||||
{ action: "/settings/pm-visibility", method: "POST" },
|
||||
select({ name: "pmVisibility" },
|
||||
option({ value: "whole", ...(currentPmVisibility === "whole" ? { selected: true } : {})}, i18n.settingsPmVisibilityWhole),
|
||||
option({ value: "following", ...(currentPmVisibility === "following" ? { selected: true } : {})}, i18n.settingsPmVisibilityFollowing),
|
||||
option({ value: "mutuals", ...(currentPmVisibility === "mutuals" ? { selected: true } : {})}, i18n.settingsPmVisibilityMutuals)
|
||||
), br(), br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
),
|
||||
section({ id: "mentions-visibility" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsMentionsVisibilityTitle),
|
||||
p(i18n.settingsMentionsVisibilityDesc),
|
||||
form(
|
||||
{ action: "/settings/mentions-visibility", method: "POST" },
|
||||
select({ name: "mentionsVisibility" },
|
||||
option({ value: "whole", ...(currentMentionsVisibility === "whole" ? { selected: true } : {})}, i18n.settingsPmVisibilityWhole),
|
||||
option({ value: "following", ...(currentMentionsVisibility === "following" ? { selected: true } : {})}, i18n.settingsPmVisibilityFollowing),
|
||||
option({ value: "mutuals", ...(currentMentionsVisibility === "mutuals" ? { selected: true } : {})}, i18n.settingsPmVisibilityMutuals)
|
||||
), br(), br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
),
|
||||
modOn('inbox') ? section({ id: "inbox-bots" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsInboxBotsTitle),
|
||||
p(i18n.settingsInboxBotsDesc),
|
||||
form(
|
||||
{ action: "/settings/inbox-bots", method: "POST" },
|
||||
...INBOX_BOT_ORDER.map(bot =>
|
||||
label({ for: `inbox-bot-${bot}`, class: "lan-checkbox-label inbox-bot-label" },
|
||||
input({
|
||||
type: "checkbox",
|
||||
id: `inbox-bot-${bot}`,
|
||||
name: "bots",
|
||||
value: bot,
|
||||
class: "lan-checkbox-input",
|
||||
checked: (Array.isArray(currentConfig.inboxMutedBots) && currentConfig.inboxMutedBots.includes(bot)) ? undefined : true
|
||||
}),
|
||||
span({ class: "lan-checkbox-text" }, inboxBotLabel(bot))
|
||||
)
|
||||
),
|
||||
br(),
|
||||
button({ type: "submit" }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
) : null,
|
||||
section({ id: "hidden" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.settingsHiddenTitle),
|
||||
p(i18n.settingsHiddenDesc),
|
||||
p(strong(`${i18n.settingsMutedLabel} (${viewerHidden.muted.length})`)),
|
||||
viewerHidden.muted.length
|
||||
? div({ class: "viewer-hidden-block" }, table({ class: "block-info-table" },
|
||||
tr(
|
||||
td({ class: "card-label" }, i18n.inhabitantProfileTitle),
|
||||
td({ class: "card-label" }, i18n.date),
|
||||
td({ class: "card-label" }, "")
|
||||
),
|
||||
...viewerHidden.muted.map(m => tr(
|
||||
td({ "data-label": i18n.inhabitantProfileTitle }, userLink(m.id)),
|
||||
td({ "data-label": i18n.date }, hiddenAt(m.at)),
|
||||
td({ "data-label": "" }, form({ action: `/unmute/${encodeURIComponent(m.id)}`, method: "POST" },
|
||||
input({ type: "hidden", name: "returnTo", value: "/settings?filter=content#hidden" }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.unmute)))
|
||||
))
|
||||
))
|
||||
: p({ class: "muted" }, i18n.settingsMutedEmpty),
|
||||
p(strong(`${i18n.settingsHiddenLabel} (${viewerHidden.hidden.length})`)),
|
||||
viewerHidden.hidden.length
|
||||
? div({ class: "viewer-hidden-block" }, table({ class: "block-info-table" },
|
||||
tr(
|
||||
td({ class: "card-label" }, i18n.settingsTabContent),
|
||||
td({ class: "card-label" }, i18n.date),
|
||||
td({ class: "card-label" }, "")
|
||||
),
|
||||
...viewerHidden.hidden.map(h => tr(
|
||||
td({ "data-label": i18n.settingsTabContent }, h.href ? a({ href: h.href }, plainLabel(h.label) || h.id) : span(plainLabel(h.label) || h.id)),
|
||||
td({ "data-label": i18n.date }, hiddenAt(h.at)),
|
||||
td({ "data-label": "" }, form({ action: `/unhide/${encodeURIComponent(h.id)}`, method: "POST" },
|
||||
input({ type: "hidden", name: "returnTo", value: "/settings?filter=content#hidden" }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.unhideContent)))
|
||||
))
|
||||
))
|
||||
: p({ class: "muted" }, i18n.settingsHiddenEmpty)
|
||||
)
|
||||
),
|
||||
];
|
||||
const multiverseParts = modOn('fediverse') ? [
|
||||
section({ id: "mastodon" },
|
||||
div({ class: "tags-header" },
|
||||
|
|
@ -586,10 +651,11 @@ const settingsView = (params) => {
|
|||
),
|
||||
div({ class: "mode-buttons-row" },
|
||||
a({ class: filter === 'oasis' ? 'filter-btn active' : 'filter-btn', href: '/settings?filter=oasis' }, String(i18n.linkBoxOasis).toUpperCase()),
|
||||
a({ class: filter === 'content' ? 'filter-btn active' : 'filter-btn', href: '/settings?filter=content' }, String(i18n.settingsTabContent).toUpperCase()),
|
||||
modOn('fediverse') ? a({ class: filter === 'multiverse' ? 'filter-btn active' : 'filter-btn', href: '/settings?filter=multiverse' }, String(i18n.multiverse).toUpperCase()) : null
|
||||
)
|
||||
),
|
||||
...(filter === 'multiverse' ? multiverseParts : oasisParts)
|
||||
...(filter === 'multiverse' ? multiverseParts : filter === 'content' ? contentParts : oasisParts)
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -80,10 +80,11 @@ const renderTorrentSeeds = (t, spreadMap) => {
|
|||
const info = spreadInfoOf(t, spreadMap);
|
||||
return String(info && typeof info.count === "number" ? info.count : (info && Array.isArray(info.voters) ? info.voters.length : 0));
|
||||
};
|
||||
const renderTorrentSpread = (t, spreadMap) => String(t.author) === String(userId) ? "" : renderSpreadButton(t.key, spreadInfoOf(t, spreadMap), i18n.seedAction, false);
|
||||
const renderTorrentSpread = (t, spreadMap) => String(t.author) === String(userId) ? "—" : renderSpreadButton(t.key, spreadInfoOf(t, spreadMap), i18n.seedAction, false);
|
||||
|
||||
const renderTorrentTable = exports.renderTorrentTable = (torrents, filter, params = {}) => {
|
||||
const returnTo = buildReturnTo(filter, params);
|
||||
const extraColumns = typeof params.rowFor === "function";
|
||||
|
||||
if (!torrents.length) return p(params.q ? i18n.torrentNoMatch : i18n.noTorrents);
|
||||
|
||||
|
|
@ -91,21 +92,18 @@ const renderTorrentTable = exports.renderTorrentTable = (torrents, filter, param
|
|||
{ border: "1", class: "torrent-table" },
|
||||
tr(
|
||||
th(i18n.createdAt || "DATE"),
|
||||
th(i18n.authorLabel || "AUTHOR"),
|
||||
th(i18n.torrentTitleLabel || "TITLE"),
|
||||
th(i18n.torrentSizeLabel || "SIZE"),
|
||||
th(i18n.seedsLabel),
|
||||
th(i18n.seedAction),
|
||||
th({ class: "torrent-spread-cell" }, i18n.seedsLabel),
|
||||
th({ class: "torrent-spread-cell" }, i18n.seedAction),
|
||||
th(""),
|
||||
th(""),
|
||||
th("")
|
||||
...(extraColumns ? [th(""), th("")] : [])
|
||||
),
|
||||
torrents.map((t) => {
|
||||
const row = typeof params.rowFor === "function" ? params.rowFor(t) : null;
|
||||
return tr(
|
||||
td({ class: "row-chips" }, t.tribeOrigin ? renderTribeOriginChip(t.tribeOrigin) : t.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('torrents', t.title, t.key)) : null, ...(row && Array.isArray(row.titleChips) ? row.titleChips.filter(Boolean) : [])),
|
||||
td({ "data-label": i18n.createdAt || "DATE" }, moment(t.createdAt).format("YYYY/MM/DD HH:mm")),
|
||||
td({ class: "row-author" }, userLink(t.author)),
|
||||
td({ class: "row-title", "data-label": i18n.torrentTitleLabel || "TITLE" }, t.title || ""),
|
||||
td({ "data-label": i18n.torrentSizeLabel || "SIZE" }, formatSize(t.size)),
|
||||
td({ class: "torrent-spread-cell", "data-label": i18n.seedsLabel }, t.tribeOrigin || row ? "" : renderTorrentSeeds(t, params.spreadMap)),
|
||||
|
|
@ -124,25 +122,7 @@ const renderTorrentTable = exports.renderTorrentTable = (torrents, filter, param
|
|||
button({ type: "submit", class: "filter-btn" }, i18n.torrentDetailsButton)
|
||||
)
|
||||
),
|
||||
td(
|
||||
row
|
||||
? (row.download || "")
|
||||
: t.url && t.url.startsWith("&")
|
||||
? div({ class: "torrent-card-actions" }, renderTorrentSourceDownload(t.key, t.source), renderTorrentDownload(torrentDownloadHref(t.url, t.title)))
|
||||
: ""
|
||||
),
|
||||
td({ class: "row-actions" },
|
||||
row
|
||||
? (row.actions || "")
|
||||
: t.tribeOrigin ? "" : renderContentActions(t.key, `/torrents/${encodeURIComponent(t.key)}`, {
|
||||
author: t.author,
|
||||
favKind: 'torrents',
|
||||
isFavorite: t.isFavorite,
|
||||
returnTo,
|
||||
reportTitle: t.title,
|
||||
deleteAction: String(t.author) === String(userId) ? contentDeleteAction('torrent', t.key) : undefined
|
||||
})
|
||||
)
|
||||
...(extraColumns ? [td(row ? (row.download || "") : ""), td({ class: "row-actions" }, row ? (row.actions || "") : "")] : [])
|
||||
);
|
||||
})
|
||||
);
|
||||
|
|
@ -308,7 +288,6 @@ exports.torrentsView = async (torrents, filter = "all", torrentId = null, params
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
...(emptyMod ? [] : [
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterAll).toUpperCase()),
|
||||
|
|
@ -449,7 +428,7 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa
|
|||
: null
|
||||
);
|
||||
})(),
|
||||
renderEngagement(torrentObj.key,
|
||||
torrentObj.tribeId ? null : renderEngagement(torrentObj.key,
|
||||
renderOpinionsVoting('/torrents/opinions', torrentObj.key, torrentObj.opinions, returnTo, torrentObj.opinions_inhabitants),
|
||||
renderTorrentCommentsSection(torrentObj.key, comments, returnTo)
|
||||
)
|
||||
|
|
@ -466,7 +445,6 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterAll).toUpperCase()), ...(mediaChip("favorites") ? [button(
|
||||
...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterRecent).toUpperCase())] : []),
|
||||
|
|
|
|||
|
|
@ -295,7 +295,6 @@ exports.transferView = async (transfers, filter, transferId, params = {}) => {
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/transfers", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "minAmount", value: String(minAmountRaw ?? "") }),
|
||||
input({ type: "hidden", name: "maxAmount", value: String(maxAmountRaw ?? "") }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
|
|
@ -558,7 +557,6 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/transfers", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "minAmount", value: String(params.minAmount ?? "") }),
|
||||
input({ type: "hidden", name: "maxAmount", value: String(params.maxAmount ?? "") }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
|
|
|
|||
|
|
@ -11,12 +11,15 @@ const { sanitizeHtml } = require('../backend/sanitizeHtml');
|
|||
|
||||
const userId = config.keys.id;
|
||||
|
||||
const capFirst = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1);
|
||||
const typeLabel = (type) => i18n['type' + capFirst(type)] || type;
|
||||
|
||||
const filterButton = (mode, currentFilter) =>
|
||||
form({ method: 'GET', action: '/trending' },
|
||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||
button(
|
||||
{ type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' },
|
||||
String(i18n[mode + 'Button'] || mode).toUpperCase()
|
||||
String(i18n[mode + 'Button'] || typeLabel(mode)).toUpperCase()
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -162,6 +165,16 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new
|
|||
: null
|
||||
)
|
||||
);
|
||||
} else if (c.type === 'post') {
|
||||
contentHtml = div({ class: 'trending-blog' },
|
||||
div({ class: 'card-section blog' },
|
||||
c.contentWarning ? div({ class: 'shop-title-row' }, h2({ class: 'tribe-card-title' }, a({ href: `/blogs/${encodeURIComponent(item.key)}` }, c.contentWarning))) : "",
|
||||
div({ class: 'blog-card-text', [RAW_HTML]: sanitizeHtml(renderStyledHtml(String(c.text || '').trim().length > 420 ? String(c.text).trim().slice(0, 420).replace(/\s+$/, '') + '…' : String(c.text || '').trim())) }),
|
||||
Array.isArray(c.tags) && c.tags.length
|
||||
? div({ class: 'card-tags' }, c.tags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)))
|
||||
: null
|
||||
)
|
||||
);
|
||||
} else if (c.type === 'feed') {
|
||||
const { text, refeeds } = c;
|
||||
contentHtml = div({ class: 'trending-feed' },
|
||||
|
|
@ -221,6 +234,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new
|
|||
file: 'files',
|
||||
document: 'documents',
|
||||
feed: 'feed',
|
||||
post: 'blogs',
|
||||
votes: 'votes',
|
||||
transfer: 'transfers',
|
||||
industry: 'industry',
|
||||
|
|
@ -249,12 +263,12 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new
|
|||
div(
|
||||
{ class: 'card-header activity-card-header' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-whole' },
|
||||
span({ class: 'pm-exposition-text' }, String(i18n['type' + String(c.type || '').charAt(0).toUpperCase() + String(c.type || '').slice(1)] || c.type || '').toUpperCase())
|
||||
span({ class: 'pm-exposition-text' }, String(typeLabel(c.type) || '').toUpperCase())
|
||||
),
|
||||
renderContentActions(item.key, detailHref, {
|
||||
spread: spreadMap.get(item.key) || null,
|
||||
author: item.value.author,
|
||||
reportTitle: c.title || c.question || c.concept || '',
|
||||
reportTitle: c.title || c.question || c.concept || c.contentWarning || '',
|
||||
...(CONTENT_TORRENTABLE.has(c.type) && typeof c.url === 'string' && c.url.startsWith('&')
|
||||
? { torrentFrom: { blobId: c.url, name: c.title || c.fileName || '' } }
|
||||
: {}),
|
||||
|
|
@ -301,7 +315,7 @@ exports.trendingView = (items, filter, categories = opinionCategories, spreadMap
|
|||
const baseFilters = ['ALL', 'RECENT', 'MINE', 'TOP'];
|
||||
const contentFilters = [
|
||||
['votes', 'event', 'task', 'report'],
|
||||
['feed', 'project', 'industry', 'shopProduct', 'transfer'],
|
||||
['feed', 'post', 'project', 'industry', 'shopProduct', 'transfer'],
|
||||
['audio', 'bookmark', 'document', 'file', 'image', 'torrent', 'video'],
|
||||
['podcast', 'podcastEpisode', 'campaign', 'logisticsRoute']
|
||||
];
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const { renderCalendarCard } = require('./calendars_view');
|
|||
const { renderInhabitantCard } = require('./inhabitants_view');
|
||||
const { config } = require('../server/SSB_server.js');
|
||||
const { renderStyledText, safeExternalHref } = require('../backend/renderStyledText');
|
||||
const { renderSearchForm } = require('./search_view');
|
||||
const { renderMapCard, renderMapLocationVisitLabel, renderMapEmbed } = require("./maps_view");
|
||||
const { renderTribeRoomsSection, roomStatsBy } = require('./rooms_view');
|
||||
|
||||
|
|
@ -110,18 +111,19 @@ const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => {
|
|||
if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
if (s.startsWith('/assets/') || s.startsWith('/blob/') || s.startsWith('/image/')) return img({ loading: 'lazy', src: s, ...attrs })
|
||||
if (s.startsWith('&')) return img({ loading: 'lazy', src: `/blob/${encodeURIComponent(s)}`, ...attrs })
|
||||
const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}`, preload: 'metadata' })
|
||||
const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` })
|
||||
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mImg) return img({ loading: 'lazy', src: `/blob/${encodeURIComponent(mImg[1])}`, ...attrs })
|
||||
const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256|\/blob\/sealed\/[^)\s]+)\s*\)/)
|
||||
if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: toBlobUrl(mVideo[1]), preload: 'metadata' })
|
||||
const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256|\/blob\/sealed\/[^)\s]+)\s*\)/)
|
||||
if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: toBlobUrl(mAudio[1]) })
|
||||
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256|\/blob\/sealed\/[^)\s]+)\s*\)/)
|
||||
if (mImg) return img({ loading: 'lazy', src: toBlobUrl(mImg[1]), ...attrs })
|
||||
return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
}
|
||||
|
||||
const toBlobUrl = (raw) => {
|
||||
if (!raw) return null
|
||||
const s = String(raw).trim()
|
||||
if (s.startsWith('/blob/sealed/')) return s
|
||||
if (s.startsWith('&')) return `/blob/${encodeURIComponent(s)}`
|
||||
const m = s.match(/\((&[^)\s]+\.sha256)\s*\)/)
|
||||
return m ? `/blob/${encodeURIComponent(m[1])}` : null
|
||||
|
|
@ -387,6 +389,15 @@ const sectionLink = (tribe, sectionKey, label, currentSection) =>
|
|||
button({ type: 'submit', class: currentSection === sectionKey ? 'filter-btn active' : 'filter-btn' }, String(label).toUpperCase())
|
||||
);
|
||||
|
||||
const tribeTagHref = (tribe) => (tag) => `/tribe/${encodeURIComponent(tribe.id)}?section=search&q=${encodeURIComponent('#' + String(tag).toLowerCase().replace(/^#+/, ''))}`;
|
||||
const tribeText = (tribe, text, opts = {}) => renderStyledText(String(text || ''), { ...opts, ...(tribe && tribe.id ? { hashtagHref: tribeTagHref(tribe) } : {}) });
|
||||
const tribeExcerpt = (tribe, text, max = 200) => {
|
||||
const full = String(text || '').trim();
|
||||
if (!full) return null;
|
||||
const cut = full.length > max ? full.slice(0, max).replace(/\s+$/, '') + '…' : full;
|
||||
return p({ class: 'post-text post-text-pre' }, ...tribeText(tribe, cut));
|
||||
};
|
||||
|
||||
const renderSectionNav = (tribe, section) => {
|
||||
const firstGroup = [{ key: 'activity', label: i18n.tribeSectionActivity }, { key: 'inhabitants', label: i18n.tribeSectionInhabitants }];
|
||||
if (!tribe.parentTribeId) firstGroup.push({ key: 'subtribes', label: i18n.tribeSubTribes });
|
||||
|
|
@ -449,7 +460,7 @@ const contentTypeVerb = (ct) => {
|
|||
};
|
||||
|
||||
const contentTypeName = (ct) => {
|
||||
const map = { event: i18n.tribeSectionEvents, task: i18n.tribeSectionTasks, votation: i18n.tribeSectionVotations, poll: i18n.pollsTitle, forum: i18n.tribeSectionForum, 'forum-reply': i18n.tribeSectionForum, blog: i18n.blogTitle, media: i18n.tribeSectionMedia, feed: i18n.tribeSectionFeed, pad: i18n.tribeSectionPads || 'PADS', chat: i18n.tribeSectionChats || 'CHATS', calendar: i18n.tribeSectionCalendars || 'CALENDARS', map: i18n.tribeSectionMaps || 'MAPS', wiki: i18n.tribeSectionWiki || 'WIKI' };
|
||||
const map = { event: i18n.tribeSectionEvents, task: i18n.tribeSectionTasks, votation: i18n.tribeSectionVotations, poll: String(i18n.pollsTitle || 'POLLS').toUpperCase(), forum: i18n.tribeSectionForum, 'forum-reply': i18n.tribeSectionForum, blog: String(i18n.blogTitle || 'BLOGS').toUpperCase(), media: i18n.tribeSectionMedia, feed: i18n.tribeSectionFeed, pad: i18n.tribeSectionPads || 'PADS', chat: i18n.tribeSectionChats || 'CHATS', calendar: i18n.tribeSectionCalendars || 'CALENDARS', map: i18n.tribeSectionMaps || 'MAPS', wiki: i18n.tribeSectionWiki || 'WIKI' };
|
||||
return map[ct] || ct;
|
||||
};
|
||||
|
||||
|
|
@ -494,7 +505,7 @@ const renderTribeActivitySection = (tribe, sectionData) => {
|
|||
),
|
||||
div({ class: 'tribe-card-body' },
|
||||
item.title ? div({ class: 'card-field' }, span({ class: 'card-value' }, item.title)) : null,
|
||||
item.description ? p(String(item.description).substring(0, 200)) : null,
|
||||
tribeExcerpt(tribe, item.description),
|
||||
replies.map(r => div({ class: 'thread-reply-item' },
|
||||
r.text ? p({ class: 'post-text post-text-pre' }, String(r.text)) : null,
|
||||
div({ class: 'card-footer thread-reply-footer' },
|
||||
|
|
@ -546,7 +557,7 @@ const renderTribeActivitySection = (tribe, sectionData) => {
|
|||
span({ class: 'card-value' }, item.title)
|
||||
) : null,
|
||||
mediaContent,
|
||||
item.description && !(item.contentType === 'media' && item.mediaType === 'bookmark' && item.description === item.url) ? p(item.description.substring(0, 200)) : null
|
||||
item.description && !(item.contentType === 'media' && item.mediaType === 'bookmark' && item.description === item.url) ? tribeExcerpt(tribe, item.description) : null
|
||||
),
|
||||
p({ class: 'card-footer' },
|
||||
span({ class: 'date-link' }, `${date}`),
|
||||
|
|
@ -589,11 +600,8 @@ const renderTribeTrendingSection = (tribe, sectionData, query) => {
|
|||
|
||||
const renderTribeTagsSection = (tribe, sectionData, query) => {
|
||||
const tagsList = Array.isArray(sectionData?.tags) ? sectionData.tags : [];
|
||||
const selectedTag = sectionData?.selectedTag || '';
|
||||
const filteredItems = Array.isArray(sectionData?.filteredItems) ? sectionData.filteredItems : [];
|
||||
const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
|
||||
const sortedTags = tagsList.slice().sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||
const threadsById = reachIndexOf(tagsList.flatMap(t => Array.isArray(t.items) ? t.items : []));
|
||||
const tagHref = tribeTagHref(tribe);
|
||||
return section(
|
||||
div({ class: 'tags-header module-header-line' },
|
||||
h2(i18n.tribeSectionTags || i18n.tagsTitle || 'TAGS'),
|
||||
|
|
@ -608,29 +616,13 @@ const renderTribeTagsSection = (tribe, sectionData, query) => {
|
|||
th(i18n.tagsTableHeaderCount || 'Count')
|
||||
)),
|
||||
tbody(
|
||||
(selectedTag ? sortedTags : paged(sortedTags)).map(t => tr(
|
||||
td(a({ href: `${tribeUrl}?section=tags&tag=${encodeURIComponent(t.tag)}` }, t.tag)),
|
||||
paged(sortedTags).map(t => tr(
|
||||
td(a({ href: tagHref(t.tag) }, t.tag)),
|
||||
td(`${t.count}`)
|
||||
))
|
||||
)
|
||||
)
|
||||
),
|
||||
selectedTag ? div({ class: 'tribe-content-list' },
|
||||
h2(`#${selectedTag} (${filteredItems.length})`),
|
||||
filteredItems.length === 0 ? p(i18n.tribeTagsEmpty || 'No items') :
|
||||
paged(filteredItems).map(item => div({ class: 'card card-rpg' },
|
||||
div({ class: 'card-header' }, h2({ class: 'card-label' }, `[${String(contentTypeName(item.contentType)).toUpperCase()}]`)),
|
||||
div({ class: 'card-body' },
|
||||
renderTribeReachListChip(tribe, item, threadsById),
|
||||
item.title ? div({ class: 'card-field' }, span({ class: 'card-value' }, item.title)) : null,
|
||||
item.description ? p(item.description.substring(0, 200)) : null
|
||||
),
|
||||
p({ class: 'card-footer' },
|
||||
span({ class: 'date-link' }, moment(item.createdAt).format("YYYY/MM/DD HH:mm")),
|
||||
userLink(item.author)
|
||||
)
|
||||
))
|
||||
) : null
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -639,16 +631,19 @@ const renderTribeSearchSection = (tribe, sectionData, query) => {
|
|||
const sq = sectionData?.query || '';
|
||||
const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
|
||||
const threadsById = reachIndexOf(results);
|
||||
const hashtag = sq.trim().startsWith('#') ? sq.trim().replace(/^#+/, '').toLowerCase() : '';
|
||||
const typeCounts = new Map();
|
||||
for (const item of results) {
|
||||
const label = String(contentTypeName(item.contentType)).toUpperCase();
|
||||
typeCounts.set(label, (typeCounts.get(label) || 0) + 1);
|
||||
}
|
||||
const stats = renderModuleStats(results.length, [...typeCounts].map(([label, count]) => ({ label, count })).sort((x, y) => y.count - x.count || x.label.localeCompare(y.label)));
|
||||
return div({ class: 'tribe-content-list' },
|
||||
div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionSearch)),
|
||||
form({ method: 'GET', action: tribeUrl, class: 'tribe-search-form' },
|
||||
input({ type: 'hidden', name: 'section', value: 'search' }),
|
||||
input({ type: 'text', name: 'q', value: sq, placeholder: i18n.tribeSearchPlaceholder, minlength: 2 }),
|
||||
button({ type: 'submit', class: 'create-button' }, i18n.tribeSectionSearch)
|
||||
),
|
||||
div({ class: 'tags-header module-header-line' }, h2(i18n.tribeSectionSearch), p(hashtag ? i18n.hashtagDescription : i18n.searchDescriptionLabel)),
|
||||
renderSearchForm({ action: tribeUrl, query: sq, queryName: 'q', hidden: [['section', 'search']] }),
|
||||
sq.length > 0 && sq.length < 2 ? p(i18n.tribeSearchMinChars) : null,
|
||||
sq.length >= 2 ? div(
|
||||
h2(`${i18n.tribeSearchResults}: ${results.length}`),
|
||||
stats ? div({ class: 'filters activity-filter-chips activity-toolbar-row search-stats-row' }, stats) : null,
|
||||
results.length === 0 ? p(i18n.tribeSearchEmpty) :
|
||||
paged(results).map(item => div({ class: 'card card-rpg' },
|
||||
div({ class: 'card-header' },
|
||||
|
|
@ -660,7 +655,7 @@ const renderTribeSearchSection = (tribe, sectionData, query) => {
|
|||
span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
|
||||
span({ class: 'card-value' }, item.title)
|
||||
) : null,
|
||||
item.description ? p(item.description.substring(0, 200)) : null
|
||||
tribeExcerpt(tribe, item.description)
|
||||
),
|
||||
p({ class: 'card-footer' },
|
||||
span({ class: 'date-link' }, moment(item.createdAt).format("YYYY/MM/DD HH:mm")),
|
||||
|
|
@ -686,7 +681,7 @@ const renderOverviewSection = (tribe, query, sectionData) => {
|
|||
recentFeed.map(m =>
|
||||
div({ class: 'feed-item' },
|
||||
p(`${moment(m.createdAt).format("YYYY/MM/DD HH:mm")} — `, userLink(m.author)),
|
||||
p(...renderStyledText(m.description))
|
||||
p(...tribeText(tribe, m.description))
|
||||
)
|
||||
)
|
||||
),
|
||||
|
|
@ -743,7 +738,7 @@ const tribeStatusOf = (item) => String(item.status || 'OPEN').toUpperCase();
|
|||
const capFirst = (s) => String(s).charAt(0).toUpperCase() + String(s).slice(1);
|
||||
const blobIdOf = (raw) => {
|
||||
const s = String(raw || '').trim();
|
||||
if (s.startsWith('&')) return s;
|
||||
if (s.startsWith('&') || s.startsWith('/blob/sealed/')) return s;
|
||||
const m = s.match(/\((&[^)\s]+\.sha256)\s*\)/);
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
|
|
@ -864,6 +859,7 @@ const renderFeedTribeView = (feedItems, tribe, query = {}) => {
|
|||
const chip = tribeReachChipFor(tribe, m);
|
||||
const controls = renderTribeReachControls(tribe, m, returnTo);
|
||||
return renderFeedCard(toFeedShape(m), null, {
|
||||
hashtagHref: tribeTagHref(tribe),
|
||||
refeedAction: `${tribeUrl}/refeed/${encodeURIComponent(m.id)}`,
|
||||
canRefeed: member,
|
||||
headerActions: tribeItemActions(tribe, m, member, m.description),
|
||||
|
|
@ -1193,7 +1189,7 @@ const renderForumThreadDetail = (tribe, allItems, threadId, member) => {
|
|||
reachChip
|
||||
),
|
||||
renderTribeReachControls(tribe, thread, returnTo),
|
||||
div({ class: 'forum-body' }, ...renderStyledText(thread.description || '')),
|
||||
div({ class: 'forum-body' }, ...tribeText(tribe, thread.description || '')),
|
||||
div({ class: 'forum-meta' },
|
||||
span({ class: 'forum-positive-votes' }, `▲: ${thread.refeeds || 0}`),
|
||||
span({ class: 'forum-participants' }, `${String(i18n.forumParticipants).toUpperCase()}: ${participants.size}`),
|
||||
|
|
@ -1227,7 +1223,7 @@ const renderForumThreadDetail = (tribe, allItems, threadId, member) => {
|
|||
div({ class: 'comment-body-row' },
|
||||
div({ class: 'comment-vote-col' }, forumScoreBox(tribe, r, member, threadId)),
|
||||
div({ class: 'comment-text-col' },
|
||||
div(...String(r.description || '').split('\n').map(l => l.trim()).filter(Boolean).map(l => p(...renderStyledText(l, { zoomImages: true }))))
|
||||
div(...String(r.description || '').split('\n').map(l => l.trim()).filter(Boolean).map(l => p(...tribeText(tribe, l, { zoomImages: true }))))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -1871,7 +1867,7 @@ exports.clearnetTribeView = async ({ tribe, items, names = {}, slug, page = 1, f
|
|||
? `<div class="cn-filter-row">${btn(hrefFor('', activeAuthor), i18n.all, byAuthor.length, !activeType)}${[...typeCounts.entries()].map(([k, n]) => btn(hrefFor(k, activeAuthor), kindName(byAuthor.find(it => kindKey(it) === k)), n, activeType === k)).join('')}</div>`
|
||||
: '';
|
||||
const authorRow = authorCounts.size > 1
|
||||
? `<div class="cn-filter-row">${btn(hrefFor(activeType, ''), i18n.all, [...authorCounts.values()].reduce((a, b) => a + b, 0), !activeAuthor)}${[...authorCounts.entries()].map(([id, n]) => btn(hrefFor(activeType, id), who(id), n, activeAuthor === id)).join('')}</div>`
|
||||
? `<div class="cn-filter-row">${[...authorCounts.entries()].map(([id, n]) => btn(hrefFor(activeType, activeAuthor === id ? '' : id), who(id), n, activeAuthor === id)).join('')}</div>`
|
||||
: '';
|
||||
const renderItem = (it) => {
|
||||
const mt = it.contentType === 'media' ? String(it.mediaType || '') : '';
|
||||
|
|
@ -1879,9 +1875,9 @@ exports.clearnetTribeView = async ({ tribe, items, names = {}, slug, page = 1, f
|
|||
const title = escapeHtml(it.title || '');
|
||||
const blob = cnBlobUrl(it.image);
|
||||
const media = !blob ? ''
|
||||
: mt === 'image' ? `<img class="cn-tribe-img" src="${blob}" alt="${title}" loading="lazy"/>`
|
||||
: mt === 'audio' ? `<audio class="cn-tribe-player" controls preload="metadata" src="${blob}"></audio>`
|
||||
: mt === 'video' ? `<video class="cn-tribe-player" controls preload="metadata" src="${blob}"></video>`
|
||||
: mt === 'image' ? `<img class="post-image" src="${blob}" alt="${title}" loading="lazy"/>`
|
||||
: mt === 'audio' ? `<audio class="post-audio" controls preload="metadata" src="${blob}"></audio>`
|
||||
: mt === 'video' ? `<video class="post-video" controls preload="metadata" src="${blob}"></video>`
|
||||
: ['document', 'file', 'torrent'].includes(mt) ? `<a class="cn-tribe-link" href="${blob}" rel="noopener">⇩ ${title || escapeHtml(kind)}</a>`
|
||||
: '';
|
||||
const url = mt === 'bookmark' ? String(it.url || '') : '';
|
||||
|
|
@ -1929,8 +1925,6 @@ exports.clearnetTribeView = async ({ tribe, items, names = {}, slug, page = 1, f
|
|||
.cn-tribe-meta{display:flex;gap:12px;flex-wrap:wrap;align-items:baseline;color:var(--fg-dim);font-size:12px;word-break:break-all}
|
||||
.cn-tribe-title{color:var(--fg);font-size:18px;font-weight:700;margin:8px 0;word-break:break-word}
|
||||
.cn-tribe-text{color:var(--fg-soft);line-height:1.5;margin:8px 0;word-break:break-word}
|
||||
.cn-tribe-img{display:block;max-width:100%;height:auto;border-radius:6px;border:1px solid var(--border);margin:10px 0}
|
||||
.cn-tribe-player{display:block;width:100%;max-width:100%;margin:10px 0;border-radius:6px;background:#000}
|
||||
.cn-tribe-link{display:inline-block;margin:8px 0;padding:6px 12px;background:var(--bg-sub);border:1px solid var(--border);border-radius:6px;color:var(--fg);word-break:break-all}
|
||||
.cn-tribe-options{margin:8px 0;padding-left:20px;color:var(--fg-soft)}
|
||||
.cn-tribe-replies{margin-top:12px;border-top:1px solid var(--border);padding-top:8px}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink }
|
|||
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { renderLicenseChip, renderLicenseSelect, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require('./clearnet_view');
|
||||
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions , renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views");
|
||||
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf } = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderStyledText } = require("../backend/renderStyledText")
|
||||
const { renderMapLocationVisitLabel, renderMapEmbed } = require("./maps_view");
|
||||
|
|
@ -55,7 +55,7 @@ const renderVideoPlayer = (videoObj) =>
|
|||
{ class: "video-container video-container-row" },
|
||||
videoHyperaxe({
|
||||
controls: true,
|
||||
src: `/blob/${encodeURIComponent(videoObj.url)}`,
|
||||
src: blobUrlOf(videoObj.url),
|
||||
preload: "metadata"
|
||||
})
|
||||
)
|
||||
|
|
@ -232,7 +232,6 @@ exports.videoView = async (videos, filter = "all", videoId = null, params = {})
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
...(emptyMod ? [] : [
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterAll).toUpperCase()),
|
||||
|
|
@ -370,7 +369,6 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params
|
|||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterAll).toUpperCase()), ...(mediaChip("favorites") ? [button(
|
||||
...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterRecent).toUpperCase())] : []),
|
||||
|
|
|
|||
|
|
@ -37,14 +37,15 @@ const stepAction = (key, profile, status) => {
|
|||
if (key === "ux") {
|
||||
let cfg = {}
|
||||
try { cfg = require("../configs/config-manager.js").getConfig() || {} } catch (_) {}
|
||||
const cur = ["ainav", "chats", "feed", "phone"].includes(cfg.ux?.current) ? cfg.ux.current : "blocks"
|
||||
const cur = ["ainav", "chats", "feed", "phone", "rooms"].includes(cfg.ux?.current) ? cfg.ux.current : "blocks"
|
||||
return form({ method: "POST", action: "/welcome/ux", class: "welcome-ux-form" },
|
||||
div({ class: "welcome-ux-grid" },
|
||||
pickCard("ux", "blocks", cur, i18n.uxModeMenus, "/assets/images/ux-blocks.png"),
|
||||
cfg.modules?.aiNavMod === "on" ? pickCard("ux", "ainav", cur, i18n.uxModeAINav, "/assets/images/ux-ainav.png") : null,
|
||||
cfg.modules?.chatsMod === "on" ? pickCard("ux", "chats", cur, i18n.uxModeChats, "/assets/images/ux-chats.png") : null,
|
||||
pickCard("ux", "feed", cur, i18n.uxModeFeed, "/assets/images/ux-feed.png"),
|
||||
cfg.modules?.phoneMod !== "off" ? pickCard("ux", "phone", cur, i18n.phoneTitle, "/assets/images/ux-phone.png") : null
|
||||
cfg.modules?.phoneMod !== "off" ? pickCard("ux", "phone", cur, i18n.phoneTitle, "/assets/images/ux-phone.png") : null,
|
||||
cfg.modules?.roomsMod !== "off" ? pickCard("ux", "rooms", cur, i18n.roomsTitle, "/assets/images/ux-rooms.png") : null
|
||||
),
|
||||
submitRow(i18n.welcomeStepUxAction)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -117,7 +117,6 @@ const renderFilters = (filter, q, params = {}, census = []) => {
|
|||
const labelOf = (mode) => String(i18n[`wikiFilter${mode.charAt(0).toUpperCase() + mode.slice(1)}`] || mode).toUpperCase();
|
||||
return div({ class: "filters" },
|
||||
form({ method: "GET", action: "/wiki", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q || "" }),
|
||||
tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
|
||||
...(params.emptyMod ? [] : [
|
||||
...FILTERS.filter(chipVisible).map(mode => button({ type: "submit", name: "filter", value: mode, class: filter === mode ? "filter-btn active" : "filter-btn" }, labelOf(mode))),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue