integrar la 1.3.5 del dev y perfiles Minimo, Acampada y Activista
Lo del dev, tal cual desde el nodejs-project de su APK oficial 1.3.5, mezclado a tres bandas con la 1.3.3 como base. Lo que no habiamos tocado se coge entero. Conflictos: - backend.js: su middleware nuevo de media va delante de nuestro router. - main_views.js y bookmark_view.js: lo suyo. Su menu de tarjeta (⋯) ya aparta borrar del pulgar, que era lo que hacia nuestro cambio. - settings_view.js y tribes_view.js: lo suyo mas nuestra linea de enganche. La 1.3.5 trae una cabecera de modulo para el movil (apkModbar) que se lleva la primera caja de fichas, el buscador y los contadores. Con el skin Activista su CSS (OasisMobile.css) no se carga: se le da estilo en mobile_ux.css, los avisos suben encima con order y se ajusta la cabecera nueva de las tarjetas. Filtrar va ahora dentro de la caja de fichas (casilla + etiqueta) para viajar con ella. Perfiles: Minimo (6 modulos, de serie), Acampada (16), Activista (24, con tribus, votaciones, campañas, encuestas y tareas) y despues los del dev por numero de modulos.
This commit is contained in:
parent
ff91ef938c
commit
83f26ba19e
157 changed files with 6633 additions and 4710 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@krakenslab/oasis",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.5",
|
||||
"description": "Oasis - Social Networking Utopia",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -230,7 +230,141 @@ const stripPdfMetadata = (buffer) => {
|
|||
}
|
||||
};
|
||||
|
||||
const STRIP_CHUNK = 64 * 1024;
|
||||
const STRIP_IN_MEMORY_MAX = 256 * 1024 * 1024;
|
||||
const FFMPEG_STRIP_MS = 120000;
|
||||
const MP4_LIKE = /mp4|quicktime|m4a|m4v|3gpp/;
|
||||
const MP4_METADATA_BOXES = new Set(['udta', 'meta']);
|
||||
const MP4_WALKED_BOXES = new Set(['moov', 'trak']);
|
||||
const XMP_UUID = Buffer.from('be7acfcb97a942e89c71999491e3afac', 'hex');
|
||||
const WAV_METADATA_CHUNKS = new Set(['LIST', 'id3 ', 'ID3 ', 'bext', 'iXML', '_PMX', 'XMP ']);
|
||||
const FLAC_METADATA_BLOCKS = new Set([4, 6]);
|
||||
const FLAC_PADDING = 1;
|
||||
const ID3V1_NO_GENRE = 255;
|
||||
const FFMPEG_FORMATS = { 'video/webm': 'webm', 'audio/webm': 'webm', 'video/x-matroska': 'matroska', 'audio/x-matroska': 'matroska', 'video/ogg': 'ogg', 'audio/ogg': 'ogg', 'audio/opus': 'ogg', 'video/x-msvideo': 'avi' };
|
||||
|
||||
const readAt = async (fh, pos, len) => {
|
||||
const buf = Buffer.alloc(len);
|
||||
const { bytesRead } = await fh.read(buf, 0, len, pos);
|
||||
return buf.subarray(0, bytesRead);
|
||||
};
|
||||
|
||||
const zeroAt = async (fh, pos, len) => {
|
||||
const zeros = Buffer.alloc(Math.min(len, STRIP_CHUNK));
|
||||
for (let done = 0; done < len; done += zeros.length) await fh.write(zeros, 0, Math.min(zeros.length, len - done), pos + done);
|
||||
};
|
||||
|
||||
const stripMp4 = async (fh, start, end, depth = 0) => {
|
||||
let pos = start;
|
||||
while (pos + 8 <= end) {
|
||||
const head = await readAt(fh, pos, 16);
|
||||
if (head.length < 8) return;
|
||||
let size = head.readUInt32BE(0);
|
||||
let header = 8;
|
||||
const type = head.toString('latin1', 4, 8);
|
||||
if (size === 1) { if (head.length < 16) return; size = Number(head.readBigUInt64BE(8)); header = 16; }
|
||||
else if (size === 0) size = end - pos;
|
||||
if (size < header || pos + size > end) return;
|
||||
const isXmp = type === 'uuid' && (await readAt(fh, pos + header, XMP_UUID.length)).equals(XMP_UUID);
|
||||
if (MP4_METADATA_BOXES.has(type) || isXmp) {
|
||||
await fh.write(Buffer.from('free', 'latin1'), 0, 4, pos + 4);
|
||||
await zeroAt(fh, pos + header, size - header);
|
||||
}
|
||||
else if (MP4_WALKED_BOXES.has(type) && depth < MP4_WALKED_BOXES.size) await stripMp4(fh, pos + header, pos + size, depth + 1);
|
||||
pos += size;
|
||||
}
|
||||
};
|
||||
|
||||
const stripMp3 = async (fh, size) => {
|
||||
const head = await readAt(fh, 0, 10);
|
||||
if (head.length === 10 && head.toString('latin1', 0, 3) === 'ID3') {
|
||||
const tagSize = ((head[6] & 0x7f) << 21) | ((head[7] & 0x7f) << 14) | ((head[8] & 0x7f) << 7) | (head[9] & 0x7f);
|
||||
const footer = head[5] & 0x10 ? 10 : 0;
|
||||
await fh.write(Buffer.from([0]), 0, 1, 5);
|
||||
await zeroAt(fh, 10, Math.min(tagSize + footer, size - 10));
|
||||
}
|
||||
if (size >= 128 && (await readAt(fh, size - 128, 3)).toString('latin1') === 'TAG') {
|
||||
await zeroAt(fh, size - 125, 125);
|
||||
await fh.write(Buffer.from([ID3V1_NO_GENRE]), 0, 1, size - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const stripFlac = async (fh, size) => {
|
||||
if ((await readAt(fh, 0, 4)).toString('latin1') !== 'fLaC') return;
|
||||
for (let pos = 4; pos + 4 <= size;) {
|
||||
const head = await readAt(fh, pos, 4);
|
||||
if (head.length < 4) return;
|
||||
const last = head[0] & 0x80;
|
||||
const len = head.readUIntBE(1, 3);
|
||||
if (FLAC_METADATA_BLOCKS.has(head[0] & 0x7f)) {
|
||||
await fh.write(Buffer.from([last | FLAC_PADDING]), 0, 1, pos);
|
||||
await zeroAt(fh, pos + 4, len);
|
||||
}
|
||||
if (last) return;
|
||||
pos += 4 + len;
|
||||
}
|
||||
};
|
||||
|
||||
const stripWav = async (fh, size) => {
|
||||
const head = await readAt(fh, 0, 12);
|
||||
if (head.length < 12 || head.toString('latin1', 0, 4) !== 'RIFF' || head.toString('latin1', 8, 12) !== 'WAVE') return;
|
||||
for (let pos = 12; pos + 8 <= size;) {
|
||||
const chunk = await readAt(fh, pos, 8);
|
||||
if (chunk.length < 8) return;
|
||||
const len = chunk.readUInt32LE(4);
|
||||
if (WAV_METADATA_CHUNKS.has(chunk.toString('latin1', 0, 4))) {
|
||||
await fh.write(Buffer.from('JUNK', 'latin1'), 0, 4, pos);
|
||||
await zeroAt(fh, pos + 8, Math.min(len, size - pos - 8));
|
||||
}
|
||||
pos += 8 + len + (len & 1);
|
||||
}
|
||||
};
|
||||
|
||||
const stripWithFfmpeg = (filepath, format) => new Promise((resolve) => {
|
||||
const out = `${filepath}.clean`;
|
||||
const args = ['-v', 'error', '-y', '-i', filepath, '-map', '0', '-map_metadata', '-1', '-c', 'copy', '-f', format, out];
|
||||
require('child_process').execFile(process.env.OASIS_FFMPEG || 'ffmpeg', args, { timeout: FFMPEG_STRIP_MS }, async (err) => {
|
||||
try {
|
||||
if (err) await promisesFs.unlink(out).catch(() => {});
|
||||
else await promisesFs.rename(out, filepath);
|
||||
} catch (_) {}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const stripFileMetadata = async (filepath, mime) => {
|
||||
const m = String(mime || '');
|
||||
try {
|
||||
const { size } = await promisesFs.stat(filepath);
|
||||
if ((m.startsWith('image/') && m !== 'image/gif') || m === 'application/pdf') {
|
||||
if (size > STRIP_IN_MEMORY_MAX) return;
|
||||
const data = await promisesFs.readFile(filepath);
|
||||
const clean = m === 'application/pdf' ? stripPdfMetadata(data) : await stripImageMetadata(data);
|
||||
if (clean !== data) await promisesFs.writeFile(filepath, clean);
|
||||
return;
|
||||
}
|
||||
if (!m.startsWith('audio/') && !m.startsWith('video/')) return;
|
||||
if (FFMPEG_FORMATS[m]) { await stripWithFfmpeg(filepath, FFMPEG_FORMATS[m]); return; }
|
||||
const fh = await promisesFs.open(filepath, 'r+');
|
||||
try {
|
||||
if (MP4_LIKE.test(m)) await stripMp4(fh, 0, size);
|
||||
else if (m === 'audio/mpeg') await stripMp3(fh, size);
|
||||
else if (m === 'audio/flac' || m === 'audio/x-flac') await stripFlac(fh, size);
|
||||
else if (/wav|wave/.test(m)) await stripWav(fh, size);
|
||||
} finally { await fh.close(); }
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
const MAX_BLOB_SIZE = 75 * 1024 * 1024;
|
||||
const KIND_MIME = { image: /^image\//, audio: /^audio\//, video: /^video\//, document: /^application\/pdf$/, torrent: /^application\/x-bittorrent$/ };
|
||||
const looksLikeTorrent = (data) => data.length > 0 && data[0] === 0x64 && data.subarray(0, 4096).includes(Buffer.from('4:info'));
|
||||
|
||||
class WrongFileTypeError extends Error {
|
||||
constructor(fileName, mime) {
|
||||
super(`Wrong file type: ${fileName} (${mime})`);
|
||||
this.name = 'WrongFileTypeError';
|
||||
}
|
||||
}
|
||||
|
||||
class FileTooLargeError extends Error {
|
||||
constructor(fileName, fileSize) {
|
||||
|
|
@ -253,7 +387,7 @@ const oggMime = (buffer) => {
|
|||
|
||||
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) {
|
||||
const storeUploadedFile = async function (blobUpload, seal = null, kind = null) {
|
||||
if (!blobUpload || !blobUpload.filepath) return null;
|
||||
|
||||
let data = await promisesFs.readFile(blobUpload.filepath);
|
||||
|
|
@ -294,11 +428,16 @@ const storeUploadedFile = async function (blobUpload, seal = null) {
|
|||
if (!blob.mime) {
|
||||
blob.mime = 'application/octet-stream';
|
||||
}
|
||||
if (blob.mime === 'application/octet-stream' && looksLikeTorrent(data)) blob.mime = 'application/x-bittorrent';
|
||||
if (kind && KIND_MIME[kind] && !KIND_MIME[kind].test(blob.mime)) throw new WrongFileTypeError(blob.name, blob.mime);
|
||||
|
||||
if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') {
|
||||
data = await stripImageMetadata(data);
|
||||
} else if (blob.mime === 'application/pdf') {
|
||||
data = stripPdfMetadata(data);
|
||||
} else if (blob.mime.startsWith('audio/') || blob.mime.startsWith('video/')) {
|
||||
await stripFileMetadata(blobUpload.filepath, blob.mime);
|
||||
data = await promisesFs.readFile(blobUpload.filepath);
|
||||
}
|
||||
|
||||
if (typeof seal === 'function') {
|
||||
|
|
@ -324,11 +463,11 @@ const storeUploadedFile = async function (blobUpload, seal = null) {
|
|||
return `\n[${blob.name}](${blob.id})`;
|
||||
};
|
||||
|
||||
const handleBlobUpload = async function (ctx, fileFieldName, seal = null) {
|
||||
const handleBlobUpload = async function (ctx, fileFieldName, seal = null, kind = 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, seal);
|
||||
return storeUploadedFile(first, seal, kind);
|
||||
};
|
||||
|
||||
const handleBlobUploads = async function (ctx, fileFieldName, max = 8, seal = null) {
|
||||
|
|
@ -507,5 +646,5 @@ const serveBlob = async function (ctx) {
|
|||
sendBlobBuffer(ctx, buffer);
|
||||
};
|
||||
|
||||
module.exports = { handleBlobUpload, handleBlobUploads, serveBlob, sendBlobBuffer, contentDisposition, oggMime, FileTooLargeError, stripImageMetadata };
|
||||
module.exports = { handleBlobUpload, handleBlobUploads, serveBlob, sendBlobBuffer, contentDisposition, oggMime, FileTooLargeError, WrongFileTypeError, stripImageMetadata, stripFileMetadata };
|
||||
|
||||
|
|
|
|||
|
|
@ -94,15 +94,6 @@ const assertKind = (kind) => {
|
|||
return k;
|
||||
};
|
||||
|
||||
const lastArg = (args, n) => args[args.length - n];
|
||||
|
||||
const kindFromArgs = (args) => {
|
||||
const k = String(lastArg(args, 1) || "").trim();
|
||||
return assertKind(k);
|
||||
};
|
||||
|
||||
const idFromArgs = (args) => String(lastArg(args, 1) || "").trim();
|
||||
|
||||
exports.getFavoriteSet = async (kind) => {
|
||||
const k = assertKind(kind);
|
||||
const data = await readAll();
|
||||
|
|
@ -142,15 +133,4 @@ exports.removeFavorite = async (kind, id) =>
|
|||
await writeAll(data);
|
||||
});
|
||||
|
||||
exports.getFavoritesSet = async (...args) => exports.getFavoriteSet(kindFromArgs(args));
|
||||
exports.addToFavorites = async (...args) => {
|
||||
const kind = kindFromArgs(args.slice(0, -1));
|
||||
const id = idFromArgs(args);
|
||||
return exports.addFavorite(kind, id);
|
||||
};
|
||||
exports.removeFromFavorites = async (...args) => {
|
||||
const kind = kindFromArgs(args.slice(0, -1));
|
||||
const id = idFromArgs(args);
|
||||
return exports.removeFavorite(kind, id);
|
||||
};
|
||||
exports.storePath = storePath;
|
||||
|
|
|
|||
|
|
@ -17,4 +17,15 @@ const set = (feedId, name, ts) => {
|
|||
const has = (feedId) => !!feedId && cache.has(String(feedId))
|
||||
const size = () => cache.size
|
||||
|
||||
module.exports = { get, set, has, size }
|
||||
const images = new Map()
|
||||
const NULL_IMAGE_RE = /^&0{43}=\.sha256$/
|
||||
|
||||
const image = (feedId) => (feedId && images.get(String(feedId))) || null
|
||||
|
||||
const setImage = (feedId, img) => {
|
||||
const link = typeof img === 'string' ? img : (img && typeof img.link === 'string' ? img.link : null)
|
||||
if (!feedId || !link || !link.startsWith('&') || NULL_IMAGE_RE.test(link)) return
|
||||
images.set(String(feedId), link)
|
||||
}
|
||||
|
||||
module.exports = { get, set, has, size, image, setImage }
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const constructive = [
|
|||
"rejected",
|
||||
"needsSources",
|
||||
"wrong",
|
||||
"outdated",
|
||||
"lowQuality",
|
||||
"confusing",
|
||||
"misleading",
|
||||
|
|
@ -46,7 +47,7 @@ const all = [...positive, ...constructive, ...moderation];
|
|||
all.positive = positive;
|
||||
all.constructive = constructive;
|
||||
all.moderation = moderation;
|
||||
const karmaNeutral = ["adultOnly", "nsfw", "triggering", "duplicate", "offTopic", "needsSources"];
|
||||
const karmaNeutral = ["adultOnly", "nsfw", "triggering", "duplicate", "offTopic", "needsSources", "outdated"];
|
||||
const karmaModeration = [...moderation.filter(c => !karmaNeutral.includes(c)), "propaganda"];
|
||||
all.karma = {
|
||||
positive,
|
||||
|
|
@ -79,7 +80,19 @@ all.messageTypes = {
|
|||
podcast: 'podcastOpinion',
|
||||
podcastEpisode: 'podcastOpinion',
|
||||
campaign: 'campaignOpinion',
|
||||
logisticsRoute: 'logisticsOpinion'
|
||||
logisticsRoute: 'logisticsOpinion',
|
||||
wikiPage: 'wikiOpinion',
|
||||
map: 'mapOpinion',
|
||||
industryBlueprint: 'blueprintOpinion',
|
||||
job: 'jobOpinion'
|
||||
};
|
||||
all.opinable = (c) => !!c && typeof c === 'object' && !!all.messageTypes[c.type] && !c.tribeId && !c.encrypted && !c.encryptedPayload && !c.contentEncrypted
|
||||
&& (c.type !== 'map' || String(c.mapType || '').toUpperCase() === 'OPEN')
|
||||
&& (c.type !== 'job' || String(c.visibility || 'PUBLIC').toUpperCase() !== 'HIDDEN')
|
||||
&& (c.type !== 'schoolCourse' || String(c.visibility || 'PUBLIC').toUpperCase() !== 'INVITE');
|
||||
|
||||
all.hasOpinions = (x) => Object.values((x && x.opinions) || {}).some(n => (Number(n) || 0) > 0);
|
||||
all.MEDIA_LOCKED = 'Cannot replace the file after it has received opinions.';
|
||||
all.assertMediaKept = (item, replacing) => { if (replacing && all.hasOpinions(item)) throw new Error(all.MEDIA_LOCKED); };
|
||||
|
||||
module.exports = all;
|
||||
|
|
|
|||
|
|
@ -450,14 +450,6 @@ const reportSections = (report) => {
|
|||
out.push({ kind: 'text', text: report.description });
|
||||
}
|
||||
|
||||
const tpl = report.template && typeof report.template === 'object' ? report.template : {};
|
||||
const tplEntries = Object.entries(tpl).filter(([, v]) => txt(v).trim());
|
||||
if (tplEntries.length) {
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'DETAILS' });
|
||||
for (const [k, v] of tplEntries) out.push({ kind: 'kv', label: humanLabel(k), value: v });
|
||||
}
|
||||
|
||||
const confirmations = asList(report.confirmations);
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'CONFIRMATIONS' });
|
||||
|
|
|
|||
176
src/backend/pdf_view.js
Normal file
176
src/backend/pdf_view.js
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const WORKER_TAG = 'oasis-pdf-view';
|
||||
const MUPDF_URL = pathToFileURL(path.join(__dirname, '..', 'server', 'node_modules', 'mupdf', 'dist', 'mupdf.js')).href;
|
||||
const PDF_BASE_WIDTH = 1600;
|
||||
const PDF_FIT_WIDTH = 1200;
|
||||
const PDF_WEBP_EFFORT = 1;
|
||||
const PDF_ZOOMS = [50, 75, 100, 125, 150, 200, 300];
|
||||
const PDF_ZOOM_DEFAULT = 100;
|
||||
const PDF_PAGE_MAX_PX = 4096;
|
||||
const PDF_CALL_MS = 20000;
|
||||
const PDF_DOCS_BYTES = 128 * 1024 * 1024;
|
||||
const PDF_ROTATIONS = [0, 90, 180, 270];
|
||||
const PDF_FRAME_ID_LEN = 12;
|
||||
const PDF_PAGES_CACHE_BYTES = 64 * 1024 * 1024;
|
||||
const PDF_WORKER_HEAP_MB = 256;
|
||||
const PDF_WEBP_QUALITY = 85;
|
||||
const PDF_MAGIC = Buffer.from('%PDF-');
|
||||
const PDF_MAGIC_WINDOW = 1024;
|
||||
|
||||
const widthOf = (zoom) => (zoom <= PDF_ZOOM_DEFAULT ? PDF_FIT_WIDTH : Math.round(PDF_BASE_WIDTH * zoom / 100));
|
||||
const PDF_WIDTHS = new Set(PDF_ZOOMS.map(widthOf));
|
||||
const zoomOf = (value) => { const z = Number(value); return PDF_ZOOMS.includes(z) ? z : PDF_ZOOM_DEFAULT; };
|
||||
const rotationOf = (value) => { const r = Number(value); return PDF_ROTATIONS.includes(r) ? r : 0; };
|
||||
const looksLikePdf = (buf) => Buffer.isBuffer(buf) && buf.subarray(0, PDF_MAGIC_WINDOW).indexOf(PDF_MAGIC) >= 0;
|
||||
|
||||
if (!isMainThread && workerData === WORKER_TAG) {
|
||||
const docs = new Map();
|
||||
const sizes = new Map();
|
||||
let openBytes = 0;
|
||||
let mupdf = null;
|
||||
const need = (key) => {
|
||||
const doc = docs.get(key);
|
||||
if (!doc) throw new Error('NO_DOC');
|
||||
docs.delete(key);
|
||||
docs.set(key, doc);
|
||||
return doc;
|
||||
};
|
||||
const describe = (doc) => ({ pages: doc.countPages() });
|
||||
const ops = {
|
||||
open({ key, data }) {
|
||||
const doc = mupdf.Document.openDocument(Buffer.from(data), 'application/pdf');
|
||||
if (doc.needsPassword()) { doc.destroy(); throw new Error('LOCKED'); }
|
||||
docs.set(key, doc);
|
||||
sizes.set(key, data.byteLength);
|
||||
openBytes += data.byteLength;
|
||||
while (openBytes > PDF_DOCS_BYTES && docs.size > 1) {
|
||||
const [oldKey, oldDoc] = docs.entries().next().value;
|
||||
docs.delete(oldKey);
|
||||
openBytes -= sizes.get(oldKey) || 0;
|
||||
sizes.delete(oldKey);
|
||||
try { oldDoc.destroy(); } catch (_) {}
|
||||
}
|
||||
return describe(doc);
|
||||
},
|
||||
info({ key }) { return describe(need(key)); },
|
||||
render({ key, page, width, rotate }) {
|
||||
const doc = need(key);
|
||||
const pg = doc.loadPage(page - 1);
|
||||
try {
|
||||
const [x0, y0, x1, y1] = pg.getBounds();
|
||||
let scale = width / Math.max(1, x1 - x0);
|
||||
scale = Math.min(scale, PDF_PAGE_MAX_PX / Math.max(1, y1 - y0), PDF_PAGE_MAX_PX / Math.max(1, x1 - x0));
|
||||
const pix = pg.toPixmap(mupdf.Matrix.concat(mupdf.Matrix.scale(scale, scale), mupdf.Matrix.rotate(rotate || 0)), mupdf.ColorSpace.DeviceRGB, false, true);
|
||||
try { return { jpeg: pix.asJPEG(PDF_WEBP_QUALITY, false) }; } finally { pix.destroy(); }
|
||||
} finally { pg.destroy(); }
|
||||
}
|
||||
};
|
||||
const debug = process.argv.includes('--debug') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
|
||||
const ready = import(MUPDF_URL).then((m) => {
|
||||
mupdf = m;
|
||||
mupdf.setLog(debug ? (message) => console.error(`[pdf] ${message}`) : () => {});
|
||||
});
|
||||
parentPort.on('message', async ({ id, op, args }) => {
|
||||
try {
|
||||
await ready;
|
||||
if (!ops[op]) throw new Error('BAD_OP');
|
||||
const { data, ...rest } = args || {};
|
||||
if (data && !docs.has(rest.key)) ops.open({ key: rest.key, data });
|
||||
const value = op === 'open' ? describe(need(rest.key)) : ops[op](rest);
|
||||
parentPort.postMessage({ id, value });
|
||||
} catch (e) {
|
||||
parentPort.postMessage({ id, error: String((e && e.message) || e) });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let worker = null;
|
||||
let current = null;
|
||||
const queue = [];
|
||||
const stop = () => { const w = worker; worker = null; if (w) w.terminate().catch(() => {}); };
|
||||
const settle = (outcome) => {
|
||||
const job = current;
|
||||
if (!job) return;
|
||||
current = null;
|
||||
clearTimeout(job.timer);
|
||||
if (outcome.error) job.reject(new Error(outcome.error)); else job.resolve(outcome.value);
|
||||
pump();
|
||||
};
|
||||
const start = () => {
|
||||
const w = new Worker(__filename, { workerData: WORKER_TAG, resourceLimits: { maxOldGenerationSizeMb: PDF_WORKER_HEAP_MB } });
|
||||
w.on('message', (msg) => { if (worker === w && current && msg.id === current.id) settle(msg); });
|
||||
const lost = () => { if (worker === w) { worker = null; settle({ error: 'WORKER' }); } };
|
||||
w.on('error', lost);
|
||||
w.on('exit', lost);
|
||||
w.unref();
|
||||
worker = w;
|
||||
};
|
||||
let seq = 0;
|
||||
const pump = () => {
|
||||
if (current || !queue.length) return;
|
||||
if (!worker) start();
|
||||
current = queue.shift();
|
||||
current.timer = setTimeout(() => { stop(); settle({ error: 'TIMEOUT' }); }, PDF_CALL_MS);
|
||||
worker.postMessage({ id: current.id, op: current.op, args: current.args });
|
||||
};
|
||||
const call = (op, args) => new Promise((resolve, reject) => {
|
||||
queue.push({ id: ++seq, op, args, resolve, reject, timer: null });
|
||||
pump();
|
||||
});
|
||||
const loading = new Map();
|
||||
const loadPdf = (key, load) => {
|
||||
if (!loading.has(key)) {
|
||||
loading.set(key, (async () => {
|
||||
const data = await load();
|
||||
if (!data) throw new Error('UNAVAILABLE');
|
||||
if (!looksLikePdf(data)) throw new Error('NOT_PDF');
|
||||
return data;
|
||||
})().finally(() => loading.delete(key)));
|
||||
}
|
||||
return loading.get(key);
|
||||
};
|
||||
const withDoc = async (key, load, op, args = {}) => {
|
||||
try { return await call(op, { key, ...args }); } catch (e) { if (e.message !== 'NO_DOC') throw e; }
|
||||
return call(op, { key, ...args, data: await loadPdf(key, load) });
|
||||
};
|
||||
const pages = new Map();
|
||||
let pagesBytes = 0;
|
||||
const remember = (k, v) => {
|
||||
pages.set(k, v);
|
||||
pagesBytes += v.body.length;
|
||||
for (const [old, entry] of pages) {
|
||||
if (pagesBytes <= PDF_PAGES_CACHE_BYTES) break;
|
||||
pages.delete(old);
|
||||
pagesBytes -= entry.body.length;
|
||||
}
|
||||
};
|
||||
const encode = async ({ jpeg }) => ({ body: Buffer.from(jpeg), type: 'image/jpeg' });
|
||||
const drawing = new Map();
|
||||
const keyOf = (text) => crypto.createHash('sha256').update(String(text)).digest('hex');
|
||||
const frameIdOf = (src) => `pdf-${keyOf(src).slice(0, PDF_FRAME_ID_LEN)}`;
|
||||
module.exports = {
|
||||
PDF_BASE_WIDTH, PDF_ZOOMS, PDF_ZOOM_DEFAULT, PDF_ROTATIONS,
|
||||
widthOf, zoomOf, rotationOf, looksLikePdf, keyOf, frameIdOf,
|
||||
isFrameId: (v) => new RegExp(`^pdf-[0-9a-f]{${PDF_FRAME_ID_LEN}}$`).test(String(v || '')),
|
||||
isPdfWidth: (w) => PDF_WIDTHS.has(Number(w)),
|
||||
info: (key, load) => withDoc(key, load, 'info'),
|
||||
async page(key, load, page, width, rotation) {
|
||||
const r = rotationOf(rotation);
|
||||
const cacheKey = `${key}|${page}|${width}|${r}`;
|
||||
const hit = pages.get(cacheKey);
|
||||
if (hit) { pages.delete(cacheKey); pages.set(cacheKey, hit); return hit; }
|
||||
if (!drawing.has(cacheKey)) {
|
||||
drawing.set(cacheKey, (async () => {
|
||||
const out = await encode(await withDoc(key, load, 'render', { page: Number(page), width: Number(width), rotate: r }), r);
|
||||
remember(cacheKey, out);
|
||||
return out;
|
||||
})().finally(() => drawing.delete(cacheKey)));
|
||||
}
|
||||
return drawing.get(cacheKey);
|
||||
},
|
||||
shutdown: () => { stop(); while (queue.length) queue.shift().reject(new Error('STOPPED')); if (current) settle({ error: 'STOPPED' }); }
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
const { a, img, video, audio, span, strong, em, u, s: strike, code, div, textarea } = require("../server/node_modules/hyperaxe");
|
||||
const { a, img, video, audio, span, strong, em, u, s: strike, code, div, textarea, object } = require("../server/node_modules/hyperaxe");
|
||||
const i18nBase = require("../client/assets/translations/i18n");
|
||||
const { WIKILINK_RE, slugify, linkTarget } = require("../models/wiki_model");
|
||||
|
||||
|
|
@ -202,7 +202,20 @@ function renderStyledText(value, opts = {}) {
|
|||
result.push(audio({ controls: true, class: 'post-audio', src: blobHref(m.blob) }));
|
||||
} else if (m.type === 'blob-pdf') {
|
||||
const i18n = getI18n();
|
||||
result.push(a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, m.name || i18n.pdfFallbackLabel || 'PDF'));
|
||||
const pdfLabel = m.name || i18n.pdfFallbackLabel || 'PDF';
|
||||
const clearnet = String(opts.blobPrefix || '').startsWith('/c/');
|
||||
let lang = '';
|
||||
try { lang = clearnet ? ((require('../models/typed_log').requestScope.getStore() || {}).cnLang || '') : ''; } catch (_) {}
|
||||
const fid = require('./pdf_view').frameIdOf(blobHref(m.blob));
|
||||
const query = new URLSearchParams({ src: blobHref(m.blob), title: String(m.name || ''), ...(lang ? { lang } : {}), fid });
|
||||
const holder = (src, cls) => span({ class: 'pdf-holder' },
|
||||
object({ id: fid, data: src, type: 'text/html', class: cls, title: pdfLabel, 'aria-label': pdfLabel }, ''),
|
||||
a({ href: '#', class: 'pdf-unmax', title: i18n.pdfClose || 'Close' }, '✕'));
|
||||
result.push(clearnet
|
||||
? holder(`/c/pdf/embed?${query}`, 'cn-pdf-frame')
|
||||
: opts.blobPrefix
|
||||
? a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, pdfLabel)
|
||||
: holder(`/pdf/embed?${query}`, 'pdf-frame'));
|
||||
} else if (m.type === 'blob-file') {
|
||||
result.push(a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, m.name || m.blob.slice(1, 9)));
|
||||
} else if (m.type === 'md-mention') {
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ const publicModeGuard = ({ isPublic, onBlocked }) => async (ctx, next) => {
|
|||
await next();
|
||||
};
|
||||
|
||||
const buildCsp = (isClearnet, { frames = false } = {}) => (isClearnet
|
||||
const buildCsp = (isClearnet, { frames = '', framed = false, objects = '' } = {}) => (isClearnet
|
||||
? [
|
||||
"default-src 'self'",
|
||||
"script-src 'none'",
|
||||
|
|
@ -76,24 +76,27 @@ const buildCsp = (isClearnet, { frames = false } = {}) => (isClearnet
|
|||
"img-src 'self' data:",
|
||||
"media-src 'self' blob:",
|
||||
"connect-src 'self'",
|
||||
"frame-src 'none'",
|
||||
frames ? `frame-src ${frames}` : "frame-src 'none'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
objects ? `object-src ${objects}` : "object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"frame-ancestors 'none'"
|
||||
framed ? "frame-ancestors 'self'" : "frame-ancestors 'none'"
|
||||
]
|
||||
: [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' http://localhost:3000/js",
|
||||
"script-src 'none'",
|
||||
"style-src 'self'",
|
||||
"img-src 'self'",
|
||||
"media-src 'self' blob:",
|
||||
"worker-src 'self' blob:",
|
||||
frames ? "frame-src 'self'" : "frame-src 'none'",
|
||||
frames ? `frame-src ${frames}` : "frame-src 'none'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
objects ? `object-src ${objects}` : "object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"frame-ancestors 'none'"
|
||||
framed ? "frame-ancestors 'self'" : "frame-ancestors 'none'"
|
||||
]).join('; ');
|
||||
|
||||
module.exports = { isMsgKey, pickMsgKeys, isLocalPath, safeReturnTo, refererPath, safeRefererRedirect, isClearnetPath, isTrustedRequest, publicModeGuard, buildCsp };
|
||||
const IMAGE_SIZE_MIN = 16;
|
||||
const IMAGE_SIZE_MAX = 1024;
|
||||
const isImageSize = (value) => { const n = Number(value); return Number.isInteger(n) && n >= IMAGE_SIZE_MIN && n <= IMAGE_SIZE_MAX; };
|
||||
|
||||
module.exports = { IMAGE_SIZE_MIN, IMAGE_SIZE_MAX, isImageSize, isMsgKey, pickMsgKeys, isLocalPath, safeReturnTo, refererPath, safeRefererRedirect, isClearnetPath, isTrustedRequest, publicModeGuard, buildCsp };
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: var(--base03);
|
||||
}
|
||||
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class,
|
||||
.hljs-regexp,
|
||||
.hljs-deletion {
|
||||
color: var(--base08);
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name,
|
||||
.hljs-literal,
|
||||
.hljs-type,
|
||||
.hljs-params,
|
||||
.hljs-meta,
|
||||
.hljs-link {
|
||||
color: var(--base09);
|
||||
}
|
||||
|
||||
.hljs-attribute {
|
||||
color: var(--base0A);
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet,
|
||||
.hljs-addition {
|
||||
color: var(--base0B);
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-section {
|
||||
color: var(--base0D);
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag {
|
||||
color: var(--base0E);
|
||||
}
|
||||
|
||||
.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
color: var(--base05);
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hljs-string, .hljs-link {
|
||||
background: #FFA500;
|
||||
user-select: text;
|
||||
}
|
||||
|
|
@ -672,8 +672,7 @@ a.filter-btn,
|
|||
.main-column .edit-btn,
|
||||
.main-column .join-btn,
|
||||
.main-column .leave-btn,
|
||||
.main-column .buy-btn,
|
||||
.main-column .submit-button {
|
||||
.main-column .buy-btn {
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
|
|
@ -682,8 +681,7 @@ a.filter-btn,
|
|||
.main-column .btn,
|
||||
.main-column .create-button,
|
||||
.main-column .update-btn,
|
||||
.main-column .delete-btn,
|
||||
.main-column .submit-button {
|
||||
.main-column .delete-btn {
|
||||
white-space: normal !important;
|
||||
}
|
||||
.main-column form {
|
||||
|
|
|
|||
|
|
@ -695,22 +695,33 @@ body:has(#oasis-menu-toggle:checked) form.oasis-phone-state { display: none !imp
|
|||
width: auto !important; min-width: 0; min-height: 44px !important; margin: 0 !important; padding: 4px 6px !important;
|
||||
font-size: 14px !important; line-height: 1.15 !important; overflow-wrap: anywhere;
|
||||
}
|
||||
/* las fichas van plegadas tras un boton Filtrar (mobile_ui.activityFilter); por
|
||||
defecto se ve todo y con un filtro puesto el boton dice cual */
|
||||
.main-column details.activity-filter { margin: 0 0 12px !important; }
|
||||
.main-column details.activity-filter > summary.activity-filter-toggle {
|
||||
display: flex; align-items: center; justify-content: center; min-height: 48px; list-style: none; cursor: pointer;
|
||||
border: 1px solid var(--ux-line); border-radius: var(--a26-radius, 12px);
|
||||
/* las fichas van plegadas tras un boton Filtrar (mobile_ui.activityFilter): casilla,
|
||||
etiqueta y fichas en la misma caja, que la 1.3.5 mueve a su barra fija (.apk-strip).
|
||||
Por defecto se ve todo; con un filtro puesto el boton dice cual */
|
||||
.main-column div.activity-filter-chips.mobile-filter {
|
||||
display: block !important; overflow: visible !important; white-space: normal !important;
|
||||
margin: 0 0 8px !important; padding: 0 !important; width: 100% !important;
|
||||
}
|
||||
.main-column .mobile-filter > input.mobile-filter-toggle { position: absolute !important; opacity: 0 !important; width: 1px !important; height: 1px !important; min-height: 0 !important; pointer-events: none; }
|
||||
.main-column .mobile-filter > label.mobile-filter-btn {
|
||||
display: flex !important; align-items: center; justify-content: center; min-height: 48px; margin: 0 !important; cursor: pointer;
|
||||
border: 1px solid var(--ux-line); border-radius: var(--a26-radius, 12px); background: var(--ux-bg); color: var(--ux-fg);
|
||||
font-weight: 700; font-size: 16px; text-transform: uppercase; letter-spacing: 1px;
|
||||
}
|
||||
.main-column details.activity-filter > summary::-webkit-details-marker { display: none; }
|
||||
.main-column details.activity-filter > summary::after { content: "\25BE"; margin-left: 8px; font-size: 16px; }
|
||||
.main-column details.activity-filter[open] > summary { margin-bottom: 10px; }
|
||||
.main-column details.activity-filter[open] > summary::after { content: "\25B4"; }
|
||||
.main-column details.activity-filter.is-filtered > summary { background: var(--ux-accent); color: var(--ux-on-accent); border-color: var(--ux-accent); }
|
||||
.main-column .activity-filter-now { margin-left: 8px; font-weight: 400; text-transform: none; letter-spacing: 0; }
|
||||
.main-column .activity-filter-now::before { content: "\00B7"; margin-right: 8px; }
|
||||
.main-column details.activity-filter div.activity-filter-chips:not(.activity-toolbar-row) { margin: 0 !important; }
|
||||
.main-column .mobile-filter > label.mobile-filter-btn::after { content: "\25BE"; margin-left: 8px; font-size: 16px; }
|
||||
.main-column .mobile-filter > input:checked + label.mobile-filter-btn::after { content: "\25B4"; }
|
||||
.main-column .mobile-filter.is-filtered > label.mobile-filter-btn { background: var(--ux-accent); color: var(--ux-on-accent); border-color: var(--ux-accent); }
|
||||
.main-column .mobile-filter .activity-filter-now { margin-left: 8px; font-weight: 400; text-transform: none; letter-spacing: 0; }
|
||||
.main-column .mobile-filter .activity-filter-now::before { content: "\00B7"; margin-right: 8px; }
|
||||
.main-column .mobile-filter > div.mobile-filter-chips { display: none !important; }
|
||||
.main-column .mobile-filter > input:checked ~ div.mobile-filter-chips {
|
||||
display: grid !important; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-gap: 8px; margin: 10px 0 0 !important; padding: 0 !important;
|
||||
}
|
||||
.main-column .mobile-filter div.mobile-filter-chips a.activity-chip {
|
||||
display: flex !important; align-items: center; justify-content: center; text-align: center;
|
||||
width: auto !important; min-width: 0; min-height: 44px !important; margin: 0 !important; padding: 4px 6px !important;
|
||||
font-size: 14px !important; line-height: 1.15 !important; overflow-wrap: anywhere; white-space: normal !important;
|
||||
}
|
||||
|
||||
/* cabecera de cada tarjeta: los botones en una fila arriba a la derecha y la etiqueta
|
||||
(FEEDS, SALAS...) debajo a la izquierda; antes compartian fila y la X caia sola */
|
||||
|
|
@ -810,3 +821,38 @@ body .oasis-mobile-topbar .omt-logo img, body .oasis-mobile-topbar .omt-avatar i
|
|||
background: var(--ux-accent) !important; color: var(--ux-on-accent) !important; border-color: var(--ux-accent) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
/* ---------- cabecera de modulo de la 1.3.5 (apkModbar en main_views.js) ----------
|
||||
el dev la pinta en OasisMobile.css, que con el skin Activista no se carga: sin esto
|
||||
sus div heredan el relleno generico y la cabecera ocupaba media pantalla. Los avisos
|
||||
(.apk-banners: EMPEZAR, version nueva) suben por encima con order, sin tocar su HTML */
|
||||
.main-column { display: flex !important; flex-direction: column !important; }
|
||||
.main-column > * { flex: 0 0 auto; min-width: 0; }
|
||||
.main-column > div.apk-banners { order: -1; margin: 0 0 12px !important; padding: 0 !important; }
|
||||
.main-column > div.apk-banners > * { margin: 0 0 10px !important; }
|
||||
.main-column > div.apk-modbar { display: block !important; position: static !important; margin: 0 0 12px !important; padding: 0 !important; background: transparent !important; box-shadow: none !important; }
|
||||
.main-column div.apk-modbar > div.apk-modbar-row { display: flex !important; align-items: center; justify-content: space-between; margin: 0 0 10px !important; padding: 0 !important; }
|
||||
.main-column div.apk-modbar .apk-title { margin: 0 !important; padding: 0 !important; color: var(--ux-fg) !important; }
|
||||
.main-column div.apk-modbar .apk-pin { margin: 0 0 0 10px !important; width: auto !important; flex: 0 0 auto; }
|
||||
.main-column div.apk-modbar > .apk-strip:not(.mobile-filter) { display: flex !important; flex-wrap: nowrap !important; overflow-x: auto !important; margin: 0 0 10px !important; padding: 0 0 4px !important; }
|
||||
.main-column div.apk-modbar > .apk-strip:not(.mobile-filter) > * { flex: 0 0 auto !important; width: auto !important; margin: 0 8px 0 0 !important; white-space: nowrap !important; }
|
||||
.main-column div.apk-modbar > form.filter-box { margin: 0 0 8px !important; }
|
||||
.main-column div.apk-modbar > div.apk-modbar-foot { margin: 0 !important; padding: 0 !important; }
|
||||
.main-column div.apk-modbar div.module-stats-line { margin: 0 !important; padding: 0 !important; font-size: 13px; color: var(--ux-muted) !important; }
|
||||
.main-column div.apk-modbar div.module-stats-line > span { margin-right: 12px; }
|
||||
/* la seccion que se queda vacia cuando la cabecera se lleva las fichas y el buscador */
|
||||
.main-column > section:empty, .main-column div.activity-toolbar-row:empty { display: none !important; }
|
||||
.main-column > section > div.activity-toolbar-row:only-child:empty { display: none !important; }
|
||||
|
||||
/* cabecera de tarjeta de la 1.3.5: autoria a la izquierda (nombre cortado con ...) y
|
||||
el menu ⋯ y la flecha juntos a la derecha. Pisa lo de la 1.3.3 (columna) */
|
||||
.main-column div.card-header.activity-card-header.card-top { flex-direction: row !important; flex-wrap: nowrap !important; align-items: flex-start !important; }
|
||||
.main-column div.card-header.card-top > div.card-byline { display: flex !important; flex-wrap: wrap !important; align-items: center; flex: 1 1 auto !important; min-width: 0 !important; width: auto !important; margin: 0 !important; padding: 0 !important; }
|
||||
.main-column div.card-byline > a.user-link { display: block !important; max-width: calc(100% - 34px) !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; margin: 0 0 0 8px !important; }
|
||||
.main-column div.card-byline > .card-byline-time, .main-column div.card-byline > div.card-chips-row { flex: 0 0 auto; margin: 4px 8px 0 0 !important; }
|
||||
.main-column div.card-header.card-top > div.content-actions { order: 2 !important; flex: 0 0 auto !important; width: auto !important; display: flex !important; flex-wrap: nowrap !important; justify-content: flex-end !important; margin: 0 0 0 8px !important; }
|
||||
.main-column div.content-actions > details.card-menu { width: auto !important; flex: 0 0 auto !important; margin: 0 8px 0 0 !important; }
|
||||
.main-column details.card-menu > summary.card-menu-toggle { min-width: 44px !important; height: 44px !important; }
|
||||
/* el menu desplegado con los colores del skin y filas de 48px */
|
||||
.main-column details.card-menu > div.card-menu-items { background: var(--ux-bg) !important; border: 1px solid var(--ux-line) !important; color: var(--ux-fg) !important; min-width: 220px; }
|
||||
.main-column div.card-menu-items > * { min-height: 48px; }
|
||||
|
|
|
|||
|
|
@ -35,6 +35,15 @@ input[type="text"], input[type="email"], input[type="password"], textarea {
|
|||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > br { display: none }
|
||||
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > :is(label, span) { display: block; margin: 0 0 6px 0 }
|
||||
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > :is(h2, h3, p) { margin: 0 0 16px 0 }
|
||||
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > :is(input:not([type="hidden"], [type="checkbox"], [type="radio"], [type="submit"], [type="button"], .logistics-short), select:not(.logistics-price-unit), textarea) { display: block; box-sizing: border-box; width: 100%; max-width: 720px; margin: 0 0 16px 0 }
|
||||
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > input:is([type="checkbox"], [type="radio"]) { display: block; width: auto; margin: 0 0 16px 0 }
|
||||
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > :is(.logistics-short, .logistics-price-unit) { margin: 0 8px 16px 0 }
|
||||
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > :is(div, table):not(.map-form-row, .map-toolbar, .map-form-map-slot) { max-width: 720px; box-sizing: border-box; margin-bottom: 16px }
|
||||
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > button { margin-top: 0 }
|
||||
|
||||
/* Body and overall page styles */
|
||||
html {
|
||||
color: #FFA500;
|
||||
|
|
@ -1602,7 +1611,58 @@ button.create-button:hover {
|
|||
}
|
||||
|
||||
/* Documents */
|
||||
.pdf-holder {
|
||||
display: block;
|
||||
}
|
||||
.pdf-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 80vh;
|
||||
min-height: 520px;
|
||||
border: 0;
|
||||
margin-bottom: 1rem;
|
||||
background: transparent;
|
||||
}
|
||||
.pdf-frame:target {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
background: #111;
|
||||
}
|
||||
.pdf-unmax {
|
||||
display: none;
|
||||
}
|
||||
.pdf-frame:target + .pdf-unmax {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 18px;
|
||||
z-index: 100001;
|
||||
background-color: #2d2d2d;
|
||||
color: #eee;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.pdf-embed-root,
|
||||
body.pdf-embed {
|
||||
height: 100%;
|
||||
}
|
||||
body.pdf-embed {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: transparent !important;
|
||||
}
|
||||
.pdf-viewer-container {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background: #1e1e1e;
|
||||
|
|
@ -1611,42 +1671,116 @@ button.create-button:hover {
|
|||
border-radius: 6px;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.pdf-embedded {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.pdf-canvas-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
height: 420px;
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pdf-wrap-scroll {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pdf-viewer-container canvas {
|
||||
.pdf-embedded .pdf-canvas-wrap {
|
||||
flex: 1 1 auto;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.pdf-page-image {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
border: 1px solid #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.pdf-viewer-container .pdf-controls {
|
||||
margin-top: 1rem;
|
||||
.pdf-z50 {
|
||||
max-width: 50%;
|
||||
max-height: 50%;
|
||||
}
|
||||
|
||||
.pdf-viewer-container button {
|
||||
.pdf-z75 {
|
||||
max-width: 75%;
|
||||
max-height: 75%;
|
||||
}
|
||||
.pdf-z100 {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.pdf-z125 {
|
||||
width: 100%;
|
||||
}
|
||||
.pdf-z150 {
|
||||
width: 150%;
|
||||
}
|
||||
.pdf-z200 {
|
||||
width: 200%;
|
||||
}
|
||||
.pdf-z300 {
|
||||
width: 300%;
|
||||
}
|
||||
.pdf-viewer-container .pdf-controls {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.pdf-viewer-container .pdf-controls a,
|
||||
.pdf-viewer-container .pdf-controls span {
|
||||
display: inline-block;
|
||||
margin: 0 4px 4px 0;
|
||||
background-color: #2d2d2d;
|
||||
color: #eee;
|
||||
border: none;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.pdf-viewer-container button:hover {
|
||||
.pdf-viewer-container .pdf-controls a:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
.pdf-viewer-container span {
|
||||
.pdf-viewer-container .pdf-controls .pdf-off {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.pdf-message {
|
||||
padding: 12px;
|
||||
color: #ccc;
|
||||
margin: 0 1rem;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
}
|
||||
.pdf-maximized {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
border-radius: 0;
|
||||
background: #111;
|
||||
}
|
||||
.pdf-maximized .pdf-canvas-wrap {
|
||||
flex: 1 1 auto;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
}
|
||||
.pdf-maximized .pdf-controls {
|
||||
flex-shrink: 0;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* CVs */
|
||||
|
|
@ -2098,6 +2232,51 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
|
|||
right: 112px;
|
||||
font-size: 1.15em;
|
||||
}
|
||||
.lightbox-prev,
|
||||
.lightbox-next {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 2em;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
.lightbox-prev {
|
||||
left: 15px;
|
||||
}
|
||||
.lightbox-next {
|
||||
right: 15px;
|
||||
}
|
||||
.media-download-btn {
|
||||
display: inline-flex;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.edited-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.5);
|
||||
border-radius: 10px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.media-lock-note {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.85;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* Opinions */
|
||||
.voting-buttons {
|
||||
|
|
@ -2614,15 +2793,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.market-comments {
|
||||
background: #2f2f2f;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.market-comments .votations-comment-card {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.countdown-strong {
|
||||
font-weight: 700;
|
||||
|
|
@ -3169,12 +3340,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
|
|||
color: #ffd740;
|
||||
}
|
||||
|
||||
.comment-footer {
|
||||
font-size: 0.85em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #ffd740;
|
||||
}
|
||||
|
||||
.date-link {
|
||||
color: #ffd740;
|
||||
|
|
@ -3799,8 +3964,6 @@ a.pm-btn:hover { text-decoration: none }
|
|||
.card-field-stacked .card-value{white-space:pre-wrap;word-break:break-word}
|
||||
.card-field-rich{flex-direction:column;align-items:flex-start}
|
||||
.card-field-rich .card-value{white-space:pre-wrap;word-break:break-word;margin-top:4px}
|
||||
.report-template{margin-top:12px;padding:12px;background:rgba(255,255,255,0.05);border-radius:8px}
|
||||
.report-template .card-label{white-space:nowrap}
|
||||
|
||||
@media (max-width: 420px){
|
||||
.card-field{ flex-direction:column; }
|
||||
|
|
@ -4311,6 +4474,7 @@ a.pm-btn:hover { text-decoration: none }
|
|||
align-items: center;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.tribe-side-actions.report-confirm-actions { justify-content: center }
|
||||
|
||||
.tribe-side-actions form {
|
||||
margin: 0;
|
||||
|
|
@ -6927,8 +7091,6 @@ a.chip-link:hover .pm-exposition-chip{filter:brightness(1.2)}
|
|||
.jobs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(max(300px,calc((100% - 40px)/3)),1fr));gap:20px}
|
||||
.blogs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(max(300px,calc((100% - 40px)/3)),1fr));gap:20px}
|
||||
.jobs-grid .tribe-card,.tribe-grid .tribe-card{margin-bottom:0}
|
||||
.tribe-card .card-comments-summary{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:8px}
|
||||
.tribe-card .card-comments-summary .inline-form{margin:0}
|
||||
.jobs-info-table{table-layout:auto}
|
||||
.jobs-info-table .tribe-info-label{width:1%;white-space:nowrap}
|
||||
.jobs-info-table .tribe-info-value{white-space:normal;word-break:normal;overflow-wrap:anywhere}
|
||||
|
|
@ -6940,14 +7102,13 @@ a.chip-link:hover .pm-exposition-chip{filter:brightness(1.2)}
|
|||
.job-price-line{margin:0;font-size:18px;font-weight:700;font-family:'JetBrains Mono',monospace}
|
||||
.job-card-view-btn{margin-top:8px}
|
||||
.job-card-view-btn form{margin:0}
|
||||
.vote-comments-section,.comments-count,.comment-form-wrapper,.comments-list,.vote-buttons-block,.vote-buttons-row,.voting-buttons,.vote-table{border:none;background:transparent;padding:0;margin:0}
|
||||
.vote-comments-section,.comment-form-wrapper,.comments-list,.vote-buttons-block,.vote-buttons-row,.voting-buttons,.vote-table{border:none;background:transparent;padding:0;margin:0}
|
||||
.vote-buttons-block{display:flex;flex-direction:column;gap:8px}
|
||||
.vote-buttons-row{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.voting-buttons{display:flex;gap:10px;margin-top:10px;flex-wrap:wrap}
|
||||
.vote-comments-section{margin-top:16px}
|
||||
.comment-form-wrapper{margin-top:12px}
|
||||
.comments-list{margin-top:12px}
|
||||
.comments-count{margin-bottom:8px}
|
||||
.title-with-chip{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:transparent !important;border:0 !important;padding:0 !important}
|
||||
.title-with-chip h2{margin:0}
|
||||
.pm-exposition-icon{font-size:14px;line-height:1}
|
||||
|
|
@ -6955,7 +7116,7 @@ a.chip-link:hover .pm-exposition-chip{filter:brightness(1.2)}
|
|||
.shop-title-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:6px;padding:0;background:transparent;border-radius:0;box-shadow:none}
|
||||
.shop-title-row h2{margin:0}
|
||||
.card-chips-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0 0 10px 0;padding:0;border:none;background:transparent;border-radius:0;box-shadow:none}
|
||||
.tribe-card-body .job-price-line,.tribe-card-body .job-meta-line,.tribe-card-body .job-card-view-btn,.tribe-card-body .card-comments-summary,.tribe-card-body .tribe-card-members{padding:0;background:transparent;border-radius:0;box-shadow:none;border:none}
|
||||
.tribe-card-body .job-price-line,.tribe-card-body .job-meta-line,.tribe-card-body .job-card-view-btn{padding:0;background:transparent;border-radius:0;box-shadow:none;border:none}
|
||||
.tribe-card-body .job-price-line{margin:4px 0}
|
||||
.tribe-card-body .job-card-view-btn{margin-top:8px;margin-bottom:0}
|
||||
.transfer-block-card{border:none;background:transparent;padding:0;margin:0 0 12px 0}
|
||||
|
|
@ -7316,9 +7477,8 @@ a.user-link{padding:8px 16px;border-radius:5px;text-align:center;font-weight:bol
|
|||
.industry-blueprint-card{border:1px solid rgba(128,128,128,.3);border-radius:6px;padding:10px;margin:8px 0}
|
||||
.industry-card-wrap{position:relative;border:1px solid rgba(128,128,128,.3);border-radius:6px;padding:10px;margin:8px 0;background:transparent;box-shadow:none}
|
||||
.industry-card-wrap .industry-blueprint-card{border:none;background:transparent;padding:0;margin:0;box-shadow:none;border-radius:0}
|
||||
.industry-card-wrap > .card-header{position:absolute;top:8px;right:10px;margin:0;padding:0;border:none;background:transparent;box-shadow:none;z-index:1}
|
||||
.industry-card-wrap > .card-header > span:empty{display:none}
|
||||
.industry-card-wrap > .industry-blueprint-card{padding:0 96px 0 0}
|
||||
.industry-card-wrap > .card-header:not(.card-top){position:absolute;top:8px;right:10px;margin:0;padding:0;border:none;background:transparent;box-shadow:none;z-index:1}
|
||||
.industry-card-wrap > .industry-blueprint-card{padding:0}
|
||||
.industry-actions form,.industry-actions .project-control-form--status{margin:0;padding:0;border:none;background:transparent;display:flex;align-items:center;gap:10px}
|
||||
.industry-actions .project-control-select,.industry-actions .project-control-btn,.industry-actions select,.industry-actions button{margin:0}
|
||||
.industry-actions button,.industry-actions .project-control-select{display:inline-flex;align-items:center;justify-content:center;height:38px;box-sizing:border-box}
|
||||
|
|
@ -7459,7 +7619,6 @@ a.user-link{padding:8px 16px;border-radius:5px;text-align:center;font-weight:bol
|
|||
.content-actions .spread-form{margin:0;display:inline-flex}
|
||||
|
||||
.blog-card-body,
|
||||
.blog-detail-body,
|
||||
.mention-card-body {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
|
|
@ -7490,14 +7649,6 @@ a.user-link{padding:8px 16px;border-radius:5px;text-align:center;font-weight:bol
|
|||
word-break: break-word;
|
||||
}
|
||||
|
||||
.blog-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 6px 0;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.blog-allow-comments {
|
||||
display: inline-flex;
|
||||
|
|
@ -8865,6 +9016,8 @@ label.wallet-create-transfer input[type="checkbox"]{width:auto !important;flex:0
|
|||
.graphos-lightbox .lightbox-close{position:fixed;top:14px;right:22px;z-index:10002;font-size:28px;text-decoration:none}
|
||||
.live-lightbox .lightbox-image-link{display:flex;align-items:center;justify-content:center;max-width:100%;max-height:100%;padding:0;margin:0;border:0;background:none}
|
||||
.shop-buy-form,.market-item.actions,.market-bid-form,.market-buy-form{border:0;background:transparent;box-shadow:none;padding:0}
|
||||
.pending-media{display:flex;align-items:center;justify-content:center;min-height:120px;max-width:480px;padding:16px;border:1px dashed #8a8a8a;border-radius:10px;background:rgba(128,128,128,0.15);color:#a0a0a0;text-align:center;box-sizing:border-box;margin:8px 0}
|
||||
.buy-box{border:1px solid rgba(255,165,0,0.35);border-radius:8px;padding:14px 16px;margin:0 0 16px 0;background:rgba(255,255,255,0.03)}
|
||||
.project-progress-block .confirmations-progress{width:100%}
|
||||
.tags-list .tag-table th:last-child,.tags-list .tag-table td:last-child{width:1%;white-space:nowrap;text-align:right}
|
||||
.stats-block .tag-table,.stats-card .tag-table{width:100%;border-collapse:collapse;table-layout:fixed}
|
||||
|
|
@ -8924,3 +9077,81 @@ label.wallet-create-transfer input[type="checkbox"]{width:auto !important;flex:0
|
|||
.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}
|
||||
.graphos-zoom-icon{font-size:1.5em;line-height:1;vertical-align:0.06em}
|
||||
.card-top{display:flex;flex-wrap:nowrap;align-items:center;justify-content:space-between;gap:8px;margin:0 0 6px !important;background:transparent !important;border:0 !important;padding:0 !important;box-shadow:none !important;overflow:visible !important}
|
||||
.card-byline{display:flex;align-items:center;flex-wrap:wrap;gap:4px 6px;flex:1 1 auto;min-width:0;margin:0 !important;font-size:12px;line-height:16px;background:transparent !important;border:0 !important;padding:0 !important;box-shadow:none !important;overflow:visible !important}
|
||||
.card-byline-avatar{display:inline-flex;flex:0 0 auto;background:transparent !important;border:0 !important;padding:0 !important}
|
||||
.card-byline-avatar img{display:block;width:24px;height:24px;border-radius:50%;object-fit:cover;margin:0;border:0}
|
||||
.card-byline a.user-link{display:inline;background:transparent !important;border:0 !important;padding:0 !important;margin:0;border-radius:0;color:#FFD700 !important;font-size:12px;font-weight:700;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;max-width:100%;box-shadow:none !important}
|
||||
.card-byline a.user-link:hover{text-decoration:underline;background:transparent !important}
|
||||
.card-byline-time{opacity:.65;white-space:nowrap}
|
||||
.card-byline-time::before{content:"\2022";margin-right:6px}
|
||||
.card-byline .card-chips-row{display:inline-flex;flex-wrap:wrap;align-items:center;gap:4px;margin:0 !important;background:transparent !important;border:0 !important;padding:0 !important;box-shadow:none !important}
|
||||
.card-byline .pm-exposition-chip{font-size:11px;padding:1px 8px}
|
||||
.card-byline :is(.emergency-severity,.edited-chip,.eco-tax-chip){font-size:11px;padding:1px 8px;margin:0;line-height:16px}
|
||||
.card-byline :is(.edited-badge,.votations-comment-date){font-size:12px;opacity:.65;margin:0}
|
||||
.card-top>.content-actions{display:flex;flex:0 0 auto;flex-wrap:nowrap;align-items:center;gap:4px;margin:0 0 0 auto !important;background:transparent !important;border:0 !important;padding:0 !important;box-shadow:none !important;overflow:visible !important}
|
||||
.card-top .btn-singleview.btn-content{min-width:32px;height:32px;padding:0 8px;font-size:.95em}
|
||||
.card-menu{position:relative}
|
||||
.card-menu>.card-menu-toggle.btn-singleview{list-style:none;min-width:32px;height:32px;padding:0 6px;font-size:1.3em;border-radius:50%}
|
||||
.card-menu[open]>.card-menu-toggle::before{content:"";position:fixed;inset:0;z-index:29;cursor:default}
|
||||
.card-menu[open]{z-index:40}
|
||||
.card-top .spread-form{display:inline-flex;align-items:center;margin:0;padding:0}
|
||||
.card-top .spread-form .btn-singleview{box-sizing:border-box;height:32px;min-width:32px;margin:0;padding:0 12px;gap:6px;border-radius:16px;font-size:.95em;line-height:1}
|
||||
.card-top .spread-form .btn-singleview .btn-count{line-height:1}
|
||||
.card-top>.content-actions>*{margin-top:0;margin-bottom:0}
|
||||
.card-menu-toggle::-webkit-details-marker{display:none}
|
||||
.card-menu-toggle::marker{content:""}
|
||||
.card-menu-items{position:absolute;right:0;top:calc(100% + 4px);z-index:30;display:flex;flex-direction:column;gap:0;min-width:200px;margin:0 !important;padding:6px 0 !important;background:#1e1f23;border:1px solid #333;border-radius:8px;box-shadow:0 6px 18px rgba(0,0,0,.45);overflow:visible !important}
|
||||
.card-menu-items .content-action-form,.card-menu-items .spread-form{display:block;margin:0;background:transparent !important;border:0 !important;padding:0 !important}
|
||||
.card-menu-items .btn-singleview{width:100%;justify-content:flex-start;gap:12px;height:40px;margin:0;padding:0 16px;border-radius:0;font-size:1em;background:transparent !important}
|
||||
.card-menu-items .btn-singleview:hover{background:rgba(128,128,128,.2) !important}
|
||||
.card-menu-items .btn-singleview:has(>.btn-icon:only-child){width:100%;min-width:0;height:40px;padding:0 16px;flex-shrink:0}
|
||||
.card-menu-items .btn-singleview .btn-icon{flex:0 0 18px;width:18px;height:18px}
|
||||
.card-menu-items .btn-singleview::after{content:attr(title);font-size:13px;white-space:nowrap}
|
||||
div:has(.card-menu[open]){overflow:visible !important}
|
||||
div:has(>.card-top):not(.tribe-side){padding:8px 16px 12px !important;border-radius:16px !important}
|
||||
.podcast-episode-row .podcast-episode-main{padding:0 !important;border-radius:0 !important}
|
||||
:is(.campaign-update,.logistics-booking,.logistics-rating):has(>.card-top){border-radius:0 !important}
|
||||
:where(:is(div,li):has(>.card-top):not(.tribe-side)) div{margin:0 0 8px;padding:0;border:0;border-radius:0;background-color:transparent;box-shadow:none}
|
||||
:where(:is(div,li):has(>.card-top):not(.tribe-side)) :is(.card-section,.card-body){margin:0 !important;padding:0 !important;border:0 !important;border-radius:0 !important;background:transparent !important;box-shadow:none !important}
|
||||
:is(div,li):has(>.card-top):not(.tribe-side){font-family:'Inter','Roboto','Segoe UI','Helvetica Neue',Arial,sans-serif;font-size:14px;line-height:20px;font-weight:400}
|
||||
:where(:is(div,li):has(>.card-top):not(.tribe-side)) :is(p,.blog-card-text,.feed-text,.post-text){font-size:14px;line-height:20px;font-weight:400}
|
||||
:where(:is(div,li):has(>.card-top)) :is(h2,.tribe-card-title){font-size:18px;font-weight:600;line-height:24px;letter-spacing:0;margin:4px 0 6px}
|
||||
:where(:is(div,li):has(>.card-top)) :is(h2,.tribe-card-title) a{text-decoration:none}
|
||||
:where(:is(div,li):has(>.card-top)) :is(h2,.tribe-card-title) a:hover{text-decoration:underline}
|
||||
.card-engage{display:flex !important;flex-wrap:wrap;align-items:center;justify-content:flex-start;gap:8px;margin:8px 0 0 !important}
|
||||
.card-engage>*{flex:0 0 auto;margin:0 !important}
|
||||
.card-engage>details[open],.card-engage>details[open]~details,.card-engage>.feed-votes-summary{flex:1 0 100%}
|
||||
.card-engage>details:not([open]){width:auto}
|
||||
.card-meta-row{gap:8px}
|
||||
.card-engage .opinions-summary,.card-engage .comments-summary,.card-meta-row .opinions-summary,.card-meta-row .comments-summary-link,.card-meta-row .comments-summary,.share-summary-link{display:inline-flex !important;align-items:center;gap:6px;width:auto !important;max-width:none;box-sizing:border-box;height:32px;padding:0 12px !important;border-radius:5px !important;font-size:12px;font-weight:600;line-height:30px;text-decoration:none}
|
||||
.share-summary-link{border:1px solid rgba(128,128,128,.5);background:rgba(128,128,128,.12);color:inherit}
|
||||
.share-summary-link:hover{background:rgba(128,128,128,.25);text-decoration:none}
|
||||
.card-meta-row>.feed-votes-summary{flex:1 0 100%;order:-1;margin:0}
|
||||
.card-share{display:inline-flex;flex:0 0 auto;background:transparent !important;border:0 !important;padding:0 !important}
|
||||
.share-summary-icon{margin:0}
|
||||
.tribe-section-nav{gap:6px;padding:10px 12px !important;margin:0 0 12px !important;overflow:visible}
|
||||
.tribe-section-group{display:contents}
|
||||
.tribe-section-group>form:last-child{margin-right:12px}
|
||||
.tribe-section-nav .filter-btn{margin:0}
|
||||
.wallet-error-actions{display:flex;flex-wrap:wrap;gap:10px;margin:12px 0 4px;padding:0;border:0;background:none;box-shadow:none;overflow:visible}
|
||||
.wallet-error-actions .update-download-link{display:inline-block;padding:10px 18px;background:#FFA500;border:0;border-radius:6px;color:#000;font-size:14px;font-weight:bold;text-decoration:none}
|
||||
.wallet-error-actions .update-download-link:hover{background:#FFD700;color:#000;text-decoration:none}
|
||||
.tribe-card:has(>.card-top)>.tribe-card-body{padding-bottom:0}
|
||||
.tribe-card:has(>.card-top)>.tribe-card-body>:last-child{margin-bottom:0}
|
||||
.bookmark-title .card-title-link:not([href^="/tribe/"]){color:#ff6600}
|
||||
.blockexplorer-search{margin:12px 0 16px;padding:10px 12px}
|
||||
.blockexplorer-search-form :is(.blockexplorer-search-row,.blockexplorer-search-pair,.blockexplorer-search-dates,.blockexplorer-search-actions,.filter-box__controls){margin:0;padding:0;border:0;background:transparent;box-shadow:none;overflow:visible}
|
||||
.blockexplorer-search-form .blockexplorer-search-row{flex-direction:row;flex-wrap:wrap;align-items:center;gap:8px}
|
||||
.blockexplorer-search-form .blockexplorer-search-pair,.blockexplorer-search-form .blockexplorer-search-dates{gap:8px}
|
||||
.blockexplorer-search-form .blockexplorer-search-input{min-width:180px;margin:0}
|
||||
.tribe-main>.media-download-btn{align-self:flex-start}
|
||||
.tribe-side-actions .update-btn.danger-btn{border-color:#d9534f;color:#d9534f;background:transparent}
|
||||
.tribe-side-actions .update-btn.danger-btn:hover{background:#d9534f;color:#000}
|
||||
.detail-share{display:flex;justify-content:flex-start;margin:4px 0 !important;padding:0 !important;border:0 !important;background:transparent !important;box-shadow:none !important;overflow:visible}
|
||||
.detail-share .share-summary-link{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 12px;border:1px solid rgba(128,128,128,.5);border-radius:5px;background:rgba(128,128,128,.12);font-size:12px;font-weight:600;line-height:30px;text-decoration:none;color:inherit}
|
||||
.detail-share .share-summary-link:hover{background:rgba(128,128,128,.25);text-decoration:none}
|
||||
|
||||
.link-lightbox > .lightbox-close::before { content: ""; position: fixed; inset: 0; z-index: 0; cursor: default; }
|
||||
.link-lightbox > .link-box { position: relative; z-index: 1; }
|
||||
|
|
|
|||
|
|
@ -320,6 +320,8 @@ a.user-link:focus, button.user-link:focus {
|
|||
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #FF8F00 !important; color: #FFFFFF !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; }
|
||||
.card-menu-items { background: #F0F0F0 !important; border-color: #D0D0D0 !important; }
|
||||
.card-byline a.user-link { color: #FF6F00 !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; }
|
||||
|
|
@ -409,3 +411,7 @@ button.tribe-action-btn { background: transparent !important; border: 1px solid
|
|||
.oasis-pager-info { color: #888888; }
|
||||
.oasis-pager-btn { background: #FFFFFF; color: #555555 !important; border: 1px solid #E0E0E0; text-decoration: none !important; }
|
||||
.oasis-pager-btn:hover, .oasis-pager-btn.active { border-color: #2C2C2C; color: #2C2C2C !important; text-decoration: none !important; }
|
||||
|
||||
.pdf-viewer-full {
|
||||
background-color: #F9F9F9;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,11 @@ div {
|
|||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
:where(:is(div, li):has(> .card-top):not(.tribe-side)) div {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
div .header-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -320,6 +325,7 @@ a.user-link:focus, button.user-link:focus {
|
|||
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #ffb733 !important; color: #000000 !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; }
|
||||
.card-menu-items { background: #1e1f23 !important; border-color: #333333 !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; }
|
||||
|
|
|
|||
|
|
@ -322,6 +322,8 @@ a.user-link:focus, button.user-link:focus {
|
|||
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #66FF66 !important; color: #000000 !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; }
|
||||
.card-menu-items { background: #000000 !important; border-color: #00FF00 !important; }
|
||||
.card-byline a.user-link { 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; }
|
||||
|
|
|
|||
|
|
@ -1220,16 +1220,12 @@ audio, video { max-width: 100% !important; }
|
|||
.module-table td:first-child a { white-space: normal !important; word-break: keep-all !important; overflow-wrap: normal !important; hyphens: none !important; }
|
||||
.module-table td, .module-table th { padding: 8px 6px !important; vertical-align: middle !important; }
|
||||
.module-table td:nth-child(2) p { font-size: .85em !important; margin: 0 !important; }
|
||||
.pdf-canvas-wrap { height: auto !important; max-height: 70vh !important; overflow: auto !important; }
|
||||
.pdf-viewer-container { padding: 8px !important; }
|
||||
.pdf-frame { height: 75vh !important; min-height: 420px !important; }
|
||||
.pdf-frame:target { height: 100vh !important; }
|
||||
.pdf-viewer-container { padding: 8px !important; margin-bottom: 8px !important; }
|
||||
.pdf-embedded { margin-bottom: 0 !important; }
|
||||
.pdf-viewer-container .pdf-controls { display: flex !important; flex-wrap: wrap !important; gap: 8px !important; justify-content: center !important; align-items: center !important; margin-top: 8px !important; }
|
||||
.pdf-viewer-container button { font-size: 1em !important; padding: 7px 10px !important; min-width: 42px !important; }
|
||||
.pdf-viewer-container span { margin: 0 4px !important; }
|
||||
.pdf-viewer-container { margin-bottom: 8px !important; }
|
||||
.pdf-maximized { position: fixed !important; inset: 0 !important; z-index: 9999 !important; margin: 0 !important; border-radius: 0 !important; background: #111 !important; display: flex !important; flex-direction: column !important; padding: 6px !important; overflow: hidden !important; }
|
||||
.pdf-maximized .pdf-canvas-wrap { flex: 1 1 auto !important; max-height: none !important; height: auto !important; overflow: auto !important; }
|
||||
.pdf-maximized .pdf-controls { flex-shrink: 0 !important; margin: 6px 0 !important; }
|
||||
.pdf-maximized-body { overflow: hidden !important; }
|
||||
.pdf-viewer-container .pdf-controls a, .pdf-viewer-container .pdf-controls span { font-size: 1em !important; padding: 7px 10px !important; min-width: 42px !important; margin: 0 !important; text-align: center !important; box-sizing: border-box !important; text-decoration: none !important; }
|
||||
|
||||
.main-content .feed-workspace { display: flex !important; flex-direction: column !important; gap: 10px !important; }
|
||||
.feed-workspace .feed-side { display: block !important; position: static !important; }
|
||||
|
|
@ -1290,10 +1286,8 @@ audio, video { max-width: 100% !important; }
|
|||
.feed-card .refeed-column { flex-wrap: wrap !important; }
|
||||
.feed-card .refeed-column .muted { flex-basis: 100% !important; text-align: center !important; margin: 4px 0 0 0 !important; }
|
||||
.feed-card .card-engage summary, .feed-card .comments-link { min-height: 40px !important; padding: 8px 10px !important; box-sizing: border-box !important; display: flex !important; align-items: center !important; justify-content: center !important; font-size: .85em !important; }
|
||||
.comments-collapse .comments-inline-form { display: flex !important; flex-direction: column !important; gap: 6px !important; padding: 8px !important; }
|
||||
.comments-collapse textarea { width: 100% !important; box-sizing: border-box !important; margin: 0 !important; }
|
||||
.comments-collapse .filter-btn { width: auto !important; align-self: center !important; padding: 6px 18px !important; }
|
||||
.comments-collapse .comments-view-all { display: block !important; text-align: center !important; padding: 6px !important; font-size: .8em !important; }
|
||||
.comments-collapse .comment-form { display: flex !important; flex-direction: column !important; gap: 6px !important; width: 100% !important; }
|
||||
.comments-collapse .comment-textarea { width: 100% !important; box-sizing: border-box !important; margin: 0 !important; }
|
||||
.comments-collapse .comment-file-upload { display: flex !important; flex-direction: column !important; gap: 4px !important; width: 100% !important; }
|
||||
|
|
@ -3411,7 +3405,7 @@ body > .header {
|
|||
:where(.main-column) section {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.main-column :is(form, section, div) > br {
|
||||
.main-column :is(form, section, div):has(> :is(input, select, textarea, button, label)) > br {
|
||||
display: none !important;
|
||||
}
|
||||
.main-column .card-field > a.card-value.user-link {
|
||||
|
|
@ -3604,6 +3598,10 @@ body > .header {
|
|||
width: 100% !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
.main-column .room-live-panel .room-live-head-main > .card-chips-row {
|
||||
justify-content: center !important;
|
||||
margin-bottom: 1em !important;
|
||||
}
|
||||
.main-column .tribe-main:has(> .room-live-panel) {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
|
|
@ -4040,3 +4038,127 @@ body > .header {
|
|||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
}
|
||||
.main-column .tribe-side { flex-flow: row wrap !important; justify-content: center !important; align-items: center !important; column-gap: 8px !important; }
|
||||
.main-column .tribe-side > * { flex: 0 0 100% !important; min-width: 0 !important; }
|
||||
.main-column .tribe-side > .tribe-side-actions:not(.tribe-side-actions-bottom) { flex-wrap: nowrap !important; justify-content: center !important; gap: 8px !important; }
|
||||
.main-column .tribe-side > .tribe-side-actions:not(.tribe-side-actions-bottom) button { font-size: 13px !important; padding: 6px 10px !important; white-space: nowrap !important; }
|
||||
.main-column .tribe-side > .tribe-side-actions-bottom { order: 1 !important; flex: 0 0 auto !important; margin: 0 !important; }
|
||||
.main-column .tribe-side > .tribe-side-subtribes { order: 2 !important; flex: 0 0 auto !important; margin: 0 !important; }
|
||||
.main-column .tribe-side > .tribe-side-subtribes ~ .tribe-side-tags { order: 3 !important; }
|
||||
.main-column .tribe-side > .tribe-side-subtribes { overflow: visible !important; }
|
||||
.main-column .tribe-side > :is(.tribe-side-subtribes, .tribe-side-actions-bottom) :is(form, button) { margin-top: 0 !important; margin-bottom: 0 !important; }
|
||||
.main-column .tribe-side > .tribe-members-count { order: 4 !important; text-align: center !important; }
|
||||
.main-column .tribe-open-invite-panel { text-align: center !important; }
|
||||
.main-column .tribe-open-invite-panel .invite-qr-card { margin-left: auto !important; margin-right: auto !important; }
|
||||
.main-column .tribe-open-invite-panel > form { display: flex !important; justify-content: center !important; }
|
||||
.main-column .tribe-open-invite-panel .tribe-open-invite { text-align: center !important; }
|
||||
.main-column .tribe-open-invite-panel .tribe-open-invite-code { margin-bottom: 6px !important; }
|
||||
.main-column .tribe-open-invite-panel .invite-qr-card { margin-top: 0 !important; margin-bottom: 6px !important; }
|
||||
.main-column .tribe-open-invite-panel > form button { margin-top: 0 !important; }
|
||||
.main-column .pm-card .pm-info-table tr { flex-wrap: nowrap !important; align-items: center !important; gap: 8px !important; }
|
||||
.main-column .pm-card .pm-info-table td.card-label { flex: 0 0 auto !important; width: auto !important; }
|
||||
.main-column .pm-card .pm-info-table td.card-value { flex: 1 1 auto !important; min-width: 0 !important; width: auto !important; }
|
||||
.main-column .pm-card .pm-info-table td.card-value > a.user-link { display: block !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; }
|
||||
.main-column .pm-card .pm-info-table, .main-column .pm-card .pm-info-table > tbody { display: flex !important; flex-direction: column !important; }
|
||||
.main-column .pm-card .pm-info-table tr:has(> .pm-subject-cell) { order: -1 !important; }
|
||||
.main-column .pm-card .pm-info-table tr:has(> .pm-subject-cell) { flex-wrap: wrap !important; align-items: baseline !important; row-gap: 6px !important; }
|
||||
.main-column .pm-card .pm-info-table tr:has(> .pm-subject-cell)::after { content: "" !important; order: -1 !important; flex: 0 0 100% !important; height: 0 !important; }
|
||||
.main-column .pm-card .pm-subject-cell { display: contents !important; }
|
||||
.main-column .pm-card .pm-subject-cell > .pm-exposition-chip { order: -2 !important; margin: 0 !important; }
|
||||
.main-column .pm-card .pm-subject-cell > span:not(.pm-exposition-chip) { flex: 1 1 0 !important; min-width: 0 !important; }
|
||||
|
||||
.main-column .card-header.card-top { display: flex !important; flex-wrap: nowrap !important; align-items: flex-start !important; justify-content: space-between !important; gap: 8px !important; width: auto !important; min-height: 0 !important; margin: 0 0 6px !important; padding: 0 !important; }
|
||||
.main-column .card-header.card-top > .card-byline { display: flex !important; flex: 1 1 auto !important; flex-wrap: wrap !important; align-items: center !important; gap: 4px 6px !important; min-width: 0 !important; min-height: 32px !important; width: auto !important; margin: 0 !important; padding: 0 !important; }
|
||||
.main-column .card-header.card-top .card-byline-avatar { display: inline-flex !important; flex: 0 0 auto !important; align-items: center !important; width: 24px !important; height: 32px !important; min-width: 0 !important; padding: 0 !important; margin: 0 !important; }
|
||||
.main-column .card-header.card-top .card-byline-avatar img { width: 24px !important; height: 24px !important; min-height: 0 !important; max-height: 24px !important; border-radius: 50% !important; object-fit: cover !important; margin: 0 !important; }
|
||||
.main-column .card-header.card-top .card-byline a.user-link { display: block !important; flex: 0 1 auto !important; width: auto !important; height: auto !important; min-width: min(72px, 100%) !important; max-width: calc(100% - 150px) !important; padding: 0 !important; margin: 0 !important; font-size: 12px !important; line-height: 16px !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; }
|
||||
.main-column .card-header.card-top > .content-actions { display: flex !important; flex: 0 0 auto !important; flex-wrap: nowrap !important; align-items: center !important; gap: 4px !important; width: auto !important; margin: 0 0 0 auto !important; padding: 0 !important; }
|
||||
.main-column .card-header.card-top > .content-actions > .card-menu { display: block !important; width: auto !important; margin: 0 !important; }
|
||||
.main-column .card-header.card-top > .content-actions .btn-singleview:not(.card-menu-items *) { width: auto !important; min-width: 32px !important; height: 32px !important; margin: 0 !important; }
|
||||
.main-column .card-header.card-top .card-menu > .card-menu-toggle.btn-singleview { background: #2a2a2a !important; border-radius: 50% !important; }
|
||||
.main-column .card-header.card-top .card-menu > .card-menu-items { position: absolute !important; top: calc(100% + 4px) !important; right: 0 !important; left: auto !important; z-index: 700 !important; display: flex !important; flex-direction: column !important; flex-wrap: nowrap !important; gap: 0 !important; width: max-content !important; min-width: 200px !important; max-width: calc(100vw - 32px) !important; height: auto !important; margin: 0 !important; padding: 6px 0 !important; background: #1e1f23 !important; border: 1px solid #333 !important; border-radius: 8px !important; box-shadow: 0 6px 18px rgba(0,0,0,.45) !important; }
|
||||
.main-column .card-header.card-top .card-menu-items :is(.content-action-form, .spread-form) { display: block !important; width: 100% !important; margin: 0 !important; padding: 0 !important; background: transparent !important; border: 0 !important; }
|
||||
.main-column .card-header.card-top .card-menu-items .btn-singleview { display: flex !important; align-items: center !important; justify-content: flex-start !important; gap: 12px !important; width: 100% !important; min-width: 0 !important; height: 40px !important; margin: 0 !important; padding: 0 16px !important; border: 0 !important; border-radius: 0 !important; background: transparent !important; box-shadow: none !important; }
|
||||
.main-column .card-header.card-top .card-menu-items .btn-singleview::after { content: attr(title); font-size: 13px; white-space: nowrap; }
|
||||
.lightbox:not(.graphos-lightbox, .bank-eco-chart-lightbox, .live-lightbox) > .lightbox-close { right: 16px !important; }
|
||||
.lightbox:not(.graphos-lightbox, .bank-eco-chart-lightbox, .live-lightbox) > .lightbox-download { right: 68px !important; }
|
||||
|
||||
/* APK: barra de modulo compacta y fija */
|
||||
body:has(.apk-modbar), body:has(.apk-modbar) .main-content { overflow-x: clip !important; overflow-y: visible !important; }
|
||||
.main-column > .apk-modbar { position: sticky !important; top: 60px !important; z-index: 800 !important; background: #121212 !important; display: flex !important; flex-direction: column !important; gap: 6px !important; width: auto !important; max-width: none !important; margin: -20px calc(50% - 50vw) 6px !important; padding: 12px calc(50vw - 50%) 6px !important; box-shadow: none !important; }
|
||||
.main-column > .apk-modbar > .apk-modbar-row { display: flex !important; flex-wrap: nowrap !important; align-items: center !important; gap: 8px !important; min-width: 0 !important; width: 100% !important; }
|
||||
.main-column > .apk-modbar > .apk-modbar-row > .apk-title { flex: 0 1 auto !important; min-width: 0 !important; margin: 0 !important; padding: 0 !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; color: #ffa300 !important; line-height: 1.1 !important; }
|
||||
.main-column > .apk-modbar > .apk-modbar-row > .tags-header { flex: 0 1 auto !important; margin: 0 !important; padding: 0 !important; border: 0 !important; background: transparent !important; }
|
||||
.main-column > .apk-modbar > .apk-modbar-row > .tags-header > p { display: none !important; }
|
||||
.main-column > .apk-modbar > .apk-modbar-row > a { flex: 0 0 auto !important; display: inline-flex !important; align-items: center !important; margin: 0 !important; }
|
||||
.main-column > .apk-modbar > .apk-modbar-row > a > .pm-exposition-chip { margin: 0 !important; }
|
||||
.main-column > .apk-modbar > .apk-modbar-foot { display: flex !important; flex-wrap: nowrap !important; align-items: center !important; gap: 8px !important; min-width: 0 !important; width: 100% !important; }
|
||||
.main-column > .apk-modbar > :is(.apk-modbar-row, .apk-modbar-foot) > .apk-pin { flex: 0 0 auto !important; margin: 0 0 0 auto !important; }
|
||||
.main-column > .apk-modbar > :is(.apk-modbar-row, .apk-modbar-foot) > form.apk-pin { display: flex !important; padding: 0 !important; }
|
||||
.main-column > .apk-modbar > :is(.apk-modbar-row, .apk-modbar-foot) :is(.apk-pin.create-button, .apk-pin .create-button) { width: auto !important; height: auto !important; min-height: 0 !important; margin: 0 !important; padding: 2px 10px !important; font-size: 11.25px !important; font-weight: 700 !important; line-height: 16.875px !important; border-radius: 12px !important; border: 1px solid #ffcc00 !important; background: #ffcc00 !important; color: #111 !important; white-space: nowrap !important; }
|
||||
.main-column > .apk-modbar > .apk-strip { display: flex !important; flex-wrap: nowrap !important; align-items: center !important; overflow-x: auto !important; scrollbar-width: none; gap: 6px !important; width: auto !important; max-width: none !important; margin: 0 calc((100% - 100vw) / 2) !important; padding: 0 calc((100vw - 100%) / 2) !important; border: 0 !important; background: transparent !important; overscroll-behavior: none !important; }
|
||||
@supports (animation-timeline: scroll()) {
|
||||
.main-column > .apk-modbar > :is(.apk-strip, .apk-xrow)::before, .main-column > .apk-modbar > :is(.apk-strip, .apk-xrow)::after { position: sticky; z-index: 3; flex: 0 0 auto; align-self: stretch; width: 22px; padding-bottom: 3px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; color: #FFD700; font-size: 22px; font-weight: 700; line-height: 1; pointer-events: none; opacity: 0; animation-duration: auto; animation-timing-function: linear; animation-fill-mode: both; animation-timeline: scroll(nearest inline); }
|
||||
.main-column > .apk-modbar > :is(.apk-strip, .apk-xrow)::before { content: "‹"; order: -1; left: calc((100% - 100vw) / 2); margin-right: -28px; justify-content: center; background: #121212; animation-name: apk-strip-left; }
|
||||
.main-column > .apk-modbar > :is(.apk-strip, .apk-xrow)::after { content: "›"; right: calc((100% - 100vw) / 2); margin-left: -28px; justify-content: center; background: #121212; animation-name: apk-strip-right; }
|
||||
@keyframes apk-strip-right { 0%, 92% { opacity: 1; } 100% { opacity: 0; } }
|
||||
@keyframes apk-strip-left { 0% { opacity: 0; } 8%, 100% { opacity: 1; } }
|
||||
}
|
||||
.main-column > .apk-modbar > .apk-strip::-webkit-scrollbar { display: none; }
|
||||
.main-column > .apk-modbar > .apk-strip :is(form, div) { display: flex !important; flex-wrap: nowrap !important; align-items: center !important; gap: 6px !important; margin: 0 !important; padding: 0 !important; flex: 0 0 auto !important; width: auto !important; max-width: none !important; }
|
||||
.main-column > .apk-modbar > .apk-strip :is(a, button, label) { flex: 0 0 auto !important; white-space: nowrap !important; margin: 0 !important; }
|
||||
.main-column > .apk-modbar > .filter-box { display: flex !important; flex-direction: row !important; flex-wrap: nowrap !important; align-items: center !important; gap: 4px !important; margin: 0 !important; padding: 0 !important; width: 100% !important; }
|
||||
.main-column > .apk-modbar > .filter-box .filter-box__input { flex: 1 1 auto !important; min-width: 0 !important; width: auto !important; height: 30px !important; padding-top: 0 !important; padding-bottom: 0 !important; margin: 0 !important; font-size: 14px !important; }
|
||||
.main-column > .apk-modbar > .filter-box .filter-box__controls { flex: 0 0 auto !important; width: auto !important; min-width: 0 !important; margin: 0 !important; display: flex !important; flex-direction: row !important; gap: 4px !important; }
|
||||
.main-column > .apk-modbar > .filter-box .filter-box__select { height: 30px !important; padding-top: 0 !important; padding-bottom: 0 !important; width: auto !important; max-width: 110px !important; margin: 0 !important; font-size: 13px !important; }
|
||||
.main-column > .apk-modbar > .filter-box .filter-box__button { font-size: 0 !important; width: 36px !important; min-width: 36px !important; height: 30px !important; padding: 0 !important; margin: 0 !important; }
|
||||
.main-column > .apk-modbar > .filter-box .filter-box__button::after { content: "⌕"; font-size: 22px; line-height: 1; color: #FFD700; font-weight: 700; }
|
||||
.main-column > .apk-modbar > :is(.apk-modbar-row, .apk-modbar-foot) > .apk-pin.create-button { margin: 0 0 0 auto !important; }
|
||||
.main-column > .apk-modbar > .filter-box:has(.apk-more-fields) { flex-wrap: wrap !important; row-gap: 6px !important; }
|
||||
.main-column > .apk-modbar .apk-more-toggle { display: none !important; }
|
||||
.main-column > .apk-modbar .apk-more-btn { flex: 0 0 auto !important; display: flex !important; align-items: center !important; justify-content: center !important; width: 36px !important; height: 30px !important; margin: 0 !important; padding: 0 !important; font-size: 18px !important; line-height: 1 !important; color: #FFD700 !important; background: #2a2a2a !important; border: 1px solid #444 !important; border-radius: 8px !important; cursor: pointer !important; }
|
||||
.main-column > .apk-modbar .apk-more-btn::after { content: "⊕"; }
|
||||
.main-column > .apk-modbar .filter-box:has(.apk-more-toggle:checked) .apk-more-btn::after { content: "⊖"; }
|
||||
.main-column > .apk-modbar .apk-more-fields { display: none !important; order: 99 !important; flex: 0 0 100% !important; flex-wrap: wrap !important; gap: 6px !important; margin: 0 !important; padding: 0 !important; border: 0 !important; border-radius: 0 !important; overflow: visible !important; background: transparent !important; }
|
||||
.main-column > .apk-modbar .apk-more-toggle:checked ~ .apk-more-fields { display: flex !important; }
|
||||
.main-column > .apk-modbar .apk-more-fields > :is(input, select) { background: rgba(255, 255, 255, 0.12) !important; border: 1px solid rgba(255, 255, 255, 0.12) !important; border-radius: 4px !important; color: #FFD700 !important; font-size: 14px !important; padding: 0 12px !important; }
|
||||
.main-column > .apk-modbar .apk-more-fields > select:not(#apk-z) { -webkit-appearance: none !important; appearance: none !important; box-sizing: border-box !important; height: 32px !important; padding: 0 30px 0 12px !important; border-radius: 4px !important; text-align: left !important; background-color: rgba(255, 255, 255, 0.12) !important; background-image: linear-gradient(45deg, transparent 50%, #FFD700 50%), linear-gradient(135deg, #FFD700 50%, transparent 50%) !important; background-position: calc(100% - 17px) 55%, calc(100% - 12px) 55% !important; background-size: 5px 5px, 5px 5px !important; background-repeat: no-repeat !important; }
|
||||
.main-column > .apk-modbar .apk-more-fields > input:not(#apk-z) { box-sizing: border-box !important; height: 32px !important; }
|
||||
.main-column > .apk-modbar .apk-more-toggle:checked ~ .apk-more-fields:has(> .apk-more-field) { display: grid !important; grid-template-columns: 1fr 1fr !important; gap: 6px 8px !important; }
|
||||
.main-column > .apk-modbar .apk-more-fields > .apk-more-field { display: flex !important; flex-direction: column !important; align-items: stretch !important; gap: 2px !important; min-width: 0 !important; height: auto !important; background: transparent !important; border: 0 !important; border-radius: 0 !important; overflow: visible !important; padding: 0 !important; }
|
||||
.main-column > .apk-modbar .apk-more-field > .apk-more-label { flex: 0 0 auto !important; font-size: 11px !important; line-height: 1.2 !important; color: #FFD700 !important; text-align: left !important; opacity: 0.85 !important; }
|
||||
.main-column > .apk-modbar .apk-more-field > :is(input, select):not(#apk-z) { flex: 0 0 auto !important; min-width: 0 !important; width: 100% !important; box-sizing: border-box !important; height: 32px !important; margin: 0 !important; padding: 0 8px !important; font-size: 13px !important; color: #FFD700 !important; background-color: rgba(255, 255, 255, 0.12) !important; border: 1px solid rgba(255, 255, 255, 0.12) !important; border-radius: 4px !important; }
|
||||
.main-column > .apk-modbar .apk-more-field > select:not(#apk-z) { -webkit-appearance: none !important; appearance: none !important; padding: 0 30px 0 12px !important; background-image: linear-gradient(45deg, transparent 50%, #FFD700 50%), linear-gradient(135deg, #FFD700 50%, transparent 50%) !important; background-position: calc(100% - 17px) 55%, calc(100% - 12px) 55% !important; background-size: 5px 5px, 5px 5px !important; background-repeat: no-repeat !important; }
|
||||
.main-column > .apk-modbar .apk-more-fields input:is([type="datetime-local"], [type="date"], [type="time"])::-webkit-calendar-picker-indicator { display: none !important; -webkit-appearance: none !important; }
|
||||
.main-column > .apk-modbar .apk-more-field > input::placeholder { color: rgba(255, 215, 0, 0.75) !important; opacity: 1 !important; }
|
||||
.main-column > .apk-modbar .apk-more-fields > input::placeholder { color: rgba(255, 215, 0, 0.75) !important; opacity: 1 !important; }
|
||||
.main-column > .apk-modbar .apk-more-toggle:checked ~ .filter-box__controls .apk-more-btn, .main-column > .apk-modbar .filter-box:has(.apk-more-toggle:checked) .apk-more-btn { border-color: #FFD700 !important; }
|
||||
.main-column > .apk-modbar .apk-more-fields > * { flex: 1 1 100% !important; min-width: 0 !important; width: auto !important; max-width: none !important; height: 30px !important; margin: 0 !important; font-size: 14px !important; }
|
||||
body .main-column > .apk-modbar > .filter-box input[type="hidden"] { display: none !important; margin: 0 !important; }
|
||||
.main-column > .apk-modbar > .filter-box :is(input.filter-box__input, select, .filter-box__controls, .apk-more-fields > *):not(#apk-z) { margin: 0 !important; }
|
||||
.main-column > .apk-modbar > .apk-xrow { display: flex !important; flex-wrap: nowrap !important; overflow-x: auto !important; scrollbar-width: none; overscroll-behavior: none !important; align-items: center !important; gap: 4px !important; width: auto !important; max-width: none !important; margin: 0 calc((100% - 100vw) / 2) !important; padding: 0 calc((100vw - 100%) / 2) !important; border: 0 !important; background: transparent !important; }
|
||||
.main-column > .apk-modbar > .apk-xrow::-webkit-scrollbar { display: none; }
|
||||
.main-column > .apk-modbar > .apk-xrow > * { flex: 0 0 auto !important; margin: 0 !important; }
|
||||
.main-column > .apk-modbar > .apk-xrow.inbox-exposition > .inbox-filters-label { display: none !important; }
|
||||
.main-column > .apk-modbar > .apk-xrow .inbox-bulk-delete { display: none !important; }
|
||||
.main-column > .apk-banners { display: flex !important; flex-direction: column !important; gap: 6px !important; margin: 0 0 18px !important; }
|
||||
.main-column > .apk-banners:has(+ .apk-stats) { margin-bottom: 8px !important; }
|
||||
.main-column > .apk-banners > * { margin: 0 !important; }
|
||||
.main-column > .apk-banners .welcome-banner-start { background: #FFA300 !important; border-color: #FFA300 !important; color: #111 !important; text-transform: uppercase !important; white-space: nowrap !important; width: auto !important; height: auto !important; min-height: 0 !important; margin: 0 !important; padding: 5px 14px !important; font-size: 13px !important; font-weight: 700 !important; line-height: 1.2 !important; border-radius: 14px !important; }
|
||||
.main-column > .apk-modbar > .apk-modbar-foot > .module-stats-line.apk-stats { flex: 1 1 auto !important; min-width: 0 !important; margin: 0 !important; padding: 0 !important; display: flex !important; align-items: center !important; flex-wrap: wrap !important; gap: 4px 8px !important; font-size: .72em !important; white-space: nowrap !important; text-transform: uppercase !important; background: transparent !important; border: 0 !important; }
|
||||
.main-column .activity-toolbar-row:not(:has(*)) { display: none !important; }
|
||||
.main-column .inbox-exposition { display: flex !important; flex-wrap: nowrap !important; overflow-x: auto !important; scrollbar-width: none; align-items: center !important; gap: 4px !important; margin: 0 0 10px !important; padding: 0 !important; border: 0 !important; background: transparent !important; }
|
||||
.main-column .inbox-exposition > .inbox-filters-label { flex: 0 0 auto !important; margin: 0 !important; font-size: 11.25px !important; }
|
||||
.main-column .inbox-exposition > form { flex: 0 0 auto !important; }
|
||||
.main-column .inbox-exposition::-webkit-scrollbar { display: none; }
|
||||
.main-column .inbox-exposition > form { display: inline-flex !important; margin: 0 !important; padding: 0 !important; width: auto !important; }
|
||||
.main-column .inbox-exposition .btn { flex: 0 0 auto !important; width: auto !important; height: auto !important; min-height: 0 !important; margin: 0 !important; padding: 2px 7px !important; font-size: 11.25px !important; font-weight: 400 !important; line-height: 16.875px !important; border-radius: 12px !important; border: 1px solid #555 !important; background: transparent !important; color: #FFD700 !important; white-space: nowrap !important; text-transform: uppercase !important; }
|
||||
.main-column .inbox-exposition .btn.delete-btn { color: #e74c3c !important; border-color: #e74c3c !important; }
|
||||
.main-column .tribe-side:has(> h2.tribe-members-count):not(#apk-z) > .tribe-side-description { order: 1 !important; }
|
||||
.main-column .tribe-side:has(> h2.tribe-members-count):not(#apk-z) > :is(.tribe-side-actions:not(.tribe-side-actions-bottom), .tribe-open-invite-panel) { order: 3 !important; }
|
||||
.main-column .tribe-side:has(> h2.tribe-members-count):not(#apk-z) > .tribe-side-tags { order: 2 !important; }
|
||||
.main-column .tribe-side:has(> h2.tribe-members-count):not(#apk-z) > .tribe-members-count { order: 4 !important; }
|
||||
.main-column .tribe-side:has(> h2.tribe-members-count):not(#apk-z) > .tribe-side-actions-bottom { order: 5 !important; }
|
||||
.main-column .tribe-side:has(> h2.tribe-members-count):not(#apk-z) > .tribe-side-subtribes { order: 6 !important; }
|
||||
.main-column .tribe-details:has(> .tribe-side):not(#apk-z) { background: transparent !important; padding: 0 !important; border: 0 !important; box-shadow: none !important; }
|
||||
.main-column .tribe-details:has(> .tribe-side):not(#apk-z) > .tribe-side { background: #1e1e1e !important; border-radius: 14px !important; padding: 12px !important; margin: 0 0 12px !important; }
|
||||
|
|
|
|||
|
|
@ -357,6 +357,8 @@ a.user-link:focus, button.user-link:focus {
|
|||
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #CE8CE9 !important; color: #2A0E42 !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; }
|
||||
.card-menu-items { background: #4B1A72 !important; border-color: #5A1A85 !important; }
|
||||
.card-byline a.user-link { 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; }
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@ module.exports = {
|
|||
wfHint_mobile: "The basics for a phone",
|
||||
workflow_acampada: "Camp",
|
||||
workflow_activista: "Activist",
|
||||
wfHint_activista: "Direct action: call out, coordinate, phone and alert",
|
||||
wfHint_activista: "For a collective: everything in Camp plus campaigns, alerts, polls, calendar and tasks",
|
||||
workflow_minimal: "Minimal",
|
||||
wfHint_minimal: "Direct action: call out, coordinate, phone and alert",
|
||||
wfNameMobile_activists: "Movement",
|
||||
wfModuleCount: "{n} modules",
|
||||
activityFilter: "Filter",
|
||||
|
|
@ -222,7 +224,9 @@ module.exports = {
|
|||
wfHint_mobile: "Lo básico para el móvil",
|
||||
workflow_acampada: "Acampada",
|
||||
workflow_activista: "Activista",
|
||||
wfHint_activista: "Acción directa: convocar, coordinarse, llamar y avisar",
|
||||
wfHint_activista: "Para un colectivo: lo de Acampada más campañas, alertas, encuestas, calendario y tareas",
|
||||
workflow_minimal: "Mínimo",
|
||||
wfHint_minimal: "Acción directa: convocar, coordinarse, llamar y avisar",
|
||||
wfNameMobile_activists: "Movimiento",
|
||||
wfModuleCount: "{n} módulos",
|
||||
activityFilter: "Filtrar",
|
||||
|
|
@ -369,7 +373,9 @@ module.exports = {
|
|||
wfHint_mobile: "L'essentiel pour le téléphone",
|
||||
workflow_acampada: "Campement",
|
||||
workflow_activista: "Activiste",
|
||||
wfHint_activista: "Action directe : appeler, se coordonner, téléphoner et alerter",
|
||||
wfHint_activista: "Pour un collectif : tout Campement plus campagnes, alertes, sondages, calendrier et tâches",
|
||||
workflow_minimal: "Minimal",
|
||||
wfHint_minimal: "Action directe : appeler, se coordonner, téléphoner et alerter",
|
||||
wfNameMobile_activists: "Mouvement",
|
||||
wfModuleCount: "{n} modules",
|
||||
activityFilter: "Filtrer",
|
||||
|
|
@ -510,7 +516,9 @@ module.exports = {
|
|||
wfHint_mobile: "Mugikorrerako oinarrizkoa",
|
||||
workflow_acampada: "Kanpaldia",
|
||||
workflow_activista: "Aktibista",
|
||||
wfHint_activista: "Ekintza zuzena: deitu, koordinatu, telefonoz hitz egin eta abisatu",
|
||||
wfHint_activista: "Kolektibo baterako: Kanpaldiko guztia gehi kanpainak, alertak, inkestak, egutegia eta zereginak",
|
||||
workflow_minimal: "Gutxienekoa",
|
||||
wfHint_minimal: "Ekintza zuzena: deitu, koordinatu, telefonoz hitz egin eta abisatu",
|
||||
wfNameMobile_activists: "Mugimendua",
|
||||
wfModuleCount: "{n} modulu",
|
||||
activityFilter: "Iragazi",
|
||||
|
|
@ -651,7 +659,9 @@ module.exports = {
|
|||
wfHint_mobile: "Das Wichtigste fürs Handy",
|
||||
workflow_acampada: "Camp",
|
||||
workflow_activista: "Aktivist*in",
|
||||
wfHint_activista: "Direkte Aktion: aufrufen, koordinieren, telefonieren und warnen",
|
||||
wfHint_activista: "Für ein Kollektiv: alles aus Camp plus Kampagnen, Warnungen, Umfragen, Kalender und Aufgaben",
|
||||
workflow_minimal: "Minimal",
|
||||
wfHint_minimal: "Direkte Aktion: aufrufen, koordinieren, telefonieren und warnen",
|
||||
wfNameMobile_activists: "Bewegung",
|
||||
wfModuleCount: "{n} Module",
|
||||
activityFilter: "Filtern",
|
||||
|
|
@ -792,7 +802,9 @@ module.exports = {
|
|||
wfHint_mobile: "L'essenziale per il telefono",
|
||||
workflow_acampada: "Accampata",
|
||||
workflow_activista: "Attivista",
|
||||
wfHint_activista: "Azione diretta: convocare, coordinarsi, chiamare e avvisare",
|
||||
wfHint_activista: "Per un collettivo: tutto di Accampamento più campagne, allerte, sondaggi, calendario e compiti",
|
||||
workflow_minimal: "Minimo",
|
||||
wfHint_minimal: "Azione diretta: convocare, coordinarsi, chiamare e avvisare",
|
||||
wfNameMobile_activists: "Movimento",
|
||||
wfModuleCount: "{n} moduli",
|
||||
activityFilter: "Filtra",
|
||||
|
|
@ -933,7 +945,9 @@ module.exports = {
|
|||
wfHint_mobile: "O básico para o telemóvel",
|
||||
workflow_acampada: "Acampada",
|
||||
workflow_activista: "Ativista",
|
||||
wfHint_activista: "Ação direta: convocar, coordenar, ligar e avisar",
|
||||
wfHint_activista: "Para um coletivo: tudo do Acampamento mais campanhas, alertas, sondagens, calendário e tarefas",
|
||||
workflow_minimal: "Mínimo",
|
||||
wfHint_minimal: "Ação direta: convocar, coordenar, ligar e avisar",
|
||||
wfNameMobile_activists: "Movimento",
|
||||
wfModuleCount: "{n} módulos",
|
||||
activityFilter: "Filtrar",
|
||||
|
|
@ -1074,7 +1088,9 @@ module.exports = {
|
|||
wfHint_mobile: "手机基本功能",
|
||||
workflow_acampada: "营地",
|
||||
workflow_activista: "活动者",
|
||||
wfHint_activista: "直接行动:召集、协调、通话和预警",
|
||||
wfHint_activista: "适合团体:营地的全部功能,加上运动、预警、投票、日历和任务",
|
||||
workflow_minimal: "极简",
|
||||
wfHint_minimal: "直接行动:召集、协调、通话和预警",
|
||||
wfNameMobile_activists: "运动",
|
||||
wfModuleCount: "{n} 个模块",
|
||||
activityFilter: "筛选",
|
||||
|
|
@ -1215,7 +1231,9 @@ module.exports = {
|
|||
wfHint_mobile: "الأساسيات للهاتف",
|
||||
workflow_acampada: "مخيم",
|
||||
workflow_activista: "ناشط",
|
||||
wfHint_activista: "عمل مباشر: دعوة وتنسيق واتصال وتنبيه",
|
||||
wfHint_activista: "لمجموعة: كل ما في المخيم مع الحملات والتنبيهات والاستطلاعات والتقويم والمهام",
|
||||
workflow_minimal: "الأدنى",
|
||||
wfHint_minimal: "عمل مباشر: دعوة وتنسيق واتصال وتنبيه",
|
||||
wfNameMobile_activists: "الحركة",
|
||||
wfModuleCount: "{n} وحدات",
|
||||
activityFilter: "تصفية",
|
||||
|
|
@ -1356,7 +1374,9 @@ module.exports = {
|
|||
wfHint_mobile: "फ़ोन के लिए बुनियादी",
|
||||
workflow_acampada: "शिविर",
|
||||
workflow_activista: "कार्यकर्ता",
|
||||
wfHint_activista: "सीधी कार्रवाई: बुलाना, तालमेल, फ़ोन और चेतावनी",
|
||||
wfHint_activista: "एक समूह के लिए: शिविर का सब कुछ, साथ में अभियान, चेतावनी, सर्वे, कैलेंडर और काम",
|
||||
workflow_minimal: "न्यूनतम",
|
||||
wfHint_minimal: "सीधी कार्रवाई: बुलाना, तालमेल, फ़ोन और चेतावनी",
|
||||
wfNameMobile_activists: "आंदोलन",
|
||||
wfModuleCount: "{n} मॉड्यूल",
|
||||
activityFilter: "फ़िल्टर",
|
||||
|
|
@ -1497,7 +1517,9 @@ module.exports = {
|
|||
wfHint_mobile: "Основное для телефона",
|
||||
workflow_acampada: "Лагерь",
|
||||
workflow_activista: "Активист",
|
||||
wfHint_activista: "Прямое действие: созвать, координировать, звонить и предупреждать",
|
||||
wfHint_activista: "Для коллектива: всё из Лагеря плюс кампании, тревоги, опросы, календарь и задачи",
|
||||
workflow_minimal: "Минимум",
|
||||
wfHint_minimal: "Прямое действие: созвать, координировать, звонить и предупреждать",
|
||||
wfNameMobile_activists: "Движение",
|
||||
wfModuleCount: "{n} модулей",
|
||||
activityFilter: "Фильтр",
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ module.exports = {
|
|||
graphosBackToMine: "← شبكتي",
|
||||
graphosShowing: "عرض",
|
||||
graphosYou: "أنت",
|
||||
graphosTotalNodes: "إجمالي العقد",
|
||||
graphosTotalNodes: "الإجمالي",
|
||||
onlinePlural: "متصلون",
|
||||
mentions: "الإشارات",
|
||||
mentionsDescription: [
|
||||
strong("كل رسالة تذكرك بـ @"),
|
||||
|
|
@ -420,6 +421,7 @@ module.exports = {
|
|||
menuMedia: "المكتبة",
|
||||
menuTools: "الأدوات",
|
||||
comment: "تعليق",
|
||||
typeComment: "تعليق",
|
||||
subtopic: "موضوع فرعي",
|
||||
json: "JSON",
|
||||
unfollow: "إلغاء الدعم",
|
||||
|
|
@ -449,9 +451,6 @@ module.exports = {
|
|||
publishCustomDescription: [
|
||||
"تذكّر: بسبب تقنية البلوكتشين، بمجرد نشر المنشور لا يمكن تعديله أو حذفه.",
|
||||
],
|
||||
commentWarning: [
|
||||
"تذكّر: بسبب تقنية البلوكتشين، بمجرد نشر المنشور لا يمكن تعديله أو حذفه.",
|
||||
],
|
||||
commentLabel: ({ publicOrPrivate, markdownUrl }) => [
|
||||
],
|
||||
publishLabel: ({ markdownUrl, linkTarget }) => [
|
||||
|
|
@ -730,10 +729,6 @@ module.exports = {
|
|||
votesQuestionLabel:"السؤال",
|
||||
voteStatusOpen:"مفتوح",
|
||||
voteStatusClosed:"مغلق",
|
||||
commentDescription: ({ parentUrl }) => [
|
||||
" علّق على ",
|
||||
a({ href: parentUrl }, " المحادثة"),
|
||||
],
|
||||
commentTitle: ({ authorName }) => [`تعليق على منشور @${authorName}`],
|
||||
subtopicDescription: ({ parentUrl }) => [
|
||||
" أنشأ موضوعًا فرعيًا من ",
|
||||
|
|
@ -868,6 +863,8 @@ module.exports = {
|
|||
bankRulesEligibility5: "يدفع الـ PUB في دورته التالية وينشر تحويل دخل أساسي يمكنك تأكيده.",
|
||||
bankRulesEligibility6: "إذا غاب عنوانك أو كانت التغذية حديثة جداً أو رفضت الشهر أو لم يكن لدى الـ PUB أموال، تُتخطى المطالبة وتُسجَّل على الـ PUB.",
|
||||
copyLinkTitle: "نسخ الرابط",
|
||||
cardMenuTitle: "إجراءات أخرى",
|
||||
shareContent: "مشاركة",
|
||||
linkBoxOasis: "Oasis",
|
||||
linkBoxClearnet: "Clearnet",
|
||||
bankRefuseUBI: "رفض الدخل الأساسي",
|
||||
|
|
@ -901,7 +898,7 @@ module.exports = {
|
|||
walletNotConfigured: "محفظة ECOin الخاصة بك غير مُهيأة بعد.",
|
||||
walletSettingsDescription: "دمج Oasis مع محفظة ECOin الخاصة بك.",
|
||||
walletSettingsMobileDescription: "اربط Oasis بتطبيق ECOin Wallet على هذا الجهاز.",
|
||||
walletDownloadEcoin: "نزّل ECOin!",
|
||||
walletDownloadEcoin: "نزّل ECOin",
|
||||
walletRpcUrl: "عنوان RPC",
|
||||
walletStatusMessages: {
|
||||
invalid_amount: "مبلغ غير صالح",
|
||||
|
|
@ -954,6 +951,7 @@ module.exports = {
|
|||
bookmarkUpdateButton: "تحديث",
|
||||
bookmarkAddFavoriteButton: "إضافة للمفضلة",
|
||||
bookmarkRemoveFavoriteButton: "إزالة من المفضلة",
|
||||
bookmarkTitleLabel: "العنوان",
|
||||
bookmarkUrlLabel: "الرابط",
|
||||
bookmarkUrlPlaceholder: "https://solarnethub.com",
|
||||
bookmarkDescriptionLabel: "الوصف",
|
||||
|
|
@ -1098,9 +1096,9 @@ module.exports = {
|
|||
allCVs: "السير الذاتية",
|
||||
discoverPeople: "اكتشف السكان في شبكتك.",
|
||||
searchInhabitantsPlaceholder: "ابحث في السكان...",
|
||||
filterLocation: "تصفية السكان حسب الموقع …",
|
||||
filterLanguage: "تصفية السكان حسب اللغة …",
|
||||
filterSkills: "تصفية السكان حسب المهارات …",
|
||||
filterLocation: "ابحث حسب الموقع…",
|
||||
filterLanguage: "ابحث حسب اللغة…",
|
||||
filterSkills: "ابحث حسب المهارات…",
|
||||
cvSkillsCount: "المهارات",
|
||||
applyFilters: "تطبيق الفلاتر",
|
||||
locationLabel: "الموقع",
|
||||
|
|
@ -1116,6 +1114,7 @@ module.exports = {
|
|||
topactivitySectionTitle: "الأكثر نشاطًا",
|
||||
"TOP INACTIVITYButton": "الأقل نشاطاً",
|
||||
topinactivitySectionTitle: "السكان الأقل نشاطاً",
|
||||
contactsSectionTitle: "جهات الاتصال",
|
||||
blockedSectionTitle: "المحظورون",
|
||||
gallerySectionTitle: "المعرض",
|
||||
topSectionTitle: "الأعلى",
|
||||
|
|
@ -1569,9 +1568,7 @@ module.exports = {
|
|||
voteUnknownDate: "تاريخ غير معروف",
|
||||
voteDetailSectionTitle: 'تفاصيل التصويت',
|
||||
voteCommentsLabel: 'التعليقات',
|
||||
voteCommentsForumButton: 'فتح النقاش',
|
||||
voteCommentsSectionTitle: 'فتح النقاش',
|
||||
voteNoCommentsYet: 'لا توجد تعليقات بعد. كن أول من يرد.',
|
||||
voteNoCommentsYet: 'لا توجد تعليقات بعد.',
|
||||
commentsGoToLast: "اذهب إلى آخر تعليق",
|
||||
voteNewCommentPlaceholder: 'اكتب تعليقك هنا…',
|
||||
voteNewCommentButton: 'نشر التعليق',
|
||||
|
|
@ -1698,7 +1695,6 @@ module.exports = {
|
|||
pollVoteButton: "صوّت",
|
||||
pollChangeVote: "تغيير الصوت",
|
||||
pollVoters: "المصوّتون",
|
||||
pollComments: "التعليقات",
|
||||
pollStatusLabel: "الحالة",
|
||||
pollStatusOpen: "مفتوح",
|
||||
pollStatusClosed: "مغلق",
|
||||
|
|
@ -1720,9 +1716,22 @@ module.exports = {
|
|||
blogAllowComments: "السماح بالتعليقات",
|
||||
blogCommentsClosed: "أغلق الكاتب التعليقات على هذه المدونة.",
|
||||
blogNoItems: "لم يتم العثور على مدونات.",
|
||||
viewBlog: "عرض المدونة",
|
||||
forumTitle: "المنتديات",
|
||||
forumCategoryLabel: "الفئة",
|
||||
forumCatGENERAL: "عام",
|
||||
forumCatPOLITICS: "سياسة",
|
||||
forumCatTECH: "تقنية",
|
||||
forumCatSCIENCE: "علوم",
|
||||
forumCatMUSIC: "موسيقى",
|
||||
forumCatART: "فن",
|
||||
forumCatGAMING: "ألعاب",
|
||||
forumCatBOOKS: "كتب",
|
||||
forumCatFILMS: "أفلام",
|
||||
forumCatPHILOSOPHY: "فلسفة",
|
||||
forumCatSOCIETY: "مجتمع",
|
||||
forumCatPRIVACY: "خصوصية",
|
||||
forumCatCYBERWARFARE: "حرب سيبرانية",
|
||||
forumCatSURVIVALISM: "البقاء",
|
||||
forumTitleLabel: "العنوان",
|
||||
forumTitlePlaceholder: "عنوان المنتدى...",
|
||||
forumCreateButton: "إنشاء منتدى",
|
||||
|
|
@ -1991,43 +2000,6 @@ module.exports = {
|
|||
reportsUnderReviewSectionTitle: "التقارير قيد المراجعة",
|
||||
reportsResolvedSectionTitle: "التقارير المحلولة",
|
||||
reportsInvalidSectionTitle: "التقارير غير الصالحة",
|
||||
reportsBugTemplateTitle: 'تفاصيل إعادة الإنتاج (الأخطاء)',
|
||||
reportsFeatureTemplateTitle: 'التفاصيل (الميزات)',
|
||||
reportsAbuseTemplateTitle: 'التفاصيل (الإساءة)',
|
||||
reportsContentTemplateTitle: 'التفاصيل (مشاكل المحتوى)',
|
||||
reportsStepsToReproduceLabel: 'خطوات إعادة الإنتاج',
|
||||
reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...',
|
||||
reportsExpectedBehaviorLabel: 'النتيجة المتوقعة',
|
||||
reportsExpectedBehaviorPlaceholder: 'ماذا يجب أن يحدث؟',
|
||||
reportsActualBehaviorLabel: 'النتيجة الفعلية',
|
||||
reportsActualBehaviorPlaceholder: 'ماذا يحدث فعلاً؟',
|
||||
reportsEnvironmentLabel: 'البيئة',
|
||||
reportsEnvironmentPlaceholder: 'الإصدار، الجهاز، نظام التشغيل، المتصفح، الإعدادات، السجلات، إلخ.',
|
||||
reportsReproduceRateLabel: 'معدل إعادة الإنتاج',
|
||||
reportsReproduceRateAlways: 'دائمًا',
|
||||
reportsReproduceRateOften: 'غالبًا',
|
||||
reportsReproduceRateSometimes: 'أحيانًا',
|
||||
reportsReproduceRateRarely: 'نادرًا',
|
||||
reportsReproduceRateUnable: 'غير قابل لإعادة الإنتاج',
|
||||
reportsReproduceRateUnknown: 'غير محدد',
|
||||
reportsProblemStatementLabel: 'المشكلة / الحاجة',
|
||||
reportsProblemStatementPlaceholder: 'ما المشكلة التي تحلها هذه الميزة؟',
|
||||
reportsUserStoryLabel: 'قصة الساكن',
|
||||
reportsUserStoryPlaceholder: 'بصفتي <ساكن>، أريد <إجراء>، لكي <فائدة>.',
|
||||
reportsAcceptanceCriteriaLabel: 'معايير القبول',
|
||||
reportsAcceptanceCriteriaPlaceholder: '- بفرض...\n- عندما...\n- عندها...',
|
||||
reportsWhatHappenedLabel: 'ماذا حدث؟',
|
||||
reportsWhatHappenedPlaceholder: 'صف الحادثة مع السياق والتواريخ التقريبية.',
|
||||
reportsReportedUserLabel: 'الساكن / الكيان المُبلَّغ عنه',
|
||||
reportsReportedUserPlaceholder: '@ساكن، تغذية، معرّف، رابط، إلخ.',
|
||||
reportsEvidenceLinksLabel: 'الأدلة / الروابط',
|
||||
reportsEvidenceLinksPlaceholder: 'الصق الروابط، معرّفات الرسائل، لقطات الشاشة (إن وُجدت)، إلخ.',
|
||||
reportsContentLocationLabel: 'مكان المحتوى',
|
||||
reportsContentLocationPlaceholder: 'رابط، معرّف، قناة، محادثة، مؤلف، إلخ.',
|
||||
reportsWhyInappropriateLabel: "لماذا هو غير مناسب",
|
||||
reportsWhyInappropriatePlaceholder: 'اشرح السبب والتأثير.',
|
||||
reportsRequestedActionLabel: 'الإجراء المطلوب',
|
||||
reportsRequestedActionPlaceholder: 'إزالة، إخفاء، وسم، تحذير، إلخ.',
|
||||
tribesTitle: "القبائل",
|
||||
tribeFilterAll: "الكل",
|
||||
tribeFilterRecent: "الأحدث",
|
||||
|
|
@ -2271,6 +2243,7 @@ module.exports = {
|
|||
voteWellResearched: "بحث جيد",
|
||||
voteNeedsSources: "يحتاج مصادر",
|
||||
voteWrong: "خاطئ",
|
||||
voteOutdated: "قديم",
|
||||
voteLowQuality: "جودة منخفضة",
|
||||
voteLove: "حب",
|
||||
voteClear: "واضح",
|
||||
|
|
@ -2515,7 +2488,6 @@ module.exports = {
|
|||
shopNoProducts: "لا توجد منتجات بعد.",
|
||||
shopOutOfStock: "نفد المخزون",
|
||||
shopBuy: "شراء",
|
||||
shopBuyEncryptedNote: "بيانات التوصيل الخاصة بك مشفّرة ولا يراها سوى صاحب المتجر.",
|
||||
shopBuyDeliveryAddress: "عنوان التوصيل",
|
||||
shopBuyContact: "جهة الاتصال",
|
||||
shopBuyContactPlaceholder: "البريد، الهاتف، إلخ.",
|
||||
|
|
@ -3032,6 +3004,16 @@ module.exports = {
|
|||
industryDescriptionLabel: "الوصف",
|
||||
industryDescriptionPlaceholder: "ماذا ينتج هذا المصنع؟",
|
||||
industrySector: "القطاع",
|
||||
industrySector_software: "برمجيات",
|
||||
industrySector_hardware: "عتاد",
|
||||
industrySector_agriculture: "زراعة",
|
||||
industrySector_textile: "نسيج",
|
||||
industrySector_energy: "طاقة",
|
||||
industrySector_food: "غذاء",
|
||||
industrySector_construction: "بناء",
|
||||
industrySector_media: "إعلام",
|
||||
industrySector_services: "خدمات",
|
||||
industrySector_other: "أخرى",
|
||||
industryMembershipPolicy: "سياسة العضوية",
|
||||
industryQuorum: "النصاب",
|
||||
industryMajority: "الأغلبية",
|
||||
|
|
@ -3233,6 +3215,18 @@ module.exports = {
|
|||
modulesFavoritesDescription: "وحدة لإدارة المحتوى المفضل لديك.",
|
||||
fileTooLargeTitle: "الملف كبير جدًا",
|
||||
fileTooLargeMessage: "يتجاوز الملف الحجم الأقصى المسموح به (50 ميغابايت). يرجى اختيار ملف أصغر.",
|
||||
fileWrongType: "هذا الملف ليس من النوع المناسب لهذا القسم.",
|
||||
mediaLockedAfterOpinions: "لديه آراء بالفعل: يمكنك تعديل الباقي، لكن لا يمكنك تغيير ما تم تقييمه.",
|
||||
pdfPrevPage: "الصفحة السابقة",
|
||||
pdfNextPage: "الصفحة التالية",
|
||||
pdfPage: "صفحة",
|
||||
pdfZoomIn: "تكبير",
|
||||
pdfZoomOut: "تصغير",
|
||||
pdfFullscreen: "ملء الشاشة",
|
||||
pdfClose: "إغلاق",
|
||||
pdfRotate: "تدوير",
|
||||
contentNotAvailableYet: "هذا المحتوى غير متاح بعد.",
|
||||
pdfRetry: "إعادة المحاولة",
|
||||
goBack: "رجوع",
|
||||
errorDismiss: "حسنًا",
|
||||
errorPageTitle: "حدث خطأ",
|
||||
|
|
@ -3275,9 +3269,7 @@ module.exports = {
|
|||
uploadMedia: "رفع وسائط (الحد الأقصى: 50 ميغابايت)",
|
||||
feedOpenDiscussion: "فتح النقاش",
|
||||
feedDetailTitle: "التغذية",
|
||||
feedPostComment: "نشر تعليق",
|
||||
courtsRespondentInvalid: "يجب أن يكون معرّف SSB صالحًا (@...ed25519)",
|
||||
noComments: "لا توجد تعليقات بعد",
|
||||
mapsLabel: "خرائط",
|
||||
mapTitle: "خرائط",
|
||||
mapDescription: "أدر الخرائط غير المتصلة وشاركها في شبكتك.",
|
||||
|
|
@ -3581,6 +3573,8 @@ module.exports = {
|
|||
podcastTagsPlaceholder: "أدخل الوسوم مفصولة بفواصل",
|
||||
podcastFilterAll: "الكل",
|
||||
podcastFilterMine: "قنواتي",
|
||||
podcastFilterFavorites: "المفضلة",
|
||||
podcastEpisodeNumberLabel: "رقم الحلقة",
|
||||
podcastFilterRecent: "الأحدث",
|
||||
podcastFilterTop: "الأفضل",
|
||||
podcastFilterViewers: "المستمعون",
|
||||
|
|
@ -3799,7 +3793,6 @@ module.exports = {
|
|||
wikiBackToPage: "العودة إلى الصفحة",
|
||||
wikiLinkedChip: "مرتبطة",
|
||||
wikiRestoredSummary: "إصدار مستعاد",
|
||||
wikiExportPdf: "إنشاء الصفحة",
|
||||
wikiPreview: "معاينة",
|
||||
tribeSectionWiki: "ويكي",
|
||||
tribeWikiCreate: "إنشاء ويكي",
|
||||
|
|
@ -3867,6 +3860,9 @@ module.exports = {
|
|||
roomRecStop: "إيقاف التسجيل",
|
||||
roomStreamFile: "بثّ",
|
||||
roomStreamStop: "إيقاف",
|
||||
roomScreen: "الشاشة",
|
||||
roomScreenSharing: "مشاركة الشاشة",
|
||||
roomScreenWarning: "سيرى كل من في الغرفة شاشتك بالكامل حتى تتوقف.",
|
||||
roomStreaming: "جارٍ البث",
|
||||
liveFullscreen: "ملء الشاشة",
|
||||
roomStreamPickTitle: "اختر فيديو",
|
||||
|
|
@ -3924,6 +3920,7 @@ module.exports = {
|
|||
inviteAlreadyMember: "أنت منضم بالفعل. يمكنك فتحه من وحدته.",
|
||||
actionFailed: "تعذّر تنفيذ ذلك. حاول مرة أخرى.",
|
||||
publishTooShort: "منشورك قصير جدًا.",
|
||||
publishEmpty: "اكتب شيئًا أو أرفق ملفًا.",
|
||||
walletSendFailed: "تعذّر إرسال الدفعة: {reason}",
|
||||
pixeliaCoordsError: "هذه الإحداثيات خارج الشبكة.",
|
||||
courtsErrTitle: "العنوان مطلوب.",
|
||||
|
|
@ -4435,6 +4432,9 @@ module.exports = {
|
|||
torrentTagsLabel: "الوسوم",
|
||||
torrentTagsPlaceholder: "أدخل الوسوم مفصولة بفواصل",
|
||||
torrentSizeLabel: "الحجم",
|
||||
torrentInfoHash: "بصمة المعلومات",
|
||||
torrentFilesLabel: "الملفات",
|
||||
torrentMagnet: "MAGNET",
|
||||
torrentFromContent: "إنشاء تورنت",
|
||||
torrentFromContentMissing: "الملف غير متاح بعد على هذه العقدة. حاولي لاحقًا.",
|
||||
torrentDownloads: "التنزيلات",
|
||||
|
|
@ -4520,9 +4520,6 @@ module.exports = {
|
|||
inhabitantsPendingAccept: "قبول",
|
||||
inhabitantsPendingReject: "رفض",
|
||||
tribeSectionGovernance: "الحوكمة",
|
||||
tribePadInviteRequired: "لا يمكنك الوصول. اطلب دعوة.",
|
||||
tribeChatInviteRequired: "لا يمكنك الوصول للدردشة. اطلب دعوة.",
|
||||
tribeGovernanceDesc: "الحوكمة الداخلية لهذه القبيلة.",
|
||||
tribeGovernanceNoGov: "لا توجد حكومة نشطة",
|
||||
tribeGovernanceNoGovDesc: "لم تنتخب هذه القبيلة حكومة بعد.",
|
||||
tribeGovCardTitle: "الحكومة الحالية",
|
||||
|
|
@ -4579,7 +4576,6 @@ module.exports = {
|
|||
logsFilterMonth: "الشهر الماضي",
|
||||
logsFilterWeek: "الأسبوع الماضي",
|
||||
logsFilterToday: "اليوم",
|
||||
logsComments: "التعليقات",
|
||||
logsAuthorEntries: "المدخلات",
|
||||
logsCreate: "إنشاء إدخال",
|
||||
logsExport: "تصدير السجل",
|
||||
|
|
@ -4735,6 +4731,8 @@ module.exports = {
|
|||
larpWillTestTitle: "اختبار WILL",
|
||||
settingsLanDesc: "الإعلان بشكل دوري عن هذا النظير لمثيلات Oasis الأخرى على نفس الشبكة المحلية.",
|
||||
settingsLanEnable: "تفعيل بث الشبكة المحلية",
|
||||
settingsConnectionsTitle: "الاتصالات",
|
||||
settingsConnectionsDesc: "عدد النظراء الذين تبقيهم هذه العقدة متصلين في آن واحد.",
|
||||
settingsLanTitle: "بث الشبكة المحلية",
|
||||
settingsReplicationTitle: "النسخ",
|
||||
settingsReplicationDesc: "اضبط عدد القفزات التي يتبعها نظيرك انطلاقًا من خلاصتك عند نسخ المحتوى.",
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ module.exports = {
|
|||
graphosBackToMine: "← La meva xarxa",
|
||||
graphosShowing: "Mostrant",
|
||||
graphosYou: "Tu",
|
||||
graphosTotalNodes: "Nodes totals",
|
||||
graphosTotalNodes: "Totals",
|
||||
onlinePlural: "Connectats",
|
||||
mentions: "Mencions",
|
||||
mentionsDescription: [
|
||||
strong("Tots els missatges que et @mencionen"),
|
||||
|
|
@ -412,6 +413,7 @@ module.exports = {
|
|||
menuMedia: "Biblioteca",
|
||||
menuTools: "Eines",
|
||||
comment: "Comentar",
|
||||
typeComment: "Comentari",
|
||||
subtopic: "Subtema",
|
||||
json: "JSON",
|
||||
unfollow: "Deixar de donar suport",
|
||||
|
|
@ -441,9 +443,6 @@ module.exports = {
|
|||
publishCustomDescription: [
|
||||
"RECORDA: A causa de la tecnologia blockchain, un cop has publicat alguna cosa, no es pot editar ni esborrar. Pensa-t'ho bé!",
|
||||
],
|
||||
commentWarning: [
|
||||
"RECORDA: A causa de la tecnologia blockchain, un cop has publicat alguna cosa, no es pot editar ni esborrar. Pensa-t'ho bé!",
|
||||
],
|
||||
commentLabel: ({ publicOrPrivate, markdownUrl }) => [
|
||||
],
|
||||
publishLabel: ({ markdownUrl, linkTarget }) => [
|
||||
|
|
@ -718,10 +717,6 @@ module.exports = {
|
|||
votesQuestionLabel:"Pregunta",
|
||||
voteStatusOpen:"OBERT",
|
||||
voteStatusClosed:"TANCAT",
|
||||
commentDescription: ({ parentUrl }) => [
|
||||
" ha comentat en ",
|
||||
a({ href: parentUrl }, " fil"),
|
||||
],
|
||||
commentTitle: ({ authorName }) => [`Ha comentat al post de @${authorName}`],
|
||||
subtopicDescription: ({ parentUrl }) => [
|
||||
" ha creat un subtema des de ",
|
||||
|
|
@ -856,6 +851,8 @@ module.exports = {
|
|||
bankRulesEligibility5: "El PUB paga en el seu següent cicle i publica un transfer RBU que pots confirmar.",
|
||||
bankRulesEligibility6: "Si falta la teva adreça, el feed és massa jove, has rebutjat el mes o el PUB no té fons, la reclamació se salta i queda registrada al PUB.",
|
||||
copyLinkTitle: "Copiar l'enllaç",
|
||||
cardMenuTitle: "Més accions",
|
||||
shareContent: "Compartir",
|
||||
linkBoxOasis: "Oasis",
|
||||
linkBoxClearnet: "Clearnet",
|
||||
bankRefuseUBI: "Rebutjar RBU",
|
||||
|
|
@ -889,7 +886,7 @@ module.exports = {
|
|||
walletNotConfigured: "La teva cartera d'ECOin encara no està configurada.",
|
||||
walletSettingsDescription: "Integrar Oasis amb el teu moneder d'ECOin.",
|
||||
walletSettingsMobileDescription: "Vincula Oasis amb l'app ECOin Wallet d'aquest dispositiu.",
|
||||
walletDownloadEcoin: "Baixar ECOin!",
|
||||
walletDownloadEcoin: "Baixar ECOin",
|
||||
walletRpcUrl: "URL del RPC",
|
||||
walletStatusMessages: {
|
||||
invalid_amount: "Quantitat no vàlida",
|
||||
|
|
@ -942,6 +939,7 @@ module.exports = {
|
|||
bookmarkUpdateButton: "Actualitzar",
|
||||
bookmarkAddFavoriteButton: "Afegir a preferits",
|
||||
bookmarkRemoveFavoriteButton: "Treure de preferits",
|
||||
bookmarkTitleLabel: "Títol",
|
||||
bookmarkUrlLabel: "Enllaç",
|
||||
bookmarkUrlPlaceholder: "https://solarnethub.com",
|
||||
bookmarkDescriptionLabel: "Descripció",
|
||||
|
|
@ -1086,9 +1084,9 @@ module.exports = {
|
|||
allCVs: "CV",
|
||||
discoverPeople: "Descobreix habitants a la teva xarxa.",
|
||||
searchInhabitantsPlaceholder: "Cercar a Habitants...",
|
||||
filterLocation: "FILTRAR habitants PER UBICACIÓ …",
|
||||
filterLanguage: "FILTRAR habitants PER IDIOMA …",
|
||||
filterSkills: "FILTRAR habitants PER HABILITATS …",
|
||||
filterLocation: "Cercar per ubicació…",
|
||||
filterLanguage: "Cercar per idioma…",
|
||||
filterSkills: "Cercar per habilitats…",
|
||||
cvSkillsCount: "Habilitats",
|
||||
applyFilters: "Aplicar els filtres",
|
||||
locationLabel: "Ubicació",
|
||||
|
|
@ -1104,6 +1102,7 @@ module.exports = {
|
|||
topactivitySectionTitle: "Més activitat",
|
||||
"TOP INACTIVITYButton": "MÉS INACTIUS",
|
||||
topinactivitySectionTitle: "Habitants menys actius",
|
||||
contactsSectionTitle: "Contactes",
|
||||
blockedSectionTitle: "Bloquejats",
|
||||
gallerySectionTitle: "GALERIA",
|
||||
topSectionTitle: "TOP",
|
||||
|
|
@ -1558,9 +1557,7 @@ module.exports = {
|
|||
voteUnknownDate: "Data desconeguda",
|
||||
voteDetailSectionTitle: "Detalls de la votació",
|
||||
voteCommentsLabel: "Comentaris",
|
||||
voteCommentsForumButton: "Obrir la conversa",
|
||||
voteCommentsSectionTitle: "Conversa oberta",
|
||||
voteNoCommentsYet: "Encara no hi ha comentaris. Sigues la primera persona a opinar.",
|
||||
voteNoCommentsYet: "Encara no hi ha comentaris.",
|
||||
commentsGoToLast: "Anar al darrer comentari",
|
||||
voteNewCommentPlaceholder: "Escriu aquí el teu comentari…",
|
||||
voteNewCommentButton: "Publicar el comentari",
|
||||
|
|
@ -1687,7 +1684,6 @@ module.exports = {
|
|||
pollVoteButton: "Votar",
|
||||
pollChangeVote: "Canviar el vot",
|
||||
pollVoters: "Votants",
|
||||
pollComments: "Comentaris",
|
||||
pollStatusLabel: "Estat",
|
||||
pollStatusOpen: "OBERTA",
|
||||
pollStatusClosed: "TANCADA",
|
||||
|
|
@ -1709,9 +1705,22 @@ module.exports = {
|
|||
blogAllowComments: "Permetre comentaris",
|
||||
blogCommentsClosed: "L'autor ha tancat els comentaris d'aquest blog.",
|
||||
blogNoItems: "No s'han trobat blogs.",
|
||||
viewBlog: "Veure el blog",
|
||||
forumTitle: "Fòrums",
|
||||
forumCategoryLabel: "Categoria",
|
||||
forumCatGENERAL: "GENERAL",
|
||||
forumCatPOLITICS: "POLÍTICA",
|
||||
forumCatTECH: "TECNOLOGIA",
|
||||
forumCatSCIENCE: "CIÈNCIA",
|
||||
forumCatMUSIC: "MÚSICA",
|
||||
forumCatART: "ART",
|
||||
forumCatGAMING: "JOCS",
|
||||
forumCatBOOKS: "LLIBRES",
|
||||
forumCatFILMS: "PEL·LÍCULES",
|
||||
forumCatPHILOSOPHY: "FILOSOFIA",
|
||||
forumCatSOCIETY: "SOCIETAT",
|
||||
forumCatPRIVACY: "PRIVADESA",
|
||||
forumCatCYBERWARFARE: "CIBERGUERRA",
|
||||
forumCatSURVIVALISM: "SUPERVIVÈNCIA",
|
||||
forumTitleLabel: "Títol",
|
||||
forumTitlePlaceholder: "Títol del fòrum...",
|
||||
forumCreateButton: "Crear un fòrum",
|
||||
|
|
@ -1770,8 +1779,6 @@ module.exports = {
|
|||
feedTitle: "Feed",
|
||||
feedDetailTitle: "Feed",
|
||||
feedOpenDiscussion: "Obrir el debat",
|
||||
feedPostComment: "Publicar el comentari",
|
||||
noComments: "Encara no hi ha comentaris",
|
||||
createFeedTitle: "Crear un feed",
|
||||
createFeedButton: "Enviar el feed!",
|
||||
feedPlaceholder: "Què està passant? (màxim 280 caràcters)",
|
||||
|
|
@ -1982,42 +1989,6 @@ module.exports = {
|
|||
reportsUnderReviewSectionTitle: "Informes en revisió",
|
||||
reportsResolvedSectionTitle: "Informes resolts",
|
||||
reportsInvalidSectionTitle: "Informes no vàlids",
|
||||
reportsBugTemplateTitle: "Dades per reproduir (errors)",
|
||||
reportsFeatureTemplateTitle: "Detalls (funcions)",
|
||||
reportsAbuseTemplateTitle: "Detalls (abús)",
|
||||
reportsContentTemplateTitle: "Detalls (problemes de contingut)",
|
||||
reportsStepsToReproduceLabel: "Passos per reproduir-ho",
|
||||
reportsStepsToReproducePlaceholder: "1) ...\n2) ...\n3) ...",
|
||||
reportsExpectedBehaviorLabel: "Resultat esperat",
|
||||
reportsExpectedBehaviorPlaceholder: "Què hauria de passar?",
|
||||
reportsActualBehaviorLabel: "Resultat real",
|
||||
reportsActualBehaviorPlaceholder: "Què passa realment?",
|
||||
reportsEnvironmentLabel: "Entorn",
|
||||
reportsEnvironmentPlaceholder: "Versió, dispositiu, SO, navegador, configuració, registres, etc.",
|
||||
reportsReproduceRateLabel: "Freqüència",
|
||||
reportsReproduceRateAlways: "Sempre",
|
||||
reportsReproduceRateOften: "Sovint",
|
||||
reportsReproduceRateSometimes: "De vegades",
|
||||
reportsReproduceRateRarely: "Rarament",
|
||||
reportsReproduceRateUnable: "No es pot reproduir",
|
||||
reportsReproduceRateUnknown: "No especificat",
|
||||
reportsProblemStatementLabel: "Problema / necessitat",
|
||||
reportsProblemStatementPlaceholder: "Quin problema resol aquesta funció?",
|
||||
reportsUserStoryLabel: "Història d'habitant",
|
||||
reportsUserStoryPlaceholder: "Com a <habitant>, vull <acció>, per <benefici>.",
|
||||
reportsAcceptanceCriteriaLabel: "Criteris d'acceptació",
|
||||
reportsAcceptanceCriteriaPlaceholder: "- Donat...\n- Quan...\n- Aleshores...",
|
||||
reportsWhatHappenedLabel: "Què ha passat?",
|
||||
reportsWhatHappenedPlaceholder: "Descriu l'incident amb context i dates aproximades.",
|
||||
reportsReportedUserLabel: "Habitant / entitat denunciada",
|
||||
reportsReportedUserPlaceholder: "@habitant, feed, ID, enllaç, etc.",
|
||||
reportsEvidenceLinksLabel: "Proves / enllaços",
|
||||
reportsEvidenceLinksPlaceholder: "Enganxa enllaços, IDs de missatges, captures (si escau), etc.",
|
||||
reportsContentLocationLabel: "On és el contingut",
|
||||
reportsContentLocationPlaceholder: "Enllaç, ID, canal, fil, autor, etc.",
|
||||
reportsWhyInappropriatePlaceholder: "Explica el motiu i l'impacte.",
|
||||
reportsRequestedActionLabel: "Acció sol·licitada",
|
||||
reportsRequestedActionPlaceholder: "Eliminar, amagar, marcar, advertir, etc.",
|
||||
tribesTitle: "Tribus",
|
||||
tribeFilterAll: "TOTES",
|
||||
tribeFilterRecent: "RECENTS",
|
||||
|
|
@ -2261,6 +2232,7 @@ module.exports = {
|
|||
voteWellResearched: "Ben documentat",
|
||||
voteNeedsSources: "Necessita fonts",
|
||||
voteWrong: "Incorrecte",
|
||||
voteOutdated: "Desactualitzat",
|
||||
voteLowQuality: "Baixa qualitat",
|
||||
voteLove: "Amor",
|
||||
voteClear: "Clar",
|
||||
|
|
@ -2505,7 +2477,6 @@ module.exports = {
|
|||
shopNoProducts: "Encara no hi ha productes.",
|
||||
shopOutOfStock: "Exhaurit",
|
||||
shopBuy: "Comprar",
|
||||
shopBuyEncryptedNote: "Les teves dades de lliurament s'envien xifrades; només el propietari de la botiga les pot veure.",
|
||||
shopBuyDeliveryAddress: "Adreça de lliurament",
|
||||
shopBuyContact: "Contacte",
|
||||
shopBuyContactPlaceholder: "correu, telèfon, etc.",
|
||||
|
|
@ -3022,6 +2993,16 @@ module.exports = {
|
|||
industryDescriptionLabel: "Descripció",
|
||||
industryDescriptionPlaceholder: "Què produeix aquesta fàbrica?",
|
||||
industrySector: "Sector",
|
||||
industrySector_software: "Programari",
|
||||
industrySector_hardware: "Maquinari",
|
||||
industrySector_agriculture: "Agricultura",
|
||||
industrySector_textile: "Tèxtil",
|
||||
industrySector_energy: "Energia",
|
||||
industrySector_food: "Alimentació",
|
||||
industrySector_construction: "Construcció",
|
||||
industrySector_media: "Mitjans",
|
||||
industrySector_services: "Serveis",
|
||||
industrySector_other: "Altres",
|
||||
industryMembershipPolicy: "Política de pertinença",
|
||||
industryQuorum: "Quòrum",
|
||||
industryMajority: "Majoria",
|
||||
|
|
@ -3223,6 +3204,18 @@ module.exports = {
|
|||
modulesFavoritesDescription: "Mòdul per gestionar el teu contingut preferit.",
|
||||
fileTooLargeTitle: "Fitxer massa gran",
|
||||
fileTooLargeMessage: "El fitxer supera la mida màxima permesa (75 MB). Si us plau, selecciona un fitxer més petit.",
|
||||
fileWrongType: "Aquest fitxer no és del tipus adequat per a aquesta secció.",
|
||||
mediaLockedAfterOpinions: "Ja té opinions: pots editar la resta, però no canviar el que s'ha valorat.",
|
||||
pdfPrevPage: "Pàgina anterior",
|
||||
pdfNextPage: "Pàgina següent",
|
||||
pdfPage: "Pàgina",
|
||||
pdfZoomIn: "Apropa",
|
||||
pdfZoomOut: "Allunya",
|
||||
pdfFullscreen: "Pantalla completa",
|
||||
pdfClose: "Tanca",
|
||||
pdfRotate: "Gira",
|
||||
contentNotAvailableYet: "Aquest contingut encara no està disponible.",
|
||||
pdfRetry: "Torna-ho a provar",
|
||||
goBack: "Tornar",
|
||||
errorDismiss: "D'acord",
|
||||
errorPageTitle: "S'ha produït un error",
|
||||
|
|
@ -3267,7 +3260,6 @@ module.exports = {
|
|||
invitesFollow: "Seguir",
|
||||
invitesUnfollowedInvites: "No federades",
|
||||
invitesNoUnfollowed: "No hi ha pubs no federats.",
|
||||
reportsWhyInappropriateLabel: "Per què és inadequat?",
|
||||
blockchainContentDeleted: "Aquest contingut s'ha eliminat",
|
||||
visitContent: "Visitar el contingut",
|
||||
favoriteAdd: "Fixar a preferits",
|
||||
|
|
@ -3579,6 +3571,8 @@ module.exports = {
|
|||
podcastTagsPlaceholder: "Introdueix etiquetes separades per comes",
|
||||
podcastFilterAll: "TOTS",
|
||||
podcastFilterMine: "MEUS",
|
||||
podcastFilterFavorites: "PREFERITS",
|
||||
podcastEpisodeNumberLabel: "Número d'episodi",
|
||||
podcastFilterRecent: "RECENTS",
|
||||
podcastFilterTop: "TOP",
|
||||
podcastFilterViewers: "OIENTS",
|
||||
|
|
@ -3797,7 +3791,6 @@ module.exports = {
|
|||
wikiBackToPage: "Tornar a la pàgina",
|
||||
wikiLinkedChip: "Enllaçada",
|
||||
wikiRestoredSummary: "Versió restaurada",
|
||||
wikiExportPdf: "Generar la pàgina",
|
||||
wikiPreview: "Previsualització",
|
||||
tribeSectionWiki: "WIKI",
|
||||
tribeWikiCreate: "Crear una wiki",
|
||||
|
|
@ -3865,6 +3858,9 @@ module.exports = {
|
|||
roomRecStop: "Atura la gravació",
|
||||
roomStreamFile: "Emet",
|
||||
roomStreamStop: "Atura",
|
||||
roomScreen: "Pantalla",
|
||||
roomScreenSharing: "Compartint pantalla",
|
||||
roomScreenWarning: "Tothom a la sala veurà la teva pantalla sencera fins que aturis.",
|
||||
roomStreaming: "Emetent",
|
||||
liveFullscreen: "Pantalla completa",
|
||||
roomStreamPickTitle: "Tria un vídeo",
|
||||
|
|
@ -3922,6 +3918,7 @@ module.exports = {
|
|||
inviteAlreadyMember: "Ja en formes part. Pots obrir-ho des del seu mòdul.",
|
||||
actionFailed: "No s'ha pogut fer. Torna-ho a provar.",
|
||||
publishTooShort: "La teva publicació és massa curta.",
|
||||
publishEmpty: "Escriu alguna cosa o adjunta un fitxer.",
|
||||
walletSendFailed: "No s'ha pogut enviar el pagament: {reason}",
|
||||
pixeliaCoordsError: "Aquestes coordenades són fora de la quadrícula.",
|
||||
courtsErrTitle: "Cal un títol.",
|
||||
|
|
@ -4434,6 +4431,9 @@ module.exports = {
|
|||
torrentTagsLabel: "Etiquetes",
|
||||
torrentTagsPlaceholder: "Introdueix etiquetes separades per comes",
|
||||
torrentSizeLabel: "Mida",
|
||||
torrentInfoHash: "Infohash",
|
||||
torrentFilesLabel: "Fitxers",
|
||||
torrentMagnet: "MAGNET",
|
||||
torrentFromContent: "Generar torrent",
|
||||
torrentFromContentMissing: "El fitxer encara no està disponible en aquest node. Torna-ho a provar d'aquí a una estona.",
|
||||
torrentDownloads: "Baixades",
|
||||
|
|
@ -4519,9 +4519,6 @@ module.exports = {
|
|||
inhabitantsPendingAccept: "Acceptar",
|
||||
inhabitantsPendingReject: "Rebutjar",
|
||||
tribeSectionGovernance: "GOVERN",
|
||||
tribePadInviteRequired: "No tens accés al pad. Demana una invitació per accedir al contingut.",
|
||||
tribeChatInviteRequired: "No tens accés al xat. Demana una invitació per accedir al contingut.",
|
||||
tribeGovernanceDesc: "Govern intern d'aquesta tribu.",
|
||||
tribeGovernanceNoGov: "Sense govern actiu",
|
||||
tribeGovernanceNoGovDesc: "Aquesta tribu encara no té govern. Proposa candidatures per iniciar el procés.",
|
||||
tribeGovCardTitle: "Govern actual",
|
||||
|
|
@ -4578,7 +4575,6 @@ module.exports = {
|
|||
logsFilterMonth: "DARRER MES",
|
||||
logsFilterWeek: "DARRERA SETMANA",
|
||||
logsFilterToday: "AVUI",
|
||||
logsComments: "Comentaris",
|
||||
logsAuthorEntries: "Entrades",
|
||||
logsCreate: "Crear una entrada",
|
||||
logsExport: "Exportar la bitàcola",
|
||||
|
|
@ -4657,6 +4653,8 @@ module.exports = {
|
|||
larpWillTestTitle: "Test WILL",
|
||||
settingsLanDesc: "Anuncia periòdicament aquest node a altres instàncies d'Oasis de la mateixa xarxa local.",
|
||||
settingsLanEnable: "Activar la difusió LAN",
|
||||
settingsConnectionsTitle: "Connexions",
|
||||
settingsConnectionsDesc: "Quants peers manté connectats aquest node alhora.",
|
||||
settingsLanTitle: "Difusió LAN",
|
||||
settingsReplicationTitle: "Replicació",
|
||||
settingsReplicationDesc: "Configura el nombre de salts que el teu node segueix des del teu propi feed en replicar contingut.",
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ module.exports = {
|
|||
graphosBackToMine: "← Mein Netzwerk",
|
||||
graphosShowing: "Zeige",
|
||||
graphosYou: "Du",
|
||||
graphosTotalNodes: "Knoten insgesamt",
|
||||
graphosTotalNodes: "Gesamt",
|
||||
onlinePlural: "Online",
|
||||
mentions: "Erwähnungen",
|
||||
mentionsDescription: [
|
||||
strong("Jede Nachricht, die dich @erwähnt"),
|
||||
|
|
@ -418,6 +419,7 @@ module.exports = {
|
|||
menuMedia: "Bibliothek",
|
||||
menuTools: "Werkzeuge",
|
||||
comment: "Kommentar",
|
||||
typeComment: "Kommentar",
|
||||
subtopic: "Unterthema",
|
||||
json: "JSON",
|
||||
unfollow: "Nicht mehr unterstützen",
|
||||
|
|
@ -447,9 +449,6 @@ module.exports = {
|
|||
publishCustomDescription: [
|
||||
"ACHTUNG: Aufgrund der Blockchain-Technologie kann ein veröffentlichter Beitrag nicht bearbeitet oder gelöscht werden.",
|
||||
],
|
||||
commentWarning: [
|
||||
"ACHTUNG: Aufgrund der Blockchain-Technologie kann ein veröffentlichter Beitrag nicht bearbeitet oder gelöscht werden.",
|
||||
],
|
||||
commentLabel: ({ publicOrPrivate, markdownUrl }) => [
|
||||
],
|
||||
publishLabel: ({ markdownUrl, linkTarget }) => [
|
||||
|
|
@ -725,10 +724,6 @@ module.exports = {
|
|||
votesQuestionLabel: "Frage",
|
||||
voteStatusOpen: "OFFEN",
|
||||
voteStatusClosed: "GESCHLOSSEN",
|
||||
commentDescription: ({ parentUrl }) => [
|
||||
" hat kommentiert in ",
|
||||
a({ href: parentUrl }, " Thread"),
|
||||
],
|
||||
commentTitle: ({ authorName }) => [`Kommentar zu @${authorName}s Beitrag`],
|
||||
subtopicDescription: ({ parentUrl }) => [
|
||||
" hat ein Unterthema erstellt aus ",
|
||||
|
|
@ -863,6 +858,8 @@ module.exports = {
|
|||
bankRulesEligibility5: "Das PUB zahlt im nächsten Zyklus und veröffentlicht einen UBI-Transfer, den du bestätigen kannst.",
|
||||
bankRulesEligibility6: "Fehlt die Adresse, ist der Feed zu jung, wurde der Monat abgelehnt oder hat das PUB keine Mittel, wird der Anspruch übersprungen und auf dem PUB protokolliert.",
|
||||
copyLinkTitle: "Link kopieren",
|
||||
cardMenuTitle: "Weitere Aktionen",
|
||||
shareContent: "Teilen",
|
||||
linkBoxOasis: "Oasis",
|
||||
linkBoxClearnet: "Clearnet",
|
||||
bankRefuseUBI: "UBI ablehnen",
|
||||
|
|
@ -896,7 +893,7 @@ module.exports = {
|
|||
walletNotConfigured: "Deine ECOin-Wallet ist noch nicht eingerichtet.",
|
||||
walletSettingsDescription: "Integriere Oasis mit deiner ECOin-Wallet.",
|
||||
walletSettingsMobileDescription: "Verbinde Oasis mit der ECOin-Wallet-App auf diesem Gerät.",
|
||||
walletDownloadEcoin: "ECOin herunterladen!",
|
||||
walletDownloadEcoin: "ECOin herunterladen",
|
||||
walletRpcUrl: "RPC-URL",
|
||||
walletStatusMessages: {
|
||||
invalid_amount: "Ungültiger Betrag",
|
||||
|
|
@ -948,6 +945,7 @@ module.exports = {
|
|||
bookmarkUpdateButton: "Aktualisieren",
|
||||
bookmarkAddFavoriteButton: "Favorit hinzufügen",
|
||||
bookmarkRemoveFavoriteButton: "Favorit entfernen",
|
||||
bookmarkTitleLabel: "Titel",
|
||||
bookmarkUrlLabel: "Link",
|
||||
bookmarkUrlPlaceholder: "https://solarnethub.com",
|
||||
bookmarkDescriptionLabel: "Beschreibung",
|
||||
|
|
@ -1092,9 +1090,9 @@ module.exports = {
|
|||
allCVs: "Lebensläufe",
|
||||
discoverPeople: "Entdecke Bewohner in deinem Netzwerk.",
|
||||
searchInhabitantsPlaceholder: "In Bewohner suchen...",
|
||||
filterLocation: "BEWOHNER NACH ORT FILTERN …",
|
||||
filterLanguage: "BEWOHNER NACH SPRACHE FILTERN …",
|
||||
filterSkills: "BEWOHNER NACH FÄHIGKEITEN FILTERN …",
|
||||
filterLocation: "Nach Ort suchen…",
|
||||
filterLanguage: "Nach Sprache suchen…",
|
||||
filterSkills: "Nach Fähigkeiten suchen…",
|
||||
cvSkillsCount: "Fähigkeiten",
|
||||
applyFilters: "Filter anwenden",
|
||||
locationLabel: "Ort",
|
||||
|
|
@ -1110,6 +1108,7 @@ module.exports = {
|
|||
topactivitySectionTitle: "Top Aktivität",
|
||||
"TOP INACTIVITYButton": "AM INAKTIVSTEN",
|
||||
topinactivitySectionTitle: "Am wenigsten aktive Bewohner",
|
||||
contactsSectionTitle: "Kontakte",
|
||||
blockedSectionTitle: "Blockiert",
|
||||
gallerySectionTitle: "GALERIE",
|
||||
topSectionTitle: "TOP",
|
||||
|
|
@ -1563,9 +1562,7 @@ module.exports = {
|
|||
voteUnknownDate: "Unbekanntes Datum",
|
||||
voteDetailSectionTitle: 'Abstimmungsdetails',
|
||||
voteCommentsLabel: 'Kommentare',
|
||||
voteCommentsForumButton: 'Diskussion eröffnen',
|
||||
voteCommentsSectionTitle: 'Offene Diskussion',
|
||||
voteNoCommentsYet: 'Noch keine Kommentare. Sei der Erste, der antwortet.',
|
||||
voteNoCommentsYet: 'Noch keine Kommentare.',
|
||||
commentsGoToLast: "Zum letzten Kommentar",
|
||||
voteNewCommentPlaceholder: 'Schreibe deinen Kommentar hier…',
|
||||
voteNewCommentButton: 'Kommentar veröffentlichen',
|
||||
|
|
@ -1692,7 +1689,6 @@ module.exports = {
|
|||
pollVoteButton: "Abstimmen",
|
||||
pollChangeVote: "Stimme Ändern",
|
||||
pollVoters: "Abstimmende",
|
||||
pollComments: "Kommentare",
|
||||
pollStatusLabel: "Status",
|
||||
pollStatusOpen: "OFFEN",
|
||||
pollStatusClosed: "GESCHLOSSEN",
|
||||
|
|
@ -1714,9 +1710,22 @@ module.exports = {
|
|||
blogAllowComments: "Kommentare erlauben",
|
||||
blogCommentsClosed: "Der Autor hat die Kommentare für diesen Blog geschlossen.",
|
||||
blogNoItems: "Keine Blogs gefunden.",
|
||||
viewBlog: "Blog Ansehen",
|
||||
forumTitle: "Foren",
|
||||
forumCategoryLabel: "Kategorie",
|
||||
forumCatGENERAL: "ALLGEMEIN",
|
||||
forumCatPOLITICS: "POLITIK",
|
||||
forumCatTECH: "TECHNIK",
|
||||
forumCatSCIENCE: "WISSENSCHAFT",
|
||||
forumCatMUSIC: "MUSIK",
|
||||
forumCatART: "KUNST",
|
||||
forumCatGAMING: "SPIELE",
|
||||
forumCatBOOKS: "BÜCHER",
|
||||
forumCatFILMS: "FILME",
|
||||
forumCatPHILOSOPHY: "PHILOSOPHIE",
|
||||
forumCatSOCIETY: "GESELLSCHAFT",
|
||||
forumCatPRIVACY: "PRIVATSPHÄRE",
|
||||
forumCatCYBERWARFARE: "CYBERKRIEG",
|
||||
forumCatSURVIVALISM: "SURVIVALISMUS",
|
||||
forumTitleLabel: "Titel",
|
||||
forumTitlePlaceholder: "Forentitel...",
|
||||
forumCreateButton: "Forum erstellen",
|
||||
|
|
@ -1988,43 +1997,6 @@ module.exports = {
|
|||
reportsUnderReviewSectionTitle: "Berichte in Prüfung",
|
||||
reportsResolvedSectionTitle: "Gelöste Berichte",
|
||||
reportsInvalidSectionTitle: "Ungültige Berichte",
|
||||
reportsBugTemplateTitle: 'Reproduktionsdetails (Fehler)',
|
||||
reportsFeatureTemplateTitle: 'Details (Funktionen)',
|
||||
reportsAbuseTemplateTitle: 'Details (Missbrauch)',
|
||||
reportsContentTemplateTitle: 'Details (Inhaltsprobleme)',
|
||||
reportsStepsToReproduceLabel: 'Reproduktionsschritte',
|
||||
reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...',
|
||||
reportsExpectedBehaviorLabel: 'Erwartetes Ergebnis',
|
||||
reportsExpectedBehaviorPlaceholder: 'Was sollte passieren?',
|
||||
reportsActualBehaviorLabel: 'Tatsächliches Ergebnis',
|
||||
reportsActualBehaviorPlaceholder: 'Was passiert tatsächlich?',
|
||||
reportsEnvironmentLabel: 'Umgebung',
|
||||
reportsEnvironmentPlaceholder: 'Version, Gerät, Betriebssystem, Browser, Einstellungen, Protokolle, etc.',
|
||||
reportsReproduceRateLabel: 'Reproduktionsrate',
|
||||
reportsReproduceRateAlways: 'Immer',
|
||||
reportsReproduceRateOften: 'Häufig',
|
||||
reportsReproduceRateSometimes: 'Manchmal',
|
||||
reportsReproduceRateRarely: 'Selten',
|
||||
reportsReproduceRateUnable: 'Nicht reproduzierbar',
|
||||
reportsReproduceRateUnknown: 'Nicht angegeben',
|
||||
reportsProblemStatementLabel: 'Problem / Bedarf',
|
||||
reportsProblemStatementPlaceholder: 'Welches Problem löst diese Funktion?',
|
||||
reportsUserStoryLabel: 'Bewohner-Geschichte',
|
||||
reportsUserStoryPlaceholder: 'Als <Bewohner> möchte ich <Aktion>, damit <Nutzen>.',
|
||||
reportsAcceptanceCriteriaLabel: 'Akzeptanzkriterien',
|
||||
reportsAcceptanceCriteriaPlaceholder: '- Gegeben...\n- Wenn...\n- Dann...',
|
||||
reportsWhatHappenedLabel: 'Was ist passiert?',
|
||||
reportsWhatHappenedPlaceholder: 'Beschreibe den Vorfall mit Kontext und ungefährem Datum.',
|
||||
reportsReportedUserLabel: 'Gemeldeter Bewohner / Entität',
|
||||
reportsReportedUserPlaceholder: '@Bewohner, Feed, ID, Link, etc.',
|
||||
reportsEvidenceLinksLabel: 'Beweise / Links',
|
||||
reportsEvidenceLinksPlaceholder: 'Links, Nachrichten-IDs, Screenshots (falls zutreffend), etc.',
|
||||
reportsContentLocationLabel: 'Wo sich der Inhalt befindet',
|
||||
reportsContentLocationPlaceholder: 'Link, ID, Kanal, Thread, Autor, etc.',
|
||||
reportsWhyInappropriateLabel: "Warum es unangemessen ist",
|
||||
reportsWhyInappropriatePlaceholder: 'Grund und Auswirkung erklären.',
|
||||
reportsRequestedActionLabel: 'Gewünschte Maßnahme',
|
||||
reportsRequestedActionPlaceholder: 'Entfernen, verbergen, markieren, warnen, etc.',
|
||||
tribesTitle: "Stämme",
|
||||
tribeFilterAll: "ALLE",
|
||||
tribeFilterRecent: "NEUESTE",
|
||||
|
|
@ -2268,6 +2240,7 @@ module.exports = {
|
|||
voteWellResearched: "Gut recherchiert",
|
||||
voteNeedsSources: "Quellen nötig",
|
||||
voteWrong: "Falsch",
|
||||
voteOutdated: "Veraltet",
|
||||
voteLowQuality: "Niedrige Qualität",
|
||||
voteLove: "Liebe",
|
||||
voteClear: "Leeren",
|
||||
|
|
@ -2512,7 +2485,6 @@ module.exports = {
|
|||
shopNoProducts: "Noch keine Produkte.",
|
||||
shopOutOfStock: "Ausverkauft",
|
||||
shopBuy: "Kaufen",
|
||||
shopBuyEncryptedNote: "Deine Lieferdaten werden verschlüsselt übertragen; nur der Shop-Inhaber kann sie lesen.",
|
||||
shopBuyDeliveryAddress: "Lieferadresse",
|
||||
shopBuyContact: "Kontakt",
|
||||
shopBuyContactPlaceholder: "E-Mail, Telefon, usw.",
|
||||
|
|
@ -3029,6 +3001,16 @@ module.exports = {
|
|||
industryDescriptionLabel: "Beschreibung",
|
||||
industryDescriptionPlaceholder: "Was produziert diese Fabrik?",
|
||||
industrySector: "Sektor",
|
||||
industrySector_software: "Software",
|
||||
industrySector_hardware: "Hardware",
|
||||
industrySector_agriculture: "Landwirtschaft",
|
||||
industrySector_textile: "Textil",
|
||||
industrySector_energy: "Energie",
|
||||
industrySector_food: "Lebensmittel",
|
||||
industrySector_construction: "Bauwesen",
|
||||
industrySector_media: "Medien",
|
||||
industrySector_services: "Dienstleistungen",
|
||||
industrySector_other: "Sonstiges",
|
||||
industryMembershipPolicy: "Mitgliedschaftsrichtlinie",
|
||||
industryQuorum: "Quorum",
|
||||
industryMajority: "Mehrheit",
|
||||
|
|
@ -3230,6 +3212,18 @@ module.exports = {
|
|||
modulesFavoritesDescription: "Modul zum Verwalten deiner favorisierten Inhalte.",
|
||||
fileTooLargeTitle: "Datei zu groß",
|
||||
fileTooLargeMessage: "Die Datei überschreitet die maximal erlaubte Größe (75 MB). Bitte wähle eine kleinere Datei.",
|
||||
fileWrongType: "Diese Datei hat nicht den passenden Typ für diesen Bereich.",
|
||||
mediaLockedAfterOpinions: "Es hat bereits Meinungen: Du kannst den Rest bearbeiten, aber nicht das, was bewertet wurde.",
|
||||
pdfPrevPage: "Vorherige Seite",
|
||||
pdfNextPage: "Nächste Seite",
|
||||
pdfPage: "Seite",
|
||||
pdfZoomIn: "Vergrößern",
|
||||
pdfZoomOut: "Verkleinern",
|
||||
pdfFullscreen: "Vollbild",
|
||||
pdfClose: "Schließen",
|
||||
pdfRotate: "Drehen",
|
||||
contentNotAvailableYet: "Dieser Inhalt ist noch nicht verfügbar.",
|
||||
pdfRetry: "Erneut versuchen",
|
||||
goBack: "Zurück",
|
||||
errorDismiss: "OK",
|
||||
errorPageTitle: "Etwas ist schiefgelaufen",
|
||||
|
|
@ -3274,8 +3268,6 @@ module.exports = {
|
|||
courtsRespondentInvalid: "Ungültiger Beklagter",
|
||||
feedDetailTitle: "Feed-Details",
|
||||
feedOpenDiscussion: "Diskussion eröffnen",
|
||||
feedPostComment: "Kommentar veröffentlichen",
|
||||
noComments: "Noch keine Kommentare.",
|
||||
mapsLabel: "Karten",
|
||||
mapTitle: "Karten",
|
||||
mapDescription: "Verwalten und teilen Sie Offline-Karten in Ihrem Netzwerk.",
|
||||
|
|
@ -3579,6 +3571,8 @@ module.exports = {
|
|||
podcastTagsPlaceholder: "Tags durch Kommas getrennt eingeben",
|
||||
podcastFilterAll: "ALLE",
|
||||
podcastFilterMine: "MEINE",
|
||||
podcastFilterFavorites: "FAVORITEN",
|
||||
podcastEpisodeNumberLabel: "Folgennummer",
|
||||
podcastFilterRecent: "NEU",
|
||||
podcastFilterTop: "TOP",
|
||||
podcastFilterViewers: "HÖRER",
|
||||
|
|
@ -3797,7 +3791,6 @@ module.exports = {
|
|||
wikiBackToPage: "Zurück zur Seite",
|
||||
wikiLinkedChip: "Verknüpft",
|
||||
wikiRestoredSummary: "Wiederhergestellte Version",
|
||||
wikiExportPdf: "Seite erzeugen",
|
||||
wikiPreview: "Vorschau",
|
||||
tribeSectionWiki: "WIKI",
|
||||
tribeWikiCreate: "Wiki erstellen",
|
||||
|
|
@ -3865,6 +3858,9 @@ module.exports = {
|
|||
roomRecStop: "Aufnahme beenden",
|
||||
roomStreamFile: "Streamen",
|
||||
roomStreamStop: "Stoppen",
|
||||
roomScreen: "Bildschirm",
|
||||
roomScreenSharing: "Bildschirm wird geteilt",
|
||||
roomScreenWarning: "Alle im Raum sehen deinen ganzen Bildschirm, bis du stoppst.",
|
||||
roomStreaming: "Wird gestreamt",
|
||||
liveFullscreen: "Vollbild",
|
||||
roomStreamPickTitle: "Video auswählen",
|
||||
|
|
@ -3922,6 +3918,7 @@ module.exports = {
|
|||
inviteAlreadyMember: "Du bist schon dabei. Du kannst es über sein Modul öffnen.",
|
||||
actionFailed: "Das hat nicht geklappt. Bitte versuche es erneut.",
|
||||
publishTooShort: "Dein Beitrag ist zu kurz.",
|
||||
publishEmpty: "Schreibe etwas oder hänge eine Datei an.",
|
||||
walletSendFailed: "Die Zahlung konnte nicht gesendet werden: {reason}",
|
||||
pixeliaCoordsError: "Diese Koordinaten liegen außerhalb des Rasters.",
|
||||
courtsErrTitle: "Ein Titel ist erforderlich.",
|
||||
|
|
@ -4430,6 +4427,9 @@ module.exports = {
|
|||
torrentTagsLabel: "Tags",
|
||||
torrentTagsPlaceholder: "Tags durch Kommas getrennt eingeben",
|
||||
torrentSizeLabel: "Größe",
|
||||
torrentInfoHash: "Infohash",
|
||||
torrentFilesLabel: "Dateien",
|
||||
torrentMagnet: "MAGNET",
|
||||
torrentFromContent: "Torrent erzeugen",
|
||||
torrentFromContentMissing: "Die Datei ist auf diesem Knoten noch nicht verfügbar. Versuche es später erneut.",
|
||||
torrentDownloads: "Downloads",
|
||||
|
|
@ -4515,9 +4515,6 @@ module.exports = {
|
|||
inhabitantsPendingAccept: "Akzeptieren",
|
||||
inhabitantsPendingReject: "Ablehnen",
|
||||
tribeSectionGovernance: "GOVERNANCE",
|
||||
tribePadInviteRequired: "Kein Zugriff auf das Pad. Bitte um Einladung.",
|
||||
tribeChatInviteRequired: "Kein Zugriff auf den Chat. Bitte um Einladung.",
|
||||
tribeGovernanceDesc: "Interne Governance dieses Stammes.",
|
||||
tribeGovernanceNoGov: "Keine aktive Regierung",
|
||||
tribeGovernanceNoGovDesc: "Dieser Stamm hat noch keine Regierung gewählt.",
|
||||
tribeGovCardTitle: "Aktuelle Regierung",
|
||||
|
|
@ -4574,7 +4571,6 @@ module.exports = {
|
|||
logsFilterMonth: "LETZTER MONAT",
|
||||
logsFilterWeek: "LETZTE WOCHE",
|
||||
logsFilterToday: "HEUTE",
|
||||
logsComments: "Kommentare",
|
||||
logsAuthorEntries: "Einträge",
|
||||
logsCreate: "Eintrag erstellen",
|
||||
logsExport: "Logbuch exportieren",
|
||||
|
|
@ -4652,6 +4648,8 @@ module.exports = {
|
|||
larpWillTestTitle: "WILL-Test",
|
||||
settingsLanDesc: "Diesen Peer periodisch an andere Oasis-Instanzen im selben lokalen Netzwerk ankündigen.",
|
||||
settingsLanEnable: "LAN-Broadcasting aktivieren",
|
||||
settingsConnectionsTitle: "Verbindungen",
|
||||
settingsConnectionsDesc: "Wie viele Peers dieser Knoten gleichzeitig verbunden hält.",
|
||||
settingsLanTitle: "LAN-Broadcasting",
|
||||
settingsReplicationTitle: "Replikation",
|
||||
settingsReplicationDesc: "Konfiguriere die Anzahl der Hops, die dein Peer von deinem eigenen Feed aus beim Replizieren von Inhalten folgt.",
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ module.exports = {
|
|||
graphosBackToMine: "← My network",
|
||||
graphosShowing: "Showing",
|
||||
graphosYou: "You",
|
||||
graphosTotalNodes: "Total nodes",
|
||||
graphosTotalNodes: "Total",
|
||||
onlinePlural: "Online",
|
||||
mentions: "Mentions",
|
||||
mentionsDescription: [
|
||||
strong("Every message that @mention you"),
|
||||
|
|
@ -430,6 +431,7 @@ module.exports = {
|
|||
menuMedia: "Library",
|
||||
menuTools: "Tools",
|
||||
comment: "Comment",
|
||||
typeComment: "Comment",
|
||||
subtopic: "Subtopic",
|
||||
json: "JSON",
|
||||
unfollow: "Unsupport",
|
||||
|
|
@ -459,9 +461,6 @@ module.exports = {
|
|||
publishCustomDescription: [
|
||||
"REMEMBER: Due to blockchain technology, once a post is published it cannot be edited or deleted.",
|
||||
],
|
||||
commentWarning: [
|
||||
"REMEMBER: Due to blockchain technology, once a post is published it cannot be edited or deleted.",
|
||||
],
|
||||
commentLabel: ({ publicOrPrivate, markdownUrl }) => [
|
||||
],
|
||||
publishLabel: ({ markdownUrl, linkTarget }) => [
|
||||
|
|
@ -513,6 +512,8 @@ module.exports = {
|
|||
settingsBlobCacheCleanNow: "Clean now",
|
||||
settingsBlobCacheCleaned: "{n} files removed, {size} freed.",
|
||||
settingsLanEnable: "Enable LAN broadcasting",
|
||||
settingsConnectionsTitle: "Connections",
|
||||
settingsConnectionsDesc: "How many peers this node keeps connected at the same time.",
|
||||
saveSettings: "Save settings",
|
||||
exportDescription: "Set password (min 32 characters long) to encrypt your key",
|
||||
exportDataTitle: "Instant Backup",
|
||||
|
|
@ -754,10 +755,6 @@ module.exports = {
|
|||
votesQuestionLabel:"Question",
|
||||
voteStatusOpen:"OPEN",
|
||||
voteStatusClosed:"CLOSED",
|
||||
commentDescription: ({ parentUrl }) => [
|
||||
" commented on ",
|
||||
a({ href: parentUrl }, " thread"),
|
||||
],
|
||||
commentTitle: ({ authorName }) => [`Comment on @${authorName}'s post`],
|
||||
subtopicDescription: ({ parentUrl }) => [
|
||||
" created a subtopic from ",
|
||||
|
|
@ -895,6 +892,8 @@ module.exports = {
|
|||
bankRulesEligibility5: "The PUB pays on its next cycle and publishes a UBI transfer that you can confirm.",
|
||||
bankRulesEligibility6: "If your address is missing, the feed is too young, you refused the month or the PUB has no funds, the claim is skipped and logged on the PUB.",
|
||||
copyLinkTitle: "Copy link",
|
||||
cardMenuTitle: "More actions",
|
||||
shareContent: "Share",
|
||||
linkBoxOasis: "Oasis",
|
||||
linkBoxClearnet: "Clearnet",
|
||||
bankRefuseUBI: "Refuse UBI",
|
||||
|
|
@ -928,7 +927,7 @@ module.exports = {
|
|||
walletNotConfigured: "Your ECOin wallet is not configured yet.",
|
||||
walletSettingsDescription: "Integrate Oasis with your ECOin wallet.",
|
||||
walletSettingsMobileDescription: "Link Oasis with the ECOin Wallet app on this device.",
|
||||
walletDownloadEcoin: "Download ECOin!",
|
||||
walletDownloadEcoin: "Download ECOin",
|
||||
walletRpcUrl: "RPC URL",
|
||||
walletStatusMessages: {
|
||||
invalid_amount: "Invalid amount",
|
||||
|
|
@ -981,6 +980,7 @@ module.exports = {
|
|||
bookmarkUpdateButton: "Update",
|
||||
bookmarkAddFavoriteButton: "Add favorite",
|
||||
bookmarkRemoveFavoriteButton: "Remove favorite",
|
||||
bookmarkTitleLabel: "Title",
|
||||
bookmarkUrlLabel: "Link",
|
||||
bookmarkUrlPlaceholder: "https://solarnethub.com",
|
||||
bookmarkDescriptionLabel: "Description",
|
||||
|
|
@ -1212,9 +1212,9 @@ module.exports = {
|
|||
allCVs: "CVs",
|
||||
discoverPeople: "Discover inhabitants in your network.",
|
||||
searchInhabitantsPlaceholder: "Search in Inhabitants...",
|
||||
filterLocation: "FILTER inhabitants BY LOCATION …",
|
||||
filterLanguage: "FILTER inhabitants BY LANGUAGE …",
|
||||
filterSkills: "FILTER inhabitants BY SKILLS …",
|
||||
filterLocation: "Search by location…",
|
||||
filterLanguage: "Search by language…",
|
||||
filterSkills: "Search by skills…",
|
||||
cvSkillsCount: "Skills",
|
||||
applyFilters: "Apply Filters",
|
||||
locationLabel: "Location",
|
||||
|
|
@ -1230,6 +1230,7 @@ module.exports = {
|
|||
topactivitySectionTitle: "Top Activity",
|
||||
"TOP INACTIVITYButton": "TOP INACTIVITY",
|
||||
topinactivitySectionTitle: "Least active inhabitants",
|
||||
contactsSectionTitle: "Contacts",
|
||||
blockedSectionTitle: "Blocked",
|
||||
gallerySectionTitle: "GALLERY",
|
||||
topSectionTitle: "TOP",
|
||||
|
|
@ -1684,9 +1685,7 @@ module.exports = {
|
|||
voteUnknownDate: "Unknown Date",
|
||||
voteDetailSectionTitle: 'Vote details',
|
||||
voteCommentsLabel: 'Comments',
|
||||
voteCommentsForumButton: 'Open discussion',
|
||||
voteCommentsSectionTitle: 'Open discussion',
|
||||
voteNoCommentsYet: 'There are no comments yet. Be the first to reply.',
|
||||
voteNoCommentsYet: 'There are no comments yet.',
|
||||
commentsGoToLast: "Go to the last comment",
|
||||
voteNewCommentPlaceholder: 'Write your comment here…',
|
||||
voteNewCommentButton: 'Post comment',
|
||||
|
|
@ -1813,7 +1812,6 @@ module.exports = {
|
|||
pollVoteButton: "Vote",
|
||||
pollChangeVote: "Change Vote",
|
||||
pollVoters: "Voters",
|
||||
pollComments: "Comments",
|
||||
pollStatusLabel: "Status",
|
||||
pollStatusOpen: "OPEN",
|
||||
pollStatusClosed: "CLOSED",
|
||||
|
|
@ -1835,9 +1833,22 @@ module.exports = {
|
|||
blogAllowComments: "Allow comments",
|
||||
blogCommentsClosed: "The author has closed comments on this blog.",
|
||||
blogNoItems: "No blogs found.",
|
||||
viewBlog: "View Blog",
|
||||
forumTitle: "Forums",
|
||||
forumCategoryLabel: "Category",
|
||||
forumCatGENERAL: "GENERAL",
|
||||
forumCatPOLITICS: "POLITICS",
|
||||
forumCatTECH: "TECH",
|
||||
forumCatSCIENCE: "SCIENCE",
|
||||
forumCatMUSIC: "MUSIC",
|
||||
forumCatART: "ART",
|
||||
forumCatGAMING: "GAMING",
|
||||
forumCatBOOKS: "BOOKS",
|
||||
forumCatFILMS: "FILMS",
|
||||
forumCatPHILOSOPHY: "PHILOSOPHY",
|
||||
forumCatSOCIETY: "SOCIETY",
|
||||
forumCatPRIVACY: "PRIVACY",
|
||||
forumCatCYBERWARFARE: "CYBERWARFARE",
|
||||
forumCatSURVIVALISM: "SURVIVALISM",
|
||||
forumTitleLabel: "Title",
|
||||
forumTitlePlaceholder: "Forum title...",
|
||||
forumCreateButton: "Create forum",
|
||||
|
|
@ -2026,8 +2037,6 @@ module.exports = {
|
|||
feedTitle: "Feed",
|
||||
feedDetailTitle: "Feed",
|
||||
feedOpenDiscussion: "Open discussion",
|
||||
feedPostComment: "Post comment",
|
||||
noComments: "No comments yet",
|
||||
createFeedTitle: "Create Feed",
|
||||
createFeedButton: "Send Feed!",
|
||||
feedPlaceholder: "What's happening? (max 280 characters)",
|
||||
|
|
@ -2248,43 +2257,6 @@ module.exports = {
|
|||
reportsUnderReviewSectionTitle: "Under Review Reports",
|
||||
reportsResolvedSectionTitle: "Resolved Reports",
|
||||
reportsInvalidSectionTitle: "Invalid Reports",
|
||||
reportsBugTemplateTitle: 'Reproduction details (Bugs)',
|
||||
reportsFeatureTemplateTitle: 'Details (Features)',
|
||||
reportsAbuseTemplateTitle: 'Details (Abuse)',
|
||||
reportsContentTemplateTitle: 'Details (Content Issues)',
|
||||
reportsStepsToReproduceLabel: 'Steps to reproduce',
|
||||
reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...',
|
||||
reportsExpectedBehaviorLabel: 'Expected result',
|
||||
reportsExpectedBehaviorPlaceholder: 'What should happen?',
|
||||
reportsActualBehaviorLabel: 'Actual result',
|
||||
reportsActualBehaviorPlaceholder: 'What actually happens?',
|
||||
reportsEnvironmentLabel: 'Environment',
|
||||
reportsEnvironmentPlaceholder: 'Version, device, OS, browser, settings, logs, etc.',
|
||||
reportsReproduceRateLabel: 'Reproduction rate',
|
||||
reportsReproduceRateAlways: 'Always',
|
||||
reportsReproduceRateOften: 'Often',
|
||||
reportsReproduceRateSometimes: 'Sometimes',
|
||||
reportsReproduceRateRarely: 'Rarely',
|
||||
reportsReproduceRateUnable: 'Unable to reproduce',
|
||||
reportsReproduceRateUnknown: 'Not specified',
|
||||
reportsProblemStatementLabel: 'Problem / need',
|
||||
reportsProblemStatementPlaceholder: 'What problem does this feature solve?',
|
||||
reportsUserStoryLabel: 'Inhabitant story',
|
||||
reportsUserStoryPlaceholder: 'As a <inhabitant>, I want <action>, so that <benefit>.',
|
||||
reportsAcceptanceCriteriaLabel: 'Acceptance criteria',
|
||||
reportsAcceptanceCriteriaPlaceholder: '- Given...\n- When...\n- Then...',
|
||||
reportsWhatHappenedLabel: 'What happened?',
|
||||
reportsWhatHappenedPlaceholder: 'Describe the incident with context and approximate dates.',
|
||||
reportsReportedUserLabel: 'Reported inhabitant / entity',
|
||||
reportsReportedUserPlaceholder: '@inhabitant, feed, ID, link, etc.',
|
||||
reportsEvidenceLinksLabel: 'Evidence / links',
|
||||
reportsEvidenceLinksPlaceholder: 'Paste links, message IDs, screenshots (if applicable), etc.',
|
||||
reportsContentLocationLabel: 'Where the content is',
|
||||
reportsContentLocationPlaceholder: 'Link, ID, channel, thread, author, etc.',
|
||||
reportsWhyInappropriateLabel: "Why it's inappropriate",
|
||||
reportsWhyInappropriatePlaceholder: 'Explain the reason and impact.',
|
||||
reportsRequestedActionLabel: 'Requested action',
|
||||
reportsRequestedActionPlaceholder: 'Remove, hide, tag, warn, etc.',
|
||||
tribesTitle: "Tribes",
|
||||
tribeFilterAll: "ALL",
|
||||
tribeFilterRecent: "RECENT",
|
||||
|
|
@ -2528,6 +2500,7 @@ module.exports = {
|
|||
voteWellResearched: "Well researched",
|
||||
voteNeedsSources: "Needs sources",
|
||||
voteWrong: "Wrong",
|
||||
voteOutdated: "Outdated",
|
||||
voteLowQuality: "Low quality",
|
||||
voteLove: "Love",
|
||||
voteClear: "Clear",
|
||||
|
|
@ -2772,7 +2745,6 @@ module.exports = {
|
|||
shopNoProducts: "No products yet.",
|
||||
shopOutOfStock: "Out of stock",
|
||||
shopBuy: "Buy",
|
||||
shopBuyEncryptedNote: "Your delivery details are sent encrypted, visible only to the shop owner.",
|
||||
shopBuyDeliveryAddress: "Delivery address",
|
||||
shopBuyContact: "Contact",
|
||||
shopBuyContactPlaceholder: "email, phone, etc.",
|
||||
|
|
@ -3289,6 +3261,16 @@ module.exports = {
|
|||
industryDescriptionLabel: "Description",
|
||||
industryDescriptionPlaceholder: "What does this facility produce?",
|
||||
industrySector: "Sector",
|
||||
industrySector_software: "Software",
|
||||
industrySector_hardware: "Hardware",
|
||||
industrySector_agriculture: "Agriculture",
|
||||
industrySector_textile: "Textile",
|
||||
industrySector_energy: "Energy",
|
||||
industrySector_food: "Food",
|
||||
industrySector_construction: "Construction",
|
||||
industrySector_media: "Media",
|
||||
industrySector_services: "Services",
|
||||
industrySector_other: "Other",
|
||||
industryMembershipPolicy: "Membership policy",
|
||||
industryQuorum: "Quorum",
|
||||
industryMajority: "Majority",
|
||||
|
|
@ -3490,6 +3472,18 @@ module.exports = {
|
|||
modulesFavoritesDescription: "Module to manage your favorite content.",
|
||||
fileTooLargeTitle: "File too large",
|
||||
fileTooLargeMessage: "The file exceeds the maximum allowed size (75 MB). Please select a smaller file.",
|
||||
fileWrongType: "This file is not the right type for this section.",
|
||||
mediaLockedAfterOpinions: "It already has opinions: you can edit the rest, but not change what was rated.",
|
||||
pdfPrevPage: "Previous page",
|
||||
pdfNextPage: "Next page",
|
||||
pdfPage: "Page",
|
||||
pdfZoomIn: "Zoom in",
|
||||
pdfZoomOut: "Zoom out",
|
||||
pdfFullscreen: "Full screen",
|
||||
pdfClose: "Close",
|
||||
pdfRotate: "Rotate",
|
||||
contentNotAvailableYet: "This content is not available, yet.",
|
||||
pdfRetry: "Retry",
|
||||
goBack: "Go back",
|
||||
errorDismiss: "OK",
|
||||
errorPageTitle: "Something went wrong",
|
||||
|
|
@ -3834,6 +3828,8 @@ module.exports = {
|
|||
podcastTagsPlaceholder: "Enter tags separated by commas",
|
||||
podcastFilterAll: "ALL",
|
||||
podcastFilterMine: "MINE",
|
||||
podcastFilterFavorites: "FAVORITES",
|
||||
podcastEpisodeNumberLabel: "Episode number",
|
||||
podcastFilterRecent: "RECENT",
|
||||
podcastFilterTop: "TOP",
|
||||
podcastFilterViewers: "VIEWERS",
|
||||
|
|
@ -4052,7 +4048,6 @@ module.exports = {
|
|||
wikiBackToPage: "Back to page",
|
||||
wikiLinkedChip: "Linked",
|
||||
wikiRestoredSummary: "Restored version",
|
||||
wikiExportPdf: "Generate Page",
|
||||
wikiPreview: "Preview",
|
||||
tribeSectionWiki: "WIKI",
|
||||
tribeWikiCreate: "Create Wiki",
|
||||
|
|
@ -4121,6 +4116,9 @@ module.exports = {
|
|||
roomRecStop: "Stop recording",
|
||||
roomStreamFile: "Stream",
|
||||
roomStreamStop: "Stop",
|
||||
roomScreen: "Screen",
|
||||
roomScreenSharing: "Sharing screen",
|
||||
roomScreenWarning: "Everyone in the room will see your whole screen until you stop.",
|
||||
roomStreaming: "Streaming",
|
||||
liveFullscreen: "Full screen",
|
||||
roomStreamPickTitle: "Choose a video",
|
||||
|
|
@ -4178,6 +4176,7 @@ module.exports = {
|
|||
inviteAlreadyMember: "You are already in. You can open it from its module.",
|
||||
actionFailed: "That could not be done. Please try again.",
|
||||
publishTooShort: "Your post is too short.",
|
||||
publishEmpty: "Write something or attach a file.",
|
||||
walletSendFailed: "The payment could not be sent: {reason}",
|
||||
pixeliaCoordsError: "Those coordinates are outside the grid.",
|
||||
courtsErrTitle: "A title is required.",
|
||||
|
|
@ -4751,6 +4750,9 @@ module.exports = {
|
|||
torrentTagsLabel: "Tags",
|
||||
torrentTagsPlaceholder: "Enter tags separated by commas",
|
||||
torrentSizeLabel: "Size",
|
||||
torrentInfoHash: "Infohash",
|
||||
torrentFilesLabel: "Files",
|
||||
torrentMagnet: "MAGNET",
|
||||
torrentFromContent: "Generate torrent",
|
||||
torrentFromContentMissing: "The file is not available on this node yet. Try again in a while.",
|
||||
torrentDownloads: "Downloads",
|
||||
|
|
@ -4836,9 +4838,6 @@ module.exports = {
|
|||
inhabitantsPendingAccept: "Accept",
|
||||
inhabitantsPendingReject: "Reject",
|
||||
tribeSectionGovernance: "GOVERNANCE",
|
||||
tribePadInviteRequired: "You do not have access to the pad. Ask for an invitation to access the content.",
|
||||
tribeChatInviteRequired: "You do not have access to the chat. Ask for an invitation to access the content.",
|
||||
tribeGovernanceDesc: "Internal governance for this tribe. Propose candidatures, debate rules, elect leaders.",
|
||||
tribeGovernanceNoGov: "No active government",
|
||||
tribeGovernanceNoGovDesc: "This tribe has not yet elected a government. Propose candidatures to start the process.",
|
||||
tribeGovCardTitle: "Current Government",
|
||||
|
|
@ -4895,7 +4894,6 @@ module.exports = {
|
|||
logsFilterMonth: "LAST MONTH",
|
||||
logsFilterWeek: "LAST WEEK",
|
||||
logsFilterToday: "TODAY",
|
||||
logsComments: "Comments",
|
||||
logsAuthorEntries: "Entries",
|
||||
logsCreate: "Create Log",
|
||||
logsExport: "Export Logs",
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ module.exports = {
|
|||
graphosBackToMine: "← Mi red",
|
||||
graphosShowing: "Mostrando",
|
||||
graphosYou: "Tú",
|
||||
graphosTotalNodes: "Nodos totales",
|
||||
graphosTotalNodes: "Totales",
|
||||
onlinePlural: "Conectados",
|
||||
mentions: "Menciones",
|
||||
mentionsDescription: [
|
||||
strong("Todos los mensajes que te @mencionan"),
|
||||
|
|
@ -412,6 +413,7 @@ module.exports = {
|
|||
menuMedia: "Biblioteca",
|
||||
menuTools: "Herramientas",
|
||||
comment: "Comentar",
|
||||
typeComment: "Comentario",
|
||||
subtopic: "Subtema",
|
||||
json: "JSON",
|
||||
unfollow: "Dejar de soportar",
|
||||
|
|
@ -441,9 +443,6 @@ module.exports = {
|
|||
publishCustomDescription: [
|
||||
"RECUERDA: debido a la tecnología blockchain, una vez publicas algo, no se puede editar ni borrar. ¡Piénsalo bien!",
|
||||
],
|
||||
commentWarning: [
|
||||
"RECUERDA: debido a la tecnología blockchain, una vez publicas algo, no se puede editar ni borrar. ¡Piénsalo bien!",
|
||||
],
|
||||
commentLabel: ({ publicOrPrivate, markdownUrl }) => [
|
||||
],
|
||||
publishLabel: ({ markdownUrl, linkTarget }) => [
|
||||
|
|
@ -718,10 +717,6 @@ module.exports = {
|
|||
votesQuestionLabel:"Pregunta",
|
||||
voteStatusOpen:"ABIERTO",
|
||||
voteStatusClosed:"CERRADO",
|
||||
commentDescription: ({ parentUrl }) => [
|
||||
" ha comentado en ",
|
||||
a({ href: parentUrl }, " hilo"),
|
||||
],
|
||||
commentTitle: ({ authorName }) => [`Ha comentado en el post de @${authorName}`],
|
||||
subtopicDescription: ({ parentUrl }) => [
|
||||
" ha creado un subtema desde ",
|
||||
|
|
@ -856,6 +851,8 @@ module.exports = {
|
|||
bankRulesEligibility5: "El PUB paga en su siguiente ciclo y publica un transfer RBU que puedes confirmar.",
|
||||
bankRulesEligibility6: "Si falta tu dirección, el feed es demasiado joven, rechazaste el mes o el PUB no tiene fondos, el reclamo se salta y queda registrado en el PUB.",
|
||||
copyLinkTitle: "Copiar enlace",
|
||||
cardMenuTitle: "Más acciones",
|
||||
shareContent: "Compartir",
|
||||
linkBoxOasis: "Oasis",
|
||||
linkBoxClearnet: "Clearnet",
|
||||
bankRefuseUBI: "Rechazar RBU",
|
||||
|
|
@ -889,7 +886,7 @@ module.exports = {
|
|||
walletNotConfigured: "Tu cartera de ECOin aún no está configurada.",
|
||||
walletSettingsDescription: "Integrar Oasis con tu cartera de ECOin.",
|
||||
walletSettingsMobileDescription: "Vincula Oasis con la app ECOin Wallet de este dispositivo.",
|
||||
walletDownloadEcoin: "¡Descargar ECOin!",
|
||||
walletDownloadEcoin: "Descargar ECOin",
|
||||
walletRpcUrl: "URL del RPC",
|
||||
walletStatusMessages: {
|
||||
invalid_amount: "Cantidad inválida",
|
||||
|
|
@ -942,6 +939,7 @@ module.exports = {
|
|||
bookmarkUpdateButton: "Actualizar",
|
||||
bookmarkAddFavoriteButton: "Añadir favorito",
|
||||
bookmarkRemoveFavoriteButton: "Quitar favorito",
|
||||
bookmarkTitleLabel: "Título",
|
||||
bookmarkUrlLabel: "Enlace",
|
||||
bookmarkUrlPlaceholder: "https://solarnethub.com",
|
||||
bookmarkDescriptionLabel: "Descripción",
|
||||
|
|
@ -1086,9 +1084,9 @@ module.exports = {
|
|||
allCVs: "CVs",
|
||||
discoverPeople: "Descubre habitantes en tu red.",
|
||||
searchInhabitantsPlaceholder: "Buscar en Habitantes...",
|
||||
filterLocation: "FILTRAR habitantes POR UBICACIÓN …",
|
||||
filterLanguage: "FILTRAR habitantes POR IDIOMA …",
|
||||
filterSkills: "FILTRAR habitantes POR HABILIDADES …",
|
||||
filterLocation: "Buscar por ubicación…",
|
||||
filterLanguage: "Buscar por idioma…",
|
||||
filterSkills: "Buscar por habilidades…",
|
||||
cvSkillsCount: "Habilidades",
|
||||
applyFilters: "Aplicar Filtros",
|
||||
locationLabel: "Ubicación",
|
||||
|
|
@ -1104,6 +1102,7 @@ module.exports = {
|
|||
topactivitySectionTitle: "Top Actividad",
|
||||
"TOP INACTIVITYButton": "MÁS INACTIVOS",
|
||||
topinactivitySectionTitle: "Habitantes menos activos",
|
||||
contactsSectionTitle: "Contactos",
|
||||
blockedSectionTitle: "Bloqueadas",
|
||||
gallerySectionTitle: "GALERÍA",
|
||||
topSectionTitle: "TOP",
|
||||
|
|
@ -1558,9 +1557,7 @@ module.exports = {
|
|||
voteUnknownDate: "Fecha Desconocida",
|
||||
voteDetailSectionTitle: 'Detalles de la votación',
|
||||
voteCommentsLabel: 'Comentarios',
|
||||
voteCommentsForumButton: 'Abrir conversación',
|
||||
voteCommentsSectionTitle: 'Conversación abierta',
|
||||
voteNoCommentsYet: 'Todavía no hay comentarios. Sé el primer habitante en opinar.',
|
||||
voteNoCommentsYet: 'Todavía no hay comentarios.',
|
||||
commentsGoToLast: "Ir al último comentario",
|
||||
voteNewCommentPlaceholder: 'Escribe aquí tu comentario…',
|
||||
voteNewCommentButton: 'Publicar comentario',
|
||||
|
|
@ -1687,7 +1684,6 @@ module.exports = {
|
|||
pollVoteButton: "Votar",
|
||||
pollChangeVote: "Cambiar Voto",
|
||||
pollVoters: "Votantes",
|
||||
pollComments: "Comentarios",
|
||||
pollStatusLabel: "Estado",
|
||||
pollStatusOpen: "ABIERTA",
|
||||
pollStatusClosed: "CERRADA",
|
||||
|
|
@ -1709,9 +1705,22 @@ module.exports = {
|
|||
blogAllowComments: "Permitir comentarios",
|
||||
blogCommentsClosed: "La autora ha cerrado los comentarios de este blog.",
|
||||
blogNoItems: "No se han encontrado blogs.",
|
||||
viewBlog: "Ver Blog",
|
||||
forumTitle: "Foros",
|
||||
forumCategoryLabel: "Categoría",
|
||||
forumCatGENERAL: "GENERAL",
|
||||
forumCatPOLITICS: "POLÍTICA",
|
||||
forumCatTECH: "TECNOLOGÍA",
|
||||
forumCatSCIENCE: "CIENCIA",
|
||||
forumCatMUSIC: "MÚSICA",
|
||||
forumCatART: "ARTE",
|
||||
forumCatGAMING: "JUEGOS",
|
||||
forumCatBOOKS: "LIBROS",
|
||||
forumCatFILMS: "PELÍCULAS",
|
||||
forumCatPHILOSOPHY: "FILOSOFÍA",
|
||||
forumCatSOCIETY: "SOCIEDAD",
|
||||
forumCatPRIVACY: "PRIVACIDAD",
|
||||
forumCatCYBERWARFARE: "CIBERGUERRA",
|
||||
forumCatSURVIVALISM: "SUPERVIVENCIA",
|
||||
forumTitleLabel: "Título",
|
||||
forumTitlePlaceholder: "Título del foro...",
|
||||
forumCreateButton: "Crear foro",
|
||||
|
|
@ -1770,8 +1779,6 @@ module.exports = {
|
|||
feedTitle: "Feed",
|
||||
feedDetailTitle: "Feed",
|
||||
feedOpenDiscussion: "Abrir discusión",
|
||||
feedPostComment: "Publicar comentario",
|
||||
noComments: "Aún no hay comentarios",
|
||||
createFeedTitle: "Crear Feed",
|
||||
createFeedButton: "¡Enviar feed!",
|
||||
feedPlaceholder: "¿Qué está pasando? (máximo 280 caracteres)",
|
||||
|
|
@ -1982,42 +1989,6 @@ module.exports = {
|
|||
reportsUnderReviewSectionTitle: "Informes en Revisión",
|
||||
reportsResolvedSectionTitle: "Informes Resueltos",
|
||||
reportsInvalidSectionTitle: "Informes Inválidos",
|
||||
reportsBugTemplateTitle: 'Datos para reproducir (Bugs)',
|
||||
reportsFeatureTemplateTitle: 'Detalles (Features)',
|
||||
reportsAbuseTemplateTitle: 'Detalles (Abuso)',
|
||||
reportsContentTemplateTitle: 'Detalles (Problemas de Contenido)',
|
||||
reportsStepsToReproduceLabel: 'Pasos para reproducir',
|
||||
reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...',
|
||||
reportsExpectedBehaviorLabel: 'Resultado esperado',
|
||||
reportsExpectedBehaviorPlaceholder: '¿Qué debería pasar?',
|
||||
reportsActualBehaviorLabel: 'Resultado actual',
|
||||
reportsActualBehaviorPlaceholder: '¿Qué pasa realmente?',
|
||||
reportsEnvironmentLabel: 'Entorno',
|
||||
reportsEnvironmentPlaceholder: 'Versión, dispositivo, OS, navegador, configuración, logs, etc.',
|
||||
reportsReproduceRateLabel: 'Frecuencia',
|
||||
reportsReproduceRateAlways: 'Siempre',
|
||||
reportsReproduceRateOften: 'A menudo',
|
||||
reportsReproduceRateSometimes: 'A veces',
|
||||
reportsReproduceRateRarely: 'Rara vez',
|
||||
reportsReproduceRateUnable: 'No se puede reproducir',
|
||||
reportsReproduceRateUnknown: 'No especificado',
|
||||
reportsProblemStatementLabel: 'Problema / necesidad',
|
||||
reportsProblemStatementPlaceholder: '¿Qué problema resuelve esta feature?',
|
||||
reportsUserStoryLabel: 'Historia de habitante',
|
||||
reportsUserStoryPlaceholder: 'Como <habitante>, quiero <acción>, para <beneficio>.',
|
||||
reportsAcceptanceCriteriaLabel: 'Criterios de aceptación',
|
||||
reportsAcceptanceCriteriaPlaceholder: '- Dado...\n- Cuando...\n- Entonces...',
|
||||
reportsWhatHappenedLabel: '¿Qué ocurrió?',
|
||||
reportsWhatHappenedPlaceholder: 'Describe el incidente con contexto y fechas aproximadas.',
|
||||
reportsReportedUserLabel: 'Habitante / entidad reportada',
|
||||
reportsReportedUserPlaceholder: '@habitante, feed, ID, enlace, etc.',
|
||||
reportsEvidenceLinksLabel: 'Evidencia / enlaces',
|
||||
reportsEvidenceLinksPlaceholder: 'Pega enlaces, IDs de mensajes, capturas (si aplica), etc.',
|
||||
reportsContentLocationLabel: 'Dónde está el contenido',
|
||||
reportsContentLocationPlaceholder: 'Enlace, ID, canal, hilo, autoría, etc.',
|
||||
reportsWhyInappropriatePlaceholder: 'Explica el motivo y el impacto.',
|
||||
reportsRequestedActionLabel: 'Acción solicitada',
|
||||
reportsRequestedActionPlaceholder: 'Eliminar, ocultar, marcar, advertir, etc.',
|
||||
tribesTitle: "Tribus",
|
||||
tribeFilterAll: "TODAS",
|
||||
tribeFilterRecent: "RECIENTES",
|
||||
|
|
@ -2261,6 +2232,7 @@ module.exports = {
|
|||
voteWellResearched: "Bien investigado",
|
||||
voteNeedsSources: "Necesita fuentes",
|
||||
voteWrong: "Incorrecto",
|
||||
voteOutdated: "Desactualizado",
|
||||
voteLowQuality: "Baja calidad",
|
||||
voteLove: "Amor",
|
||||
voteClear: "Claro",
|
||||
|
|
@ -2505,7 +2477,6 @@ module.exports = {
|
|||
shopNoProducts: "Aún no hay productos.",
|
||||
shopOutOfStock: "Agotado",
|
||||
shopBuy: "Comprar",
|
||||
shopBuyEncryptedNote: "Tus datos de entrega se envían cifrados; solo la dueña de la tienda puede verlos.",
|
||||
shopBuyDeliveryAddress: "Dirección de entrega",
|
||||
shopBuyContact: "Contacto",
|
||||
shopBuyContactPlaceholder: "correo, teléfono, etc.",
|
||||
|
|
@ -3022,6 +2993,16 @@ module.exports = {
|
|||
industryDescriptionLabel: "Descripción",
|
||||
industryDescriptionPlaceholder: "¿Qué produce esta fábrica?",
|
||||
industrySector: "Sector",
|
||||
industrySector_software: "Software",
|
||||
industrySector_hardware: "Hardware",
|
||||
industrySector_agriculture: "Agricultura",
|
||||
industrySector_textile: "Textil",
|
||||
industrySector_energy: "Energía",
|
||||
industrySector_food: "Alimentación",
|
||||
industrySector_construction: "Construcción",
|
||||
industrySector_media: "Medios",
|
||||
industrySector_services: "Servicios",
|
||||
industrySector_other: "Otros",
|
||||
industryMembershipPolicy: "Política de membresía",
|
||||
industryQuorum: "Quórum",
|
||||
industryMajority: "Mayoría",
|
||||
|
|
@ -3223,6 +3204,18 @@ module.exports = {
|
|||
modulesFavoritesDescription: "Módulo para gestionar tu contenido favorito.",
|
||||
fileTooLargeTitle: "Archivo demasiado grande",
|
||||
fileTooLargeMessage: "El archivo supera el tamaño máximo permitido (75 MB). Por favor, selecciona un archivo más pequeño.",
|
||||
fileWrongType: "Este fichero no es del tipo adecuado para esta sección.",
|
||||
mediaLockedAfterOpinions: "Ya tiene opiniones: puedes editar el resto, pero no cambiar lo que se ha valorado.",
|
||||
pdfPrevPage: "Página anterior",
|
||||
pdfNextPage: "Página siguiente",
|
||||
pdfPage: "Página",
|
||||
pdfZoomIn: "Acercar",
|
||||
pdfZoomOut: "Alejar",
|
||||
pdfFullscreen: "Pantalla completa",
|
||||
pdfClose: "Cerrar",
|
||||
pdfRotate: "Girar",
|
||||
contentNotAvailableYet: "Este contenido aún no está disponible.",
|
||||
pdfRetry: "Reintentar",
|
||||
goBack: "Volver",
|
||||
errorDismiss: "OK",
|
||||
errorPageTitle: "Ha ocurrido un error",
|
||||
|
|
@ -3267,7 +3260,6 @@ module.exports = {
|
|||
invitesFollow: "Seguir",
|
||||
invitesUnfollowedInvites: "No federadas",
|
||||
invitesNoUnfollowed: "No hay pubs no federados.",
|
||||
reportsWhyInappropriateLabel: "¿Por qué es inapropiado?",
|
||||
blockchainContentDeleted: "Este contenido ha sido eliminado",
|
||||
visitContent: "Visitar Contenido",
|
||||
favoriteAdd: "Fijar en Favoritos",
|
||||
|
|
@ -3579,6 +3571,8 @@ module.exports = {
|
|||
podcastTagsPlaceholder: "Introduce etiquetas separadas por comas",
|
||||
podcastFilterAll: "TODOS",
|
||||
podcastFilterMine: "MÍOS",
|
||||
podcastFilterFavorites: "FAVORITOS",
|
||||
podcastEpisodeNumberLabel: "Número de episodio",
|
||||
podcastFilterRecent: "RECIENTES",
|
||||
podcastFilterTop: "TOP",
|
||||
podcastFilterViewers: "OYENTES",
|
||||
|
|
@ -3797,7 +3791,6 @@ module.exports = {
|
|||
wikiBackToPage: "Volver a la página",
|
||||
wikiLinkedChip: "Enlazada",
|
||||
wikiRestoredSummary: "Versión restaurada",
|
||||
wikiExportPdf: "Generar página",
|
||||
wikiPreview: "Vista previa",
|
||||
tribeSectionWiki: "WIKI",
|
||||
tribeWikiCreate: "Crear wiki",
|
||||
|
|
@ -3865,6 +3858,9 @@ module.exports = {
|
|||
roomRecStop: "Parar grabación",
|
||||
roomStreamFile: "Emitir",
|
||||
roomStreamStop: "Parar",
|
||||
roomScreen: "Pantalla",
|
||||
roomScreenSharing: "Compartiendo pantalla",
|
||||
roomScreenWarning: "Todos en la sala verán tu pantalla completa hasta que pares.",
|
||||
roomStreaming: "Emitiendo",
|
||||
liveFullscreen: "Pantalla completa",
|
||||
roomStreamPickTitle: "Elige un vídeo",
|
||||
|
|
@ -3922,6 +3918,7 @@ module.exports = {
|
|||
inviteAlreadyMember: "Ya formas parte. Puedes abrirlo desde su módulo.",
|
||||
actionFailed: "No se ha podido hacer. Inténtalo de nuevo.",
|
||||
publishTooShort: "Tu publicación es demasiado corta.",
|
||||
publishEmpty: "Escribe algo o adjunta un fichero.",
|
||||
walletSendFailed: "No se ha podido enviar el pago: {reason}",
|
||||
pixeliaCoordsError: "Esas coordenadas están fuera de la cuadrícula.",
|
||||
courtsErrTitle: "Hace falta un título.",
|
||||
|
|
@ -4434,6 +4431,9 @@ module.exports = {
|
|||
torrentTagsLabel: "Etiquetas",
|
||||
torrentTagsPlaceholder: "Introduce etiquetas separadas por comas",
|
||||
torrentSizeLabel: "Tamaño",
|
||||
torrentInfoHash: "Infohash",
|
||||
torrentFilesLabel: "Ficheros",
|
||||
torrentMagnet: "MAGNET",
|
||||
torrentFromContent: "Generar torrent",
|
||||
torrentFromContentMissing: "El fichero aún no está disponible en este nodo. Inténtalo en un rato.",
|
||||
torrentDownloads: "Descargas",
|
||||
|
|
@ -4519,9 +4519,6 @@ module.exports = {
|
|||
inhabitantsPendingAccept: "Aceptar",
|
||||
inhabitantsPendingReject: "Rechazar",
|
||||
tribeSectionGovernance: "GOBIERNO",
|
||||
tribePadInviteRequired: "No tienes acceso al pad. Pide una invitación para acceder al contenido.",
|
||||
tribeChatInviteRequired: "No tienes acceso al chat. Pide una invitación para acceder al contenido.",
|
||||
tribeGovernanceDesc: "Gobierno interno de esta tribu.",
|
||||
tribeGovernanceNoGov: "Sin gobierno activo",
|
||||
tribeGovernanceNoGovDesc: "Esta tribu aún no tiene gobierno. Propón candidaturas para iniciar el proceso.",
|
||||
tribeGovCardTitle: "Gobierno Actual",
|
||||
|
|
@ -4578,7 +4575,6 @@ module.exports = {
|
|||
logsFilterMonth: "ÚLTIMO MES",
|
||||
logsFilterWeek: "ÚLTIMA SEMANA",
|
||||
logsFilterToday: "HOY",
|
||||
logsComments: "Comentarios",
|
||||
logsAuthorEntries: "Entradas",
|
||||
logsCreate: "Crear entrada",
|
||||
logsExport: "Exportar bitácora",
|
||||
|
|
@ -4600,7 +4596,7 @@ module.exports = {
|
|||
larpNoHousesMatch: "Ninguna casa coincide con tu búsqueda.",
|
||||
larpSearchPlaceholder: "Buscar en L.A.R.P....",
|
||||
larpAllHouses: "Casas:",
|
||||
larpFilterRuling: "RIGE",
|
||||
larpFilterRuling: "GOBERNANDO",
|
||||
larpFilterHouses: "CASAS",
|
||||
larpFilterRules: "FAQ",
|
||||
larpRulesTitle: "FAQ del L.A.R.P.",
|
||||
|
|
@ -4657,6 +4653,8 @@ module.exports = {
|
|||
larpWillTestTitle: "Test WILL",
|
||||
settingsLanDesc: "Anuncia periódicamente este peer a otras instancias de Oasis en la misma red local.",
|
||||
settingsLanEnable: "Activar difusión LAN",
|
||||
settingsConnectionsTitle: "Conexiones",
|
||||
settingsConnectionsDesc: "Cuántos peers mantiene conectados este nodo a la vez.",
|
||||
settingsLanTitle: "Difusión LAN",
|
||||
settingsReplicationTitle: "Replicación",
|
||||
settingsReplicationDesc: "Configura el número de saltos que tu nodo sigue desde tu propio feed al replicar contenido.",
|
||||
|
|
@ -4790,7 +4788,7 @@ module.exports = {
|
|||
larpGoToHouse: "Ir a tu casa",
|
||||
larpBackToAcademia: "Volver a ACADEMIA",
|
||||
larpBadgeYou: "Tú",
|
||||
larpBadgeRuling: "Rige",
|
||||
larpBadgeRuling: "Gobernando",
|
||||
modulesLarpLabel: "L.A.R.P.",
|
||||
modulesLarpDescription: "Módulo para la capa de juego de rol en vivo.",
|
||||
larpProfileQ1: "Cuando te enfrentas a un problema complejo, ¿qué haces primero?",
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ module.exports = {
|
|||
graphosBackToMine: "← Nire sarea",
|
||||
graphosShowing: "Erakusten",
|
||||
graphosYou: "Zu",
|
||||
graphosTotalNodes: "Nodo guztiak",
|
||||
graphosTotalNodes: "Guztira",
|
||||
onlinePlural: "Linean",
|
||||
mentions: "Aipamenak",
|
||||
mentionsDescription: [
|
||||
strong("Zu @aipatzen zaituzten mezu guztiak"),
|
||||
|
|
@ -414,6 +415,7 @@ module.exports = {
|
|||
menuMedia: "Liburutegia",
|
||||
menuTools: "Tresnak",
|
||||
comment: "Iruzkindu",
|
||||
typeComment: "Iruzkina",
|
||||
subtopic: "Azpi-gaia",
|
||||
json: "JSON",
|
||||
unfollow: "Laguntza eten",
|
||||
|
|
@ -443,9 +445,6 @@ module.exports = {
|
|||
publishCustomDescription: [
|
||||
"GOGORATU: Blockchain teknologia dela eta, bidalketa argitaratu ezkero, ezin izango da aldatu ezta ezabatu.",
|
||||
],
|
||||
commentWarning: [
|
||||
"GOGORATU: Blockchain teknologia dela eta, bidalketa argitaratu ezkero, ezin izango da aldatu ezta ezabatu.",
|
||||
],
|
||||
commentLabel: ({ publicOrPrivate, markdownUrl }) => [
|
||||
],
|
||||
publishLabel: ({ markdownUrl, linkTarget }) => [
|
||||
|
|
@ -724,10 +723,6 @@ module.exports = {
|
|||
votesQuestionLabel:"Galdera",
|
||||
voteStatusOpen:"IREKITA",
|
||||
voteStatusClosed:"ITXITA",
|
||||
commentDescription: ({ parentUrl }) => [
|
||||
" noiz komentatuta ",
|
||||
a({ href: parentUrl }, " haria"),
|
||||
],
|
||||
commentTitle: ({ authorName }) => [`Komentatu @${authorName}-(r)en bidalketa`],
|
||||
subtopicDescription: ({ parentUrl }) => [
|
||||
" azpigaia hemendik sortuta ",
|
||||
|
|
@ -862,6 +857,8 @@ module.exports = {
|
|||
bankRulesEligibility5: "PUBak hurrengo zikloan ordaintzen du eta berretsi dezakezun OBU transferentzia bat argitaratzen du.",
|
||||
bankRulesEligibility6: "Helbidea falta bada, feeda gazteegia bada, hilabeteari uko egin badiozu edo PUBak fondorik ez badu, eskaera saltatu eta PUBean erregistratzen da.",
|
||||
copyLinkTitle: "Kopiatu esteka",
|
||||
cardMenuTitle: "Ekintza gehiago",
|
||||
shareContent: "Partekatu",
|
||||
linkBoxOasis: "Oasis",
|
||||
linkBoxClearnet: "Clearnet",
|
||||
bankRefuseUBI: "OBUari uko egin",
|
||||
|
|
@ -895,7 +892,7 @@ module.exports = {
|
|||
walletNotConfigured: "Zure ECOin zorroa ez dago oraindik konfiguratuta.",
|
||||
walletSettingsDescription: "Integratu Oasis zure ECOin zorroarekin.",
|
||||
walletSettingsMobileDescription: "Lotu Oasis gailu honetako ECOin Wallet aplikazioarekin.",
|
||||
walletDownloadEcoin: "Deskargatu ECOin!",
|
||||
walletDownloadEcoin: "Deskargatu ECOin",
|
||||
walletRpcUrl: "RPC URLa",
|
||||
walletStatusMessages: {
|
||||
invalid_amount: "Zenbatekoa oker",
|
||||
|
|
@ -948,6 +945,7 @@ module.exports = {
|
|||
bookmarkUpdateButton: "Eguneratu",
|
||||
bookmarkAddFavoriteButton: "Gogokoetara gehitu",
|
||||
bookmarkRemoveFavoriteButton: "Gogokoetatik kendu",
|
||||
bookmarkTitleLabel: "Izenburua",
|
||||
bookmarkUrlLabel: "Esteka",
|
||||
bookmarkUrlPlaceholder: "https://solarnethub.com",
|
||||
bookmarkDescriptionLabel: "Deskribapena",
|
||||
|
|
@ -1092,9 +1090,9 @@ module.exports = {
|
|||
allCVs: "CVak",
|
||||
discoverPeople: "Aurkitu bizilagunak zure sarean.",
|
||||
searchInhabitantsPlaceholder: "Bilatu hemen: Bizilagunak...",
|
||||
filterLocation: "IRAGAZI bizilagunak KOKAPENAREN ARABERA …",
|
||||
filterLanguage: "IRAGAZI bizilagunak BY HIZKUNTZAREN ARABERA …",
|
||||
filterSkills: "IRAGAZI bizilagunak BY TREBETASUNEN ARABERA …",
|
||||
filterLocation: "Bilatu kokapenaren arabera…",
|
||||
filterLanguage: "Bilatu hizkuntzaren arabera…",
|
||||
filterSkills: "Bilatu trebetasunen arabera…",
|
||||
cvSkillsCount: "Trebetasunak",
|
||||
applyFilters: "Ezarri Iragazkiak",
|
||||
locationLabel: "Kokapena",
|
||||
|
|
@ -1110,6 +1108,7 @@ module.exports = {
|
|||
topactivitySectionTitle: "Top Jarduera",
|
||||
"TOP INACTIVITYButton": "INAKTIBOENAK",
|
||||
topinactivitySectionTitle: "Biztanle gutxien aktiboak",
|
||||
contactsSectionTitle: "Kontaktuak",
|
||||
blockedSectionTitle: "Blokeatuta",
|
||||
gallerySectionTitle: "GALERIA",
|
||||
topSectionTitle: "TOP",
|
||||
|
|
@ -1599,9 +1598,7 @@ module.exports = {
|
|||
voteUnknownDate: "Data Ezezaguna",
|
||||
voteDetailSectionTitle: 'Bozketa xehetasunak',
|
||||
voteCommentsLabel: 'Iruzkinak',
|
||||
voteCommentsForumButton: 'Eztabaida ireki',
|
||||
voteCommentsSectionTitle: 'Eztabaida irekia',
|
||||
voteNoCommentsYet: 'Oraindik ez dago iruzkinik. Izan zaitez lehen erantzuten.',
|
||||
voteNoCommentsYet: 'Oraindik ez dago iruzkinik.',
|
||||
commentsGoToLast: "Joan azken iruzkinera",
|
||||
voteNewCommentPlaceholder: 'Idatzi hemen zure iruzkina…',
|
||||
voteNewCommentButton: 'Bidali iruzkina',
|
||||
|
|
@ -1728,7 +1725,6 @@ module.exports = {
|
|||
pollVoteButton: "Bozkatu",
|
||||
pollChangeVote: "Aldatu Botoa",
|
||||
pollVoters: "Bozkatzaileak",
|
||||
pollComments: "Iruzkinak",
|
||||
pollStatusLabel: "Egoera",
|
||||
pollStatusOpen: "IREKIA",
|
||||
pollStatusClosed: "ITXIA",
|
||||
|
|
@ -1750,9 +1746,22 @@ module.exports = {
|
|||
blogAllowComments: "Baimendu iruzkinak",
|
||||
blogCommentsClosed: "Egileak blog honen iruzkinak itxi ditu.",
|
||||
blogNoItems: "Ez da blogik aurkitu.",
|
||||
viewBlog: "Ikusi Bloga",
|
||||
forumTitle: "Foroak",
|
||||
forumCategoryLabel: "Kategoria",
|
||||
forumCatGENERAL: "OROKORRA",
|
||||
forumCatPOLITICS: "POLITIKA",
|
||||
forumCatTECH: "TEKNOLOGIA",
|
||||
forumCatSCIENCE: "ZIENTZIA",
|
||||
forumCatMUSIC: "MUSIKA",
|
||||
forumCatART: "ARTEA",
|
||||
forumCatGAMING: "JOKOAK",
|
||||
forumCatBOOKS: "LIBURUAK",
|
||||
forumCatFILMS: "FILMAK",
|
||||
forumCatPHILOSOPHY: "FILOSOFIA",
|
||||
forumCatSOCIETY: "GIZARTEA",
|
||||
forumCatPRIVACY: "PRIBATUTASUNA",
|
||||
forumCatCYBERWARFARE: "ZIBERGERRA",
|
||||
forumCatSURVIVALISM: "BIZIRAUPENA",
|
||||
forumTitleLabel: "Izenburua",
|
||||
forumTitlePlaceholder: "Foroaren izenburua...",
|
||||
forumCreateButton: "Sortu foroa",
|
||||
|
|
@ -1811,8 +1820,6 @@ module.exports = {
|
|||
feedTitle: "Jarioa",
|
||||
feedDetailTitle: "Feed",
|
||||
feedOpenDiscussion: "Eztabaida ireki",
|
||||
feedPostComment: "Iruzkina argitaratu",
|
||||
noComments: "Oraindik iruzkinik ez",
|
||||
createFeedTitle: "Sortu Jarioa",
|
||||
createFeedButton: "Bidali Jarioa!",
|
||||
feedPlaceholder: "Zer berri? (max 280 characters)",
|
||||
|
|
@ -1986,42 +1993,6 @@ module.exports = {
|
|||
reportsUnderReviewSectionTitle: "Txostenak Berrikuspenean",
|
||||
reportsResolvedSectionTitle: "Zuzendutako Txostenak",
|
||||
reportsInvalidSectionTitle: "Txosten Okerrak",
|
||||
reportsBugTemplateTitle: 'Erreprodukzio xehetasunak (Bug-ak)',
|
||||
reportsFeatureTemplateTitle: 'Xehetasunak (Ezaugarriak)',
|
||||
reportsAbuseTemplateTitle: 'Xehetasunak (Abusua)',
|
||||
reportsContentTemplateTitle: 'Xehetasunak (Arazoak Edukiarekin)',
|
||||
reportsStepsToReproduceLabel: 'Erreproduzitzeko urratsak',
|
||||
reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...',
|
||||
reportsExpectedBehaviorLabel: 'Espero den emaitza',
|
||||
reportsExpectedBehaviorPlaceholder: 'Zer gertatu beharko litzateke?',
|
||||
reportsActualBehaviorLabel: 'Benetako emaitza',
|
||||
reportsActualBehaviorPlaceholder: 'Zer gertatzen da benetan?',
|
||||
reportsEnvironmentLabel: 'Ingurunea',
|
||||
reportsEnvironmentPlaceholder: 'Bertsioa, gailua, OS, nabigatzailea, ezarpenak, log-ak, etab.',
|
||||
reportsReproduceRateLabel: 'Maiztasuna',
|
||||
reportsReproduceRateAlways: 'Beti',
|
||||
reportsReproduceRateOften: 'Askotan',
|
||||
reportsReproduceRateSometimes: 'Batzuetan',
|
||||
reportsReproduceRateRarely: 'Gutxitan',
|
||||
reportsReproduceRateUnable: 'Ezin da erreproduzitu',
|
||||
reportsReproduceRateUnknown: 'Zehaztu gabe',
|
||||
reportsProblemStatementLabel: 'Arazoa / beharra',
|
||||
reportsProblemStatementPlaceholder: 'Zein arazo konpontzen du ezaugarri honek?',
|
||||
reportsUserStoryLabel: 'User story',
|
||||
reportsUserStoryPlaceholder: '<erabiltzaile> naizenez, <ekintza> nahi dut, <onura> lortzeko.',
|
||||
reportsAcceptanceCriteriaLabel: 'Onarpen-irizpideak',
|
||||
reportsAcceptanceCriteriaPlaceholder: '- Emanda...\n- Noiz...\n- Orduan...',
|
||||
reportsWhatHappenedLabel: 'Zer gertatu da?',
|
||||
reportsWhatHappenedPlaceholder: 'Deskribatu gertakaria testuinguruarekin eta gutxi gorabeherako datekin.',
|
||||
reportsReportedUserLabel: 'Salatutako erabiltzailea / entitatea',
|
||||
reportsReportedUserPlaceholder: '@erabiltzailea, feed, ID, esteka, etab.',
|
||||
reportsEvidenceLinksLabel: 'Frogak / estekak',
|
||||
reportsEvidenceLinksPlaceholder: 'Itsatsi estekak, mezu ID-ak, pantaila-argazkiak (bada), etab.',
|
||||
reportsContentLocationLabel: 'Non dago edukia',
|
||||
reportsContentLocationPlaceholder: 'Esteka, ID, kanala, haria, egilea, etab.',
|
||||
reportsWhyInappropriatePlaceholder: 'Azaldu arrazoia eta eragina.',
|
||||
reportsRequestedActionLabel: 'Eskatutako ekintza',
|
||||
reportsRequestedActionPlaceholder: 'Kendu, ezkutatu, etiketatu, ohartarazi, etab.',
|
||||
tribesTitle: "Tribuak",
|
||||
tribeFilterAll: "GUZTIAK",
|
||||
tribeFilterRecent: "BERRIAK",
|
||||
|
|
@ -2265,6 +2236,7 @@ module.exports = {
|
|||
voteWellResearched: "Ondo ikertua",
|
||||
voteNeedsSources: "Iturririk behar du",
|
||||
voteWrong: "Okerrak",
|
||||
voteOutdated: "Zaharkitua",
|
||||
voteLowQuality: "Behera kalitatea",
|
||||
voteLove: "Maitasuna",
|
||||
voteClear: "Argi",
|
||||
|
|
@ -2510,7 +2482,6 @@ module.exports = {
|
|||
shopNoProducts: "Oraindik produkturik ez.",
|
||||
shopOutOfStock: "Agortuta",
|
||||
shopBuy: "Erosi",
|
||||
shopBuyEncryptedNote: "Zure entrega datuak zifratuta bidaltzen dira, dendaren jabeak soilik ikus ditzake.",
|
||||
shopBuyDeliveryAddress: "Entrega helbidea",
|
||||
shopBuyContact: "Kontaktua",
|
||||
shopBuyContactPlaceholder: "e-posta, telefonoa, etab.",
|
||||
|
|
@ -3026,6 +2997,16 @@ module.exports = {
|
|||
industryDescriptionLabel: "Deskribapena",
|
||||
industryDescriptionPlaceholder: "Zer ekoizten du fabrika honek?",
|
||||
industrySector: "Sektorea",
|
||||
industrySector_software: "Softwarea",
|
||||
industrySector_hardware: "Hardwarea",
|
||||
industrySector_agriculture: "Nekazaritza",
|
||||
industrySector_textile: "Ehungintza",
|
||||
industrySector_energy: "Energia",
|
||||
industrySector_food: "Elikadura",
|
||||
industrySector_construction: "Eraikuntza",
|
||||
industrySector_media: "Hedabideak",
|
||||
industrySector_services: "Zerbitzuak",
|
||||
industrySector_other: "Besteak",
|
||||
industryMembershipPolicy: "Kidetza politika",
|
||||
industryQuorum: "Quoruma",
|
||||
industryMajority: "Gehiengoa",
|
||||
|
|
@ -3227,6 +3208,18 @@ module.exports = {
|
|||
modulesFavoritesDescription: "Zure gogoko edukia kudeatzeko modulua.",
|
||||
fileTooLargeTitle: "Fitxategia handiegia",
|
||||
fileTooLargeMessage: "Fitxategiak onartutako gehienezko tamaina gainditzen du (75 MB). Mesedez, hautatu fitxategi txikiago bat.",
|
||||
fileWrongType: "Fitxategi hau ez da atal honetarako mota egokikoa.",
|
||||
mediaLockedAfterOpinions: "Iritziak ditu dagoeneko: gainerakoa edita dezakezu, baina ez baloratutakoa aldatu.",
|
||||
pdfPrevPage: "Aurreko orria",
|
||||
pdfNextPage: "Hurrengo orria",
|
||||
pdfPage: "Orria",
|
||||
pdfZoomIn: "Hurbildu",
|
||||
pdfZoomOut: "Urrundu",
|
||||
pdfFullscreen: "Pantaila osoa",
|
||||
pdfClose: "Itxi",
|
||||
pdfRotate: "Biratu",
|
||||
contentNotAvailableYet: "Eduki hau ez dago oraindik eskuragarri.",
|
||||
pdfRetry: "Saiatu berriro",
|
||||
goBack: "Itzuli",
|
||||
errorDismiss: "Ados",
|
||||
errorPageTitle: "Zerbait gaizki joan da",
|
||||
|
|
@ -3268,7 +3261,6 @@ module.exports = {
|
|||
statsCarbonTombstone: "Tombstoning-aren aztarna",
|
||||
uploadMedia: "Multimedia igo (max: 75MB)",
|
||||
|
||||
reportsWhyInappropriateLabel: "Zergatik da desegokia?",
|
||||
visitContent: "Bisitatu Edukia",
|
||||
favoriteAdd: "Ainguratu Gogokoetan",
|
||||
pmContentTooltip: "Bidali Mezu Pribatua",
|
||||
|
|
@ -3579,6 +3571,8 @@ module.exports = {
|
|||
podcastTagsPlaceholder: "Idatzi etiketak komaz bereizita",
|
||||
podcastFilterAll: "GUZTIAK",
|
||||
podcastFilterMine: "NIREAK",
|
||||
podcastFilterFavorites: "GOGOKOAK",
|
||||
podcastEpisodeNumberLabel: "Atal zenbakia",
|
||||
podcastFilterRecent: "AZKENAK",
|
||||
podcastFilterTop: "TOP",
|
||||
podcastFilterViewers: "ENTZULEAK",
|
||||
|
|
@ -3797,7 +3791,6 @@ module.exports = {
|
|||
wikiBackToPage: "Itzuli orrira",
|
||||
wikiLinkedChip: "Lotuta",
|
||||
wikiRestoredSummary: "Berreskuratutako bertsioa",
|
||||
wikiExportPdf: "Orria sortu",
|
||||
wikiPreview: "Aurrebista",
|
||||
tribeSectionWiki: "WIKIA",
|
||||
tribeWikiCreate: "Wikia sortu",
|
||||
|
|
@ -3865,6 +3858,9 @@ module.exports = {
|
|||
roomRecStop: "Grabazioa gelditu",
|
||||
roomStreamFile: "Igorri",
|
||||
roomStreamStop: "Gelditu",
|
||||
roomScreen: "Pantaila",
|
||||
roomScreenSharing: "Pantaila partekatzen",
|
||||
roomScreenWarning: "Gelako guztiek zure pantaila osoa ikusiko dute gelditu arte.",
|
||||
roomStreaming: "Igortzen",
|
||||
liveFullscreen: "Pantaila osoa",
|
||||
roomStreamPickTitle: "Aukeratu bideo bat",
|
||||
|
|
@ -3922,6 +3918,7 @@ module.exports = {
|
|||
inviteAlreadyMember: "Dagoeneko bertan zaude. Bere modulutik ireki dezakezu.",
|
||||
actionFailed: "Ezin izan da egin. Saiatu berriro.",
|
||||
publishTooShort: "Zure argitalpena laburregia da.",
|
||||
publishEmpty: "Idatzi zerbait edo erantsi fitxategi bat.",
|
||||
walletSendFailed: "Ezin izan da ordainketa bidali: {reason}",
|
||||
pixeliaCoordsError: "Koordenatu horiek saretik kanpo daude.",
|
||||
courtsErrTitle: "Izenburu bat behar da.",
|
||||
|
|
@ -4433,6 +4430,9 @@ module.exports = {
|
|||
torrentTagsLabel: "Etiketak",
|
||||
torrentTagsPlaceholder: "Idatzi etiketak komaz bereizita",
|
||||
torrentSizeLabel: "Tamaina",
|
||||
torrentInfoHash: "Infohash",
|
||||
torrentFilesLabel: "Fitxategiak",
|
||||
torrentMagnet: "MAGNET",
|
||||
torrentFromContent: "Torrenta sortu",
|
||||
torrentFromContentMissing: "Fitxategia oraindik ez dago nodo honetan eskuragarri. Saiatu geroago.",
|
||||
torrentDownloads: "Deskargak",
|
||||
|
|
@ -4518,9 +4518,6 @@ module.exports = {
|
|||
inhabitantsPendingAccept: "Onartu",
|
||||
inhabitantsPendingReject: "Ukatu",
|
||||
tribeSectionGovernance: "GOBERNANTZA",
|
||||
tribePadInviteRequired: "Ez duzu pada atzipena. Eskatu gonbidapena.",
|
||||
tribeChatInviteRequired: "Ez duzu txata atzipena. Eskatu gonbidapena.",
|
||||
tribeGovernanceDesc: "Tribu honen barne gobernantza.",
|
||||
tribeGovernanceNoGov: "Ez dago gobernu aktiborik",
|
||||
tribeGovernanceNoGovDesc: "Tribu honek ez du oraindik gobernurik aukeratu.",
|
||||
tribeGovCardTitle: "Egungo Gobernua",
|
||||
|
|
@ -4577,7 +4574,6 @@ module.exports = {
|
|||
logsFilterMonth: "AZKEN HILABETEA",
|
||||
logsFilterWeek: "AZKEN ASTEA",
|
||||
logsFilterToday: "GAUR",
|
||||
logsComments: "Iruzkinak",
|
||||
logsAuthorEntries: "Sarrerak",
|
||||
logsCreate: "Sarrera sortu",
|
||||
logsExport: "Egunkaria esportatu",
|
||||
|
|
@ -4736,6 +4732,8 @@ module.exports = {
|
|||
larpWillTestTitle: "WILL proba",
|
||||
settingsLanDesc: "Aldian behin iragarri pareko hau sare lokal bereko beste Oasis instantziei.",
|
||||
settingsLanEnable: "Gaitu LAN igorpena",
|
||||
settingsConnectionsTitle: "Konexioak",
|
||||
settingsConnectionsDesc: "Nodo honek aldi berean konektatuta mantentzen dituen peer kopurua.",
|
||||
settingsLanTitle: "LAN igorpena",
|
||||
settingsReplicationTitle: "Erreplikazioa",
|
||||
settingsReplicationDesc: "Konfiguratu zenbat jauzi jarraitu behar dituen zure kideak zure jariotik abiatuz edukia erreplikatzean.",
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ module.exports = {
|
|||
graphosBackToMine: "← Mon réseau",
|
||||
graphosShowing: "Affichage",
|
||||
graphosYou: "Toi",
|
||||
graphosTotalNodes: "Nœuds totaux",
|
||||
graphosTotalNodes: "Totaux",
|
||||
onlinePlural: "En ligne",
|
||||
mentions: "Mentions",
|
||||
mentionsDescription: [
|
||||
strong("Chaque message qui vous @mentionne"),
|
||||
|
|
@ -412,6 +413,7 @@ module.exports = {
|
|||
menuMedia: "Bibliothèque",
|
||||
menuTools: "Outils",
|
||||
comment: "Commenter",
|
||||
typeComment: "Commentaire",
|
||||
subtopic: "Sous-sujet",
|
||||
json: "JSON",
|
||||
unfollow: "Ne plus soutenir",
|
||||
|
|
@ -441,9 +443,6 @@ module.exports = {
|
|||
publishCustomDescription: [
|
||||
"RAPPELEZ-VOUS : En raison de la technologie blockchain, une fois que vous avez publié quelque chose, cela ne peut pas être modifié ni supprimé. Réfléchissez bien !",
|
||||
],
|
||||
commentWarning: [
|
||||
"RAPPELEZ-VOUS : En raison de la technologie blockchain, une fois que vous avez publié quelque chose, cela ne peut pas être modifié ni supprimé. Réfléchissez bien !",
|
||||
],
|
||||
commentLabel: ({ publicOrPrivate, markdownUrl }) => [
|
||||
],
|
||||
publishLabel: ({ markdownUrl, linkTarget }) => [
|
||||
|
|
@ -718,10 +717,6 @@ module.exports = {
|
|||
votesQuestionLabel:"Question",
|
||||
voteStatusOpen:"OUVERT",
|
||||
voteStatusClosed:"FERMÉ",
|
||||
commentDescription: ({ parentUrl }) => [
|
||||
" a commenté dans ",
|
||||
a({ href: parentUrl }, " fil"),
|
||||
],
|
||||
commentTitle: ({ authorName }) => [`A commenté sur le post de @${authorName}`],
|
||||
subtopicDescription: ({ parentUrl }) => [
|
||||
" a créé un sous-sujet depuis ",
|
||||
|
|
@ -856,6 +851,8 @@ module.exports = {
|
|||
bankRulesEligibility5: "Le PUB paie à son prochain cycle et publie un transfert RBU que vous pouvez confirmer.",
|
||||
bankRulesEligibility6: "Sans adresse, avec un feed trop jeune, un mois refusé ou un PUB sans fonds, la réclamation est ignorée et journalisée sur le PUB.",
|
||||
copyLinkTitle: "Copier le lien",
|
||||
cardMenuTitle: "Plus d'actions",
|
||||
shareContent: "Partager",
|
||||
linkBoxOasis: "Oasis",
|
||||
linkBoxClearnet: "Clearnet",
|
||||
bankRefuseUBI: "Refuser le RBU",
|
||||
|
|
@ -889,7 +886,7 @@ module.exports = {
|
|||
walletNotConfigured: "Votre portefeuille ECOin n'est pas encore configuré.",
|
||||
walletSettingsDescription: "Intégrer Oasis avec votre portefeuille ECOin.",
|
||||
walletSettingsMobileDescription: "Associez Oasis à l’application ECOin Wallet de cet appareil.",
|
||||
walletDownloadEcoin: "Télécharger ECOin !",
|
||||
walletDownloadEcoin: "Télécharger ECOin",
|
||||
walletRpcUrl: "URL du RPC",
|
||||
walletStatusMessages: {
|
||||
invalid_amount: "Montant invalide",
|
||||
|
|
@ -942,6 +939,7 @@ module.exports = {
|
|||
bookmarkUpdateButton: "Mettre à jour",
|
||||
bookmarkAddFavoriteButton: "Ajouter aux favoris",
|
||||
bookmarkRemoveFavoriteButton: "Retirer des favoris",
|
||||
bookmarkTitleLabel: "Titre",
|
||||
bookmarkUrlLabel: "Lien",
|
||||
bookmarkUrlPlaceholder: "https://solarnethub.com",
|
||||
bookmarkDescriptionLabel: "Description",
|
||||
|
|
@ -1086,9 +1084,9 @@ module.exports = {
|
|||
allCVs: "CV",
|
||||
discoverPeople: "Découvrez des habitants dans votre réseau.",
|
||||
searchInhabitantsPlaceholder: "Rechercher dans Habitants...",
|
||||
filterLocation: "FILTRER les habitants PAR LOCALISATION …",
|
||||
filterLanguage: "FILTRER les habitants PAR LANGUE …",
|
||||
filterSkills: "FILTRER les habitants PAR COMPÉTENCES …",
|
||||
filterLocation: "Rechercher par localisation…",
|
||||
filterLanguage: "Rechercher par langue…",
|
||||
filterSkills: "Rechercher par compétences…",
|
||||
cvSkillsCount: "Compétences",
|
||||
applyFilters: "Appliquer les filtres",
|
||||
locationLabel: "Localisation",
|
||||
|
|
@ -1104,6 +1102,7 @@ module.exports = {
|
|||
topactivitySectionTitle: "Top activité",
|
||||
"TOP INACTIVITYButton": "PLUS INACTIFS",
|
||||
topinactivitySectionTitle: "Habitants les moins actifs",
|
||||
contactsSectionTitle: "Contacts",
|
||||
blockedSectionTitle: "Bloqués",
|
||||
gallerySectionTitle: "GALERIE",
|
||||
topSectionTitle: "TOP",
|
||||
|
|
@ -1557,9 +1556,7 @@ module.exports = {
|
|||
voteUnknownDate: "Date inconnue",
|
||||
voteDetailSectionTitle: 'Détails du vote',
|
||||
voteCommentsLabel: 'Commentaires',
|
||||
voteCommentsForumButton: 'Ouvrir la discussion',
|
||||
voteCommentsSectionTitle: 'Discussion ouverte',
|
||||
voteNoCommentsYet: 'Il n’y a pas encore de commentaires. Soyez le premier à répondre.',
|
||||
voteNoCommentsYet: 'Il n’y a pas encore de commentaires.',
|
||||
commentsGoToLast: "Aller au dernier commentaire",
|
||||
voteNewCommentPlaceholder: 'Écrivez votre commentaire ici…',
|
||||
voteNewCommentButton: 'Publier le commentaire',
|
||||
|
|
@ -1686,7 +1683,6 @@ module.exports = {
|
|||
pollVoteButton: "Voter",
|
||||
pollChangeVote: "Changer de Vote",
|
||||
pollVoters: "Votants",
|
||||
pollComments: "Commentaires",
|
||||
pollStatusLabel: "Statut",
|
||||
pollStatusOpen: "OUVERT",
|
||||
pollStatusClosed: "FERMÉ",
|
||||
|
|
@ -1708,9 +1704,22 @@ module.exports = {
|
|||
blogAllowComments: "Autoriser les commentaires",
|
||||
blogCommentsClosed: "L'auteur a fermé les commentaires de ce blog.",
|
||||
blogNoItems: "Aucun blog trouvé.",
|
||||
viewBlog: "Voir le Blog",
|
||||
forumTitle: "Forums",
|
||||
forumCategoryLabel: "Catégorie",
|
||||
forumCatGENERAL: "GÉNÉRAL",
|
||||
forumCatPOLITICS: "POLITIQUE",
|
||||
forumCatTECH: "TECHNOLOGIE",
|
||||
forumCatSCIENCE: "SCIENCE",
|
||||
forumCatMUSIC: "MUSIQUE",
|
||||
forumCatART: "ART",
|
||||
forumCatGAMING: "JEUX",
|
||||
forumCatBOOKS: "LIVRES",
|
||||
forumCatFILMS: "FILMS",
|
||||
forumCatPHILOSOPHY: "PHILOSOPHIE",
|
||||
forumCatSOCIETY: "SOCIÉTÉ",
|
||||
forumCatPRIVACY: "VIE PRIVÉE",
|
||||
forumCatCYBERWARFARE: "CYBERGUERRE",
|
||||
forumCatSURVIVALISM: "SURVIVALISME",
|
||||
forumTitleLabel: "Titre",
|
||||
forumTitlePlaceholder: "Titre du forum...",
|
||||
forumCreateButton: "Créer un forum",
|
||||
|
|
@ -1769,8 +1778,6 @@ module.exports = {
|
|||
feedTitle: "Fil",
|
||||
feedDetailTitle: "Fil",
|
||||
feedOpenDiscussion: "Ouvrir la discussion",
|
||||
feedPostComment: "Publier un commentaire",
|
||||
noComments: "Pas encore de commentaires",
|
||||
createFeedTitle: "Créer un fil",
|
||||
createFeedButton: "Envoyer le fil !",
|
||||
feedPlaceholder: "Que se passe-t-il ? (maximum 280 caractères)",
|
||||
|
|
@ -1981,36 +1988,6 @@ module.exports = {
|
|||
reportsUnderReviewSectionTitle: "Rapports en révision",
|
||||
reportsResolvedSectionTitle: "Rapports résolus",
|
||||
reportsInvalidSectionTitle: "Rapports invalides",
|
||||
reportsBugTemplateTitle: 'Détails de reproduction (Bugs)',
|
||||
reportsFeatureTemplateTitle: 'Détails (Fonctionnalités)',
|
||||
reportsAbuseTemplateTitle: 'Détails (Abus)',
|
||||
reportsContentTemplateTitle: 'Détails (Problèmes de Contenu)',
|
||||
reportsStepsToReproduceLabel: 'Étapes pour reproduire',
|
||||
reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...',
|
||||
reportsExpectedBehaviorLabel: 'Résultat attendu',
|
||||
reportsExpectedBehaviorPlaceholder: 'Que devrait-il se passer ?',
|
||||
reportsActualBehaviorLabel: 'Résultat obtenu',
|
||||
reportsEnvironmentLabel: 'Environnement',
|
||||
reportsEnvironmentPlaceholder: 'Version, appareil, OS, navigateur, paramètres, logs, etc.',
|
||||
reportsReproduceRateLabel: 'Fréquence',
|
||||
reportsReproduceRateAlways: 'Toujours',
|
||||
reportsReproduceRateOften: 'Souvent',
|
||||
reportsReproduceRateSometimes: 'Parfois',
|
||||
reportsReproduceRateRarely: 'Rarement',
|
||||
reportsReproduceRateUnable: 'Impossible à reproduire',
|
||||
reportsReproduceRateUnknown: 'Non précisé',
|
||||
reportsProblemStatementLabel: 'Problème / besoin',
|
||||
reportsProblemStatementPlaceholder: 'Quel problème cette fonctionnalité résout-elle ?',
|
||||
reportsAcceptanceCriteriaPlaceholder: '- Étant donné...\n- Quand...\n- Alors...',
|
||||
reportsReportedUserLabel: 'Utilisateur / entité signalé(e)',
|
||||
reportsReportedUserPlaceholder: '@utilisateur, feed, ID, lien, etc.',
|
||||
reportsEvidenceLinksLabel: 'Preuves / liens',
|
||||
reportsContentLocationLabel: 'Où se trouve le contenu',
|
||||
reportsContentLocationPlaceholder: 'Lien, ID, canal, fil, auteur, etc.',
|
||||
reportsWhyInappropriateLabel: "Pourquoi est-ce inapproprié ?",
|
||||
reportsWhyInappropriatePlaceholder: 'Expliquez la raison et l’impact.',
|
||||
reportsRequestedActionLabel: 'Action demandée',
|
||||
reportsRequestedActionPlaceholder: 'Supprimer, masquer, étiqueter, avertir, etc.',
|
||||
tribesTitle: "Tribus",
|
||||
tribeFilterAll: "TOUTES",
|
||||
tribeFilterRecent: "RÉCENTES",
|
||||
|
|
@ -2254,6 +2231,7 @@ module.exports = {
|
|||
voteWellResearched: "Bien recherché",
|
||||
voteNeedsSources: "Besoin de sources",
|
||||
voteWrong: "Faux",
|
||||
voteOutdated: "Obsolète",
|
||||
voteLowQuality: "Faible qualité",
|
||||
voteLove: "Amour",
|
||||
voteClear: "Clair",
|
||||
|
|
@ -2499,7 +2477,6 @@ module.exports = {
|
|||
shopNoProducts: "Pas encore de produits.",
|
||||
shopOutOfStock: "Épuisé",
|
||||
shopBuy: "Acheter",
|
||||
shopBuyEncryptedNote: "Vos coordonnées de livraison sont chiffrées et visibles uniquement par le propriétaire de la boutique.",
|
||||
shopBuyDeliveryAddress: "Adresse de livraison",
|
||||
shopBuyContact: "Contact",
|
||||
shopBuyContactPlaceholder: "e-mail, téléphone, etc.",
|
||||
|
|
@ -3015,6 +2992,16 @@ module.exports = {
|
|||
industryDescriptionLabel: "Description",
|
||||
industryDescriptionPlaceholder: "Que produit cette usine ?",
|
||||
industrySector: "Secteur",
|
||||
industrySector_software: "Logiciel",
|
||||
industrySector_hardware: "Matériel",
|
||||
industrySector_agriculture: "Agriculture",
|
||||
industrySector_textile: "Textile",
|
||||
industrySector_energy: "Énergie",
|
||||
industrySector_food: "Alimentation",
|
||||
industrySector_construction: "Construction",
|
||||
industrySector_media: "Médias",
|
||||
industrySector_services: "Services",
|
||||
industrySector_other: "Autre",
|
||||
industryMembershipPolicy: "Politique d'adhésion",
|
||||
industryQuorum: "Quorum",
|
||||
industryMajority: "Majorité",
|
||||
|
|
@ -3216,6 +3203,18 @@ module.exports = {
|
|||
modulesFavoritesDescription: "Module pour gérer votre contenu favori.",
|
||||
fileTooLargeTitle: "Fichier trop volumineux",
|
||||
fileTooLargeMessage: "Le fichier dépasse la taille maximale autorisée (50 Mo). Veuillez sélectionner un fichier plus petit.",
|
||||
fileWrongType: "Ce fichier n'est pas du bon type pour cette section.",
|
||||
mediaLockedAfterOpinions: "Il a déjà des avis : tu peux modifier le reste, mais pas ce qui a été évalué.",
|
||||
pdfPrevPage: "Page précédente",
|
||||
pdfNextPage: "Page suivante",
|
||||
pdfPage: "Page",
|
||||
pdfZoomIn: "Zoom avant",
|
||||
pdfZoomOut: "Zoom arrière",
|
||||
pdfFullscreen: "Plein écran",
|
||||
pdfClose: "Fermer",
|
||||
pdfRotate: "Pivoter",
|
||||
contentNotAvailableYet: "Ce contenu n’est pas encore disponible.",
|
||||
pdfRetry: "Réessayer",
|
||||
goBack: "Retour",
|
||||
errorDismiss: "OK",
|
||||
errorPageTitle: "Une erreur est survenue",
|
||||
|
|
@ -3571,6 +3570,8 @@ module.exports = {
|
|||
podcastTagsPlaceholder: "Saisissez des tags séparés par des virgules",
|
||||
podcastFilterAll: "TOUS",
|
||||
podcastFilterMine: "MIENS",
|
||||
podcastFilterFavorites: "FAVORIS",
|
||||
podcastEpisodeNumberLabel: "Numéro d'épisode",
|
||||
podcastFilterRecent: "RÉCENTS",
|
||||
podcastFilterTop: "TOP",
|
||||
podcastFilterViewers: "AUDITEURS",
|
||||
|
|
@ -3789,7 +3790,6 @@ module.exports = {
|
|||
wikiBackToPage: "Retour à la page",
|
||||
wikiLinkedChip: "Liée",
|
||||
wikiRestoredSummary: "Version restaurée",
|
||||
wikiExportPdf: "Générer la page",
|
||||
wikiPreview: "Aperçu",
|
||||
tribeSectionWiki: "WIKI",
|
||||
tribeWikiCreate: "Créer un wiki",
|
||||
|
|
@ -3857,6 +3857,9 @@ module.exports = {
|
|||
roomRecStop: "Arrêter l'enregistrement",
|
||||
roomStreamFile: "Diffuser",
|
||||
roomStreamStop: "Arrêter",
|
||||
roomScreen: "Écran",
|
||||
roomScreenSharing: "Partage d'écran",
|
||||
roomScreenWarning: "Tout le monde dans la salle verra votre écran entier jusqu'à ce que vous arrêtiez.",
|
||||
roomStreaming: "En diffusion",
|
||||
liveFullscreen: "Plein écran",
|
||||
roomStreamPickTitle: "Choisir une vidéo",
|
||||
|
|
@ -3914,6 +3917,7 @@ module.exports = {
|
|||
inviteAlreadyMember: "Vous en faites déjà partie. Vous pouvez l'ouvrir depuis son module.",
|
||||
actionFailed: "Cela n'a pas pu être fait. Réessayez.",
|
||||
publishTooShort: "Votre publication est trop courte.",
|
||||
publishEmpty: "Écrivez quelque chose ou joignez un fichier.",
|
||||
walletSendFailed: "Le paiement n'a pas pu être envoyé : {reason}",
|
||||
pixeliaCoordsError: "Ces coordonnées sont en dehors de la grille.",
|
||||
courtsErrTitle: "Un titre est requis.",
|
||||
|
|
@ -4425,6 +4429,9 @@ module.exports = {
|
|||
torrentTagsLabel: "Étiquettes",
|
||||
torrentTagsPlaceholder: "Saisissez des tags séparés par des virgules",
|
||||
torrentSizeLabel: "Taille",
|
||||
torrentInfoHash: "Infohash",
|
||||
torrentFilesLabel: "Fichiers",
|
||||
torrentMagnet: "MAGNET",
|
||||
torrentFromContent: "Générer un torrent",
|
||||
torrentFromContentMissing: "Le fichier n'est pas encore disponible sur ce nœud. Réessayez plus tard.",
|
||||
torrentDownloads: "Téléchargements",
|
||||
|
|
@ -4510,9 +4517,6 @@ module.exports = {
|
|||
inhabitantsPendingAccept: "Accepter",
|
||||
inhabitantsPendingReject: "Refuser",
|
||||
tribeSectionGovernance: "GOUVERNANCE",
|
||||
tribePadInviteRequired: "Vous n'avez pas accès au pad. Demandez une invitation.",
|
||||
tribeChatInviteRequired: "Vous n'avez pas accès au chat. Demandez une invitation.",
|
||||
tribeGovernanceDesc: "Gouvernance interne de cette tribu.",
|
||||
tribeGovernanceNoGov: "Pas de gouvernement actif",
|
||||
tribeGovernanceNoGovDesc: "Cette tribu n'a pas encore de gouvernement.",
|
||||
tribeGovCardTitle: "Gouvernement Actuel",
|
||||
|
|
@ -4569,7 +4573,6 @@ module.exports = {
|
|||
logsFilterMonth: "MOIS",
|
||||
logsFilterWeek: "SEMAINE",
|
||||
logsFilterToday: "AUJOURD'HUI",
|
||||
logsComments: "Commentaires",
|
||||
logsAuthorEntries: "Entrées",
|
||||
logsCreate: "Créer une entrée",
|
||||
logsExport: "Exporter le journal",
|
||||
|
|
@ -4632,13 +4635,6 @@ module.exports = {
|
|||
padAttributionView: "Par autrice",
|
||||
padVersionView: "Voir",
|
||||
parliamentRevocationPublish: "Publier la révocation",
|
||||
reportsAcceptanceCriteriaLabel: "Critères d'acceptation",
|
||||
reportsActualBehaviorPlaceholder: "Que se passe-t-il réellement ?",
|
||||
reportsEvidenceLinksPlaceholder: "Collez des liens, des identifiants de messages, des captures d'écran (le cas échéant), etc.",
|
||||
reportsUserStoryLabel: "Récit d'habitant",
|
||||
reportsUserStoryPlaceholder: "En tant que <habitant>, je veux <action>, afin de <bénéfice>.",
|
||||
reportsWhatHappenedLabel: "Que s'est-il passé ?",
|
||||
reportsWhatHappenedPlaceholder: "Décrivez l'incident avec le contexte et des dates approximatives.",
|
||||
statsChat: "Chats",
|
||||
statsChatMessage: "Messages de chat",
|
||||
statsGameScore: "Jeux",
|
||||
|
|
@ -4656,6 +4652,8 @@ module.exports = {
|
|||
larpWillTestTitle: "Test WILL",
|
||||
settingsLanDesc: "Annoncer périodiquement ce pair aux autres instances d'Oasis sur le même réseau local.",
|
||||
settingsLanEnable: "Activer la diffusion LAN",
|
||||
settingsConnectionsTitle: "Connexions",
|
||||
settingsConnectionsDesc: "Nombre de pairs que ce nœud garde connectés en même temps.",
|
||||
settingsLanTitle: "Diffusion LAN",
|
||||
settingsReplicationTitle: "Réplication",
|
||||
settingsReplicationDesc: "Configure le nombre de sauts que ton pair suit à partir de ton propre flux lors de la réplication du contenu.",
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ module.exports = {
|
|||
graphosBackToMine: "← A miña rede",
|
||||
graphosShowing: "Amosando",
|
||||
graphosYou: "Ti",
|
||||
graphosTotalNodes: "Nodos totais",
|
||||
graphosTotalNodes: "Totais",
|
||||
onlinePlural: "Conectados",
|
||||
mentions: "Mencións",
|
||||
mentionsDescription: [
|
||||
strong("Todas as mensaxes que te @mencionan"),
|
||||
|
|
@ -412,6 +413,7 @@ module.exports = {
|
|||
menuMedia: "Biblioteca",
|
||||
menuTools: "Ferramentas",
|
||||
comment: "Comentar",
|
||||
typeComment: "Comentario",
|
||||
subtopic: "Subtema",
|
||||
json: "JSON",
|
||||
unfollow: "Deixar de apoiar",
|
||||
|
|
@ -441,9 +443,6 @@ module.exports = {
|
|||
publishCustomDescription: [
|
||||
"LEMBRA: Por mor da tecnoloxía blockchain, unha vez que publicas algo, non se pode editar nin borrar. Pénsao ben!",
|
||||
],
|
||||
commentWarning: [
|
||||
"LEMBRA: Por mor da tecnoloxía blockchain, unha vez que publicas algo, non se pode editar nin borrar. Pénsao ben!",
|
||||
],
|
||||
commentLabel: ({ publicOrPrivate, markdownUrl }) => [
|
||||
],
|
||||
publishLabel: ({ markdownUrl, linkTarget }) => [
|
||||
|
|
@ -718,10 +717,6 @@ module.exports = {
|
|||
votesQuestionLabel:"Pregunta",
|
||||
voteStatusOpen:"ABERTO",
|
||||
voteStatusClosed:"PECHADO",
|
||||
commentDescription: ({ parentUrl }) => [
|
||||
" comentou en ",
|
||||
a({ href: parentUrl }, " fío"),
|
||||
],
|
||||
commentTitle: ({ authorName }) => [`Comentou no post de @${authorName}`],
|
||||
subtopicDescription: ({ parentUrl }) => [
|
||||
" creou un subtema desde ",
|
||||
|
|
@ -856,6 +851,8 @@ module.exports = {
|
|||
bankRulesEligibility5: "O PUB paga no seu seguinte ciclo e publica un transfer RBU que podes confirmar.",
|
||||
bankRulesEligibility6: "Se falta o teu enderezo, o feed é demasiado novo, rexeitaches o mes ou o PUB non ten fondos, a reclamación sáltase e queda rexistrada no PUB.",
|
||||
copyLinkTitle: "Copiar a ligazón",
|
||||
cardMenuTitle: "Máis accións",
|
||||
shareContent: "Compartir",
|
||||
linkBoxOasis: "Oasis",
|
||||
linkBoxClearnet: "Clearnet",
|
||||
bankRefuseUBI: "Rexeitar RBU",
|
||||
|
|
@ -889,7 +886,7 @@ module.exports = {
|
|||
walletNotConfigured: "A túa carteira de ECOin aínda non está configurada.",
|
||||
walletSettingsDescription: "Integrar Oasis coa túa carteira de ECOin.",
|
||||
walletSettingsMobileDescription: "Vincula Oasis coa app ECOin Wallet deste dispositivo.",
|
||||
walletDownloadEcoin: "Descargar ECOin!",
|
||||
walletDownloadEcoin: "Descargar ECOin",
|
||||
walletRpcUrl: "URL do RPC",
|
||||
walletStatusMessages: {
|
||||
invalid_amount: "Cantidade non válida",
|
||||
|
|
@ -942,6 +939,7 @@ module.exports = {
|
|||
bookmarkUpdateButton: "Actualizar",
|
||||
bookmarkAddFavoriteButton: "Engadir a favoritos",
|
||||
bookmarkRemoveFavoriteButton: "Quitar de favoritos",
|
||||
bookmarkTitleLabel: "Título",
|
||||
bookmarkUrlLabel: "Ligazón",
|
||||
bookmarkUrlPlaceholder: "https://solarnethub.com",
|
||||
bookmarkDescriptionLabel: "Descrición",
|
||||
|
|
@ -1086,9 +1084,9 @@ module.exports = {
|
|||
allCVs: "CV",
|
||||
discoverPeople: "Descobre habitantes na túa rede.",
|
||||
searchInhabitantsPlaceholder: "Buscar en Habitantes...",
|
||||
filterLocation: "FILTRAR habitantes POR LOCALIZACIÓN …",
|
||||
filterLanguage: "FILTRAR habitantes POR IDIOMA …",
|
||||
filterSkills: "FILTRAR habitantes POR HABILIDADES …",
|
||||
filterLocation: "Buscar por localización…",
|
||||
filterLanguage: "Buscar por idioma…",
|
||||
filterSkills: "Buscar por habilidades…",
|
||||
cvSkillsCount: "Habilidades",
|
||||
applyFilters: "Aplicar os filtros",
|
||||
locationLabel: "Localización",
|
||||
|
|
@ -1104,6 +1102,7 @@ module.exports = {
|
|||
topactivitySectionTitle: "Máis actividade",
|
||||
"TOP INACTIVITYButton": "MÁIS INACTIVOS",
|
||||
topinactivitySectionTitle: "Habitantes menos activos",
|
||||
contactsSectionTitle: "Contactos",
|
||||
blockedSectionTitle: "Bloqueados",
|
||||
gallerySectionTitle: "GALERÍA",
|
||||
topSectionTitle: "TOP",
|
||||
|
|
@ -1558,9 +1557,7 @@ module.exports = {
|
|||
voteUnknownDate: "Data descoñecida",
|
||||
voteDetailSectionTitle: "Detalles da votación",
|
||||
voteCommentsLabel: "Comentarios",
|
||||
voteCommentsForumButton: "Abrir a conversa",
|
||||
voteCommentsSectionTitle: "Conversa aberta",
|
||||
voteNoCommentsYet: "Aínda non hai comentarios. Sé a primeira persoa en opinar.",
|
||||
voteNoCommentsYet: "Aínda non hai comentarios.",
|
||||
commentsGoToLast: "Ir ao último comentario",
|
||||
voteNewCommentPlaceholder: "Escribe aquí o teu comentario…",
|
||||
voteNewCommentButton: "Publicar o comentario",
|
||||
|
|
@ -1687,7 +1684,6 @@ module.exports = {
|
|||
pollVoteButton: "Votar",
|
||||
pollChangeVote: "Cambiar o voto",
|
||||
pollVoters: "Votantes",
|
||||
pollComments: "Comentarios",
|
||||
pollStatusLabel: "Estado",
|
||||
pollStatusOpen: "ABERTA",
|
||||
pollStatusClosed: "PECHADA",
|
||||
|
|
@ -1709,9 +1705,22 @@ module.exports = {
|
|||
blogAllowComments: "Permitir comentarios",
|
||||
blogCommentsClosed: "O autor pechou os comentarios deste blog.",
|
||||
blogNoItems: "Non se atoparon blogs.",
|
||||
viewBlog: "Ver o blog",
|
||||
forumTitle: "Foros",
|
||||
forumCategoryLabel: "Categoría",
|
||||
forumCatGENERAL: "XERAL",
|
||||
forumCatPOLITICS: "POLÍTICA",
|
||||
forumCatTECH: "TECNOLOXÍA",
|
||||
forumCatSCIENCE: "CIENCIA",
|
||||
forumCatMUSIC: "MÚSICA",
|
||||
forumCatART: "ARTE",
|
||||
forumCatGAMING: "XOGOS",
|
||||
forumCatBOOKS: "LIBROS",
|
||||
forumCatFILMS: "PELÍCULAS",
|
||||
forumCatPHILOSOPHY: "FILOSOFÍA",
|
||||
forumCatSOCIETY: "SOCIEDADE",
|
||||
forumCatPRIVACY: "PRIVACIDADE",
|
||||
forumCatCYBERWARFARE: "CIBERGUERRA",
|
||||
forumCatSURVIVALISM: "SUPERVIVENCIA",
|
||||
forumTitleLabel: "Título",
|
||||
forumTitlePlaceholder: "Título do foro...",
|
||||
forumCreateButton: "Crear un foro",
|
||||
|
|
@ -1770,8 +1779,6 @@ module.exports = {
|
|||
feedTitle: "Feed",
|
||||
feedDetailTitle: "Feed",
|
||||
feedOpenDiscussion: "Abrir o debate",
|
||||
feedPostComment: "Publicar o comentario",
|
||||
noComments: "Aínda non hai comentarios",
|
||||
createFeedTitle: "Crear un feed",
|
||||
createFeedButton: "Enviar o feed!",
|
||||
feedPlaceholder: "Que está a pasar? (máximo 280 caracteres)",
|
||||
|
|
@ -1982,42 +1989,6 @@ module.exports = {
|
|||
reportsUnderReviewSectionTitle: "Informes en revisión",
|
||||
reportsResolvedSectionTitle: "Informes resoltos",
|
||||
reportsInvalidSectionTitle: "Informes non válidos",
|
||||
reportsBugTemplateTitle: "Datos para reproducir (erros)",
|
||||
reportsFeatureTemplateTitle: "Detalles (funcións)",
|
||||
reportsAbuseTemplateTitle: "Detalles (abuso)",
|
||||
reportsContentTemplateTitle: "Detalles (problemas de contido)",
|
||||
reportsStepsToReproduceLabel: "Pasos para reproducilo",
|
||||
reportsStepsToReproducePlaceholder: "1) ...\n2) ...\n3) ...",
|
||||
reportsExpectedBehaviorLabel: "Resultado esperado",
|
||||
reportsExpectedBehaviorPlaceholder: "Que debería pasar?",
|
||||
reportsActualBehaviorLabel: "Resultado real",
|
||||
reportsActualBehaviorPlaceholder: "Que pasa realmente?",
|
||||
reportsEnvironmentLabel: "Contorno",
|
||||
reportsEnvironmentPlaceholder: "Versión, dispositivo, SO, navegador, configuración, rexistros, etc.",
|
||||
reportsReproduceRateLabel: "Frecuencia",
|
||||
reportsReproduceRateAlways: "Sempre",
|
||||
reportsReproduceRateOften: "A miúdo",
|
||||
reportsReproduceRateSometimes: "Ás veces",
|
||||
reportsReproduceRateRarely: "Raramente",
|
||||
reportsReproduceRateUnable: "Non se pode reproducir",
|
||||
reportsReproduceRateUnknown: "Non especificado",
|
||||
reportsProblemStatementLabel: "Problema / necesidade",
|
||||
reportsProblemStatementPlaceholder: "Que problema resolve esta función?",
|
||||
reportsUserStoryLabel: "Historia de habitante",
|
||||
reportsUserStoryPlaceholder: "Como <habitante>, quero <acción>, para <beneficio>.",
|
||||
reportsAcceptanceCriteriaLabel: "Criterios de aceptación",
|
||||
reportsAcceptanceCriteriaPlaceholder: "- Dado...\n- Cando...\n- Entón...",
|
||||
reportsWhatHappenedLabel: "Que ocorreu?",
|
||||
reportsWhatHappenedPlaceholder: "Describe o incidente con contexto e datas aproximadas.",
|
||||
reportsReportedUserLabel: "Habitante / entidade denunciada",
|
||||
reportsReportedUserPlaceholder: "@habitante, feed, ID, ligazón, etc.",
|
||||
reportsEvidenceLinksLabel: "Probas / ligazóns",
|
||||
reportsEvidenceLinksPlaceholder: "Pega ligazóns, IDs de mensaxes, capturas (se procede), etc.",
|
||||
reportsContentLocationLabel: "Onde está o contido",
|
||||
reportsContentLocationPlaceholder: "Ligazón, ID, canle, fío, autor, etc.",
|
||||
reportsWhyInappropriatePlaceholder: "Explica o motivo e o impacto.",
|
||||
reportsRequestedActionLabel: "Acción solicitada",
|
||||
reportsRequestedActionPlaceholder: "Eliminar, agochar, marcar, advertir, etc.",
|
||||
tribesTitle: "Tribos",
|
||||
tribeFilterAll: "TODAS",
|
||||
tribeFilterRecent: "RECENTES",
|
||||
|
|
@ -2261,6 +2232,7 @@ module.exports = {
|
|||
voteWellResearched: "Ben investigado",
|
||||
voteNeedsSources: "Necesita fontes",
|
||||
voteWrong: "Incorrecto",
|
||||
voteOutdated: "Desactualizado",
|
||||
voteLowQuality: "Baixa calidade",
|
||||
voteLove: "Amor",
|
||||
voteClear: "Claro",
|
||||
|
|
@ -2505,7 +2477,6 @@ module.exports = {
|
|||
shopNoProducts: "Aínda non hai produtos.",
|
||||
shopOutOfStock: "Esgotado",
|
||||
shopBuy: "Comprar",
|
||||
shopBuyEncryptedNote: "Os teus datos de entrega envíanse cifrados; só o dono da tenda pode velos.",
|
||||
shopBuyDeliveryAddress: "Enderezo de entrega",
|
||||
shopBuyContact: "Contacto",
|
||||
shopBuyContactPlaceholder: "correo, teléfono, etc.",
|
||||
|
|
@ -3022,6 +2993,16 @@ module.exports = {
|
|||
industryDescriptionLabel: "Descrición",
|
||||
industryDescriptionPlaceholder: "Que produce esta fábrica?",
|
||||
industrySector: "Sector",
|
||||
industrySector_software: "Software",
|
||||
industrySector_hardware: "Hardware",
|
||||
industrySector_agriculture: "Agricultura",
|
||||
industrySector_textile: "Téxtil",
|
||||
industrySector_energy: "Enerxía",
|
||||
industrySector_food: "Alimentación",
|
||||
industrySector_construction: "Construción",
|
||||
industrySector_media: "Medios",
|
||||
industrySector_services: "Servizos",
|
||||
industrySector_other: "Outros",
|
||||
industryMembershipPolicy: "Política de pertenza",
|
||||
industryQuorum: "Quórum",
|
||||
industryMajority: "Maioría",
|
||||
|
|
@ -3223,6 +3204,18 @@ module.exports = {
|
|||
modulesFavoritesDescription: "Módulo para xestionar o teu contido favorito.",
|
||||
fileTooLargeTitle: "Ficheiro demasiado grande",
|
||||
fileTooLargeMessage: "O ficheiro supera o tamaño máximo permitido (75 MB). Por favor, selecciona un ficheiro máis pequeno.",
|
||||
fileWrongType: "Este ficheiro non é do tipo axeitado para esta sección.",
|
||||
mediaLockedAfterOpinions: "Xa ten opinións: podes editar o resto, pero non cambiar o que se valorou.",
|
||||
pdfPrevPage: "Páxina anterior",
|
||||
pdfNextPage: "Páxina seguinte",
|
||||
pdfPage: "Páxina",
|
||||
pdfZoomIn: "Achegar",
|
||||
pdfZoomOut: "Afastar",
|
||||
pdfFullscreen: "Pantalla completa",
|
||||
pdfClose: "Pechar",
|
||||
pdfRotate: "Xirar",
|
||||
contentNotAvailableYet: "Este contido aínda non está dispoñible.",
|
||||
pdfRetry: "Tentar de novo",
|
||||
goBack: "Volver",
|
||||
errorDismiss: "De acordo",
|
||||
errorPageTitle: "Produciuse un erro",
|
||||
|
|
@ -3267,7 +3260,6 @@ module.exports = {
|
|||
invitesFollow: "Seguir",
|
||||
invitesUnfollowedInvites: "Non federadas",
|
||||
invitesNoUnfollowed: "Non hai pubs non federados.",
|
||||
reportsWhyInappropriateLabel: "Por que é inadecuado?",
|
||||
blockchainContentDeleted: "Este contido foi eliminado",
|
||||
visitContent: "Visitar o contido",
|
||||
favoriteAdd: "Fixar en favoritos",
|
||||
|
|
@ -3579,6 +3571,8 @@ module.exports = {
|
|||
podcastTagsPlaceholder: "Introduce etiquetas separadas por comas",
|
||||
podcastFilterAll: "TODOS",
|
||||
podcastFilterMine: "MEUS",
|
||||
podcastFilterFavorites: "FAVORITOS",
|
||||
podcastEpisodeNumberLabel: "Número de episodio",
|
||||
podcastFilterRecent: "RECENTES",
|
||||
podcastFilterTop: "TOP",
|
||||
podcastFilterViewers: "OÍNTES",
|
||||
|
|
@ -3797,7 +3791,6 @@ module.exports = {
|
|||
wikiBackToPage: "Volver á páxina",
|
||||
wikiLinkedChip: "Ligada",
|
||||
wikiRestoredSummary: "Versión restaurada",
|
||||
wikiExportPdf: "Xerar a páxina",
|
||||
wikiPreview: "Vista previa",
|
||||
tribeSectionWiki: "WIKI",
|
||||
tribeWikiCreate: "Crear unha wiki",
|
||||
|
|
@ -3865,6 +3858,9 @@ module.exports = {
|
|||
roomRecStop: "Parar a gravación",
|
||||
roomStreamFile: "Emitir",
|
||||
roomStreamStop: "Parar",
|
||||
roomScreen: "Pantalla",
|
||||
roomScreenSharing: "Compartindo pantalla",
|
||||
roomScreenWarning: "Todos na sala verán a túa pantalla completa ata que pares.",
|
||||
roomStreaming: "Emitindo",
|
||||
liveFullscreen: "Pantalla completa",
|
||||
roomStreamPickTitle: "Escolle un vídeo",
|
||||
|
|
@ -3922,6 +3918,7 @@ module.exports = {
|
|||
inviteAlreadyMember: "Xa formas parte. Podes abrilo desde o seu módulo.",
|
||||
actionFailed: "Non se puido facer. Téntao de novo.",
|
||||
publishTooShort: "A túa publicación é demasiado curta.",
|
||||
publishEmpty: "Escribe algo ou achega un ficheiro.",
|
||||
walletSendFailed: "Non se puido enviar o pagamento: {reason}",
|
||||
pixeliaCoordsError: "Esas coordenadas están fóra da grella.",
|
||||
courtsErrTitle: "Cómpre un título.",
|
||||
|
|
@ -4434,6 +4431,9 @@ module.exports = {
|
|||
torrentTagsLabel: "Etiquetas",
|
||||
torrentTagsPlaceholder: "Introduce etiquetas separadas por comas",
|
||||
torrentSizeLabel: "Tamaño",
|
||||
torrentInfoHash: "Infohash",
|
||||
torrentFilesLabel: "Ficheiros",
|
||||
torrentMagnet: "MAGNET",
|
||||
torrentFromContent: "Xerar torrent",
|
||||
torrentFromContentMissing: "O ficheiro aínda non está dispoñible neste nodo. Téntao máis tarde.",
|
||||
torrentDownloads: "Descargas",
|
||||
|
|
@ -4519,9 +4519,6 @@ module.exports = {
|
|||
inhabitantsPendingAccept: "Aceptar",
|
||||
inhabitantsPendingReject: "Rexeitar",
|
||||
tribeSectionGovernance: "GOBERNO",
|
||||
tribePadInviteRequired: "Non tes acceso ao pad. Pide un convite para acceder ao contido.",
|
||||
tribeChatInviteRequired: "Non tes acceso ao chat. Pide un convite para acceder ao contido.",
|
||||
tribeGovernanceDesc: "Goberno interno desta tribo.",
|
||||
tribeGovernanceNoGov: "Sen goberno activo",
|
||||
tribeGovernanceNoGovDesc: "Esta tribo aínda non ten goberno. Propón candidaturas para iniciar o proceso.",
|
||||
tribeGovCardTitle: "Goberno actual",
|
||||
|
|
@ -4578,7 +4575,6 @@ module.exports = {
|
|||
logsFilterMonth: "ÚLTIMO MES",
|
||||
logsFilterWeek: "ÚLTIMA SEMANA",
|
||||
logsFilterToday: "HOXE",
|
||||
logsComments: "Comentarios",
|
||||
logsAuthorEntries: "Entradas",
|
||||
logsCreate: "Crear unha entrada",
|
||||
logsExport: "Exportar a bitácora",
|
||||
|
|
@ -4657,6 +4653,8 @@ module.exports = {
|
|||
larpWillTestTitle: "Test WILL",
|
||||
settingsLanDesc: "Anuncia periodicamente este nodo a outras instancias de Oasis na mesma rede local.",
|
||||
settingsLanEnable: "Activar a difusión LAN",
|
||||
settingsConnectionsTitle: "Conexións",
|
||||
settingsConnectionsDesc: "Cantos peers mantén conectados este nodo á vez.",
|
||||
settingsLanTitle: "Difusión LAN",
|
||||
settingsReplicationTitle: "Replicación",
|
||||
settingsReplicationDesc: "Configura o número de saltos que o teu nodo segue desde o teu propio feed ao replicar contido.",
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ module.exports = {
|
|||
graphosBackToMine: "← मेरा नेटवर्क",
|
||||
graphosShowing: "दिखा रहे",
|
||||
graphosYou: "आप",
|
||||
graphosTotalNodes: "कुल नोड्स",
|
||||
graphosTotalNodes: "कुल",
|
||||
onlinePlural: "ऑनलाइन",
|
||||
mentions: "उल्लेख",
|
||||
mentionsDescription: [
|
||||
strong("हर संदेश जो आपको @उल्लेख करता है"),
|
||||
|
|
@ -420,6 +421,7 @@ module.exports = {
|
|||
menuMedia: "पुस्तकालय",
|
||||
menuTools: "उपकरण",
|
||||
comment: "टिप्पणी",
|
||||
typeComment: "टिप्पणी",
|
||||
subtopic: "उपविषय",
|
||||
json: "JSON",
|
||||
unfollow: "समर्थन वापस लें",
|
||||
|
|
@ -449,9 +451,6 @@ module.exports = {
|
|||
publishCustomDescription: [
|
||||
"याद रखें: ब्लॉकचेन तकनीक के कारण, एक बार पोस्ट प्रकाशित होने के बाद इसे संपादित या हटाया नहीं जा सकता।",
|
||||
],
|
||||
commentWarning: [
|
||||
"याद रखें: ब्लॉकचेन तकनीक के कारण, एक बार पोस्ट प्रकाशित होने के बाद इसे संपादित या हटाया नहीं जा सकता।",
|
||||
],
|
||||
commentLabel: ({ publicOrPrivate, markdownUrl }) => [
|
||||
],
|
||||
publishLabel: ({ markdownUrl, linkTarget }) => [
|
||||
|
|
@ -730,10 +729,6 @@ module.exports = {
|
|||
votesQuestionLabel:"प्रश्न",
|
||||
voteStatusOpen:"खुला",
|
||||
voteStatusClosed:"बंद",
|
||||
commentDescription: ({ parentUrl }) => [
|
||||
" ने टिप्पणी की ",
|
||||
a({ href: parentUrl }, " थ्रेड पर"),
|
||||
],
|
||||
commentTitle: ({ authorName }) => [`@${authorName} की पोस्ट पर टिप्पणी`],
|
||||
subtopicDescription: ({ parentUrl }) => [
|
||||
" ने एक उपविषय बनाया ",
|
||||
|
|
@ -868,6 +863,8 @@ module.exports = {
|
|||
bankRulesEligibility5: "PUB अगले चक्र में भुगतान करता है और एक UBI ट्रांसफर प्रकाशित करता है जिसे आप पुष्टि कर सकते हैं।",
|
||||
bankRulesEligibility6: "यदि पता नहीं है, फ़ीड बहुत नई है, आपने माह अस्वीकार किया या PUB के पास धन नहीं है, तो दावा छोड़ दिया जाता है और PUB पर लॉग किया जाता है।",
|
||||
copyLinkTitle: "लिंक कॉपी करें",
|
||||
cardMenuTitle: "और कार्रवाइयाँ",
|
||||
shareContent: "साझा करें",
|
||||
linkBoxOasis: "Oasis",
|
||||
linkBoxClearnet: "Clearnet",
|
||||
bankRefuseUBI: "UBI अस्वीकार करें",
|
||||
|
|
@ -901,7 +898,7 @@ module.exports = {
|
|||
walletNotConfigured: "आपका ECOin वॉलेट अभी कॉन्फ़िगर नहीं है।",
|
||||
walletSettingsDescription: "Oasis को अपने ECOin वॉलेट के साथ एकीकृत करें।",
|
||||
walletSettingsMobileDescription: "Oasis को इस डिवाइस के ECOin Wallet ऐप से जोड़ें।",
|
||||
walletDownloadEcoin: "ECOin डाउनलोड करें!",
|
||||
walletDownloadEcoin: "ECOin डाउनलोड करें",
|
||||
walletRpcUrl: "RPC URL",
|
||||
walletStatusMessages: {
|
||||
invalid_amount: "अमान्य राशि",
|
||||
|
|
@ -954,6 +951,7 @@ module.exports = {
|
|||
bookmarkUpdateButton: "अपडेट",
|
||||
bookmarkAddFavoriteButton: "पसंदीदा जोड़ें",
|
||||
bookmarkRemoveFavoriteButton: "पसंदीदा हटाएँ",
|
||||
bookmarkTitleLabel: "शीर्षक",
|
||||
bookmarkUrlLabel: "लिंक",
|
||||
bookmarkUrlPlaceholder: "https://solarnethub.com",
|
||||
bookmarkDescriptionLabel: "विवरण",
|
||||
|
|
@ -1098,9 +1096,9 @@ module.exports = {
|
|||
allCVs: "CVs",
|
||||
discoverPeople: "अपने नेटवर्क में निवासियों को खोजें।",
|
||||
searchInhabitantsPlaceholder: "निवासी में खोजें...",
|
||||
filterLocation: "स्थान से निवासी फ़िल्टर करें …",
|
||||
filterLanguage: "भाषा से निवासी फ़िल्टर करें …",
|
||||
filterSkills: "कौशल से निवासी फ़िल्टर करें …",
|
||||
filterLocation: "स्थान से खोजें…",
|
||||
filterLanguage: "भाषा से खोजें…",
|
||||
filterSkills: "कौशल से खोजें…",
|
||||
cvSkillsCount: "कौशल",
|
||||
applyFilters: "फ़िल्टर लागू करें",
|
||||
locationLabel: "स्थान",
|
||||
|
|
@ -1116,6 +1114,7 @@ module.exports = {
|
|||
topactivitySectionTitle: "शीर्ष गतिविधि",
|
||||
"TOP INACTIVITYButton": "सबसे निष्क्रिय",
|
||||
topinactivitySectionTitle: "सबसे कम सक्रिय निवासी",
|
||||
contactsSectionTitle: "संपर्क",
|
||||
blockedSectionTitle: "ब्लॉक किए गए",
|
||||
gallerySectionTitle: "गैलरी",
|
||||
topSectionTitle: "शीर्ष",
|
||||
|
|
@ -1569,9 +1568,7 @@ module.exports = {
|
|||
voteUnknownDate: "अज्ञात दिनांक",
|
||||
voteDetailSectionTitle: 'मत विवरण',
|
||||
voteCommentsLabel: 'टिप्पणियाँ',
|
||||
voteCommentsForumButton: 'चर्चा खोलें',
|
||||
voteCommentsSectionTitle: 'चर्चा खोलें',
|
||||
voteNoCommentsYet: 'अभी तक कोई टिप्पणी नहीं है। पहले उत्तर देने वाले बनें।',
|
||||
voteNoCommentsYet: 'अभी तक कोई टिप्पणी नहीं है।',
|
||||
commentsGoToLast: "अंतिम टिप्पणी पर जाएँ",
|
||||
voteNewCommentPlaceholder: 'अपनी टिप्पणी यहाँ लिखें…',
|
||||
voteNewCommentButton: 'टिप्पणी पोस्ट करें',
|
||||
|
|
@ -1698,7 +1695,6 @@ module.exports = {
|
|||
pollVoteButton: "मत दें",
|
||||
pollChangeVote: "वोट बदलें",
|
||||
pollVoters: "मतदाता",
|
||||
pollComments: "टिप्पणियाँ",
|
||||
pollStatusLabel: "स्थिति",
|
||||
pollStatusOpen: "खुला",
|
||||
pollStatusClosed: "बंद",
|
||||
|
|
@ -1720,9 +1716,22 @@ module.exports = {
|
|||
blogAllowComments: "टिप्पणियाँ अनुमति दें",
|
||||
blogCommentsClosed: "लेखक ने इस ब्लॉग की टिप्पणियाँ बंद कर दी हैं।",
|
||||
blogNoItems: "कोई ब्लॉग नहीं मिला।",
|
||||
viewBlog: "ब्लॉग देखें",
|
||||
forumTitle: "मंच",
|
||||
forumCategoryLabel: "श्रेणी",
|
||||
forumCatGENERAL: "सामान्य",
|
||||
forumCatPOLITICS: "राजनीति",
|
||||
forumCatTECH: "तकनीक",
|
||||
forumCatSCIENCE: "विज्ञान",
|
||||
forumCatMUSIC: "संगीत",
|
||||
forumCatART: "कला",
|
||||
forumCatGAMING: "गेमिंग",
|
||||
forumCatBOOKS: "किताबें",
|
||||
forumCatFILMS: "फ़िल्में",
|
||||
forumCatPHILOSOPHY: "दर्शन",
|
||||
forumCatSOCIETY: "समाज",
|
||||
forumCatPRIVACY: "निजता",
|
||||
forumCatCYBERWARFARE: "साइबर युद्ध",
|
||||
forumCatSURVIVALISM: "उत्तरजीविता",
|
||||
forumTitleLabel: "शीर्षक",
|
||||
forumTitlePlaceholder: "मंच शीर्षक...",
|
||||
forumCreateButton: "मंच बनाएँ",
|
||||
|
|
@ -1991,43 +2000,6 @@ module.exports = {
|
|||
reportsUnderReviewSectionTitle: "समीक्षाधीन रिपोर्ट",
|
||||
reportsResolvedSectionTitle: "हल की गई रिपोर्ट",
|
||||
reportsInvalidSectionTitle: "अमान्य रिपोर्ट",
|
||||
reportsBugTemplateTitle: 'पुनरुत्पादन विवरण (बग)',
|
||||
reportsFeatureTemplateTitle: 'विवरण (सुविधाएँ)',
|
||||
reportsAbuseTemplateTitle: 'विवरण (दुरुपयोग)',
|
||||
reportsContentTemplateTitle: 'विवरण (सामग्री समस्याएँ)',
|
||||
reportsStepsToReproduceLabel: 'पुनरुत्पादन के चरण',
|
||||
reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...',
|
||||
reportsExpectedBehaviorLabel: 'अपेक्षित परिणाम',
|
||||
reportsExpectedBehaviorPlaceholder: 'क्या होना चाहिए?',
|
||||
reportsActualBehaviorLabel: 'वास्तविक परिणाम',
|
||||
reportsActualBehaviorPlaceholder: 'वास्तव में क्या होता है?',
|
||||
reportsEnvironmentLabel: 'वातावरण',
|
||||
reportsEnvironmentPlaceholder: 'संस्करण, उपकरण, OS, ब्राउज़र, सेटिंग्स, लॉग, आदि।',
|
||||
reportsReproduceRateLabel: 'पुनरुत्पादन दर',
|
||||
reportsReproduceRateAlways: 'हमेशा',
|
||||
reportsReproduceRateOften: 'अक्सर',
|
||||
reportsReproduceRateSometimes: 'कभी-कभी',
|
||||
reportsReproduceRateRarely: 'शायद ही कभी',
|
||||
reportsReproduceRateUnable: 'पुनरुत्पादन करने में असमर्थ',
|
||||
reportsReproduceRateUnknown: 'निर्दिष्ट नहीं',
|
||||
reportsProblemStatementLabel: 'समस्या / आवश्यकता',
|
||||
reportsProblemStatementPlaceholder: 'यह सुविधा कौन सी समस्या हल करती है?',
|
||||
reportsUserStoryLabel: 'निवासी कहानी',
|
||||
reportsUserStoryPlaceholder: 'एक <निवासी> के रूप में, मैं <कार्रवाई> चाहता हूँ, ताकि <लाभ>।',
|
||||
reportsAcceptanceCriteriaLabel: 'स्वीकृति मानदंड',
|
||||
reportsAcceptanceCriteriaPlaceholder: '- दिया गया...\n- जब...\n- तब...',
|
||||
reportsWhatHappenedLabel: 'क्या हुआ?',
|
||||
reportsWhatHappenedPlaceholder: 'संदर्भ और अनुमानित तिथियों के साथ घटना का वर्णन करें।',
|
||||
reportsReportedUserLabel: 'रिपोर्ट किया गया निवासी / संस्था',
|
||||
reportsReportedUserPlaceholder: '@निवासी, फ़ीड, ID, लिंक, आदि।',
|
||||
reportsEvidenceLinksLabel: 'साक्ष्य / लिंक',
|
||||
reportsEvidenceLinksPlaceholder: 'लिंक, संदेश IDs, स्क्रीनशॉट (यदि लागू हो), आदि पेस्ट करें।',
|
||||
reportsContentLocationLabel: 'सामग्री कहाँ है',
|
||||
reportsContentLocationPlaceholder: 'लिंक, ID, चैनल, थ्रेड, लेखक, आदि।',
|
||||
reportsWhyInappropriateLabel: "यह अनुचित क्यों है",
|
||||
reportsWhyInappropriatePlaceholder: 'कारण और प्रभाव बताएँ।',
|
||||
reportsRequestedActionLabel: 'अनुरोधित कार्रवाई',
|
||||
reportsRequestedActionPlaceholder: 'हटाएँ, छुपाएँ, टैग करें, चेतावनी दें, आदि।',
|
||||
tribesTitle: "जनजातियाँ",
|
||||
tribeFilterAll: "सभी",
|
||||
tribeFilterRecent: "हाल के",
|
||||
|
|
@ -2271,6 +2243,7 @@ module.exports = {
|
|||
voteWellResearched: "अच्छी तरह शोधित",
|
||||
voteNeedsSources: "स्रोत चाहिए",
|
||||
voteWrong: "गलत",
|
||||
voteOutdated: "पुराना",
|
||||
voteLowQuality: "निम्न गुणवत्ता",
|
||||
voteLove: "प्यार",
|
||||
voteClear: "स्पष्ट",
|
||||
|
|
@ -2515,7 +2488,6 @@ module.exports = {
|
|||
shopNoProducts: "अभी तक कोई उत्पाद नहीं।",
|
||||
shopOutOfStock: "स्टॉक में नहीं",
|
||||
shopBuy: "खरीदें",
|
||||
shopBuyEncryptedNote: "आपके डिलीवरी विवरण एन्क्रिप्टेड भेजे जाते हैं और केवल दुकान मालिक देख सकता है।",
|
||||
shopBuyDeliveryAddress: "डिलीवरी पता",
|
||||
shopBuyContact: "संपर्क",
|
||||
shopBuyContactPlaceholder: "ईमेल, फोन, आदि।",
|
||||
|
|
@ -3032,6 +3004,16 @@ module.exports = {
|
|||
industryDescriptionLabel: "विवरण",
|
||||
industryDescriptionPlaceholder: "यह फैक्टरी क्या उत्पादित करती है?",
|
||||
industrySector: "क्षेत्र",
|
||||
industrySector_software: "सॉफ़्टवेयर",
|
||||
industrySector_hardware: "हार्डवेयर",
|
||||
industrySector_agriculture: "कृषि",
|
||||
industrySector_textile: "वस्त्र",
|
||||
industrySector_energy: "ऊर्जा",
|
||||
industrySector_food: "खाद्य",
|
||||
industrySector_construction: "निर्माण",
|
||||
industrySector_media: "मीडिया",
|
||||
industrySector_services: "सेवाएँ",
|
||||
industrySector_other: "अन्य",
|
||||
industryMembershipPolicy: "सदस्यता नीति",
|
||||
industryQuorum: "कोरम",
|
||||
industryMajority: "बहुमत",
|
||||
|
|
@ -3233,6 +3215,18 @@ module.exports = {
|
|||
modulesFavoritesDescription: "अपनी पसंदीदा सामग्री प्रबंधित करने का मॉड्यूल।",
|
||||
fileTooLargeTitle: "फ़ाइल बहुत बड़ी",
|
||||
fileTooLargeMessage: "फ़ाइल अधिकतम अनुमत आकार (75 MB) से अधिक है। कृपया एक छोटी फ़ाइल चुनें।",
|
||||
fileWrongType: "यह फ़ाइल इस अनुभाग के लिए सही प्रकार की नहीं है।",
|
||||
mediaLockedAfterOpinions: "इस पर पहले से राय हैं: आप बाकी संपादित कर सकते हैं, लेकिन जिसका मूल्यांकन हुआ उसे नहीं बदल सकते।",
|
||||
pdfPrevPage: "पिछला पृष्ठ",
|
||||
pdfNextPage: "अगला पृष्ठ",
|
||||
pdfPage: "पृष्ठ",
|
||||
pdfZoomIn: "ज़ूम इन",
|
||||
pdfZoomOut: "ज़ूम आउट",
|
||||
pdfFullscreen: "पूर्ण स्क्रीन",
|
||||
pdfClose: "बंद करें",
|
||||
pdfRotate: "घुमाएँ",
|
||||
contentNotAvailableYet: "यह सामग्री अभी उपलब्ध नहीं है।",
|
||||
pdfRetry: "फिर से कोशिश करें",
|
||||
goBack: "वापस जाएँ",
|
||||
errorDismiss: "ठीक",
|
||||
errorPageTitle: "कुछ गलत हो गया",
|
||||
|
|
@ -3275,9 +3269,7 @@ module.exports = {
|
|||
uploadMedia: "मीडिया अपलोड करें (अधिकतम-आकार: 75MB)",
|
||||
feedOpenDiscussion: "चर्चा खोलें",
|
||||
feedDetailTitle: "फ़ीड",
|
||||
feedPostComment: "टिप्पणी पोस्ट करें",
|
||||
courtsRespondentInvalid: "एक वैध SSB ID होनी चाहिए (@...ed25519)",
|
||||
noComments: "अभी तक कोई टिप्पणी नहीं",
|
||||
mapsLabel: "मानचित्र",
|
||||
mapTitle: "मानचित्र",
|
||||
mapDescription: "अपने नेटवर्क में ऑफ़लाइन मानचित्र प्रबंधित और साझा करें।",
|
||||
|
|
@ -3581,6 +3573,8 @@ module.exports = {
|
|||
podcastTagsPlaceholder: "अल्पविराम से अलग करके टैग दर्ज करें",
|
||||
podcastFilterAll: "सभी",
|
||||
podcastFilterMine: "मेरे",
|
||||
podcastFilterFavorites: "पसंदीदा",
|
||||
podcastEpisodeNumberLabel: "एपिसोड संख्या",
|
||||
podcastFilterRecent: "हाल के",
|
||||
podcastFilterTop: "टॉप",
|
||||
podcastFilterViewers: "श्रोता",
|
||||
|
|
@ -3799,7 +3793,6 @@ module.exports = {
|
|||
wikiBackToPage: "पृष्ठ पर वापस",
|
||||
wikiLinkedChip: "जुड़ा",
|
||||
wikiRestoredSummary: "पुनर्स्थापित संस्करण",
|
||||
wikiExportPdf: "पृष्ठ बनाएँ",
|
||||
wikiPreview: "पूर्वावलोकन",
|
||||
tribeSectionWiki: "विकी",
|
||||
tribeWikiCreate: "विकी बनाएँ",
|
||||
|
|
@ -3867,6 +3860,9 @@ module.exports = {
|
|||
roomRecStop: "रिकॉर्डिंग रोकें",
|
||||
roomStreamFile: "स्ट्रीम करें",
|
||||
roomStreamStop: "रोकें",
|
||||
roomScreen: "स्क्रीन",
|
||||
roomScreenSharing: "स्क्रीन साझा हो रही है",
|
||||
roomScreenWarning: "जब तक आप रोकते नहीं, कमरे में सभी आपकी पूरी स्क्रीन देखेंगे।",
|
||||
roomStreaming: "स्ट्रीम हो रहा है",
|
||||
liveFullscreen: "पूर्ण स्क्रीन",
|
||||
roomStreamPickTitle: "वीडियो चुनें",
|
||||
|
|
@ -3924,6 +3920,7 @@ module.exports = {
|
|||
inviteAlreadyMember: "आप पहले से शामिल हैं। आप इसे इसके मॉड्यूल से खोल सकते हैं।",
|
||||
actionFailed: "यह नहीं हो सका। कृपया फिर से प्रयास करें।",
|
||||
publishTooShort: "आपकी पोस्ट बहुत छोटी है।",
|
||||
publishEmpty: "कुछ लिखें या कोई फ़ाइल जोड़ें।",
|
||||
walletSendFailed: "भुगतान नहीं भेजा जा सका: {reason}",
|
||||
pixeliaCoordsError: "ये निर्देशांक ग्रिड से बाहर हैं।",
|
||||
courtsErrTitle: "शीर्षक आवश्यक है।",
|
||||
|
|
@ -4435,6 +4432,9 @@ module.exports = {
|
|||
torrentTagsLabel: "टैग",
|
||||
torrentTagsPlaceholder: "अल्पविराम से अलग करके टैग दर्ज करें",
|
||||
torrentSizeLabel: "आकार",
|
||||
torrentInfoHash: "इन्फोहैश",
|
||||
torrentFilesLabel: "फ़ाइलें",
|
||||
torrentMagnet: "MAGNET",
|
||||
torrentFromContent: "टोरेंट बनाएँ",
|
||||
torrentFromContentMissing: "फ़ाइल अभी इस नोड पर उपलब्ध नहीं है। थोड़ी देर बाद फिर कोशिश करें।",
|
||||
torrentDownloads: "डाउनलोड",
|
||||
|
|
@ -4520,9 +4520,6 @@ module.exports = {
|
|||
inhabitantsPendingAccept: "स्वीकार",
|
||||
inhabitantsPendingReject: "अस्वीकार",
|
||||
tribeSectionGovernance: "शासन",
|
||||
tribePadInviteRequired: "पैड तक पहुंच नहीं। निमंत्रण मांगें।",
|
||||
tribeChatInviteRequired: "चैट तक पहुंच नहीं। निमंत्रण मांगें।",
|
||||
tribeGovernanceDesc: "इस जनजाति का आंतरिक शासन।",
|
||||
tribeGovernanceNoGov: "कोई सक्रिय सरकार नहीं",
|
||||
tribeGovernanceNoGovDesc: "इस जनजाति ने अभी सरकार नहीं चुनी।",
|
||||
tribeGovCardTitle: "वर्तमान सरकार",
|
||||
|
|
@ -4579,7 +4576,6 @@ module.exports = {
|
|||
logsFilterMonth: "पिछला महीना",
|
||||
logsFilterWeek: "पिछला सप्ताह",
|
||||
logsFilterToday: "आज",
|
||||
logsComments: "टिप्पणियाँ",
|
||||
logsAuthorEntries: "प्रविष्टियाँ",
|
||||
logsCreate: "प्रविष्टि बनाएँ",
|
||||
logsExport: "लॉग निर्यात करें",
|
||||
|
|
@ -4735,6 +4731,8 @@ module.exports = {
|
|||
larpWillTestTitle: "WILL परीक्षण",
|
||||
settingsLanDesc: "इस पीयर को समय-समय पर उसी स्थानीय नेटवर्क में अन्य Oasis इंस्टेंस को घोषित करें।",
|
||||
settingsLanEnable: "LAN प्रसारण सक्षम करें",
|
||||
settingsConnectionsTitle: "कनेक्शन",
|
||||
settingsConnectionsDesc: "यह नोड एक साथ कितने पीयर से जुड़ा रहता है।",
|
||||
settingsLanTitle: "LAN प्रसारण",
|
||||
settingsReplicationTitle: "प्रतिकृति",
|
||||
settingsReplicationDesc: "सामग्री की प्रतिकृति बनाते समय आपके पीयर द्वारा आपके अपने फ़ीड से आगे जाने वाले हॉप की संख्या कॉन्फ़िगर करें।",
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ module.exports = {
|
|||
graphosBackToMine: "← La mia rete",
|
||||
graphosShowing: "Mostrando",
|
||||
graphosYou: "Tu",
|
||||
graphosTotalNodes: "Nodi totali",
|
||||
graphosTotalNodes: "Totali",
|
||||
onlinePlural: "Online",
|
||||
mentions: "Menzioni",
|
||||
mentionsDescription: [
|
||||
strong("Ogni messaggio che ti @menziona"),
|
||||
|
|
@ -418,6 +419,7 @@ module.exports = {
|
|||
menuMedia: "Biblioteca",
|
||||
menuTools: "Strumenti",
|
||||
comment: "Commento",
|
||||
typeComment: "Commento",
|
||||
subtopic: "Sotto-argomento",
|
||||
json: "JSON",
|
||||
unfollow: "Rimuovi supporto",
|
||||
|
|
@ -447,9 +449,6 @@ module.exports = {
|
|||
publishCustomDescription: [
|
||||
"RICORDA: A causa della tecnologia blockchain, una volta pubblicato un post non può essere modificato né eliminato.",
|
||||
],
|
||||
commentWarning: [
|
||||
"RICORDA: A causa della tecnologia blockchain, una volta pubblicato un post non può essere modificato né eliminato.",
|
||||
],
|
||||
commentLabel: ({ publicOrPrivate, markdownUrl }) => [
|
||||
],
|
||||
publishLabel: ({ markdownUrl, linkTarget }) => [
|
||||
|
|
@ -728,10 +727,6 @@ module.exports = {
|
|||
votesQuestionLabel:"Domanda",
|
||||
voteStatusOpen:"APERTO",
|
||||
voteStatusClosed:"CHIUSO",
|
||||
commentDescription: ({ parentUrl }) => [
|
||||
" ha commentato in ",
|
||||
a({ href: parentUrl }, " discussione"),
|
||||
],
|
||||
commentTitle: ({ authorName }) => [`Commento al post di @${authorName}`],
|
||||
subtopicDescription: ({ parentUrl }) => [
|
||||
" ha creato un sotto-argomento da ",
|
||||
|
|
@ -866,6 +861,8 @@ module.exports = {
|
|||
bankRulesEligibility5: "Il PUB paga nel ciclo successivo e pubblica un trasferimento RBU che puoi confermare.",
|
||||
bankRulesEligibility6: "Se manca l'indirizzo, il feed è troppo giovane, hai rifiutato il mese o il PUB non ha fondi, la richiesta viene saltata e registrata sul PUB.",
|
||||
copyLinkTitle: "Copia link",
|
||||
cardMenuTitle: "Altre azioni",
|
||||
shareContent: "Condividi",
|
||||
linkBoxOasis: "Oasis",
|
||||
linkBoxClearnet: "Clearnet",
|
||||
bankRefuseUBI: "Rifiuta il RBU",
|
||||
|
|
@ -899,7 +896,7 @@ module.exports = {
|
|||
walletNotConfigured: "Il tuo portafoglio ECOin non è ancora configurato.",
|
||||
walletSettingsDescription: "Integra Oasis con il tuo portafoglio ECOin.",
|
||||
walletSettingsMobileDescription: "Collega Oasis all'app ECOin Wallet di questo dispositivo.",
|
||||
walletDownloadEcoin: "Scarica ECOin!",
|
||||
walletDownloadEcoin: "Scarica ECOin",
|
||||
walletRpcUrl: "URL RPC",
|
||||
walletStatusMessages: {
|
||||
invalid_amount: "Importo non valido",
|
||||
|
|
@ -952,6 +949,7 @@ module.exports = {
|
|||
bookmarkUpdateButton: "Aggiorna",
|
||||
bookmarkAddFavoriteButton: "Aggiungi preferito",
|
||||
bookmarkRemoveFavoriteButton: "Rimuovi preferito",
|
||||
bookmarkTitleLabel: "Titolo",
|
||||
bookmarkUrlLabel: "Link",
|
||||
bookmarkUrlPlaceholder: "https://solarnethub.com",
|
||||
bookmarkDescriptionLabel: "Descrizione",
|
||||
|
|
@ -1096,9 +1094,9 @@ module.exports = {
|
|||
allCVs: "CV",
|
||||
discoverPeople: "Scopri abitanti nella tua rete.",
|
||||
searchInhabitantsPlaceholder: "Cerca in Abitanti...",
|
||||
filterLocation: "FILTRA abitanti PER POSIZIONE …",
|
||||
filterLanguage: "FILTRA abitanti PER LINGUA …",
|
||||
filterSkills: "FILTRA abitanti PER COMPETENZE …",
|
||||
filterLocation: "Cerca per posizione…",
|
||||
filterLanguage: "Cerca per lingua…",
|
||||
filterSkills: "Cerca per competenze…",
|
||||
cvSkillsCount: "Competenze",
|
||||
applyFilters: "Applica filtri",
|
||||
locationLabel: "Posizione",
|
||||
|
|
@ -1114,6 +1112,7 @@ module.exports = {
|
|||
topactivitySectionTitle: "Top Attività",
|
||||
"TOP INACTIVITYButton": "PIÙ INATTIVI",
|
||||
topinactivitySectionTitle: "Abitanti meno attivi",
|
||||
contactsSectionTitle: "Contatti",
|
||||
blockedSectionTitle: "Bloccati",
|
||||
gallerySectionTitle: "GALLERIA",
|
||||
topSectionTitle: "TOP",
|
||||
|
|
@ -1567,9 +1566,7 @@ module.exports = {
|
|||
voteUnknownDate: "Data sconosciuta",
|
||||
voteDetailSectionTitle: 'Dettagli voto',
|
||||
voteCommentsLabel: 'Commenti',
|
||||
voteCommentsForumButton: 'Apri discussione',
|
||||
voteCommentsSectionTitle: 'Apri discussione',
|
||||
voteNoCommentsYet: 'Non ci sono ancora commenti. Sii il primo a rispondere.',
|
||||
voteNoCommentsYet: 'Non ci sono ancora commenti.',
|
||||
commentsGoToLast: "Vai all'ultimo commento",
|
||||
voteNewCommentPlaceholder: 'Scrivi il tuo commento qui…',
|
||||
voteNewCommentButton: 'Pubblica commento',
|
||||
|
|
@ -1696,7 +1693,6 @@ module.exports = {
|
|||
pollVoteButton: "Vota",
|
||||
pollChangeVote: "Cambia Voto",
|
||||
pollVoters: "Votanti",
|
||||
pollComments: "Commenti",
|
||||
pollStatusLabel: "Stato",
|
||||
pollStatusOpen: "APERTO",
|
||||
pollStatusClosed: "CHIUSO",
|
||||
|
|
@ -1718,9 +1714,22 @@ module.exports = {
|
|||
blogAllowComments: "Consenti commenti",
|
||||
blogCommentsClosed: "L'autore ha chiuso i commenti di questo blog.",
|
||||
blogNoItems: "Nessun blog trovato.",
|
||||
viewBlog: "Vedi Blog",
|
||||
forumTitle: "Forum",
|
||||
forumCategoryLabel: "Categoria",
|
||||
forumCatGENERAL: "GENERALE",
|
||||
forumCatPOLITICS: "POLITICA",
|
||||
forumCatTECH: "TECNOLOGIA",
|
||||
forumCatSCIENCE: "SCIENZA",
|
||||
forumCatMUSIC: "MUSICA",
|
||||
forumCatART: "ARTE",
|
||||
forumCatGAMING: "GIOCHI",
|
||||
forumCatBOOKS: "LIBRI",
|
||||
forumCatFILMS: "FILM",
|
||||
forumCatPHILOSOPHY: "FILOSOFIA",
|
||||
forumCatSOCIETY: "SOCIETÀ",
|
||||
forumCatPRIVACY: "PRIVACY",
|
||||
forumCatCYBERWARFARE: "CYBERGUERRA",
|
||||
forumCatSURVIVALISM: "SOPRAVVIVENZA",
|
||||
forumTitleLabel: "Titolo",
|
||||
forumTitlePlaceholder: "Titolo del forum...",
|
||||
forumCreateButton: "Crea forum",
|
||||
|
|
@ -1989,43 +1998,6 @@ module.exports = {
|
|||
reportsUnderReviewSectionTitle: "Segnalazioni in revisione",
|
||||
reportsResolvedSectionTitle: "Segnalazioni risolte",
|
||||
reportsInvalidSectionTitle: "Segnalazioni non valide",
|
||||
reportsBugTemplateTitle: 'Dettagli di riproduzione (Bug)',
|
||||
reportsFeatureTemplateTitle: 'Dettagli (Funzionalità)',
|
||||
reportsAbuseTemplateTitle: 'Dettagli (Abuso)',
|
||||
reportsContentTemplateTitle: 'Dettagli (Problemi di contenuto)',
|
||||
reportsStepsToReproduceLabel: 'Passaggi per riprodurre',
|
||||
reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...',
|
||||
reportsExpectedBehaviorLabel: 'Risultato atteso',
|
||||
reportsExpectedBehaviorPlaceholder: 'Cosa dovrebbe succedere?',
|
||||
reportsActualBehaviorLabel: 'Risultato effettivo',
|
||||
reportsActualBehaviorPlaceholder: 'Cosa succede effettivamente?',
|
||||
reportsEnvironmentLabel: 'Ambiente',
|
||||
reportsEnvironmentPlaceholder: 'Versione, dispositivo, SO, browser, impostazioni, log, ecc.',
|
||||
reportsReproduceRateLabel: 'Frequenza di riproduzione',
|
||||
reportsReproduceRateAlways: 'Sempre',
|
||||
reportsReproduceRateOften: 'Spesso',
|
||||
reportsReproduceRateSometimes: 'A volte',
|
||||
reportsReproduceRateRarely: 'Raramente',
|
||||
reportsReproduceRateUnable: 'Impossibile riprodurre',
|
||||
reportsReproduceRateUnknown: 'Non specificato',
|
||||
reportsProblemStatementLabel: 'Problema / necessità',
|
||||
reportsProblemStatementPlaceholder: 'Quale problema risolve questa funzionalità?',
|
||||
reportsUserStoryLabel: 'Storia abitante',
|
||||
reportsUserStoryPlaceholder: 'Come <abitante>, voglio <azione>, così che <beneficio>.',
|
||||
reportsAcceptanceCriteriaLabel: 'Criteri di accettazione',
|
||||
reportsAcceptanceCriteriaPlaceholder: '- Dato che...\n- Quando...\n- Allora...',
|
||||
reportsWhatHappenedLabel: 'Cosa è successo?',
|
||||
reportsWhatHappenedPlaceholder: 'Descrivi l\'incidente con contesto e date approssimative.',
|
||||
reportsReportedUserLabel: 'Abitante / entità segnalata',
|
||||
reportsReportedUserPlaceholder: '@abitante, feed, ID, link, ecc.',
|
||||
reportsEvidenceLinksLabel: 'Prove / link',
|
||||
reportsEvidenceLinksPlaceholder: 'Incolla link, ID messaggi, screenshot (se applicabile), ecc.',
|
||||
reportsContentLocationLabel: 'Dove si trova il contenuto',
|
||||
reportsContentLocationPlaceholder: 'Link, ID, canale, discussione, autore, ecc.',
|
||||
reportsWhyInappropriateLabel: "Perché è inappropriato",
|
||||
reportsWhyInappropriatePlaceholder: 'Spiega il motivo e l\'impatto.',
|
||||
reportsRequestedActionLabel: 'Azione richiesta',
|
||||
reportsRequestedActionPlaceholder: 'Rimuovi, nascondi, tagga, avvisa, ecc.',
|
||||
tribesTitle: "Tribù",
|
||||
tribeFilterAll: "TUTTE",
|
||||
tribeFilterRecent: "RECENTI",
|
||||
|
|
@ -2269,6 +2241,7 @@ module.exports = {
|
|||
voteWellResearched: "Ben documentato",
|
||||
voteNeedsSources: "Servono fonti",
|
||||
voteWrong: "Sbagliato",
|
||||
voteOutdated: "Obsoleto",
|
||||
voteLowQuality: "Bassa qualità",
|
||||
voteLove: "Amore",
|
||||
voteClear: "Cancella",
|
||||
|
|
@ -2513,7 +2486,6 @@ module.exports = {
|
|||
shopNoProducts: "Nessun prodotto ancora.",
|
||||
shopOutOfStock: "Esaurito",
|
||||
shopBuy: "Acquista",
|
||||
shopBuyEncryptedNote: "I tuoi dati di consegna vengono inviati cifrati e visibili solo al proprietario del negozio.",
|
||||
shopBuyDeliveryAddress: "Indirizzo di consegna",
|
||||
shopBuyContact: "Contatto",
|
||||
shopBuyContactPlaceholder: "email, telefono, ecc.",
|
||||
|
|
@ -3030,6 +3002,16 @@ module.exports = {
|
|||
industryDescriptionLabel: "Descrizione",
|
||||
industryDescriptionPlaceholder: "Cosa produce questa fabbrica?",
|
||||
industrySector: "Settore",
|
||||
industrySector_software: "Software",
|
||||
industrySector_hardware: "Hardware",
|
||||
industrySector_agriculture: "Agricoltura",
|
||||
industrySector_textile: "Tessile",
|
||||
industrySector_energy: "Energia",
|
||||
industrySector_food: "Alimentare",
|
||||
industrySector_construction: "Edilizia",
|
||||
industrySector_media: "Media",
|
||||
industrySector_services: "Servizi",
|
||||
industrySector_other: "Altro",
|
||||
industryMembershipPolicy: "Politica di adesione",
|
||||
industryQuorum: "Quorum",
|
||||
industryMajority: "Maggioranza",
|
||||
|
|
@ -3231,6 +3213,18 @@ module.exports = {
|
|||
modulesFavoritesDescription: "Modulo per gestire i tuoi contenuti preferiti.",
|
||||
fileTooLargeTitle: "File troppo grande",
|
||||
fileTooLargeMessage: "Il file supera la dimensione massima consentita (75 MB). Seleziona un file più piccolo.",
|
||||
fileWrongType: "Questo file non è del tipo giusto per questa sezione.",
|
||||
mediaLockedAfterOpinions: "Ha già delle opinioni: puoi modificare il resto, ma non ciò che è stato valutato.",
|
||||
pdfPrevPage: "Pagina precedente",
|
||||
pdfNextPage: "Pagina successiva",
|
||||
pdfPage: "Pagina",
|
||||
pdfZoomIn: "Ingrandisci",
|
||||
pdfZoomOut: "Riduci",
|
||||
pdfFullscreen: "Schermo intero",
|
||||
pdfClose: "Chiudi",
|
||||
pdfRotate: "Ruota",
|
||||
contentNotAvailableYet: "Questo contenuto non è ancora disponibile.",
|
||||
pdfRetry: "Riprova",
|
||||
goBack: "Torna indietro",
|
||||
errorDismiss: "OK",
|
||||
errorPageTitle: "Si è verificato un errore",
|
||||
|
|
@ -3275,8 +3269,6 @@ module.exports = {
|
|||
courtsRespondentInvalid: "Deve essere un ID SSB valido (@...ed25519)",
|
||||
feedDetailTitle: "Feed",
|
||||
feedOpenDiscussion: "Apri discussione",
|
||||
feedPostComment: "Pubblica commento",
|
||||
noComments: "Nessun commento ancora",
|
||||
mapsLabel: "Mappe",
|
||||
mapTitle: "Mappe",
|
||||
mapDescription: "Gestisci e condividi mappe offline nella tua rete.",
|
||||
|
|
@ -3580,6 +3572,8 @@ module.exports = {
|
|||
podcastTagsPlaceholder: "Inserisci tag separati da virgole",
|
||||
podcastFilterAll: "TUTTI",
|
||||
podcastFilterMine: "MIEI",
|
||||
podcastFilterFavorites: "PREFERITI",
|
||||
podcastEpisodeNumberLabel: "Numero di episodio",
|
||||
podcastFilterRecent: "RECENTI",
|
||||
podcastFilterTop: "TOP",
|
||||
podcastFilterViewers: "ASCOLTATORI",
|
||||
|
|
@ -3798,7 +3792,6 @@ module.exports = {
|
|||
wikiBackToPage: "Torna alla pagina",
|
||||
wikiLinkedChip: "Collegata",
|
||||
wikiRestoredSummary: "Versione ripristinata",
|
||||
wikiExportPdf: "Genera pagina",
|
||||
wikiPreview: "Anteprima",
|
||||
tribeSectionWiki: "WIKI",
|
||||
tribeWikiCreate: "Crea wiki",
|
||||
|
|
@ -3866,6 +3859,9 @@ module.exports = {
|
|||
roomRecStop: "Ferma la registrazione",
|
||||
roomStreamFile: "Trasmetti",
|
||||
roomStreamStop: "Ferma",
|
||||
roomScreen: "Schermo",
|
||||
roomScreenSharing: "Condivisione schermo",
|
||||
roomScreenWarning: "Tutti nella stanza vedranno il tuo schermo intero finché non ti fermi.",
|
||||
roomStreaming: "In trasmissione",
|
||||
liveFullscreen: "Schermo intero",
|
||||
roomStreamPickTitle: "Scegli un video",
|
||||
|
|
@ -3923,6 +3919,7 @@ module.exports = {
|
|||
inviteAlreadyMember: "Ne fai già parte. Puoi aprirlo dal suo modulo.",
|
||||
actionFailed: "Non è stato possibile farlo. Riprova.",
|
||||
publishTooShort: "Il tuo post è troppo corto.",
|
||||
publishEmpty: "Scrivi qualcosa o allega un file.",
|
||||
walletSendFailed: "Non è stato possibile inviare il pagamento: {reason}",
|
||||
pixeliaCoordsError: "Quelle coordinate sono fuori dalla griglia.",
|
||||
courtsErrTitle: "Serve un titolo.",
|
||||
|
|
@ -4434,6 +4431,9 @@ module.exports = {
|
|||
torrentTagsLabel: "Tag",
|
||||
torrentTagsPlaceholder: "Inserisci tag separati da virgole",
|
||||
torrentSizeLabel: "Dimensione",
|
||||
torrentInfoHash: "Infohash",
|
||||
torrentFilesLabel: "File",
|
||||
torrentMagnet: "MAGNET",
|
||||
torrentFromContent: "Genera torrent",
|
||||
torrentFromContentMissing: "Il file non è ancora disponibile su questo nodo. Riprova tra un po'.",
|
||||
torrentDownloads: "Download",
|
||||
|
|
@ -4519,9 +4519,6 @@ module.exports = {
|
|||
inhabitantsPendingAccept: "Accetta",
|
||||
inhabitantsPendingReject: "Rifiuta",
|
||||
tribeSectionGovernance: "GOVERNANCE",
|
||||
tribePadInviteRequired: "Non hai accesso al pad. Richiedi un invito.",
|
||||
tribeChatInviteRequired: "Non hai accesso alla chat. Richiedi un invito.",
|
||||
tribeGovernanceDesc: "Governance interna di questa tribù.",
|
||||
tribeGovernanceNoGov: "Nessun governo attivo",
|
||||
tribeGovernanceNoGovDesc: "Questa tribù non ha ancora eletto un governo.",
|
||||
tribeGovCardTitle: "Governo Attuale",
|
||||
|
|
@ -4578,7 +4575,6 @@ module.exports = {
|
|||
logsFilterMonth: "ULTIMO MESE",
|
||||
logsFilterWeek: "ULTIMA SETTIMANA",
|
||||
logsFilterToday: "OGGI",
|
||||
logsComments: "Commenti",
|
||||
logsAuthorEntries: "Voci",
|
||||
logsCreate: "Crea voce",
|
||||
logsExport: "Esporta diario",
|
||||
|
|
@ -4657,6 +4653,8 @@ module.exports = {
|
|||
larpWillTestTitle: "Test WILL",
|
||||
settingsLanDesc: "Annuncia periodicamente questo peer ad altre istanze di Oasis sulla stessa rete locale.",
|
||||
settingsLanEnable: "Abilita trasmissione LAN",
|
||||
settingsConnectionsTitle: "Connessioni",
|
||||
settingsConnectionsDesc: "Quanti peer questo nodo mantiene connessi contemporaneamente.",
|
||||
settingsLanTitle: "Trasmissione LAN",
|
||||
settingsReplicationTitle: "Replicazione",
|
||||
settingsReplicationDesc: "Configura il numero di salti che il tuo peer segue dal tuo feed durante la replica dei contenuti.",
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ module.exports = {
|
|||
graphosBackToMine: "← Minha rede",
|
||||
graphosShowing: "Mostrando",
|
||||
graphosYou: "Você",
|
||||
graphosTotalNodes: "Total de nós",
|
||||
graphosTotalNodes: "Totais",
|
||||
onlinePlural: "Online",
|
||||
mentions: "Menções",
|
||||
mentionsDescription: [
|
||||
strong("Todas as mensagens que te @mencionam"),
|
||||
|
|
@ -418,6 +419,7 @@ module.exports = {
|
|||
menuMedia: "Biblioteca",
|
||||
menuTools: "Ferramentas",
|
||||
comment: "Comentário",
|
||||
typeComment: "Comentário",
|
||||
subtopic: "Subtópico",
|
||||
json: "JSON",
|
||||
unfollow: "Deixar de apoiar",
|
||||
|
|
@ -447,9 +449,6 @@ module.exports = {
|
|||
publishCustomDescription: [
|
||||
"LEMBRA-TE: Devido à tecnologia blockchain, uma vez publicado, não pode ser editado nem eliminado.",
|
||||
],
|
||||
commentWarning: [
|
||||
"LEMBRA-TE: Devido à tecnologia blockchain, uma vez publicado, não pode ser editado nem eliminado.",
|
||||
],
|
||||
commentLabel: ({ publicOrPrivate, markdownUrl }) => [
|
||||
],
|
||||
publishLabel: ({ markdownUrl, linkTarget }) => [
|
||||
|
|
@ -728,10 +727,6 @@ module.exports = {
|
|||
votesQuestionLabel:"Pergunta",
|
||||
voteStatusOpen:"ABERTO",
|
||||
voteStatusClosed:"FECHADO",
|
||||
commentDescription: ({ parentUrl }) => [
|
||||
" comentou em ",
|
||||
a({ href: parentUrl }, " conversa"),
|
||||
],
|
||||
commentTitle: ({ authorName }) => [`Comentário na publicação de @${authorName}`],
|
||||
subtopicDescription: ({ parentUrl }) => [
|
||||
" criou um subtópico de ",
|
||||
|
|
@ -866,6 +861,8 @@ module.exports = {
|
|||
bankRulesEligibility5: "O PUB paga no ciclo seguinte e publica um transfer RBU que podes confirmar.",
|
||||
bankRulesEligibility6: "Se falta o endereço, o feed é demasiado novo, recusaste o mês ou o PUB não tem fundos, a reclamação é ignorada e registada no PUB.",
|
||||
copyLinkTitle: "Copiar ligação",
|
||||
cardMenuTitle: "Mais ações",
|
||||
shareContent: "Partilhar",
|
||||
linkBoxOasis: "Oasis",
|
||||
linkBoxClearnet: "Clearnet",
|
||||
bankRefuseUBI: "Recusar RBU",
|
||||
|
|
@ -899,7 +896,7 @@ module.exports = {
|
|||
walletNotConfigured: "A sua carteira ECOin ainda não está configurada.",
|
||||
walletSettingsDescription: "Integra o Oasis com a tua carteira ECOin.",
|
||||
walletSettingsMobileDescription: "Ligue o Oasis à app ECOin Wallet deste dispositivo.",
|
||||
walletDownloadEcoin: "Descarregar ECOin!",
|
||||
walletDownloadEcoin: "Descarregar ECOin",
|
||||
walletRpcUrl: "URL do RPC",
|
||||
walletStatusMessages: {
|
||||
invalid_amount: "Montante inválido",
|
||||
|
|
@ -952,6 +949,7 @@ module.exports = {
|
|||
bookmarkUpdateButton: "Atualizar",
|
||||
bookmarkAddFavoriteButton: "Adicionar favorito",
|
||||
bookmarkRemoveFavoriteButton: "Remover favorito",
|
||||
bookmarkTitleLabel: "Título",
|
||||
bookmarkUrlLabel: "Link",
|
||||
bookmarkUrlPlaceholder: "https://solarnethub.com",
|
||||
bookmarkDescriptionLabel: "Descrição",
|
||||
|
|
@ -1096,9 +1094,9 @@ module.exports = {
|
|||
allCVs: "CVs",
|
||||
discoverPeople: "Descobre habitantes na tua rede.",
|
||||
searchInhabitantsPlaceholder: "Pesquisar em Habitantes...",
|
||||
filterLocation: "FILTRAR habitantes POR LOCALIZAÇÃO …",
|
||||
filterLanguage: "FILTRAR habitantes POR IDIOMA …",
|
||||
filterSkills: "FILTRAR habitantes POR COMPETÊNCIAS …",
|
||||
filterLocation: "Pesquisar por localização…",
|
||||
filterLanguage: "Pesquisar por idioma…",
|
||||
filterSkills: "Pesquisar por competências…",
|
||||
cvSkillsCount: "Competências",
|
||||
applyFilters: "Aplicar filtros",
|
||||
locationLabel: "Localização",
|
||||
|
|
@ -1114,6 +1112,7 @@ module.exports = {
|
|||
topactivitySectionTitle: "Top Atividade",
|
||||
"TOP INACTIVITYButton": "MAIS INATIVOS",
|
||||
topinactivitySectionTitle: "Habitantes menos ativos",
|
||||
contactsSectionTitle: "Contactos",
|
||||
blockedSectionTitle: "Bloqueados",
|
||||
gallerySectionTitle: "GALERIA",
|
||||
topSectionTitle: "TOP",
|
||||
|
|
@ -1567,9 +1566,7 @@ module.exports = {
|
|||
voteUnknownDate: "Data desconhecida",
|
||||
voteDetailSectionTitle: 'Detalhes da votação',
|
||||
voteCommentsLabel: 'Comentários',
|
||||
voteCommentsForumButton: 'Abrir discussão',
|
||||
voteCommentsSectionTitle: 'Abrir discussão',
|
||||
voteNoCommentsYet: 'Ainda sem comentários. Sê o primeiro a responder.',
|
||||
voteNoCommentsYet: 'Ainda sem comentários.',
|
||||
commentsGoToLast: "Ir para o último comentário",
|
||||
voteNewCommentPlaceholder: 'Escreve o teu comentário aqui…',
|
||||
voteNewCommentButton: 'Publicar comentário',
|
||||
|
|
@ -1696,7 +1693,6 @@ module.exports = {
|
|||
pollVoteButton: "Votar",
|
||||
pollChangeVote: "Mudar Voto",
|
||||
pollVoters: "Votantes",
|
||||
pollComments: "Comentários",
|
||||
pollStatusLabel: "Estado",
|
||||
pollStatusOpen: "ABERTA",
|
||||
pollStatusClosed: "FECHADA",
|
||||
|
|
@ -1718,9 +1714,22 @@ module.exports = {
|
|||
blogAllowComments: "Permitir comentários",
|
||||
blogCommentsClosed: "O autor fechou os comentários deste blog.",
|
||||
blogNoItems: "Nenhum blog encontrado.",
|
||||
viewBlog: "Ver Blog",
|
||||
forumTitle: "Fóruns",
|
||||
forumCategoryLabel: "Categoria",
|
||||
forumCatGENERAL: "GERAL",
|
||||
forumCatPOLITICS: "POLÍTICA",
|
||||
forumCatTECH: "TECNOLOGIA",
|
||||
forumCatSCIENCE: "CIÊNCIA",
|
||||
forumCatMUSIC: "MÚSICA",
|
||||
forumCatART: "ARTE",
|
||||
forumCatGAMING: "JOGOS",
|
||||
forumCatBOOKS: "LIVROS",
|
||||
forumCatFILMS: "FILMES",
|
||||
forumCatPHILOSOPHY: "FILOSOFIA",
|
||||
forumCatSOCIETY: "SOCIEDADE",
|
||||
forumCatPRIVACY: "PRIVACIDADE",
|
||||
forumCatCYBERWARFARE: "CIBERGUERRA",
|
||||
forumCatSURVIVALISM: "SOBREVIVÊNCIA",
|
||||
forumTitleLabel: "Título",
|
||||
forumTitlePlaceholder: "Título do fórum...",
|
||||
forumCreateButton: "Criar fórum",
|
||||
|
|
@ -1989,43 +1998,6 @@ module.exports = {
|
|||
reportsUnderReviewSectionTitle: "Relatórios em análise",
|
||||
reportsResolvedSectionTitle: "Relatórios resolvidos",
|
||||
reportsInvalidSectionTitle: "Relatórios inválidos",
|
||||
reportsBugTemplateTitle: 'Detalhes de reprodução (Bugs)',
|
||||
reportsFeatureTemplateTitle: 'Detalhes (Funcionalidades)',
|
||||
reportsAbuseTemplateTitle: 'Detalhes (Abuso)',
|
||||
reportsContentTemplateTitle: 'Detalhes (Problemas de conteúdo)',
|
||||
reportsStepsToReproduceLabel: 'Passos para reproduzir',
|
||||
reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...',
|
||||
reportsExpectedBehaviorLabel: 'Resultado esperado',
|
||||
reportsExpectedBehaviorPlaceholder: 'O que deveria acontecer?',
|
||||
reportsActualBehaviorLabel: 'Resultado real',
|
||||
reportsActualBehaviorPlaceholder: 'O que realmente acontece?',
|
||||
reportsEnvironmentLabel: 'Ambiente',
|
||||
reportsEnvironmentPlaceholder: 'Versão, dispositivo, SO, navegador, definições, registos, etc.',
|
||||
reportsReproduceRateLabel: 'Taxa de reprodução',
|
||||
reportsReproduceRateAlways: 'Sempre',
|
||||
reportsReproduceRateOften: 'Frequentemente',
|
||||
reportsReproduceRateSometimes: 'Às vezes',
|
||||
reportsReproduceRateRarely: 'Raramente',
|
||||
reportsReproduceRateUnable: 'Impossível reproduzir',
|
||||
reportsReproduceRateUnknown: 'Não especificado',
|
||||
reportsProblemStatementLabel: 'Problema / necessidade',
|
||||
reportsProblemStatementPlaceholder: 'Que problema esta funcionalidade resolve?',
|
||||
reportsUserStoryLabel: 'História do habitante',
|
||||
reportsUserStoryPlaceholder: 'Como <habitante>, quero <ação>, para que <benefício>.',
|
||||
reportsAcceptanceCriteriaLabel: 'Critérios de aceitação',
|
||||
reportsAcceptanceCriteriaPlaceholder: '- Dado...\n- Quando...\n- Então...',
|
||||
reportsWhatHappenedLabel: 'O que aconteceu?',
|
||||
reportsWhatHappenedPlaceholder: 'Descreve o incidente com contexto e datas aproximadas.',
|
||||
reportsReportedUserLabel: 'Habitante / entidade denunciada',
|
||||
reportsReportedUserPlaceholder: '@habitante, feed, ID, link, etc.',
|
||||
reportsEvidenceLinksLabel: 'Provas / links',
|
||||
reportsEvidenceLinksPlaceholder: 'Cola links, IDs de mensagens, capturas de ecrã (se aplicável), etc.',
|
||||
reportsContentLocationLabel: 'Onde está o conteúdo',
|
||||
reportsContentLocationPlaceholder: 'Link, ID, canal, conversa, autor, etc.',
|
||||
reportsWhyInappropriateLabel: "Porquê inapropriado",
|
||||
reportsWhyInappropriatePlaceholder: 'Explica o motivo e o impacto.',
|
||||
reportsRequestedActionLabel: 'Ação solicitada',
|
||||
reportsRequestedActionPlaceholder: 'Remover, ocultar, etiquetar, avisar, etc.',
|
||||
tribesTitle: "Tribos",
|
||||
tribeFilterAll: "TODAS",
|
||||
tribeFilterRecent: "RECENTES",
|
||||
|
|
@ -2269,6 +2241,7 @@ module.exports = {
|
|||
voteWellResearched: "Bem pesquisado",
|
||||
voteNeedsSources: "Precisa fontes",
|
||||
voteWrong: "Errado",
|
||||
voteOutdated: "Desatualizado",
|
||||
voteLowQuality: "Baixa qualidade",
|
||||
voteLove: "Amor",
|
||||
voteClear: "Limpar",
|
||||
|
|
@ -2513,7 +2486,6 @@ module.exports = {
|
|||
shopNoProducts: "Nenhum produto ainda.",
|
||||
shopOutOfStock: "Esgotado",
|
||||
shopBuy: "Comprar",
|
||||
shopBuyEncryptedNote: "Os teus dados de entrega são enviados cifrados, visíveis apenas para o dono da loja.",
|
||||
shopBuyDeliveryAddress: "Endereço de entrega",
|
||||
shopBuyContact: "Contacto",
|
||||
shopBuyContactPlaceholder: "email, telefone, etc.",
|
||||
|
|
@ -3030,6 +3002,16 @@ module.exports = {
|
|||
industryDescriptionLabel: "Descrição",
|
||||
industryDescriptionPlaceholder: "O que esta fábrica produz?",
|
||||
industrySector: "Setor",
|
||||
industrySector_software: "Software",
|
||||
industrySector_hardware: "Hardware",
|
||||
industrySector_agriculture: "Agricultura",
|
||||
industrySector_textile: "Têxtil",
|
||||
industrySector_energy: "Energia",
|
||||
industrySector_food: "Alimentação",
|
||||
industrySector_construction: "Construção",
|
||||
industrySector_media: "Media",
|
||||
industrySector_services: "Serviços",
|
||||
industrySector_other: "Outros",
|
||||
industryMembershipPolicy: "Política de adesão",
|
||||
industryQuorum: "Quórum",
|
||||
industryMajority: "Maioria",
|
||||
|
|
@ -3231,6 +3213,18 @@ module.exports = {
|
|||
modulesFavoritesDescription: "Módulo para gerir o teu conteúdo favorito.",
|
||||
fileTooLargeTitle: "Ficheiro demasiado grande",
|
||||
fileTooLargeMessage: "O ficheiro excede o tamanho máximo permitido (75 MB). Por favor, seleciona um ficheiro mais pequeno.",
|
||||
fileWrongType: "Este ficheiro não é do tipo adequado para esta secção.",
|
||||
mediaLockedAfterOpinions: "Já tem opiniões: podes editar o resto, mas não mudar o que foi avaliado.",
|
||||
pdfPrevPage: "Página anterior",
|
||||
pdfNextPage: "Página seguinte",
|
||||
pdfPage: "Página",
|
||||
pdfZoomIn: "Aproximar",
|
||||
pdfZoomOut: "Afastar",
|
||||
pdfFullscreen: "Ecrã inteiro",
|
||||
pdfClose: "Fechar",
|
||||
pdfRotate: "Rodar",
|
||||
contentNotAvailableYet: "Este conteúdo ainda não está disponível.",
|
||||
pdfRetry: "Tentar de novo",
|
||||
goBack: "Voltar",
|
||||
errorDismiss: "OK",
|
||||
errorPageTitle: "Algo correu mal",
|
||||
|
|
@ -3275,8 +3269,6 @@ module.exports = {
|
|||
courtsRespondentInvalid: "Demandado inválido",
|
||||
feedDetailTitle: "Detalhe do feed",
|
||||
feedOpenDiscussion: "Abrir discussão",
|
||||
feedPostComment: "Publicar comentário",
|
||||
noComments: "Ainda sem comentários.",
|
||||
mapsLabel: "Mapas",
|
||||
mapTitle: "Mapas",
|
||||
mapDescription: "Gerencie e compartilhe mapas offline na sua rede.",
|
||||
|
|
@ -3580,6 +3572,8 @@ module.exports = {
|
|||
podcastTagsPlaceholder: "Introduz tags separadas por vírgulas",
|
||||
podcastFilterAll: "TODOS",
|
||||
podcastFilterMine: "MEUS",
|
||||
podcastFilterFavorites: "FAVORITOS",
|
||||
podcastEpisodeNumberLabel: "Número do episódio",
|
||||
podcastFilterRecent: "RECENTES",
|
||||
podcastFilterTop: "TOP",
|
||||
podcastFilterViewers: "OUVINTES",
|
||||
|
|
@ -3798,7 +3792,6 @@ module.exports = {
|
|||
wikiBackToPage: "Voltar à página",
|
||||
wikiLinkedChip: "Ligada",
|
||||
wikiRestoredSummary: "Versão restaurada",
|
||||
wikiExportPdf: "Gerar página",
|
||||
wikiPreview: "Pré-visualizar",
|
||||
tribeSectionWiki: "WIKI",
|
||||
tribeWikiCreate: "Criar wiki",
|
||||
|
|
@ -3866,6 +3859,9 @@ module.exports = {
|
|||
roomRecStop: "Parar a gravação",
|
||||
roomStreamFile: "Transmitir",
|
||||
roomStreamStop: "Parar",
|
||||
roomScreen: "Ecrã",
|
||||
roomScreenSharing: "Partilhando ecrã",
|
||||
roomScreenWarning: "Todos na sala verão o teu ecrã inteiro até parares.",
|
||||
roomStreaming: "A transmitir",
|
||||
liveFullscreen: "Ecrã inteiro",
|
||||
roomStreamPickTitle: "Escolhe um vídeo",
|
||||
|
|
@ -3923,6 +3919,7 @@ module.exports = {
|
|||
inviteAlreadyMember: "Já fazes parte. Podes abri-lo a partir do seu módulo.",
|
||||
actionFailed: "Não foi possível fazê-lo. Tenta de novo.",
|
||||
publishTooShort: "A tua publicação é demasiado curta.",
|
||||
publishEmpty: "Escreve algo ou anexa um ficheiro.",
|
||||
walletSendFailed: "Não foi possível enviar o pagamento: {reason}",
|
||||
pixeliaCoordsError: "Essas coordenadas estão fora da grelha.",
|
||||
courtsErrTitle: "É necessário um título.",
|
||||
|
|
@ -4434,6 +4431,9 @@ module.exports = {
|
|||
torrentTagsLabel: "Tags",
|
||||
torrentTagsPlaceholder: "Introduz tags separadas por vírgulas",
|
||||
torrentSizeLabel: "Tamanho",
|
||||
torrentInfoHash: "Infohash",
|
||||
torrentFilesLabel: "Ficheiros",
|
||||
torrentMagnet: "MAGNET",
|
||||
torrentFromContent: "Gerar torrent",
|
||||
torrentFromContentMissing: "O ficheiro ainda não está disponível neste nó. Tenta de novo mais tarde.",
|
||||
torrentDownloads: "Transferências",
|
||||
|
|
@ -4519,9 +4519,6 @@ module.exports = {
|
|||
inhabitantsPendingAccept: "Aceitar",
|
||||
inhabitantsPendingReject: "Rejeitar",
|
||||
tribeSectionGovernance: "GOVERNANÇA",
|
||||
tribePadInviteRequired: "Sem acesso ao pad. Peça um convite.",
|
||||
tribeChatInviteRequired: "Sem acesso ao chat. Peça um convite.",
|
||||
tribeGovernanceDesc: "Governança interna desta tribo.",
|
||||
tribeGovernanceNoGov: "Sem governo ativo",
|
||||
tribeGovernanceNoGovDesc: "Esta tribo ainda não elegeu governo.",
|
||||
tribeGovCardTitle: "Governo Atual",
|
||||
|
|
@ -4578,7 +4575,6 @@ module.exports = {
|
|||
logsFilterMonth: "ÚLTIMO MÊS",
|
||||
logsFilterWeek: "ÚLTIMA SEMANA",
|
||||
logsFilterToday: "HOJE",
|
||||
logsComments: "Comentários",
|
||||
logsAuthorEntries: "Entradas",
|
||||
logsCreate: "Criar entrada",
|
||||
logsExport: "Exportar diário",
|
||||
|
|
@ -4672,6 +4668,8 @@ module.exports = {
|
|||
larpWillTestTitle: "Teste WILL",
|
||||
settingsLanDesc: "Anuncia periodicamente este peer a outras instâncias do Oasis na mesma rede local.",
|
||||
settingsLanEnable: "Ativar difusão LAN",
|
||||
settingsConnectionsTitle: "Ligações",
|
||||
settingsConnectionsDesc: "Quantos peers este nó mantém ligados ao mesmo tempo.",
|
||||
settingsLanTitle: "Difusão LAN",
|
||||
settingsReplicationTitle: "Replicação",
|
||||
settingsReplicationDesc: "Configura o número de saltos que o teu peer segue a partir do teu próprio feed ao replicar conteúdo.",
|
||||
|
|
|
|||
|
|
@ -211,7 +211,8 @@ module.exports = {
|
|||
graphosBackToMine: "← Моя сеть",
|
||||
graphosShowing: "Показано",
|
||||
graphosYou: "Вы",
|
||||
graphosTotalNodes: "Всего узлов",
|
||||
graphosTotalNodes: "Всего",
|
||||
onlinePlural: "В сети",
|
||||
mentions: "Упоминания",
|
||||
mentionsDescription: [
|
||||
strong("Каждое сообщение, где вас @упоминают"),
|
||||
|
|
@ -429,6 +430,7 @@ module.exports = {
|
|||
menuMedia: "Библиотека",
|
||||
menuTools: "Инструменты",
|
||||
comment: "Комментарий",
|
||||
typeComment: "Комментарий",
|
||||
subtopic: "Подтема",
|
||||
json: "JSON",
|
||||
unfollow: "Отменить поддержку",
|
||||
|
|
@ -458,9 +460,6 @@ module.exports = {
|
|||
publishCustomDescription: [
|
||||
"ПОМНИТЕ: Благодаря технологии блокчейн, после публикации запись невозможно отредактировать или удалить.",
|
||||
],
|
||||
commentWarning: [
|
||||
"ПОМНИТЕ: Благодаря технологии блокчейн, после публикации запись невозможно отредактировать или удалить.",
|
||||
],
|
||||
commentLabel: ({ publicOrPrivate, markdownUrl }) => [
|
||||
],
|
||||
publishLabel: ({ markdownUrl, linkTarget }) => [
|
||||
|
|
@ -739,10 +738,6 @@ module.exports = {
|
|||
votesQuestionLabel:"Вопрос",
|
||||
voteStatusOpen:"ОТКРЫТО",
|
||||
voteStatusClosed:"ЗАКРЫТО",
|
||||
commentDescription: ({ parentUrl }) => [
|
||||
" прокомментировал ",
|
||||
a({ href: parentUrl }, " обсуждение"),
|
||||
],
|
||||
commentTitle: ({ authorName }) => [`Комментарий к записи @${authorName}`],
|
||||
subtopicDescription: ({ parentUrl }) => [
|
||||
" создал подтему из ",
|
||||
|
|
@ -877,6 +872,8 @@ module.exports = {
|
|||
bankRulesEligibility5: "PUB платит в следующем цикле и публикует перевод ББД, который вы можете подтвердить.",
|
||||
bankRulesEligibility6: "Если нет адреса, лента слишком молода, месяц отклонён или у PUB нет средств, запрос пропускается и записывается в журнал PUB.",
|
||||
copyLinkTitle: "Скопировать ссылку",
|
||||
cardMenuTitle: "Другие действия",
|
||||
shareContent: "Поделиться",
|
||||
linkBoxOasis: "Oasis",
|
||||
linkBoxClearnet: "Clearnet",
|
||||
bankRefuseUBI: "Отказаться от ББД",
|
||||
|
|
@ -910,7 +907,7 @@ module.exports = {
|
|||
walletNotConfigured: "Ваш кошелёк ECOin ещё не настроен.",
|
||||
walletSettingsDescription: "Интегрируйте Oasis с вашим кошельком ECOin.",
|
||||
walletSettingsMobileDescription: "Свяжите Oasis с приложением ECOin Wallet на этом устройстве.",
|
||||
walletDownloadEcoin: "Скачать ECOin!",
|
||||
walletDownloadEcoin: "Скачать ECOin",
|
||||
walletRpcUrl: "URL RPC",
|
||||
walletStatusMessages: {
|
||||
invalid_amount: "Неверная сумма",
|
||||
|
|
@ -963,6 +960,7 @@ module.exports = {
|
|||
bookmarkUpdateButton: "Обновить",
|
||||
bookmarkAddFavoriteButton: "В избранное",
|
||||
bookmarkRemoveFavoriteButton: "Из избранного",
|
||||
bookmarkTitleLabel: "Заголовок",
|
||||
bookmarkUrlLabel: "Ссылка",
|
||||
bookmarkUrlPlaceholder: "https://solarnethub.com",
|
||||
bookmarkDescriptionLabel: "Описание",
|
||||
|
|
@ -1107,9 +1105,9 @@ module.exports = {
|
|||
allCVs: "Резюме",
|
||||
discoverPeople: "Находите жителей в вашей сети.",
|
||||
searchInhabitantsPlaceholder: "Поиск в Жители...",
|
||||
filterLocation: "ФИЛЬТР жителей ПО МЕСТОПОЛОЖЕНИЮ...",
|
||||
filterLanguage: "ФИЛЬТР жителей ПО ЯЗЫКУ...",
|
||||
filterSkills: "ФИЛЬТР жителей ПО НАВЫКАМ...",
|
||||
filterLocation: "Искать по местоположению…",
|
||||
filterLanguage: "Искать по языку…",
|
||||
filterSkills: "Искать по навыкам…",
|
||||
cvSkillsCount: "Навыки",
|
||||
applyFilters: "Применить фильтры",
|
||||
locationLabel: "Местоположение",
|
||||
|
|
@ -1125,6 +1123,7 @@ module.exports = {
|
|||
topactivitySectionTitle: "Наибольшая активность",
|
||||
"TOP INACTIVITYButton": "НЕАКТИВНЫЕ",
|
||||
topinactivitySectionTitle: "Наименее активные жители",
|
||||
contactsSectionTitle: "Контакты",
|
||||
blockedSectionTitle: "Заблокированные",
|
||||
gallerySectionTitle: "ГАЛЕРЕЯ",
|
||||
topSectionTitle: "ТОП",
|
||||
|
|
@ -1577,9 +1576,7 @@ module.exports = {
|
|||
voteUnknownDate: "Неизвестная дата",
|
||||
voteDetailSectionTitle: 'Детали голосования',
|
||||
voteCommentsLabel: 'Комментарии',
|
||||
voteCommentsForumButton: 'Открыть обсуждение',
|
||||
voteCommentsSectionTitle: 'Открыть обсуждение',
|
||||
voteNoCommentsYet: 'Комментариев пока нет. Будьте первым.',
|
||||
voteNoCommentsYet: 'Комментариев пока нет.',
|
||||
commentsGoToLast: "Перейти к последнему комментарию",
|
||||
voteNewCommentPlaceholder: 'Напишите ваш комментарий...',
|
||||
voteNewCommentButton: 'Отправить комментарий',
|
||||
|
|
@ -1706,7 +1703,6 @@ module.exports = {
|
|||
pollVoteButton: "Голосовать",
|
||||
pollChangeVote: "Изменить голос",
|
||||
pollVoters: "Проголосовали",
|
||||
pollComments: "Комментарии",
|
||||
pollStatusLabel: "Статус",
|
||||
pollStatusOpen: "ОТКРЫТ",
|
||||
pollStatusClosed: "ЗАКРЫТ",
|
||||
|
|
@ -1728,9 +1724,22 @@ module.exports = {
|
|||
blogAllowComments: "Разрешить комментарии",
|
||||
blogCommentsClosed: "Автор закрыл комментарии к этому блогу.",
|
||||
blogNoItems: "Блоги не найдены.",
|
||||
viewBlog: "Смотреть блог",
|
||||
forumTitle: "Форумы",
|
||||
forumCategoryLabel: "Категория",
|
||||
forumCatGENERAL: "ОБЩЕЕ",
|
||||
forumCatPOLITICS: "ПОЛИТИКА",
|
||||
forumCatTECH: "ТЕХНОЛОГИИ",
|
||||
forumCatSCIENCE: "НАУКА",
|
||||
forumCatMUSIC: "МУЗЫКА",
|
||||
forumCatART: "ИСКУССТВО",
|
||||
forumCatGAMING: "ИГРЫ",
|
||||
forumCatBOOKS: "КНИГИ",
|
||||
forumCatFILMS: "ФИЛЬМЫ",
|
||||
forumCatPHILOSOPHY: "ФИЛОСОФИЯ",
|
||||
forumCatSOCIETY: "ОБЩЕСТВО",
|
||||
forumCatPRIVACY: "ПРИВАТНОСТЬ",
|
||||
forumCatCYBERWARFARE: "КИБЕРВОЙНА",
|
||||
forumCatSURVIVALISM: "ВЫЖИВАНИЕ",
|
||||
forumTitleLabel: "Название",
|
||||
forumTitlePlaceholder: "Название форума...",
|
||||
forumCreateButton: "Создать форум",
|
||||
|
|
@ -2005,43 +2014,6 @@ module.exports = {
|
|||
reportsUnderReviewSectionTitle: "Отчёты на рассмотрении",
|
||||
reportsResolvedSectionTitle: "Решённые отчёты",
|
||||
reportsInvalidSectionTitle: "Недействительные отчёты",
|
||||
reportsBugTemplateTitle: "Детали воспроизведения (Ошибки)",
|
||||
reportsFeatureTemplateTitle: "Детали (Функции)",
|
||||
reportsAbuseTemplateTitle: "Детали (Злоупотребления)",
|
||||
reportsContentTemplateTitle: "Детали (Проблемы с контентом)",
|
||||
reportsStepsToReproduceLabel: "Шаги воспроизведения",
|
||||
reportsStepsToReproducePlaceholder: "1) ...\n2) ...\n3) ...",
|
||||
reportsExpectedBehaviorLabel: "Ожидаемый результат",
|
||||
reportsExpectedBehaviorPlaceholder: "Что должно произойти?",
|
||||
reportsActualBehaviorLabel: "Фактический результат",
|
||||
reportsActualBehaviorPlaceholder: "Что произошло на самом деле?",
|
||||
reportsEnvironmentLabel: "Среда",
|
||||
reportsEnvironmentPlaceholder: "Версия, устройство, ОС, браузер, настройки, журналы и т.д.",
|
||||
reportsReproduceRateLabel: "Частота воспроизведения",
|
||||
reportsReproduceRateAlways: "Всегда",
|
||||
reportsReproduceRateOften: "Часто",
|
||||
reportsReproduceRateSometimes: "Иногда",
|
||||
reportsReproduceRateRarely: "Редко",
|
||||
reportsReproduceRateUnable: "Невозможно воспроизвести",
|
||||
reportsReproduceRateUnknown: "Не указано",
|
||||
reportsProblemStatementLabel: "Проблема / потребность",
|
||||
reportsProblemStatementPlaceholder: "Какую проблему решает эта функция?",
|
||||
reportsUserStoryLabel: "История жителя",
|
||||
reportsUserStoryPlaceholder: "Как <житель>, я хочу <действие>, чтобы <польза>.",
|
||||
reportsAcceptanceCriteriaLabel: "Критерии приёмки",
|
||||
reportsAcceptanceCriteriaPlaceholder: "- Дано...\n- Когда...\n- Тогда...",
|
||||
reportsWhatHappenedLabel: "Что произошло?",
|
||||
reportsWhatHappenedPlaceholder: "Опишите инцидент с контекстом и примерными датами.",
|
||||
reportsReportedUserLabel: "Сообщаемый житель / сущность",
|
||||
reportsReportedUserPlaceholder: "@житель, лента, ID, ссылка и т.д.",
|
||||
reportsEvidenceLinksLabel: "Доказательства / ссылки",
|
||||
reportsEvidenceLinksPlaceholder: "Вставьте ссылки, ID сообщений, скриншоты (если применимо) и т.д.",
|
||||
reportsContentLocationLabel: "Где находится контент",
|
||||
reportsContentLocationPlaceholder: "Ссылка, ID, канал, тред, автор и т.д.",
|
||||
reportsWhyInappropriateLabel: "Почему это неуместно",
|
||||
reportsWhyInappropriatePlaceholder: "Объясните причину и последствия.",
|
||||
reportsRequestedActionLabel: "Запрашиваемое действие",
|
||||
reportsRequestedActionPlaceholder: "Удалить, скрыть, пометить, предупредить и т.д.",
|
||||
tribesTitle: "Племена",
|
||||
tribeFilterAll: "ВСЕ",
|
||||
tribeFilterRecent: "НЕДАВНИЕ",
|
||||
|
|
@ -2282,6 +2254,7 @@ module.exports = {
|
|||
voteWellResearched: "Хорошо исследованное",
|
||||
voteNeedsSources: "Нужны источники",
|
||||
voteWrong: "Неправильное",
|
||||
voteOutdated: "Устарело",
|
||||
voteLowQuality: "Низкое качество",
|
||||
voteLove: "Люблю",
|
||||
voteClear: "Ясное",
|
||||
|
|
@ -2515,7 +2488,6 @@ module.exports = {
|
|||
shopNoProducts: "Товаров пока нет.",
|
||||
shopOutOfStock: "Нет в наличии",
|
||||
shopBuy: "Купить",
|
||||
shopBuyEncryptedNote: "Ваши данные доставки отправляются зашифрованно и видны только владельцу магазина.",
|
||||
shopBuyDeliveryAddress: "Адрес доставки",
|
||||
shopBuyContact: "Контакт",
|
||||
shopBuyContactPlaceholder: "email, телефон и т.д.",
|
||||
|
|
@ -3027,6 +2999,16 @@ module.exports = {
|
|||
industryDescriptionLabel: "Описание",
|
||||
industryDescriptionPlaceholder: "Что производит эта фабрика?",
|
||||
industrySector: "Сектор",
|
||||
industrySector_software: "Программы",
|
||||
industrySector_hardware: "Оборудование",
|
||||
industrySector_agriculture: "Сельское хозяйство",
|
||||
industrySector_textile: "Текстиль",
|
||||
industrySector_energy: "Энергетика",
|
||||
industrySector_food: "Продовольствие",
|
||||
industrySector_construction: "Строительство",
|
||||
industrySector_media: "Медиа",
|
||||
industrySector_services: "Услуги",
|
||||
industrySector_other: "Другое",
|
||||
industryMembershipPolicy: "Политика членства",
|
||||
industryQuorum: "Кворум",
|
||||
industryMajority: "Большинство",
|
||||
|
|
@ -3228,6 +3210,18 @@ module.exports = {
|
|||
modulesFavoritesDescription: "Модуль для управления избранным контентом.",
|
||||
fileTooLargeTitle: "Файл слишком большой",
|
||||
fileTooLargeMessage: "Файл превышает максимально допустимый размер (50 МБ). Пожалуйста, выберите файл меньшего размера.",
|
||||
fileWrongType: "Этот файл не подходит для этого раздела по типу.",
|
||||
mediaLockedAfterOpinions: "У этого уже есть мнения: остальное можно править, но не то, что оценивали.",
|
||||
pdfPrevPage: "Предыдущая страница",
|
||||
pdfNextPage: "Следующая страница",
|
||||
pdfPage: "Страница",
|
||||
pdfZoomIn: "Увеличить",
|
||||
pdfZoomOut: "Уменьшить",
|
||||
pdfFullscreen: "Полный экран",
|
||||
pdfClose: "Закрыть",
|
||||
pdfRotate: "Повернуть",
|
||||
contentNotAvailableYet: "Этот контент пока недоступен.",
|
||||
pdfRetry: "Повторить",
|
||||
goBack: "Назад",
|
||||
errorDismiss: "ОК",
|
||||
errorPageTitle: "Что-то пошло не так",
|
||||
|
|
@ -3270,9 +3264,7 @@ module.exports = {
|
|||
uploadMedia: "Загрузить медиа (макс: 75MB)",
|
||||
feedOpenDiscussion: "Открыть обсуждение",
|
||||
feedDetailTitle: "Лента",
|
||||
feedPostComment: "Опубликовать комментарий",
|
||||
courtsRespondentInvalid: "Должен быть действительным SSB ID (@...ed25519)",
|
||||
noComments: "Пока нет комментариев",
|
||||
bankAllocId: "ID распределения",
|
||||
pmFromLabel: "От:",
|
||||
pmToLabel: "Кому:",
|
||||
|
|
@ -3581,6 +3573,8 @@ module.exports = {
|
|||
podcastTagsPlaceholder: "Введите теги через запятую",
|
||||
podcastFilterAll: "ВСЕ",
|
||||
podcastFilterMine: "МОИ",
|
||||
podcastFilterFavorites: "ИЗБРАННОЕ",
|
||||
podcastEpisodeNumberLabel: "Номер выпуска",
|
||||
podcastFilterRecent: "НЕДАВНИЕ",
|
||||
podcastFilterTop: "ТОП",
|
||||
podcastFilterViewers: "СЛУШАТЕЛИ",
|
||||
|
|
@ -3799,7 +3793,6 @@ module.exports = {
|
|||
wikiBackToPage: "Назад к странице",
|
||||
wikiLinkedChip: "Связана",
|
||||
wikiRestoredSummary: "Восстановленная версия",
|
||||
wikiExportPdf: "Создать страницу",
|
||||
wikiPreview: "Предпросмотр",
|
||||
tribeSectionWiki: "ВИКИ",
|
||||
tribeWikiCreate: "Создать вики",
|
||||
|
|
@ -3867,6 +3860,9 @@ module.exports = {
|
|||
roomRecStop: "Остановить запись",
|
||||
roomStreamFile: "Транслировать",
|
||||
roomStreamStop: "Остановить",
|
||||
roomScreen: "Экран",
|
||||
roomScreenSharing: "Демонстрация экрана",
|
||||
roomScreenWarning: "Все в комнате будут видеть ваш экран целиком, пока вы не остановите.",
|
||||
roomStreaming: "Идёт трансляция",
|
||||
liveFullscreen: "Во весь экран",
|
||||
roomStreamPickTitle: "Выберите видео",
|
||||
|
|
@ -3924,6 +3920,7 @@ module.exports = {
|
|||
inviteAlreadyMember: "Вы уже участвуете. Откройте его в соответствующем модуле.",
|
||||
actionFailed: "Не удалось это сделать. Попробуйте ещё раз.",
|
||||
publishTooShort: "Ваша публикация слишком короткая.",
|
||||
publishEmpty: "Напишите что-нибудь или прикрепите файл.",
|
||||
walletSendFailed: "Не удалось отправить платёж: {reason}",
|
||||
pixeliaCoordsError: "Эти координаты находятся за пределами сетки.",
|
||||
courtsErrTitle: "Нужен заголовок.",
|
||||
|
|
@ -4435,6 +4432,9 @@ module.exports = {
|
|||
torrentTagsLabel: "Теги",
|
||||
torrentTagsPlaceholder: "Введите теги через запятую",
|
||||
torrentSizeLabel: "Размер",
|
||||
torrentInfoHash: "Инфохеш",
|
||||
torrentFilesLabel: "Файлы",
|
||||
torrentMagnet: "MAGNET",
|
||||
torrentFromContent: "Создать торрент",
|
||||
torrentFromContentMissing: "Файл ещё недоступен на этом узле. Попробуйте позже.",
|
||||
torrentDownloads: "Загрузки",
|
||||
|
|
@ -4520,9 +4520,6 @@ module.exports = {
|
|||
inhabitantsPendingAccept: "Принять",
|
||||
inhabitantsPendingReject: "Отклонить",
|
||||
tribeSectionGovernance: "УПРАВЛЕНИЕ",
|
||||
tribePadInviteRequired: "Нет доступа к паду. Запросите приглашение.",
|
||||
tribeChatInviteRequired: "Нет доступа к чату. Запросите приглашение.",
|
||||
tribeGovernanceDesc: "Внутреннее управление этим племенем.",
|
||||
tribeGovernanceNoGov: "Нет активного правительства",
|
||||
tribeGovernanceNoGovDesc: "Это племя ещё не выбрало правительство.",
|
||||
tribeGovCardTitle: "Текущее правительство",
|
||||
|
|
@ -4579,7 +4576,6 @@ module.exports = {
|
|||
logsFilterMonth: "МЕСЯЦ",
|
||||
logsFilterWeek: "НЕДЕЛЯ",
|
||||
logsFilterToday: "СЕГОДНЯ",
|
||||
logsComments: "Комментарии",
|
||||
logsAuthorEntries: "Записи",
|
||||
logsCreate: "Создать запись",
|
||||
logsExport: "Экспорт журнала",
|
||||
|
|
@ -4735,6 +4731,8 @@ module.exports = {
|
|||
larpWillTestTitle: "WILL-тест",
|
||||
settingsLanDesc: "Периодически анонсировать этот узел другим экземплярам Oasis в той же локальной сети.",
|
||||
settingsLanEnable: "Включить LAN-вещание",
|
||||
settingsConnectionsTitle: "Подключения",
|
||||
settingsConnectionsDesc: "Сколько пиров этот узел держит подключёнными одновременно.",
|
||||
settingsLanTitle: "LAN-вещание",
|
||||
settingsReplicationTitle: "Репликация",
|
||||
settingsReplicationDesc: "Настройте количество переходов, которые ваш узел делает от вашего собственного канала при репликации содержимого.",
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ module.exports = {
|
|||
graphosBackToMine: "← 我的网络",
|
||||
graphosShowing: "显示",
|
||||
graphosYou: "你",
|
||||
graphosTotalNodes: "节点总数",
|
||||
graphosTotalNodes: "总计",
|
||||
onlinePlural: "在线",
|
||||
mentions: "提及",
|
||||
mentionsDescription: [
|
||||
strong("每一条 @提到你的消息"),
|
||||
|
|
@ -420,6 +421,7 @@ module.exports = {
|
|||
menuMedia: "资料库",
|
||||
menuTools: "工具",
|
||||
comment: "评论",
|
||||
typeComment: "评论",
|
||||
subtopic: "子话题",
|
||||
json: "JSON",
|
||||
unfollow: "取消支持",
|
||||
|
|
@ -449,9 +451,6 @@ module.exports = {
|
|||
publishCustomDescription: [
|
||||
"提醒:由于区块链技术,帖子一旦发布就无法编辑或删除。",
|
||||
],
|
||||
commentWarning: [
|
||||
"提醒:由于区块链技术,帖子一旦发布就无法编辑或删除。",
|
||||
],
|
||||
commentLabel: ({ publicOrPrivate, markdownUrl }) => [
|
||||
],
|
||||
publishLabel: ({ markdownUrl, linkTarget }) => [
|
||||
|
|
@ -730,10 +729,6 @@ module.exports = {
|
|||
votesQuestionLabel:"问题",
|
||||
voteStatusOpen:"进行中",
|
||||
voteStatusClosed:"已关闭",
|
||||
commentDescription: ({ parentUrl }) => [
|
||||
" 评论了 ",
|
||||
a({ href: parentUrl }, " 讨论串"),
|
||||
],
|
||||
commentTitle: ({ authorName }) => [`对 @${authorName} 帖子的评论`],
|
||||
subtopicDescription: ({ parentUrl }) => [
|
||||
" 从 ",
|
||||
|
|
@ -869,6 +864,8 @@ module.exports = {
|
|||
bankRulesEligibility5: "PUB 在下一周期支付并发布一笔可供确认的 UBI 转账。",
|
||||
bankRulesEligibility6: "若缺少地址、feed 太新、已拒绝当月或 PUB 无资金,领取将被跳过并记录在 PUB 上。",
|
||||
copyLinkTitle: "复制链接",
|
||||
cardMenuTitle: "更多操作",
|
||||
shareContent: "分享",
|
||||
linkBoxOasis: "Oasis",
|
||||
linkBoxClearnet: "Clearnet",
|
||||
bankRefuseUBI: "拒绝 UBI",
|
||||
|
|
@ -902,7 +899,7 @@ module.exports = {
|
|||
walletNotConfigured: "你的 ECOin 钱包尚未配置。",
|
||||
walletSettingsDescription: "将 Oasis 与你的 ECOin 钱包集成。",
|
||||
walletSettingsMobileDescription: "将 Oasis 与此设备上的 ECOin Wallet 应用关联。",
|
||||
walletDownloadEcoin: "下载 ECOin!",
|
||||
walletDownloadEcoin: "下载 ECOin",
|
||||
walletRpcUrl: "RPC 地址",
|
||||
walletStatusMessages: {
|
||||
invalid_amount: "无效金额",
|
||||
|
|
@ -955,6 +952,7 @@ module.exports = {
|
|||
bookmarkUpdateButton: "更新",
|
||||
bookmarkAddFavoriteButton: "添加收藏",
|
||||
bookmarkRemoveFavoriteButton: "取消收藏",
|
||||
bookmarkTitleLabel: "标题",
|
||||
bookmarkUrlLabel: "链接",
|
||||
bookmarkUrlPlaceholder: "https://solarnethub.com",
|
||||
bookmarkDescriptionLabel: "描述",
|
||||
|
|
@ -1099,9 +1097,9 @@ module.exports = {
|
|||
allCVs: "简历",
|
||||
discoverPeople: "发现你网络中的居民。",
|
||||
searchInhabitantsPlaceholder: "在居民中搜索...",
|
||||
filterLocation: "按位置筛选居民...",
|
||||
filterLanguage: "按语言筛选居民...",
|
||||
filterSkills: "按技能筛选居民...",
|
||||
filterLocation: "按位置搜索…",
|
||||
filterLanguage: "按语言搜索…",
|
||||
filterSkills: "按技能搜索…",
|
||||
cvSkillsCount: "技能",
|
||||
applyFilters: "应用筛选",
|
||||
locationLabel: "位置",
|
||||
|
|
@ -1117,6 +1115,7 @@ module.exports = {
|
|||
topactivitySectionTitle: "最高活跃度",
|
||||
"TOP INACTIVITYButton": "最不活跃",
|
||||
topinactivitySectionTitle: "最不活跃的居民",
|
||||
contactsSectionTitle: "联系人",
|
||||
blockedSectionTitle: "已屏蔽",
|
||||
gallerySectionTitle: "画廊",
|
||||
topSectionTitle: "排行",
|
||||
|
|
@ -1570,9 +1569,7 @@ module.exports = {
|
|||
voteUnknownDate: "未知日期",
|
||||
voteDetailSectionTitle: '投票详情',
|
||||
voteCommentsLabel: '评论',
|
||||
voteCommentsForumButton: '公开讨论',
|
||||
voteCommentsSectionTitle: '公开讨论',
|
||||
voteNoCommentsYet: '还没有评论。成为第一个回复的人。',
|
||||
voteNoCommentsYet: '还没有评论。',
|
||||
commentsGoToLast: "跳到最后一条评论",
|
||||
voteNewCommentPlaceholder: '在此写下你的评论…',
|
||||
voteNewCommentButton: '发表评论',
|
||||
|
|
@ -1699,7 +1696,6 @@ module.exports = {
|
|||
pollVoteButton: "投票",
|
||||
pollChangeVote: "修改投票",
|
||||
pollVoters: "投票人",
|
||||
pollComments: "评论",
|
||||
pollStatusLabel: "状态",
|
||||
pollStatusOpen: "进行中",
|
||||
pollStatusClosed: "已结束",
|
||||
|
|
@ -1721,9 +1717,22 @@ module.exports = {
|
|||
blogAllowComments: "允许评论",
|
||||
blogCommentsClosed: "作者已关闭此博客的评论。",
|
||||
blogNoItems: "未找到博客。",
|
||||
viewBlog: "查看博客",
|
||||
forumTitle: "论坛",
|
||||
forumCategoryLabel: "类别",
|
||||
forumCatGENERAL: "综合",
|
||||
forumCatPOLITICS: "政治",
|
||||
forumCatTECH: "科技",
|
||||
forumCatSCIENCE: "科学",
|
||||
forumCatMUSIC: "音乐",
|
||||
forumCatART: "艺术",
|
||||
forumCatGAMING: "游戏",
|
||||
forumCatBOOKS: "书籍",
|
||||
forumCatFILMS: "电影",
|
||||
forumCatPHILOSOPHY: "哲学",
|
||||
forumCatSOCIETY: "社会",
|
||||
forumCatPRIVACY: "隐私",
|
||||
forumCatCYBERWARFARE: "网络战",
|
||||
forumCatSURVIVALISM: "生存",
|
||||
forumTitleLabel: "标题",
|
||||
forumTitlePlaceholder: "论坛标题...",
|
||||
forumCreateButton: "创建论坛",
|
||||
|
|
@ -1992,43 +2001,6 @@ module.exports = {
|
|||
reportsUnderReviewSectionTitle: "审查中的报告",
|
||||
reportsResolvedSectionTitle: "已解决的报告",
|
||||
reportsInvalidSectionTitle: "无效报告",
|
||||
reportsBugTemplateTitle: '复现详情(漏洞)',
|
||||
reportsFeatureTemplateTitle: '详情(功能)',
|
||||
reportsAbuseTemplateTitle: '详情(滥用)',
|
||||
reportsContentTemplateTitle: '详情(内容问题)',
|
||||
reportsStepsToReproduceLabel: '复现步骤',
|
||||
reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...',
|
||||
reportsExpectedBehaviorLabel: '预期结果',
|
||||
reportsExpectedBehaviorPlaceholder: '应该发生什么?',
|
||||
reportsActualBehaviorLabel: '实际结果',
|
||||
reportsActualBehaviorPlaceholder: '实际发生了什么?',
|
||||
reportsEnvironmentLabel: '环境',
|
||||
reportsEnvironmentPlaceholder: '版本、设备、操作系统、浏览器、设置、日志等。',
|
||||
reportsReproduceRateLabel: '复现率',
|
||||
reportsReproduceRateAlways: '总是',
|
||||
reportsReproduceRateOften: '经常',
|
||||
reportsReproduceRateSometimes: '有时',
|
||||
reportsReproduceRateRarely: '偶尔',
|
||||
reportsReproduceRateUnable: '无法复现',
|
||||
reportsReproduceRateUnknown: '未指定',
|
||||
reportsProblemStatementLabel: '问题/需求',
|
||||
reportsProblemStatementPlaceholder: '此功能解决什么问题?',
|
||||
reportsUserStoryLabel: '居民故事',
|
||||
reportsUserStoryPlaceholder: '作为一个 <居民>,我希望 <动作>,以便 <收益>。',
|
||||
reportsAcceptanceCriteriaLabel: '验收标准',
|
||||
reportsAcceptanceCriteriaPlaceholder: '- 假设...\n- 当...\n- 那么...',
|
||||
reportsWhatHappenedLabel: '发生了什么?',
|
||||
reportsWhatHappenedPlaceholder: '描述事件的背景和大致日期。',
|
||||
reportsReportedUserLabel: '被举报的居民/实体',
|
||||
reportsReportedUserPlaceholder: '@居民、动态、ID、链接等。',
|
||||
reportsEvidenceLinksLabel: '证据/链接',
|
||||
reportsEvidenceLinksPlaceholder: '粘贴链接、消息 ID、截图(如适用)等。',
|
||||
reportsContentLocationLabel: '内容位置',
|
||||
reportsContentLocationPlaceholder: '链接、ID、频道、讨论串、作者等。',
|
||||
reportsWhyInappropriateLabel: "为什么不合适",
|
||||
reportsWhyInappropriatePlaceholder: '说明原因和影响。',
|
||||
reportsRequestedActionLabel: '请求的操作',
|
||||
reportsRequestedActionPlaceholder: '删除、隐藏、标记、警告等。',
|
||||
tribesTitle: "部落",
|
||||
tribeFilterAll: "全部",
|
||||
tribeFilterRecent: "最近",
|
||||
|
|
@ -2272,6 +2244,7 @@ module.exports = {
|
|||
voteWellResearched: "研究充分",
|
||||
voteNeedsSources: "需要来源",
|
||||
voteWrong: "错误",
|
||||
voteOutdated: "已过时",
|
||||
voteLowQuality: "低质量",
|
||||
voteLove: "喜爱",
|
||||
voteClear: "清晰",
|
||||
|
|
@ -2516,7 +2489,6 @@ module.exports = {
|
|||
shopNoProducts: "暂无产品。",
|
||||
shopOutOfStock: "已售罄",
|
||||
shopBuy: "购买",
|
||||
shopBuyEncryptedNote: "您的配送信息将加密发送,仅店主可见。",
|
||||
shopBuyDeliveryAddress: "配送地址",
|
||||
shopBuyContact: "联系方式",
|
||||
shopBuyContactPlaceholder: "邮箱、电话等",
|
||||
|
|
@ -3033,6 +3005,16 @@ module.exports = {
|
|||
industryDescriptionLabel: "描述",
|
||||
industryDescriptionPlaceholder: "这家工厂生产什么?",
|
||||
industrySector: "部门",
|
||||
industrySector_software: "软件",
|
||||
industrySector_hardware: "硬件",
|
||||
industrySector_agriculture: "农业",
|
||||
industrySector_textile: "纺织",
|
||||
industrySector_energy: "能源",
|
||||
industrySector_food: "食品",
|
||||
industrySector_construction: "建筑",
|
||||
industrySector_media: "媒体",
|
||||
industrySector_services: "服务",
|
||||
industrySector_other: "其他",
|
||||
industryMembershipPolicy: "成员政策",
|
||||
industryQuorum: "法定人数",
|
||||
industryMajority: "多数",
|
||||
|
|
@ -3234,6 +3216,18 @@ module.exports = {
|
|||
modulesFavoritesDescription: "管理你收藏内容的模块。",
|
||||
fileTooLargeTitle: "文件过大",
|
||||
fileTooLargeMessage: "文件超过了允许的最大大小(75 MB)。请选择较小的文件。",
|
||||
fileWrongType: "此文件类型不适用于此栏目。",
|
||||
mediaLockedAfterOpinions: "已有评价:可以编辑其他内容,但不能更改已被评价的部分。",
|
||||
pdfPrevPage: "上一页",
|
||||
pdfNextPage: "下一页",
|
||||
pdfPage: "页",
|
||||
pdfZoomIn: "放大",
|
||||
pdfZoomOut: "缩小",
|
||||
pdfFullscreen: "全屏",
|
||||
pdfClose: "关闭",
|
||||
pdfRotate: "旋转",
|
||||
contentNotAvailableYet: "此内容暂不可用。",
|
||||
pdfRetry: "重试",
|
||||
goBack: "返回",
|
||||
errorDismiss: "好",
|
||||
errorPageTitle: "出现了问题",
|
||||
|
|
@ -3276,9 +3270,7 @@ module.exports = {
|
|||
uploadMedia: "上传媒体(最大:75MB)",
|
||||
feedOpenDiscussion: "公开讨论",
|
||||
feedDetailTitle: "动态",
|
||||
feedPostComment: "发表评论",
|
||||
courtsRespondentInvalid: "必须是有效的 SSB ID (@...ed25519)",
|
||||
noComments: "还没有评论",
|
||||
mapsLabel: "地图",
|
||||
mapTitle: "地图",
|
||||
mapDescription: "在您的网络中管理和分享离线地图。",
|
||||
|
|
@ -3582,6 +3574,8 @@ module.exports = {
|
|||
podcastTagsPlaceholder: "输入标签,用逗号分隔",
|
||||
podcastFilterAll: "全部",
|
||||
podcastFilterMine: "我的",
|
||||
podcastFilterFavorites: "收藏",
|
||||
podcastEpisodeNumberLabel: "集数",
|
||||
podcastFilterRecent: "最近",
|
||||
podcastFilterTop: "热门",
|
||||
podcastFilterViewers: "听众",
|
||||
|
|
@ -3800,7 +3794,6 @@ module.exports = {
|
|||
wikiBackToPage: "返回页面",
|
||||
wikiLinkedChip: "已关联",
|
||||
wikiRestoredSummary: "已恢复的版本",
|
||||
wikiExportPdf: "生成页面",
|
||||
wikiPreview: "预览",
|
||||
tribeSectionWiki: "维基",
|
||||
tribeWikiCreate: "创建维基",
|
||||
|
|
@ -3868,6 +3861,9 @@ module.exports = {
|
|||
roomRecStop: "停止录音",
|
||||
roomStreamFile: "播放",
|
||||
roomStreamStop: "停止",
|
||||
roomScreen: "屏幕",
|
||||
roomScreenSharing: "正在共享屏幕",
|
||||
roomScreenWarning: "在你停止之前,房间里的每个人都会看到你的整个屏幕。",
|
||||
roomStreaming: "正在播放",
|
||||
liveFullscreen: "全屏",
|
||||
roomStreamPickTitle: "选择视频",
|
||||
|
|
@ -3925,6 +3921,7 @@ module.exports = {
|
|||
inviteAlreadyMember: "你已经加入了,可以在对应模块中打开。",
|
||||
actionFailed: "无法完成此操作,请重试。",
|
||||
publishTooShort: "你的帖子太短了。",
|
||||
publishEmpty: "请写点内容或附加一个文件。",
|
||||
walletSendFailed: "无法发送付款:{reason}",
|
||||
pixeliaCoordsError: "这些坐标超出了网格范围。",
|
||||
courtsErrTitle: "需要填写标题。",
|
||||
|
|
@ -4436,6 +4433,9 @@ module.exports = {
|
|||
torrentTagsLabel: "标签",
|
||||
torrentTagsPlaceholder: "输入标签,用逗号分隔",
|
||||
torrentSizeLabel: "大小",
|
||||
torrentInfoHash: "信息哈希",
|
||||
torrentFilesLabel: "文件",
|
||||
torrentMagnet: "MAGNET",
|
||||
torrentFromContent: "生成种子",
|
||||
torrentFromContentMissing: "该文件在本节点尚不可用,请稍后再试。",
|
||||
torrentDownloads: "下载",
|
||||
|
|
@ -4521,9 +4521,6 @@ module.exports = {
|
|||
inhabitantsPendingAccept: "接受",
|
||||
inhabitantsPendingReject: "拒绝",
|
||||
tribeSectionGovernance: "治理",
|
||||
tribePadInviteRequired: "您无权访问 pad。请求邀请。",
|
||||
tribeChatInviteRequired: "您无权访问聊天。请求邀请。",
|
||||
tribeGovernanceDesc: "此部落的内部治理。",
|
||||
tribeGovernanceNoGov: "无活跃政府",
|
||||
tribeGovernanceNoGovDesc: "此部落尚未选举政府。",
|
||||
tribeGovCardTitle: "当前政府",
|
||||
|
|
@ -4580,7 +4577,6 @@ module.exports = {
|
|||
logsFilterMonth: "上月",
|
||||
logsFilterWeek: "上周",
|
||||
logsFilterToday: "今天",
|
||||
logsComments: "评论",
|
||||
logsAuthorEntries: "条目",
|
||||
logsCreate: "创建条目",
|
||||
logsExport: "导出日志",
|
||||
|
|
@ -4736,6 +4732,8 @@ module.exports = {
|
|||
larpWillTestTitle: "WILL 测试",
|
||||
settingsLanDesc: "定期向同一局域网中的其他 Oasis 实例广播此节点。",
|
||||
settingsLanEnable: "启用局域网广播",
|
||||
settingsConnectionsTitle: "连接",
|
||||
settingsConnectionsDesc: "此节点同时保持连接的节点数量。",
|
||||
settingsLanTitle: "局域网广播",
|
||||
settingsReplicationTitle: "复制",
|
||||
settingsReplicationDesc: "配置在复制内容时,你的节点从自己的信息流出发跟随的跳数。",
|
||||
|
|
|
|||
|
|
@ -115,13 +115,13 @@ module.exports = ({ offline, port = 3000, host = 'localhost', isPublic = false }
|
|||
return new Promise((resolve, reject) => {
|
||||
if (internalSSB) {
|
||||
const { printMetadata, colors } = require('../server/ssb_metadata');
|
||||
printMetadata('OASIS UX', colors.yellow, port, host, offline, isPublic);
|
||||
printMetadata('OASIS UX', colors.yellow, port, host, isPublic);
|
||||
return resolve(windowLogStream(internalSSB));
|
||||
}
|
||||
if (internalOpen) {
|
||||
return internalOpen().then((srv) => {
|
||||
const { printMetadata, colors } = require('../server/ssb_metadata');
|
||||
printMetadata('OASIS UX', colors.yellow, port, host, offline, isPublic);
|
||||
printMetadata('OASIS UX', colors.yellow, port, host, isPublic);
|
||||
resolve(windowLogStream(srv));
|
||||
}).catch(reject);
|
||||
}
|
||||
|
|
@ -138,7 +138,7 @@ module.exports = ({ offline, port = 3000, host = 'localhost', isPublic = false }
|
|||
reject(new Error("Closing Oasis"));
|
||||
} else {
|
||||
const { printMetadata, colors } = require('../server/ssb_metadata');
|
||||
printMetadata('OASIS UX', colors.yellow, port, host, offline, isPublic);
|
||||
printMetadata('OASIS UX', colors.yellow, port, host, isPublic);
|
||||
resolve(ssb);
|
||||
}
|
||||
}).catch(reject);
|
||||
|
|
|
|||
|
|
@ -54,6 +54,19 @@ module.exports = ({ host, port, middleware, allowHost }) => {
|
|||
|
||||
const { isClearnetPath, isTrustedRequest, buildCsp } = require(path.join(__dirname, "..", "backend", "request_guards"));
|
||||
const isValidRequest = (request) => isTrustedRequest(request, validHosts);
|
||||
const EMBED_PATHS = ["/pdf/embed"];
|
||||
const CLEARNET_EMBED_PATHS = ["/c/pdf/embed"];
|
||||
const pageFrames = (ctx, isClearnet) => {
|
||||
if (ctx.method !== "GET") return {};
|
||||
const host = String(ctx.host || '');
|
||||
const embeds = /^[A-Za-z0-9.-]+(:\d+)?$/.test(host) ? (isClearnet ? CLEARNET_EMBED_PATHS : EMBED_PATHS).map(p => `${host}${p}`).join(' ') : "'self'";
|
||||
const game = /^\/games\/[a-z0-9]+\/?$/.test(ctx.path);
|
||||
return {
|
||||
objects: embeds,
|
||||
frames: game ? "'self'" : embeds,
|
||||
framed: EMBED_PATHS.includes(ctx.path) || CLEARNET_EMBED_PATHS.includes(ctx.path)
|
||||
};
|
||||
};
|
||||
|
||||
const httpDebug = process.argv.includes('--debug') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
|
||||
|
||||
|
|
@ -92,15 +105,13 @@ module.exports = ({ host, port, middleware, allowHost }) => {
|
|||
gamesStatic.use(koaStatic(join(__dirname, "..", "games")));
|
||||
app.use(mount("/game-assets", gamesStatic));
|
||||
|
||||
app.use(mount("/js", koaStatic(path.join(__dirname, 'public/js'))));
|
||||
app.use(koaStatic(path.join(__dirname, 'public')));
|
||||
|
||||
app.use(async (ctx, next) => {
|
||||
|
||||
if (httpDebug) console.log(`[http] ${ctx.method} ${ctx.path}`);
|
||||
|
||||
const isClearnet = isClearnetPath(ctx.request);
|
||||
ctx.set("Content-Security-Policy", buildCsp(isClearnet, { frames: ctx.method === "GET" && /^\/games\/[a-z0-9]+\/?$/.test(ctx.path) }));
|
||||
ctx.set("Content-Security-Policy", buildCsp(isClearnet, pageFrames(ctx, isClearnet)));
|
||||
ctx.set("X-Frame-Options", "SAMEORIGIN");
|
||||
|
||||
ctx.set("X-Content-Type-Options", "nosniff");
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ PUB admin commands (require the Oasis Sbot to be running):
|
|||
GUI options (forwarded to the backend):
|
||||
--host=<ip> Hostname / IP the web UI listens on (default: localhost).
|
||||
Use 0.0.0.0 to expose on a VPS.
|
||||
--port=<n> Port for the web UI (default: 3000).
|
||||
--port=<n> Port for the web UI (default: 3000, or OASIS_PORT).
|
||||
--allow-host=<host> Extra hostname allowed when behind a reverse proxy.
|
||||
--public Public-hosting mode: disables POST and redacts content
|
||||
from people who haven't opted in to public hosting.
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
const showPdfFallback = () => {
|
||||
document.querySelectorAll('.pdf-viewer-container').forEach(container => {
|
||||
if (container.dataset.pdfReady) return;
|
||||
container.dataset.pdfReady = '1';
|
||||
container.textContent = '';
|
||||
const url = container.getAttribute('data-pdf-url');
|
||||
if (url) {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.textContent = '📄 PDF';
|
||||
container.appendChild(link);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initPdfViewers = async () => {
|
||||
let pdfjsLib;
|
||||
try {
|
||||
pdfjsLib = await import('/js/pdf.min.mjs?v=102');
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdf.worker.min.mjs?v=102';
|
||||
} catch (err) {
|
||||
showPdfFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.pdf-viewer-container').forEach(async container => {
|
||||
const pdfUrl = container.getAttribute('data-pdf-url');
|
||||
if (!pdfUrl || container.dataset.pdfReady) return;
|
||||
container.dataset.pdfReady = '1';
|
||||
|
||||
let pdf;
|
||||
try {
|
||||
pdf = await pdfjsLib.getDocument(pdfUrl).promise;
|
||||
} catch (err) {
|
||||
container.textContent = '';
|
||||
const fallback = document.createElement('a');
|
||||
fallback.href = pdfUrl;
|
||||
fallback.textContent = '📄 PDF';
|
||||
container.appendChild(fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
let currentPage = 1;
|
||||
let scale = 1.5;
|
||||
let rotation = 0;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const canvasWrap = document.createElement('div');
|
||||
canvasWrap.className = 'pdf-canvas-wrap';
|
||||
canvasWrap.appendChild(canvas);
|
||||
container.innerHTML = '';
|
||||
container.appendChild(canvasWrap);
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'pdf-controls';
|
||||
controls.innerHTML = `
|
||||
<button class="pdf-prev">⬅️</button>
|
||||
<button class="pdf-next">➡️</button>
|
||||
<button class="pdf-zoom-in">🔍+</button>
|
||||
<button class="pdf-zoom-out">🔍−</button>
|
||||
<button class="pdf-rotate">↻</button>
|
||||
<button class="pdf-download">⬇️</button>
|
||||
<button class="pdf-fullscreen">🔲</button>
|
||||
<button class="pdf-metadata">ℹ️</button>
|
||||
`;
|
||||
|
||||
container.appendChild(controls);
|
||||
|
||||
const renderPage = async (num) => {
|
||||
const page = await pdf.getPage(num);
|
||||
const viewport = page.getViewport({ scale, rotation });
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
};
|
||||
|
||||
const goToPage = (delta) => {
|
||||
const newPage = currentPage + delta;
|
||||
if (newPage >= 1 && newPage <= pdf.numPages) {
|
||||
currentPage = newPage;
|
||||
renderPage(currentPage);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const firstPage = await pdf.getPage(1);
|
||||
const baseViewport = firstPage.getViewport({ scale: 1, rotation: 0 });
|
||||
const avail = canvasWrap.clientWidth || container.clientWidth;
|
||||
if (avail > 0 && baseViewport.width > 0) {
|
||||
scale = Math.min(1.5, Math.max(0.5, (avail - 40) / baseViewport.width));
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
renderPage(currentPage);
|
||||
|
||||
controls.querySelector('.pdf-prev').onclick = () => goToPage(-1);
|
||||
controls.querySelector('.pdf-next').onclick = () => goToPage(1);
|
||||
controls.querySelector('.pdf-zoom-in').onclick = () => { scale += 0.2; renderPage(currentPage); };
|
||||
controls.querySelector('.pdf-zoom-out').onclick = () => { scale = Math.max(0.5, scale - 0.2); renderPage(currentPage); };
|
||||
controls.querySelector('.pdf-rotate').onclick = () => { rotation = (rotation + 90) % 360; renderPage(currentPage); };
|
||||
controls.querySelector('.pdf-download').onclick = () => {
|
||||
const a = document.createElement('a');
|
||||
a.href = pdfUrl;
|
||||
a.download = 'document.pdf';
|
||||
a.click();
|
||||
};
|
||||
controls.querySelector('.pdf-fullscreen').onclick = async () => {
|
||||
const maximized = container.classList.toggle('pdf-maximized');
|
||||
document.body.classList.toggle('pdf-maximized-body', maximized);
|
||||
try {
|
||||
const p1 = await pdf.getPage(currentPage);
|
||||
const vw1 = p1.getViewport({ scale: 1, rotation });
|
||||
const avail = Math.max(260, (maximized ? window.innerWidth : (canvasWrap.clientWidth || container.clientWidth || 360)) - 24);
|
||||
scale = maximized ? avail / vw1.width : Math.min(1.5, avail / vw1.width);
|
||||
await renderPage(currentPage);
|
||||
} catch (_) {}
|
||||
};
|
||||
controls.querySelector('.pdf-metadata').onclick = async () => {
|
||||
const info = await pdf.getMetadata();
|
||||
alert(`Title: ${info.info.Title || 'N/A'}\nAuthor: ${info.info.Author || 'N/A'}\nPDF Producer: ${info.info.Producer || 'N/A'}`);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initPdfViewers);
|
||||
} else {
|
||||
initPdfViewers();
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -25,6 +25,13 @@ const writeStateFile = (file, data) => {
|
|||
};
|
||||
let configCache = { at: 0, cfg: null };
|
||||
const CONFIG_TTL_MS = 2000;
|
||||
const CONNECTIONS_MIN = 5;
|
||||
const CONNECTIONS_MAX = 30;
|
||||
const CONNECTIONS_DEFAULT = 10;
|
||||
const connectionsOf = (value) => {
|
||||
const n = Math.round(Number(value));
|
||||
return value === undefined || value === null || value === '' || !Number.isFinite(n) ? CONNECTIONS_DEFAULT : Math.min(CONNECTIONS_MAX, Math.max(CONNECTIONS_MIN, n));
|
||||
};
|
||||
const writeConfigFile = (data) => { configCache = { at: 0, cfg: null }; return writeStateFile(configFilePath, data); };
|
||||
const readJsonFile = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; } };
|
||||
const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
|
|
@ -160,6 +167,7 @@ const readConfig = () => {
|
|||
const configData = fs.readFileSync(configFilePath);
|
||||
const cfg = JSON.parse(configData);
|
||||
if (!['whole', 'mutuals', 'only-lan', 'local'].includes(cfg.wish)) cfg.wish = 'whole';
|
||||
cfg.connections = connectionsOf(cfg.connections);
|
||||
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 = {};
|
||||
|
|
@ -207,4 +215,8 @@ module.exports = {
|
|||
saveServerConfig,
|
||||
readInviteCodes,
|
||||
defaultInvite,
|
||||
CONNECTIONS_MIN,
|
||||
CONNECTIONS_MAX,
|
||||
CONNECTIONS_DEFAULT,
|
||||
connectionsOf,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ const HIDDEN_ENVELOPE_TYPES = new Set([
|
|||
'courts-key'
|
||||
]);
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel, larpModel }) => {
|
||||
module.exports = ({ cooler, cryptoModel, tribesModel, padsModel, industryModel, larpModel }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
|
||||
|
||||
|
|
@ -121,26 +121,25 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel,
|
|||
};
|
||||
|
||||
const tryDecryptPublicInviteKey = (invites) => {
|
||||
if (!tribeCrypto || !Array.isArray(invites)) return null;
|
||||
if (!cryptoModel || !Array.isArray(invites)) return null;
|
||||
for (const inv of invites) {
|
||||
if (!inv || typeof inv !== 'object' || inv.public !== true) continue;
|
||||
if (typeof inv.code !== 'string') continue;
|
||||
if (typeof inv.ek === 'string') {
|
||||
try {
|
||||
const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt);
|
||||
const k = cryptoModel.decryptFromInvite(inv.ek, inv.code, inv.salt);
|
||||
if (k) return k;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (typeof inv.ekChain === 'string') {
|
||||
try {
|
||||
const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt);
|
||||
const chain = cryptoModel.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt);
|
||||
if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key;
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const hasBlob = async (ssbClient, url) => new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
|
||||
const getMsg = async (ssbClient, key) => new Promise(resolve => ssbClient.get(key, (err, msg) => resolve(err ? null : msg)));
|
||||
const normNL = (s) => String(s || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const stripHtml = (s) => normNL(s)
|
||||
|
|
@ -302,7 +301,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel,
|
|||
for (const choice of v.choices) entry.counts[choice] = (entry.counts[choice] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
|
||||
const fpIdx = cryptoModel ? cryptoModel.buildFingerprintIndex() : null;
|
||||
const accessibleTribeIds = await buildAccessibleTribeIds();
|
||||
let larpWall = null;
|
||||
if (larpModel && results.some(m => m && m.value && m.value.content && m.value.content.type === 'larpHousePost')) {
|
||||
|
|
@ -330,8 +329,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel,
|
|||
if (c.type === 'larpHousePost' && !larpPostVisible(v.author, String(c.house || '').toLowerCase())) continue;
|
||||
if (typeof c.type === 'string' && /e2ee/i.test(c.type)) continue;
|
||||
if (c.type === 'contact') continue;
|
||||
if (tribeCrypto && tribeCrypto.isTribeMsg(c)) {
|
||||
const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null;
|
||||
if (cryptoModel && cryptoModel.isSealedMsg(c)) {
|
||||
const r = fpIdx ? cryptoModel.unwrapMsg(c, fpIdx) : null;
|
||||
if (!r || !r.body) continue;
|
||||
const inner = r.body;
|
||||
if (inner.k !== 'tribe' || inner.op !== 'create') continue;
|
||||
|
|
@ -341,11 +340,11 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel,
|
|||
}
|
||||
if (!c.type) continue;
|
||||
if (c.type === 'tombstone') continue;
|
||||
if (c.encryptedPayload && tribeCrypto && !c.tribeId) {
|
||||
if (c.encryptedPayload && cryptoModel && !c.tribeId) {
|
||||
const pubKey = tryDecryptPublicInviteKey(c.invites);
|
||||
if (pubKey) {
|
||||
try {
|
||||
const dec = tribeCrypto.decryptContent(c, [[pubKey]]);
|
||||
const dec = cryptoModel.decryptContent(c, [[pubKey]]);
|
||||
if (dec && !dec._undecryptable) c = dec;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -618,6 +617,15 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel,
|
|||
feedOpinionsByRoot.get(r).push({ author: v.author, category: String(c.category || '') });
|
||||
}
|
||||
|
||||
const reportConfirmsByRoot = new Map();
|
||||
for (const msg of results) {
|
||||
const c = msg && msg.value && msg.value.content;
|
||||
if (!c || c.type !== 'reportConfirm' || typeof c.target !== 'string') continue;
|
||||
const r = rootOf(c.target);
|
||||
if (!reportConfirmsByRoot.has(r)) reportConfirmsByRoot.set(r, new Set());
|
||||
reportConfirmsByRoot.get(r).add(msg.value.author);
|
||||
}
|
||||
|
||||
const forgedFeedAggByRoot = new Map();
|
||||
for (const [child, fc] of forgedFeedContent.entries()) {
|
||||
let cur = child;
|
||||
|
|
@ -632,8 +640,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel,
|
|||
}
|
||||
|
||||
const latest = [];
|
||||
const docUrls = [...new Set([...idToAction.values()].filter(a => a.type === 'document').map(a => (a.content || {}).url))];
|
||||
const docBlobOk = new Map(await Promise.all(docUrls.map(async url => [url, await hasBlob(ssbClient, url)])));
|
||||
for (const a of idToAction.values()) {
|
||||
if (tombstoned.has(a.id)) continue;
|
||||
if (a.type === 'tribe' && parentOf.has(a.id)) continue;
|
||||
|
|
@ -644,7 +650,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel,
|
|||
if (c.key && tombstoned.has(c.key)) continue;
|
||||
if (c.branch && tombstoned.has(c.branch)) continue;
|
||||
if (c.target && tombstoned.has(c.target)) continue;
|
||||
if (a.type === 'document' && !docBlobOk.get(c.url)) continue;
|
||||
if (a.type === 'forum' && c.root) {
|
||||
const rootId = typeof c.root === 'string' ? c.root : (c.root?.key || c.root?.id || '');
|
||||
const rootAction = idToAction.get(rootId);
|
||||
|
|
@ -696,6 +701,9 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel,
|
|||
}
|
||||
content = { ...content, opinions, opinions_inhabitants: voters };
|
||||
}
|
||||
if (a.type === 'report' && reportConfirmsByRoot.has(actionRoot)) {
|
||||
content = { ...content, confirmations: [...new Set([...(Array.isArray(content.confirmations) ? content.confirmations : []), ...reportConfirmsByRoot.get(actionRoot)])] };
|
||||
}
|
||||
latest.push({ ...a, content, ...extra, tipId: idToTipId.get(a.id) || a.id, rootId: actionRoot !== a.id ? actionRoot : null });
|
||||
}
|
||||
|
||||
|
|
@ -745,7 +753,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel,
|
|||
}
|
||||
} else if (textTypes.has(a.type)) {
|
||||
const t = norm(c.text);
|
||||
const key = t ? `${a.type}:${a.author}:${t}` : null;
|
||||
const key = t ? `${a.type}:${a.author}:${typeof c.root === 'string' ? c.root : ''}:${t}` : null;
|
||||
const prev = key ? byKey.get(key) : null;
|
||||
if (!key) {
|
||||
byKey.set(`id:${a.id}`, { ...a, __effTs: effTs });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const pull = require("../server/node_modules/pull-stream");
|
||||
const opinionRules = require("../backend/opinion_categories");
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { dedupeBy, norm, versionGroups } = require('../backend/dedupe');
|
||||
const { readTyped, memoIndex } = require('./typed_log');
|
||||
|
|
@ -230,7 +231,7 @@ module.exports = ({ cooler }) => {
|
|||
if (!oldMsg || oldMsg.content?.type !== "audio") throw new Error("Audio not found");
|
||||
if (oldMsg.content.author !== userId) throw new Error("Not the author");
|
||||
const aggAudio = await this.getAudioById(id, userId);
|
||||
if (aggAudio && Object.keys(aggAudio.opinions || {}).some(k => (aggAudio.opinions[k] || 0) > 0)) throw new Error("Cannot edit audio after it has received opinions.");
|
||||
opinionRules.assertMediaKept(aggAudio, !!blobMarkdown);
|
||||
|
||||
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
|
||||
const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const { readContentWindow, requestScope, logTip } = require('./typed_log');
|
|||
const { versionGroups, versionEntry } = require('../backend/dedupe');
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
||||
module.exports = ({ cooler, cryptoModel, tribesModel }) => {
|
||||
let ssb;
|
||||
|
||||
const openSsb = async () => {
|
||||
|
|
@ -41,9 +41,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
return set;
|
||||
};
|
||||
|
||||
const hasBlob = async (ssbClient, url) =>
|
||||
new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
|
||||
|
||||
const isReplacedBlock = (vg, tombstoned, block) => {
|
||||
const c = block.content;
|
||||
const rootDeleted = !c.replaces && c?.type === 'forum' && c.root && tombstoned.has(c.root);
|
||||
|
|
@ -142,7 +139,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const idToBlock = new Map();
|
||||
const versions = [];
|
||||
|
||||
const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
|
||||
const fpIdx = cryptoModel ? cryptoModel.buildFingerprintIndex() : null;
|
||||
const accessibleTribeIds = await buildAccessibleTribeIds();
|
||||
for (const msg of results) {
|
||||
const k = msg.key;
|
||||
|
|
@ -157,8 +154,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
}
|
||||
if (!c?.type) continue;
|
||||
if (HIDDEN_ENVELOPE_TYPES.has(c.type)) continue;
|
||||
if (tribeCrypto && tribeCrypto.isTribeMsg(c)) {
|
||||
const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null;
|
||||
if (cryptoModel && cryptoModel.isSealedMsg(c)) {
|
||||
const r = fpIdx ? cryptoModel.unwrapMsg(c, fpIdx) : null;
|
||||
if (!r || !r.body) continue;
|
||||
const inner = r.body;
|
||||
const innerType = inner.k === 'tribe' ? 'tribe' : (inner.k === 'tribe-content' ? `tribe-content:${inner.contentType || ''}` : inner.k || 'tribe-msg');
|
||||
|
|
@ -264,7 +261,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const tombstoned = buildValidatedTombstoneSet(results);
|
||||
const idToBlock = new Map();
|
||||
const versions = [];
|
||||
const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
|
||||
const fpIdx = cryptoModel ? cryptoModel.buildFingerprintIndex() : null;
|
||||
const accessibleTribeIds = await buildAccessibleTribeIds();
|
||||
|
||||
for (const msg of results) {
|
||||
|
|
@ -280,8 +277,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
}
|
||||
if (!c?.type) continue;
|
||||
if (HIDDEN_ENVELOPE_TYPES.has(c.type)) continue;
|
||||
if (tribeCrypto && tribeCrypto.isTribeMsg(c)) {
|
||||
const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null;
|
||||
if (cryptoModel && cryptoModel.isSealedMsg(c)) {
|
||||
const r = fpIdx ? cryptoModel.unwrapMsg(c, fpIdx) : null;
|
||||
if (!r || !r.body) continue;
|
||||
const inner = r.body;
|
||||
const innerType = inner.k === 'tribe' ? 'tribe' : (inner.k === 'tribe-content' ? `tribe-content:${inner.contentType || ''}` : inner.k || 'tribe-msg');
|
||||
|
|
@ -300,11 +297,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const block = idToBlock.get(id);
|
||||
if (!block) return null;
|
||||
|
||||
if (block.type === 'document') {
|
||||
const valid = await hasBlob(ssbClient, block.content.url);
|
||||
if (!valid) return null;
|
||||
}
|
||||
|
||||
const isTombstoned = tombstoned.has(block.id);
|
||||
const isReplaced = isReplacedBlock(versionGroups(versions), tombstoned, block);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const { getConfig } = require('../configs/config-manager.js');
|
|||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { readTyped, memoIndex } = require('./typed_log');
|
||||
const longText = require('../backend/long_text');
|
||||
const { isComment, isBlogPost } = require('./comments_model');
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
|
||||
const OPINION_TYPE = 'blogOpinion';
|
||||
|
|
@ -15,9 +16,6 @@ module.exports = ({ cooler, isPublic = false }) => {
|
|||
|
||||
const getAllMessages = async (ssbClient) => readTyped(ssbClient, BLOG_TYPES, { limit: logLimit });
|
||||
|
||||
const isRootPost = (c) =>
|
||||
!!c && c.type === 'post' && typeof c.text === 'string' &&
|
||||
!c.root && !c.fork && !c.branch && !c.about && !c.target;
|
||||
|
||||
const buildIndex = (messages) => {
|
||||
const tomb = buildValidatedTombstoneSet(messages);
|
||||
|
|
@ -33,9 +31,9 @@ module.exports = ({ cooler, isPublic = false }) => {
|
|||
if (tomb.has(m.key)) continue;
|
||||
|
||||
if (c.type === 'post' && typeof c.text === 'string') {
|
||||
if (isRootPost(c)) {
|
||||
if (isBlogPost(c)) {
|
||||
posts.set(m.key, { key: m.key, author: v.author, ts: v.timestamp || m.timestamp || 0, c });
|
||||
} else if (typeof c.root === 'string') {
|
||||
} else if (isComment(c)) {
|
||||
commentsByRoot.set(c.root, (commentsByRoot.get(c.root) || 0) + 1);
|
||||
}
|
||||
continue;
|
||||
|
|
@ -142,7 +140,7 @@ module.exports = ({ cooler, isPublic = false }) => {
|
|||
const ssbClient = await openSsb();
|
||||
const userId = ssbClient.id;
|
||||
const msg = await new Promise((res, rej) => ssbClient.get(id, (err, m) => (err || !m || !m.content) ? rej(new Error('Blog not found')) : res(m)));
|
||||
if (!isRootPost(msg.content)) throw new Error('Blog not found');
|
||||
if (!isBlogPost(msg.content)) throw new Error('Blog not found');
|
||||
if (msg.author !== userId) throw new Error('Not the author');
|
||||
const content = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
|
||||
return new Promise((res, rej) => ssbClient.publish(content, (err, result) => err ? rej(err) : res(result)));
|
||||
|
|
@ -157,27 +155,6 @@ module.exports = ({ cooler, isPublic = false }) => {
|
|||
if (blog.opinions_inhabitants.includes(userId)) throw new Error('Already opined');
|
||||
const content = { type: OPINION_TYPE, target: blog.id, category, createdAt: new Date().toISOString() };
|
||||
return new Promise((res, rej) => ssbClient.publish(content, (err, result) => err ? rej(err) : res(result)));
|
||||
},
|
||||
|
||||
async resolveRootId(id) {
|
||||
const ssbClient = await openSsb();
|
||||
const msg = await new Promise((res) => ssbClient.get(id, (err, m) => res(err ? null : m)));
|
||||
const c = msg && (msg.content || (msg.value && msg.value.content));
|
||||
if (c && c.type === 'post' && typeof c.root === 'string') return c.root;
|
||||
return id;
|
||||
},
|
||||
|
||||
async blogHrefFor(id) {
|
||||
const ssbClient = await openSsb();
|
||||
const msg = await new Promise((res) => ssbClient.get(id, (err, m) => res(err ? null : m)));
|
||||
const c = msg && (msg.content || (msg.value && msg.value.content));
|
||||
if (!c || c.type !== 'post' || typeof c.text !== 'string') return null;
|
||||
if (c.private === true || Array.isArray(c.recps)) return null;
|
||||
if (typeof c.root === 'string') {
|
||||
return `/blogs/${encodeURIComponent(c.root)}#${encodeURIComponent(id)}`;
|
||||
}
|
||||
if (c.fork || c.branch || c.about || c.target) return null;
|
||||
return `/blogs/${encodeURIComponent(id)}`;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const pull = require("../server/node_modules/pull-stream");
|
||||
const opinionRules = require("../backend/opinion_categories");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { getConfig } = require("../configs/config-manager.js");
|
||||
const categories = require("../backend/opinion_categories");
|
||||
|
|
@ -29,6 +30,8 @@ const coerceLastVisit = (lastVisit) => {
|
|||
const voteSum = (opinions = {}) =>
|
||||
Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
|
||||
|
||||
const BOOKMARK_TITLE_MAX = 100;
|
||||
|
||||
module.exports = ({ cooler }) => {
|
||||
let ssb;
|
||||
|
||||
|
|
@ -103,6 +106,7 @@ module.exports = ({ cooler }) => {
|
|||
id: node.key,
|
||||
rootId,
|
||||
url: c.url || "",
|
||||
title: c.title || "",
|
||||
createdAt: c.createdAt || new Date(node.ts).toISOString(),
|
||||
updatedAt: c.updatedAt || null,
|
||||
lastVisit: c.lastVisit || null,
|
||||
|
|
@ -139,7 +143,7 @@ module.exports = ({ cooler }) => {
|
|||
return root;
|
||||
},
|
||||
|
||||
async createBookmark(url, tagsRaw, description, lastVisit) {
|
||||
async createBookmark(url, tagsRaw, description, lastVisit, title = "") {
|
||||
const ssbClient = await openSsb();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
|
|
@ -150,6 +154,7 @@ module.exports = ({ cooler }) => {
|
|||
type: "bookmark",
|
||||
author: ssbClient.id,
|
||||
url: u,
|
||||
...(safeText(title) ? { title: safeText(title).slice(0, BOOKMARK_TITLE_MAX) } : {}),
|
||||
tags: normalizeTags(tagsRaw),
|
||||
description: description || "",
|
||||
createdAt: now,
|
||||
|
|
@ -174,7 +179,7 @@ module.exports = ({ cooler }) => {
|
|||
if (!oldMsg || oldMsg.content?.type !== "bookmark") throw new Error("Bookmark not found");
|
||||
if (String(oldMsg.content.author) !== String(userId)) throw new Error("Not the author");
|
||||
const aggBm = await this.getBookmarkById(id, userId);
|
||||
if (aggBm && Object.keys(aggBm.opinions || {}).some(k => (aggBm.opinions[k] || 0) > 0)) throw new Error("Cannot edit bookmark after it has received opinions.");
|
||||
opinionRules.assertMediaKept(aggBm, updatedData.url !== undefined && safeText(updatedData.url) !== safeText(oldMsg.content.url));
|
||||
|
||||
const url = safeText(updatedData.url || oldMsg.content.url);
|
||||
if (!url) throw new Error("URL is required");
|
||||
|
|
@ -185,6 +190,7 @@ module.exports = ({ cooler }) => {
|
|||
...oldMsg.content,
|
||||
replaces: tipId,
|
||||
url,
|
||||
title: updatedData.title !== undefined ? safeText(updatedData.title).slice(0, BOOKMARK_TITLE_MAX) : (oldMsg.content.title || ""),
|
||||
tags: updatedData.tags !== undefined ? normalizeTags(updatedData.tags) : safeArr(oldMsg.content.tags),
|
||||
description: updatedData.description !== undefined ? updatedData.description || "" : oldMsg.content.description || "",
|
||||
lastVisit: coerceLastVisit(updatedData.lastVisit || oldMsg.content.lastVisit),
|
||||
|
|
|
|||
|
|
@ -51,31 +51,31 @@ const assertCalendarDates = ({ deadline, date, until }) => {
|
|||
if (un !== null && dl !== null && un > dl) throw new Error("The recurrence cannot end after the deadline")
|
||||
}
|
||||
|
||||
module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel }) => {
|
||||
module.exports = ({ cooler, pmModel, cryptoModel, calendarCrypto, tribesModel }) => {
|
||||
let ssb
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
|
||||
|
||||
const ownCrypto = calendarCrypto || tribeCrypto
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
|
||||
const ownCrypto = calendarCrypto || cryptoModel
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (cryptoModel && cryptoModel.getKey(rid)) || null
|
||||
const lookupKeys = (rid) => {
|
||||
const a = (ownCrypto && ownCrypto.getKeys(rid)) || []
|
||||
if (a.length) return a
|
||||
return (tribeCrypto && tribeCrypto.getKeys(rid)) || []
|
||||
return (cryptoModel && cryptoModel.getKeys(rid)) || []
|
||||
}
|
||||
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0)
|
||||
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (cryptoModel && cryptoModel.getGen(rid)) || 0)
|
||||
const keyProofFor = (keyHex, id) => { try { return crypto.createHmac("sha256", Buffer.from(keyHex, "hex")).update(String(id), "utf8").digest("hex") } catch (_) { return null } }
|
||||
|
||||
const rotateCalendarKey = async (rootId, remainingMembers) => {
|
||||
if (!ownCrypto || !tribeCrypto || !rootId) return
|
||||
if (!ownCrypto || !cryptoModel || !rootId) return
|
||||
const existing = lookupKey(rootId)
|
||||
if (!existing) return
|
||||
const newKey = ownCrypto.generateTribeKey()
|
||||
const newKey = ownCrypto.generateKey()
|
||||
const newGen = ownCrypto.addNewKey(rootId, newKey)
|
||||
const ssbClient = await openSsb()
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys")
|
||||
const memberKeys = {}
|
||||
for (const m of new Set([ssbClient.id, ...(Array.isArray(remainingMembers) ? remainingMembers : [])])) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[m] = cryptoModel.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (Object.keys(memberKeys).length) {
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -115,7 +115,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
const readAll = async (ssbClient) => unwrapForIndex(await readTyped(ssbClient, CALENDAR_TYPES, { limit: logLimit, withWindow: true }))
|
||||
|
||||
const ensureMemberKeys = async (ssbClient, messages, cals) => {
|
||||
if (!tribeCrypto || !ownCrypto) return
|
||||
if (!cryptoModel || !ownCrypto) return
|
||||
const leavesByRoot = new Map()
|
||||
for (const m of messages) {
|
||||
const c = m.value && m.value.content
|
||||
|
|
@ -138,7 +138,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
const key = lookupKey(rootId)
|
||||
const memberKeys = {}
|
||||
for (const m of plan.missing) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(key, m, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[m] = cryptoModel.boxKeyForMember(key, m, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (!Object.keys(memberKeys).length) continue
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -147,50 +147,50 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
}
|
||||
}
|
||||
|
||||
const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null
|
||||
const tribeHelpers = cryptoModel ? cryptoModel.createHelpers(tribesModel) : null
|
||||
const unwrapForIndex = (msgs) => tribeHelpers ? tribeHelpers.unwrapMessagesForKind(msgs, CALENDAR_TYPES) : msgs
|
||||
const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c
|
||||
const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c
|
||||
const encryptIfGroup = tribeHelpers ? tribeHelpers.encryptIfGroup : async (c) => c
|
||||
const decryptIfGroup = tribeHelpers ? tribeHelpers.decryptIfGroup : async (c) => c
|
||||
const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {}
|
||||
|
||||
const encryptStandalone = (content, rootId) => {
|
||||
if (!tribeCrypto || !rootId) return content
|
||||
if (!cryptoModel || !rootId) return content
|
||||
const key = lookupKey(rootId)
|
||||
if (!key) return content
|
||||
return tribeCrypto.encryptContent(content, [key], true)
|
||||
return cryptoModel.encryptContent(content, [key], true)
|
||||
}
|
||||
|
||||
const decryptScoped = async (content, rootId) => {
|
||||
if (!content) return content
|
||||
if (content.tribeId || (tribeCrypto && tribeCrypto.isTribeMsg(content))) return await decryptIfTribe(content)
|
||||
if (content.tribeId || (cryptoModel && cryptoModel.isSealedMsg(content))) return await decryptIfGroup(content)
|
||||
return decryptCalendarRoot(content, rootId)
|
||||
}
|
||||
|
||||
const decryptCalendarRoot = (content, rootId) => {
|
||||
if (!content || !content.encryptedPayload) return content
|
||||
if (!tribeCrypto) return content
|
||||
if (!cryptoModel) return content
|
||||
const keys = lookupKeys(rootId)
|
||||
if (!keys || !keys.length) return { ...content, _undecryptable: true }
|
||||
return tribeCrypto.decryptContent(content, keys.map(k => [k]))
|
||||
return cryptoModel.decryptContent(content, keys.map(k => [k]))
|
||||
}
|
||||
|
||||
const tryDecryptPublicInviteKey = (invites) => {
|
||||
if (!tribeCrypto || !Array.isArray(invites)) return null
|
||||
if (!cryptoModel || !Array.isArray(invites)) return null
|
||||
for (const inv of invites) {
|
||||
if (!inv || typeof inv !== "object" || inv.public !== true) continue
|
||||
if (typeof inv.code !== "string") continue
|
||||
if (typeof inv.ek === "string") {
|
||||
try { const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt); if (k) return k } catch (_) {}
|
||||
try { const k = cryptoModel.decryptFromInvite(inv.ek, inv.code, inv.salt); if (k) return k } catch (_) {}
|
||||
}
|
||||
if (typeof inv.ekChain === "string") {
|
||||
try { const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt); if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key } catch (_) {}
|
||||
try { const chain = cryptoModel.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt); if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key } catch (_) {}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const decryptIndexNodes = async (idx) => {
|
||||
if (!tribeCrypto) return
|
||||
if (!cryptoModel) return
|
||||
for (const [k, n] of idx.nodes.entries()) {
|
||||
if (!n.c || !n.c.encryptedPayload) continue
|
||||
let root = k
|
||||
|
|
@ -198,7 +198,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
let dec = null
|
||||
if (n.c.tribeId && tribesModel) {
|
||||
try {
|
||||
const r = await tribeCrypto.decryptFromTribe(n.c, tribesModel)
|
||||
const r = await cryptoModel.decryptFromGroup(n.c, tribesModel)
|
||||
if (r && !r._undecryptable) dec = r
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -209,7 +209,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
if (!dec) {
|
||||
const pubKey = tryDecryptPublicInviteKey(n.c.invites)
|
||||
if (pubKey) {
|
||||
try { const r = tribeCrypto.decryptContent(n.c, [[pubKey]]); if (r && !r._undecryptable) dec = r } catch (_) {}
|
||||
try { const r = cryptoModel.decryptContent(n.c, [[pubKey]]); if (r && !r._undecryptable) dec = r } catch (_) {}
|
||||
}
|
||||
}
|
||||
if (dec) {
|
||||
|
|
@ -438,10 +438,10 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
let calKey = null
|
||||
let content = plainContent
|
||||
if (tribeId) {
|
||||
content = await encryptIfTribe(plainContent)
|
||||
} else if (tribeCrypto) {
|
||||
calKey = ownCrypto.generateTribeKey()
|
||||
content = tribeCrypto.encryptContent(plainContent, [calKey], true)
|
||||
content = await encryptIfGroup(plainContent)
|
||||
} else if (cryptoModel) {
|
||||
calKey = ownCrypto.generateKey()
|
||||
content = cryptoModel.encryptContent(plainContent, [calKey], true)
|
||||
}
|
||||
|
||||
const calMsg = await new Promise((resolve, reject) => {
|
||||
|
|
@ -450,11 +450,11 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
|
||||
const calendarId = calMsg.key
|
||||
|
||||
if (calKey && tribeCrypto) {
|
||||
if (calKey && cryptoModel) {
|
||||
ownCrypto.setKey(calendarId, calKey, 1)
|
||||
try {
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys")
|
||||
const boxedKey = tribeCrypto.boxKeyForMember(calKey, userId, ssbKeys)
|
||||
const boxedKey = cryptoModel.boxKeyForMember(calKey, userId, ssbKeys)
|
||||
await new Promise((resolve) => {
|
||||
ssbClient.publish({ type: "tribe-keys", tribeId: calendarId, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve())
|
||||
})
|
||||
|
|
@ -462,8 +462,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
if (validStatus === "OPEN") {
|
||||
try {
|
||||
const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
|
||||
const inviteSalt = tribeCrypto.generateInviteSalt()
|
||||
const ek = tribeCrypto.encryptForInvite(calKey, pubCode, inviteSalt)
|
||||
const inviteSalt = cryptoModel.generateInviteSalt()
|
||||
const ek = cryptoModel.encryptForInvite(calKey, pubCode, inviteSalt)
|
||||
const tipId = await this.resolveCurrentId(calendarId)
|
||||
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
|
||||
const dec = decryptCalendarRoot(item.content, calendarId)
|
||||
|
|
@ -501,8 +501,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
...(hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) && (intervalDeadline || deadline) ? { intervalDeadline: intervalDeadline || deadline } : {}),
|
||||
...(tribeId ? { tribeId } : {})
|
||||
}
|
||||
if (tribeId) dateContent = await encryptIfTribe(dateContent)
|
||||
else if (calKey) dateContent = tribeCrypto.encryptContent(dateContent, [calKey], true)
|
||||
if (tribeId) dateContent = await encryptIfGroup(dateContent)
|
||||
else if (calKey) dateContent = cryptoModel.encryptContent(dateContent, [calKey], true)
|
||||
const dateMsg = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(dateContent, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
})
|
||||
|
|
@ -517,8 +517,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
createdAt: new Date().toISOString(),
|
||||
...(tribeId ? { tribeId } : {})
|
||||
}
|
||||
if (tribeId) noteContent = await encryptIfTribe(noteContent)
|
||||
else if (calKey) noteContent = tribeCrypto.encryptContent(noteContent, [calKey], true)
|
||||
if (tribeId) noteContent = await encryptIfGroup(noteContent)
|
||||
else if (calKey) noteContent = cryptoModel.encryptContent(noteContent, [calKey], true)
|
||||
await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
})
|
||||
|
|
@ -561,7 +561,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
updated.invites = updated.invites.filter(inv => !(inv && typeof inv === "object" && inv.public === true))
|
||||
try { await rotateCalendarKey(rootId, (updated.participants || []).filter(p => p !== userId)) } catch (_) {}
|
||||
}
|
||||
if (updated.tribeId) updated = await encryptIfTribe(updated)
|
||||
if (updated.tribeId) updated = await encryptIfGroup(updated)
|
||||
else updated = encryptStandalone(updated, rootId)
|
||||
const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
|
||||
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
|
||||
|
|
@ -591,7 +591,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
if (!cal) throw new Error("Calendar not found")
|
||||
const participants = Array.isArray(cal.participants) ? cal.participants : []
|
||||
if (participants.includes(userId)) return
|
||||
if (tribeCrypto && Array.isArray(cal.invites)) {
|
||||
if (cryptoModel && Array.isArray(cal.invites)) {
|
||||
const pub = cal.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain))
|
||||
if (pub) return await this.joinByInvite(pub.code)
|
||||
}
|
||||
|
|
@ -693,7 +693,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
...(ruleDeadline ? { intervalDeadline: ruleDeadline } : {}),
|
||||
...(cal.tribeId ? { tribeId: cal.tribeId } : {})
|
||||
}
|
||||
if (cal.tribeId) dateContent = await encryptIfTribe(dateContent)
|
||||
if (cal.tribeId) dateContent = await encryptIfGroup(dateContent)
|
||||
else dateContent = encryptStandalone(dateContent, rootId)
|
||||
const msg = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(dateContent, (err, m) => err ? reject(err) : resolve(m))
|
||||
|
|
@ -728,15 +728,15 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
if (c.calendarId !== rootId) continue
|
||||
if (!members.has(v.author)) continue
|
||||
let dec = c
|
||||
if (c.encryptedPayload && tribeCrypto) {
|
||||
if (c.encryptedPayload && cryptoModel) {
|
||||
if (c.tribeId && tribesModel) {
|
||||
const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
|
||||
const r = await cryptoModel.decryptFromGroup(c, tribesModel)
|
||||
dec = r && !r._undecryptable ? r : c
|
||||
if (r && r._undecryptable) continue
|
||||
} else {
|
||||
const keys = [...(lookupKeys(c.calendarId) || []), ...(pubKey ? [pubKey] : [])]
|
||||
if (!keys.length) continue
|
||||
const r = tribeCrypto.decryptContent(c, keys.map(k => [k]))
|
||||
const r = cryptoModel.decryptContent(c, keys.map(k => [k]))
|
||||
if (!r || r._undecryptable) continue
|
||||
dec = r
|
||||
}
|
||||
|
|
@ -789,8 +789,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
if (!c || c.type !== "calendarDate") continue
|
||||
if (tombstoned.has(m.key)) break
|
||||
let dec = c
|
||||
if (c.encryptedPayload && tribeCrypto && tribesModel) {
|
||||
const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
|
||||
if (c.encryptedPayload && cryptoModel && tribesModel) {
|
||||
const r = await cryptoModel.decryptFromGroup(c, tribesModel)
|
||||
if (r && !r._undecryptable) dec = r
|
||||
}
|
||||
dateAuthor = dec.author || (m.value || {}).author
|
||||
|
|
@ -828,7 +828,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
createdAt: new Date().toISOString(),
|
||||
...(cal.tribeId ? { tribeId: cal.tribeId } : {})
|
||||
}
|
||||
if (cal.tribeId) noteContent = await encryptIfTribe(noteContent)
|
||||
if (cal.tribeId) noteContent = await encryptIfGroup(noteContent)
|
||||
else noteContent = encryptStandalone(noteContent, rootId)
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
|
|
@ -871,12 +871,12 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
let dec = c
|
||||
if (c.encryptedPayload) {
|
||||
let r = await decryptScoped(c, rootId)
|
||||
if ((!r || r._undecryptable) && !c.tribeId && tribeCrypto) {
|
||||
if ((!r || r._undecryptable) && !c.tribeId && cryptoModel) {
|
||||
if (pubKey === undefined) {
|
||||
const cal = await this.getCalendarById(rootId).catch(() => null)
|
||||
pubKey = cal ? tryDecryptPublicInviteKey(cal.invites) : null
|
||||
}
|
||||
if (pubKey) r = tribeCrypto.decryptContent(c, [[pubKey]])
|
||||
if (pubKey) r = cryptoModel.decryptContent(c, [[pubKey]])
|
||||
}
|
||||
if (r && !r._undecryptable) dec = r
|
||||
else continue
|
||||
|
|
@ -924,8 +924,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
const dated = messages.filter(m => !tombstoned.has(m.key) && ((m.value || {}).content || {}).type === "calendarDate")
|
||||
const decoded = await Promise.all(dated.map(async (m) => {
|
||||
const c = m.value.content
|
||||
if (!(c.encryptedPayload && tribeCrypto && tribesModel)) return c
|
||||
const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
|
||||
if (!(c.encryptedPayload && cryptoModel && tribesModel)) return c
|
||||
const r = await cryptoModel.decryptFromGroup(c, tribesModel)
|
||||
return !r || r._undecryptable ? null : r
|
||||
}))
|
||||
for (let i = 0; i < dated.length; i++) {
|
||||
|
|
@ -1013,12 +1013,12 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
|
||||
let invite = code
|
||||
const calKey = !cal.tribeId ? lookupKey(cal.rootId) : null
|
||||
if (tribeCrypto && calKey) {
|
||||
const inviteSalt = tribeCrypto.generateInviteSalt()
|
||||
const ek = tribeCrypto.encryptForInvite(calKey, code, inviteSalt)
|
||||
if (cryptoModel && calKey) {
|
||||
const inviteSalt = cryptoModel.generateInviteSalt()
|
||||
const ek = cryptoModel.encryptForInvite(calKey, code, inviteSalt)
|
||||
invite = opts.public
|
||||
? { code, ek, salt: inviteSalt, gen: lookupGen(cal.rootId), public: true }
|
||||
: { ch: tribeCrypto.hashInviteCode(code, inviteSalt), ek, salt: inviteSalt, gen: lookupGen(cal.rootId) }
|
||||
: { ch: cryptoModel.hashInviteCode(code, inviteSalt), ek, salt: inviteSalt, gen: lookupGen(cal.rootId) }
|
||||
}
|
||||
if (opts.public && typeof invite !== "object") invite = { code, public: true }
|
||||
const tipId = await this.resolveCurrentId(calendarId)
|
||||
|
|
@ -1041,7 +1041,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
updatedAt: new Date().toISOString(),
|
||||
replaces: tipId
|
||||
}
|
||||
if (updated.tribeId) updated = await encryptIfTribe(updated)
|
||||
if (updated.tribeId) updated = await encryptIfGroup(updated)
|
||||
else updated = encryptStandalone(updated, cal.rootId)
|
||||
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
|
||||
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
|
||||
|
|
@ -1087,7 +1087,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
updatedAt: new Date().toISOString(),
|
||||
replaces: tipId
|
||||
}
|
||||
if (updated.tribeId) updated = await encryptIfTribe(updated)
|
||||
if (updated.tribeId) updated = await encryptIfGroup(updated)
|
||||
else updated = encryptStandalone(updated, cal.rootId)
|
||||
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
|
||||
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
|
||||
|
|
@ -1105,15 +1105,15 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
const invs = Array.isArray(cal.invites) ? cal.invites : []
|
||||
for (const inv of invs) {
|
||||
if (typeof inv === "string" && inv === code) { matched = cal; matchedInvite = inv; break }
|
||||
if (typeof inv === "object" && inv && (inv.code === code || (inv.ch && tribeCrypto && inv.ch === tribeCrypto.hashInviteCode(code, inv.salt)))) { matched = cal; matchedInvite = inv; break }
|
||||
if (typeof inv === "object" && inv && (inv.code === code || (inv.ch && cryptoModel && inv.ch === cryptoModel.hashInviteCode(code, inv.salt)))) { matched = cal; matchedInvite = inv; break }
|
||||
}
|
||||
if (matched) break
|
||||
}
|
||||
if (!matched) throw new Error("Invalid or expired invite code")
|
||||
if (matched.participants.includes(userId)) throw new Error("Already a participant")
|
||||
if (typeof matchedInvite === "object" && matchedInvite.ch && tribeCrypto && matchedInvite.ek) {
|
||||
if (typeof matchedInvite === "object" && matchedInvite.ch && cryptoModel && matchedInvite.ek) {
|
||||
let probeKey = null
|
||||
try { probeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt) } catch (_) {}
|
||||
try { probeKey = cryptoModel.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt) } catch (_) {}
|
||||
const messages = await readAll(await openSsb())
|
||||
const spent = messages.some(m => {
|
||||
const c = m.value && m.value.content
|
||||
|
|
@ -1122,21 +1122,21 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
if (spent) throw new Error("Invite already used")
|
||||
}
|
||||
let calKey = null
|
||||
if (tribeCrypto && typeof matchedInvite === "object") {
|
||||
if (cryptoModel && typeof matchedInvite === "object") {
|
||||
if (matchedInvite.ekChain) {
|
||||
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3)
|
||||
const chain = cryptoModel.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3)
|
||||
if (Array.isArray(chain) && chain.length) {
|
||||
for (const entry of chain) {
|
||||
if (Array.isArray(entry.keys) && entry.keys.length) {
|
||||
tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length)
|
||||
cryptoModel.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length)
|
||||
} else if (entry.key) {
|
||||
tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1)
|
||||
cryptoModel.setKey(entry.rootId, entry.key, entry.gen || 1)
|
||||
}
|
||||
}
|
||||
calKey = chain[0].key
|
||||
}
|
||||
} else if (matchedInvite.ek) {
|
||||
calKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt)
|
||||
calKey = cryptoModel.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt)
|
||||
ownCrypto.setKey(matched.rootId, calKey, matchedInvite.gen || 1)
|
||||
}
|
||||
}
|
||||
|
|
@ -1166,18 +1166,18 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
|||
updatedAt: new Date().toISOString(),
|
||||
replaces: tipId
|
||||
}
|
||||
if (updated.tribeId) updated = await encryptIfTribe(updated)
|
||||
if (updated.tribeId) updated = await encryptIfGroup(updated)
|
||||
else updated = encryptStandalone(updated, matched.rootId)
|
||||
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
|
||||
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
|
||||
await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
|
||||
if (tribeCrypto && calKey) {
|
||||
if (cryptoModel && calKey) {
|
||||
try {
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys")
|
||||
const memberKeys = {}
|
||||
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(calKey, userId, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[userId] = cryptoModel.boxKeyForMember(calKey, userId, ssbKeys) } catch (_) {}
|
||||
if (matched.author && matched.author !== userId) {
|
||||
try { memberKeys[matched.author] = tribeCrypto.boxKeyForMember(calKey, matched.author, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[matched.author] = cryptoModel.boxKeyForMember(calKey, matched.author, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (Object.keys(memberKeys).length) {
|
||||
await new Promise((resolve) => {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const REACTION_EMOJIS = ["up", "heart", "laugh", "down"]
|
|||
|
||||
const DEFAULT_MESSAGES_PER_HOUR = 60
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
||||
module.exports = ({ cooler, cryptoModel, chatCrypto, tribesModel }) => {
|
||||
let ssb
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
|
||||
|
||||
|
|
@ -35,9 +35,9 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
}
|
||||
})()
|
||||
|
||||
const ownCrypto = chatCrypto || tribeCrypto
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
|
||||
const hashCode = (code, salt) => tribeCrypto ? tribeCrypto.hashInviteCode(code, salt) : null
|
||||
const ownCrypto = chatCrypto || cryptoModel
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (cryptoModel && cryptoModel.getKey(rid)) || null
|
||||
const hashCode = (code, salt) => cryptoModel ? cryptoModel.hashInviteCode(code, salt) : null
|
||||
const inviteMatches = (inv, token) => {
|
||||
if (!token) return false
|
||||
if (typeof inv === "string") return inv === token
|
||||
|
|
@ -51,21 +51,21 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
const lookupKeys = (rid) => {
|
||||
const a = (ownCrypto && ownCrypto.getKeys(rid)) || []
|
||||
if (a.length) return a
|
||||
return (tribeCrypto && tribeCrypto.getKeys(rid)) || []
|
||||
return (cryptoModel && cryptoModel.getKeys(rid)) || []
|
||||
}
|
||||
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0)
|
||||
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (cryptoModel && cryptoModel.getGen(rid)) || 0)
|
||||
|
||||
const rotateChatKey = async (rootId, remainingMembers) => {
|
||||
if (!ownCrypto || !tribeCrypto || !rootId) return
|
||||
if (!ownCrypto || !cryptoModel || !rootId) return
|
||||
const existing = lookupKey(rootId)
|
||||
if (!existing) return
|
||||
const newKey = ownCrypto.generateTribeKey()
|
||||
const newKey = ownCrypto.generateKey()
|
||||
const newGen = ownCrypto.addNewKey(rootId, newKey)
|
||||
const ssbClient = await openSsb()
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys")
|
||||
const memberKeys = {}
|
||||
for (const m of new Set([ssbClient.id, ...(Array.isArray(remainingMembers) ? remainingMembers : [])])) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[m] = cryptoModel.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (Object.keys(memberKeys).length) {
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -104,10 +104,10 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
}
|
||||
|
||||
const getTribeKeysFor = async (tribeId) => {
|
||||
if (!tribeCrypto || !tribesModel || !tribeId) return []
|
||||
if (!cryptoModel || !tribesModel || !tribeId) return []
|
||||
try {
|
||||
const rootId = await tribesModel.getRootId(tribeId)
|
||||
return tribeCrypto.getKeys(rootId) || []
|
||||
return cryptoModel.getKeys(rootId) || []
|
||||
} catch (_) { return [] }
|
||||
}
|
||||
|
||||
|
|
@ -258,25 +258,25 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
}
|
||||
|
||||
const resolveKeyChainSets = (chatRootId) => {
|
||||
if (!tribeCrypto) return []
|
||||
if (!cryptoModel) return []
|
||||
const keys = lookupKeys(chatRootId)
|
||||
return keys.map(k => [k])
|
||||
}
|
||||
|
||||
const tryDecryptPublicInviteKey = (invites) => {
|
||||
if (!tribeCrypto || !Array.isArray(invites)) return null
|
||||
if (!cryptoModel || !Array.isArray(invites)) return null
|
||||
for (const inv of invites) {
|
||||
if (!inv || typeof inv !== "object" || inv.public !== true) continue
|
||||
if (typeof inv.code !== "string") continue
|
||||
if (typeof inv.ek === "string") {
|
||||
try {
|
||||
const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt)
|
||||
const k = cryptoModel.decryptFromInvite(inv.ek, inv.code, inv.salt)
|
||||
if (k) return k
|
||||
} catch (_) {}
|
||||
}
|
||||
if (typeof inv.ekChain === "string") {
|
||||
try {
|
||||
const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt)
|
||||
const chain = cryptoModel.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt)
|
||||
if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -287,14 +287,14 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
const decryptChatContent = (rawC, rootId) => {
|
||||
let c = rawC
|
||||
let undecryptable = false
|
||||
if (tribeCrypto && c.encryptedPayload) {
|
||||
if (cryptoModel && c.encryptedPayload) {
|
||||
const keyChainSets = resolveKeyChainSets(rootId)
|
||||
c = tribeCrypto.decryptContent(c, keyChainSets)
|
||||
c = cryptoModel.decryptContent(c, keyChainSets)
|
||||
undecryptable = !!c._undecryptable
|
||||
if (undecryptable) {
|
||||
const pubKey = tryDecryptPublicInviteKey(rawC.invites)
|
||||
if (pubKey) {
|
||||
const retry = tribeCrypto.decryptContent(rawC, [[pubKey]])
|
||||
const retry = cryptoModel.decryptContent(rawC, [[pubKey]])
|
||||
if (retry && !retry._undecryptable) {
|
||||
c = retry
|
||||
undecryptable = false
|
||||
|
|
@ -369,13 +369,13 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
let text = c.text || ""
|
||||
let image = c.image || null
|
||||
let mimeType = typeof c.mimeType === "string" ? c.mimeType : null
|
||||
if (tribeCrypto && c.encryptedText) {
|
||||
if (cryptoModel && 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)
|
||||
text = cryptoModel.decryptWithKey(c.encryptedText, keyHex)
|
||||
if (c.encryptedImage) image = cryptoModel.decryptWithKey(c.encryptedImage, keyHex)
|
||||
if (c.encryptedMime) mimeType = cryptoModel.decryptWithKey(c.encryptedMime, keyHex)
|
||||
break
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -400,7 +400,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
})
|
||||
|
||||
const ensureMemberKeys = async (ssbClient, messages, chats) => {
|
||||
if (!tribeCrypto || !ownCrypto) return
|
||||
if (!cryptoModel || !ownCrypto) return
|
||||
const leavesByRoot = new Map()
|
||||
for (const m of messages) {
|
||||
const c = m.value && m.value.content
|
||||
|
|
@ -421,7 +421,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
const key = lookupKey(rootId)
|
||||
const memberKeys = {}
|
||||
for (const m of plan.missing) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(key, m, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[m] = cryptoModel.boxKeyForMember(key, m, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (!Object.keys(memberKeys).length) continue
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -440,11 +440,11 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
})
|
||||
|
||||
const decryptTribeChats = async (idx) => {
|
||||
if (!tribeCrypto || !tribesModel || typeof tribeCrypto.decryptFromTribe !== "function") return idx
|
||||
if (!cryptoModel || !tribesModel || typeof cryptoModel.decryptFromGroup !== "function") return idx
|
||||
for (const [k, n] of idx.nodes) {
|
||||
const c = n.c || {}
|
||||
if (!c.encryptedPayload || !c.tribeId) continue
|
||||
const dec = await tribeCrypto.decryptFromTribe(c, tribesModel).catch(() => null)
|
||||
const dec = await cryptoModel.decryptFromGroup(c, tribesModel).catch(() => null)
|
||||
if (dec && !dec._undecryptable) idx.nodes.set(k, { ...n, c: dec })
|
||||
}
|
||||
return idx
|
||||
|
|
@ -466,7 +466,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
type: "chat",
|
||||
|
||||
async encryptionKeyFor(chatRootId, tribeId = null) {
|
||||
if (!tribeCrypto) return null
|
||||
if (!cryptoModel) return null
|
||||
if (tribeId) {
|
||||
const k = await getTribeFirstKeyFor(tribeId)
|
||||
if (k) return k
|
||||
|
|
@ -517,7 +517,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
...(extra && typeof extra === "object" && typeof extra.courseId === "string" && extra.courseId ? { courseId: extra.courseId } : {})
|
||||
}
|
||||
|
||||
if (!tribeCrypto) {
|
||||
if (!cryptoModel) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
})
|
||||
|
|
@ -528,11 +528,11 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
const ancestryIds = await tribesModel.getAncestryChain(tribeId)
|
||||
const chain = []
|
||||
for (const rid of ancestryIds || []) {
|
||||
const k = tribeCrypto.getKey(rid)
|
||||
const k = cryptoModel.getKey(rid)
|
||||
if (!k) { chain.length = 0; break }
|
||||
chain.push(k)
|
||||
}
|
||||
if (chain.length) content = tribeCrypto.encryptContent(content, chain, true)
|
||||
if (chain.length) content = cryptoModel.encryptContent(content, chain, true)
|
||||
} catch (_) {}
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
|
|
@ -545,15 +545,15 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
})
|
||||
}
|
||||
|
||||
const chatKey = ownCrypto.generateTribeKey()
|
||||
content = tribeCrypto.encryptContent(content, [chatKey], true)
|
||||
const chatKey = ownCrypto.generateKey()
|
||||
content = cryptoModel.encryptContent(content, [chatKey], true)
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
})
|
||||
ownCrypto.setKey(result.key, chatKey, 1)
|
||||
try {
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys")
|
||||
const boxedKey = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys)
|
||||
const boxedKey = cryptoModel.boxKeyForMember(chatKey, userId, ssbKeys)
|
||||
await new Promise((resolve) => {
|
||||
ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve())
|
||||
})
|
||||
|
|
@ -600,21 +600,21 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
...(chat.tribeId ? { tribeId: chat.tribeId } : {})
|
||||
}
|
||||
|
||||
if (tribeCrypto) {
|
||||
if (cryptoModel) {
|
||||
if (chat.tribeId) {
|
||||
try {
|
||||
const ancestryIds = await tribesModel.getAncestryChain(chat.tribeId)
|
||||
const chain = []
|
||||
for (const rid of ancestryIds || []) {
|
||||
const k = tribeCrypto.getKey(rid)
|
||||
const k = cryptoModel.getKey(rid)
|
||||
if (!k) { chain.length = 0; break }
|
||||
chain.push(k)
|
||||
}
|
||||
if (chain.length) updated = tribeCrypto.encryptContent(updated, chain, true)
|
||||
if (chain.length) updated = cryptoModel.encryptContent(updated, chain, true)
|
||||
} catch (_) {}
|
||||
} else {
|
||||
const chatKey = lookupKey(rootId)
|
||||
if (chatKey) updated = tribeCrypto.encryptContent(updated, [chatKey], true)
|
||||
if (chatKey) updated = cryptoModel.encryptContent(updated, [chatKey], true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -747,16 +747,16 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
let invite = code
|
||||
const pubFlag = opts.public ? { public: true } : {}
|
||||
|
||||
if (tribeCrypto) {
|
||||
const inviteSalt = tribeCrypto.generateInviteSalt()
|
||||
const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code, inviteSalt)
|
||||
if (cryptoModel) {
|
||||
const inviteSalt = cryptoModel.generateInviteSalt()
|
||||
const ekChain = cryptoModel.encryptChainForInvite([chat.rootId], code, inviteSalt)
|
||||
const ident = opts.public ? { code, ...pubFlag } : { ch: hashCode(code, inviteSalt) }
|
||||
if (ekChain) {
|
||||
invite = { ...ident, ekChain, salt: inviteSalt, gen: lookupGen(chat.rootId) }
|
||||
} else {
|
||||
const chatKey = lookupKey(chat.rootId)
|
||||
if (chatKey) {
|
||||
const ek = tribeCrypto.encryptForInvite(chatKey, code, inviteSalt)
|
||||
const ek = cryptoModel.encryptForInvite(chatKey, code, inviteSalt)
|
||||
invite = { ...ident, ek, salt: inviteSalt, gen: lookupGen(chat.rootId) }
|
||||
} else {
|
||||
invite = { ...ident, salt: inviteSalt }
|
||||
|
|
@ -823,13 +823,13 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
|
||||
let chatKey = null
|
||||
let chain = null
|
||||
if (tribeCrypto && typeof matchedInvite === "object") {
|
||||
if (cryptoModel && typeof matchedInvite === "object") {
|
||||
if (matchedInvite.ekChain) {
|
||||
chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3)
|
||||
chain = cryptoModel.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3)
|
||||
if (Array.isArray(chain) && chain.length) chatKey = chain[0].key
|
||||
else chain = null
|
||||
} else if (matchedInvite.ek) {
|
||||
chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt)
|
||||
chatKey = cryptoModel.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -843,9 +843,9 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
if (chain) {
|
||||
for (const entry of chain) {
|
||||
if (Array.isArray(entry.keys) && entry.keys.length) {
|
||||
tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length)
|
||||
cryptoModel.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length)
|
||||
} else if (entry.key) {
|
||||
tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1)
|
||||
cryptoModel.setKey(entry.rootId, entry.key, entry.gen || 1)
|
||||
}
|
||||
}
|
||||
} else if (chatKey) {
|
||||
|
|
@ -854,13 +854,13 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
|
||||
await publishMemberToggle(ssbClient, matchedChat.rootId, userId, true, isPublic ? undefined : token)
|
||||
|
||||
if (tribeCrypto && chatKey) {
|
||||
if (cryptoModel && chatKey) {
|
||||
try {
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys")
|
||||
const memberKeys = {}
|
||||
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[userId] = cryptoModel.boxKeyForMember(chatKey, userId, ssbKeys) } catch (_) {}
|
||||
if (matchedChat.author && matchedChat.author !== userId) {
|
||||
try { memberKeys[matchedChat.author] = tribeCrypto.boxKeyForMember(chatKey, matchedChat.author, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[matchedChat.author] = cryptoModel.boxKeyForMember(chatKey, matchedChat.author, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (Object.keys(memberKeys).length) {
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -881,7 +881,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
if (chat.status === "CLOSED") throw new Error("Chat is closed")
|
||||
if (chat.members.includes(userId)) return chat.key
|
||||
|
||||
if (tribeCrypto && Array.isArray(chat.invites)) {
|
||||
if (cryptoModel && Array.isArray(chat.invites)) {
|
||||
const pub = chat.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain))
|
||||
if (pub) return await this.joinByInvite(pub.code)
|
||||
}
|
||||
|
|
@ -960,17 +960,17 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
}
|
||||
|
||||
const chatIsEncrypted = !!(chat.tribeId) || !!lookupKey(chat.rootId)
|
||||
if (chatIsEncrypted && tribeCrypto) {
|
||||
if (chatIsEncrypted && cryptoModel) {
|
||||
let encKey = null
|
||||
if (chat.tribeId) encKey = await getTribeFirstKeyFor(chat.tribeId)
|
||||
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)
|
||||
content.encryptedText = cryptoModel.encryptWithKey(safeText(text), encKey)
|
||||
if (content.image) {
|
||||
content.encryptedImage = tribeCrypto.encryptWithKey(String(content.image), encKey)
|
||||
content.encryptedImage = cryptoModel.encryptWithKey(String(content.image), encKey)
|
||||
delete content.image
|
||||
if (content.mimeType) {
|
||||
content.encryptedMime = tribeCrypto.encryptWithKey(String(content.mimeType), encKey)
|
||||
content.encryptedMime = cryptoModel.encryptWithKey(String(content.mimeType), encKey)
|
||||
delete content.mimeType
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
src/models/comments_model.js
Normal file
111
src/models/comments_model.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
const { readTyped } = require('./typed_log');
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
|
||||
const MSG_ID_RE = /^%[A-Za-z0-9+/]{43}=?\.sha256$/;
|
||||
const ROOT_CACHE_MAX = 2000;
|
||||
|
||||
const isMsgId = (v) => typeof v === 'string' && MSG_ID_RE.test(v);
|
||||
const isComment = (c) => !!c && typeof c === 'object' && c.type === 'post' && isMsgId(c.root) && !c.fork;
|
||||
const isBlogPost = (c) => !!c && typeof c === 'object' && c.type === 'post' && typeof c.text === 'string' && !c.root && !c.fork && !c.branch && !c.about && !c.target;
|
||||
const contentOf = (m) => (m && m.value && m.value.content) || (m && m.content) || null;
|
||||
|
||||
const commentsModel = ({ cooler, post }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||
const roots = new Map();
|
||||
|
||||
const getMsg = async (id) => {
|
||||
const client = await openSsb();
|
||||
return new Promise((res) => client.get(id, (err, m) => res(err ? null : m)));
|
||||
};
|
||||
|
||||
const remember = (id, type) => {
|
||||
if (roots.size >= ROOT_CACHE_MAX) roots.delete(roots.keys().next().value);
|
||||
roots.set(id, type);
|
||||
};
|
||||
|
||||
const rootTypeOf = async (rootId) => {
|
||||
if (!isMsgId(rootId)) return null;
|
||||
if (roots.has(rootId)) return roots.get(rootId);
|
||||
const c = contentOf(await getMsg(rootId).catch(() => null));
|
||||
const type = !c || typeof c !== 'object' || typeof c.type !== 'string' ? null : (c.type === 'post' && !isBlogPost(c) ? null : c.type);
|
||||
if (type) remember(rootId, type);
|
||||
return type;
|
||||
};
|
||||
|
||||
const annotate = async (items) => {
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
const wanted = new Set();
|
||||
for (const it of list) { const c = contentOf(it); if (isComment(c)) wanted.add(c.root); }
|
||||
const types = new Map();
|
||||
await Promise.all([...wanted].map(async (id) => types.set(id, await rootTypeOf(id).catch(() => null))));
|
||||
for (const it of list) {
|
||||
const c = contentOf(it);
|
||||
if (!isComment(c)) continue;
|
||||
it.comment = { rootId: c.root, rootType: types.get(c.root) || null };
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
const locate = async (id) => {
|
||||
if (!isMsgId(id)) return null;
|
||||
const msg = await getMsg(id).catch(() => null);
|
||||
const c = contentOf(msg);
|
||||
if (!c || typeof c !== 'object' || typeof c.type !== 'string') return null;
|
||||
if (isComment(c)) return { commentId: id, rootId: c.root, rootType: await rootTypeOf(c.root).catch(() => null) };
|
||||
if (c.type === 'post') return isBlogPost(c) ? { rootId: id, rootType: 'post' } : null;
|
||||
remember(id, c.type);
|
||||
return { rootId: id, rootType: c.type, author: msg.author, content: c };
|
||||
};
|
||||
|
||||
const listFor = async (rootId) => {
|
||||
if (!isMsgId(rootId)) return [];
|
||||
const raw = await post.topicComments(rootId);
|
||||
return (raw || []).filter(m => { const c = contentOf(m); return isComment(c) && c.root === rootId; })
|
||||
.sort((a, b) => (a?.value?.timestamp || 0) - (b?.value?.timestamp || 0));
|
||||
};
|
||||
|
||||
const listForMany = async (ids) => {
|
||||
const want = new Set((ids || []).filter(isMsgId));
|
||||
const out = new Map();
|
||||
if (!want.size) return out;
|
||||
const client = await openSsb();
|
||||
const msgs = await readTyped(client, ['post', 'tombstone'], { limit: getConfig().ssbLogStream?.limit || 1000 });
|
||||
const gone = buildValidatedTombstoneSet(msgs || []);
|
||||
for (const m of msgs || []) {
|
||||
const c = contentOf(m);
|
||||
if (!isComment(c) || gone.has(m.key) || !want.has(c.root)) continue;
|
||||
if (!out.has(c.root)) out.set(c.root, []);
|
||||
out.get(c.root).push(m);
|
||||
}
|
||||
for (const list of out.values()) list.sort((a, b) => (a?.value?.timestamp || 0) - (b?.value?.timestamp || 0));
|
||||
return out;
|
||||
};
|
||||
|
||||
const countsFor = async (ids) => {
|
||||
const want = new Set((ids || []).filter(isMsgId));
|
||||
const counts = new Map();
|
||||
if (!want.size) return counts;
|
||||
const client = await openSsb();
|
||||
const msgs = await readTyped(client, ['post', 'tombstone'], { limit: getConfig().ssbLogStream?.limit || 1000 });
|
||||
const gone = buildValidatedTombstoneSet(msgs || []);
|
||||
for (const m of msgs || []) {
|
||||
const c = contentOf(m);
|
||||
if (!isComment(c) || gone.has(m.key) || !want.has(c.root)) continue;
|
||||
counts.set(c.root, (counts.get(c.root) || 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
|
||||
const publish = async ({ rootId, text }) => {
|
||||
if (!isMsgId(rootId)) throw new Error('Invalid root');
|
||||
return post.publish({ text, root: rootId, dest: rootId });
|
||||
};
|
||||
|
||||
return { isComment, isBlogPost, rootTypeOf, annotate, locate, listFor, listForMany, countsFor, publish };
|
||||
};
|
||||
|
||||
module.exports = commentsModel;
|
||||
module.exports.isComment = isComment;
|
||||
module.exports.isBlogPost = isBlogPost;
|
||||
|
|
@ -27,7 +27,7 @@ const COURTS_TYPES = [
|
|||
];
|
||||
const VOTE_LOG_TYPES = ['votes', 'votesVote', 'tombstone'];
|
||||
|
||||
module.exports = ({ cooler, services = {}, tribeCrypto }) => {
|
||||
module.exports = ({ cooler, services = {}, cryptoModel }) => {
|
||||
let ssb;
|
||||
let userId;
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ module.exports = ({ cooler, services = {}, tribeCrypto }) => {
|
|||
for (const f of fields) {
|
||||
if (content[f] !== undefined) payload[f] = content[f];
|
||||
}
|
||||
const enc = tribeCrypto.encryptWithKey(JSON.stringify(payload), keyHex);
|
||||
const enc = cryptoModel.encryptWithKey(JSON.stringify(payload), keyHex);
|
||||
const result = { ...content };
|
||||
for (const f of fields) delete result[f];
|
||||
result.encryptedPayload = enc;
|
||||
|
|
@ -57,7 +57,7 @@ module.exports = ({ cooler, services = {}, tribeCrypto }) => {
|
|||
const decryptFields = (content, keyHex) => {
|
||||
if (!content || !content.encryptedPayload) return content;
|
||||
try {
|
||||
const plain = tribeCrypto.decryptWithKey(content.encryptedPayload, keyHex);
|
||||
const plain = cryptoModel.decryptWithKey(content.encryptedPayload, keyHex);
|
||||
const payload = JSON.parse(plain);
|
||||
const result = { ...content };
|
||||
delete result.encryptedPayload;
|
||||
|
|
@ -68,15 +68,15 @@ module.exports = ({ cooler, services = {}, tribeCrypto }) => {
|
|||
};
|
||||
|
||||
const getCaseKey = (obj) => {
|
||||
if (!tribeCrypto || !obj) return null;
|
||||
return tribeCrypto.getKey(obj.rootCaseId || obj.id);
|
||||
if (!cryptoModel || !obj) return null;
|
||||
return cryptoModel.getKey(obj.rootCaseId || obj.id);
|
||||
};
|
||||
|
||||
const distributeKey = async (caseKey, caseRootId, recipientId) => {
|
||||
if (!tribeCrypto || !recipientId) return;
|
||||
if (!cryptoModel || !recipientId) return;
|
||||
const ssbClient = await openSsb();
|
||||
const ssbKeys = require('../server/node_modules/ssb-keys');
|
||||
const boxed = tribeCrypto.boxKeyForMember(caseKey, recipientId, ssbKeys);
|
||||
const boxed = cryptoModel.boxKeyForMember(caseKey, recipientId, ssbKeys);
|
||||
const content = { type: 'courts-key', caseRootId, for: recipientId, memberKey: boxed };
|
||||
await new Promise((res, rej) => ssbClient.publish(content, (e) => e ? rej(e) : res()));
|
||||
};
|
||||
|
|
@ -182,15 +182,15 @@ if (c.type === type) {
|
|||
createdAt: openedAt
|
||||
};
|
||||
|
||||
if (tribeCrypto) {
|
||||
if (cryptoModel) {
|
||||
const stub = { ...content };
|
||||
for (const f of CASE_FIELDS) delete stub[f];
|
||||
const initialMsg = await new Promise((res, rej) =>
|
||||
ssbClient.publish(stub, (err, msg) => (err ? rej(err) : res(msg)))
|
||||
);
|
||||
const caseRootId = initialMsg.key;
|
||||
const caseKey = tribeCrypto.generateTribeKey();
|
||||
tribeCrypto.setKey(caseRootId, caseKey, 1);
|
||||
const caseKey = cryptoModel.generateKey();
|
||||
cryptoModel.setKey(caseRootId, caseKey, 1);
|
||||
const encrypted = encryptFields({ ...content, rootCaseId: caseRootId }, caseKey, CASE_FIELDS);
|
||||
const update = { ...encrypted, replaces: caseRootId, updatedAt: openedAt };
|
||||
const finalMsg = await new Promise((res, rej) =>
|
||||
|
|
@ -317,7 +317,7 @@ if (c.type === type) {
|
|||
const node = nodes.get(tip);
|
||||
if (!node) return null;
|
||||
let c = { id: tip, ...node.c };
|
||||
if (tribeCrypto) { const key = getCaseKey(c); if (key) c = decryptFields(c, key); }
|
||||
if (cryptoModel) { const key = getCaseKey(c); if (key) c = decryptFields(c, key); }
|
||||
if (!c.rootCaseId) c.rootCaseId = root;
|
||||
return c;
|
||||
}
|
||||
|
|
@ -344,7 +344,7 @@ if (c.type === type) {
|
|||
const author = String(m.value?.author || '');
|
||||
const ts = m.value?.timestamp || m.timestamp || 0;
|
||||
if (c.type === 'courtsMediators' && matchC(c)) {
|
||||
let dec = c; if (tribeCrypto && caseKey) dec = decryptFields(c, caseKey);
|
||||
let dec = c; if (cryptoModel && caseKey) dec = decryptFields(c, caseKey);
|
||||
const list = ensureArray(dec.mediators || []).map(x => String(x || '').trim()).filter(Boolean).filter(x => x !== accuser && x !== respondentId);
|
||||
if (c.side === 'accuser' && author === accuser && ts >= medATs) { medA = list; medATs = ts; }
|
||||
else if (c.side === 'respondent' && author === respondentId && ts >= medRTs) { medR = list; medRTs = ts; }
|
||||
|
|
@ -454,7 +454,7 @@ if (c.type === type) {
|
|||
);
|
||||
const clean = list.filter((id) => id !== c.accuser && id !== c.respondentId);
|
||||
const caseRootId = c.rootCaseId || c.id;
|
||||
if (tribeCrypto) {
|
||||
if (cryptoModel) {
|
||||
const caseKey = getCaseKey(c);
|
||||
if (caseKey) {
|
||||
for (const mediatorId of clean) {
|
||||
|
|
@ -464,7 +464,7 @@ if (c.type === type) {
|
|||
}
|
||||
const ssbClient = await openSsb();
|
||||
let content = { type: 'courtsMediators', caseId: caseRootId, side, mediators: clean, author: userId, createdAt: nowISO() };
|
||||
if (tribeCrypto) {
|
||||
if (cryptoModel) {
|
||||
const caseKey = getCaseKey(c);
|
||||
if (caseKey) content = encryptFields(content, caseKey, MEDIATORS_FIELDS);
|
||||
}
|
||||
|
|
@ -488,7 +488,7 @@ if (c.type === type) {
|
|||
throw new Error('Judge cannot be a party of the case.');
|
||||
}
|
||||
const caseRootId = c.rootCaseId || c.id;
|
||||
if (tribeCrypto) {
|
||||
if (cryptoModel) {
|
||||
const caseKey = getCaseKey(c);
|
||||
if (caseKey) {
|
||||
await distributeKey(caseKey, caseRootId, id);
|
||||
|
|
@ -525,7 +525,7 @@ if (c.type === type) {
|
|||
imageUrl,
|
||||
createdAt: nowISO()
|
||||
};
|
||||
if (tribeCrypto) {
|
||||
if (cryptoModel) {
|
||||
const caseKey = getCaseKey(c);
|
||||
if (caseKey) content = encryptFields(content, caseKey, EVIDENCE_FIELDS);
|
||||
}
|
||||
|
|
@ -552,7 +552,7 @@ if (c.type === type) {
|
|||
text: t,
|
||||
createdAt: nowISO()
|
||||
};
|
||||
if (tribeCrypto) {
|
||||
if (cryptoModel) {
|
||||
const caseKey = getCaseKey(c);
|
||||
if (caseKey) content = encryptFields(content, caseKey, ANSWER_FIELDS);
|
||||
}
|
||||
|
|
@ -592,7 +592,7 @@ if (c.type === type) {
|
|||
orders: o,
|
||||
createdAt: nowISO()
|
||||
};
|
||||
if (tribeCrypto) {
|
||||
if (cryptoModel) {
|
||||
const caseKey = getCaseKey(c);
|
||||
if (caseKey) content = encryptFields(content, caseKey, VERDICT_FIELDS);
|
||||
}
|
||||
|
|
@ -620,7 +620,7 @@ if (c.type === type) {
|
|||
terms: t,
|
||||
createdAt: nowISO()
|
||||
};
|
||||
if (tribeCrypto) {
|
||||
if (cryptoModel) {
|
||||
const caseKey = getCaseKey(c);
|
||||
if (caseKey) content = encryptFields(content, caseKey, SETTLEMENT_FIELDS);
|
||||
}
|
||||
|
|
@ -972,7 +972,7 @@ if (c.type === type) {
|
|||
}
|
||||
|
||||
async function processIncomingCourtsKeys() {
|
||||
if (!tribeCrypto) return;
|
||||
if (!cryptoModel) return;
|
||||
const ssbKeys = require('../server/node_modules/ssb-keys');
|
||||
const ssbConfig = require('../server/ssb_config');
|
||||
const ssbClient = await openSsb();
|
||||
|
|
@ -982,10 +982,10 @@ if (c.type === type) {
|
|||
if (!c || c.type !== 'courts-key') continue;
|
||||
if (c.for !== ssbClient.id) continue;
|
||||
if (!c.memberKey || !c.caseRootId) continue;
|
||||
if (tribeCrypto.getKey(c.caseRootId)) continue;
|
||||
if (cryptoModel.getKey(c.caseRootId)) continue;
|
||||
try {
|
||||
const key = tribeCrypto.unboxKeyFromMember(c.memberKey, ssbConfig.keys, ssbKeys);
|
||||
if (key) tribeCrypto.setKey(c.caseRootId, key, 1);
|
||||
const key = cryptoModel.unboxKeyFromMember(c.memberKey, ssbConfig.keys, ssbKeys);
|
||||
if (key) cryptoModel.setKey(c.caseRootId, key, 1);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,14 @@ const ENVELOPE_VERSION = 1;
|
|||
const KEY_DISTRIB_TYPE = 'tribe-keys-distrib';
|
||||
const KEY_DISTRIB_BATCH = 7;
|
||||
|
||||
module.exports = (configPath, namespace = 'tribes') => {
|
||||
const SHARED_SCOPE = 'tribes';
|
||||
|
||||
const createScope = (configPath, namespace) => {
|
||||
const keysDir = require('../configs/state-manager').keysDir(configPath);
|
||||
try { fs.mkdirSync(keysDir, { recursive: true, mode: 0o700 }); } catch (_) {}
|
||||
const keyringPath = path.join(keysDir, `${namespace}-keys.json`);
|
||||
|
||||
if (namespace === 'tribes') {
|
||||
if (namespace === SHARED_SCOPE) {
|
||||
const legacyPath = path.join(configPath, 'tribe-keys.json');
|
||||
try {
|
||||
if (fs.existsSync(legacyPath) && !fs.existsSync(keyringPath)) {
|
||||
|
|
@ -65,7 +67,7 @@ module.exports = (configPath, namespace = 'tribes') => {
|
|||
try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
|
||||
};
|
||||
|
||||
const generateTribeKey = () => crypto.randomBytes(32).toString('hex');
|
||||
const generateKey = () => crypto.randomBytes(32).toString('hex');
|
||||
const HEX_KEY = /^[0-9a-f]{64}$/i;
|
||||
const validKey = (k) => typeof k === 'string' && HEX_KEY.test(k);
|
||||
|
||||
|
|
@ -198,7 +200,7 @@ module.exports = (configPath, namespace = 'tribes') => {
|
|||
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
||||
};
|
||||
|
||||
const isTribeMsg = (c) =>
|
||||
const isSealedMsg = (c) =>
|
||||
!!c && c.type === ENVELOPE_TYPE && c.v === ENVELOPE_VERSION && typeof c.fp === 'string' && typeof c.p === 'string';
|
||||
|
||||
const wrapMsg = (body, kHex) => {
|
||||
|
|
@ -210,7 +212,7 @@ module.exports = (configPath, namespace = 'tribes') => {
|
|||
};
|
||||
|
||||
const unwrapMsg = (envelope, fpIdx) => {
|
||||
if (!isTribeMsg(envelope)) return null;
|
||||
if (!isSealedMsg(envelope)) return null;
|
||||
const aad = buildAad(envelope.fp);
|
||||
const tryKey = (rootId, keyHex) => {
|
||||
try {
|
||||
|
|
@ -495,15 +497,15 @@ module.exports = (configPath, namespace = 'tribes') => {
|
|||
return buildKeyChainSets(ancestryIds);
|
||||
};
|
||||
|
||||
const encryptForTribe = async (content, tribeId, tribesModel) => {
|
||||
const encryptForGroup = async (content, tribeId, tribesModel) => {
|
||||
const chain = await resolveKeyChain(tribeId, tribesModel);
|
||||
if (!chain) throw new Error('Missing tribe key chain — cannot encrypt content for this tribe');
|
||||
if (!chain) throw new Error('Missing key chain — cannot encrypt this content');
|
||||
return encryptContent(content, chain, true);
|
||||
};
|
||||
|
||||
const decryptFromTribe = async (content, tribesModel) => {
|
||||
const decryptFromGroup = async (content, tribesModel) => {
|
||||
if (!content) return content;
|
||||
if (isTribeMsg(content)) {
|
||||
if (isSealedMsg(content)) {
|
||||
const fpIdx = buildFingerprintIndex();
|
||||
const r = unwrapMsg(content, fpIdx);
|
||||
if (!r) return { ...content, _undecryptable: true };
|
||||
|
|
@ -539,17 +541,17 @@ module.exports = (configPath, namespace = 'tribes') => {
|
|||
};
|
||||
|
||||
const createHelpers = (tribesModel) => ({
|
||||
async encryptIfTribe(content) {
|
||||
async encryptIfGroup(content) {
|
||||
if (!content || !content.tribeId || !tribesModel) return content;
|
||||
const rootId = await tribesModel.getRootId(content.tribeId);
|
||||
const key = getKey(rootId);
|
||||
if (!key) throw new Error(`Missing tribe key for ${rootId} — cannot publish encrypted content`);
|
||||
if (!key) throw new Error(`Missing key for ${rootId} — cannot publish encrypted content`);
|
||||
const body = { k: content.type, ...content };
|
||||
return wrapMsg(body, key);
|
||||
},
|
||||
async decryptIfTribe(content) {
|
||||
async decryptIfGroup(content) {
|
||||
if (!content || !tribesModel) return content;
|
||||
if (isTribeMsg(content)) {
|
||||
if (isSealedMsg(content)) {
|
||||
const fpIdx = buildFingerprintIndex();
|
||||
const r = unwrapMsg(content, fpIdx);
|
||||
if (!r) return { ...content, _undecryptable: true };
|
||||
|
|
@ -560,17 +562,17 @@ module.exports = (configPath, namespace = 'tribes') => {
|
|||
return flat;
|
||||
}
|
||||
if (!content.encryptedPayload) return content;
|
||||
return await decryptFromTribe(content, tribesModel);
|
||||
return await decryptFromGroup(content, tribesModel);
|
||||
},
|
||||
assertReadable(decrypted, what) {
|
||||
if (decrypted && decrypted._undecryptable) throw new Error(`${what} is tribe-encrypted and cannot be decrypted with available keys`);
|
||||
if (decrypted && decrypted._undecryptable) throw new Error(`${what} is encrypted and cannot be decrypted with available keys`);
|
||||
},
|
||||
async decryptIndexNodes(idx) {
|
||||
if (!tribesModel) return;
|
||||
for (const [k, n] of idx.nodes.entries()) {
|
||||
if (!n.c) continue;
|
||||
if (!n.c.encryptedPayload && !isTribeMsg(n.c)) continue;
|
||||
const dec = await decryptFromTribe(n.c, tribesModel);
|
||||
if (!n.c.encryptedPayload && !isSealedMsg(n.c)) continue;
|
||||
const dec = await decryptFromGroup(n.c, tribesModel);
|
||||
if (dec && !dec._undecryptable) {
|
||||
idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } });
|
||||
} else {
|
||||
|
|
@ -586,7 +588,7 @@ module.exports = (configPath, namespace = 'tribes') => {
|
|||
for (const m of messages) {
|
||||
const c = m && m.value && m.value.content;
|
||||
if (!c) continue;
|
||||
if (!isTribeMsg(c)) { out.push(m); continue; }
|
||||
if (!isSealedMsg(c)) { out.push(m); continue; }
|
||||
const r = unwrapMsg(c, fpIdx);
|
||||
if (!r || !r.body) continue;
|
||||
const inner = r.body;
|
||||
|
|
@ -606,7 +608,7 @@ module.exports = (configPath, namespace = 'tribes') => {
|
|||
if (!tribeId || !tribesModel) return { type: 'tombstone', target, deletedAt, author };
|
||||
const rootId = await tribesModel.getRootId(tribeId);
|
||||
const key = getKey(rootId);
|
||||
if (!key) throw new Error(`Missing tribe key for ${rootId} — cannot publish encrypted tombstone`);
|
||||
if (!key) throw new Error(`Missing key for ${rootId} — cannot publish encrypted tombstone`);
|
||||
return wrapMsg({ k: 'tombstone', target, deletedAt, author }, key);
|
||||
}
|
||||
});
|
||||
|
|
@ -617,10 +619,10 @@ module.exports = (configPath, namespace = 'tribes') => {
|
|||
SENSITIVE_FIELDS, ENVELOPE_PRESERVE,
|
||||
ENVELOPE_TYPE, ENVELOPE_VERSION, KEY_DISTRIB_TYPE, KEY_DISTRIB_BATCH,
|
||||
loadKeyring, saveKeyring,
|
||||
generateTribeKey, getKey, getKeys, getGen, getAllRootIds,
|
||||
generateKey, getKey, getKeys, getGen, getAllRootIds,
|
||||
setKey, setKeys, mergeKeys, addNewKey, dropKey, keyPlan,
|
||||
fingerprint, buildFingerprintIndex,
|
||||
isTribeMsg, wrapMsg, unwrapMsg,
|
||||
isSealedMsg, wrapMsg, unwrapMsg,
|
||||
encryptWithKey, decryptWithKey,
|
||||
generateInviteSalt, hashInviteCode, deriveInviteKey,
|
||||
encryptForInvite, decryptFromInvite,
|
||||
|
|
@ -630,7 +632,16 @@ module.exports = (configPath, namespace = 'tribes') => {
|
|||
canonicalAad, encryptChain, decryptChain,
|
||||
encryptContent, decryptContent,
|
||||
buildKeyChainSets, resolveKeyChain, resolveKeyChainSets,
|
||||
encryptForTribe, decryptFromTribe,
|
||||
encryptForGroup, decryptFromGroup,
|
||||
createHelpers
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = (configPath) => {
|
||||
const scopes = new Map();
|
||||
const scope = (namespace = SHARED_SCOPE) => {
|
||||
if (!scopes.has(namespace)) scopes.set(namespace, createScope(configPath, namespace));
|
||||
return scopes.get(namespace);
|
||||
};
|
||||
return { ...scope(SHARED_SCOPE), scope };
|
||||
};
|
||||
|
|
@ -165,6 +165,7 @@ module.exports = ({ cooler, favoriteIdsFor = null }) => {
|
|||
const vg = versionGroups(versions);
|
||||
const latestByRoot = new Map();
|
||||
for (const root of vg.roots) {
|
||||
if (tomb.has(root) || vg.isForeignCopy(root)) continue;
|
||||
const tip = candidates.get(vg.tipOf(root));
|
||||
if (!tip) continue;
|
||||
if (tip.kind !== 'tribes') { latestByRoot.set(root, { ...tip, key: root }); continue; }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const pull = require("../server/node_modules/pull-stream");
|
||||
const opinionRules = require("../backend/opinion_categories");
|
||||
const { getConfig } = require("../configs/config-manager.js");
|
||||
const categories = require("../backend/opinion_categories");
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
|
|
@ -107,12 +108,6 @@ module.exports = ({ cooler }) => {
|
|||
};
|
||||
};
|
||||
|
||||
const hasBlob = (ssbClient, blobId) =>
|
||||
new Promise((resolve) => {
|
||||
if (!blobId) return resolve(false);
|
||||
ssbClient.blobs.has(blobId, (err, has) => resolve(!err && !!has));
|
||||
});
|
||||
|
||||
const favoritesSetForDocuments = async () => {
|
||||
try {
|
||||
return await contentFavorites.getFavoriteSet("documents");
|
||||
|
|
@ -180,7 +175,7 @@ module.exports = ({ cooler }) => {
|
|||
if (oldMsg.content?.type !== "document") throw new Error("Document not found");
|
||||
if (String(oldMsg.content.author) !== String(userId)) throw new Error("Not the author");
|
||||
const aggDoc = await this.getDocumentById(id);
|
||||
if (aggDoc && Object.keys(aggDoc.opinions || {}).some(k => (aggDoc.opinions[k] || 0) > 0)) throw new Error("Cannot edit document after it has received opinions.");
|
||||
opinionRules.assertMediaKept(aggDoc, !!blobMarkdown);
|
||||
|
||||
const parsedTags = parseCSV(tagsRaw);
|
||||
const tags = parsedTags !== undefined ? parsedTags : safeArr(oldMsg.content.tags);
|
||||
|
|
@ -277,10 +272,7 @@ module.exports = ({ cooler }) => {
|
|||
out = out.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
}
|
||||
|
||||
const checked = await Promise.all(out.map(async (d) => ((await hasBlob(ssbClient, d.url)) ? d : null)));
|
||||
return checked
|
||||
.filter(Boolean)
|
||||
.map((d) => ({ ...d, isFavorite: favorites.has(d.rootId || d.key) }));
|
||||
return out.map((d) => ({ ...d, isFavorite: favorites.has(d.rootId || d.key) }));
|
||||
},
|
||||
|
||||
async getDocumentById(id) {
|
||||
|
|
|
|||
|
|
@ -16,28 +16,28 @@ const EVENT_TYPES = [
|
|||
'tribe-keys', 'tombstone'
|
||||
];
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
||||
module.exports = ({ cooler, cryptoModel, eventCrypto, tribesModel }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||
const me = async () => (await openSsb()).id;
|
||||
|
||||
const ownCrypto = eventCrypto || tribeCrypto;
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null;
|
||||
const ownCrypto = eventCrypto || cryptoModel;
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (cryptoModel && cryptoModel.getKey(rid)) || null;
|
||||
|
||||
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0);
|
||||
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (cryptoModel && cryptoModel.getGen(rid)) || 0);
|
||||
|
||||
const readAll = async (ssbClient) =>
|
||||
readTyped(ssbClient, EVENT_TYPES, { limit: logLimit });
|
||||
|
||||
const rotateEventKey = async (rid, attendees) => {
|
||||
if (!ownCrypto || !tribeCrypto || !rid || !lookupKey(rid)) return;
|
||||
if (!ownCrypto || !cryptoModel || !rid || !lookupKey(rid)) return;
|
||||
const ssbClient = await openSsb();
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys");
|
||||
const newKey = ownCrypto.generateTribeKey();
|
||||
const newKey = ownCrypto.generateKey();
|
||||
const newGen = ownCrypto.addNewKey(rid, newKey);
|
||||
const memberKeys = {};
|
||||
for (const m of new Set([ssbClient.id, ...(Array.isArray(attendees) ? attendees : [])])) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {}
|
||||
try { memberKeys[m] = cryptoModel.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {}
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
ssbClient.publish({ type: "tribe-keys", tribeId: rid, generation: newGen, memberKeys }, () => resolve());
|
||||
|
|
@ -45,7 +45,7 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
};
|
||||
|
||||
const ensureAttendeeKeys = async (ssbClient, messages, events) => {
|
||||
if (!tribeCrypto || !ownCrypto) return;
|
||||
if (!cryptoModel || !ownCrypto) return;
|
||||
const leavesByRoot = new Map();
|
||||
for (const m of messages) {
|
||||
const c = m.value && m.value.content;
|
||||
|
|
@ -63,7 +63,7 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
const key = lookupKey(ev.rid);
|
||||
const memberKeys = {};
|
||||
for (const m of plan.missing) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(key, m, ssbKeys); } catch (_) {}
|
||||
try { memberKeys[m] = cryptoModel.boxKeyForMember(key, m, ssbKeys); } catch (_) {}
|
||||
}
|
||||
if (!Object.keys(memberKeys).length) continue;
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -100,10 +100,10 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
const decryptEventContent = (rawContent, eventId) => {
|
||||
if (!rawContent) return null;
|
||||
if (!rawContent.encryptedPayload) return rawContent;
|
||||
if (!ownCrypto || !tribeCrypto) return { ...rawContent, _undecryptable: true };
|
||||
if (!ownCrypto || !cryptoModel) return { ...rawContent, _undecryptable: true };
|
||||
const keys = (ownCrypto.getKeys && ownCrypto.getKeys(eventId)) || [];
|
||||
if (!keys.length) return { ...rawContent, _undecryptable: true };
|
||||
const dec = tribeCrypto.decryptContent(rawContent, keys.map(k => [k]));
|
||||
const dec = cryptoModel.decryptContent(rawContent, keys.map(k => [k]));
|
||||
if (!dec || dec._undecryptable) return { ...rawContent, _undecryptable: true };
|
||||
return { ...dec, _decrypted: true };
|
||||
};
|
||||
|
|
@ -256,12 +256,12 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
opinions_inhabitants: []
|
||||
};
|
||||
|
||||
const shouldEncrypt = visibility === 'private' && ownCrypto && tribeCrypto;
|
||||
const shouldEncrypt = visibility === 'private' && ownCrypto && cryptoModel;
|
||||
let eventKey = null;
|
||||
let content = plainContent;
|
||||
if (shouldEncrypt) {
|
||||
eventKey = ownCrypto.generateTribeKey();
|
||||
content = tribeCrypto.encryptContent(plainContent, [eventKey], true);
|
||||
eventKey = ownCrypto.generateKey();
|
||||
content = cryptoModel.encryptContent(plainContent, [eventKey], true);
|
||||
}
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
|
|
@ -274,7 +274,7 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
const ssbKeys = require("../server/node_modules/ssb-keys");
|
||||
const memberKeys = {};
|
||||
for (const m of attendeeList) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(eventKey, m, ssbKeys); } catch (_) {}
|
||||
try { memberKeys[m] = cryptoModel.boxKeyForMember(eventKey, m, ssbKeys); } catch (_) {}
|
||||
}
|
||||
if (Object.keys(memberKeys).length) {
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -287,7 +287,7 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
},
|
||||
|
||||
async generateInvite(eventId) {
|
||||
if (!ownCrypto || !tribeCrypto) throw new Error("Event crypto unavailable");
|
||||
if (!ownCrypto || !cryptoModel) throw new Error("Event crypto unavailable");
|
||||
const ssbClient = await openSsb();
|
||||
const userId = await me();
|
||||
const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, m) => err || !m || !m.content ? rej(new Error("Error retrieving event")) : res(m)));
|
||||
|
|
@ -299,11 +299,11 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
const key = lookupKey(rid);
|
||||
if (!key) throw new Error("Missing event key — cannot generate invite");
|
||||
const code = crypto.randomBytes(16).toString('hex');
|
||||
const inviteSalt = tribeCrypto.generateInviteSalt();
|
||||
const ek = tribeCrypto.encryptForInvite(key, code, inviteSalt);
|
||||
const inviteSalt = cryptoModel.generateInviteSalt();
|
||||
const ek = cryptoModel.encryptForInvite(key, code, inviteSalt);
|
||||
const inviteRef = { code, ek, salt: inviteSalt, gen: 1, rootId: rid };
|
||||
await new Promise((resolve, reject) => {
|
||||
ssbClient.publish({ type: 'event-invite', target: rid, ek, salt: inviteSalt, codeHash: tribeCrypto.hashInviteCode(code, inviteSalt) }, (err) => err ? reject(err) : resolve());
|
||||
ssbClient.publish({ type: 'event-invite', target: rid, ek, salt: inviteSalt, codeHash: cryptoModel.hashInviteCode(code, inviteSalt) }, (err) => err ? reject(err) : resolve());
|
||||
});
|
||||
return { code, eventId: rid };
|
||||
},
|
||||
|
|
@ -339,7 +339,7 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
},
|
||||
|
||||
async generateOpenInvite(eventId) {
|
||||
if (!ownCrypto || !tribeCrypto) throw new Error("Event crypto unavailable");
|
||||
if (!ownCrypto || !cryptoModel) throw new Error("Event crypto unavailable");
|
||||
const ssbClient = await openSsb();
|
||||
const userId = await me();
|
||||
const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, m) => err || !m || !m.content ? rej(new Error("Error retrieving event")) : res(m)));
|
||||
|
|
@ -352,10 +352,10 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
const key = lookupKey(rid);
|
||||
if (!key) throw new Error("Missing event key — cannot generate invite");
|
||||
const code = crypto.randomBytes(16).toString('hex');
|
||||
const inviteSalt = tribeCrypto.generateInviteSalt();
|
||||
const ek = tribeCrypto.encryptForInvite(key, code, inviteSalt);
|
||||
const inviteSalt = cryptoModel.generateInviteSalt();
|
||||
const ek = cryptoModel.encryptForInvite(key, code, inviteSalt);
|
||||
const invitePub = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish({ type: 'event-invite', target: rid, ek, salt: inviteSalt, codeHash: tribeCrypto.hashInviteCode(code, inviteSalt), multi: 1 }, (err, r) => err ? reject(err) : resolve(r));
|
||||
ssbClient.publish({ type: 'event-invite', target: rid, ek, salt: inviteSalt, codeHash: cryptoModel.hashInviteCode(code, inviteSalt), multi: 1 }, (err, r) => err ? reject(err) : resolve(r));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
ssbClient.publish({ type: 'event-open-invite', v: 1, target: rid, code, inviteKey: invitePub.key, by: userId, createdAt: new Date().toISOString() }, (err) => err ? reject(err) : resolve());
|
||||
|
|
@ -391,7 +391,7 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
},
|
||||
|
||||
async joinByInvite(code) {
|
||||
if (!ownCrypto || !tribeCrypto) throw new Error("Event crypto unavailable");
|
||||
if (!ownCrypto || !cryptoModel) throw new Error("Event crypto unavailable");
|
||||
const ssbClient = await openSsb();
|
||||
const userId = await me();
|
||||
const messages = await readAll(ssbClient);
|
||||
|
|
@ -406,12 +406,12 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
if (!c || c.type !== 'event-invite') continue;
|
||||
if (invTomb.has(m.key)) continue;
|
||||
try {
|
||||
const hash = tribeCrypto.hashInviteCode(code, c.salt);
|
||||
const hash = cryptoModel.hashInviteCode(code, c.salt);
|
||||
if (hash === c.codeHash) { matched = c; break; }
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!matched) throw new Error("Invalid or expired invite code");
|
||||
const eventKey = tribeCrypto.decryptFromInvite(matched.ek, code, matched.salt);
|
||||
const eventKey = cryptoModel.decryptFromInvite(matched.ek, code, matched.salt);
|
||||
if (!eventKey) throw new Error("Could not decrypt invite");
|
||||
ownCrypto.addNewKey(matched.target, eventKey);
|
||||
await this.toggleAttendee(matched.target);
|
||||
|
|
@ -444,13 +444,13 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
ssbClient.publish({ type: 'eventAttend', target: rid, on, createdAt: new Date().toISOString() }, (err2, res2) => err2 ? reject(err2) : resolve(res2));
|
||||
});
|
||||
|
||||
if (isPrivate && !isLeaving && ownCrypto && tribeCrypto) {
|
||||
if (isPrivate && !isLeaving && ownCrypto && cryptoModel) {
|
||||
try {
|
||||
const key = lookupKey(rid);
|
||||
if (key) {
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys");
|
||||
const memberKeys = {};
|
||||
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(key, userId, ssbKeys); } catch (_) {}
|
||||
try { memberKeys[userId] = cryptoModel.boxKeyForMember(key, userId, ssbKeys); } catch (_) {}
|
||||
if (memberKeys[userId]) {
|
||||
await new Promise((resolve) => {
|
||||
ssbClient.publish({ type: "tribe-keys", tribeId: rid, generation: 1, memberKeys }, () => resolve());
|
||||
|
|
@ -498,6 +498,7 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
|
||||
return {
|
||||
id: contentTip,
|
||||
rootId: rid,
|
||||
title: c.title || '',
|
||||
description: c.description || '',
|
||||
date: c.date || '',
|
||||
|
|
@ -578,26 +579,26 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
|||
const wasPrivate = normalizePrivacy(c.isPublic) === 'private';
|
||||
const isPrivate = updated.isPublic === 'private';
|
||||
let newKey = null;
|
||||
if (isPrivate && ownCrypto && tribeCrypto) {
|
||||
if (isPrivate && ownCrypto && cryptoModel) {
|
||||
let key = lookupKey(rid);
|
||||
if (!key) {
|
||||
newKey = ownCrypto.generateTribeKey();
|
||||
newKey = ownCrypto.generateKey();
|
||||
ownCrypto.setKey(rid, newKey, 1);
|
||||
key = newKey;
|
||||
}
|
||||
updated = tribeCrypto.encryptContent(updated, [key], true);
|
||||
updated = cryptoModel.encryptContent(updated, [key], true);
|
||||
}
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(updated, (err2, res2) => err2 ? reject(err2) : resolve(res2));
|
||||
});
|
||||
|
||||
if (newKey && tribeCrypto) {
|
||||
if (newKey && cryptoModel) {
|
||||
try {
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys");
|
||||
const memberKeys = {};
|
||||
for (const m of (Array.isArray(c.attendees) ? c.attendees : [userId])) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {}
|
||||
try { memberKeys[m] = cryptoModel.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {}
|
||||
}
|
||||
if (Object.keys(memberKeys).length) {
|
||||
await new Promise((resolve) => {
|
||||
|
|
|
|||
|
|
@ -18,9 +18,12 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
const cleanText = (t) => (typeof t === "string" ? t.replace(/\r\n?/g, "\n").trim() : "");
|
||||
|
||||
const FEED_MEDIA_SRC = String.raw`!?\[[^\]\n]*\]\(\s*&[^)\s]+\.sha256\s*\)`;
|
||||
const isValidFeedText = (t) => {
|
||||
const s = cleanText(t).replace(/!?\[[^\]\n]*\]\(\s*&[^)\s]+\.sha256\s*\)/g, "").trim();
|
||||
return s.length >= FEED_TEXT_MIN && s.length <= FEED_TEXT_MAX;
|
||||
const all = cleanText(t);
|
||||
const s = all.replace(new RegExp(FEED_MEDIA_SRC, "g"), "").trim();
|
||||
if (s.length > FEED_TEXT_MAX) return false;
|
||||
return s.length >= FEED_TEXT_MIN || (!s && new RegExp(FEED_MEDIA_SRC).test(all));
|
||||
};
|
||||
|
||||
const getMsg = (ssbClient, id) =>
|
||||
|
|
@ -100,16 +103,18 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
if (typeof text !== "string") throw new Error("Invalid text");
|
||||
const cleaned = cleanText(text);
|
||||
const attached = typeof media === "string" ? media.trim() : "";
|
||||
const full = [cleaned, attached].filter(Boolean).join("\n");
|
||||
|
||||
if (!isValidFeedText(cleaned)) {
|
||||
if (cleaned.length < FEED_TEXT_MIN) throw new Error("Text too short");
|
||||
if (!full) throw new Error("Text required");
|
||||
if (!isValidFeedText(full)) {
|
||||
if (cleaned.length > FEED_TEXT_MAX) throw new Error("Text too long");
|
||||
throw new Error("Text required");
|
||||
throw new Error("Text too short");
|
||||
}
|
||||
|
||||
const content = {
|
||||
type: "feed",
|
||||
text: typeof media === "string" && media.trim() ? `${cleaned}\n${media.trim()}` : cleaned,
|
||||
text: full,
|
||||
author: userId,
|
||||
createdAt: new Date().toISOString(),
|
||||
tags: extractTags(cleaned),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const pull = require("../server/node_modules/pull-stream");
|
||||
const opinionRules = require("../backend/opinion_categories");
|
||||
const { getConfig } = require("../configs/config-manager.js");
|
||||
const categories = require("../backend/opinion_categories");
|
||||
const { readTyped, memoIndex } = require("./typed_log");
|
||||
|
|
@ -19,6 +20,7 @@ const fileExtra = (extra) => {
|
|||
const out = {};
|
||||
if (extra.mime) out.mime = String(extra.mime);
|
||||
if (extra.fileName) out.fileName = String(extra.fileName);
|
||||
if (typeof extra.license === "string" && extra.license) out.license = extra.license;
|
||||
if (typeof extra.torrentKey === "string" && extra.torrentKey.startsWith("%")) out.torrentKey = extra.torrentKey;
|
||||
if (typeof extra.torrentUrl === "string" && extra.torrentUrl.startsWith("&")) out.torrentUrl = extra.torrentUrl;
|
||||
if (extra.cipher && typeof extra.cipher === "object" && extra.cipher.key && extra.cipher.manifestBlobId) out.cipher = { v: Number(extra.cipher.v) || 1, key: String(extra.cipher.key), manifestBlobId: String(extra.cipher.manifestBlobId), chunkCount: Number(extra.cipher.chunkCount) || 0 };
|
||||
|
|
@ -36,7 +38,7 @@ const parseBlobId = (blobMarkdown) => {
|
|||
const voteSum = (opinions = {}) =>
|
||||
Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
||||
module.exports = ({ cooler, cryptoModel, tribesModel }) => {
|
||||
let ssb;
|
||||
|
||||
const openSsb = async () => {
|
||||
|
|
@ -44,9 +46,9 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
return ssb;
|
||||
};
|
||||
|
||||
const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null;
|
||||
const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
|
||||
const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
|
||||
const tribeHelpers = cryptoModel ? cryptoModel.createHelpers(tribesModel) : null;
|
||||
const encryptIfGroup = tribeHelpers ? tribeHelpers.encryptIfGroup : async (c) => c;
|
||||
const decryptIfGroup = tribeHelpers ? tribeHelpers.decryptIfGroup : async (c) => c;
|
||||
const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {};
|
||||
const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {};
|
||||
const unwrapForIndex = (msgs) => tribeHelpers ? tribeHelpers.unwrapMessagesForKind(msgs, 'file') : msgs;
|
||||
|
|
@ -141,6 +143,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
size: c.size || 0,
|
||||
mime: undec ? "" : (c.mime || ""),
|
||||
fileName: undec ? "" : (c.fileName || ""),
|
||||
license: undec ? "" : (c.license || ""),
|
||||
torrentKey: undec ? "" : (typeof c.torrentKey === "string" && c.torrentKey.startsWith("%") ? c.torrentKey : ""),
|
||||
torrentUrl: undec ? "" : (typeof c.torrentUrl === "string" && c.torrentUrl.startsWith("&") ? c.torrentUrl : ""),
|
||||
cipher: undec ? null : (c.cipher && typeof c.cipher === "object" && typeof c.cipher.key === "string" && typeof c.cipher.manifestBlobId === "string" ? { v: Number(c.cipher.v) || 1, key: c.cipher.key, manifestBlobId: c.cipher.manifestBlobId, chunkCount: Number(c.cipher.chunkCount) || 0 } : null),
|
||||
|
|
@ -199,7 +202,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
...fileExtra(extra)
|
||||
};
|
||||
|
||||
content = await encryptIfTribe(content);
|
||||
content = await encryptIfGroup(content);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
|
||||
|
|
@ -236,12 +239,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const oldMsg = await getMsg(ssbClient, tipId);
|
||||
|
||||
if (!oldMsg) throw new Error("File not found");
|
||||
const oldDec = await decryptIfTribe(oldMsg.content);
|
||||
const oldDec = await decryptIfGroup(oldMsg.content);
|
||||
if (!oldDec || oldDec.type !== "file") throw new Error("File not found");
|
||||
assertReadable(oldDec, "File");
|
||||
if (oldMsg.author !== userId) throw new Error("Not the author");
|
||||
const aggFile = await this.getFileById(id, userId);
|
||||
if (aggFile && Object.keys(aggFile.opinions || {}).some(k => (aggFile.opinions[k] || 0) > 0)) throw new Error("Cannot edit file after it has received opinions.");
|
||||
opinionRules.assertMediaKept(aggFile, !!blobMarkdown);
|
||||
|
||||
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldDec.tags);
|
||||
const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
|
||||
|
|
@ -255,7 +258,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
title: title !== undefined ? title || "" : oldDec.title || "",
|
||||
description: description !== undefined ? description || "" : oldDec.description || "",
|
||||
size: extra && Number(extra.size) > 0 ? Number(extra.size) : (oldDec.size || 0),
|
||||
...(extra ? fileExtra(extra) : fileExtra(oldDec)),
|
||||
...fileExtra(blobId ? { license: oldDec.license, ...(extra || {}) } : { ...oldDec, ...(extra || {}) }),
|
||||
opinions: oldDec.opinions || {},
|
||||
opinions_inhabitants: oldDec.opinions_inhabitants || [],
|
||||
author: oldDec.author || userId,
|
||||
|
|
@ -264,7 +267,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
updatedAt: now
|
||||
};
|
||||
|
||||
updated = await encryptIfTribe(updated);
|
||||
updated = await encryptIfGroup(updated);
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
|
||||
|
|
@ -281,7 +284,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const msg = await getMsg(ssbClient, tipId);
|
||||
|
||||
if (!msg) throw new Error("File not found");
|
||||
const dec = await decryptIfTribe(msg.content);
|
||||
const dec = await decryptIfGroup(msg.content);
|
||||
if (!dec || dec.type !== "file") throw new Error("File not found");
|
||||
if (msg.author !== userId) throw new Error("Not the author");
|
||||
|
||||
|
|
@ -360,8 +363,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const msg = await getMsg(ssbClient, tip);
|
||||
if (!msg) throw new Error("File not found");
|
||||
let c = msg.content;
|
||||
if (tribeCrypto && (c?.encryptedPayload || tribeCrypto.isTribeMsg(c)) && tribesModel) {
|
||||
const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
|
||||
if (cryptoModel && (c?.encryptedPayload || cryptoModel.isSealedMsg(c)) && tribesModel) {
|
||||
const dec = await cryptoModel.decryptFromGroup(c, tribesModel);
|
||||
c = dec && !dec._undecryptable ? { ...dec, _decrypted: true } : { ...c, _decrypted: false };
|
||||
}
|
||||
if (!c || c.type !== "file") throw new Error("File not found");
|
||||
|
|
|
|||
|
|
@ -8,19 +8,18 @@
|
|||
// Ver docs/FRONTERA_UPSTREAM.md.
|
||||
//
|
||||
// En el movil los workflows son los skins: cada uno enciende unos modulos y trae su
|
||||
// barra de abajo. Van de menos a mas: Activista (accion directa, lo minimo),
|
||||
// Acampada, y despues los del dev por numero de modulos.
|
||||
// barra de abajo. Van de menos a mas: Minimo, Acampada, Activista y despues los del
|
||||
// dev por numero de modulos.
|
||||
|
||||
const wf = require('./workflows_model');
|
||||
|
||||
const FORK_WORKFLOWS = [
|
||||
{
|
||||
// accion directa: convocar, coordinarse, llamar y avisar. Lo minimo para salir a
|
||||
// la calle; lo demas se enciende con otro skin
|
||||
key: 'activista',
|
||||
// lo minimo para salir a la calle: convocar, coordinarse, llamar y avisar
|
||||
// (sin mapas: backend.js los apaga a la fuerza en el movil)
|
||||
key: 'minimal',
|
||||
theme: 'Activista',
|
||||
homePage: 'activity',
|
||||
// (sin mapas: backend.js los apaga a la fuerza en el movil)
|
||||
modules: ['chats', 'phone', 'events', 'emergencies', 'invites', 'backup']
|
||||
},
|
||||
{
|
||||
|
|
@ -31,13 +30,27 @@ const FORK_WORKFLOWS = [
|
|||
homePage: 'activity',
|
||||
modules: ['chats', 'phone', 'rooms', 'pads', 'forum', 'images', 'videos', 'wiki', 'blogs', 'feed', 'invites', 'backup',
|
||||
'tribes', 'parliament', 'votes', 'events']
|
||||
},
|
||||
{
|
||||
// un colectivo que se organiza: lo de Acampada mas campañas, alertas, encuestas,
|
||||
// calendario, tareas, informes y documentos. Menos que el activists del dev (45)
|
||||
key: 'activista',
|
||||
theme: 'Activista',
|
||||
homePage: 'activity',
|
||||
modules: ['chats', 'phone', 'rooms', 'pads', 'forum', 'images', 'videos', 'wiki', 'blogs', 'feed', 'invites', 'backup',
|
||||
'tribes', 'parliament', 'votes', 'events', 'emergencies', 'campaigns', 'polls', 'calendars', 'tasks', 'reports',
|
||||
'docs', 'mailing']
|
||||
}
|
||||
];
|
||||
|
||||
// orden en el movil: estos tres primero y el resto por numero de modulos
|
||||
const LADDER = ['minimal', 'acampada', 'activista'];
|
||||
|
||||
// barra de abajo de cada skin: los cuatro atajos de lo que mas se usa en el (claves
|
||||
// de PINNABLE_MODULES en hive_views.js). Quien la cambia, la cambia para ese skin
|
||||
const BARS = {
|
||||
activista: ['activity', 'chats', 'events', 'phone'],
|
||||
minimal: ['activity', 'chats', 'events', 'phone'],
|
||||
activista: ['activity', 'tribes', 'chats', 'votes'],
|
||||
acampada: ['activity', 'chats', 'tribes', 'votes'],
|
||||
activists: ['activity', 'tribes', 'votes', 'forum'],
|
||||
social: ['activity', 'feed', 'images', 'chats'],
|
||||
|
|
@ -89,4 +102,4 @@ const saveBar = (cfg, pins) => {
|
|||
return cfg;
|
||||
};
|
||||
|
||||
module.exports = { FORK_WORKFLOWS, BARS, register, moduleCount, barFor, saveBar };
|
||||
module.exports = { FORK_WORKFLOWS, LADDER, BARS, register, moduleCount, barFor, saveBar };
|
||||
|
|
|
|||
|
|
@ -6,15 +6,15 @@ const { readTyped, requestScope, memoIndex } = require('./typed_log');
|
|||
const longText = require('../backend/long_text');
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
|
||||
module.exports = ({ cooler, cryptoModel, forumCrypto }) => {
|
||||
let ssb, userId;
|
||||
const ownCrypto = forumCrypto || tribeCrypto;
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null;
|
||||
const ownCrypto = forumCrypto || cryptoModel;
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (cryptoModel && cryptoModel.getKey(rid)) || null;
|
||||
|
||||
const decryptForumContent = (rawContent, rootId) => {
|
||||
if (!rawContent) return rawContent;
|
||||
if (!rawContent.encryptedPayload) return rawContent;
|
||||
if (!ownCrypto || !tribeCrypto) return { ...rawContent, _undecryptable: true };
|
||||
if (!ownCrypto || !cryptoModel) return { ...rawContent, _undecryptable: true };
|
||||
let keys = (rootId && ownCrypto.getKeys && ownCrypto.getKeys(rootId)) || [];
|
||||
if (!keys.length && typeof ownCrypto.getAllRootIds === 'function') {
|
||||
const seen = new Set();
|
||||
|
|
@ -24,7 +24,7 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
|
|||
}
|
||||
}
|
||||
if (!keys.length) return { ...rawContent, _undecryptable: true };
|
||||
const dec = tribeCrypto.decryptContent(rawContent, keys.map(k => [k]));
|
||||
const dec = cryptoModel.decryptContent(rawContent, keys.map(k => [k]));
|
||||
if (!dec || dec._undecryptable) return { ...rawContent, _undecryptable: true };
|
||||
return { ...dec, _decrypted: true };
|
||||
};
|
||||
|
|
@ -289,8 +289,8 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
|
|||
isPrivate: isPrivateFlag
|
||||
};
|
||||
let forumKey = null;
|
||||
if (isPrivateFlag && ownCrypto && tribeCrypto) forumKey = ownCrypto.generateTribeKey();
|
||||
const seal = (c) => forumKey ? tribeCrypto.encryptContent(c, [forumKey], true) : c;
|
||||
if (isPrivateFlag && ownCrypto && cryptoModel) forumKey = ownCrypto.generateKey();
|
||||
const seal = (c) => forumKey ? cryptoModel.encryptContent(c, [forumKey], true) : c;
|
||||
const publish = (c) => new Promise((resolve, reject) =>
|
||||
ssbClient.publish(seal(c), (err, res) => err ? reject(err) : resolve(res))
|
||||
);
|
||||
|
|
@ -300,7 +300,7 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
|
|||
ownCrypto.setKey(result.key, forumKey, 1);
|
||||
try {
|
||||
const ssbKeys = require('../server/node_modules/ssb-keys');
|
||||
const memberKeys = { [userId]: tribeCrypto.boxKeyForMember(forumKey, userId, ssbKeys) };
|
||||
const memberKeys = { [userId]: cryptoModel.boxKeyForMember(forumKey, userId, ssbKeys) };
|
||||
await new Promise((resolve) => {
|
||||
ssbClient.publish({ type: 'tribe-keys', tribeId: result.key, generation: 1, memberKeys }, () => resolve());
|
||||
});
|
||||
|
|
@ -328,11 +328,11 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
|
|||
};
|
||||
if (parentId) content.branch = parentId;
|
||||
let key = null;
|
||||
if (isPrivate && ownCrypto && tribeCrypto) {
|
||||
if (isPrivate && ownCrypto && cryptoModel) {
|
||||
key = lookupKey(forumId);
|
||||
if (!key) throw new Error('Missing forum key — cannot reply to encrypted forum');
|
||||
}
|
||||
const seal = (c) => key ? tribeCrypto.encryptContent(c, [key], true) : c;
|
||||
const seal = (c) => key ? cryptoModel.encryptContent(c, [key], true) : c;
|
||||
const publish = (c) => new Promise((resolve, reject) =>
|
||||
ssbClient.publish(seal(c), (err, res) => err ? reject(err) : resolve(res))
|
||||
);
|
||||
|
|
@ -341,7 +341,7 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
|
|||
},
|
||||
|
||||
generateInvite: async (forumId) => {
|
||||
if (!ownCrypto || !tribeCrypto) throw new Error('Forum crypto unavailable');
|
||||
if (!ownCrypto || !cryptoModel) throw new Error('Forum crypto unavailable');
|
||||
const ssbClient = await openSsb();
|
||||
const rawRoot = await new Promise((res, rej) => ssbClient.get(forumId, (e, m) => e ? rej(e) : res(m)));
|
||||
if (!rawRoot || !rawRoot.content) throw new Error('Forum not found');
|
||||
|
|
@ -353,10 +353,10 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
|
|||
const key = lookupKey(forumId);
|
||||
if (!key) throw new Error('Missing forum key');
|
||||
const code = crypto.randomBytes(16).toString('hex');
|
||||
const inviteSalt = tribeCrypto.generateInviteSalt();
|
||||
const ek = tribeCrypto.encryptForInvite(key, code, inviteSalt);
|
||||
const inviteSalt = cryptoModel.generateInviteSalt();
|
||||
const ek = cryptoModel.encryptForInvite(key, code, inviteSalt);
|
||||
await new Promise((resolve, reject) => {
|
||||
ssbClient.publish({ type: 'forum-invite', target: forumId, ek, salt: inviteSalt, codeHash: tribeCrypto.hashInviteCode(code, inviteSalt) }, (err) => err ? reject(err) : resolve());
|
||||
ssbClient.publish({ type: 'forum-invite', target: forumId, ek, salt: inviteSalt, codeHash: cryptoModel.hashInviteCode(code, inviteSalt) }, (err) => err ? reject(err) : resolve());
|
||||
});
|
||||
return { code, forumId };
|
||||
},
|
||||
|
|
@ -367,7 +367,7 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
|
|||
},
|
||||
|
||||
generateOpenInvite: async (forumId) => {
|
||||
if (!ownCrypto || !tribeCrypto) throw new Error('Forum crypto unavailable');
|
||||
if (!ownCrypto || !cryptoModel) throw new Error('Forum crypto unavailable');
|
||||
const ssbClient = await openSsb();
|
||||
const rawRoot = await new Promise((res, rej) => ssbClient.get(forumId, (e, m) => e ? rej(e) : res(m)));
|
||||
if (!rawRoot || !rawRoot.content) throw new Error('Forum not found');
|
||||
|
|
@ -380,10 +380,10 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
|
|||
const key = lookupKey(forumId);
|
||||
if (!key) throw new Error('Missing forum key');
|
||||
const code = crypto.randomBytes(16).toString('hex');
|
||||
const inviteSalt = tribeCrypto.generateInviteSalt();
|
||||
const ek = tribeCrypto.encryptForInvite(key, code, inviteSalt);
|
||||
const inviteSalt = cryptoModel.generateInviteSalt();
|
||||
const ek = cryptoModel.encryptForInvite(key, code, inviteSalt);
|
||||
const invitePub = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish({ type: 'forum-invite', target: forumId, ek, salt: inviteSalt, codeHash: tribeCrypto.hashInviteCode(code, inviteSalt), multi: 1 }, (err, r) => err ? reject(err) : resolve(r));
|
||||
ssbClient.publish({ type: 'forum-invite', target: forumId, ek, salt: inviteSalt, codeHash: cryptoModel.hashInviteCode(code, inviteSalt), multi: 1 }, (err, r) => err ? reject(err) : resolve(r));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
ssbClient.publish({ type: 'forum-open-invite', v: 1, target: forumId, code, inviteKey: invitePub.key, by: userId, createdAt: new Date().toISOString() }, (err) => err ? reject(err) : resolve());
|
||||
|
|
@ -406,7 +406,7 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
|
|||
},
|
||||
|
||||
joinByInvite: async (code) => {
|
||||
if (!ownCrypto || !tribeCrypto) throw new Error('Forum crypto unavailable');
|
||||
if (!ownCrypto || !cryptoModel) throw new Error('Forum crypto unavailable');
|
||||
const ssbClient = await openSsb();
|
||||
const messages = await readForumLog();
|
||||
const authorOf = authorsByKey(messages);
|
||||
|
|
@ -418,12 +418,12 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
|
|||
if (invTomb.has(m.key)) continue;
|
||||
if (!authorOf.get(c.target) || authorOf.get(c.target) !== m.value.author) continue;
|
||||
try {
|
||||
const hash = tribeCrypto.hashInviteCode(code, c.salt);
|
||||
const hash = cryptoModel.hashInviteCode(code, c.salt);
|
||||
if (hash === c.codeHash) { matched = c; break; }
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!matched) throw new Error('Invalid or expired invite code');
|
||||
const forumKey = tribeCrypto.decryptFromInvite(matched.ek, code, matched.salt);
|
||||
const forumKey = cryptoModel.decryptFromInvite(matched.ek, code, matched.salt);
|
||||
if (!forumKey) throw new Error('Could not decrypt invite');
|
||||
ownCrypto.addNewKey(matched.target, forumKey);
|
||||
return { ok: true, forumId: matched.target };
|
||||
|
|
@ -521,7 +521,7 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
|
|||
};
|
||||
},
|
||||
|
||||
getMessagesByForumId: async forumId => {
|
||||
getMessagesByForumId: async (forumId, ssbReplies = []) => {
|
||||
const ssbClient = await openSsb();
|
||||
const msgs = await readForumLog();
|
||||
const deleted = deletedOf(msgs);
|
||||
|
|
@ -537,8 +537,11 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
|
|||
if (c.root !== forumId) return null;
|
||||
return { c, m };
|
||||
};
|
||||
const replies = msgs
|
||||
.map(decodeReply)
|
||||
const parentOf = (branch) => [].concat(branch || []).filter(b => typeof b === 'string' && b !== forumId).pop() || null;
|
||||
const fromPosts = (Array.isArray(ssbReplies) ? ssbReplies : [])
|
||||
.filter(m => m && m.value && m.value.content && m.value.content.type === 'post' && m.value.content.root === forumId)
|
||||
.map(m => ({ c: { ...m.value.content, branch: parentOf(m.value.content.branch) }, m }));
|
||||
const replies = [...msgs.map(decodeReply), ...fromPosts]
|
||||
.filter(r => r && !deleted.has(r.m.key))
|
||||
.map(({ c, m }) => ({
|
||||
key: m.key,
|
||||
|
|
|
|||
|
|
@ -68,47 +68,47 @@ const matchSearch = (item, q) => {
|
|||
return hay.includes(qq)
|
||||
}
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto }) => {
|
||||
module.exports = ({ cooler, cryptoModel }) => {
|
||||
let ssb
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
|
||||
|
||||
const isEncrypted = (c) => !!(c && c.encryptedPayload)
|
||||
|
||||
const keysForRoot = (rootId) => {
|
||||
if (!tribeCrypto || !rootId) return []
|
||||
const ks = (tribeCrypto.getKeys && tribeCrypto.getKeys(rootId)) || []
|
||||
if (!cryptoModel || !rootId) return []
|
||||
const ks = (cryptoModel.getKeys && cryptoModel.getKeys(rootId)) || []
|
||||
if (ks.length) return ks
|
||||
const k = tribeCrypto.getKey ? tribeCrypto.getKey(rootId) : null
|
||||
const k = cryptoModel.getKey ? cryptoModel.getKey(rootId) : null
|
||||
return k ? [k] : []
|
||||
}
|
||||
|
||||
const decryptHousing = (c, rootId) => {
|
||||
if (!isEncrypted(c)) return c
|
||||
if (!tribeCrypto) return { ...c, _undecryptable: true }
|
||||
if (!cryptoModel) return { ...c, _undecryptable: true }
|
||||
const keys = keysForRoot(rootId)
|
||||
if (!keys.length) return { ...c, _undecryptable: true }
|
||||
return tribeCrypto.decryptContent(c, keys.map(k => [k]))
|
||||
return cryptoModel.decryptContent(c, keys.map(k => [k]))
|
||||
}
|
||||
|
||||
const publishHousing = (ssbClient, content, rootId) => new Promise((resolve, reject) => {
|
||||
const hidden = String(content.visibility || "PUBLIC").toUpperCase() === "HIDDEN"
|
||||
if (!hidden || !tribeCrypto) {
|
||||
if (!hidden || !cryptoModel) {
|
||||
ssbClient.publish(content, (err, msg) => {
|
||||
if (err) return reject(err)
|
||||
if (msg && msg.key && tribeCrypto && !rootId) {
|
||||
try { tribeCrypto.setKey(msg.key, tribeCrypto.generateTribeKey(), 1) } catch (_) {}
|
||||
if (msg && msg.key && cryptoModel && !rootId) {
|
||||
try { cryptoModel.setKey(msg.key, cryptoModel.generateKey(), 1) } catch (_) {}
|
||||
}
|
||||
resolve(msg)
|
||||
})
|
||||
return
|
||||
}
|
||||
let key = rootId ? (keysForRoot(rootId)[0] || null) : null
|
||||
if (!key) key = tribeCrypto.generateTribeKey()
|
||||
if (!key) key = cryptoModel.generateKey()
|
||||
let envelope
|
||||
try { envelope = tribeCrypto.encryptContent(content, [key], true) } catch (err) { return reject(err) }
|
||||
try { envelope = cryptoModel.encryptContent(content, [key], true) } catch (err) { return reject(err) }
|
||||
ssbClient.publish(envelope, (err, msg) => {
|
||||
if (err) return reject(err)
|
||||
try { tribeCrypto.setKey(rootId || (msg && msg.key), key, 1) } catch (_) {}
|
||||
try { cryptoModel.setKey(rootId || (msg && msg.key), key, 1) } catch (_) {}
|
||||
resolve(msg)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const pull = require("../server/node_modules/pull-stream");
|
||||
const opinionRules = require("../backend/opinion_categories");
|
||||
const { getConfig } = require("../configs/config-manager.js");
|
||||
const categories = require("../backend/opinion_categories");
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
|
|
@ -170,7 +171,7 @@ module.exports = ({ cooler }) => {
|
|||
if (!oldMsg || oldMsg.content?.type !== "image") throw new Error("Image not found");
|
||||
if (oldMsg.content.author !== userId) throw new Error("Not the author");
|
||||
const aggImg = await this.getImageById(id, userId);
|
||||
if (aggImg && Object.keys(aggImg.opinions || {}).some(k => (aggImg.opinions[k] || 0) > 0)) throw new Error("Cannot edit image after it has received opinions.");
|
||||
opinionRules.assertMediaKept(aggImg, !!blobMarkdown);
|
||||
|
||||
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
|
||||
const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
|
||||
|
|
|
|||
|
|
@ -724,6 +724,7 @@ module.exports = ({ cooler }) => {
|
|||
builds.sort((x, y) => new Date(y.startDate || 0) - new Date(x.startDate || 0))
|
||||
return {
|
||||
...b.blueprint,
|
||||
rootId: b.root,
|
||||
facilityId: g.root,
|
||||
facilityName: g.facility.name,
|
||||
members: g.facility.members,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const matchSearch = (job, q) => {
|
|||
return hay.includes(qq)
|
||||
}
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto }) => {
|
||||
module.exports = ({ cooler, cryptoModel }) => {
|
||||
let ssb
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
}
|
||||
|
||||
const buildJobObject = (node, rootId, subscribers) => {
|
||||
const visibleSubs = (tribeCrypto && tribeCrypto.getKey(rootId)) || ssb?.id === (node.author || node.c?.author) ? subscribers : [];
|
||||
const visibleSubs = (cryptoModel && cryptoModel.getKey(rootId)) || ssb?.id === (node.author || node.c?.author) ? subscribers : [];
|
||||
const c = node.c || {}
|
||||
let blobId = c.image || null
|
||||
if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
|
||||
|
|
@ -207,9 +207,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
|
||||
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => {
|
||||
if (e) return rej(e)
|
||||
if (m && m.key && tribeCrypto) {
|
||||
const key = tribeCrypto.generateTribeKey()
|
||||
tribeCrypto.setKey(m.key, key, 1)
|
||||
if (m && m.key && cryptoModel) {
|
||||
const key = cryptoModel.generateKey()
|
||||
cryptoModel.setKey(m.key, key, 1)
|
||||
}
|
||||
res(m)
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const pull = require("../server/node_modules/pull-stream");
|
||||
const { readTyped, CONTENT_TYPES } = require("./typed_log");
|
||||
const { isComment } = require("./comments_model");
|
||||
const { getConfig } = require("../configs/config-manager.js");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ function idsEqual(a, b) {
|
|||
}
|
||||
|
||||
function normalizeType(a) {
|
||||
if (isComment(a.content)) return "comment";
|
||||
const t = a.type || a.content?.type || inferType(a.content) || "";
|
||||
return String(t).toLowerCase();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ function getGoverningPeriodId(now = new Date()) {
|
|||
return `${getGoverningHouseKey(now)}:${now.getFullYear()}-${month}`;
|
||||
}
|
||||
|
||||
module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
|
||||
module.exports = ({ cooler, tribesModel, cryptoModel }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||
const startedAt = Date.now();
|
||||
|
|
@ -204,6 +204,11 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
|
|||
return candidates[0];
|
||||
}
|
||||
|
||||
async function houseTribeReady(houseKey) {
|
||||
if (await findMyHouseTribe(houseKey).catch(() => null)) return true;
|
||||
return houseKey === 'academia' && !!(await findAnyHouseTribe(houseKey).catch(() => null));
|
||||
}
|
||||
|
||||
async function findAnyHouseTribe(houseKey) {
|
||||
if (!tribesModel || !VALID_KEY(houseKey)) return null;
|
||||
let list = [];
|
||||
|
|
@ -307,8 +312,8 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
|
|||
if (houseKey !== 'academia') {
|
||||
await publishHouseAnchorTombstone(houseKey, rootId).catch(() => {});
|
||||
}
|
||||
if (tribeCrypto && typeof tribeCrypto.dropKey === 'function') {
|
||||
try { tribeCrypto.dropKey(rootId); } catch (_) {}
|
||||
if (cryptoModel && typeof cryptoModel.dropKey === 'function') {
|
||||
try { cryptoModel.dropKey(rootId); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -387,7 +392,10 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
|
|||
return myCanonical.tribe;
|
||||
}
|
||||
|
||||
if (anchor) return null;
|
||||
if (anchor) {
|
||||
await redeemPendingAutoInvites().catch(() => {});
|
||||
return await findMyHouseTribe(houseKey);
|
||||
}
|
||||
|
||||
const existingAny = await findAnyHouseTribe(houseKey).catch(() => null);
|
||||
if (existingAny) {
|
||||
|
|
@ -437,8 +445,8 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
|
|||
if (houseKey !== 'academia') {
|
||||
await publishHouseAnchorTombstone(houseKey, t.rootId).catch(() => {});
|
||||
}
|
||||
if (tribeCrypto && typeof tribeCrypto.dropKey === 'function') {
|
||||
try { tribeCrypto.dropKey(t.rootId); } catch (_) {}
|
||||
if (cryptoModel && typeof cryptoModel.dropKey === 'function') {
|
||||
try { cryptoModel.dropKey(t.rootId); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -954,6 +962,7 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
|
|||
createHouseInvite,
|
||||
redeemHouseInvite,
|
||||
findMyHouseTribe,
|
||||
houseTribeReady,
|
||||
ensureHouseTribe,
|
||||
leaveMyHouseTribe,
|
||||
issueAutoInvitesForMyHouse,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
const { isComment } = require('./comments_model');
|
||||
const pull = require('../server/node_modules/pull-stream');
|
||||
const { requestScope } = require('./typed_log');
|
||||
const util = require('util');
|
||||
|
|
@ -60,6 +61,7 @@ const ACTION_TYPES = new Set([
|
|||
|
||||
const ACTION_PHRASES = {
|
||||
post: 'published a post',
|
||||
comment: 'commented on some content',
|
||||
about: 'updated profile information',
|
||||
contact: 'followed or unfollowed someone',
|
||||
feed: 'shared content in the feed',
|
||||
|
|
@ -243,7 +245,7 @@ module.exports = ({ cooler }) => {
|
|||
if (!ACTION_TYPES.has(c.type)) continue;
|
||||
const ts = v.timestamp || 0;
|
||||
const summary = c.title || c.text || c.question || c.subject || c.name || c.concept || c.description || '';
|
||||
out.push({ key: m.key, ts, type: c.type, summary: compact(summary) });
|
||||
out.push({ key: m.key, ts, type: isComment(c) ? 'comment' : c.type, summary: compact(summary) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -384,7 +384,7 @@ models.about = {
|
|||
);
|
||||
});
|
||||
const now = Date.now();
|
||||
for (const [id, p] of out) if (p.name) nameCache.set(id, p.name, now);
|
||||
for (const [id, p] of out) { if (p.name) nameCache.set(id, p.name, now); if (p.image) nameCache.setImage(id, p.image); }
|
||||
return out;
|
||||
})();
|
||||
if (store) store.aboutProfiles = pending;
|
||||
|
|
@ -433,6 +433,7 @@ models.about = {
|
|||
return nullImage;
|
||||
}
|
||||
if (typeof raw.link === "string") {
|
||||
nameCache.setImage(feedId, raw.link);
|
||||
return raw.link;
|
||||
}
|
||||
return raw;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const normalizeTags = (raw) => {
|
|||
|
||||
const ALLOWED_MAP_TYPES = new Set(["OPEN", "CLOSED", "SINGLE"]);
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
||||
module.exports = ({ cooler, cryptoModel, mapCrypto, tribesModel }) => {
|
||||
let ssb;
|
||||
|
||||
const openSsb = async () => {
|
||||
|
|
@ -27,16 +27,16 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
return ssb;
|
||||
};
|
||||
|
||||
const ownCrypto = mapCrypto || tribeCrypto;
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null;
|
||||
const ownCrypto = mapCrypto || cryptoModel;
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (cryptoModel && cryptoModel.getKey(rid)) || null;
|
||||
const lookupKeys = (rid) => {
|
||||
const a = (ownCrypto && ownCrypto.getKeys(rid)) || [];
|
||||
if (a.length) return a;
|
||||
return (tribeCrypto && tribeCrypto.getKeys(rid)) || [];
|
||||
return (cryptoModel && cryptoModel.getKeys(rid)) || [];
|
||||
};
|
||||
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0);
|
||||
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (cryptoModel && cryptoModel.getGen(rid)) || 0);
|
||||
const keyProofFor = (keyHex, id) => { try { return crypto.createHmac("sha256", Buffer.from(keyHex, "hex")).update(String(id), "utf8").digest("hex"); } catch (_) { return null; } };
|
||||
const hashCode = (code, salt) => (tribeCrypto ? tribeCrypto.hashInviteCode(code, salt) : null);
|
||||
const hashCode = (code, salt) => (cryptoModel ? cryptoModel.hashInviteCode(code, salt) : null);
|
||||
const inviteMatches = (inv, code) => {
|
||||
if (!code) return false;
|
||||
if (typeof inv === "string") return inv === code;
|
||||
|
|
@ -47,16 +47,16 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
const inviteToken = (inv) => (inv && typeof inv === "object" && typeof inv.ch === "string") ? inv.ch : (typeof inv === "string" ? inv : (inv && inv.code) || "");
|
||||
|
||||
const rotateMapKey = async (rootId, remainingMembers) => {
|
||||
if (!ownCrypto || !tribeCrypto || !rootId) return;
|
||||
if (!ownCrypto || !cryptoModel || !rootId) return;
|
||||
const existing = lookupKey(rootId);
|
||||
if (!existing) return;
|
||||
const newKey = ownCrypto.generateTribeKey();
|
||||
const newKey = ownCrypto.generateKey();
|
||||
const newGen = ownCrypto.addNewKey(rootId, newKey);
|
||||
const ssbClient = await openSsb();
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys");
|
||||
const memberKeys = {};
|
||||
for (const m of new Set([ssbClient.id, ...(Array.isArray(remainingMembers) ? remainingMembers : [])])) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {}
|
||||
try { memberKeys[m] = cryptoModel.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {}
|
||||
}
|
||||
if (Object.keys(memberKeys).length) {
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -91,34 +91,34 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
} catch (_) {}
|
||||
};
|
||||
|
||||
const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null;
|
||||
const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
|
||||
const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
|
||||
const tribeHelpers = cryptoModel ? cryptoModel.createHelpers(tribesModel) : null;
|
||||
const encryptIfGroup = tribeHelpers ? tribeHelpers.encryptIfGroup : async (c) => c;
|
||||
const decryptIfGroup = tribeHelpers ? tribeHelpers.decryptIfGroup : async (c) => c;
|
||||
const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {};
|
||||
const unwrapForIndex = (msgs) => tribeHelpers ? tribeHelpers.unwrapMessagesForKind(msgs, ['map', 'mapMarker']) : msgs;
|
||||
const tombFor = async (target, tribeId, author) => tribeHelpers ? tribeHelpers.encryptTombstone(target, tribeId, author) : { type: 'tombstone', target, deletedAt: new Date().toISOString(), author };
|
||||
|
||||
const encryptStandalone = (content, rootId) => {
|
||||
if (!tribeCrypto || !rootId) return content;
|
||||
if (!cryptoModel || !rootId) return content;
|
||||
const key = lookupKey(rootId);
|
||||
if (!key) return content;
|
||||
return tribeCrypto.encryptContent(content, [key], true);
|
||||
return cryptoModel.encryptContent(content, [key], true);
|
||||
};
|
||||
|
||||
const tryDecryptPublicInviteKey = (invites) => {
|
||||
if (!tribeCrypto || !Array.isArray(invites)) return null;
|
||||
if (!cryptoModel || !Array.isArray(invites)) return null;
|
||||
for (const inv of invites) {
|
||||
if (!inv || typeof inv !== "object" || inv.public !== true) continue;
|
||||
if (typeof inv.code !== "string") continue;
|
||||
if (typeof inv.ek === "string") {
|
||||
try {
|
||||
const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt);
|
||||
const k = cryptoModel.decryptFromInvite(inv.ek, inv.code, inv.salt);
|
||||
if (k) return k;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (typeof inv.ekChain === "string") {
|
||||
try {
|
||||
const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt);
|
||||
const chain = cryptoModel.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt);
|
||||
if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -128,24 +128,24 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
|
||||
const decryptMapRoot = (content, rootId) => {
|
||||
if (!content || !content.encryptedPayload) return content;
|
||||
if (!tribeCrypto) return content;
|
||||
if (!cryptoModel) return content;
|
||||
const keys = lookupKeys(rootId);
|
||||
let candidateChains = (keys || []).map(k => [k]);
|
||||
const pubKey = tryDecryptPublicInviteKey(content.invites);
|
||||
if (pubKey) candidateChains.push([pubKey]);
|
||||
if (!candidateChains.length) return { ...content, _undecryptable: true };
|
||||
return tribeCrypto.decryptContent(content, candidateChains);
|
||||
return cryptoModel.decryptContent(content, candidateChains);
|
||||
};
|
||||
|
||||
const decryptIndexNodes = async (idx) => {
|
||||
if (!tribeCrypto) return;
|
||||
if (!cryptoModel) return;
|
||||
for (const [k, n] of (idx.nodes ? idx.nodes.entries() : [])) {
|
||||
if (!n.c || !n.c.encryptedPayload) continue;
|
||||
const root = idx.rootOf(k);
|
||||
let dec = null;
|
||||
if (n.c.tribeId && tribesModel) {
|
||||
try {
|
||||
const r = await tribeCrypto.decryptFromTribe(n.c, tribesModel);
|
||||
const r = await cryptoModel.decryptFromGroup(n.c, tribesModel);
|
||||
if (r && !r._undecryptable) dec = r;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -342,7 +342,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
};
|
||||
|
||||
const ensureMemberKeys = async (ssbClient, messages, items) => {
|
||||
if (!tribeCrypto || !ownCrypto) return;
|
||||
if (!cryptoModel || !ownCrypto) return;
|
||||
const leavesByRoot = new Map();
|
||||
for (const m of messages) {
|
||||
const c = m.value && m.value.content;
|
||||
|
|
@ -363,7 +363,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
const key = lookupKey(rootId);
|
||||
const memberKeys = {};
|
||||
for (const m of plan.missing) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(key, m, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[m] = cryptoModel.boxKeyForMember(key, m, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (!Object.keys(memberKeys).length) continue;
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -378,8 +378,8 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
for (const r of raws) {
|
||||
if (idx.tomb.has(r.key)) continue;
|
||||
let c = r.c;
|
||||
if (c.encryptedPayload && tribeCrypto && tribesModel) {
|
||||
const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
|
||||
if (c.encryptedPayload && cryptoModel && tribesModel) {
|
||||
const dec = await cryptoModel.decryptFromGroup(c, tribesModel);
|
||||
if (dec && !dec._undecryptable) c = dec;
|
||||
}
|
||||
list.push({
|
||||
|
|
@ -530,14 +530,14 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
};
|
||||
|
||||
const isPublicOpen = mType === "OPEN" && !tribeId;
|
||||
const shouldEncryptStandalone = !tribeId && !isPublicOpen && tribeCrypto;
|
||||
const shouldEncryptStandalone = !tribeId && !isPublicOpen && cryptoModel;
|
||||
let mapKey = null;
|
||||
let content = plainContent;
|
||||
if (tribeId) {
|
||||
content = await encryptIfTribe(plainContent);
|
||||
content = await encryptIfGroup(plainContent);
|
||||
} else if (shouldEncryptStandalone) {
|
||||
mapKey = ownCrypto.generateTribeKey();
|
||||
content = tribeCrypto.encryptContent(plainContent, [mapKey], true);
|
||||
mapKey = ownCrypto.generateKey();
|
||||
content = cryptoModel.encryptContent(plainContent, [mapKey], true);
|
||||
}
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
|
|
@ -548,7 +548,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
ownCrypto.setKey(result.key, mapKey, 1);
|
||||
try {
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys");
|
||||
const boxedKey = tribeCrypto.boxKeyForMember(mapKey, userId, ssbKeys);
|
||||
const boxedKey = cryptoModel.boxKeyForMember(mapKey, userId, ssbKeys);
|
||||
await new Promise((resolve) => {
|
||||
ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve());
|
||||
});
|
||||
|
|
@ -556,8 +556,8 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
if (mType === "OPEN") {
|
||||
try {
|
||||
const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex");
|
||||
const inviteSalt = tribeCrypto.generateInviteSalt();
|
||||
const ek = tribeCrypto.encryptForInvite(mapKey, pubCode, inviteSalt);
|
||||
const inviteSalt = cryptoModel.generateInviteSalt();
|
||||
const ek = cryptoModel.encryptForInvite(mapKey, pubCode, inviteSalt);
|
||||
let updated = {
|
||||
type: "map",
|
||||
replaces: result.key,
|
||||
|
|
@ -593,10 +593,10 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
const oldMsg = await getMsg(ssbClient, tipId);
|
||||
|
||||
if (!oldMsg) throw new Error("Map not found");
|
||||
const isWrapped = tribeCrypto && tribeCrypto.isTribeMsg(oldMsg.content);
|
||||
const isWrapped = cryptoModel && cryptoModel.isSealedMsg(oldMsg.content);
|
||||
const tribeIdHint = isWrapped ? null : (oldMsg.content && oldMsg.content.tribeId);
|
||||
const oldDecrypted = (isWrapped || tribeIdHint)
|
||||
? await decryptIfTribe(oldMsg.content)
|
||||
? await decryptIfGroup(oldMsg.content)
|
||||
: decryptMapRoot(oldMsg.content, rootId);
|
||||
const effectiveTribeId = oldDecrypted && oldDecrypted.tribeId;
|
||||
if (!oldDecrypted || (oldDecrypted.type && oldDecrypted.type !== "map" && !isWrapped && oldMsg.content?.type !== "map")) throw new Error("Map not found");
|
||||
|
|
@ -627,7 +627,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
};
|
||||
|
||||
if (effectiveTribeId) {
|
||||
updated = await encryptIfTribe(updated);
|
||||
updated = await encryptIfGroup(updated);
|
||||
} else if (mType !== "OPEN") {
|
||||
updated = encryptStandalone(updated, rootId);
|
||||
}
|
||||
|
|
@ -647,7 +647,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
const msg = await getMsg(ssbClient, tipId);
|
||||
|
||||
if (!msg) throw new Error("Map not found");
|
||||
const decrypted = await decryptIfTribe(msg.content);
|
||||
const decrypted = await decryptIfGroup(msg.content);
|
||||
if (!decrypted) throw new Error("Map not found");
|
||||
if ((decrypted.author || msg.content.author) !== userId) throw new Error("Not the author");
|
||||
|
||||
|
|
@ -673,7 +673,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
const node = idx.nodes.get(tipId);
|
||||
if (!node) throw new Error("Map not found");
|
||||
|
||||
const mapDecrypted = await decryptIfTribe(node.c);
|
||||
const mapDecrypted = await decryptIfGroup(node.c);
|
||||
assertReadable(mapDecrypted, "Map");
|
||||
const mapType = mapDecrypted.mapType || node.c.mapType || "SINGLE";
|
||||
const mapAuthor = node.author;
|
||||
|
|
@ -694,10 +694,10 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
if (image) content.image = image;
|
||||
|
||||
if (node.c.tribeId) {
|
||||
content = await encryptIfTribe(content);
|
||||
} else if (tribeCrypto) {
|
||||
content = await encryptIfGroup(content);
|
||||
} else if (cryptoModel) {
|
||||
const mapKey = lookupKey(rootId);
|
||||
if (mapKey) content = tribeCrypto.encryptContent(content, [mapKey], true);
|
||||
if (mapKey) content = cryptoModel.encryptContent(content, [mapKey], true);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -711,8 +711,8 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
const msg = await getMsg(ssbClient, markerId);
|
||||
if (!msg || !msg.content) throw new Error("Marker not found");
|
||||
let c = msg.content;
|
||||
if (c.encryptedPayload && tribeCrypto && tribesModel) {
|
||||
const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
|
||||
if (c.encryptedPayload && cryptoModel && tribesModel) {
|
||||
const dec = await cryptoModel.decryptFromGroup(c, tribesModel);
|
||||
if (dec && !dec._undecryptable) c = dec;
|
||||
}
|
||||
if (c.type !== "mapMarker" && !c.encryptedPayload) throw new Error("Marker not found");
|
||||
|
|
@ -774,8 +774,8 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
const msg = await getMsg(ssbClient, tip);
|
||||
if (!msg || msg.content?.type !== "map") throw new Error("Map not found");
|
||||
let c = msg.content;
|
||||
if (c.encryptedPayload && tribeCrypto && tribesModel) {
|
||||
const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
|
||||
if (c.encryptedPayload && cryptoModel && tribesModel) {
|
||||
const dec = await cryptoModel.decryptFromGroup(c, tribesModel);
|
||||
c = dec && !dec._undecryptable ? { ...dec, _decrypted: true } : { ...c, _decrypted: false };
|
||||
}
|
||||
const markerList = markersFor(idx, tip, root);
|
||||
|
|
@ -795,21 +795,21 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
if (map.author !== userId) throw new Error("Only the author can generate invites");
|
||||
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex");
|
||||
let invite = code;
|
||||
if (tribeCrypto && !map.tribeId) {
|
||||
const inviteSalt = tribeCrypto.generateInviteSalt();
|
||||
if (cryptoModel && !map.tribeId) {
|
||||
const inviteSalt = cryptoModel.generateInviteSalt();
|
||||
const ident = opts.public ? { code, public: true } : { ch: hashCode(code, inviteSalt) };
|
||||
const ekChain = tribeCrypto.encryptChainForInvite([map.rootId || map.key], code, inviteSalt);
|
||||
const ekChain = cryptoModel.encryptChainForInvite([map.rootId || map.key], code, inviteSalt);
|
||||
const mapKey = ekChain ? null : lookupKey(map.rootId || map.key);
|
||||
if (ekChain) invite = { ...ident, ekChain, salt: inviteSalt, gen: lookupGen(map.rootId || map.key) || 1 };
|
||||
else if (mapKey) invite = { ...ident, ek: tribeCrypto.encryptForInvite(mapKey, code, inviteSalt), salt: inviteSalt, gen: lookupGen(map.rootId || map.key) || 1 };
|
||||
else if (mapKey) invite = { ...ident, ek: cryptoModel.encryptForInvite(mapKey, code, inviteSalt), salt: inviteSalt, gen: lookupGen(map.rootId || map.key) || 1 };
|
||||
}
|
||||
if (opts.public && typeof invite !== "object") invite = { code, public: true };
|
||||
const tipId = await this.resolveCurrentId(mapId);
|
||||
const rootId = await this.resolveRootId(mapId);
|
||||
const oldMsg = await getMsg(ssbClient, tipId);
|
||||
const isWrapped = tribeCrypto && tribeCrypto.isTribeMsg(oldMsg.content);
|
||||
const isWrapped = cryptoModel && cryptoModel.isSealedMsg(oldMsg.content);
|
||||
const oldDecrypted = (isWrapped || (oldMsg.content && oldMsg.content.tribeId))
|
||||
? await decryptIfTribe(oldMsg.content)
|
||||
? await decryptIfGroup(oldMsg.content)
|
||||
: decryptMapRoot(oldMsg.content, rootId);
|
||||
const effectiveTribeId = oldDecrypted && oldDecrypted.tribeId;
|
||||
const invites = [...(Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : []), invite];
|
||||
|
|
@ -831,7 +831,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
createdAt: oldDecrypted.createdAt,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
if (effectiveTribeId) updated = await encryptIfTribe(updated);
|
||||
if (effectiveTribeId) updated = await encryptIfGroup(updated);
|
||||
else updated = encryptStandalone(updated, rootId);
|
||||
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
|
||||
const tomb1 = await tombFor(tipId, effectiveTribeId, userId);
|
||||
|
|
@ -865,9 +865,9 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
const tipId = await this.resolveCurrentId(mapId);
|
||||
const rootId = await this.resolveRootId(mapId);
|
||||
const oldMsg = await getMsg(ssbClient, tipId);
|
||||
const isWrapped = tribeCrypto && tribeCrypto.isTribeMsg(oldMsg.content);
|
||||
const isWrapped = cryptoModel && cryptoModel.isSealedMsg(oldMsg.content);
|
||||
const oldDecrypted = (isWrapped || (oldMsg.content && oldMsg.content.tribeId))
|
||||
? await decryptIfTribe(oldMsg.content)
|
||||
? await decryptIfGroup(oldMsg.content)
|
||||
: decryptMapRoot(oldMsg.content, rootId);
|
||||
const effectiveTribeId = oldDecrypted && oldDecrypted.tribeId;
|
||||
const invites = (Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : []).filter(inv => !(typeof inv === "object" && inv.public === true));
|
||||
|
|
@ -889,7 +889,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
createdAt: oldDecrypted.createdAt,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
if (effectiveTribeId) updated = await encryptIfTribe(updated);
|
||||
if (effectiveTribeId) updated = await encryptIfGroup(updated);
|
||||
else updated = encryptStandalone(updated, rootId);
|
||||
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
|
||||
const tomb1 = await tombFor(tipId, effectiveTribeId, userId);
|
||||
|
|
@ -920,13 +920,13 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
const token = inviteToken(matchedInvite) || code;
|
||||
let mapKey = null;
|
||||
let chain = null;
|
||||
if (tribeCrypto && typeof matchedInvite === "object") {
|
||||
if (cryptoModel && typeof matchedInvite === "object") {
|
||||
if (matchedInvite.ekChain) {
|
||||
chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3);
|
||||
chain = cryptoModel.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3);
|
||||
if (Array.isArray(chain) && chain.length) mapKey = chain[0].key || (Array.isArray(chain[0].keys) ? chain[0].keys[0] : null);
|
||||
else chain = null;
|
||||
} else if (matchedInvite.ek) {
|
||||
mapKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt);
|
||||
mapKey = cryptoModel.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt);
|
||||
}
|
||||
}
|
||||
if (!isPublic) {
|
||||
|
|
@ -939,9 +939,9 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
if (chain) {
|
||||
for (const entry of chain) {
|
||||
if (Array.isArray(entry.keys) && entry.keys.length) {
|
||||
tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length);
|
||||
cryptoModel.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length);
|
||||
} else if (entry.key) {
|
||||
tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1);
|
||||
cryptoModel.setKey(entry.rootId, entry.key, entry.gen || 1);
|
||||
}
|
||||
}
|
||||
} else if (mapKey) {
|
||||
|
|
@ -949,13 +949,13 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
}
|
||||
if (!mapKey && matched.contentEncrypted && !matched.tribeId) throw new Error("Invalid or expired invite code");
|
||||
await new Promise((resolve, reject) => ssbClient.publish({ type: "mapMember", target: rootId, member: userId, on: true, createdAt: new Date().toISOString(), ...(isPublic ? {} : { code: token }), ...(mapKey ? { keyProof: keyProofFor(mapKey, userId) } : {}) }, (e, r) => e ? reject(e) : resolve(r)));
|
||||
if (tribeCrypto && mapKey) {
|
||||
if (cryptoModel && mapKey) {
|
||||
try {
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys");
|
||||
const memberKeys = {};
|
||||
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(mapKey, userId, ssbKeys); } catch (_) {}
|
||||
try { memberKeys[userId] = cryptoModel.boxKeyForMember(mapKey, userId, ssbKeys); } catch (_) {}
|
||||
if (matched.author && matched.author !== userId) {
|
||||
try { memberKeys[matched.author] = tribeCrypto.boxKeyForMember(mapKey, matched.author, ssbKeys); } catch (_) {}
|
||||
try { memberKeys[matched.author] = cryptoModel.boxKeyForMember(mapKey, matched.author, ssbKeys); } catch (_) {}
|
||||
}
|
||||
if (Object.keys(memberKeys).length) {
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -972,7 +972,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
|||
const map = await this.getMapById(mapId, userId);
|
||||
if (!map) throw new Error("Map not found");
|
||||
if (Array.isArray(map.members) && map.members.includes(userId)) return map.rootId || map.key;
|
||||
if (tribeCrypto && Array.isArray(map.invites)) {
|
||||
if (cryptoModel && Array.isArray(map.invites)) {
|
||||
const pub = map.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain));
|
||||
if (pub) return await this.joinByInvite(pub.code);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ const hasBidder = (poll, userId) => {
|
|||
return false
|
||||
}
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto }) => {
|
||||
module.exports = ({ cooler, cryptoModel }) => {
|
||||
let ssb
|
||||
const openSsb = async () => {
|
||||
if (!ssb) ssb = await cooler.open()
|
||||
|
|
@ -196,9 +196,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(itemContent, (err, res) => {
|
||||
if (err) return reject(err)
|
||||
if (res && res.key && tribeCrypto) {
|
||||
const key = tribeCrypto.generateTribeKey()
|
||||
tribeCrypto.setKey(res.key, key, 1)
|
||||
if (res && res.key && cryptoModel) {
|
||||
const key = cryptoModel.generateKey()
|
||||
cryptoModel.setKey(res.key, key, 1)
|
||||
}
|
||||
resolve(res)
|
||||
})
|
||||
|
|
@ -353,7 +353,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
includesShipping: !!c.includesShipping,
|
||||
stock,
|
||||
deadline: c.deadline || null,
|
||||
auctions_poll: (tribeCrypto && tribeCrypto.getKey(rootId)) ? poll : [],
|
||||
auctions_poll: (cryptoModel && cryptoModel.getKey(rootId)) ? poll : [],
|
||||
mapUrl: c.mapUrl || "",
|
||||
shopProductId: c.shopProductId || "",
|
||||
shopId: c.shopId || "",
|
||||
|
|
@ -458,7 +458,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
includesShipping: !!c.includesShipping,
|
||||
stock,
|
||||
deadline: c.deadline,
|
||||
auctions_poll: (tribeCrypto && tribeCrypto.getKey(rootId)) ? poll : [],
|
||||
auctions_poll: (cryptoModel && cryptoModel.getKey(rootId)) ? poll : [],
|
||||
mapUrl: c.mapUrl || "",
|
||||
shopProductId: c.shopProductId || "",
|
||||
shopId: c.shopId || "",
|
||||
|
|
@ -505,6 +505,42 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
return changed
|
||||
},
|
||||
|
||||
async createOrder(itemId, details = {}) {
|
||||
const ssbClient = await openSsb()
|
||||
const me = ssbClient.id
|
||||
const item = await this.getItemById(itemId)
|
||||
if (!item) throw new Error("Item not found")
|
||||
if (item.seller === me) throw new Error("You cannot buy your own item")
|
||||
const content = {
|
||||
type: "marketOrder",
|
||||
itemId: item.rootId || item.id,
|
||||
seller: item.seller,
|
||||
title: String(item.title || ""),
|
||||
price: String(item.price || ""),
|
||||
deliveryAddress: String(details.deliveryAddress || ""),
|
||||
contact: String(details.contact || ""),
|
||||
notes: String(details.notes || ""),
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
return new Promise((res, rej) => ssbClient.private.publish(content, [me, item.seller], (e, m) => e ? rej(e) : res(m)))
|
||||
},
|
||||
|
||||
async listOrders(itemId) {
|
||||
const ssbClient = await openSsb()
|
||||
const me = ssbClient.id
|
||||
const item = await this.getItemById(itemId)
|
||||
if (!item) return []
|
||||
const rootId = item.rootId || item.id
|
||||
const msgs = await readTyped(ssbClient, ["marketOrder"], { limit: logLimit, withPrivate: true })
|
||||
return msgs
|
||||
.filter(m => {
|
||||
const c = m && m.value && m.value.content
|
||||
return m.value.private === true && c && c.type === "marketOrder" && c.itemId === rootId && c.seller === item.seller && (item.seller === me || m.value.author === me)
|
||||
})
|
||||
.map(m => ({ id: m.key, buyer: m.value.author, ts: m.value.timestamp || 0, deliveryAddress: m.value.content.deliveryAddress || "", contact: m.value.content.contact || "", notes: m.value.content.notes || "" }))
|
||||
.sort((a, b) => b.ts - a.ts)
|
||||
},
|
||||
|
||||
async getItemByShopProductId(shopProductId) {
|
||||
if (!shopProductId) return null
|
||||
const items = await this.listAllItems("all")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const fs = require('fs');
|
||||
const pull = require('../server/node_modules/pull-stream');
|
||||
const { readTyped, CONTENT_TYPES } = require('./typed_log');
|
||||
const { isComment } = require('./comments_model');
|
||||
|
||||
const SEEN_FILE = 'oasis-mentions-seen';
|
||||
|
||||
|
|
@ -133,7 +134,7 @@ module.exports = ({ cooler }) => {
|
|||
id: m.key,
|
||||
key: m.key,
|
||||
author: v.author,
|
||||
type: c.type,
|
||||
type: isComment(c) ? 'comment' : c.type,
|
||||
title: titleOf(c),
|
||||
text: snippet(c),
|
||||
content: c,
|
||||
|
|
|
|||
|
|
@ -20,20 +20,30 @@ module.exports = ({ cooler }) => {
|
|||
return ssb;
|
||||
};
|
||||
|
||||
const hasBlob = async (ssbClient, url) => {
|
||||
return new Promise(resolve => {
|
||||
ssbClient.blobs.has(url, (err, has) => {
|
||||
resolve(!err && has);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const validTypes = [
|
||||
'bookmark', 'votes', 'transfer',
|
||||
'feed', 'post', 'image', 'audio', 'video', 'document', 'torrent', 'file',
|
||||
'industry', 'project', 'report', 'task', 'event', 'shopProduct', 'housing', 'market', 'schoolCourse',
|
||||
'podcast', 'podcastEpisode', 'campaign', 'logisticsRoute'
|
||||
'podcast', 'podcastEpisode', 'campaign', 'logisticsRoute', 'wikiPage', 'map', 'industryBlueprint', 'job'
|
||||
];
|
||||
const OPINION_TYPES = [...new Set(Object.values(categories.messageTypes))];
|
||||
|
||||
const tallyOf = async (versionIds) => {
|
||||
const want = new Set((versionIds || []).filter(Boolean));
|
||||
const agg = { opinions: {}, opinions_inhabitants: [] };
|
||||
if (!want.size) return agg;
|
||||
const ssbClient = await openSsb();
|
||||
const msgs = await readTyped(ssbClient, [...OPINION_TYPES, 'tombstone'], { limit: logLimit });
|
||||
const gone = buildValidatedTombstoneSet(msgs);
|
||||
for (const m of msgs) {
|
||||
const c = m.value?.content;
|
||||
if (!c || !OPINION_TYPES.includes(c.type) || !want.has(c.target) || gone.has(m.key)) continue;
|
||||
if (agg.opinions_inhabitants.includes(m.value.author)) continue;
|
||||
agg.opinions_inhabitants.push(m.value.author);
|
||||
if (categories.includes(c.category)) agg.opinions[c.category] = (agg.opinions[c.category] || 0) + 1;
|
||||
}
|
||||
return agg;
|
||||
};
|
||||
|
||||
const getPreview = c => {
|
||||
if (c.type === 'bookmark' && c.bookmark) return `🔖 ${c.bookmark}`;
|
||||
|
|
@ -55,6 +65,7 @@ module.exports = ({ cooler }) => {
|
|||
const otype = OPINION_MSG_TYPE[type];
|
||||
if (!otype) throw new Error("Voting not allowed on this content type.");
|
||||
if (msg.content.opinions_inhabitants?.includes(userId)) throw new Error("Already voted.");
|
||||
if ((await tallyOf([contentId])).opinions_inhabitants.includes(userId)) throw new Error("Already voted.");
|
||||
const content = { type: otype, target: contentId, category, createdAt: new Date().toISOString() };
|
||||
return new Promise((resolve, reject) =>
|
||||
ssbClient.publish(content, (err, result) => err ? reject(err) : resolve(result))
|
||||
|
|
@ -77,7 +88,7 @@ module.exports = ({ cooler }) => {
|
|||
opinionMsgs.push({ target: c.target, author: msg.value.author, category: c.category });
|
||||
continue;
|
||||
}
|
||||
if (c.opinions && !['task', 'event', 'report'].includes(c.type)) {
|
||||
if ((c.opinions || categories.opinable(c)) && !['task', 'event', 'report'].includes(c.type)) {
|
||||
byId.set(key, {
|
||||
key,
|
||||
value: {
|
||||
|
|
@ -136,23 +147,6 @@ module.exports = ({ cooler }) => {
|
|||
if (c.encryptedPayload) return false;
|
||||
return String(c.visibility || 'PUBLIC').toUpperCase() !== 'INVITE';
|
||||
});
|
||||
const blobTypes = ['document', 'image', 'audio', 'video'];
|
||||
const blobCheckCache = new Map();
|
||||
|
||||
filtered = await Promise.all(
|
||||
filtered.map(async m => {
|
||||
const c = m.value.content;
|
||||
if (blobTypes.includes(c.type) && c.url) {
|
||||
if (!blobCheckCache.has(c.url)) {
|
||||
const valid = await hasBlob(ssbClient, c.url);
|
||||
blobCheckCache.set(c.url, valid);
|
||||
}
|
||||
if (!blobCheckCache.get(c.url)) return null;
|
||||
}
|
||||
return m;
|
||||
})
|
||||
);
|
||||
filtered = filtered.filter(Boolean);
|
||||
|
||||
const signatureOf = (m) => {
|
||||
const c = m.value?.content || {};
|
||||
|
|
@ -267,6 +261,7 @@ module.exports = ({ cooler }) => {
|
|||
};
|
||||
|
||||
return {
|
||||
tallyOf,
|
||||
createVote,
|
||||
listOpinions,
|
||||
getMessageById,
|
||||
|
|
|
|||
|
|
@ -19,18 +19,18 @@ const INVITE_SALT = "SolarNET.HuB-pads"
|
|||
const INVITE_BYTES = 16
|
||||
const MEMBER_COLORS = ["#e74c3c","#3498db","#2ecc71","#f39c12","#9b59b6","#1abc9c","#e67e22","#e91e63","#00bcd4","#8bc34a"]
|
||||
|
||||
module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel }) => {
|
||||
module.exports = ({ cooler, cipherModel, cryptoModel, padCrypto, tribesModel }) => {
|
||||
let ssb
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
|
||||
|
||||
const ownCrypto = padCrypto || tribeCrypto
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
|
||||
const ownCrypto = padCrypto || cryptoModel
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (cryptoModel && cryptoModel.getKey(rid)) || null
|
||||
const lookupKeys = (rid) => {
|
||||
const a = (ownCrypto && ownCrypto.getKeys(rid)) || []
|
||||
if (a.length) return a
|
||||
return (tribeCrypto && tribeCrypto.getKeys(rid)) || []
|
||||
return (cryptoModel && cryptoModel.getKeys(rid)) || []
|
||||
}
|
||||
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0)
|
||||
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (cryptoModel && cryptoModel.getGen(rid)) || 0)
|
||||
|
||||
let keyringPath = null
|
||||
let migratedToTribeCrypto = false
|
||||
|
|
@ -60,7 +60,7 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
|
|||
if (ownCrypto) return lookupKey(rootId)
|
||||
try { return JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8"))[rootId] || null } catch (_) { return null }
|
||||
}
|
||||
const hashCode = (code, salt) => (ownCrypto && typeof ownCrypto.hashInviteCode === "function") ? ownCrypto.hashInviteCode(code, salt) : (tribeCrypto ? tribeCrypto.hashInviteCode(code, salt) : null)
|
||||
const hashCode = (code, salt) => (ownCrypto && typeof ownCrypto.hashInviteCode === "function") ? ownCrypto.hashInviteCode(code, salt) : (cryptoModel ? cryptoModel.hashInviteCode(code, salt) : null)
|
||||
const inviteMatches = (inv, token) => {
|
||||
if (!token) return false
|
||||
if (typeof inv === "string") return inv === token
|
||||
|
|
@ -71,7 +71,7 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
|
|||
}
|
||||
const inviteToken = (inv) => (inv && typeof inv === "object" && typeof inv.ch === "string") ? inv.ch : (typeof inv === "string" ? inv : (inv && inv.code) || "")
|
||||
const keyProofFor = (keyHex, id) => { try { return crypto.createHmac("sha256", Buffer.from(keyHex, "hex")).update(String(id), "utf8").digest("hex") } catch (_) { return null } }
|
||||
const padKeys = (rootId) => (ownCrypto && typeof ownCrypto.getKeys === "function" ? ownCrypto.getKeys(rootId) : []).concat(tribeCrypto && typeof tribeCrypto.getKeys === "function" ? tribeCrypto.getKeys(rootId) : [])
|
||||
const padKeys = (rootId) => (ownCrypto && typeof ownCrypto.getKeys === "function" ? ownCrypto.getKeys(rootId) : []).concat(cryptoModel && typeof cryptoModel.getKeys === "function" ? cryptoModel.getKeys(rootId) : [])
|
||||
const setPadKey = (rootId, keyHex) => {
|
||||
migrateLegacyKeyring()
|
||||
if (ownCrypto) { ownCrypto.setKey(rootId, keyHex, 1); return }
|
||||
|
|
@ -113,10 +113,10 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
|
|||
}
|
||||
|
||||
const getTribeKeysFor = async (tribeId) => {
|
||||
if (!tribeCrypto || !tribesModel || !tribeId) return []
|
||||
if (!cryptoModel || !tribesModel || !tribeId) return []
|
||||
try {
|
||||
const rootId = await tribesModel.getRootId(tribeId)
|
||||
const keys = tribeCrypto.getKeys(rootId) || []
|
||||
const keys = cryptoModel.getKeys(rootId) || []
|
||||
return keys
|
||||
} catch (_) { return [] }
|
||||
}
|
||||
|
|
@ -137,7 +137,7 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
|
|||
}
|
||||
|
||||
const ensureMemberKeys = async (ssbClient, messages, items) => {
|
||||
if (!tribeCrypto || !ownCrypto) return
|
||||
if (!cryptoModel || !ownCrypto) return
|
||||
const leavesByRoot = new Map()
|
||||
for (const m of messages) {
|
||||
const c = m.value && m.value.content
|
||||
|
|
@ -161,7 +161,7 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
|
|||
const key = lookupKey(rootId)
|
||||
const memberKeys = {}
|
||||
for (const m of plan.missing) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(key, m, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[m] = cryptoModel.boxKeyForMember(key, m, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (!Object.keys(memberKeys).length) continue
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -199,7 +199,7 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
|
|||
const generateInviteSalt = () => crypto.randomBytes(16).toString("hex")
|
||||
|
||||
const rotatePadKey = async (rootId, remainingMembers) => {
|
||||
if (!ownCrypto || !tribeCrypto || !rootId) return
|
||||
if (!ownCrypto || !cryptoModel || !rootId) return
|
||||
const existing = getPadKey(rootId)
|
||||
if (!existing) return
|
||||
const newKey = crypto.randomBytes(32).toString("hex")
|
||||
|
|
@ -208,7 +208,7 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
|
|||
const ssbKeys = require("../server/node_modules/ssb-keys")
|
||||
const memberKeys = {}
|
||||
for (const m of new Set([ssbClient.id, ...(Array.isArray(remainingMembers) ? remainingMembers : [])])) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[m] = cryptoModel.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (Object.keys(memberKeys).length) {
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -537,10 +537,10 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
|
|||
if (err) return reject(err)
|
||||
if (!usesTribeKey) {
|
||||
setPadKey(msg.key, keyHex)
|
||||
if (tribeCrypto) {
|
||||
if (cryptoModel) {
|
||||
try {
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys")
|
||||
const boxedKey = tribeCrypto.boxKeyForMember(keyHex, userId, ssbKeys)
|
||||
const boxedKey = cryptoModel.boxKeyForMember(keyHex, userId, ssbKeys)
|
||||
ssbClient.publish({ type: "tribe-keys", tribeId: msg.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve(msg))
|
||||
return
|
||||
} catch (_) {}
|
||||
|
|
@ -808,13 +808,13 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
|
|||
}
|
||||
if (padKey) setPadKey(resolvedRootId, padKey)
|
||||
await this.addMemberToPad(matchedPad.rootId, userId, isPublic ? "" : token)
|
||||
if (tribeCrypto && padKey && resolvedRootId) {
|
||||
if (cryptoModel && padKey && resolvedRootId) {
|
||||
try {
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys")
|
||||
const memberKeys = {}
|
||||
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(padKey, userId, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[userId] = cryptoModel.boxKeyForMember(padKey, userId, ssbKeys) } catch (_) {}
|
||||
if (matchedPad.author && matchedPad.author !== userId) {
|
||||
try { memberKeys[matchedPad.author] = tribeCrypto.boxKeyForMember(padKey, matchedPad.author, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[matchedPad.author] = cryptoModel.boxKeyForMember(padKey, matchedPad.author, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (Object.keys(memberKeys).length) {
|
||||
await new Promise((resolve) => {
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ module.exports = ({ cooler, services = {} }) => {
|
|||
if ((n.c.replaces && nodes.has(n.c.replaces)) || !settle(n.key)) continue;
|
||||
const tip = tipOf(n.key);
|
||||
if (tomb.has(tip)) continue;
|
||||
items.push({ ...nodes.get(tip).c, id: tip });
|
||||
items.push({ ...nodes.get(tip).c, id: tip, rootId: n.key });
|
||||
}
|
||||
const resolve = (k) => (nodes.has(k) && settle(k) ? tipOf(rootOf(k)) : null);
|
||||
const authorOf = (k) => (nodes.has(k) ? nodes.get(k).author : null);
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ module.exports = ({ cooler }) => {
|
|||
channels.set(root, {
|
||||
id: root, rootId: root, tipId: tip.key,
|
||||
title: safeText(c.title), description: safeText(c.description), category: normalizeCategory(c.category),
|
||||
cover: parseMedia(c.cover), tags: normalizeTags(c.tags),
|
||||
cover: parseMedia(c.cover), tags: normalizeTags(c.tags), license: typeof c.license === 'string' ? c.license : '',
|
||||
author: first.author, createdAt: first.c.createdAt || new Date(first.ts).toISOString(),
|
||||
updatedAt: c.updatedAt || new Date(tip.ts).toISOString(), ts: tip.ts,
|
||||
episodes: eps, episodeCount: eps.length, playCount, opinionCount,
|
||||
|
|
@ -163,10 +163,7 @@ module.exports = ({ cooler }) => {
|
|||
};
|
||||
|
||||
return {
|
||||
CATEGORIES,
|
||||
parseMedia,
|
||||
|
||||
async createChannel({ title, description, category, cover, tags }) {
|
||||
async createChannel({ title, description, category, cover, tags, license }) {
|
||||
const ssbClient = await openSsb();
|
||||
const t = safeText(title);
|
||||
if (!t) throw new Error('Title is required');
|
||||
|
|
@ -174,7 +171,7 @@ module.exports = ({ cooler }) => {
|
|||
const media = parseMedia(cover);
|
||||
return publish(ssbClient, {
|
||||
type: CHANNEL_TYPE, title: t, description: safeText(description), category: normalizeCategory(category),
|
||||
...(media ? { cover: media } : {}), tags: normalizeTags(tags), author: ssbClient.id, createdAt: now, updatedAt: now
|
||||
...(media ? { cover: media } : {}), tags: normalizeTags(tags), ...(license ? { license } : {}), author: ssbClient.id, createdAt: now, updatedAt: now
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -183,6 +180,7 @@ module.exports = ({ cooler }) => {
|
|||
const ch = findChannel(idx, id);
|
||||
if (!ch) throw new Error('Podcast not found');
|
||||
if (ch.author !== ssbClient.id) throw new Error('Only the author can update this podcast');
|
||||
categories.assertMediaKept(ch, data.cover !== undefined && !!data.cover);
|
||||
const media = data.cover !== undefined && data.cover ? parseMedia(data.cover) : ch.cover;
|
||||
const res = await publish(ssbClient, {
|
||||
type: CHANNEL_TYPE,
|
||||
|
|
@ -191,6 +189,7 @@ module.exports = ({ cooler }) => {
|
|||
category: data.category !== undefined ? normalizeCategory(data.category) : ch.category,
|
||||
...(media ? { cover: media } : {}),
|
||||
tags: data.tags !== undefined ? normalizeTags(data.tags) : ch.tags,
|
||||
...((data.license !== undefined ? data.license : ch.license) ? { license: data.license !== undefined ? data.license : ch.license } : {}),
|
||||
author: ssbClient.id, createdAt: ch.createdAt, updatedAt: new Date().toISOString(), replaces: ch.tipId
|
||||
});
|
||||
return { key: res.key, rootId: ch.id };
|
||||
|
|
@ -228,6 +227,7 @@ module.exports = ({ cooler }) => {
|
|||
const ep = findEpisode(idx, id);
|
||||
if (!ep) throw new Error('Episode not found');
|
||||
if (ep.author !== ssbClient.id) throw new Error('Only the author can update this episode');
|
||||
categories.assertMediaKept(ep, !!data.media);
|
||||
const m = data.media ? parseMedia(data.media) : ep.media;
|
||||
const res = await publish(ssbClient, {
|
||||
type: EPISODE_TYPE, channel: ep.channelId, number: data.number !== undefined && num(data.number) ? num(data.number) : ep.number,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const { getConfig } = require('../configs/config-manager.js');
|
|||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { readTyped, memoIndex } = require('./typed_log');
|
||||
const { versionGroups } = require('../backend/dedupe');
|
||||
const opinionRules = require('../backend/opinion_categories');
|
||||
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
|
||||
|
|
@ -35,7 +36,7 @@ const normalizeTags = (raw) => {
|
|||
return list.map(t => clean(t)).filter(Boolean).slice(0, 20);
|
||||
};
|
||||
|
||||
module.exports = ({ cooler, isPublic = false, tribeCrypto = null, chatsModel = null }) => {
|
||||
module.exports = ({ cooler, isPublic = false, cryptoModel = null, chatsModel = null }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||
|
||||
|
|
@ -44,10 +45,10 @@ module.exports = ({ cooler, isPublic = false, tribeCrypto = null, chatsModel = n
|
|||
const getAllMessages = async (ssbClient) => readTyped(ssbClient, POLLS_TYPES, { limit: logLimit });
|
||||
|
||||
const decryptField = (value, keys) => {
|
||||
if (!tribeCrypto || typeof value !== 'string' || !value) return null;
|
||||
if (!cryptoModel || typeof value !== 'string' || !value) return null;
|
||||
for (const k of keys) {
|
||||
try {
|
||||
const out = tribeCrypto.decryptWithKey(value, k);
|
||||
const out = cryptoModel.decryptWithKey(value, k);
|
||||
if (typeof out === 'string' && out) return out;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -312,9 +313,9 @@ module.exports = ({ cooler, isPublic = false, tribeCrypto = null, chatsModel = n
|
|||
...(data.houseKey ? { houseKey: String(data.houseKey) } : {})
|
||||
};
|
||||
const key = await scopeKey({ chatId: data.chatId, tribeId: data.tribeId });
|
||||
if (key && tribeCrypto) {
|
||||
content.encryptedQuestion = tribeCrypto.encryptWithKey(question, key);
|
||||
content.encryptedOptions = tribeCrypto.encryptWithKey(JSON.stringify(options), key);
|
||||
if (key && cryptoModel) {
|
||||
content.encryptedQuestion = cryptoModel.encryptWithKey(question, key);
|
||||
content.encryptedOptions = cryptoModel.encryptWithKey(JSON.stringify(options), key);
|
||||
} else {
|
||||
content.question = question;
|
||||
content.options = options;
|
||||
|
|
@ -338,6 +339,7 @@ module.exports = ({ cooler, isPublic = false, tribeCrypto = null, chatsModel = n
|
|||
if (!Number.isFinite(t)) throw new Error('Invalid deadline');
|
||||
}
|
||||
const question = data.question !== undefined ? clean(data.question) : poll.question;
|
||||
opinionRules.assertMediaKept(poll, question !== poll.question || JSON.stringify(options) !== JSON.stringify(poll.options));
|
||||
const content = {
|
||||
type: POLL_TYPE,
|
||||
replaces: poll.tipId,
|
||||
|
|
@ -356,9 +358,9 @@ module.exports = ({ cooler, isPublic = false, tribeCrypto = null, chatsModel = n
|
|||
...(poll.houseKey ? { houseKey: poll.houseKey } : {})
|
||||
};
|
||||
const key = await scopeKey({ chatId: poll.chatId, tribeId: poll.tribeId });
|
||||
if (key && tribeCrypto) {
|
||||
content.encryptedQuestion = tribeCrypto.encryptWithKey(question, key);
|
||||
content.encryptedOptions = tribeCrypto.encryptWithKey(JSON.stringify(options), key);
|
||||
if (key && cryptoModel) {
|
||||
content.encryptedQuestion = cryptoModel.encryptWithKey(question, key);
|
||||
content.encryptedOptions = cryptoModel.encryptWithKey(JSON.stringify(options), key);
|
||||
} else {
|
||||
content.question = question;
|
||||
content.options = options;
|
||||
|
|
@ -386,7 +388,7 @@ module.exports = ({ cooler, isPublic = false, tribeCrypto = null, chatsModel = n
|
|||
...(poll.chatId ? { chatId: poll.chatId } : {})
|
||||
};
|
||||
const key = await scopeKey({ chatId: poll.chatId, tribeId: poll.tribeId });
|
||||
if (key && tribeCrypto) content.encryptedChoices = tribeCrypto.encryptWithKey(JSON.stringify(unique), key);
|
||||
if (key && cryptoModel) content.encryptedChoices = cryptoModel.encryptWithKey(JSON.stringify(unique), key);
|
||||
else content.choices = unique;
|
||||
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ module.exports = ({ cooler }) => {
|
|||
const groupKeys = []
|
||||
{ let x = root, g = 0; groupKeys.push(x); while (naiveNext.has(x) && g++ < 100000) { x = naiveNext.get(x); groupKeys.push(x) } }
|
||||
|
||||
const project = { ...best.c, id: tip }
|
||||
const project = { ...best.c, id: tip, rootId: root }
|
||||
|
||||
const followers = new Set()
|
||||
const opinions = {}
|
||||
|
|
|
|||
|
|
@ -13,38 +13,11 @@ const ensureArray = (v) => Array.isArray(v) ? v.filter(Boolean) : [];
|
|||
|
||||
const trimStr = (v) => String(v || '').trim();
|
||||
|
||||
const normalizeTemplate = (category, tpl) => {
|
||||
const cat = normU(category);
|
||||
const LEGACY_TEMPLATE_KEYS = ['problemStatement', 'userStory', 'acceptanceCriteria', 'stepsToReproduce', 'expectedBehavior', 'actualBehavior', 'environment', 'whatHappened', 'reportedUser', 'contentLocation', 'whyInappropriate', 'evidenceLinks'];
|
||||
|
||||
const withLegacyText = (description, tpl) => {
|
||||
const t = tpl && typeof tpl === 'object' ? tpl : {};
|
||||
|
||||
const pick = (keys) => {
|
||||
const out = {};
|
||||
for (const k of keys) {
|
||||
const val = trimStr(t[k]);
|
||||
if (val) out[k] = val;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
if (cat === 'BUGS') {
|
||||
const out = pick(['stepsToReproduce', 'expectedBehavior', 'actualBehavior', 'environment', 'reproduceRate']);
|
||||
if (out.reproduceRate) out.reproduceRate = normU(out.reproduceRate);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (cat === 'FEATURES') {
|
||||
return pick(['problemStatement', 'userStory', 'acceptanceCriteria']);
|
||||
}
|
||||
|
||||
if (cat === 'ABUSE') {
|
||||
return pick(['whatHappened', 'reportedUser', 'evidenceLinks']);
|
||||
}
|
||||
|
||||
if (cat === 'CONTENT') {
|
||||
return pick(['contentLocation', 'whyInappropriate', 'evidenceLinks']);
|
||||
}
|
||||
|
||||
return {};
|
||||
return [trimStr(description), ...LEGACY_TEMPLATE_KEYS.map(k => trimStr(t[k]))].filter(Boolean).join('\n\n');
|
||||
};
|
||||
|
||||
module.exports = ({ cooler }) => {
|
||||
|
|
@ -113,6 +86,7 @@ module.exports = ({ cooler }) => {
|
|||
for (const cf of (confirmsByRoot.get(root) || [])) confirmSet.add(cf.author);
|
||||
|
||||
return {
|
||||
root,
|
||||
contentTip,
|
||||
contentNode,
|
||||
opinions,
|
||||
|
|
@ -126,7 +100,7 @@ module.exports = ({ cooler }) => {
|
|||
};
|
||||
|
||||
const buildReport = (node, agg) => {
|
||||
const c = node.c || {};
|
||||
const { template, ...c } = node.c || {};
|
||||
const cat = normU(c.category);
|
||||
const opinions = agg ? agg.opinions : (c.opinions || {});
|
||||
const voters = agg ? agg.opinions_inhabitants : ensureArray(c.opinions_inhabitants);
|
||||
|
|
@ -134,6 +108,8 @@ module.exports = ({ cooler }) => {
|
|||
return {
|
||||
id: node.key,
|
||||
...c,
|
||||
description: withLegacyText(c.description, template),
|
||||
rootId: agg && agg.root ? agg.root : node.key,
|
||||
category: cat,
|
||||
status: normalizeStatus(c.status || 'OPEN'),
|
||||
images: normalizeImages(c.images && c.images.length ? c.images : c.image),
|
||||
|
|
@ -141,14 +117,13 @@ module.exports = ({ cooler }) => {
|
|||
severity: normalizeSeverity(c.severity) || 'low',
|
||||
confirmations,
|
||||
tags: ensureArray(c.tags),
|
||||
template: normalizeTemplate(cat, c.template || {}),
|
||||
opinions,
|
||||
opinions_inhabitants: voters
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
async createReport(title, description, category, image, tagsRaw = [], severity = 'low', template = {}, media = {}) {
|
||||
async createReport(title, description, category, image, tagsRaw = [], severity = 'low', media = {}) {
|
||||
const ssb = await openSsb();
|
||||
const userId = ssb.id;
|
||||
|
||||
|
|
@ -175,7 +150,6 @@ module.exports = ({ cooler }) => {
|
|||
confirmations: [],
|
||||
severity: normalizeSeverity(severity) || 'low',
|
||||
status: 'OPEN',
|
||||
template: normalizeTemplate(cat, template),
|
||||
opinions: {},
|
||||
opinions_inhabitants: []
|
||||
};
|
||||
|
|
@ -193,7 +167,8 @@ module.exports = ({ cooler }) => {
|
|||
const agg = idx.resolveGroup(root);
|
||||
if (!agg || agg.tombstoned) throw new Error('Report not found');
|
||||
|
||||
const report = { content: agg.contentNode.c };
|
||||
const { template: legacyTemplate, ...previous } = agg.contentNode.c || {};
|
||||
const report = { content: previous };
|
||||
const tipId = agg.contentTip;
|
||||
|
||||
if (agg.contentNode.author !== userId) throw new Error('Not the author');
|
||||
|
|
@ -226,11 +201,9 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
const confirmations = ensureArray(agg.confirmations);
|
||||
|
||||
const baseTemplate = Object.prototype.hasOwnProperty.call(updatedContent, 'template')
|
||||
? updatedContent.template
|
||||
: (report.content.template || {});
|
||||
|
||||
const nextTemplate = normalizeTemplate(nextCategory, baseTemplate);
|
||||
const nextDescription = Object.prototype.hasOwnProperty.call(updatedContent, 'description')
|
||||
? trimStr(updatedContent.description)
|
||||
: withLegacyText(report.content.description, legacyTemplate);
|
||||
|
||||
const updated = {
|
||||
...report.content,
|
||||
|
|
@ -247,7 +220,7 @@ module.exports = ({ cooler }) => {
|
|||
severity: nextSeverity,
|
||||
status: nextStatus,
|
||||
category: nextCategory,
|
||||
template: nextTemplate,
|
||||
description: nextDescription,
|
||||
updatedAt: new Date().toISOString(),
|
||||
author: report.content.author
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,21 +35,21 @@ const rpcValue = (fn, ...args) => new Promise((resolve) => {
|
|||
} catch (_) { resolve(null) }
|
||||
})
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => {
|
||||
module.exports = ({ cooler, cryptoModel, roomCrypto, tribesModel }) => {
|
||||
let ssb
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
|
||||
const phone = async () => { const s = await openSsb(); return s && s.phone ? s.phone : null }
|
||||
|
||||
const ownCrypto = roomCrypto || tribeCrypto
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
|
||||
const ownCrypto = roomCrypto || cryptoModel
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (cryptoModel && cryptoModel.getKey(rid)) || null
|
||||
const lookupKeys = (rid) => {
|
||||
const a = (ownCrypto && ownCrypto.getKeys(rid)) || []
|
||||
if (a.length) return a
|
||||
return (tribeCrypto && tribeCrypto.getKeys(rid)) || []
|
||||
return (cryptoModel && cryptoModel.getKeys(rid)) || []
|
||||
}
|
||||
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0)
|
||||
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (cryptoModel && cryptoModel.getGen(rid)) || 0)
|
||||
const setRoomKey = (rootId, keyHex) => { if (ownCrypto) ownCrypto.setKey(rootId, keyHex, 1) }
|
||||
const hashCode = (code, salt) => (ownCrypto && typeof ownCrypto.hashInviteCode === "function") ? ownCrypto.hashInviteCode(code, salt) : (tribeCrypto ? tribeCrypto.hashInviteCode(code, salt) : null)
|
||||
const hashCode = (code, salt) => (ownCrypto && typeof ownCrypto.hashInviteCode === "function") ? ownCrypto.hashInviteCode(code, salt) : (cryptoModel ? cryptoModel.hashInviteCode(code, salt) : null)
|
||||
const inviteMatches = (inv, token) => {
|
||||
if (!token) return false
|
||||
if (typeof inv === "string") return inv === token
|
||||
|
|
@ -87,10 +87,10 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => {
|
|||
}
|
||||
|
||||
const getTribeKeysFor = async (tribeId) => {
|
||||
if (!tribeCrypto || !tribesModel || !tribeId) return []
|
||||
if (!cryptoModel || !tribesModel || !tribeId) return []
|
||||
try {
|
||||
const rootId = await tribesModel.getRootId(tribeId)
|
||||
return tribeCrypto.getKeys(rootId) || []
|
||||
return cryptoModel.getKeys(rootId) || []
|
||||
} catch (_) { return [] }
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +143,7 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => {
|
|||
const generateInviteSalt = () => crypto.randomBytes(16).toString("hex")
|
||||
|
||||
const rotateRoomKey = async (rootId, remainingMembers) => {
|
||||
if (!ownCrypto || !tribeCrypto || !rootId) return
|
||||
if (!ownCrypto || !cryptoModel || !rootId) return
|
||||
if (!lookupKey(rootId)) return
|
||||
const newKey = crypto.randomBytes(32).toString("hex")
|
||||
const newGen = ownCrypto.addNewKey(rootId, newKey)
|
||||
|
|
@ -151,7 +151,7 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => {
|
|||
const ssbKeys = require("../server/node_modules/ssb-keys")
|
||||
const memberKeys = {}
|
||||
for (const m of new Set([ssbClient.id, ...(Array.isArray(remainingMembers) ? remainingMembers : [])])) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[m] = cryptoModel.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (Object.keys(memberKeys).length) {
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -163,7 +163,7 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => {
|
|||
const readAll = async (ssbClient) => readTyped(ssbClient, ROOM_TYPES, { limit: logLimit })
|
||||
|
||||
const ensureMemberKeys = async (ssbClient, messages, rooms) => {
|
||||
if (!tribeCrypto || !ownCrypto) return
|
||||
if (!cryptoModel || !ownCrypto) return
|
||||
const leavesByRoot = new Map()
|
||||
for (const m of messages) {
|
||||
const c = m.value && m.value.content
|
||||
|
|
@ -187,7 +187,7 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => {
|
|||
const key = lookupKey(rootId)
|
||||
const memberKeys = {}
|
||||
for (const m of plan.missing) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(key, m, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[m] = cryptoModel.boxKeyForMember(key, m, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (!Object.keys(memberKeys).length) continue
|
||||
await asP(ssbClient.publish, { type: "tribe-keys", tribeId: rootId, generation: lookupGen(rootId) || 1, memberKeys })
|
||||
|
|
@ -524,10 +524,10 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => {
|
|||
})
|
||||
if (!usesTribeKey) {
|
||||
setRoomKey(msg.key, keyHex)
|
||||
if (tribeCrypto) {
|
||||
if (cryptoModel) {
|
||||
try {
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys")
|
||||
await asP(ssbClient.publish, { type: "tribe-keys", tribeId: msg.key, generation: 1, memberKeys: { [userId]: tribeCrypto.boxKeyForMember(keyHex, userId, ssbKeys) } })
|
||||
await asP(ssbClient.publish, { type: "tribe-keys", tribeId: msg.key, generation: 1, memberKeys: { [userId]: cryptoModel.boxKeyForMember(keyHex, userId, ssbKeys) } })
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -714,13 +714,13 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => {
|
|||
if (roomKey) setRoomKey(resolvedRootId, roomKey)
|
||||
}
|
||||
await this.addMemberToRoom(matchedRoom.rootId, userId, isPublic ? "" : token)
|
||||
if (tribeCrypto && roomKey) {
|
||||
if (cryptoModel && roomKey) {
|
||||
try {
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys")
|
||||
const memberKeys = {}
|
||||
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(roomKey, userId, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[userId] = cryptoModel.boxKeyForMember(roomKey, userId, ssbKeys) } catch (_) {}
|
||||
if (matchedRoom.author && matchedRoom.author !== userId) {
|
||||
try { memberKeys[matchedRoom.author] = tribeCrypto.boxKeyForMember(roomKey, matchedRoom.author, ssbKeys) } catch (_) {}
|
||||
try { memberKeys[matchedRoom.author] = cryptoModel.boxKeyForMember(roomKey, matchedRoom.author, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (Object.keys(memberKeys).length) await asP(ssbClient.publish, { type: "tribe-keys", tribeId: resolvedRootId, generation: 1, memberKeys })
|
||||
} catch (_) {}
|
||||
|
|
|
|||
|
|
@ -386,7 +386,7 @@ module.exports = ({ cooler, transfersModel, schoolCrypto, chatsModel }) => {
|
|||
if (!schoolCrypto) return null
|
||||
let key = schoolCrypto.getKey(rootId)
|
||||
if (key) return key
|
||||
key = schoolCrypto.generateTribeKey()
|
||||
key = schoolCrypto.generateKey()
|
||||
schoolCrypto.setKey(rootId, key, 1)
|
||||
await publishKeyGrant(ssbClient, rootId, key, [ssbClient.id])
|
||||
return key
|
||||
|
|
@ -441,7 +441,7 @@ module.exports = ({ cooler, transfersModel, schoolCrypto, chatsModel }) => {
|
|||
let toPublish = content
|
||||
let courseKey = null
|
||||
if (content.visibility === "INVITE" && schoolCrypto) {
|
||||
courseKey = schoolCrypto.generateTribeKey()
|
||||
courseKey = schoolCrypto.generateKey()
|
||||
toPublish = schoolCrypto.encryptContent(content, [courseKey], true)
|
||||
}
|
||||
const published = await new Promise((res, rej) => ssbClient.publish(toPublish, (e, m) => e ? rej(e) : res(m)))
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const longText = require('../backend/long_text');
|
|||
const { versionGroups, versionEntry } = require('../backend/dedupe');
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
|
||||
module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
|
||||
module.exports = ({ cooler, padsModel, cryptoModel, tribesModel }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => {
|
||||
if (!ssb) ssb = await cooler.open();
|
||||
|
|
@ -17,13 +17,13 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
|
|||
const STANDALONE_ENCRYPTED_TYPES = new Set(['chat', 'pad', 'map', 'calendar']);
|
||||
|
||||
const tryDecryptStandalone = (content) => {
|
||||
if (!tribeCrypto || !content || !content.encryptedPayload) return null;
|
||||
if (!cryptoModel || !content || !content.encryptedPayload) return null;
|
||||
const rootCandidates = [content.calendarId, content.chatId, content.padId, content.mapId, content.roomId, content.parentId, content.dateId, content.rootId].filter(Boolean);
|
||||
for (const cand of rootCandidates) {
|
||||
const keys = tribeCrypto.getKeys(cand);
|
||||
const keys = cryptoModel.getKeys(cand);
|
||||
if (!keys || !keys.length) continue;
|
||||
try {
|
||||
const dec = tribeCrypto.decryptContent(content, keys.map(k => [k]));
|
||||
const dec = cryptoModel.decryptContent(content, keys.map(k => [k]));
|
||||
if (dec && !dec._undecryptable) return dec;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -31,9 +31,9 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
|
|||
};
|
||||
|
||||
const tryDecryptTribe = async (content) => {
|
||||
if (!tribeCrypto || !tribesModel || !content || !content.encryptedPayload || !content.tribeId) return null;
|
||||
if (!cryptoModel || !tribesModel || !content || !content.encryptedPayload || !content.tribeId) return null;
|
||||
try {
|
||||
const dec = await tribeCrypto.decryptFromTribe(content, tribesModel);
|
||||
const dec = await cryptoModel.decryptFromGroup(content, tribesModel);
|
||||
if (dec && !dec._undecryptable) return dec;
|
||||
} catch (_) {}
|
||||
return null;
|
||||
|
|
@ -74,7 +74,7 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
|
|||
case 'market':
|
||||
return [content?.item_type, content?.title, content?.description, content?.price, ...(content?.tags || []), content?.status, content?.item_status, content?.deadline, content?.includesShipping, content?.seller, content?.image, content?.auctions_poll, content?.stock];
|
||||
case 'bookmark':
|
||||
return [content?.author, content?.url, ...(content?.tags || []), content?.description, content?.category, content?.lastVisit];
|
||||
return [content?.author, content?.title, content?.url, ...(content?.tags || []), content?.description, content?.category, content?.lastVisit];
|
||||
case 'task':
|
||||
return [content?.title, content?.description, content?.startTime, content?.endTime, content?.priority, content?.location, ...(content?.tags || []), content?.isPublic, content?.assignees?.length, content?.status, content?.author];
|
||||
case 'report':
|
||||
|
|
@ -395,10 +395,10 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
|
|||
let dec = null;
|
||||
if (c.tribeId) dec = await tryDecryptTribe(c);
|
||||
if (!dec && STANDALONE_ENCRYPTED_TYPES.has(c.type)) {
|
||||
const keys = tribeCrypto && tribeCrypto.getKeys ? tribeCrypto.getKeys(k) : [];
|
||||
const keys = cryptoModel && cryptoModel.getKeys ? cryptoModel.getKeys(k) : [];
|
||||
if (keys && keys.length) {
|
||||
try {
|
||||
const out = tribeCrypto.decryptContent(c, keys.map(kk => [kk]));
|
||||
const out = cryptoModel.decryptContent(c, keys.map(kk => [kk]));
|
||||
if (out && !out._undecryptable) dec = out;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const normalizeTags = (raw) => {
|
|||
}
|
||||
const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto }) => {
|
||||
module.exports = ({ cooler, cryptoModel }) => {
|
||||
let ssb
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
|
||||
|
||||
|
|
@ -106,8 +106,8 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
|
||||
const decryptBuyers = (val, key) => {
|
||||
if (Array.isArray(val)) return val
|
||||
if (typeof val === 'string' && tribeCrypto && key) {
|
||||
try { return JSON.parse(tribeCrypto.decryptWithKey(val, key)) } catch {}
|
||||
if (typeof val === 'string' && cryptoModel && key) {
|
||||
try { return JSON.parse(cryptoModel.decryptWithKey(val, key)) } catch {}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
|
@ -116,25 +116,25 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const productShopId = (c) => (c && (c.shopId || c.tribeId)) || ""
|
||||
|
||||
const keysForRoot = (rootId) => {
|
||||
if (!tribeCrypto || !rootId) return []
|
||||
const ks = (tribeCrypto.getKeys && tribeCrypto.getKeys(rootId)) || []
|
||||
if (!cryptoModel || !rootId) return []
|
||||
const ks = (cryptoModel.getKeys && cryptoModel.getKeys(rootId)) || []
|
||||
if (ks.length) return ks
|
||||
const k = tribeCrypto.getKey ? tribeCrypto.getKey(rootId) : null
|
||||
const k = cryptoModel.getKey ? cryptoModel.getKey(rootId) : null
|
||||
return k ? [k] : []
|
||||
}
|
||||
|
||||
const decryptShopContent = (c, keyRootId) => {
|
||||
if (!isEncrypted(c)) return c
|
||||
if (!tribeCrypto) return { ...c, _undecryptable: true }
|
||||
if (!cryptoModel) return { ...c, _undecryptable: true }
|
||||
const keys = keysForRoot(keyRootId)
|
||||
if (!keys.length) return { ...c, _undecryptable: true }
|
||||
return tribeCrypto.decryptContent(c, keys.map(k => [k]))
|
||||
return cryptoModel.decryptContent(c, keys.map(k => [k]))
|
||||
}
|
||||
|
||||
const encryptForShop = (plainContent, shopRootId) => {
|
||||
const keys = keysForRoot(shopRootId)
|
||||
if (!keys.length) throw new Error("Missing shop key")
|
||||
return tribeCrypto.encryptContent(plainContent, [keys[0]], true)
|
||||
return cryptoModel.encryptContent(plainContent, [keys[0]], true)
|
||||
}
|
||||
|
||||
const buildIndex = (messages) => {
|
||||
|
|
@ -243,7 +243,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const undecryptable = !!c._undecryptable
|
||||
const agg = idx ? idx.aggregateFor(rootId, c) : { opinions: c.opinions || {}, opinions_inhabitants: safeArr(c.opinions_inhabitants) }
|
||||
const sellerStock = Number(c.stock) || 0
|
||||
const sellerBuyers = encrypted ? safeArr(c.buyers) : decryptBuyers(c.buyers, tribeCrypto ? tribeCrypto.getKey(rootId) : null)
|
||||
const sellerBuyers = encrypted ? safeArr(c.buyers) : decryptBuyers(c.buyers, cryptoModel ? cryptoModel.getKey(rootId) : null)
|
||||
const buyers = Array.from(new Set(sellerBuyers.concat(idx ? idx.purchaseAuthorsFor(rootId) : [])))
|
||||
return {
|
||||
key: node.key,
|
||||
|
|
@ -324,14 +324,14 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
opinions_inhabitants: []
|
||||
}
|
||||
|
||||
const wantsEncryption = vis === "CLOSED" && !!tribeCrypto
|
||||
const wantsEncryption = vis === "CLOSED" && !!cryptoModel
|
||||
return new Promise((resolve, reject) => {
|
||||
if (wantsEncryption) {
|
||||
const shopKey = tribeCrypto.generateTribeKey()
|
||||
const encrypted = tribeCrypto.encryptContent(content, [shopKey], true)
|
||||
const shopKey = cryptoModel.generateKey()
|
||||
const encrypted = cryptoModel.encryptContent(content, [shopKey], true)
|
||||
ssbClient.publish(encrypted, (err, msg) => {
|
||||
if (err) return reject(err)
|
||||
if (msg && msg.key) tribeCrypto.setKey(msg.key, shopKey, 1)
|
||||
if (msg && msg.key) cryptoModel.setKey(msg.key, shopKey, 1)
|
||||
resolve(msg)
|
||||
})
|
||||
} else {
|
||||
|
|
@ -500,14 +500,14 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
return new Promise((resolve, reject) => {
|
||||
if (e2e) {
|
||||
let enc
|
||||
try { enc = tribeCrypto.encryptContent(content, [shopKeys[0]], true) } catch (e) { return reject(e) }
|
||||
try { enc = cryptoModel.encryptContent(content, [shopKeys[0]], true) } catch (e) { return reject(e) }
|
||||
ssbClient.publish(enc, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
} else {
|
||||
ssbClient.publish(content, (err, msg) => {
|
||||
if (err) return reject(err)
|
||||
if (msg && msg.key && tribeCrypto) {
|
||||
const key = tribeCrypto.generateTribeKey()
|
||||
tribeCrypto.setKey(msg.key, key, 1)
|
||||
if (msg && msg.key && cryptoModel) {
|
||||
const key = cryptoModel.generateKey()
|
||||
cryptoModel.setKey(msg.key, key, 1)
|
||||
}
|
||||
resolve(msg)
|
||||
})
|
||||
|
|
@ -846,7 +846,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
},
|
||||
|
||||
async generateInvite(shopId) {
|
||||
if (!tribeCrypto) throw new Error("Shop crypto unavailable")
|
||||
if (!cryptoModel) throw new Error("Shop crypto unavailable")
|
||||
const rootId = await this.resolveRootId(shopId)
|
||||
const ssbClient = await openSsb()
|
||||
const userId = ssbClient.id
|
||||
|
|
@ -857,10 +857,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const keys = keysForRoot(rootId)
|
||||
if (!keys.length) throw new Error("Missing shop key")
|
||||
const code = require("crypto").randomBytes(16).toString("hex")
|
||||
const salt = tribeCrypto.generateInviteSalt()
|
||||
const ek = tribeCrypto.encryptForInvite(keys[0], code, salt)
|
||||
const salt = cryptoModel.generateInviteSalt()
|
||||
const ek = cryptoModel.encryptForInvite(keys[0], code, salt)
|
||||
await new Promise((resolve, reject) =>
|
||||
ssbClient.publish({ type: "shop-invite", target: rootId, ek, salt, codeHash: tribeCrypto.hashInviteCode(code, salt) }, (e) => e ? reject(e) : resolve())
|
||||
ssbClient.publish({ type: "shop-invite", target: rootId, ek, salt, codeHash: cryptoModel.hashInviteCode(code, salt) }, (e) => e ? reject(e) : resolve())
|
||||
)
|
||||
return { code, shopId: rootId }
|
||||
},
|
||||
|
|
@ -891,7 +891,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
},
|
||||
|
||||
async generateOpenInvite(shopId) {
|
||||
if (!tribeCrypto) throw new Error("Shop crypto unavailable")
|
||||
if (!cryptoModel) throw new Error("Shop crypto unavailable")
|
||||
const rootId = await this.resolveRootId(shopId)
|
||||
const ssbClient = await openSsb()
|
||||
const userId = ssbClient.id
|
||||
|
|
@ -903,10 +903,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const keys = keysForRoot(rootId)
|
||||
if (!keys.length) throw new Error("Missing shop key")
|
||||
const code = require("crypto").randomBytes(16).toString("hex")
|
||||
const salt = tribeCrypto.generateInviteSalt()
|
||||
const ek = tribeCrypto.encryptForInvite(keys[0], code, salt)
|
||||
const salt = cryptoModel.generateInviteSalt()
|
||||
const ek = cryptoModel.encryptForInvite(keys[0], code, salt)
|
||||
const invitePub = await new Promise((resolve, reject) =>
|
||||
ssbClient.publish({ type: "shop-invite", target: rootId, ek, salt, codeHash: tribeCrypto.hashInviteCode(code, salt), multi: 1 }, (e, r) => e ? reject(e) : resolve(r))
|
||||
ssbClient.publish({ type: "shop-invite", target: rootId, ek, salt, codeHash: cryptoModel.hashInviteCode(code, salt), multi: 1 }, (e, r) => e ? reject(e) : resolve(r))
|
||||
)
|
||||
await new Promise((resolve, reject) =>
|
||||
ssbClient.publish({ type: "shop-open-invite", v: 1, target: rootId, code, inviteKey: invitePub.key, by: userId, createdAt: new Date().toISOString() }, (e) => e ? reject(e) : resolve())
|
||||
|
|
@ -931,7 +931,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
},
|
||||
|
||||
async joinByCode(code) {
|
||||
if (!tribeCrypto) throw new Error("Shop crypto unavailable")
|
||||
if (!cryptoModel) throw new Error("Shop crypto unavailable")
|
||||
const ssbClient = await openSsb()
|
||||
const messages = await readAll(ssbClient)
|
||||
const invTomb = new Set()
|
||||
|
|
@ -945,14 +945,14 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
if (!c || c.type !== "shop-invite") continue
|
||||
if (invTomb.has(m.key)) continue
|
||||
try {
|
||||
if (tribeCrypto.hashInviteCode(code, c.salt) === c.codeHash) { matched = c; break }
|
||||
if (cryptoModel.hashInviteCode(code, c.salt) === c.codeHash) { matched = c; break }
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!matched) throw new Error("Invalid or expired invite code")
|
||||
const shopKey = tribeCrypto.decryptFromInvite(matched.ek, code, matched.salt)
|
||||
const shopKey = cryptoModel.decryptFromInvite(matched.ek, code, matched.salt)
|
||||
if (!shopKey) throw new Error("Could not decrypt invite")
|
||||
if (tribeCrypto.addNewKey) tribeCrypto.addNewKey(matched.target, shopKey)
|
||||
else tribeCrypto.setKey(matched.target, shopKey, 1)
|
||||
if (cryptoModel.addNewKey) cryptoModel.addNewKey(matched.target, shopKey)
|
||||
else cryptoModel.setKey(matched.target, shopKey, 1)
|
||||
return { ok: true, shopId: matched.target }
|
||||
},
|
||||
|
||||
|
|
@ -969,7 +969,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const curShop = isEncrypted(shopRaw) ? decryptShopContent(shopRaw, rootId) : shopRaw
|
||||
if (curShop._undecryptable) throw new Error("Cannot decrypt shop")
|
||||
|
||||
const wantEncrypted = tgt === "CLOSED" && !!tribeCrypto
|
||||
const wantEncrypted = tgt === "CLOSED" && !!cryptoModel
|
||||
|
||||
const messages = await readAll(ssbClient)
|
||||
const idx = buildIndex(messages)
|
||||
|
|
@ -983,9 +983,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
}
|
||||
|
||||
if (wantEncrypted) {
|
||||
const newKey = tribeCrypto.generateTribeKey()
|
||||
if (tribeCrypto.addNewKey) tribeCrypto.addNewKey(rootId, newKey)
|
||||
else tribeCrypto.setKey(rootId, newKey, 1)
|
||||
const newKey = cryptoModel.generateKey()
|
||||
if (cryptoModel.addNewKey) cryptoModel.addNewKey(rootId, newKey)
|
||||
else cryptoModel.setKey(rootId, newKey, 1)
|
||||
}
|
||||
|
||||
const publish = (c) => new Promise((res, rej) => ssbClient.publish(c, (e, m) => e ? rej(e) : res(m)))
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const ssbDirOf = () => require('../server/ssb_config').path;
|
|||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { readTyped, CONTENT_TYPES } = require('./typed_log');
|
||||
const { isComment } = require('./comments_model');
|
||||
const { versionGroups, versionEntry } = require('../backend/dedupe');
|
||||
const opinionCategories = require('../backend/opinion_categories');
|
||||
const CONSTRUCTIVE_OPINIONS = new Set(opinionCategories.karma.constructive);
|
||||
|
|
@ -43,7 +44,7 @@ const HIDDEN_ENVELOPE_TYPES = new Set([
|
|||
'tribe-invite-tombstone'
|
||||
]);
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
||||
module.exports = ({ cooler, cryptoModel, tribesModel }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||
|
||||
|
|
@ -203,6 +204,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
};
|
||||
|
||||
const normalizeActionType = (a) => {
|
||||
if (isComment(a.content)) return 'comment';
|
||||
const t = a.type || a.content?.type || inferType(a.content) || '';
|
||||
return String(t).toLowerCase();
|
||||
};
|
||||
|
|
@ -387,13 +389,13 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
versionsOf[t] = [];
|
||||
}
|
||||
|
||||
const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
|
||||
const fpIdx = cryptoModel ? cryptoModel.buildFingerprintIndex() : null;
|
||||
const accessibleTribeIds = await buildAccessibleTribeIds();
|
||||
for (const m of allMsgs) {
|
||||
const k = m.key;
|
||||
let c = m.value.content;
|
||||
if (tribeCrypto && tribeCrypto.isTribeMsg(c)) {
|
||||
const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null;
|
||||
if (cryptoModel && cryptoModel.isSealedMsg(c)) {
|
||||
const r = fpIdx ? cryptoModel.unwrapMsg(c, fpIdx) : null;
|
||||
if (!r || !r.body) continue;
|
||||
const inner = r.body;
|
||||
if (inner.k === 'tribe') {
|
||||
|
|
|
|||
|
|
@ -161,9 +161,6 @@ module.exports = ({ cooler }) => {
|
|||
},
|
||||
async hasLocal(blobId) {
|
||||
try { return await hasLocal(await openSsb(), String(blobId || '')); } catch (_) { return false; }
|
||||
},
|
||||
async seedsFor(blobId) {
|
||||
try { return (await findSeeds(await openSsb(), String(blobId || ''))).map((s) => s.id); } catch (_) { return []; }
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const pull = require("../server/node_modules/pull-stream");
|
||||
const opinionRules = require("../backend/opinion_categories");
|
||||
const { getConfig } = require("../configs/config-manager.js");
|
||||
const categories = require("../backend/opinion_categories");
|
||||
const { readTyped, memoIndex } = require("./typed_log");
|
||||
|
|
@ -29,7 +30,14 @@ const parseBlobId = (blobMarkdown) => {
|
|||
const voteSum = (opinions = {}) =>
|
||||
Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
||||
const torrentMeta = (meta) => {
|
||||
const out = {};
|
||||
if (meta && typeof meta.license === "string" && meta.license) out.license = meta.license;
|
||||
if (meta && /^[0-9a-f]{40}$/.test(String(meta.infoHash || ""))) out.infoHash = meta.infoHash;
|
||||
return out;
|
||||
};
|
||||
|
||||
module.exports = ({ cooler, cryptoModel, tribesModel }) => {
|
||||
let ssb;
|
||||
|
||||
const openSsb = async () => {
|
||||
|
|
@ -37,9 +45,9 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
return ssb;
|
||||
};
|
||||
|
||||
const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null;
|
||||
const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
|
||||
const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
|
||||
const tribeHelpers = cryptoModel ? cryptoModel.createHelpers(tribesModel) : null;
|
||||
const encryptIfGroup = tribeHelpers ? tribeHelpers.encryptIfGroup : async (c) => c;
|
||||
const decryptIfGroup = tribeHelpers ? tribeHelpers.decryptIfGroup : async (c) => c;
|
||||
const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {};
|
||||
const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {};
|
||||
const unwrapForIndex = (msgs) => tribeHelpers ? tribeHelpers.unwrapMessagesForKind(msgs, 'torrent') : msgs;
|
||||
|
|
@ -134,6 +142,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
size: c.size || 0,
|
||||
source: undec ? "" : (typeof c.source === "string" && c.source.startsWith("&") ? c.source : ""),
|
||||
sourceName: undec ? "" : (c.sourceName || ""),
|
||||
license: undec ? "" : (c.license || ""),
|
||||
infoHash: undec ? "" : (/^[0-9a-f]{40}$/.test(String(c.infoHash || "")) ? c.infoHash : ""),
|
||||
cipher: undec ? null : (c.cipher && typeof c.cipher === "object" && typeof c.cipher.key === "string" && typeof c.cipher.manifestBlobId === "string" ? { v: Number(c.cipher.v) || 1, key: c.cipher.key, manifestBlobId: c.cipher.manifestBlobId, chunkCount: Number(c.cipher.chunkCount) || 0 } : null),
|
||||
sourceMime: undec ? "" : (c.sourceMime || ""),
|
||||
opinions,
|
||||
|
|
@ -169,7 +179,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
return root;
|
||||
},
|
||||
|
||||
async createTorrent(blobMarkdown, tagsRaw, title, description, size, tribeId, source = null) {
|
||||
async createTorrent(blobMarkdown, tagsRaw, title, description, size, tribeId, source = null, meta = {}) {
|
||||
const ssbClient = await openSsb();
|
||||
const blobId = parseBlobId(blobMarkdown);
|
||||
const tags = normalizeTags(tagsRaw) || [];
|
||||
|
|
@ -191,10 +201,11 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
...(source && typeof source.source === "string" && source.source.startsWith("&")
|
||||
? { source: source.source, sourceName: String(source.sourceName || ""), sourceMime: String(source.sourceMime || "") }
|
||||
: {}),
|
||||
...cipherField(source && source.cipher)
|
||||
...cipherField(source && source.cipher),
|
||||
...torrentMeta(meta)
|
||||
};
|
||||
|
||||
content = await encryptIfTribe(content);
|
||||
content = await encryptIfGroup(content);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
|
||||
|
|
@ -208,12 +219,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const oldMsg = await getMsg(ssbClient, tipId);
|
||||
|
||||
if (!oldMsg) throw new Error("Torrent not found");
|
||||
const oldDec = await decryptIfTribe(oldMsg.content);
|
||||
const oldDec = await decryptIfGroup(oldMsg.content);
|
||||
if (!oldDec || oldDec.type !== "torrent") throw new Error("Torrent not found");
|
||||
assertReadable(oldDec, "Torrent");
|
||||
if (oldMsg.author !== userId) throw new Error("Not the author");
|
||||
const aggTorrent = await this.getTorrentById(id, userId);
|
||||
if (aggTorrent && Object.keys(aggTorrent.opinions || {}).some(k => (aggTorrent.opinions[k] || 0) > 0)) throw new Error("Cannot edit torrent after it has received opinions.");
|
||||
opinionRules.assertMediaKept(aggTorrent, !!blobMarkdown);
|
||||
|
||||
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldDec.tags);
|
||||
const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
|
||||
|
|
@ -227,7 +238,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
tags,
|
||||
title: title !== undefined ? title || "" : oldDec.title || "",
|
||||
description: description !== undefined ? description || "" : oldDec.description || "",
|
||||
size: oldDec.size || 0,
|
||||
size: blobId && extra && Number(extra.size) > 0 ? Number(extra.size) : (oldDec.size || 0),
|
||||
...torrentMeta({ license: oldDec.license, infoHash: blobId ? "" : oldDec.infoHash, ...(extra || {}) }),
|
||||
opinions: oldDec.opinions || {},
|
||||
opinions_inhabitants: oldDec.opinions_inhabitants || [],
|
||||
author: oldDec.author || userId,
|
||||
|
|
@ -236,7 +248,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
updatedAt: now
|
||||
};
|
||||
|
||||
updated = await encryptIfTribe(updated);
|
||||
updated = await encryptIfGroup(updated);
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
|
||||
|
|
@ -253,7 +265,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const msg = await getMsg(ssbClient, tipId);
|
||||
|
||||
if (!msg) throw new Error("Torrent not found");
|
||||
const dec = await decryptIfTribe(msg.content);
|
||||
const dec = await decryptIfGroup(msg.content);
|
||||
if (!dec || dec.type !== "torrent") throw new Error("Torrent not found");
|
||||
if (msg.author !== userId) throw new Error("Not the author");
|
||||
|
||||
|
|
@ -332,8 +344,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const msg = await getMsg(ssbClient, tip);
|
||||
if (!msg) throw new Error("Torrent not found");
|
||||
let c = msg.content;
|
||||
if (tribeCrypto && (c?.encryptedPayload || tribeCrypto.isTribeMsg(c)) && tribesModel) {
|
||||
const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
|
||||
if (cryptoModel && (c?.encryptedPayload || cryptoModel.isSealedMsg(c)) && tribesModel) {
|
||||
const dec = await cryptoModel.decryptFromGroup(c, tribesModel);
|
||||
c = dec && !dec._undecryptable ? { ...dec, _decrypted: true } : { ...c, _decrypted: false };
|
||||
}
|
||||
if (!c || c.type !== "torrent") throw new Error("Torrent not found");
|
||||
|
|
@ -357,3 +369,99 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
}
|
||||
};
|
||||
};
|
||||
|
||||
const BENCODE_MAX_DEPTH = 64;
|
||||
const TORRENT_FILES_SHOWN = 50;
|
||||
const MAGNET_TRACKERS_MAX = 8;
|
||||
const MAGNET_SEEDS_MAX = 4;
|
||||
const TORRENT_LINK_RE = /^(https?|udp|wss?):\/\//i;
|
||||
|
||||
const decodeBencode = (buf) => {
|
||||
let pos = 0;
|
||||
let infoRange = null;
|
||||
const fail = () => { throw new Error("Invalid torrent"); };
|
||||
const parse = (depth) => {
|
||||
if (depth > BENCODE_MAX_DEPTH || pos >= buf.length) fail();
|
||||
const ch = buf[pos];
|
||||
if (ch === 0x69) {
|
||||
const end = buf.indexOf(0x65, pos);
|
||||
if (end < 0) fail();
|
||||
const n = Number(buf.toString("latin1", pos + 1, end));
|
||||
pos = end + 1;
|
||||
return Number.isFinite(n) ? n : fail();
|
||||
}
|
||||
if (ch === 0x6c) {
|
||||
pos++;
|
||||
const out = [];
|
||||
while (buf[pos] !== 0x65) out.push(parse(depth + 1));
|
||||
pos++;
|
||||
return out;
|
||||
}
|
||||
if (ch === 0x64) {
|
||||
pos++;
|
||||
const out = {};
|
||||
while (buf[pos] !== 0x65) {
|
||||
const key = parse(depth + 1);
|
||||
if (!Buffer.isBuffer(key)) fail();
|
||||
const start = pos;
|
||||
const name = key.toString("latin1");
|
||||
out[name] = parse(depth + 1);
|
||||
if (depth === 0 && name === "info") infoRange = [start, pos];
|
||||
}
|
||||
pos++;
|
||||
return out;
|
||||
}
|
||||
if (ch >= 0x30 && ch <= 0x39) {
|
||||
const colon = buf.indexOf(0x3a, pos);
|
||||
if (colon < 0) fail();
|
||||
const len = Number(buf.toString("latin1", pos, colon));
|
||||
const start = colon + 1;
|
||||
if (!Number.isInteger(len) || len < 0 || start + len > buf.length) fail();
|
||||
pos = start + len;
|
||||
return buf.subarray(start, pos);
|
||||
}
|
||||
return fail();
|
||||
};
|
||||
return { value: parse(0), infoRange };
|
||||
};
|
||||
|
||||
const text = (v) => (Buffer.isBuffer(v) ? v.toString("utf8") : "");
|
||||
|
||||
const parseTorrent = (buf) => {
|
||||
const { value, infoRange } = decodeBencode(Buffer.from(buf));
|
||||
const info = value && value.info;
|
||||
if (!info || typeof info !== "object" || !infoRange) throw new Error("Invalid torrent");
|
||||
const crypto = require("crypto");
|
||||
const raw = Buffer.from(buf).subarray(infoRange[0], infoRange[1]);
|
||||
const v2Only = !info.pieces && info["file tree"];
|
||||
const infoHash = crypto.createHash(v2Only ? "sha256" : "sha1").update(raw).digest("hex");
|
||||
const name = text(info["name.utf-8"] || info.name);
|
||||
const files = Array.isArray(info.files)
|
||||
? info.files.map(f => ({ path: (Array.isArray(f["path.utf-8"] || f.path) ? (f["path.utf-8"] || f.path) : []).map(text).join("/"), length: Number(f.length) || 0 }))
|
||||
: [{ path: name, length: Number(info.length) || 0 }];
|
||||
const announce = [text(value.announce), ...(Array.isArray(value["announce-list"]) ? value["announce-list"].flat().map(text) : [])];
|
||||
const seeds = Array.isArray(value["url-list"]) ? value["url-list"].map(text) : [text(value["url-list"])];
|
||||
return {
|
||||
infoHash,
|
||||
v2: !!v2Only,
|
||||
name,
|
||||
length: files.reduce((sum, f) => sum + f.length, 0),
|
||||
fileCount: files.length,
|
||||
files: files.slice(0, TORRENT_FILES_SHOWN),
|
||||
trackers: [...new Set(announce.filter(t => TORRENT_LINK_RE.test(t)))].slice(0, MAGNET_TRACKERS_MAX),
|
||||
webSeeds: [...new Set(seeds.filter(u => /^https?:\/\//i.test(u)))].slice(0, MAGNET_SEEDS_MAX)
|
||||
};
|
||||
};
|
||||
|
||||
const magnetFor = (t) => {
|
||||
if (!t || !t.infoHash) return "";
|
||||
const params = [t.v2 ? `xt=urn:btmh:1220${t.infoHash}` : `xt=urn:btih:${t.infoHash}`];
|
||||
if (t.name) params.push(`dn=${encodeURIComponent(t.name)}`);
|
||||
if (t.length) params.push(`xl=${t.length}`);
|
||||
for (const tr of t.trackers || []) params.push(`tr=${encodeURIComponent(tr)}`);
|
||||
for (const ws of t.webSeeds || []) params.push(`ws=${encodeURIComponent(ws)}`);
|
||||
return `magnet:?${params.join("&")}`;
|
||||
};
|
||||
|
||||
module.exports.parseTorrent = parseTorrent;
|
||||
module.exports.magnetFor = magnetFor;
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ module.exports = ({ cooler }) => {
|
|||
for (const cf of rootConfirms) { if (cf.author === best.c.to) confirmedBy.add(cf.author) }
|
||||
for (const op of (opinionsByRoot.get(root) || [])) { if (!opinionSet.has(op.author)) { opinionSet.add(op.author); opinions[op.category] = (opinions[op.category] || 0) + 1 } }
|
||||
const ubiPayerTrusted = !!best.author && (best.author === defaultPub || !!(claimedPubs.get(best.c.to) && claimedPubs.get(best.c.to).has(best.author)))
|
||||
return { contentTip, best, confirmedBy: [...confirmedBy], opinions, opinions_inhabitants: [...opinionSet], tombstoned: tomb.has(contentTip) || tomb.has(best.key), ubiPayerTrusted }
|
||||
return { root, contentTip, best, confirmedBy: [...confirmedBy], opinions, opinions_inhabitants: [...opinionSet], tombstoned: tomb.has(contentTip) || tomb.has(best.key), ubiPayerTrusted }
|
||||
}
|
||||
|
||||
return { tomb, nodes, parent, child, rootOf, tipByRoot, resolveGroup }
|
||||
|
|
@ -204,6 +204,7 @@ module.exports = ({ cooler }) => {
|
|||
const opinions_inhabitants = agg ? agg.opinions_inhabitants : (Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [])
|
||||
return {
|
||||
id: node.key,
|
||||
rootId: agg && agg.root ? agg.root : node.key,
|
||||
author: node.author,
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
|
|
@ -289,7 +290,6 @@ module.exports = ({ cooler }) => {
|
|||
const aggU = gU ? buildTransfer(gU.best, gU) : buildTransfer({ key: tipId, c: current })
|
||||
const currentStatus = aggU.status
|
||||
|
||||
if (Object.keys(aggU.opinions || {}).some(k => (aggU.opinions[k] || 0) > 0)) throw new Error("Cannot edit transfer after it has received opinions.")
|
||||
if (current.from !== userId) throw new Error("Not the author")
|
||||
if (currentStatus !== "UNCONFIRMED") throw new Error("Can only edit unconfirmed")
|
||||
|
||||
|
|
@ -300,6 +300,7 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
const num = parseNum(amount)
|
||||
if (!Number.isFinite(num) || num <= 0) throw new Error("Amount must be positive")
|
||||
categories.assertMediaKept(aggU, to !== current.to || num.toFixed(6) !== Number(current.amount || 0).toFixed(6))
|
||||
|
||||
const dl = moment(deadline, moment.ISO_8601, true)
|
||||
if (!dl.isValid() || dl.isBefore(moment())) throw new Error("Deadline must be in the future")
|
||||
|
|
|
|||
|
|
@ -20,17 +20,11 @@ module.exports = ({ cooler }) => {
|
|||
return ssb;
|
||||
};
|
||||
|
||||
const hasBlob = async (ssbClient, url) => {
|
||||
return new Promise(resolve => {
|
||||
ssbClient.blobs.has(url, (err, has) => resolve(!err && has));
|
||||
});
|
||||
};
|
||||
|
||||
const types = [
|
||||
'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'
|
||||
'podcast', 'podcastEpisode', 'campaign', 'logisticsRoute', 'wikiPage', 'map', 'industryBlueprint', 'job'
|
||||
];
|
||||
|
||||
const categories = opinionCategories;
|
||||
|
|
@ -55,7 +49,7 @@ module.exports = ({ cooler }) => {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (c.opinions && !['task', 'event', 'report'].includes(c.type)) itemsById.set(k, m);
|
||||
if ((c.opinions || categories.opinable(c)) && !['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);
|
||||
|
|
@ -101,19 +95,7 @@ module.exports = ({ cooler }) => {
|
|||
if (c.encryptedPayload) return false;
|
||||
return String(c.visibility || 'PUBLIC').toUpperCase() !== 'INVITE';
|
||||
});
|
||||
const blobTypes = ['document', 'image', 'audio', 'video'];
|
||||
|
||||
let items = await Promise.all(
|
||||
rawItems.map(async m => {
|
||||
const c = m.value?.content;
|
||||
if (blobTypes.includes(c.type) && c.url) {
|
||||
const valid = await hasBlob(ssbClient, c.url);
|
||||
if (!valid) return null;
|
||||
}
|
||||
return m;
|
||||
})
|
||||
);
|
||||
items = items.filter(Boolean);
|
||||
let items = rawItems.slice();
|
||||
|
||||
const signatureOf = (m) => {
|
||||
const c = m.value?.content || {};
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const snapshotOf = (item, replies = []) => {
|
|||
return out;
|
||||
};
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
||||
module.exports = ({ cooler, cryptoModel, tribesModel }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||
|
||||
|
|
@ -38,15 +38,15 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
|
||||
const fingerprintsForRoot = (rootId) => {
|
||||
const fps = new Set();
|
||||
for (const k of tribeCrypto.getKeys(rootId)) fps.add(tribeCrypto.fingerprint(k));
|
||||
for (const k of cryptoModel.getKeys(rootId)) fps.add(cryptoModel.fingerprint(k));
|
||||
return fps;
|
||||
};
|
||||
|
||||
const wrapAndPublishContent = async (rootId, body) => {
|
||||
const client = await openSsb();
|
||||
const key = tribeCrypto.getKey(rootId);
|
||||
const key = cryptoModel.getKey(rootId);
|
||||
if (!key) throw new Error('Missing tribe key for ' + rootId);
|
||||
const envelope = tribeCrypto.wrapMsg(body, key);
|
||||
const envelope = cryptoModel.wrapMsg(body, key);
|
||||
return new Promise((resolve, reject) =>
|
||||
client.publish(envelope, (err, r) => err ? reject(err) : resolve(r))
|
||||
);
|
||||
|
|
@ -59,7 +59,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
};
|
||||
|
||||
const decodeContentMsgs = async (msgs, opts) => {
|
||||
const fpIdx = tribeCrypto.buildFingerprintIndex();
|
||||
const fpIdx = cryptoModel.buildFingerprintIndex();
|
||||
const targetRootId = opts && opts.rootId ? opts.rootId : null;
|
||||
const wantType = opts && opts.contentType ? opts.contentType : null;
|
||||
const allowedFps = targetRootId ? fingerprintsForRoot(targetRootId) : null;
|
||||
|
|
@ -72,10 +72,10 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
for (const m of msgs) {
|
||||
const c = m.value && m.value.content;
|
||||
if (!c) continue;
|
||||
if (!tribeCrypto.isTribeMsg(c)) continue;
|
||||
if (!cryptoModel.isSealedMsg(c)) continue;
|
||||
if (allowedFps && !allowedFps.has(c.fp)) continue;
|
||||
|
||||
const r = tribeCrypto.unwrapMsg(c, fpIdx);
|
||||
const r = cryptoModel.unwrapMsg(c, fpIdx);
|
||||
if (!r || !r.body) continue;
|
||||
const b = r.body;
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const VALID_INVITE_MODES = ['strict', 'open'];
|
|||
|
||||
const STRUCTURAL_FIELDS = ['title', 'description', 'image', 'location', 'tags', 'isAnonymous', 'inviteMode', 'status', 'parentTribeId', 'mapUrl'];
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto }) => {
|
||||
module.exports = ({ cooler, cryptoModel }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const v = m && m.value;
|
||||
if (!v) continue;
|
||||
const c = v.content;
|
||||
const dec = (c && typeof c === 'object') ? (v.private === true && tribeCrypto.isKeyDistribContent(c) ? c : null) : tribeCrypto.tryUnboxKeyDistrib(c, config.keys, ssbKeys);
|
||||
const dec = (c && typeof c === 'object') ? (v.private === true && cryptoModel.isKeyDistribContent(c) ? c : null) : cryptoModel.tryUnboxKeyDistrib(c, config.keys, ssbKeys);
|
||||
if (dec) out.push({ m, dec });
|
||||
}
|
||||
return { msgs, distribs: out };
|
||||
|
|
@ -79,7 +79,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const buildTribeIndex = async () => {
|
||||
subscribeInvalidation().catch(() => {});
|
||||
if (tribeIndex && Date.now() - tribeIndexTs < 5000) return tribeIndex;
|
||||
const fpIdx = tribeCrypto.buildFingerprintIndex();
|
||||
const fpIdx = cryptoModel.buildFingerprintIndex();
|
||||
const msgs = await streamLog();
|
||||
|
||||
const tribeMsgs = new Map();
|
||||
|
|
@ -102,8 +102,8 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
}
|
||||
|
||||
let body = null;
|
||||
if (tribeCrypto.isTribeMsg(c)) {
|
||||
const r = tribeCrypto.unwrapMsg(c, fpIdx);
|
||||
if (cryptoModel.isSealedMsg(c)) {
|
||||
const r = cryptoModel.unwrapMsg(c, fpIdx);
|
||||
if (!r || !r.body || !r.body.k) continue;
|
||||
if (r.body.k === 'open-invite' || r.body.k === 'open-invite-tombstone') {
|
||||
sealedInvites.push({ key: m.key, author, body: r.body, keyRoot: r.rootId, ts });
|
||||
|
|
@ -280,7 +280,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const c = m && m.value && m.value.content;
|
||||
if (!c) return;
|
||||
if (typeof c === 'string' && c.endsWith('.box')) { tribeIndex = null; return; }
|
||||
if (tribeCrypto.isTribeMsg(c)) { tribeIndex = null; return; }
|
||||
if (cryptoModel.isSealedMsg(c)) { tribeIndex = null; return; }
|
||||
if (c.type === 'tribe-invite-msg' || c.type === 'tribe-invite-tombstone') tribeIndex = null;
|
||||
}, () => { started = false; })
|
||||
);
|
||||
|
|
@ -314,9 +314,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
|
||||
const wrapAndPublish = async (rootId, body) => {
|
||||
const client = await openSsb();
|
||||
const key = tribeCrypto.getKey(rootId);
|
||||
const key = cryptoModel.getKey(rootId);
|
||||
if (!key) throw new Error('Missing tribe key for ' + rootId);
|
||||
const envelope = tribeCrypto.wrapMsg(body, key);
|
||||
const envelope = cryptoModel.wrapMsg(body, key);
|
||||
const result = await new Promise((resolve, reject) =>
|
||||
client.publish(envelope, (err, r) => err ? reject(err) : resolve(r))
|
||||
);
|
||||
|
|
@ -389,18 +389,18 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
if (typeof c.ch !== 'string' || typeof c.s !== 'string' || typeof c.ek !== 'string') continue;
|
||||
const multi = c.multi === 1 || c.multi === true;
|
||||
if (!multi && inviteTombstonedBy.has(m.key)) continue;
|
||||
if (tribeCrypto.hashInviteCode(code, c.s) !== c.ch) continue;
|
||||
const chain = tribeCrypto.decryptChainFromInvite(c.ek, code, c.s, 3);
|
||||
if (cryptoModel.hashInviteCode(code, c.s) !== c.ch) continue;
|
||||
const chain = cryptoModel.decryptChainFromInvite(c.ek, code, c.s, 3);
|
||||
if (Array.isArray(chain) && chain.length) {
|
||||
matched = { msgKey: m.key, codeHash: c.ch, chain, multi, inviteAuthor: v.author };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) return null;
|
||||
const fresh = matched.chain.filter(entry => entry && Array.isArray(entry.keys) && entry.keys.length && !tribeCrypto.getKeys(entry.rootId).length);
|
||||
for (const entry of fresh) tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length);
|
||||
const fresh = matched.chain.filter(entry => entry && Array.isArray(entry.keys) && entry.keys.length && !cryptoModel.getKeys(entry.rootId).length);
|
||||
for (const entry of fresh) cryptoModel.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length);
|
||||
tribeIndex = null;
|
||||
const undo = () => { for (const entry of fresh) tribeCrypto.dropKey(entry.rootId); tribeIndex = null; };
|
||||
const undo = () => { for (const entry of fresh) cryptoModel.dropKey(entry.rootId); tribeIndex = null; };
|
||||
const rootId = matched.chain[0].rootId;
|
||||
const idx = await buildTribeIndex().catch(() => null);
|
||||
const entry = idx && idx.tribes.get(idx.tipByRoot.get(rootId) || rootId);
|
||||
|
|
@ -428,7 +428,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
: String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean);
|
||||
|
||||
const isPrivate = Boolean(isAnonymous);
|
||||
const newKey = tribeCrypto.generateTribeKey();
|
||||
const newKey = cryptoModel.generateKey();
|
||||
const now = new Date().toISOString();
|
||||
const baseFields = {
|
||||
title,
|
||||
|
|
@ -449,14 +449,14 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
};
|
||||
let envelope;
|
||||
if (isPrivate) {
|
||||
envelope = tribeCrypto.wrapMsg({ k: 'tribe', op: 'create', rootId: null, replaces: null, ...baseFields }, newKey);
|
||||
envelope = cryptoModel.wrapMsg({ k: 'tribe', op: 'create', rootId: null, replaces: null, ...baseFields }, newKey);
|
||||
} else {
|
||||
envelope = { type: 'tribe', op: 'create', ...baseFields };
|
||||
}
|
||||
const result = await new Promise((resolve, reject) =>
|
||||
client.publish(envelope, (err, r) => err ? reject(err) : resolve(r))
|
||||
);
|
||||
tribeCrypto.setKey(result.key, newKey, 1);
|
||||
cryptoModel.setKey(result.key, newKey, 1);
|
||||
tribeIndex = null;
|
||||
subscribeInvalidation().catch(() => {});
|
||||
return result;
|
||||
|
|
@ -710,10 +710,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
|
||||
const targetRoot = await this.getRootId(tribeId);
|
||||
if (!targetRoot) throw new Error('Cannot resolve tribe root');
|
||||
const salt = tribeCrypto.generateInviteSalt();
|
||||
const ekChain = tribeCrypto.encryptChainForInvite([targetRoot], code, salt);
|
||||
const salt = cryptoModel.generateInviteSalt();
|
||||
const ekChain = cryptoModel.encryptChainForInvite([targetRoot], code, salt);
|
||||
if (!ekChain) throw new Error('Cannot encrypt invite chain — missing keys');
|
||||
const codeHash = tribeCrypto.hashInviteCode(code, salt);
|
||||
const codeHash = cryptoModel.hashInviteCode(code, salt);
|
||||
const inviteMsg = {
|
||||
type: 'tribe-invite-msg',
|
||||
v: 1,
|
||||
|
|
@ -727,7 +727,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const inviteRef = {
|
||||
codeHash,
|
||||
salt,
|
||||
gen: tribeCrypto.getGen(targetRoot),
|
||||
gen: cryptoModel.getGen(targetRoot),
|
||||
msgKey: invitePub.key
|
||||
};
|
||||
const invites = Array.isArray(tribe.invites) ? [...tribe.invites, inviteRef] : [inviteRef];
|
||||
|
|
@ -770,10 +770,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
|
||||
const targetRoot = await this.getRootId(tribeId);
|
||||
if (!targetRoot) throw new Error('Cannot resolve tribe root');
|
||||
const salt = tribeCrypto.generateInviteSalt();
|
||||
const ekChain = tribeCrypto.encryptChainForInvite([targetRoot], code, salt);
|
||||
const salt = cryptoModel.generateInviteSalt();
|
||||
const ekChain = cryptoModel.encryptChainForInvite([targetRoot], code, salt);
|
||||
if (!ekChain) throw new Error('Cannot encrypt invite chain — missing keys');
|
||||
const codeHash = tribeCrypto.hashInviteCode(code, salt);
|
||||
const codeHash = cryptoModel.hashInviteCode(code, salt);
|
||||
const invitePub = await new Promise((resolve, reject) =>
|
||||
client.publish({ type: 'tribe-invite-msg', v: 1, multi: 1, ch: codeHash, s: salt, ek: ekChain }, (err, r) => err ? reject(err) : resolve(r))
|
||||
);
|
||||
|
|
@ -843,8 +843,8 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
}
|
||||
for (const entry of matched.chain) {
|
||||
if (!entry || !Array.isArray(entry.keys) || !entry.keys.length) continue;
|
||||
if (fresh.includes(entry)) { if (!lineage.has(entry.rootId)) tribeCrypto.dropKey(entry.rootId); continue; }
|
||||
if (lineage.has(entry.rootId)) tribeCrypto.mergeKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length);
|
||||
if (fresh.includes(entry)) { if (!lineage.has(entry.rootId)) cryptoModel.dropKey(entry.rootId); continue; }
|
||||
if (lineage.has(entry.rootId)) cryptoModel.mergeKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length);
|
||||
}
|
||||
tribeIndex = null;
|
||||
const members = [...tribe.members, userId];
|
||||
|
|
@ -894,12 +894,12 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
if (!Array.isArray(toMembers) || !toMembers.length) return;
|
||||
const client = await openSsb();
|
||||
const rootId = await this.getRootId(tribeId);
|
||||
const keys = tribeCrypto.getKeys(rootId);
|
||||
const gen = tribeCrypto.getGen(rootId);
|
||||
const keys = cryptoModel.getKeys(rootId);
|
||||
const gen = cryptoModel.getGen(rootId);
|
||||
if (!keys.length) return;
|
||||
const payload = tribeCrypto.buildKeyDistribPayload(rootId, keys, gen);
|
||||
const payload = cryptoModel.buildKeyDistribPayload(rootId, keys, gen);
|
||||
const others = [...new Set(toMembers)].filter(m => m && m !== client.id);
|
||||
const batch = tribeCrypto.KEY_DISTRIB_BATCH - 1;
|
||||
const batch = cryptoModel.KEY_DISTRIB_BATCH - 1;
|
||||
let i = 0;
|
||||
do {
|
||||
const recps = [client.id, ...others.slice(i, i + batch)];
|
||||
|
|
@ -912,10 +912,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
|
||||
async rotateTribeKey(tribeId, remainingMembers) {
|
||||
const rootId = await this.getRootId(tribeId);
|
||||
const oldKey = tribeCrypto.getKey(rootId);
|
||||
const oldKey = cryptoModel.getKey(rootId);
|
||||
if (!oldKey) return;
|
||||
const newKey = tribeCrypto.generateTribeKey();
|
||||
tribeCrypto.addNewKey(rootId, newKey);
|
||||
const newKey = cryptoModel.generateKey();
|
||||
cryptoModel.addNewKey(rootId, newKey);
|
||||
if (Array.isArray(remainingMembers) && remainingMembers.length > 0) {
|
||||
await this.distributeTribeKey(tribeId, remainingMembers).catch(() => {});
|
||||
}
|
||||
|
|
@ -928,11 +928,11 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
try { tribe = await this.getTribeById(tribeId); } catch (_) { return; }
|
||||
if (!tribe || tribe.author !== userId) return;
|
||||
const rootId = await this.getRootId(tribeId);
|
||||
if (!tribeCrypto.getKey(rootId)) return;
|
||||
if (!cryptoModel.getKey(rootId)) return;
|
||||
const others = (tribe.members || []).filter(m => m !== userId);
|
||||
const { distribs } = await readKeyDistribs();
|
||||
const distributed = new Set();
|
||||
const currentGen = tribeCrypto.getGen(rootId);
|
||||
const currentGen = cryptoModel.getGen(rootId);
|
||||
const holdersOfCurrent = new Set();
|
||||
for (const { m, dec } of distribs) {
|
||||
if (m.value.author !== userId) continue;
|
||||
|
|
@ -968,7 +968,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
entries.sort((a, b) => b.generation - a.generation);
|
||||
const top = entries[0];
|
||||
if (top && Array.isArray(top.keys) && top.keys.length) {
|
||||
tribeCrypto.mergeKeys(rootId, top.keys, top.generation);
|
||||
cryptoModel.mergeKeys(rootId, top.keys, top.generation);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -978,7 +978,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const me = client.id;
|
||||
let tribe;
|
||||
try { tribe = await this.getTribeById(tribeId); } catch (_) { return; }
|
||||
const fpIdx = tribeCrypto.buildFingerprintIndex();
|
||||
const fpIdx = cryptoModel.buildFingerprintIndex();
|
||||
const rootId = await this.getRootId(tribeId).catch(() => tribeId);
|
||||
const tribeChainIds = await this.getChainIds(tribeId).catch(() => [tribeId]);
|
||||
const tribeRootSet = new Set([rootId]);
|
||||
|
|
@ -1006,15 +1006,15 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
if (myInviteMsgKeys.has(c.target)) discovered.add(v.author);
|
||||
continue;
|
||||
}
|
||||
const dec = tribeCrypto.tryUnboxKeyDistrib(c, config.keys, ssbKeys);
|
||||
const dec = cryptoModel.tryUnboxKeyDistrib(c, config.keys, ssbKeys);
|
||||
if (dec && tribeRootSet.has(dec.rootId)) {
|
||||
const recps = Array.isArray(dec.recps) ? dec.recps : [];
|
||||
for (const r of recps) discovered.add(r);
|
||||
if (v.author) discovered.add(v.author);
|
||||
continue;
|
||||
}
|
||||
if (tribeCrypto.isTribeMsg(c)) {
|
||||
const r = tribeCrypto.unwrapMsg(c, fpIdx);
|
||||
if (cryptoModel.isSealedMsg(c)) {
|
||||
const r = cryptoModel.unwrapMsg(c, fpIdx);
|
||||
if (!r || !r.body) continue;
|
||||
if (r.body.k !== 'tribe') continue;
|
||||
if (!tribeChainSet.has(m.key) && !tribeChainSet.has(r.body.replaces || '') && r.rootId !== rootId) continue;
|
||||
|
|
@ -1072,7 +1072,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
while (idx.child.has(tipId)) tipId = idx.child.get(tipId);
|
||||
let tribe;
|
||||
try { tribe = await this.getTribeById(tribeId); } catch (_) { tribe = null; }
|
||||
const isPrivate = tribe ? !!tribe.isAnonymous : !!tribeCrypto.getKey(rootId);
|
||||
const isPrivate = tribe ? !!tribe.isAnonymous : !!cryptoModel.getKey(rootId);
|
||||
let result;
|
||||
if (isPrivate) {
|
||||
result = await wrapAndPublish(rootId, { k: 'tombstone', rootId, target: tipId, author: userId, deletedAt: new Date().toISOString() });
|
||||
|
|
@ -1087,13 +1087,13 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
},
|
||||
|
||||
async pruneOrphanKeys() {
|
||||
if (!tribeCrypto || typeof tribeCrypto.getAllRootIds !== 'function') return 0;
|
||||
if (!cryptoModel || typeof cryptoModel.getAllRootIds !== 'function') return 0;
|
||||
const idx = await buildTribeIndex();
|
||||
const all = tribeCrypto.getAllRootIds();
|
||||
const all = cryptoModel.getAllRootIds();
|
||||
let removed = 0;
|
||||
for (const rid of all) {
|
||||
if (idx.effectivelyTombstoned.has(rid)) {
|
||||
try { tribeCrypto.dropKey(rid); removed++; } catch (_) {}
|
||||
try { cryptoModel.dropKey(rid); removed++; } catch (_) {}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const pull = require("../server/node_modules/pull-stream");
|
||||
const opinionRules = require("../backend/opinion_categories");
|
||||
const { getConfig } = require("../configs/config-manager.js");
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { dedupeBy, norm, versionGroups } = require('../backend/dedupe');
|
||||
|
|
@ -171,7 +172,7 @@ module.exports = ({ cooler }) => {
|
|||
if (!oldMsg || oldMsg.content?.type !== "video") throw new Error("Video not found");
|
||||
if (oldMsg.content.author !== userId) throw new Error("Not the author");
|
||||
const aggV = await this.getVideoById(id, userId);
|
||||
if (aggV && Object.keys(aggV.opinions || {}).some(k => (aggV.opinions[k] || 0) > 0)) throw new Error("Cannot edit video after it has received opinions.");
|
||||
opinionRules.assertMediaKept(aggV, !!blobMarkdown);
|
||||
|
||||
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
|
||||
const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const pull = require('../server/node_modules/pull-stream');
|
||||
const opinionRules = require('../backend/opinion_categories');
|
||||
const moment = require('../server/node_modules/moment');
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
|
|
@ -15,6 +16,10 @@ const isVoteLocked = (v) => !!v && (
|
|||
|| Object.values(v.opinions || {}).some(n => (Number(n) || 0) > 0)
|
||||
|| (Array.isArray(v.tags) && v.tags.some(t => INSTITUTIONAL_VOTE_TAG.test(String(t))))
|
||||
);
|
||||
const isVoteEditLocked = (v) => !!v && (
|
||||
(parseInt(v.totalVotes || 0, 10) || 0) > 0
|
||||
|| (Array.isArray(v.tags) && v.tags.some(t => INSTITUTIONAL_VOTE_TAG.test(String(t))))
|
||||
);
|
||||
const lockedError = () => Object.assign(new Error('Vote locked'), { code: 'VOTE_LOCKED' });
|
||||
|
||||
module.exports = ({ cooler }) => {
|
||||
|
|
@ -151,6 +156,7 @@ module.exports = ({ cooler }) => {
|
|||
id: best.key,
|
||||
latestId: best.key,
|
||||
...best.content,
|
||||
rootId: root,
|
||||
votes: votesMap,
|
||||
voters,
|
||||
totalVotes,
|
||||
|
|
@ -253,7 +259,7 @@ module.exports = ({ cooler }) => {
|
|||
if (c.createdBy !== userId) throw new Error('Not the author');
|
||||
const agg = await this.getVoteById(id);
|
||||
const aggTotalVotes = agg ? (parseInt(agg.totalVotes || 0, 10) || 0) : 0;
|
||||
if (isVoteLocked(c) || isVoteLocked(agg)) throw lockedError();
|
||||
if (isVoteEditLocked(c) || isVoteEditLocked(agg)) throw lockedError();
|
||||
|
||||
let newDeadline = c.deadline;
|
||||
if (deadline != null && deadline !== '') {
|
||||
|
|
@ -276,6 +282,9 @@ module.exports = ({ cooler }) => {
|
|||
options.some((o, i) => o !== newOptions[i])
|
||||
);
|
||||
|
||||
const questionChanged = question !== undefined && String(question) !== String(c.question || '');
|
||||
opinionRules.assertMediaKept(agg && Object.keys(agg.opinions || {}).length ? agg : c, questionChanged || optionsChanged);
|
||||
|
||||
if (optionsChanged) {
|
||||
if (aggTotalVotes > 0) {
|
||||
throw new Error('Cannot change options after voting has started');
|
||||
|
|
@ -387,3 +396,4 @@ module.exports = ({ cooler }) => {
|
|||
};
|
||||
};
|
||||
module.exports.isVoteLocked = isVoteLocked;
|
||||
module.exports.isVoteEditLocked = isVoteEditLocked;
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ const normalizePolicy = (v) => {
|
|||
return EDIT_POLICIES.includes(s) ? s : 'open';
|
||||
};
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto = null, tribesModel = null }) => {
|
||||
module.exports = ({ cooler, cryptoModel = null, tribesModel = null }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||
|
||||
|
|
@ -78,9 +78,9 @@ module.exports = ({ cooler, tribeCrypto = null, tribesModel = null }) => {
|
|||
};
|
||||
|
||||
const tribeKeyFor = async (tribeId) => {
|
||||
if (!tribeCrypto || !tribeId) return null;
|
||||
if (!cryptoModel || !tribeId) return null;
|
||||
const rootId = await tribeRootId(tribeId);
|
||||
return tribeCrypto.getKey(rootId) || null;
|
||||
return cryptoModel.getKey(rootId) || null;
|
||||
};
|
||||
|
||||
const storeBody = async (ssbClient, body, tribeId) => {
|
||||
|
|
@ -90,7 +90,7 @@ module.exports = ({ cooler, tribeCrypto = null, tribesModel = null }) => {
|
|||
if (tribeId) {
|
||||
const key = await tribeKeyFor(tribeId);
|
||||
if (!key) throw new Error('Missing tribe key — cannot publish encrypted wiki page');
|
||||
payload = JSON.stringify(tribeCrypto.encryptContent({ body: text }, [key], true));
|
||||
payload = JSON.stringify(cryptoModel.encryptContent({ body: text }, [key], true));
|
||||
}
|
||||
const ref = await addBlob(ssbClient, Buffer.from(payload, 'utf8'));
|
||||
bodyCache.set(ref, text);
|
||||
|
|
@ -106,7 +106,7 @@ module.exports = ({ cooler, tribeCrypto = null, tribesModel = null }) => {
|
|||
if (c.tribeId) {
|
||||
try {
|
||||
const key = await tribeKeyFor(c.tribeId);
|
||||
const r = tribeCrypto.decryptContent(JSON.parse(text), key ? [[key]] : []);
|
||||
const r = cryptoModel.decryptContent(JSON.parse(text), key ? [[key]] : []);
|
||||
text = r && !r._undecryptable && typeof r.body === 'string' ? r.body : '';
|
||||
} catch (_) { text = ''; }
|
||||
}
|
||||
|
|
@ -116,15 +116,15 @@ module.exports = ({ cooler, tribeCrypto = null, tribesModel = null }) => {
|
|||
|
||||
const readEntries = async (ssbClient) => {
|
||||
const msgs = await readTyped(ssbClient, [WIKI_TYPE, 'tombstone', ENVELOPE_TYPE], { limit: logLimit });
|
||||
const fpIdx = tribeCrypto && typeof tribeCrypto.buildFingerprintIndex === 'function' ? tribeCrypto.buildFingerprintIndex() : null;
|
||||
const fpIdx = cryptoModel && typeof cryptoModel.buildFingerprintIndex === 'function' ? cryptoModel.buildFingerprintIndex() : null;
|
||||
const out = [];
|
||||
for (const m of msgs) {
|
||||
const v = m && m.value;
|
||||
const c = v && v.content;
|
||||
if (!c || typeof c !== 'object') continue;
|
||||
if (c.type === WIKI_TYPE || c.type === 'tombstone') { out.push(m); continue; }
|
||||
if (!fpIdx || !tribeCrypto.isTribeMsg(c)) continue;
|
||||
const r = tribeCrypto.unwrapMsg(c, fpIdx);
|
||||
if (!fpIdx || !cryptoModel.isSealedMsg(c)) continue;
|
||||
const r = cryptoModel.unwrapMsg(c, fpIdx);
|
||||
if (!r || !r.body || typeof r.body !== 'object') continue;
|
||||
const body = r.body;
|
||||
if (body.k === WIKI_TYPE) {
|
||||
|
|
@ -298,7 +298,7 @@ module.exports = ({ cooler, tribeCrypto = null, tribesModel = null }) => {
|
|||
if (content.tribeId) {
|
||||
const key = await tribeKeyFor(content.tribeId);
|
||||
if (!key) throw new Error('Missing tribe key — cannot publish encrypted wiki page');
|
||||
msg = tribeCrypto.wrapMsg({ k: WIKI_TYPE, ...content }, key);
|
||||
msg = cryptoModel.wrapMsg({ k: WIKI_TYPE, ...content }, key);
|
||||
}
|
||||
return new Promise((resolve, reject) => ssbClient.publish(msg, (err, res) => err ? reject(err) : resolve(res)));
|
||||
};
|
||||
|
|
@ -407,7 +407,7 @@ module.exports = ({ cooler, tribeCrypto = null, tribesModel = null }) => {
|
|||
if (page.tribeId) {
|
||||
const key = await tribeKeyFor(page.tribeId);
|
||||
if (!key) throw new Error('Missing tribe key — cannot delete encrypted wiki page');
|
||||
msg = tribeCrypto.wrapMsg({ k: 'tombstone', target: page.id, deletedAt, author: ssbClient.id }, key);
|
||||
msg = cryptoModel.wrapMsg({ k: 'tombstone', target: page.id, deletedAt, author: ssbClient.id }, key);
|
||||
}
|
||||
return new Promise((resolve, reject) => ssbClient.publish(msg, (err, res) => err ? reject(err) : resolve(res)));
|
||||
},
|
||||
|
|
|
|||
499
src/patches/patch-node-modules.js
Normal file
499
src/patches/patch-node-modules.js
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const verbose = process.argv.includes('--verbose') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
|
||||
const log = (msg) => { if (verbose) console.log(`[OASIS] [PATCH] ${msg}`); };
|
||||
|
||||
// === Patch ssb-ref ===
|
||||
const ssbRefPath = path.resolve(__dirname, '../server/node_modules/ssb-ref/index.js');
|
||||
if (fs.existsSync(ssbRefPath)) {
|
||||
const data = fs.readFileSync(ssbRefPath, 'utf8');
|
||||
const alreadyClean = /exports\.parseAddress\s*=\s*parseAddress/.test(data);
|
||||
if (!alreadyClean) {
|
||||
const patched = data.replace(
|
||||
/exports\.parseAddress\s*=\s*deprecate\(\s*['"][^'"]*['"]\s*,\s*parseAddress\s*\)/,
|
||||
'exports.parseAddress = parseAddress'
|
||||
);
|
||||
if (patched !== data) {
|
||||
fs.writeFileSync(ssbRefPath, patched);
|
||||
log('Patched ssb-ref to remove deprecated usage of parseAddress');
|
||||
} else {
|
||||
log('ssb-ref patch skipped: unexpected parseAddress export format');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log('ssb-ref patch skipped: file not found at ' + ssbRefPath);
|
||||
}
|
||||
|
||||
// === Patch ssb-blobs ===
|
||||
const ssbBlobsPath = path.resolve(__dirname, '../server/node_modules/ssb-blobs/inject.js');
|
||||
if (fs.existsSync(ssbBlobsPath)) {
|
||||
let data = fs.readFileSync(ssbBlobsPath, 'utf8');
|
||||
|
||||
const marker = 'want: function (id, cb)';
|
||||
const startIndex = data.indexOf(marker);
|
||||
if (data.includes('if (wantCallbacks[id]) registerWant(id);')) {
|
||||
log('ssb-blobs already patched');
|
||||
} else if (startIndex !== -1) {
|
||||
const endIndex = data.indexOf('},', startIndex);
|
||||
if (endIndex !== -1) {
|
||||
const before = data.slice(0, startIndex);
|
||||
const after = data.slice(endIndex + 2);
|
||||
|
||||
const replacement = `
|
||||
want: function (id, cb) {
|
||||
id = toBlobId(id);
|
||||
if (!isBlobId(id)) return cb(new Error('invalid id:' + id));
|
||||
|
||||
if (blobStore.isEmptyHash(id)) return cb(null, true);
|
||||
|
||||
if (wantCallbacks[id]) {
|
||||
if (!Array.isArray(wantCallbacks[id])) wantCallbacks[id] = [];
|
||||
wantCallbacks[id].push(cb);
|
||||
} else {
|
||||
wantCallbacks[id] = [cb];
|
||||
blobStore.size(id, function (err, size) {
|
||||
if (err) return cb(err);
|
||||
if (size != null) {
|
||||
while (wantCallbacks[id].length) {
|
||||
const fn = wantCallbacks[id].shift();
|
||||
if (typeof fn === 'function') fn(null, true);
|
||||
}
|
||||
delete wantCallbacks[id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const peerId = findPeerWithBlob(id);
|
||||
if (peerId) get(peerId, id);
|
||||
|
||||
if (wantCallbacks[id]) registerWant(id);
|
||||
},`;
|
||||
|
||||
const finalData = before + replacement + after;
|
||||
fs.writeFileSync(ssbBlobsPath, finalData);
|
||||
log('Patched ssb-blobs to fix wantCallbacks handling');
|
||||
} else {
|
||||
log('ssb-blobs patch skipped: end of want function not found');
|
||||
}
|
||||
} else {
|
||||
log('ssb-blobs patch skipped: want function not found');
|
||||
}
|
||||
}
|
||||
|
||||
// === Patch ssb-db2 (onceWhen leaks its listener when the condition already holds) ===
|
||||
const ssbDb2UtilsPath = path.resolve(__dirname, '../server/node_modules/ssb-db2/utils.js');
|
||||
if (fs.existsSync(ssbDb2UtilsPath)) {
|
||||
const data = fs.readFileSync(ssbDb2UtilsPath, 'utf8');
|
||||
if (!data.includes('answered = true\n cb()\n return false')) {
|
||||
const start = data.indexOf('function onceWhen(obv, filter, cb) {');
|
||||
const end = data.indexOf('\n}\n', start);
|
||||
if (start >= 0 && end > start) {
|
||||
const replacement = `function onceWhen(obv, filter, cb) {
|
||||
if (!obv) return cb()
|
||||
let answered = false
|
||||
obv((x) => {
|
||||
if (answered) return false
|
||||
if (!filter(x)) return
|
||||
answered = true
|
||||
cb()
|
||||
return false
|
||||
})
|
||||
}`;
|
||||
fs.writeFileSync(ssbDb2UtilsPath, data.slice(0, start) + replacement + data.slice(end + 2));
|
||||
log('Patched ssb-db2 utils.js so onceWhen releases its listener once answered');
|
||||
} else {
|
||||
log('ssb-db2 patch skipped: onceWhen not found');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log('ssb-db2 patch skipped: file not found');
|
||||
}
|
||||
|
||||
const ssbDb2PluginPath = path.resolve(__dirname, '../server/node_modules/ssb-db2/indexes/plugin.js');
|
||||
if (fs.existsSync(ssbDb2PluginPath)) {
|
||||
const data = fs.readFileSync(ssbDb2PluginPath, 'utf8');
|
||||
const target = ` this.onRecord = function onRecord(record, isLive, pValue) {`;
|
||||
const flushTarget = ` if (changes > chunkSize) this.flush(thenMaybeReportError)\n else if (isLive) liveFlush(thenMaybeReportError)`;
|
||||
if (!data.includes('lastFlushAt') && data.includes(target) && data.includes(flushTarget)) {
|
||||
fs.writeFileSync(ssbDb2PluginPath, data
|
||||
.replace(target, ` let lastFlushAt = Date.now()\n${target}`)
|
||||
.replace(flushTarget, ` if (changes > chunkSize || Date.now() - lastFlushAt > 1000) {\n lastFlushAt = Date.now()\n this.flush(thenMaybeReportError)\n } else if (isLive) liveFlush(thenMaybeReportError)`));
|
||||
log('Patched ssb-db2 indexes so their progress is saved at least once per second');
|
||||
}
|
||||
} else {
|
||||
log('ssb-db2 index patch skipped: file not found');
|
||||
}
|
||||
|
||||
// === Patch @xenova/transformers (onnxruntime 1.19 Tensor getter) ===
|
||||
const xenovaTensorPath = path.resolve(__dirname, '../AI/node_modules/@xenova/transformers/src/utils/tensor.js');
|
||||
if (fs.existsSync(xenovaTensorPath)) {
|
||||
let data = fs.readFileSync(xenovaTensorPath, 'utf8');
|
||||
if (!data.includes('this.data = args[0].data')) {
|
||||
const patched = data
|
||||
.replace(
|
||||
" if (args[0] instanceof ONNXTensor) {\n // Create shallow copy\n Object.assign(this, args[0]);\n",
|
||||
" if (args[0] instanceof ONNXTensor) {\n // Create shallow copy\n Object.assign(this, args[0]);\n if (this.data === undefined) this.data = args[0].data;\n"
|
||||
)
|
||||
.replace(
|
||||
" args[2]\n ));\n }",
|
||||
" args[2]\n ));\n if (this.data === undefined && this.cpuData !== undefined) this.data = this.cpuData;\n }"
|
||||
);
|
||||
if (patched !== data) {
|
||||
fs.writeFileSync(xenovaTensorPath, patched);
|
||||
log('Patched @xenova/transformers tensor.js for onnxruntime 1.19 data getter');
|
||||
} else {
|
||||
log('@xenova/transformers patch skipped: unexpected tensor.js format');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log('@xenova/transformers patch skipped: file not found');
|
||||
}
|
||||
|
||||
// === Patch ssb-conn (the scheduler prefers up-to-date peers that replicate what we follow) ===
|
||||
const connSchedulerPath = path.resolve(__dirname, '../server/node_modules/ssb-conn/lib/conn-scheduler.js');
|
||||
if (fs.existsSync(connSchedulerPath)) {
|
||||
const data = fs.readFileSync(connSchedulerPath, 'utf8');
|
||||
const best = " .z((peers) => typeof this.ssb.oasisPeerRank === 'function' ? peers.sort((a, b) => this.ssb.oasisPeerRank(b) - this.ssb.oasisPeerRank(a)) : peers)\n";
|
||||
const worst = " .z((peers) => typeof this.ssb.oasisPeerRank === 'function' ? peers.sort((a, b) => this.ssb.oasisPeerRank(a) - this.ssb.oasisPeerRank(b)) : peers)\n";
|
||||
const connectAnchor = " .z(sortByCooldownAscending)\n .z(take(freeSlots))";
|
||||
const rotateAnchor = " .z(sortByOldestConnection)\n .z(take(1))";
|
||||
if (data.includes('this.ssb.oasisPeerRank')) {
|
||||
log('ssb-conn scheduler already patched');
|
||||
} else if (data.includes(connectAnchor) && data.includes(rotateAnchor)) {
|
||||
fs.writeFileSync(connSchedulerPath, data
|
||||
.replace(connectAnchor, " .z(sortByCooldownAscending)\n" + best + " .z(take(freeSlots))")
|
||||
.replace(rotateAnchor, " .z(sortByOldestConnection)\n" + worst + " .z(take(1))"));
|
||||
log('Patched ssb-conn scheduler to rank peers by version and shared replication');
|
||||
} else {
|
||||
log('ssb-conn scheduler patch skipped: unexpected conn-scheduler.js format');
|
||||
}
|
||||
} else {
|
||||
log('ssb-conn scheduler patch skipped: file not found');
|
||||
}
|
||||
|
||||
// === Patch ssb-conn (connection limits apply to the connections we open, never to the ones others open to us) ===
|
||||
const connHubPath = path.resolve(__dirname, '../server/node_modules/ssb-conn-hub/lib/index.js');
|
||||
if (fs.existsSync(connHubPath) && fs.existsSync(connSchedulerPath)) {
|
||||
const hub = fs.readFileSync(connHubPath, 'utf8');
|
||||
const sched = fs.readFileSync(connSchedulerPath, 'utf8');
|
||||
const hubFrom = 'this._setPeer(address, { ...data, state, disconnect });';
|
||||
const hubTo = 'this._setPeer(address, { ...data, state, disconnect, inbound: !isClient });';
|
||||
const schedFrom = 'const peersUp = query.peersConnected().filter(isDesiredPeer);';
|
||||
const schedTo = 'const peersUp = query.peersConnected().filter(isDesiredPeer).filter((p) => !p[1].inbound);';
|
||||
if (hub.includes(hubTo) && sched.includes(schedTo)) {
|
||||
log('ssb-conn inbound limits already patched');
|
||||
} else if ((hub.includes(hubFrom) || hub.includes(hubTo)) && (sched.includes(schedFrom) || sched.includes(schedTo))) {
|
||||
fs.writeFileSync(connHubPath, hub.replace(hubFrom, hubTo));
|
||||
fs.writeFileSync(connSchedulerPath, sched.replace(schedFrom, schedTo));
|
||||
log('Patched ssb-conn so its connection limits only apply to the connections it opens');
|
||||
} else {
|
||||
log('ssb-conn inbound patch skipped: unexpected format');
|
||||
}
|
||||
} else {
|
||||
log('ssb-conn inbound patch skipped: file not found');
|
||||
}
|
||||
|
||||
const CONN_QUOTA = 10;
|
||||
const connQuotaFrom = [4, CONN_QUOTA].map((n) => `this.maintainConnections(${n}, isNotRoom, 'dbAndStaging', null, 2 * HOUR);`);
|
||||
const connQuotaTo = `this.maintainConnections(typeof this.ssb.oasisConnQuota === 'function' ? this.ssb.oasisConnQuota() : ${CONN_QUOTA}, isNotRoom, 'dbAndStaging', null, 2 * HOUR);`;
|
||||
if (fs.existsSync(connSchedulerPath)) {
|
||||
const sched = fs.readFileSync(connSchedulerPath, 'utf8');
|
||||
const from = connQuotaFrom.find((f) => sched.includes(f));
|
||||
if (sched.includes(connQuotaTo)) {
|
||||
log('ssb-conn already takes its outgoing connections from the node settings');
|
||||
} else if (from) {
|
||||
fs.writeFileSync(connSchedulerPath, sched.replace(from, connQuotaTo));
|
||||
log('Patched ssb-conn to take its outgoing connections from the node settings');
|
||||
} else {
|
||||
log('ssb-conn quota patch skipped: unexpected conn-scheduler.js format');
|
||||
}
|
||||
} else {
|
||||
log('ssb-conn quota patch skipped: file not found');
|
||||
}
|
||||
|
||||
// === Patch ssb-lan (broadcast address detection throws without a private IPv4) ===
|
||||
const ssbLanPath = path.resolve(__dirname, '../server/node_modules/ssb-lan/lib/index.js');
|
||||
if (fs.existsSync(ssbLanPath)) {
|
||||
const data = fs.readFileSync(ssbLanPath, 'utf8');
|
||||
if (!data.includes("e.family === 'IPv4' && IP.isPrivate(addr)")) {
|
||||
const start = data.indexOf(' getBroadcastIPs() {');
|
||||
const end = data.indexOf('\n }\n', start);
|
||||
if (start >= 0 && end > start) {
|
||||
const replacement = ` getBroadcastIPs() {
|
||||
if (process.platform === 'ios')
|
||||
return ['255.255.255.255'];
|
||||
try {
|
||||
const details = nonPrivateIP(null, (addr, e) => e.family === 'IPv4' && IP.isPrivate(addr), true);
|
||||
if (!details || details.family !== 'IPv4')
|
||||
return ['255.255.255.255'];
|
||||
const { broadcastAddress } = IP.subnet(details.address, details.netmask);
|
||||
return [broadcastAddress];
|
||||
}
|
||||
catch (err) {
|
||||
debug('LAN broadcast address detection failed: %s', err);
|
||||
return ['255.255.255.255'];
|
||||
}
|
||||
}`;
|
||||
fs.writeFileSync(ssbLanPath, data.slice(0, start) + replacement + data.slice(end + 6));
|
||||
log('Patched ssb-lan to fall back to the global broadcast address');
|
||||
} else {
|
||||
log('ssb-lan patch skipped: getBroadcastIPs not found');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log('ssb-lan patch skipped: file not found');
|
||||
}
|
||||
|
||||
// === Patch ssb-box (map leaks the index as libsodium's output format without native bindings) ===
|
||||
const ssbBoxPath = path.resolve(__dirname, '../server/node_modules/ssb-box/format.js');
|
||||
if (fs.existsSync(ssbBoxPath)) {
|
||||
const data = fs.readFileSync(ssbBoxPath, 'utf8');
|
||||
const target = '.map(sodium.crypto_sign_ed25519_pk_to_curve25519);';
|
||||
if (data.includes(target)) {
|
||||
fs.writeFileSync(ssbBoxPath, data.replace(target, '.map((pk) => sodium.crypto_sign_ed25519_pk_to_curve25519(pk));'));
|
||||
log('Patched ssb-box so recipient keys are converted one argument at a time');
|
||||
}
|
||||
} else {
|
||||
log('ssb-box patch skipped: file not found');
|
||||
}
|
||||
|
||||
// === Patch multiserver (voice frames are small and frequent: send them without Nagle's delay) ===
|
||||
const msNetPath = path.resolve(__dirname, '../server/node_modules/multiserver/plugins/net.js');
|
||||
if (fs.existsSync(msNetPath)) {
|
||||
let data = fs.readFileSync(msNetPath, 'utf8');
|
||||
if (data.includes('setNoDelay(true)')) {
|
||||
log('multiserver already patched');
|
||||
} else {
|
||||
const serverTarget = ` function connectionListener(stream) {
|
||||
onConnection(toDuplex(stream))`;
|
||||
const clientTarget = ` .on('connect', function onConnect() {
|
||||
if (started) return
|
||||
started = true
|
||||
cb(null, toDuplex(stream))`;
|
||||
if (data.includes(serverTarget) && data.includes(clientTarget)) {
|
||||
data = data
|
||||
.replace(serverTarget, ` function connectionListener(stream) {
|
||||
try { stream.setNoDelay(true) } catch (_) {}
|
||||
onConnection(toDuplex(stream))`)
|
||||
.replace(clientTarget, ` .on('connect', function onConnect() {
|
||||
if (started) return
|
||||
started = true
|
||||
try { stream.setNoDelay(true) } catch (_) {}
|
||||
cb(null, toDuplex(stream))`);
|
||||
fs.writeFileSync(msNetPath, data);
|
||||
log('Patched multiserver net plugin to disable Nagle on every connection');
|
||||
} else {
|
||||
log('multiserver patch skipped: unexpected net plugin layout');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log('multiserver patch skipped: file not found');
|
||||
}
|
||||
|
||||
// === Patch hyperaxe (only strings and nodes become children; raw HTML only through a private symbol) ===
|
||||
const hyperaxePath = path.resolve(__dirname, '../server/node_modules/hyperaxe/factory.js');
|
||||
if (fs.existsSync(hyperaxePath)) {
|
||||
let data = fs.readFileSync(hyperaxePath, 'utf8');
|
||||
if (data.includes('oasis.rawHtml')) {
|
||||
log('hyperaxe already patched');
|
||||
} else {
|
||||
const target = ` return function (props) {
|
||||
return isObject(props)
|
||||
? fn(tag, props, sliceKids(arguments, 1))
|
||||
: fn(tag, sliceKids(arguments))
|
||||
}`;
|
||||
if (data.includes(target)) {
|
||||
data = data.replace(target, ` return function (props) {
|
||||
return isObject(props)
|
||||
? fn(tag, cleanProps(props), scrubKids(sliceKids(arguments, 1)))
|
||||
: fn(tag, scrubKids(sliceKids(arguments)))
|
||||
}`);
|
||||
data += `
|
||||
const RAW_HTML = Symbol.for('oasis.rawHtml')
|
||||
function isNodeLike (v) {
|
||||
return !!(v && v.nodeName && v.nodeType)
|
||||
}
|
||||
function scrubKids (arr) {
|
||||
return arr.map(function (v) {
|
||||
if (Array.isArray(v)) return scrubKids(v)
|
||||
if (v !== null && typeof v === 'object' && !isNodeLike(v) && !(v instanceof Date) && !(v instanceof RegExp)) return null
|
||||
return v
|
||||
})
|
||||
}
|
||||
function cleanProps (props) {
|
||||
if (isNodeLike(props)) return props
|
||||
const out = {}
|
||||
for (const k of Object.keys(props)) if (k !== 'innerHTML') out[k] = props[k]
|
||||
if (typeof props[RAW_HTML] === 'string') out.innerHTML = props[RAW_HTML]
|
||||
return out
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(hyperaxePath, data);
|
||||
log('Patched hyperaxe so peer objects never become attributes or raw HTML');
|
||||
} else {
|
||||
log('hyperaxe patch skipped: unexpected factory layout');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log('hyperaxe patch skipped: file not found');
|
||||
}
|
||||
|
||||
// === Patch html-element (standard attributes missing from its list are written instead of dropped) ===
|
||||
const htmlAttributesPath = path.resolve(__dirname, '../server/node_modules/html-element/html-attributes.js');
|
||||
if (fs.existsSync(htmlAttributesPath)) {
|
||||
const data = fs.readFileSync(htmlAttributesPath, 'utf8');
|
||||
const anchor = 'function isStandardAttribute(attrName, tagName) {';
|
||||
const marker = 'OASIS_EXTRA_ATTRIBUTES';
|
||||
if (data.includes(marker)) {
|
||||
log('html-element attributes already patched');
|
||||
} else if (data.includes(anchor)) {
|
||||
const extra = `var OASIS_EXTRA_ATTRIBUTES = {
|
||||
'formmethod': ['input', 'button'],
|
||||
'formenctype': ['input', 'button'],
|
||||
'formnovalidate': ['input', 'button'],
|
||||
'formtarget': ['input', 'button'],
|
||||
'minlength': ['input', 'textarea'],
|
||||
'inputmode': 'GLOBAL',
|
||||
'capture': ['input'],
|
||||
'loading': ['img', 'iframe'],
|
||||
'decoding': ['img'],
|
||||
'crossorigin': ['audio', 'img', 'link', 'script', 'video'],
|
||||
'referrerpolicy': ['a', 'area', 'iframe', 'img', 'link', 'script'],
|
||||
'playsinline': ['video'],
|
||||
'role': 'GLOBAL',
|
||||
'label': ['option', 'optgroup', 'track']
|
||||
};
|
||||
Object.keys(OASIS_EXTRA_ATTRIBUTES).forEach(function (name) {
|
||||
var tags = OASIS_EXTRA_ATTRIBUTES[name];
|
||||
var current = HTML_ATTRIBUTES[name];
|
||||
if (tags === 'GLOBAL' || current === 'GLOBAL') { HTML_ATTRIBUTES[name] = 'GLOBAL'; return; }
|
||||
var set = current instanceof Set ? current : new Set();
|
||||
tags.forEach(function (t) { set.add(t); });
|
||||
HTML_ATTRIBUTES[name] = set;
|
||||
});
|
||||
|
||||
`;
|
||||
fs.writeFileSync(htmlAttributesPath, data.replace(anchor, extra + anchor));
|
||||
log('Patched html-element so standard attributes such as formmethod, minlength and loading are written');
|
||||
} else {
|
||||
log('html-element attributes patch skipped: unexpected html-attributes.js format');
|
||||
}
|
||||
} else {
|
||||
log('html-element attributes patch skipped: file not found');
|
||||
}
|
||||
|
||||
// === Patch ssb-invite (local read of how many uses each invite has and how many are spent) ===
|
||||
const ssbInvitePath = path.resolve(__dirname, '../server/node_modules/ssb-invite/index.js');
|
||||
const ssbInviteManifestPath = path.resolve(__dirname, '../server/node_modules/ssb-invite/manifest.json');
|
||||
if (fs.existsSync(ssbInvitePath) && fs.existsSync(ssbInviteManifestPath)) {
|
||||
const data = fs.readFileSync(ssbInvitePath, 'utf8');
|
||||
const anchor = " }, 'string')\n }\n }\n}";
|
||||
if (data.includes('codesDB.createReadStream')) {
|
||||
log('ssb-invite already patched');
|
||||
} else if (data.includes(anchor)) {
|
||||
const list = ` }, 'string'),
|
||||
list: function (cb) {
|
||||
const out = []
|
||||
let done = false
|
||||
const finish = (err) => { if (done) return; done = true; err ? cb(err) : cb(null, out) }
|
||||
codesDB.createReadStream()
|
||||
.on('data', (d) => { const v = d.value || {}; out.push({ id: d.key, total: +v.total || 0, used: +v.used || 0 }) })
|
||||
.on('error', finish)
|
||||
.on('end', () => finish())
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
fs.writeFileSync(ssbInvitePath, data.replace(anchor, list));
|
||||
const manifest = JSON.parse(fs.readFileSync(ssbInviteManifestPath, 'utf8'));
|
||||
manifest.list = 'async';
|
||||
fs.writeFileSync(ssbInviteManifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
log('Patched ssb-invite with a local list of invite uses');
|
||||
} else {
|
||||
log('ssb-invite patch skipped: unexpected index.js layout');
|
||||
}
|
||||
} else {
|
||||
log('ssb-invite patch skipped: file not found');
|
||||
}
|
||||
|
||||
// === Patch on-wakeup (a busy event loop is not a wake-up: only time the machine spent suspended counts) ===
|
||||
const onWakeupPath = path.resolve(__dirname, '../server/node_modules/on-wakeup/index.js');
|
||||
if (fs.existsSync(onWakeupPath)) {
|
||||
const data = fs.readFileSync(onWakeupPath, 'utf8');
|
||||
const tickFrom = ` var t = Date.now()
|
||||
if (lastSleepCheck && (t - lastSleepCheck) > SLEEP_CHECK_INTERVAL*NUM_MISSABLE_INTERVALS)
|
||||
emitter.emit('wakeup') // missed NUM_MISSABLE_INTERVALS checks, let's run the callbacks
|
||||
lastSleepCheck = t`;
|
||||
const tickTo = ` var t = Date.now()
|
||||
var hr = process.hrtime()
|
||||
var awake = hr[0] * 1e3 + hr[1] / 1e6
|
||||
var gap = t - lastSleepCheck
|
||||
var suspended = process.platform === 'win32' ? gap : gap - (awake - lastAwake)
|
||||
if (lastSleepCheck && gap > SLEEP_CHECK_INTERVAL*NUM_MISSABLE_INTERVALS && suspended > SLEEP_CHECK_INTERVAL*(NUM_MISSABLE_INTERVALS-1))
|
||||
emitter.emit('wakeup')
|
||||
lastSleepCheck = t
|
||||
lastAwake = awake`;
|
||||
const varsFrom = 'var lastSleepCheck = false\n';
|
||||
const varsTo = 'var lastSleepCheck = false\nvar lastAwake = 0\n';
|
||||
if (data.includes('var lastAwake = 0')) {
|
||||
log('on-wakeup already patched');
|
||||
} else if (data.includes(tickFrom) && data.includes(varsFrom)) {
|
||||
fs.writeFileSync(onWakeupPath, data.replace(varsFrom, varsTo).replace(tickFrom, tickTo));
|
||||
log('Patched on-wakeup so a long busy stretch is not taken for a wake-up');
|
||||
} else {
|
||||
log('on-wakeup patch skipped: unexpected index.js layout');
|
||||
}
|
||||
} else {
|
||||
log('on-wakeup patch skipped: file not found');
|
||||
}
|
||||
|
||||
// === Patch ssb-db2 (createHistoryStream returns a feed in arrival order, which is its sequence order, not by timestamp) ===
|
||||
const historyStreamPath = path.resolve(__dirname, '../server/node_modules/ssb-db2/compat/history-stream.js');
|
||||
if (fs.existsSync(historyStreamPath)) {
|
||||
const data = fs.readFileSync(historyStreamPath, 'utf8');
|
||||
const importFrom = "const { author, and, gte, where, batch, toPullStream } = require('../operators')";
|
||||
const importTo = "const { author, and, gte, where, batch, sortByArrival, toPullStream } = require('../operators')";
|
||||
const queryFrom = ' where(query),\n';
|
||||
const queryTo = ' where(query),\n sortByArrival(),\n';
|
||||
if (data.includes(importTo)) {
|
||||
log('ssb-db2 history stream already patched');
|
||||
} else if (data.includes(importFrom) && data.split(queryFrom).length === 3) {
|
||||
fs.writeFileSync(historyStreamPath, data.replace(importFrom, importTo).split(queryFrom).join(queryTo));
|
||||
log('Patched ssb-db2 so createHistoryStream keeps sequence order');
|
||||
} else {
|
||||
log('ssb-db2 history stream patch skipped: unexpected history-stream.js layout');
|
||||
}
|
||||
} else {
|
||||
log('ssb-db2 history stream patch skipped: file not found');
|
||||
}
|
||||
|
||||
// === Patch ssb-invite-client (a pub that already follows us is not a failed invite: keep the pub and finish joining) ===
|
||||
const inviteClientPath = path.resolve(__dirname, '../server/node_modules/ssb-invite-client/lib/index.js');
|
||||
if (fs.existsSync(inviteClientPath)) {
|
||||
const data = fs.readFileSync(inviteClientPath, 'utf8');
|
||||
const useFrom = ` const [e2] = await run(rpc.invite.use)({ feed: this.ssb.id });
|
||||
if (e2)
|
||||
return cb(clarify(e2, 'Invite not accepted by the pub'));
|
||||
if (shouldPublish) {`;
|
||||
const useTo = ` const [e2] = await run(rpc.invite.use)({ feed: this.ssb.id });
|
||||
const knownToPub = !!e2 && /already following/i.test(String((e2 && e2.message) || e2));
|
||||
if (e2 && !knownToPub)
|
||||
return cb(clarify(e2, 'Invite not accepted by the pub'));
|
||||
let followsPub = false;
|
||||
if (knownToPub && this.ssb.friends && typeof this.ssb.friends.isFollowing === 'function') {
|
||||
const [, yes] = await run(this.ssb.friends.isFollowing)({ source: this.ssb.id, dest: parsed.key });
|
||||
followsPub = !!yes;
|
||||
}
|
||||
if (shouldPublish && !followsPub) {`;
|
||||
if (data.includes('knownToPub')) {
|
||||
log('ssb-invite-client already patched');
|
||||
} else if (data.includes(useFrom)) {
|
||||
fs.writeFileSync(inviteClientPath, data.replace(useFrom, useTo));
|
||||
log('Patched ssb-invite-client so a pub that already follows us still gets joined');
|
||||
} else {
|
||||
log('ssb-invite-client patch skipped: unexpected lib/index.js layout');
|
||||
}
|
||||
} else {
|
||||
log('ssb-invite-client patch skipped: file not found');
|
||||
}
|
||||
|
|
@ -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')); pm.setStreamFactory(require('./phone_stream_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')); pm.setScreenFactory(require('./phone_screen_android')); } } catch (_) {} return pm; })())
|
||||
.use(require('./network_pause'));
|
||||
|
||||
if (!config.pub) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { connectionsOf } = require('../configs/config-manager');
|
||||
|
||||
const QUOTA_TTL_MS = 5000;
|
||||
|
||||
const readOasisConfig = () => {
|
||||
try {
|
||||
|
|
@ -15,6 +18,12 @@ module.exports = {
|
|||
manifest: { pause: 'sync', resume: 'sync', paused: 'sync', lastErrors: 'sync' },
|
||||
init(server, config) {
|
||||
server.oasisNetworkPaused = readOasisConfig().networkPaused === true || process.env.OASIS_NETWORK_PAUSED === '1';
|
||||
let quota = connectionsOf(readOasisConfig().connections);
|
||||
let quotaAt = Date.now();
|
||||
server.oasisConnQuota = () => {
|
||||
if (Date.now() - quotaAt > QUOTA_TTL_MS) { quota = connectionsOf(readOasisConfig().connections); quotaAt = Date.now(); }
|
||||
return quota;
|
||||
};
|
||||
const refused = () => new Error('network paused');
|
||||
|
||||
if (server.auth && typeof server.auth.hook === 'function') {
|
||||
|
|
@ -46,18 +55,26 @@ module.exports = {
|
|||
const m = String((ev && ev.address) || '').match(/~shs:([A-Za-z0-9+/=_-]{43,44})/);
|
||||
return canonicalKey((ev && ev.key) || (m ? m[1] : ''));
|
||||
};
|
||||
const majorMinor = (v) => String(v || '').split('.').slice(0, 2).map(n => parseInt(n, 10) || 0);
|
||||
const ownVersion = (() => { try { return String(require('./package.json').version || ''); } catch (_) { return ''; } })();
|
||||
const upToDate = (v) => {
|
||||
const [a1, b1] = majorMinor(v), [a2, b2] = majorMinor(ownVersion);
|
||||
return a1 > a2 || (a1 === a2 && b1 >= b2);
|
||||
const versionParts = (v) => String(v || '').split('.').slice(0, 3).map(n => parseInt(n, 10) || 0);
|
||||
const compareVersions = (a, b) => {
|
||||
const x = versionParts(a), y = versionParts(b);
|
||||
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
|
||||
return 0;
|
||||
};
|
||||
const ownVersion = (() => { try { return String(require('./package.json').version || ''); } catch (_) { return ''; } })();
|
||||
const rank = { versions: new Map(), graph: {}, newest: ownVersion };
|
||||
const versionTier = (v, total) => {
|
||||
if (!v) return total > 0 ? 0 : 2;
|
||||
if (compareVersions(v, rank.newest) >= 0) return 4;
|
||||
const [a1, b1] = versionParts(v), [a2, b2] = versionParts(rank.newest);
|
||||
return a1 === a2 && b1 === b2 ? 3 : 1;
|
||||
};
|
||||
const rank = { versions: new Map(), graph: {} };
|
||||
const refreshRank = () => {
|
||||
const pull = require('pull-stream');
|
||||
const latest = new Map();
|
||||
const done = () => {
|
||||
rank.versions = new Map([...latest].map(([k, m]) => [k, m.version]));
|
||||
rank.newest = [...rank.versions.values()].reduce((n, v) => (compareVersions(v, n) > 0 ? v : n), ownVersion);
|
||||
try { server.friends.graph((err, g) => { if (!err && g) rank.graph = g; }); } catch (_) {}
|
||||
};
|
||||
try {
|
||||
|
|
@ -83,9 +100,8 @@ module.exports = {
|
|||
total++;
|
||||
if (mine[k] >= 0) shared++;
|
||||
}
|
||||
const v = rank.versions.get(key);
|
||||
const tier = v ? (upToDate(v) ? 2 : 0) : (total > 0 ? 0 : 1);
|
||||
return tier * 1e9 + Math.min(shared, 99999) * 1e4 + Math.min(total, 9999);
|
||||
const carriesMe = follows[server.id] >= 0 ? 1 : 0;
|
||||
return carriesMe * 1e12 + versionTier(rank.versions.get(key), total) * 1e9 + Math.min(shared, 99999) * 1e4 + Math.min(total, 9999);
|
||||
};
|
||||
server.oasisPeerRank = peerRank;
|
||||
setImmediate(refreshRank);
|
||||
|
|
|
|||
11
src/server/package-lock.json
generated
11
src/server/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@krakenslab/oasis",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@krakenslab/oasis",
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
|
@ -31,6 +31,7 @@
|
|||
"minimist": "^1.2.8",
|
||||
"module-alias": "^2.2.3",
|
||||
"moment": "^2.30.1",
|
||||
"mupdf": "^1.28.1",
|
||||
"open": "^8.4.2",
|
||||
"openpgp": "^6.3.0",
|
||||
"opusscript": "^0.1.1",
|
||||
|
|
@ -4961,6 +4962,12 @@
|
|||
"non-private-ip": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mupdf": {
|
||||
"version": "1.28.1",
|
||||
"resolved": "https://registry.npmjs.org/mupdf/-/mupdf-1.28.1.tgz",
|
||||
"integrity": "sha512-Gi11Ow2G1SlrXKJNZBL1eAIGFVih5+4ZKqjptamTVaj/5hnlrcVrVbyb7lHE2lfFKdxZPyv9ZtfOOq7XgjzEig==",
|
||||
"license": "AGPL-3.0-or-later"
|
||||
},
|
||||
"node_modules/mutexify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/mutexify/-/mutexify-1.4.0.tgz",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@krakenslab/oasis",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.5",
|
||||
"description": "Oasis - Social Networking Utopia",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
"start:ssb": "node SSB_server.js start &",
|
||||
"dev": "npm run dev:backend",
|
||||
"dev:backend": "npx --yes nodemon@3.1.14",
|
||||
"postinstall": "node ../../scripts/patch-node-modules.js"
|
||||
"postinstall": "node ../patches/patch-node-modules.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@koa/router": "^13.1.0",
|
||||
|
|
@ -42,6 +42,7 @@
|
|||
"minimist": "^1.2.8",
|
||||
"module-alias": "^2.2.3",
|
||||
"moment": "^2.30.1",
|
||||
"mupdf": "^1.28.1",
|
||||
"open": "^8.4.2",
|
||||
"openpgp": "^6.3.0",
|
||||
"opusscript": "^0.1.1",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { spawn, execFile } = require('child_process');
|
||||
const nodeCrypto = require('crypto');
|
||||
const pull = require('./node_modules/pull-stream');
|
||||
const Pushable = require('./node_modules/pull-pushable');
|
||||
|
|
@ -64,6 +64,10 @@ 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 SCREEN_SIZE = { w: 1280, h: 720 };
|
||||
const SCREEN_FPS = 2;
|
||||
const SCREEN_QUALITY = 12;
|
||||
const SCREEN_PROBE_MS = 5000;
|
||||
const GRID_TILE_LARGE = { w: 640, h: 480 };
|
||||
const GRID_LARGE_MAX = 4;
|
||||
const ROOM_VIDEO_CHUNK_EXTRA = 45;
|
||||
|
|
@ -245,46 +249,76 @@ const STREAM_BACKENDS = [
|
|||
];
|
||||
const STREAM_STALL_MS = 5000;
|
||||
const STREAM_START_MS = 20000;
|
||||
const frameProcess = ({ bin, args, retry }, { 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 = (first) => {
|
||||
const p = spawn(bin, args(first), { 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) onPcm(pcm); });
|
||||
}
|
||||
p.on('close', () => {
|
||||
if (ended || proc !== p) return;
|
||||
if (first && !frames && retry) return run(false);
|
||||
finish();
|
||||
});
|
||||
};
|
||||
run(true);
|
||||
return { stop() { if (!ended) halt(); } };
|
||||
};
|
||||
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(); } };
|
||||
open(file, handlers) {
|
||||
return frameProcess({ bin: backend.bin(), args: (withAudio) => backend.args(file, withAudio), retry: !!backend.retryWithoutAudio }, handlers);
|
||||
}
|
||||
};
|
||||
};
|
||||
let streamFactory = systemStream;
|
||||
|
||||
const x11Screen = (env) => (env.DISPLAY && !env.WAYLAND_DISPLAY && String(env.XDG_SESSION_TYPE || '').toLowerCase() !== 'wayland'
|
||||
? { device: 'x11grab', input: ['-f', 'x11grab', '-framerate', String(SCREEN_FPS), '-i', env.DISPLAY] }
|
||||
: null);
|
||||
const SCREEN_SOURCES = {
|
||||
win32: () => ({ device: 'gdigrab', input: ['-f', 'gdigrab', '-framerate', String(SCREEN_FPS), '-i', 'desktop'] }),
|
||||
darwin: () => ({ device: 'avfoundation', input: ['-f', 'avfoundation', '-capture_cursor', '1', '-i', 'Capture screen 0:none'] }),
|
||||
android: () => null
|
||||
};
|
||||
const screenSource = (platform = process.platform, env = process.env) => (SCREEN_SOURCES[platform] || x11Screen)(env || {});
|
||||
const screenArgs = (source) => [
|
||||
'-loglevel', 'quiet', ...source.input,
|
||||
'-vf', `fps=${SCREEN_FPS},scale=${SCREEN_SIZE.w}:${SCREEN_SIZE.h}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${SCREEN_SIZE.w}:${SCREEN_SIZE.h}:(ow-iw)/2:(oh-ih)/2`,
|
||||
'-c:v', 'mjpeg', '-q:v', String(SCREEN_QUALITY), '-f', 'image2pipe', 'pipe:1'
|
||||
];
|
||||
const systemScreen = () => {
|
||||
const source = screenSource();
|
||||
const bin = process.env.OASIS_FFMPEG || 'ffmpeg';
|
||||
if (!source || !hasBin(bin)) return null;
|
||||
let ready = false;
|
||||
execFile(bin, ['-hide_banner', '-devices'], { timeout: SCREEN_PROBE_MS }, (err, out) => { ready = !err && String(out || '').split(/\s+/).includes(source.device); });
|
||||
return {
|
||||
available: () => ready,
|
||||
open(handlers) { return frameProcess({ bin, args: () => screenArgs(source), retry: false }, { onJpeg: handlers.onJpeg, onEnd: handlers.onEnd }); }
|
||||
};
|
||||
};
|
||||
let screenFactory = systemScreen;
|
||||
|
||||
const videoChunks = (seq, jpeg, track = 0) => {
|
||||
const n = Math.ceil(jpeg.length / VIDEO_CHUNK);
|
||||
const out = [];
|
||||
|
|
@ -526,6 +560,7 @@ module.exports = {
|
|||
const roomQuietAt = Number(policy().roomQuietAt) > 0 ? Number(policy().roomQuietAt) : ROOM_QUIET_AT;
|
||||
const video = videoFactory(server, policy().videoDevice);
|
||||
const streamer = streamFactory(server);
|
||||
const screener = screenFactory(server);
|
||||
const videoAllowed = () => policy().video !== false;
|
||||
const videoOn = () => !!video && videoAllowed();
|
||||
const codecsOffered = () => [...(OpusScript && policy().opus !== false ? ['opus'] : []), ...(videoOn() ? ['mjpeg'] : [])];
|
||||
|
|
@ -1120,6 +1155,7 @@ module.exports = {
|
|||
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,
|
||||
sharingScreen: !!(r.stream && r.stream.screen), screenAvailable: screenOn() && 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, film: now - (p.filmAt || 0) < VIDEO_HOLD_MS, viewer: !!p.viewer }))
|
||||
|
|
@ -1396,6 +1432,7 @@ module.exports = {
|
|||
r.out.push(Buffer.concat([Buffer.from([K_VIDEO]), Buffer.from(r.kid, 'hex'), r.sealer(plain)]));
|
||||
};
|
||||
const fileStreamOn = () => videoAllowed() && !!streamer;
|
||||
const screenOn = () => videoAllowed() && !!screener && (typeof screener.available !== 'function' || !!screener.available());
|
||||
const streamFile = (file) => {
|
||||
const raw = String(file || '');
|
||||
if (!path.isAbsolute(raw)) return null;
|
||||
|
|
@ -1427,13 +1464,12 @@ module.exports = {
|
|||
if (notify) roomChanged();
|
||||
return true;
|
||||
};
|
||||
const startRoomStream = (r, file, name) => {
|
||||
if (!r || r.phase !== 'live' || !r.hubVideo || !fileStreamOn()) return false;
|
||||
const startFilm = (r, name, screen, open) => {
|
||||
stopRoomStream(r, false);
|
||||
const s = { name: String(name || '').slice(0, 120), pcm: Buffer.alloc(0), seq: 0, handle: null };
|
||||
const s = { name: String(name || '').slice(0, 120), screen, pcm: Buffer.alloc(0), seq: 0, handle: null };
|
||||
r.stream = s;
|
||||
try {
|
||||
s.handle = streamer.open(file, {
|
||||
s.handle = open({
|
||||
onJpeg: (jpeg) => {
|
||||
if (r.stream !== s || room !== r || !Buffer.isBuffer(jpeg) || jpeg.length > VIDEO_FRAME_MAX) return;
|
||||
s.seq = (s.seq + 1) & 0xffff;
|
||||
|
|
@ -1442,7 +1478,7 @@ module.exports = {
|
|||
sendRoomJpeg(r, s.seq, jpeg, 1);
|
||||
},
|
||||
onPcm: (pcm) => {
|
||||
if (r.stream !== s || !pcm || !pcm.length) return;
|
||||
if (r.stream !== s || screen || !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);
|
||||
|
|
@ -1453,6 +1489,9 @@ module.exports = {
|
|||
if (!s.handle || r.stream !== s) { if (r.stream === s) r.stream = null; return false; }
|
||||
return true;
|
||||
};
|
||||
const filmReady = (r) => !!r && r.phase === 'live' && !!r.hubVideo;
|
||||
const startRoomStream = (r, file, name) => filmReady(r) && fileStreamOn() && startFilm(r, name, false, (h) => streamer.open(file, h));
|
||||
const startRoomScreen = (r) => filmReady(r) && screenOn() && startFilm(r, '', true, (h) => screener.open(h));
|
||||
const streamFrame = (r) => {
|
||||
const s = r.stream;
|
||||
if (!s || s.pcm.length < FRAME_BYTES) return null;
|
||||
|
|
@ -1798,6 +1837,11 @@ module.exports = {
|
|||
const r = room;
|
||||
if (!r || r.phase !== 'live') return cb(new Error('no-room'));
|
||||
if (o.stop) { stopRoomStream(r); return cb(null, roomSnapshot()); }
|
||||
if (o.screen) {
|
||||
if (!startRoomScreen(r)) return cb(new Error('unavailable'));
|
||||
roomChanged();
|
||||
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'));
|
||||
|
|
@ -2108,6 +2152,8 @@ 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.setScreenFactory = (factory) => { screenFactory = typeof factory === 'function' ? factory : systemScreen; };
|
||||
module.exports.screenSource = screenSource;
|
||||
module.exports.jpegSplitter = jpegSplitter;
|
||||
module.exports.VIDEO_CHUNK = VIDEO_CHUNK;
|
||||
module.exports.VIDEO_HOLD_MS = VIDEO_HOLD_MS;
|
||||
|
|
|
|||
36
src/server/phone_screen_android.js
Normal file
36
src/server/phone_screen_android.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
const fs = require('fs');
|
||||
const net = require('net');
|
||||
const path = require('path');
|
||||
const { jpegSplitter } = require('./phone_module');
|
||||
|
||||
const MARKER = path.join(__dirname, '..', '..', '.audio_bridge');
|
||||
|
||||
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 = () => ({
|
||||
available: () => bridgePort() > 0,
|
||||
open({ onJpeg, onEnd }) {
|
||||
const port = bridgePort();
|
||||
if (!port) return null;
|
||||
let ended = false;
|
||||
const finish = () => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
try { onEnd(); } catch (_) {}
|
||||
};
|
||||
const sock = net.connect(port, '127.0.0.1');
|
||||
sock.setNoDelay(true);
|
||||
sock.on('error', finish);
|
||||
sock.on('close', finish);
|
||||
sock.on('connect', () => sock.write('C'));
|
||||
sock.on('data', jpegSplitter((jpeg) => { if (!ended) onJpeg(jpeg); }));
|
||||
return { stop() { ended = true; try { sock.destroy(); } catch (_) {} } };
|
||||
}
|
||||
});
|
||||
|
|
@ -46,6 +46,14 @@ config.blobs.max = 75 * megabyte;
|
|||
|
||||
config.db2 = { automigrate: false, dangerouslyKillFlumeWhenMigrated: false, ...(config.db2 || {}) };
|
||||
|
||||
const envPort = Number(process.env.ssb_port);
|
||||
if (Number.isInteger(envPort) && envPort > 0 && envPort < 65536) {
|
||||
config.port = envPort;
|
||||
for (const entry of (config.connections && config.connections.incoming && config.connections.incoming.net) || []) {
|
||||
if (entry && typeof entry === 'object') entry.port = envPort;
|
||||
}
|
||||
}
|
||||
|
||||
const lanOn = (() => { try { return require('../configs/config-manager').getConfig().lanBroadcasting !== false; } catch (_) { return true; } })();
|
||||
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) || []) {
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ async function checkForUpdate() {
|
|||
await updater.getRemoteVersion();
|
||||
}
|
||||
|
||||
async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, httpHost = 'localhost', offline = false, isPublic = false) {
|
||||
async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, httpHost = 'localhost', isPublic = false) {
|
||||
if (printed) return;
|
||||
printed = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const { div, h2, p, section, button, form, a, input, img, textarea, br, span, video: videoHyperaxe, audio: audioHyperaxe, table, tr, td, th, details, summary } = require("../server/node_modules/hyperaxe");
|
||||
const RAW_HTML = Symbol.for('oasis.rawHtml');
|
||||
const { template, i18n, paged, userLink, userLinkLabel, renderStateChip, renderPhoneChip, renderSpreadButton, renderContentActions, renderVotesSummary, renderModuleStats, renderCardMetaRow, renderTorrentDownload, renderTorrentSourceDownload, renderFileDownloads, torrentDownloadHref, CONTENT_FAV_KIND, CONTENT_SPREADABLE, CONTENT_TORRENTABLE, contentDeleteAction } = require('./main_views');
|
||||
const { template, i18n, paged, userLink, userLinkLabel, renderStateChip, renderPhoneChip, renderSpreadButton, renderContentActions, renderCardTop, renderShareButton, renderOpinionsPill, renderEngagement, commentCountFor, renderVotesSummary, renderModuleStats, renderCardMetaRow, renderTorrentDownload, renderTorrentSourceDownload, renderFileDownloads, torrentDownloadHref, CONTENT_FAV_KIND, CONTENT_SPREADABLE, CONTENT_TORRENTABLE, contentDeleteAction, renderPdfDocument, forumReplyAnchor, renderCommentsSummary, commentsOpenHref } = require('./main_views');
|
||||
const opinionCategories = require('../backend/opinion_categories');
|
||||
const { roomNumberOf } = require('../models/phone_number');
|
||||
const mobileUi = require("./mobile_ui");
|
||||
|
|
@ -200,7 +200,9 @@ function buildActivityItemsWithPostThreads(deduped, allActions) {
|
|||
.sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
||||
|
||||
if (!rootAction) continue;
|
||||
const rootType = rootAction.type || 'post';
|
||||
const rootUnknown = !byId.has(threadId) && getThreadIdFromPost(rootAction) !== safeMsgId(rootAction);
|
||||
const located = rootUnknown ? commentOf(rootAction) : null;
|
||||
const rootType = rootUnknown ? ((located && located.rootType) || 'comment') : (rootAction.type || 'post');
|
||||
out.push({
|
||||
id: `thread:${threadId}`,
|
||||
type: 'postThread',
|
||||
|
|
@ -209,7 +211,7 @@ function buildActivityItemsWithPostThreads(deduped, allActions) {
|
|||
content: {
|
||||
threadId,
|
||||
rootType,
|
||||
rootHref: rootType === 'post' ? `/blogs/${encodeURIComponent(threadId)}` : getViewDetailsAction(rootType, rootAction),
|
||||
rootHref: rootType === 'post' ? `/blogs/${encodeURIComponent(threadId)}` : rootUnknown ? commentHref({ rootId: threadId, rootType: located && located.rootType }) : getViewDetailsAction(rootType, rootAction),
|
||||
root: rootAction
|
||||
? {
|
||||
id: safeMsgId(rootAction),
|
||||
|
|
@ -256,12 +258,15 @@ const renderTribeOpenedCard = (action, date, authorName) => {
|
|||
: null;
|
||||
const bookmarkHref = c.mediaType === 'bookmark' ? safeExternalHref(c.url) : '';
|
||||
return div({ class: 'trending-card' },
|
||||
div({ class: 'card-header activity-card-header' },
|
||||
div({ class: 'card-chips-row' },
|
||||
renderCardTop(null, origin.href, {
|
||||
author: action.author,
|
||||
authorName,
|
||||
date: action.ts,
|
||||
chips: [div({ class: 'card-chips-row' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-whole' }, span({ class: 'pm-exposition-text' }, label)),
|
||||
renderTribeOriginChip(origin)
|
||||
)
|
||||
),
|
||||
)]
|
||||
}),
|
||||
div({ class: 'card-section' },
|
||||
c.title ? h2(origin.href ? a({ href: origin.href }, c.title) : c.title) : null,
|
||||
c.description ? p({ class: 'post-text post-text-pre' }, ...renderStyledText(String(c.description).slice(0, 600))) : null,
|
||||
|
|
@ -269,10 +274,7 @@ const renderTribeOpenedCard = (action, date, authorName) => {
|
|||
media,
|
||||
bookmarkHref ? a({ href: bookmarkHref, target: '_blank', rel: 'noopener noreferrer' }, c.url) : null
|
||||
),
|
||||
p({ class: 'card-footer' },
|
||||
span({ class: 'date-link' }, `${date}`),
|
||||
userLink(action.author, authorName)
|
||||
)
|
||||
origin.href ? renderCardMetaRow(renderShareButton(safeMsgId(action.id || action.key), origin.href, c.title || '')) : null
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -404,6 +406,8 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
headerText = `[${String(i18n.typePodcast || 'PODCAST').toUpperCase()} · ${String(i18n.typePodcastEpisode || 'EPISODE').toUpperCase()}]`;
|
||||
} else if (type === 'larpHousePost') {
|
||||
headerText = `[${String(i18n.typeLarp || 'L.A.R.P.').toUpperCase()}]`;
|
||||
} else if (type === 'post' && commentOf(action)) {
|
||||
headerText = `[${String(commentLabel(commentOf(action))).toUpperCase()}]`;
|
||||
} else {
|
||||
const typeLabel = i18n[`type${capitalize(type)}`] || type;
|
||||
headerText = `[${String(typeLabel).toUpperCase()}]`;
|
||||
|
|
@ -417,7 +421,7 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
const commentCount =
|
||||
typeof action.commentCount === 'number'
|
||||
? action.commentCount
|
||||
: (typeof content.commentCount === 'number' ? content.commentCount : 0);
|
||||
: (typeof content.commentCount === 'number' ? content.commentCount : commentCountFor([action.id, action.rootId, action.key]));
|
||||
|
||||
const votesList = votes && typeof votes === 'object'
|
||||
? Object.entries(votes).map(([option, count]) => ({ option, count }))
|
||||
|
|
@ -517,7 +521,7 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
img({ src: `/assets/larp/images/${encodeURIComponent(String(house).toLowerCase())}.jpg`, alt: house, class: 'larp-house-mini' }),
|
||||
span(`L.A.R.P · ${String(house).toUpperCase()}`)
|
||||
) : null,
|
||||
text ? p({ class: 'post-text post-text-pre' }, ...renderStyledText(String(text))) : null
|
||||
text ? p({ class: 'post-text post-text-pre' }, ...renderStyledText(String(text), { zoomImages: true })) : null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -699,11 +703,7 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
cardBody.push(
|
||||
div({ class: 'card-section document' },
|
||||
title?.trim() ? h2({ class: 'document-title' }, title) : "",
|
||||
div({
|
||||
id: `pdf-container-${key || url}`,
|
||||
class: 'pdf-viewer-container',
|
||||
'data-pdf-url': `/blob/${encodeURIComponent(url)}`
|
||||
})
|
||||
renderPdfDocument(`/blob/${encodeURIComponent(url)}`, { title })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -813,13 +813,14 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
bodyNode = div({ class: 'feed-text post-text post-text-clamped', [RAW_HTML]: sanitizeHtml(linkified) });
|
||||
} else {
|
||||
bodyNode = div({ class: 'feed-text' },
|
||||
p({ class: 'post-text post-text-pre post-text-clamped' }, ...renderStyledText(displayText))
|
||||
p({ class: 'post-text post-text-pre post-text-clamped' }, ...renderStyledText(displayText, { zoomImages: true }))
|
||||
);
|
||||
}
|
||||
const threadId = getThreadIdFromPost(action);
|
||||
const replyToId = getReplyToIdFromPost(action, byIdAll);
|
||||
const isReply = !!(threadId && threadId !== action.id);
|
||||
const ctxHref = isReply ? `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(replyToId || threadId)}` : '';
|
||||
const onContent = commentOf(action) && commentOf(action).rootType && commentOf(action).rootType !== 'post' ? commentOf(action) : null;
|
||||
const ctxHref = !isReply ? '' : onContent ? commentHref({ rootId: threadId, rootType: onContent.rootType }) : `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(replyToId || threadId)}`;
|
||||
const parent = isReply ? (byIdAll.get(replyToId) || byIdAll.get(threadId)) : null;
|
||||
const parentContent = parent ? (parent.value?.content || parent.content || {}) : {};
|
||||
const parentAuthor = parent?.author || '';
|
||||
|
|
@ -853,10 +854,8 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
const rootType = c.rootType || 'post';
|
||||
const isBlogThread = rootType === 'post';
|
||||
const baseHref = c.rootHref || `/blogs/${encodeURIComponent(threadId)}`;
|
||||
const href = isBlogThread ? baseHref : `${baseHref}${baseHref.includes('?') ? '&' : '?'}comments=open#comments-latest`;
|
||||
const rootTypeLabel = isBlogThread
|
||||
? (i18n.typePost || 'BLOG')
|
||||
: (i18n[`type${rootType.charAt(0).toUpperCase()}${rootType.slice(1)}`] || rootType);
|
||||
const href = isBlogThread || baseHref.includes('comments=open') ? baseHref : `${baseHref}${baseHref.includes('?') ? '&' : '?'}comments=open#comments-latest`;
|
||||
const threadLabel = rootType === 'comment' ? i18n.typeComment : `${rootTypeLabel(rootType)} · ${i18n.typeComment}`;
|
||||
const root = c.root;
|
||||
const replies = Array.isArray(c.replies) ? c.replies : [];
|
||||
const repliesAsc = replies.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
||||
|
|
@ -866,19 +865,20 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
? (titleSource.length > 90 ? `${titleSource.slice(0, 90)}…` : titleSource)
|
||||
: threadId;
|
||||
return div({ class: 'trending-card post-thread' + (String(action.author) === String(userId) ? ' own-content' : '') },
|
||||
div({ class: 'card-header activity-card-header' },
|
||||
div({ class: 'card-chips-row' },
|
||||
renderCardTop(threadId, baseHref, {
|
||||
bylineAuthor: action.author,
|
||||
authorName: action.authorNames && action.authorNames[action.author],
|
||||
date: action.ts,
|
||||
chips: [div({ class: 'card-chips-row' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-whole' },
|
||||
span({ class: 'pm-exposition-text' }, `${String(rootTypeLabel).toUpperCase()} · ${String(i18n.activityUpdateLabel || 'UPDATE').toUpperCase()}`)
|
||||
span({ class: 'pm-exposition-text' }, String(threadLabel).toUpperCase())
|
||||
)
|
||||
),
|
||||
renderContentActions(threadId, baseHref, {
|
||||
author: c.rootAuthor || (root && root.author) || action.author,
|
||||
reportTitle: titleText,
|
||||
spread: spreadMap.get(threadId) || null,
|
||||
...favOptsFor(rootType, threadId, extras)
|
||||
})
|
||||
),
|
||||
)],
|
||||
author: c.rootAuthor || (root && root.author) || action.author,
|
||||
reportTitle: titleText,
|
||||
spread: spreadMap.get(threadId) || null,
|
||||
...favOptsFor(rootType, threadId, extras)
|
||||
}),
|
||||
div({ class: 'card-body' },
|
||||
div({ class: 'card-section' },
|
||||
div({ class: 'card-field activity-update-title' },
|
||||
|
|
@ -887,15 +887,12 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
),
|
||||
latest && latest.text
|
||||
? div({ class: 'feed-text activity-update-msg' },
|
||||
p({ class: 'post-text post-text-pre' }, ...renderStyledText(latest.text))
|
||||
p({ class: 'post-text post-text-pre' }, ...renderStyledText(latest.text, { zoomImages: true }))
|
||||
)
|
||||
: ''
|
||||
)
|
||||
),
|
||||
p({ class: 'card-footer' },
|
||||
span({ class: 'date-link' }, `${action.ts ? moment(action.ts).format("YYYY/MM/DD HH:mm") : ''}`),
|
||||
userLink(action.author, action.authorNames && action.authorNames[action.author])
|
||||
)
|
||||
renderCardMetaRow(renderShareButton(threadId, baseHref, titleText))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -910,19 +907,20 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
const limit = 2;
|
||||
const show = repliesAsc.slice(Math.max(0, repliesAsc.length - limit));
|
||||
return div({ class: 'trending-card post-thread chat-thread' + (String(action.author) === String(userId) ? ' own-content' : '') },
|
||||
div({ class: 'card-header activity-card-header' },
|
||||
div({ class: 'card-chips-row' },
|
||||
renderCardTop(chatRoot, href, {
|
||||
bylineAuthor: action.author,
|
||||
authorName: action.authorNames && action.authorNames[action.author],
|
||||
date: action.ts,
|
||||
chips: [div({ class: 'card-chips-row' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-whole' },
|
||||
span({ class: 'pm-exposition-text' }, `${String(i18n.typeChat || 'CHAT').toUpperCase()} \u00b7 ${String(i18n.activityUpdateLabel || 'UPDATE').toUpperCase()}`)
|
||||
)
|
||||
),
|
||||
renderContentActions(chatRoot, href, {
|
||||
author: c.chatAuthor || action.author,
|
||||
reportTitle: chatTitle,
|
||||
spread: spreadMap.get(chatRoot) || null,
|
||||
...favOptsFor('chat', chatRoot, extras)
|
||||
})
|
||||
),
|
||||
)],
|
||||
author: c.chatAuthor || action.author,
|
||||
reportTitle: chatTitle,
|
||||
spread: spreadMap.get(chatRoot) || null,
|
||||
...favOptsFor('chat', chatRoot, extras)
|
||||
}),
|
||||
div({ class: 'card-body' },
|
||||
div({ class: 'card-section chat' },
|
||||
div({ class: 'card-field activity-update-title' },
|
||||
|
|
@ -947,13 +945,13 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
: renderZoomableImage(attSrc, { imgClass: 'post-image' }))
|
||||
: renderMediaBlob(latest.image);
|
||||
const textNode = latest.text
|
||||
? p({ class: 'post-text post-text-pre' }, ...(latest.poll ? [span({ class: 'card-label' }, `${String(i18n.pollInChat || 'Poll').toUpperCase()}: `)] : []), ...renderStyledText(latest.text))
|
||||
? p({ class: 'post-text post-text-pre' }, ...(latest.poll ? [span({ class: 'card-label' }, `${String(i18n.pollInChat || 'Poll').toUpperCase()}: `)] : []), ...renderStyledText(latest.text, { zoomImages: true }))
|
||||
: null;
|
||||
const quoted = latest.reply || null;
|
||||
const replyNode = quoted
|
||||
? div({ class: 'reply-context' },
|
||||
span({ class: 'reply-context-meta' },
|
||||
a({ href: `${href}#msg-${String(quoted.id || '').replace(/[^a-zA-Z0-9]/g, '')}`, class: 'tag-link' }, i18n.inReplyTo || 'IN REPLY TO'),
|
||||
a({ href: `${href}#${forumReplyAnchor(quoted.id)}`, class: 'tag-link' }, i18n.inReplyTo || 'IN REPLY TO'),
|
||||
quoted.author ? span(' ', userLink(quoted.author, action.authorNames && action.authorNames[quoted.author])) : ''
|
||||
),
|
||||
quoted.text ? p({ class: 'post-text reply-context-text' }, quoted.text) : ''
|
||||
|
|
@ -965,10 +963,7 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
})()
|
||||
)
|
||||
),
|
||||
p({ class: 'card-footer' },
|
||||
span({ class: 'date-link' }, `${action.ts ? moment(action.ts).format("YYYY/MM/DD HH:mm") : ''}`),
|
||||
userLink(action.author, action.authorNames && action.authorNames[action.author])
|
||||
)
|
||||
renderCardMetaRow(renderShareButton(chatRoot, href, chatTitle))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1001,13 +996,13 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
div(
|
||||
{ class: 'reply-context' },
|
||||
span({ class: 'reply-context-meta' },
|
||||
a({ href: `/forum/${encodeURIComponent(hrefKey)}#msg-${String(action.tipId || action.id || '').replace(/[^a-zA-Z0-9]/g, '')}`, class: 'tag-link' }, repliedAuthor ? i18n.inReplyTo : i18n.forumInThread),
|
||||
a({ href: `/forum/${encodeURIComponent(hrefKey)}#${forumReplyAnchor(action.tipId || action.id)}`, class: 'tag-link' }, repliedAuthor ? i18n.inReplyTo : i18n.forumInThread),
|
||||
repliedAuthor ? span(' ', userLink(repliedAuthor, repliedName)) : ''
|
||||
),
|
||||
parentTitle ? p({ class: 'post-text reply-context-text' }, parentTitle) : ''
|
||||
),
|
||||
div({ class: 'card-field forum-reply-body' },
|
||||
p({ class: 'forum-reply-text' }, ...renderStyledText(text))
|
||||
p({ class: 'forum-reply-text' }, ...renderStyledText(text, { zoomImages: true }))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
|
@ -1050,7 +1045,7 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
spreadTitle ? h2({ class: 'post-title activity-spread-title' }, spreadTitle) : '',
|
||||
spreadContentWarning ? h2({ class: 'content-warning' }, spreadContentWarning) : '',
|
||||
spreadExcerpt
|
||||
? div({ class: 'post-text activity-spread-text post-text-pre activity-spread-clamped' }, ...renderStyledText(spreadExcerpt))
|
||||
? div({ class: 'post-text activity-spread-text post-text-pre activity-spread-clamped' }, ...renderStyledText(spreadExcerpt, { zoomImages: true }))
|
||||
: div({ class: 'post-text activity-spread-text activity-spread-missing' }, i18n.spreadContentUnavailable || 'Content not yet available (pending replication)'),
|
||||
spreadOriginalAuthor
|
||||
? div({ class: 'card-field' },
|
||||
|
|
@ -1315,7 +1310,6 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
cardBody.push(
|
||||
div({ class: 'card-section' },
|
||||
div({ class: 'card-field' }, emergencyKey ? a({ href: `/emergencies/${encodeURIComponent(emergencyKey)}`, class: 'card-value user-link' }, emergencyTitle || emergencyKey) : span({ class: 'card-value' }, emergencyTitle || '')),
|
||||
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.statusLabel || 'Status') + ':'), span({ class: 'card-value' }, String(content.status).toUpperCase())) : '',
|
||||
content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.emergencyCategoryLabel || 'Category') + ':'), span({ class: 'card-value' }, String(content.category).toUpperCase())) : '',
|
||||
renderMediaBlob(content.text, null),
|
||||
stripMediaMarkdown(content.text) ? p({ class: 'tribe-description' }, ...renderStyledText(stripMediaMarkdown(content.text))) : ''
|
||||
|
|
@ -1526,19 +1520,20 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
.replace('%ACTION%', () => `<strong>${escapeHtml(actionWord)}</strong>`);
|
||||
|
||||
return div({ class: 'card card-rpg' },
|
||||
div({ class: 'card-header' },
|
||||
h2({ class: 'card-label' }, `[${(i18n.typeProject || 'PROJECT').toUpperCase()}]`),
|
||||
form({ method: "GET", action: `/projects/${encodeURIComponent(action.tipId || action.id)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
|
||||
)
|
||||
),
|
||||
renderCardTop(null, `/projects/${encodeURIComponent(action.tipId || action.id)}`, {
|
||||
author: action.author,
|
||||
authorName: action.authorNames && action.authorNames[action.author],
|
||||
date: action.ts,
|
||||
chips: [div({ class: 'card-chips-row' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-whole' },
|
||||
span({ class: 'pm-exposition-text' }, (i18n.typeProject || 'PROJECT').toUpperCase())
|
||||
)
|
||||
)]
|
||||
}),
|
||||
div(
|
||||
p({ [RAW_HTML]: sanitizeHtml(msgHtml) })
|
||||
),
|
||||
p({ class: 'card-footer' },
|
||||
span({ class: 'date-link' }, `${action.ts ? moment(action.ts).format("YYYY/MM/DD HH:mm") : ''}`),
|
||||
userLink(action.author, action.authorNames && action.authorNames[action.author])
|
||||
)
|
||||
renderEngagement(safeMsgId(action.tipId || action.id), null, null, { href: `/projects/${encodeURIComponent(action.tipId || action.id)}`, title: title || '' })
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1879,53 +1874,39 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
: null;
|
||||
const msgId = safeMsgId(action.tipId || action.id || action.key);
|
||||
const isOwn = action.author && String(action.author) === String(userId);
|
||||
const bylineAuthorId = action.author || (content && content.proposer) || '';
|
||||
return div({ class: 'trending-card' + (isOwn ? ' own-content' : '') },
|
||||
div({ class: 'card-header activity-card-header' },
|
||||
div({ class: 'card-chips-row' },
|
||||
renderCardTop(msgId, detailHref, {
|
||||
date: action.ts,
|
||||
authorName: (action.authorNames && action.authorNames[bylineAuthorId]) || getProfile(bylineAuthorId).name,
|
||||
chips: [div({ class: 'card-chips-row' },
|
||||
span({ class: 'pm-exposition-chip pm-exposition-whole' },
|
||||
span({ class: 'pm-exposition-text' }, String(headerText || type || '').replace(/^\[|\]$/g, ''))
|
||||
)
|
||||
),
|
||||
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 } : {}),
|
||||
...(TORRENTABLE_TYPES.has(type) && content && typeof content.url === 'string' && content.url.startsWith('&')
|
||||
? { torrentFrom: { blobId: content.url, name: content.title || content.fileName || '' } }
|
||||
: {}),
|
||||
...favOptsFor(type, msgId, extras),
|
||||
deleteAction: isOwn ? activityDeleteAction(type, action, content, msgId, userId) : undefined
|
||||
})
|
||||
),
|
||||
)],
|
||||
author: bylineAuthorId,
|
||||
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 } : {}),
|
||||
...(TORRENTABLE_TYPES.has(type) && content && typeof content.url === 'string' && content.url.startsWith('&')
|
||||
? { torrentFrom: { blobId: content.url, name: content.title || content.fileName || '' } }
|
||||
: {}),
|
||||
...(commentOf(action) ? {} : favOptsFor(type, msgId, extras)),
|
||||
deleteAction: isOwn ? (commentOf(action) ? `/comments/delete/${encodeURIComponent(msgId)}` : activityDeleteAction(type, action, content, msgId, userId)) : undefined
|
||||
}),
|
||||
...cardBody,
|
||||
(() => {
|
||||
const footerAuthorId = action.author || (content && content.proposer) || '';
|
||||
const footerNode = p({ class: 'card-footer' },
|
||||
span({ class: 'date-link' }, `${date}`),
|
||||
userLink(footerAuthorId, (action.authorNames && action.authorNames[footerAuthorId]) || getProfile(footerAuthorId).name)
|
||||
);
|
||||
const commentTotal = Number(action.commentCount) || 0;
|
||||
const commentsHref = detailHref
|
||||
? `${detailHref}${detailHref.includes('?') ? '&' : '?'}comments=open${commentTotal ? '#comments-latest' : ''}`
|
||||
: '';
|
||||
const commentsNode = detailHref && type !== 'about'
|
||||
? a({
|
||||
href: commentsHref,
|
||||
class: commentTotal ? 'comments-summary-link engage-on' : 'comments-summary-link',
|
||||
title: i18n.commentsGoToLast || i18n.voteNewCommentLabel || 'Comments'
|
||||
},
|
||||
span({ class: 'comments-summary-icon' }, '✑'),
|
||||
span({ class: 'comments-summary-count' }, `(${commentTotal})`)
|
||||
)
|
||||
: null;
|
||||
const shareNode = detailHref ? renderShareButton(msgId, detailHref, (content && (content.title || content.question || content.concept || content.name)) || '') : null;
|
||||
const commentTotal = typeof action.commentCount === 'number' ? action.commentCount : commentCountFor([action.id, action.rootId, action.key, msgId]);
|
||||
const commentsHref = !detailHref ? '' : commentOf(action) ? detailHref : commentsOpenHref(detailHref, commentTotal);
|
||||
const commentsNode = type !== 'about' ? renderCommentsSummary(commentsHref, commentTotal) : null;
|
||||
const routeFn = OPINION_TYPES.has(type) ? OPINION_ROUTES[type] : null;
|
||||
if (!routeFn) return commentsNode ? [renderCardMetaRow(commentsNode, footerNode)] : footerNode;
|
||||
if (!routeFn) return renderCardMetaRow(type !== 'about' && detailHref ? renderOpinionsPill(msgId, detailHref) : null, commentsNode, shareNode);
|
||||
const ops = (action.value?.content?.opinions) || (action.content?.opinions) || {};
|
||||
const opsTotal = Object.values(ops).reduce((s, n) => s + (Number(n) || 0), 0);
|
||||
const votingNode = details({ class: 'opinions-voting-collapse' },
|
||||
summary({ class: 'opinions-summary' },
|
||||
summary({ class: opsTotal > 0 ? 'opinions-summary engage-on' : 'opinions-summary' },
|
||||
span({ class: 'opinions-summary-icon' }, 'ꔍ'),
|
||||
span({ class: 'opinions-summary-count' }, `(${opsTotal})`)),
|
||||
div({ class: 'voting-buttons' },
|
||||
|
|
@ -1936,7 +1917,7 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
|
|||
)
|
||||
)
|
||||
);
|
||||
return [renderVotesSummary(ops), renderCardMetaRow(votingNode, commentsNode, footerNode)];
|
||||
return [renderVotesSummary(ops), renderCardMetaRow(votingNode, commentsNode, shareNode)];
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
|
@ -1974,6 +1955,51 @@ const favOptsFor = (type, id, extras = {}) => {
|
|||
};
|
||||
};
|
||||
|
||||
const commentOf = (action) => action && action.comment && action.comment.rootId ? action.comment : null;
|
||||
|
||||
function commentHref(comment, commentId) {
|
||||
const rootId = comment && comment.rootId;
|
||||
if (!rootId) return commentId ? `/thread/${encodeURIComponent(commentId)}` : '/activity';
|
||||
if (comment.rootType === 'forum') return `/forum/${encodeURIComponent(rootId)}${commentId ? `#${forumReplyAnchor(commentId)}` : ''}`;
|
||||
const tail = commentId ? `#${encodeURIComponent(commentId)}` : '';
|
||||
const base = comment.rootType === 'post' ? `/blogs/${encodeURIComponent(rootId)}` : comment.rootType ? getViewDetailsAction(comment.rootType, { id: rootId }) : null;
|
||||
if (!base || base === '/activity') return `/thread/${encodeURIComponent(commentId || rootId)}`;
|
||||
if (base.includes('#')) return base;
|
||||
return `${base}${base.includes('?') ? '&' : '?'}comments=open${tail || '#comments-latest'}`;
|
||||
}
|
||||
|
||||
const GROUP_SUBTYPES = {
|
||||
parliament: ['parliamentCandidature', 'parliamentTerm', 'parliamentProposal', 'parliamentRevocation', 'parliamentLaw'],
|
||||
courts: ['courtsCase', 'courtsNomination', 'courtsNominationVote'],
|
||||
task: ['task', 'taskAssignment'],
|
||||
votes: ['votes', 'poll'],
|
||||
shop: ['shop', 'shopProduct'],
|
||||
larp: ['larpHousePost'],
|
||||
inhabitants:['about'],
|
||||
chat: ['chat', 'chatThread'],
|
||||
wiki: ['wikiPage'],
|
||||
mailing: ['mailingList'],
|
||||
emergency: ['emergency', 'emergencyUpdate'],
|
||||
logistics: ['logisticsRoute'],
|
||||
podcast: ['podcast', 'podcastEpisode'],
|
||||
campaign: ['campaign', 'campaignUpdate'],
|
||||
industry: ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation']
|
||||
};
|
||||
const MODULE_OF_TYPE = Object.entries(GROUP_SUBTYPES).reduce((acc, [mod, types]) => {
|
||||
for (const t of types) if (t !== mod && t !== 'poll') acc[t] = mod;
|
||||
return acc;
|
||||
}, { schoolCourse: 'school', mapMarker: 'map' });
|
||||
|
||||
function rootTypeLabel(rt) {
|
||||
return rt === 'post' ? (i18n.typePost || 'BLOG') : rt === 'poll' ? (i18n.pollsTitle || 'POLLS') : rt ? (i18n[`type${capitalize(MODULE_OF_TYPE[rt] || rt)}`] || rt) : null;
|
||||
}
|
||||
|
||||
function commentLabel(comment) {
|
||||
const base = rootTypeLabel(comment && comment.rootType);
|
||||
const word = i18n.typeComment;
|
||||
return base ? `${base} · ${word}` : word;
|
||||
}
|
||||
|
||||
function getViewDetailsAction(type, action) {
|
||||
const id = encodeURIComponent(safeMsgId(action.tipId || action.id || action.key || action));
|
||||
switch (type) {
|
||||
|
|
@ -1997,9 +2023,8 @@ function getViewDetailsAction(type, action) {
|
|||
}
|
||||
case 'post': {
|
||||
const root = action.content && action.content.root;
|
||||
return root
|
||||
? `/blogs/${encodeURIComponent(root)}#${id}`
|
||||
: `/blogs/${id}`;
|
||||
if (commentOf(action) || root) return commentHref(commentOf(action) || { rootId: root }, safeMsgId(action.tipId || action.id || action.key || action));
|
||||
return `/blogs/${id}`;
|
||||
}
|
||||
case 'feed': return `/feed/${id}`;
|
||||
case 'vote': return `/thread/${encodeURIComponent(action.content.vote.link)}#${encodeURIComponent(action.content.vote.link)}`;
|
||||
|
|
@ -2021,7 +2046,7 @@ function getViewDetailsAction(type, action) {
|
|||
case 'forum': {
|
||||
const root = action.content?.root;
|
||||
const rootId = typeof root === 'string' ? root : (root && (root.key || root.id)) || '';
|
||||
if (rootId) return `/forum/${encodeURIComponent(action.content?.rootKey || rootId)}#msg-${String(action.tipId || action.id || '').replace(/[^a-zA-Z0-9]/g, '')}`;
|
||||
if (rootId) return `/forum/${encodeURIComponent(action.content?.rootKey || rootId)}#${forumReplyAnchor(action.tipId || action.id)}`;
|
||||
return `/forum/${encodeURIComponent(action.content?.key || action.tipId || action.id)}`;
|
||||
}
|
||||
case 'document': return `/documents/${id}`;
|
||||
|
|
@ -2061,6 +2086,9 @@ function getViewDetailsAction(type, action) {
|
|||
}
|
||||
|
||||
exports.getViewDetailsAction = getViewDetailsAction;
|
||||
exports.commentHref = commentHref;
|
||||
exports.commentLabel = commentLabel;
|
||||
exports.rootTypeLabel = rootTypeLabel;
|
||||
|
||||
exports.activityView = (actions, filter, userId, q = '', extras = {}) => {
|
||||
const spreadMap = (extras && extras.spreadMap) || new Map();
|
||||
|
|
@ -2112,23 +2140,6 @@ exports.activityView = (actions, filter, userId, q = '', extras = {}) => {
|
|||
];
|
||||
|
||||
const FILTER_META = new Set(['all', 'mine', 'recent']);
|
||||
const GROUP_SUBTYPES = {
|
||||
parliament: ['parliamentCandidature', 'parliamentTerm', 'parliamentProposal', 'parliamentRevocation', 'parliamentLaw'],
|
||||
courts: ['courtsCase', 'courtsNomination', 'courtsNominationVote'],
|
||||
task: ['task', 'taskAssignment'],
|
||||
votes: ['votes', 'poll'],
|
||||
shop: ['shop', 'shopProduct'],
|
||||
larp: ['larpHousePost'],
|
||||
inhabitants:['about'],
|
||||
chat: ['chat', 'chatThread'],
|
||||
wiki: ['wikiPage'],
|
||||
mailing: ['mailingList'],
|
||||
emergency: ['emergency', 'emergencyUpdate'],
|
||||
logistics: ['logisticsRoute'],
|
||||
podcast: ['podcast', 'podcastEpisode'],
|
||||
campaign: ['campaign', 'campaignUpdate'],
|
||||
industry: ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation']
|
||||
};
|
||||
const ALLOWED_TYPES = new Set(['tribeOpened']);
|
||||
for (const { type } of activityTypes) {
|
||||
if (FILTER_META.has(type)) continue;
|
||||
|
|
@ -2185,13 +2196,18 @@ exports.activityView = (actions, filter, userId, q = '', extras = {}) => {
|
|||
filteredActions = actions.filter(action => ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation'].includes(action.type) && action.type !== 'tombstone');
|
||||
} else {
|
||||
filteredActions = actions.filter(action =>
|
||||
(action.type === filter
|
||||
((action.type === filter && !(filter === 'post' && commentOf(action) && commentOf(action).rootType !== 'post'))
|
||||
|| filter === 'all'
|
||||
|| openedIn(action, filter)
|
||||
|| (filter === 'shop' && action.type === 'shopProduct')
|
||||
|| (filter === 'votes' && action.type === 'poll'))
|
||||
&& action.type !== 'tombstone');
|
||||
}
|
||||
if (!FILTER_META.has(filter) && filter !== 'post') {
|
||||
const kinds = new Set(GROUP_SUBTYPES[filter] || [filter]);
|
||||
const listed = new Set(filteredActions);
|
||||
filteredActions = filteredActions.concat(actions.filter(a => !listed.has(a) && commentOf(a) && kinds.has(commentOf(a).rootType)));
|
||||
}
|
||||
|
||||
const larpEarliest = new Map();
|
||||
for (const action of filteredActions) {
|
||||
|
|
@ -2332,12 +2348,6 @@ exports.activityView = (actions, filter, userId, q = '', extras = {}) => {
|
|||
)
|
||||
);
|
||||
|
||||
const hasDocument = actions.some(a => a && a.type === 'document');
|
||||
if (hasDocument) {
|
||||
html += `
|
||||
<script type="module" src="/js/pdf-viewer.js?v=102"></script>
|
||||
`;
|
||||
}
|
||||
return html;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const { div, h2, p, section, button, form, img, input, textarea, a, br, h1, span } = require("../server/node_modules/hyperaxe");
|
||||
const { div, h2, p, section, button, form, input, a, span } = require("../server/node_modules/hyperaxe");
|
||||
const { safeExternalHref } = require("../backend/renderStyledText");
|
||||
const { template, i18n, userLink, renderContentActions, renderModuleStats, renderStateChip, CONTENT_FAV_KIND, CONTENT_SPREADABLE, contentDeleteAction, paged } = require('./main_views');
|
||||
const { template, i18n, userLink, renderCardTop, renderModuleStats, renderStateChip, CONTENT_FAV_KIND, CONTENT_SPREADABLE, contentDeleteAction, paged } = require('./main_views');
|
||||
const moment = require('../server/node_modules/moment');
|
||||
const { config } = require('../server/SSB_server.js');
|
||||
|
||||
|
|
@ -151,27 +151,24 @@ const renderAgendaItem = (item, userId, filter, extras = {}) => {
|
|||
const href = getViewDetailsAction(item);
|
||||
const title = item.title || item.name || item.concept || '';
|
||||
return div({ class: 'trending-card agenda-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(item.type || '').toUpperCase())
|
||||
),
|
||||
renderContentActions(item.id, href, {
|
||||
author,
|
||||
reportTitle: title,
|
||||
spread: CONTENT_SPREADABLE.has(item.type) ? ((spreadMap && spreadMap.get(item.id)) || null) : undefined,
|
||||
...(favKind ? { favKind, isFavorite: item.isFavorite === true || (!!favIndex && [item.rootId, item.id].some(id => id && favIndex.get(String(id)) === favKind)) } : {}),
|
||||
returnTo: `/agenda?filter=${encodeURIComponent(filter || 'all')}`,
|
||||
deleteAction: isOwn ? agendaDeleteAction(item) : undefined
|
||||
})
|
||||
),
|
||||
renderCardTop(item.id, href, {
|
||||
date: item.createdAt,
|
||||
chips: [
|
||||
span({ class: 'pm-exposition-chip pm-exposition-whole' },
|
||||
span({ class: 'pm-exposition-text' }, String(item.type || '').toUpperCase())
|
||||
),
|
||||
chips.filter(Boolean).length ? div({ class: 'card-chips-row' }, ...chips.filter(Boolean)) : null
|
||||
],
|
||||
author,
|
||||
reportTitle: title,
|
||||
spread: CONTENT_SPREADABLE.has(item.type) ? ((spreadMap && spreadMap.get(item.id)) || null) : undefined,
|
||||
...(favKind ? { favKind, isFavorite: item.isFavorite === true || (!!favIndex && [item.rootId, item.id].some(id => id && favIndex.get(String(id)) === favKind)) } : {}),
|
||||
returnTo: `/agenda?filter=${encodeURIComponent(filter || 'all')}`,
|
||||
deleteAction: isOwn ? agendaDeleteAction(item) : undefined
|
||||
}),
|
||||
div({ class: 'card-section agenda-card-body' },
|
||||
div({ class: 'shop-title-row' }, h2({ class: 'tribe-card-title' }, a({ href }, title))),
|
||||
chips.filter(Boolean).length ? div({ class: 'card-chips-row' }, ...chips.filter(Boolean)) : null,
|
||||
...body.filter(Boolean),
|
||||
p({ class: 'card-footer' },
|
||||
span({ class: 'date-link' }, `${item.createdAt ? moment(item.createdAt).format('YYYY/MM/DD HH:mm') : ''}`),
|
||||
author ? userLink(author) : ''
|
||||
),
|
||||
div({ class: 'agenda-card-actions' }, actionButton, ...extraActions)
|
||||
)
|
||||
);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue