From 948b6460d9a9831af517242f653289fc999968ea Mon Sep 17 00:00:00 2001 From: oasis-bot Date: Mon, 19 Oct 2026 00:30:25 +0200 Subject: [PATCH] upstream: integrar Oasis 1.3.3 Diferencia 1.3.1 -> 1.3.3 del codigo de la APK oficial aplicada a tres bandas encima de la rama: karma en el perfil, salas con directo, fotos selladas, visibilidad de menciones, telefono por stream en Android y las traducciones nuevas. Sin dependencias nuevas. OasisMobile.css sigue siendo el del fork; del oficial solo se traen al final las reglas de las piezas nuevas (karma, directo a pantalla completa, salas en vivo, graphos). --- README.md | 2 +- android/app/build.gradle.kts | 2 +- android/scripts/prepare.sh | 2 +- package.json | 2 +- src/AI/buildAIContext.js | 8 +- src/backend/backend.js | 801 ++++++++++++++++----- src/backend/blobHandler.js | 33 +- src/backend/opinion_categories.js | 9 + src/backend/pm_policy.js | 5 +- src/backend/renderStyledText.js | 14 +- src/backend/sanitizeHtml.js | 2 +- src/client/assets/images/ux-rooms.png | Bin 0 -> 11953 bytes src/client/assets/styles/mobile.css | 14 +- src/client/assets/styles/style.css | 78 +- src/client/assets/themes/Clear-SNH.css | 5 +- src/client/assets/themes/Dark-SNH.css | 4 +- src/client/assets/themes/Matrix-SNH.css | 4 +- src/client/assets/themes/OasisMobile.css | 113 +++ src/client/assets/themes/Purple-SNH.css | 5 +- src/client/assets/translations/oasis_ar.js | 53 +- src/client/assets/translations/oasis_ca.js | 53 +- src/client/assets/translations/oasis_de.js | 53 +- src/client/assets/translations/oasis_en.js | 53 +- src/client/assets/translations/oasis_es.js | 53 +- src/client/assets/translations/oasis_eu.js | 53 +- src/client/assets/translations/oasis_fr.js | 53 +- src/client/assets/translations/oasis_gl.js | 53 +- src/client/assets/translations/oasis_hi.js | 53 +- src/client/assets/translations/oasis_it.js | 53 +- src/client/assets/translations/oasis_pt.js | 53 +- src/client/assets/translations/oasis_ru.js | 53 +- src/client/assets/translations/oasis_zh.js | 53 +- src/configs/config-manager.js | 6 +- src/configs/state-manager.js | 1 + src/models/banking_model.js | 416 +---------- src/models/blog_model.js | 7 +- src/models/chats_model.js | 16 +- src/models/data_model.js | 20 +- src/models/karma_model.js | 591 +++++++++++++++ src/models/main_models.js | 6 + src/models/opinions_model.js | 21 +- src/models/peer_health.js | 4 +- src/models/pixelia_model.js | 31 +- src/models/rooms_model.js | 43 +- src/models/stats_model.js | 8 +- src/models/tags_model.js | 8 +- src/models/trending_model.js | 17 +- src/models/tribes_content_model.js | 2 +- src/models/viewer_filters.js | 126 +++- src/server/SSB_server.js | 2 +- src/server/package-lock.json | 2 +- src/server/package.json | 2 +- src/server/phone_module.js | 445 +++++++++--- src/server/phone_stream_android.js | 53 ++ src/server/ssb_config.js | 4 +- src/server/ssb_metadata.js | 29 +- src/views/activity_view.js | 7 +- src/views/audio_view.js | 6 +- src/views/banking_views.js | 4 +- src/views/blockchain_view.js | 14 +- src/views/blog_view.js | 23 +- src/views/bookmark_view.js | 2 - src/views/campaigns_view.js | 1 - src/views/chats_view.js | 6 +- src/views/clearnet_view.js | 61 +- src/views/comments_view.js | 14 +- src/views/data_view.js | 47 +- src/views/document_view.js | 8 +- src/views/emergencies_view.js | 1 - src/views/feed_view.js | 2 +- src/views/files_view.js | 12 +- src/views/graphos_view.js | 47 +- src/views/image_view.js | 12 +- src/views/inhabitants_view.js | 9 +- src/views/larp_view.js | 4 +- src/views/logistics_view.js | 1 - src/views/mailing_view.js | 1 - src/views/main_views.js | 131 +++- src/views/maps_view.js | 12 +- src/views/market_view.js | 1 - src/views/opinions_view.js | 15 +- src/views/peers_view.js | 188 ++++- src/views/phone_view.js | 9 +- src/views/podcasts_view.js | 1 - src/views/polls_view.js | 2 +- src/views/rooms_view.js | 121 +++- src/views/search_view.js | 125 ++-- src/views/settings_view.js | 264 ++++--- src/views/torrents_view.js | 36 +- src/views/transfer_view.js | 2 - src/views/trending_view.js | 22 +- src/views/tribes_view.js | 94 ++- src/views/video_view.js | 6 +- src/views/welcome_view.js | 5 +- src/views/wiki_view.js | 1 - 95 files changed, 3613 insertions(+), 1356 deletions(-) create mode 100644 src/client/assets/images/ux-rooms.png create mode 100644 src/models/karma_model.js create mode 100644 src/server/phone_stream_android.js diff --git a/README.md b/README.md index 3c3ea06..27f248b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ > > **Los fallos de esta rama se reportan aquí, nunca a epsylon.** -Versión base: **Oasis 1.3.1** · Rama: `PRUEBAS` · +Versión base: **Oasis 1.3.3** · Rama: `PRUEBAS` · [Documentación completa](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki) · [Versión de escritorio](https://gitea.laenre.net/hacklab/OASIS_LINUX) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index cb86918..c1b0340 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,7 +16,7 @@ android { targetSdk = 35 // el pipeline los pasa por entorno; a mano valen los de aqui versionCode = (System.getenv("OASIS_VERSION_CODE") ?: "1").toInt() - versionName = System.getenv("OASIS_VERSION_NAME") ?: "1.3.1" + versionName = System.getenv("OASIS_VERSION_NAME") ?: "1.3.3" ndk { abiFilters += listOf("arm64-v8a") } } diff --git a/android/scripts/prepare.sh b/android/scripts/prepare.sh index 8e4ae0c..b7a7ee6 100755 --- a/android/scripts/prepare.sh +++ b/android/scripts/prepare.sh @@ -40,7 +40,7 @@ fi # --- 2) backend + dependencias --------------------------------------------- # El node_modules del movil esta podado: son ~137 MB frente a los 1,4 GB de una # instalacion de escritorio. En vez de reinstalarlo, se reutiliza el del APK base: -# tiene que ser la APK oficial de la MISMA version que src/ (ahora 1.3.1): desde la +# tiene que ser la APK oficial de la MISMA version que src/ (ahora 1.3.3): desde la # 1.1 el backend usa ssb-db2 y el node_modules de una 0.9.x ya no le sirve. BASE_ZIP="" if [ -n "$SRC" ] && [ -f "$SRC" ]; then diff --git a/package.json b/package.json index 4230048..05a85a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@krakenslab/oasis", - "version": "1.3.1", + "version": "1.3.3", "description": "Oasis - Social Networking Utopia", "repository": { "type": "git", diff --git a/src/AI/buildAIContext.js b/src/AI/buildAIContext.js index da5f793..3b7f605 100644 --- a/src/AI/buildAIContext.js +++ b/src/AI/buildAIContext.js @@ -162,8 +162,12 @@ async function listExchanges() { const netVotes = (votes, key) => { const per = votes.get(key); if (!per) return 0; let n = 0; for (const v of per.values()) n += v; return n } -async function rankExchanges(question, { embed, cosine, k = 5 } = {}) { - const { exchanges, votes } = await listExchanges() +async function rankExchanges(question, { embed, cosine, k = 5, excludeAuthors = null } = {}) { + const listed = await listExchanges() + const votes = listed.votes + const exchanges = excludeAuthors instanceof Set && excludeAuthors.size + ? listed.exchanges.filter(ex => !excludeAuthors.has(String(ex.author))) + : listed.exchanges if (!exchanges.length) return [] const scored = [] if (typeof embed === 'function' && typeof cosine === 'function') { diff --git a/src/backend/backend.js b/src/backend/backend.js index 5c705dc..68bf26e 100644 --- a/src/backend/backend.js +++ b/src/backend/backend.js @@ -102,12 +102,13 @@ const answerAiEntry = async (entry, input, lang, previous) => { step('detecting intent'); const intent = await aiIntents.detect(input, embedOpts).catch(() => null); step(`intent: ${intent || 'none'}`); + const hvAi = await hiddenView(); const facts = intent ? await aiIntents.run(intent, { - models: { + models: hiddenModels({ banking: bankingModel, agenda: agendaModel, events: eventsModel, tasks: tasksModel, transfers: transfersModel, inhabitants: inhabitantsModel, friend, parliament: parliamentModel, larp: larpModel, emergencies: emergenciesModel, jobs: jobsModel, market: marketModel, tribes: tribesModel, shops: shopsModel, school: schoolModel, projects: projectsModel, housing: housingModel, industry: industryModel, campaigns: campaignsModel, logistics: logisticsModel, calendars: calendarsModel, votes: votesModel, polls: pollsModel, wiki: wikiModel, chats: chatsModel, mailing: mailingModel, podcasts: podcastsModel, audios: audiosModel, videos: videosModel, images: imagesModel, documents: documentsModel, torrents: torrentsModel, files: filesModel, blog: blogModel, reports: reportsModel, games: gamesModel, tags: tagsModel, favorites: favoritesModel - }, + }, hvAi), config: getConfig(), sharedState, me, hrefFor: aiHrefFor, nameOf: async (id) => { try { return await about.name(id); } catch (_) { return String(id || '').slice(0, 9); } }, version: OASIS_VERSION }) : null; if (Array.isArray(facts) && facts.length) { @@ -126,7 +127,7 @@ const answerAiEntry = async (entry, input, lang, previous) => { if (!result.answer.trim()) result.answer = factLines.join('\n'); } else { step('looking for an approved answer'); - const trained = await getBestTrainedAnswer(input, embedOpts).catch(() => null); + const trained = await getBestTrainedAnswer(input, { ...embedOpts, excludeAuthors: hvAi.authors }).catch(() => null); step(trained ? 'approved answer found' : 'no approved answer'); if (trained && trained.answer) { result.answer = trained.answer; @@ -134,7 +135,7 @@ const answerAiEntry = async (entry, input, lang, previous) => { result.source = 'network'; result.trainStatus = 'approved'; } else { - const context = await rankContext(input, { ...embedOpts, k: 5 }).catch(() => []); + const context = await rankContext(input, { ...embedOpts, k: 5, excludeAuthors: hvAi.authors }).catch(() => []); result.snippets = context; step(`context: ${context.length} snippets, asking the model`); result.answer = await aiClient.ask({ system: aiSystemPrompt(lang), history: aiHistoryTurns(previous || []), context, input, lang }); @@ -308,7 +309,7 @@ const confirmPage = (ctx, { message, action, hidden = [], backHref = null }) => ctx.type = 'html'; ctx.body = require('../views/main_views').confirmView({ message, action, hidden, backHref }); }; -const LOOPBACK_ONLY_PATHS = ['/settings', '/wallet', '/banking/simulate', '/fediverse', '/peers/pause', '/peers/resume', '/peers/connect', '/peers/disconnect', '/peers/refresh', '/peers/prune', '/peers/export', '/peers/import', '/logs/export', '/ai/export', '/export', '/backup', '/update', '/panic']; +const LOOPBACK_ONLY_PATHS = ['/settings', '/wallet', '/banking/simulate', '/fediverse', '/peers/pause', '/peers/resume', '/peers/connect', '/peers/disconnect', '/peers/refresh', '/peers/prune', '/peers/export', '/peers/import', '/peers/clear-unreachable', '/logs/export', '/ai/export', '/export', '/backup', '/update', '/panic']; const validPeerHost = (hostStr) => { const isIPv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostStr); const isHostname = /^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)*$/.test(hostStr); @@ -867,8 +868,11 @@ const readTribeReach = (body) => { return tribesContentModel.reachLevels.includes(v) ? v : 'tribe'; }; const tribeRepliesFor = async (item) => item && item.contentType === 'forum' ? ((await tribesContentModel.getThread(item.id).catch(() => null)) || {}).replies || [] : []; +const publicCopyOf = async (item) => item && typeof item === 'object' + ? { ...item, image: item.image ? await materializeSealed(item.image) : item.image, description: item.description ? await materializeSealed(item.description) : item.description } + : item; const publishTribeExposure = async (item, level, replies = []) => { - const r = await tribesContentModel.publishExposure(item, level, replies); + const r = await tribesContentModel.publishExposure(await publicCopyOf(item), level, await Promise.all((replies || []).map(publicCopyOf))); clearnetTribesCache = { at: 0, data: null }; return r; }; @@ -1576,7 +1580,7 @@ const refreshInboxCount = async (messagesOpt) => { const refreshMentionsCount = async () => { try { - const all = await mentionsModel.listMentions('ALL'); + const all = await mentionsAllowed(await mentionsModel.listMentions('ALL')); sharedState.setMentionsTotal(all.length); sharedState.setMentionsCount(mentionsModel.unseenOf(all).length); } catch (_) {} @@ -1594,25 +1598,29 @@ const inboxListTitles = async (messages) => { }; const buildInboxMessages = async () => { - let messages = sanitizeMessages(await pmModel.listAllPrivate()); + const viewer = getViewerId(); + const hv = await hiddenView(); + let messages = sanitizeMessages(await pmModel.listAllPrivate()).filter(msg => { + const author = msg?.value?.author || msg?.author; + return !(author && author !== viewer && hv.authors.has(author)); + }); const cfgNow = getConfig(); - if (cfgNow.pmVisibility === 'mutuals') { - const viewer = getViewerId(); - const mutualCache = new Map(); - const isMutual = async (id) => { + if (cfgNow.pmVisibility === 'mutuals' || cfgNow.pmVisibility === 'following') { + const allowedCache = new Map(); + const allowedFrom = async (id) => { if (id === viewer) return true; - if (mutualCache.has(id)) return mutualCache.get(id); + if (allowedCache.has(id)) return allowedCache.get(id); let rel; try { rel = await friend.getRelationship(id); } catch (e) { rel = null; } - const m = !!(rel && rel.following && rel.followsMe); - mutualCache.set(id, m); - return m; + const ok = pmPolicy.isRecipientAllowed({ pmVisibility: cfgNow.pmVisibility, viewerId: viewer, recipientId: id, relationship: rel }); + allowedCache.set(id, ok); + return ok; }; const filtered = []; for (const msg of messages) { const author = msg?.value?.author || msg?.author; if (author === viewer) { filtered.push(msg); continue; } - if (await isMutual(author)) filtered.push(msg); + if (await allowedFrom(author)) filtered.push(msg); } messages = filtered; } @@ -1703,9 +1711,15 @@ const normalizeBodyNewlines = (body) => { else if (Array.isArray(v)) body[k] = v.map(x => typeof x === 'string' ? x.replace(/\r\n?/g, '\n') : x); } }; +const normalizeBodyTags = (body) => { + if (!body || typeof body !== 'object' || body.tags == null) return; + const clean = (t) => String(t == null ? '' : t).trim().replace(/^#+/, '').trim(); + if (Array.isArray(body.tags)) body.tags = body.tags.map(clean).filter(Boolean); + else if (typeof body.tags === 'string') body.tags = body.tags.split(',').map(clean).filter(Boolean).join(', '); +}; const koaBody = (opts) => { const parse = koaBodyLib(opts); - return (ctx, next) => parse(ctx, () => { normalizeBodyNewlines(ctx.request.body); return next(); }); + return (ctx, next) => parse(ctx, () => { normalizeBodyNewlines(ctx.request.body); normalizeBodyTags(ctx.request.body); return next(); }); }; const longTextBody = koaBody({ formLimit: '2mb', textLimit: '2mb' }); const { nav, ul, li, a, form, button, div, section, h2, p } = require("../server/node_modules/hyperaxe"); @@ -1853,7 +1867,7 @@ const { about, blob, friend, meta, post, vote, spreads, lifetime } = models({ }); const { handleBlobUpload, handleBlobUploads, serveBlob, sendBlobBuffer, contentDisposition, FileTooLargeError } = require('../backend/blobHandler.js'); const { mergeGallery } = require('../models/media_gallery'); -const extractBlobId = (md) => md ? (md.match(/\((&[^)]+)\)/)?.[1] ?? null) : null; +const extractBlobId = (md) => md ? (md.match(/\((\/blob\/sealed\/[^)\s]+|&[^)]+)\)/)?.[1] ?? null) : null; const exportmodeModel = require('../models/exportmode_model'); const panicmodeModel = require('../models/panicmode_model'); const cipherModel = require('../models/cipher_model'); @@ -2273,6 +2287,38 @@ const encryptUploadForTribe = async (file, { filename, mime }) => { cipher: { v: 1, key: pointer.key, manifestBlobId: pointer.manifestBlobId, chunkCount: pointer.chunkCount } }; }; +const tribeSeal = async ({ filepath, size, name, mime }) => { + const enc = await encryptUploadForTribe({ filepath, size }, { filename: name, mime }); + return { manifestBlobId: enc.cipher.manifestBlobId, key: enc.cipher.key }; +}; +const sealIf = (cond) => (cond ? tribeSeal : null); +const SEALED_INLINE = /^(image\/(png|jpeg|gif|webp|bmp)|audio\/[a-z0-9.+-]+|video\/[a-z0-9.+-]+|application\/pdf)$/; +const sealedPointerOf = (manifestBlobId, query) => { + const key = String((query && query.k) || ''); + if (!/^&[A-Za-z0-9+/=]+\.sha256$/.test(String(manifestBlobId || '')) || !/^[0-9a-f]{64}$/i.test(key)) return null; + const mime = String((query && query.t) || 'application/octet-stream').toLowerCase().slice(0, 100); + const name = String((query && query.n) || 'file').replace(/[\r\n"\\/]+/g, ' ').trim().slice(0, 120) || 'file'; + return { type: 'fileShare', v: 1, key, manifestBlobId: String(manifestBlobId), filename: name, mime }; +}; +const materializeSealed = async (text) => { + const src = String(text || ''); + const refs = [...new Set(src.match(/\/blob\/sealed\/[^)\s"']+/g) || [])]; + if (!refs.length) return src; + let out = src; + const ssbM = await cooler.open(); + for (const ref of refs) { + try { + const u = new URL(ref, 'http://local'); + const pointer = sealedPointerOf(decodeURIComponent(u.pathname.replace('/blob/sealed/', '')), Object.fromEntries(u.searchParams)); + if (!pointer || !(await fileshareModel.ensureAvailable(pointer))) continue; + const chunks = []; + for await (const chunk of fileshareModel.readShareStream(pointer)) chunks.push(Buffer.from(chunk)); + const plainId = await new Promise((resolve, reject) => pull(pull.values([Buffer.concat(chunks)]), ssbM.blobs.add((err, ref2) => err ? reject(err) : resolve(ref2)))); + out = out.split(ref).join(plainId); + } catch (_) {} + } + return out; +}; const sendDecryptedBlob = async (ctx, cipher, filename, mime) => { const pointer = { type: 'fileShare', v: Number(cipher.v) || 1, key: cipher.key, manifestBlobId: cipher.manifestBlobId, filename, mime }; if (!(await fileshareModel.ensureAvailable(pointer))) { @@ -2548,10 +2594,60 @@ const getViewerTribeAccessSets = async (userId) => { return pending; }; +const hiddenView = async () => { + const store = require('../models/typed_log').requestScope.getStore(); + if (store && store.hiddenView) return store.hiddenView; + const pending = (async () => { + const authors = new Set(viewerFilters.mutedIds()); + try { for (const id of await friend.blockedIds()) authors.add(id); } catch (_) {} + return { authors, ids: viewerFilters.hiddenIds() }; + })(); + if (store) store.hiddenView = pending; + return pending; +}; +const dropHidden = async (items, authorOf = extractItemAuthor) => { + if (!Array.isArray(items)) return items; + const h = await hiddenView(); + if (!h.authors.size && !h.ids.size) return items; + return items.filter(it => !viewerFilters.isItemHidden(it, authorOf, h)); +}; +const hiddenModels = (models, h) => { + if (!h || (!h.authors.size && !h.ids.size)) return models; + const clean = (r) => Array.isArray(r) ? r.filter(it => !viewerFilters.isItemHidden(it, extractItemAuthor, h)) : r; + const wrap = (model) => (model && typeof model === 'object') + ? new Proxy(model, { get(t, prop) { + const v = t[prop]; + if (typeof v !== 'function') return v; + return (...args) => { const r = v.apply(t, args); return r && typeof r.then === 'function' ? r.then(clean) : clean(r); }; + } }) + : model; + const out = {}; + for (const k of Object.keys(models)) out[k] = wrap(models[k]); + return out; +}; +const mentionsAllowed = async (list) => { + const base = await dropHidden(list, (x) => x && x.author); + const mode = getConfig().mentionsVisibility; + if (mode !== 'following' && mode !== 'mutuals') return base; + const viewer = getViewerId(); + const cache = new Map(); + const out = []; + for (const it of base) { + const a = it && it.author; + if (!a || a === viewer) { out.push(it); continue; } + if (!cache.has(a)) { + let rel = null; + try { rel = await friend.getRelationship(a); } catch (_) { rel = null; } + cache.set(a, mode === 'mutuals' ? !!(rel && rel.following && rel.followsMe) : !!(rel && rel.following)); + } + if (cache.get(a)) out.push(it); + } + return out; +}; const applyListFilters = async (items, ctx, opts = {}) => { if (!Array.isArray(items)) return items; const viewer = getViewerId(); - let out = items; + let out = await dropHidden(items); if (!opts.skipTribeAccess) { const { memberOf, createdBy, privateNotAccessible } = await getViewerTribeAccessSets(viewer); out = out.filter(it => { @@ -2837,10 +2933,52 @@ tribesModel.processIncomingKeys().then(async () => { courtsModel.processIncomingCourtsKeys().catch(err => { if (config.debug) console.error('courts-keys scan error:', err.message); }); +const rawContentOf = async (msgId) => { + if (!msgId) return null; + try { + const ssbT = await cooler.open(); + const m = await new Promise((resolve) => ssbT.get(String(msgId), (err, v) => resolve(err ? null : v))); + const c = m && (m.content || (m.value && m.value.content)); + return c && typeof c === 'object' ? c : null; + } catch (_) { return null; } +}; +const isTribeContent = async (msgId) => { + const c = await rawContentOf(msgId); + return !!(c && (tribeCrypto.isTribeMsg(c) || (typeof c.tribeId === 'string' && c.tribeId))); +}; +const denyTribeContent = async (ctx, msgId) => { + if (!(await isTribeContent(msgId))) return false; + ctx.status = 403; + ctx.redirect(safeReturnTo(ctx, '/tribes', ['/tribe', '/files', '/torrents', '/polls'])); + return true; +}; +const tribeOutsider = async (msgId) => { + const c = await rawContentOf(msgId); + if (!c) return false; + if (tribeCrypto.isTribeMsg(c)) return !tribeCrypto.unwrapMsg(c, tribeCrypto.buildFingerprintIndex()); + if (typeof c.tribeId === 'string' && c.tribeId) { + const t = await tribesModel.getTribeById(c.tribeId).catch(() => null); + return !(t && Array.isArray(t.members) && t.members.includes(getViewerId())); + } + return false; +}; +const denyTribeOutsider = async (ctx, msgId) => { + if (!(await tribeOutsider(msgId))) return false; + ctx.status = 403; + ctx.redirect('/tribes'); + return true; +}; +const pollTribeGate = async (pollId) => { + let poll = null; + try { poll = await pollsModel.getPollById(pollId, getViewerId()); } catch (_) { return { ok: true, poll: null, tribe: null }; } + if (!poll || !poll.tribeId) return { ok: true, poll, tribe: null }; + const tribe = await tribesModel.getTribeById(poll.tribeId).catch(() => null); + return { ok: !!(tribe && Array.isArray(tribe.members) && tribe.members.includes(getViewerId())), poll, tribe }; +}; const getVoteComments = async (voteId) => { const raw = await post.topicComments(voteId); - return (raw || []).filter(c => c?.value?.content?.type === 'post' && c.value.content.root === voteId) - .sort((a, b) => (a?.value?.timestamp || 0) - (b?.value?.timestamp || 0)); + return dropHidden((raw || []).filter(c => c?.value?.content?.type === 'post' && c.value.content.root === voteId) + .sort((a, b) => (a?.value?.timestamp || 0) - (b?.value?.timestamp || 0))); }; const commentCountsFor = async (ids) => { const want = new Set(ids.filter(Boolean)); @@ -2957,6 +3095,7 @@ const contentResolvers = { const contentModCheck = { images: 'imagesMod', audios: 'audiosMod', videos: 'videosMod', documents: 'documentsMod', bookmarks: 'bookmarksMod', market: 'marketMod', jobs: 'jobsMod', projects: 'projectsMod', shops: 'shopsMod', shopProducts: 'shopsMod', chats: 'chatsMod', maps: 'mapsMod', pads: 'padsMod', rooms: 'roomsMod', calendars: 'calendarsMod', wiki: 'wikiMod', emergencies: 'emergenciesMod', mailing: 'mailingMod', logistics: 'logisticsMod', podcasts: 'podcastsMod', campaigns: 'campaignsMod', torrents: 'torrentsMod', files: 'filesMod', events: 'eventsMod', forum: 'forumMod', blogs: 'blogsMod', logs: 'logsMod', polls: 'pollsMod', tasks: 'tasksMod', reports: 'reportsMod', votes: 'votesMod', housing: 'housingMod', transfers: 'transfersMod', school: 'schoolMod' }; const favAction = async (ctx, kind, action) => { if (!checkMod(ctx, contentModCheck[kind])) { ctx.redirect('/modules'); return; } + if (action === 'add' && await denyTribeOutsider(ctx, ctx.params.id)) return; try { const rootId = await contentResolvers[kind](ctx.params.id); if (rootId) await contentFavorites[action + 'Favorite'](kind, rootId); @@ -3386,6 +3525,7 @@ const rejectPastDates = (ctx, fields, fallback) => { const commentAction = async (ctx, kind, idParam) => { const modKey = contentModCheck[kind]; if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } + if (await denyTribeContent(ctx, ctx.params[idParam])) return; const itemId = ctx.params[idParam]; let text = stripDangerousTags((ctx.request.body.text || '').trim()); const rt = safeReturnTo(ctx, `/${kind}/${encodeURIComponent(itemId)}`, [`/${kind}`]); @@ -3410,6 +3550,7 @@ const deleteModels = { images: imagesModel, audios: audiosModel, videos: videosM const opinionAction = async (ctx, kind, idParam) => { const modKey = contentModCheck[kind]; if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } + if (await denyTribeContent(ctx, ctx.params[idParam])) return; try { await opinionModels[kind].createOpinion(ctx.params[idParam], ctx.params.category); } catch (e) { return /already/i.test(String(e && e.message)) ? failWith(ctx, 'opinionAlreadyGiven') : actionFail(ctx); } try { activityModel.invalidateCache(); } catch (_) {} @@ -3623,6 +3764,8 @@ const listConnectionPeers = async () => { } catch (_) {} try { const livePeers = (ssbX.peers && typeof ssbX.peers === 'object') ? ssbX.peers : {}; + const hubAt = new Map(); + try { for (const [, d] of (ssbX.conn && typeof ssbX.conn.hub === 'function' ? ssbX.conn.hub().entries() : [])) if (d && d.key && d.hubUpdated) hubAt.set(d.key, d.hubUpdated); } catch (_) {} for (const rawKey of Object.keys(livePeers)) { if (!rawKey || rawKey === ssbX.id) continue; const rpcs = livePeers[rawKey]; @@ -3633,7 +3776,7 @@ const listConnectionPeers = async () => { const m = String(addr).match(/^(?:net|onion):([^:]+):(\d+)/); if (m) { host = m[1]; port = Number(m[2]); } } - mergePeer(rawKey, { host, port, state: 'connected', source: 'rpc' }); + mergePeer(rawKey, { host, port, state: 'connected', stateChange: hubAt.get(rawKey) || null, source: 'rpc' }); } } catch (_) {} try { @@ -3659,7 +3802,7 @@ const listConnectionPeers = async () => { const m = String(addr).match(/^(?:net|onion):([^:]+):(\d+)/); if (m) { host = host || m[1]; port = port || Number(m[2]); } } - mergePeer(data.key, { host, port, state: 'staged', source: data.type === 'lan' ? 'lan' : (data.type || 'staged') }); + mergePeer(data.key, { host, port, state: 'staged', stateChange: data.stagingUpdated || null, source: data.type === 'lan' ? 'lan' : (data.type || 'staged') }); } } } catch (_) {} @@ -3678,6 +3821,11 @@ const listConnectionPeers = async () => { }) .sort((a, b) => (a.state === 'connected' ? -1 : 1) - (b.state === 'connected' ? -1 : 1)); }; +const connectedPeerKeys = (onlinePeersList, technicalPeers) => { + const keys = new Set((onlinePeersList || []).map(e => e && e[1] && peerHealth.canonicalKey(e[1].key)).filter(Boolean)); + for (const tp of technicalPeers || []) if (tp.state === 'connected') keys.add(peerHealth.canonicalKey(tp.key)); + return keys; +}; const peerSignals = async (ssb) => { const { dbPeers, failures } = await peerFailures(ssb); const live = new Set(Object.keys(ssb.peers || {}).filter(k => Array.isArray(ssb.peers[k]) && ssb.peers[k].length).map(k => peerHealth.canonicalKey(k))); @@ -4000,6 +4148,46 @@ const preparePreview = async function (ctx) { } const megabyte = Math.pow(2, 20); const maxSize = 75 * megabyte; +const STREAM_VIDEO_EXT = /\.(mp4|m4v|mkv|webm|mov|avi|ogv|ogg|mpg|mpeg|mpe|ts|m2ts|mts|wmv|asf|flv|3gp|3g2|vob|divx)$/i; +const streamRoots = () => { + let user = ''; + try { user = os.userInfo().username; } catch (_) {} + const out = []; + for (const c of [os.homedir(), process.env.EXTERNAL_STORAGE, '/storage/emulated/0', '/sdcard', '/media', user ? `/run/media/${user}` : null, '/mnt', '/Volumes']) { + if (!c) continue; + try { + const real = fs.realpathSync(c); + if (out.includes(real) || !fs.statSync(real).isDirectory()) continue; + fs.accessSync(real, fs.constants.R_OK); + out.push(real); + } catch (_) {} + } + return out; +}; +const insideStreamRoots = (target, roots) => roots.some(root => target === root || target.startsWith(root.endsWith(path.sep) ? root : root + path.sep)); +const streamReturnTo = (ctx) => safeReturnTo(ctx, '/rooms', ['/rooms', '/tribe']); +const STREAM_SEARCH_MS = 8000; +const STREAM_SKIP_DIRS = new Set(['node_modules', 'proc', 'sys', 'dev', 'lost+found']); +const findStreamFiles = async (name, roots) => { + const found = []; + const seen = new Set(); + const queue = [...roots]; + const deadline = Date.now() + STREAM_SEARCH_MS; + while (queue.length && Date.now() < deadline) { + const dir = queue.shift(); + const real = await promisesFs.realpath(dir).catch(() => null); + if (!real || seen.has(real)) continue; + seen.add(real); + const entries = await promisesFs.readdir(real, { withFileTypes: true }).catch(() => []); + for (const e of entries) { + if (e.name.startsWith('.') || STREAM_SKIP_DIRS.has(e.name)) continue; + const full = path.join(real, e.name); + if (e.isDirectory()) queue.push(full); + else if (e.name === name) { const target = await promisesFs.realpath(full).catch(() => null); if (target) found.push(target); } + } + } + return found; +}; const collectFediverseMedia = async (ctx) => { const files = ctx.request.files && ctx.request.files.media; if (!files) return []; @@ -4107,6 +4295,13 @@ const deadPeerKeys = async () => { return new Set([...failures].filter(([k, f]) => peerHealth.isDead({ key: k, failures: f })).map(([k]) => k)); } catch (_) { return new Set(); } }; +const forgetDeadPubs = async () => { + try { + const dead = await deadPeerKeys(); + const pubKeys = new Set(readJSON(gossipPath).map(p => p && peerHealth.canonicalKey(p.key)).filter(Boolean)); + await forgetPeers([...dead].filter(k => pubKeys.has(k))); + } catch (_) {} +}; const mastodonHandle = () => { const acc = fediverseModel.getAccount(); const host = acc ? String(acc.instance || '').replace(/^https?:\/\//, '') : ''; @@ -4287,7 +4482,7 @@ const { housingView, singleHousingView, clearnetHousingView } = require("../view const { shopsView, singleShopView, singleProductView, editProductView, shopOrdersView, myPurchasesView, clearnetShopView, renderShopInvitePage } = require("../views/shops_view"); const { chatsView, singleChatView, renderChatInvitePage } = require("../views/chats_view"); const { padsView, singlePadView, renderPadInvitePage } = require("../views/pads_view"); -const { roomsView, singleRoomView, renderRoomInvitePage, roomModesFromCensus, clearnetRoomView } = require("../views/rooms_view"); +const { roomsView, singleRoomView, renderRoomInvitePage, roomModesFromCensus, clearnetRoomView, roomStreamMatchesView } = require("../views/rooms_view"); const { wikiView, wikiPageView, wikiHistoryView, wikiChangesView } = require('../views/wiki_view'); const { emergenciesView, singleEmergencyView, clearnetEmergencyView } = require('../views/emergencies_view'); const { mailingView, singleMailingView } = require('../views/mailing_view'); @@ -4344,6 +4539,20 @@ const forumPageBody = async (ctx, forumId, replyId = null, params = {}) => { const forumObj = await forumModel.getForumById(forumId); try { const oi = await forumModel.getOpenInvite(forumId).catch(() => null); if (oi && forumObj) forumObj.openInviteCode = oi.code; } catch (_) {} const forumMsgs = await forumModel.getMessagesByForumId(forumId); + const forumHidden = await hiddenView(); + if (forumMsgs && Array.isArray(forumMsgs.messages) && (forumHidden.authors.size || forumHidden.ids.size)) { + const isHiddenReply = (r) => !!(r && ((r.author && forumHidden.authors.has(r.author)) || (r.key && forumHidden.ids.has(r.key)))); + const prune = (list) => { + const out = []; + for (const r of list || []) { + const kids = prune(r.children); + if (isHiddenReply(r)) out.push(...kids); + else { r.children = kids; out.push(r); } + } + return out; + }; + forumMsgs.messages = prune(forumMsgs.messages); + } await warmAuthorNames(forumObj, forumMsgs); try { forumObj.subscription = await subscriptionStateFor(forumObj.rootId || forumObj.key, forumObj.author); } catch (_) {} return singleForumView(await withFavorite(forumObj, 'forum'), forumMsgs, ctx.query.filter, replyId, { spreads: spreadInfo, ...params }); @@ -4411,7 +4620,8 @@ router const { ainavHomeView } = require("../views/main_views"); let recentTags = []; try { - const all = await tagsModel.listTags('top'); + const hvTags = await hiddenView(); + const all = await tagsModel.listTags('top', '', { hiddenAuthors: hvTags.authors, hiddenIds: hvTags.ids }); recentTags = (all || []).slice(0, 10); } catch (_) {} ctx.body = ainavHomeView({ recentTags }); @@ -4429,6 +4639,10 @@ router ctx.redirect("/phone"); return; } + if (currentConfig.ux?.current === "rooms" && !config.public) { + ctx.redirect("/rooms"); + return; + } const homePage = /^[a-z0-9-]+$/i.test(String(currentConfig.homePage || "")) ? currentConfig.homePage : "activity"; ctx.redirect(`/${homePage}`); }) @@ -5022,12 +5236,12 @@ router const profileFilterType = String(ctx.query.type || '').toLowerCase(); const profileSpreadable = new Set(['post','audio','video','image','document','torrent','file','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','housing','job','report','chat','chatMessage','pad','padEntry','room','wikiPage','emergency','mailingList','logisticsRoute','podcast','podcastEpisode','campaign','forum','map','schoolCourse','feed','blog','poll']); const profileSpreadKeys = profileActionsOf(allActions, feedId).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && profileSpreadable.has(a.type)).map(a => a.id); - const profilePage = await renderedPage(async () => authorView({ feedId, oasisVersion: (String(feedId) === String(getViewerId()) ? OASIS_VERSION : null) || await getOasisVersion(feedId), messages: sanitizedMsgs, firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, deviceSource, stats, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap: new Map(), fediverseConfigured: fediverseModel.hasAccount() })); + const profilePage = await renderedPage(async () => authorView({ feedId, oasisVersion: (String(feedId) === String(getViewerId()) ? OASIS_VERSION : null) || await getOasisVersion(feedId), messages: sanitizedMsgs, firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship, ecoAddress, karmaScore: bankData.karmaScore, karmaReceived: bankData.karmaReceived || null, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, deviceSource, stats, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap: new Map(), fediverseConfigured: fediverseModel.hasAccount() })); const profilePageKeys = new Set(); for (const it of profilePage) { const c = (it && it.content) || {}; for (const k of [it && it.id, it && it.rootId, it && it.tipId, c.threadId, c.chatRoot, c.root && c.root.id, ...(Array.isArray(c.replies) ? c.replies.map(r => r && r.id) : [])]) if (k) profilePageKeys.add(k); } const spreadMap = await spreads.forMessages(profileSpreadKeys.filter(k => profilePageKeys.has(k))).catch(() => new Map()); await warmAuthorNames(profileActionsOf(allActions, feedId), sanitizedMsgs, profileItems); - ctx.body = await authorView({ feedId, oasisVersion: (String(feedId) === String(getViewerId()) ? OASIS_VERSION : null) || await getOasisVersion(feedId), messages: sanitizedMsgs, firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, deviceSource, stats, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap, fediverseConfigured: fediverseModel.hasAccount() }); + ctx.body = await authorView({ feedId, oasisVersion: (String(feedId) === String(getViewerId()) ? OASIS_VERSION : null) || await getOasisVersion(feedId), messages: sanitizedMsgs, firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship, ecoAddress, karmaScore: bankData.karmaScore, karmaReceived: bankData.karmaReceived || null, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, deviceSource, stats, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap, fediverseConfigured: fediverseModel.hasAccount() }); }) .get("/search", async (ctx) => { const inhabitantQ = String(ctx.query.inhabitant || '').trim(); @@ -5059,13 +5273,17 @@ router return true; }); const results = await searchModel.search({ query, types }); + const hvSearch = await hiddenView(); + const notHidden = (m) => !(m?.value?.author && hvSearch.authors.has(m.value.author)) && !(m?.key && hvSearch.ids.has(m.key)); + for (const t of Object.keys(results)) results[t] = (results[t] || []).filter(notHidden); + const hasLiteralHits = Object.values(results).some(list => Array.isArray(list) && list.length > 0); try { - if (checkMod(ctx, 'aiMod') && aiEmbedder.isInstalled()) { + if (!hasLiteralHits && checkMod(ctx, 'aiMod') && aiEmbedder.isInstalled()) { const ssbSem = await cooler.open(); const sem = await semanticSearch.search(ssbSem, query, { embed: aiEmbedder.embed, cosine: aiEmbedder.cosine, limit: getConfig().ssbLogStream?.limit || 1000 }); for (const { msg } of sem) { const t = msg && msg.value && msg.value.content && msg.value.content.type; - if (!t) continue; + if (!t || !notHidden(msg)) continue; if (!Array.isArray(results[t])) results[t] = []; if (!results[t].some(m => m && m.key === msg.key)) results[t].push(msg); } @@ -5363,6 +5581,7 @@ router .get("/files/:fileId", async (ctx) => { if (!checkMod(ctx, 'filesMod')) { ctx.redirect('/modules'); return; } const { fileId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query; + if (await tribeOutsider(fileId)) { ctx.redirect('/tribes'); return; } const file = await filesModel.getFileById(fileId, getViewerId()); const fav = await contentFavorites.getFavoriteSet('files'); const comments = await getVoteComments(file.key); @@ -5375,6 +5594,7 @@ router .get("/torrents/:torrentId", async (ctx) => { if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; } const { torrentId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query; + if (await tribeOutsider(torrentId)) { ctx.redirect('/tribes'); return; } const torrent = await torrentsModel.getTorrentById(torrentId, getViewerId()); const fav = await contentFavorites.getFavoriteSet('torrents'); const comments = await getVoteComments(torrent.key); @@ -5543,7 +5763,7 @@ router const me = getViewerId(); if (!asNumber && !asId) ctx.state.inlineError = t.pmInvalidRecipients; else if (asId === me || (asNumber && asNumber === phoneNumbers.phoneNumberOf(me))) ctx.state.inlineError = t.phoneOwnNumber; - else if (recipients.length >= PHONE_GROUP_MAX) ctx.state.inlineError = t.phoneLimitsHint; + else if (recipients.length >= PHONE_GROUP_MAX) ctx.state.inlineError = String(t.phoneLimitsHint).replace('{n}', String(PHONE_GROUP_MAX)); else { const v = asNumber || asId; if (!recipients.includes(v)) recipients.push(v); dial = ''; } } const to = recipients.join(','); @@ -5565,7 +5785,7 @@ router const raw = [ctx.request.body.to, ctx.request.body.dial].map(v => String(v || '').trim()).filter(Boolean).join(',').slice(0, 511); const fromForm = String(ctx.request.body.compose || '') === '1'; const t = require('../views/main_views').i18n; - const PHONE_ERRORS = { invalid: t.pmInvalidRecipients, max: t.phoneLimitsHint, pub: t.phoneErrorPub, busy: t.roomErrorBusy, unavailable: t.phoneUnavailable, self: t.phoneOwnNumber, roomDnd: t.roomLineDndError, refuses: t.phoneRecipientRefuses, nobody: t.roomNobodyInside }; + const PHONE_ERRORS = { invalid: t.pmInvalidRecipients, max: String(t.phoneLimitsHint).replace('{n}', String(PHONE_GROUP_MAX)), pub: t.phoneErrorPub, busy: t.roomErrorBusy, unavailable: t.phoneUnavailable, self: t.phoneOwnNumber, roomDnd: t.roomLineDndError, refuses: t.phoneRecipientRefuses, nobody: t.roomNobodyInside }; const me = (await cooler.open()).id; const tokens = raw.split(/[,\n]+/).map(t => t.trim()).filter(Boolean).flatMap(t => phoneNumbers.normalizeNumber(t) ? [t] : t.split(/\s+/)); const typed = new Map(); @@ -5651,7 +5871,7 @@ router .get('/phone/video/:peer', async (ctx) => { if (config.public || !checkMod(ctx, 'phoneMod') || !isLoopbackRequest(ctx)) { ctx.status = 404; return; } const peer = String(ctx.params.peer || ''); - if (peer !== 'me' && peer !== 'grid' && !FEED_ID_RE.test(peer)) { ctx.status = 404; return; } + if (peer !== 'me' && peer !== 'grid' && peer !== 'film:me' && !FEED_ID_RE.test(peer) && !(peer.startsWith('film:') && FEED_ID_RE.test(peer.slice(5)))) { ctx.status = 404; return; } const source = await phoneModel.videoFrames(peer).catch(() => null); if (!source) { ctx.status = 404; return; } const { PassThrough } = require('stream'); @@ -5779,7 +5999,8 @@ router }) .get('/tags', async ctx => { const filter = qf(ctx, 'top'), search = String(ctx.query.search || '').trim(); - const tags = await tagsModel.listTags(filter, search); + const hvTags = await hiddenView(); + const tags = await tagsModel.listTags(filter, search, { hiddenAuthors: hvTags.authors, hiddenIds: hvTags.ids }); ctx.body = await tagsView(tags, filter, search); }) .get('/emergencies', async ctx => { @@ -6144,16 +6365,18 @@ router const pr = profiles ? (profiles.get(u.id) || {}) : null; const prefsNow = pr ? about.visibilityFrom(pr.visibilityPrefs) : await about.visibilityPrefs(u.id).catch(() => null); const published = Number(u.karmaScore) > 0; - const [kd, prefs, rel, fp, src, carbon] = await Promise.all([ + const [kd, prefs, rel, fp, src, carbon, kr] = await Promise.all([ (prefsNow && prefsNow.ubi === true) || !published ? bankFor(u) : { karmaScore: Number(u.karmaScore), estimatedUBI: 0, lastClaimedDate: null, totalClaimed: 0 }, prefsNow, friend.getRelationship(u.id).catch(() => null), pr ? (typeof pr.gpgFingerprint === 'string' ? pr.gpgFingerprint : '') : about.gpgFingerprint(u.id).catch(() => ''), pr ? (typeof pr.deviceSource === 'string' && pr.deviceSource.trim() ? pr.deviceSource : null) : about.deviceSource(u.id).catch(() => null), - getCarbonGramsForFeed(u.id).catch(() => 0) + getCarbonGramsForFeed(u.id).catch(() => 0), + bankingModel.getKarmaReceived(u.id).catch(() => null) ]); return [u.id, { ecoAddress: addrMap.get(u.id) || null, + karmaReceived: kr, karmaScore: kd.karmaScore ?? (typeof u.karmaScore === 'number' ? u.karmaScore : 0), estimatedUBI: kd.estimatedUBI || 0, lastClaimedDate: kd.lastClaimedDate || null, @@ -6215,7 +6438,7 @@ router const totalClaimed = bank?.totalClaimed || 0; const oasisVersion = (String(id) === String(currentUserId) ? OASIS_VERSION : null) || await getOasisVersion(id); await warmAuthorNames(feed); - ctx.body = await inhabitantsProfileView({ about: aboutMsg, cv, feed, photo, relationship, karmaScore, estimatedUBI, lastClaimedDate, totalClaimed, carbonGrams, larpHouse, lastActivityBucket: bucketInfo.bucket, viewedId: id, visibilityPrefs, deviceSource, stats, oasisVersion }, currentUserId, fediverseModel.hasAccount()); + ctx.body = await inhabitantsProfileView({ about: aboutMsg, cv, feed, photo, relationship, karmaScore, karmaReceived: bank && bank.karmaReceived ? bank.karmaReceived : null, estimatedUBI, lastClaimedDate, totalClaimed, carbonGrams, larpHouse, lastActivityBucket: bucketInfo.bucket, viewedId: id, visibilityPrefs, deviceSource, stats, oasisVersion }, currentUserId, fediverseModel.hasAccount()); }) .get('/parliament', async (ctx) => { if (!checkMod(ctx, 'parliamentMod')) return ctx.redirect('/modules'); @@ -6379,12 +6602,12 @@ router const reachStateNow = async () => { if (!reachState) reachState = await tribesContentModel.exposureState().catch(() => new Map()); return reachState; }; const listByTribeAllChain = async (tribeId, contentType) => { const st = await reachStateNow(); - if (!(await tribesModel.isTribeMember(getViewerId(), tribeId))) return tribesContentModel.listExposed(tribeId, contentType, 'oasis', st).catch(() => []); + if (!(await tribesModel.isTribeMember(getViewerId(), tribeId))) return dropHidden(await tribesContentModel.listExposed(tribeId, contentType, 'oasis', st).catch(() => [])); const chainIds = await tribesModel.getChainIds(tribeId).catch(() => [tribeId]); const results = await Promise.all(chainIds.map(id => tribesContentModel.listByTribe(id, contentType).catch(() => []))); const seen = new Set(); - return results.flat().filter(item => { const k = item.id || item.key; if (seen.has(k)) return false; seen.add(k); return true; }) - .map(item => { const e = st.get(item.originId); return { ...item, reach: e ? e.level : 'tribe', exposedBy: e && e.level !== 'tribe' ? e.by : null }; }); + return dropHidden(results.flat().filter(item => { const k = item.id || item.key; if (seen.has(k)) return false; seen.add(k); return true; }) + .map(item => { const e = st.get(item.originId); return { ...item, reach: e ? e.level : 'tribe', exposedBy: e && e.level !== 'tribe' ? e.by : null }; })); }; const tribe = await tribesModel.getTribeById(ctx.params.tribeId).catch(() => null); if (!tribe) { ctx.redirect('/tribes'); return; } @@ -6396,17 +6619,44 @@ router ctx.redirect('/tribes'); return; } - const section = ctx.query.section || 'activity'; + const TRIBE_SECTIONS = new Set(['activity', 'inhabitants', 'overview', 'feed', 'events', 'tasks', 'votations', 'polls', 'forum', 'subtribes', 'search', 'images', 'audios', 'videos', 'documents', 'bookmarks', 'torrents', 'files', 'maps', 'pads', 'wiki', 'chats', 'rooms', 'calendars', 'governance', 'trending', 'tags']); + const section = TRIBE_SECTIONS.has(String(ctx.query.section || '')) ? String(ctx.query.section) : 'activity'; const tribeViewerIsMember = await tribesModel.isTribeMember(uid, tribe.id).catch(() => false); const withTribeReach = async (list, idOf) => { const st = await reachStateNow(); return list.map(x => { const e = st.get(idOf(x)); return { ...x, reach: e ? e.level : 'tribe', exposedBy: e && e.level !== 'tribe' ? e.by : null }; }); }; const exposedModuleItems = async (contentType) => { - const list = await tribesContentModel.listExposed(tribe.id, contentType, 'oasis', await reachStateNow()).catch(() => []); + const list = await dropHidden(await tribesContentModel.listExposed(tribe.id, contentType, 'oasis', await reachStateNow()).catch(() => [])); const sectionHref = `/tribe/${encodeURIComponent(tribe.id)}?section=${TRIBE_SECTION_OF[contentType]}`; return list.map(x => SNAPSHOT_SHAPES[contentType]({ ...x, tribeOrigin: { tribeId: tribe.rootId, title: tribe.title, href: sectionHref, tribeHref: `/tribe/${encodeURIComponent(tribe.id)}`, level: x.reach, openedBy: x.exposedBy } })); }; + const tribeTaggable = async () => { + const allContent = await listByTribeAllChain(tribe.id, null); + const [allPadsT, allChatsT, allCalsT, allMapsT, allTorrentsT, allFilesT, subTribesT, tribeChainT] = await Promise.all([ + padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []), + torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + filesModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + tribesModel.listSubTribes(tribe.id, uid).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSetT = new Set(tribeChainT); + const standaloneTagged = [ + ...allPadsT.filter(p => tribeChainSetT.has(p.tribeId)).map(p => ({ ...p, contentType: 'pad', id: p.rootId || p.key })), + ...allChatsT.filter(c => tribeChainSetT.has(c.tribeId)).map(c => ({ ...c, contentType: 'chat', id: c.rootId || c.key })), + ...allCalsT.filter(c => tribeChainSetT.has(c.tribeId)).map(c => ({ ...c, contentType: 'calendar', id: c.rootId || c.key })), + ...allMapsT.filter(m => tribeChainSetT.has(m.tribeId)).map(m => ({ ...m, contentType: 'map', id: m.rootId || m.key })), + ...allTorrentsT.filter(t => tribeChainSetT.has(t.tribeId)).map(t => ({ ...t, contentType: 'torrent', id: t.rootId || t.key })), + ...allFilesT.filter(t => tribeChainSetT.has(t.tribeId)).map(t => ({ ...t, contentType: 'file', id: t.rootId || t.key })), + ...subTribesT.map(st => ({ ...st, contentType: 'tribe', tags: Array.isArray(st.tags) ? st.tags : [], title: st.title, description: st.description, author: st.author, createdAt: st.createdAt })) + ]; + const hashtagsOf = (text) => (String(text || '').match(/#[\w\u0080-\uFFFF]{1,32}(?![\w\u0080-\uFFFF])/g) || []).map(t => t.slice(1).toLowerCase()); + const allTaggable = [...allContent.map(i => i.contentType === 'feed' ? { ...i, tags: [...new Set([...(Array.isArray(i.tags) ? i.tags : []), ...hashtagsOf(i.description)])] } : i), ...standaloneTagged]; + return allTaggable; + }; const contentTypeMap = { events: 'event', tasks: 'task', reports: 'report', votations: 'votation', market: 'market', jobs: 'job', projects: 'project', media: 'media' }; const mediaSections = { 'media-audio': 'media', 'media-video': 'media', 'media-images': 'media', 'media-documents': 'media', 'media-bookmarks': 'media', 'images': 'media', 'audios': 'media', 'videos': 'media', 'documents': 'media', 'bookmarks': 'media' }; let sectionData = null; @@ -6504,42 +6754,16 @@ router }); sectionData = { items, period }; } else if (section === 'tags') { - const allContent = await listByTribeAllChain(tribe.id, null); - const [allPadsT, allChatsT, allCalsT, allMapsT, allTorrentsT, allFilesT, subTribesT, tribeChainT] = await Promise.all([ - padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), - chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), - calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), - mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []), - torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), - - filesModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), - tribesModel.listSubTribes(tribe.id, uid).catch(() => []), - tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) - ]); - const tribeChainSetT = new Set(tribeChainT); - const standaloneTagged = [ - ...allPadsT.filter(p => tribeChainSetT.has(p.tribeId)).map(p => ({ ...p, contentType: 'pad', id: p.rootId || p.key })), - ...allChatsT.filter(c => tribeChainSetT.has(c.tribeId)).map(c => ({ ...c, contentType: 'chat', id: c.rootId || c.key })), - ...allCalsT.filter(c => tribeChainSetT.has(c.tribeId)).map(c => ({ ...c, contentType: 'calendar', id: c.rootId || c.key })), - ...allMapsT.filter(m => tribeChainSetT.has(m.tribeId)).map(m => ({ ...m, contentType: 'map', id: m.rootId || m.key })), - ...allTorrentsT.filter(t => tribeChainSetT.has(t.tribeId)).map(t => ({ ...t, contentType: 'torrent', id: t.rootId || t.key })), - ...allFilesT.filter(t => tribeChainSetT.has(t.tribeId)).map(t => ({ ...t, contentType: 'file', id: t.rootId || t.key })), - ...subTribesT.map(st => ({ ...st, contentType: 'tribe', tags: Array.isArray(st.tags) ? st.tags : [], title: st.title, description: st.description, author: st.author, createdAt: st.createdAt })) - ]; - const allTaggable = [...allContent, ...standaloneTagged]; const tagMap = new Map(); - for (const item of allTaggable) { + for (const item of await tribeTaggable()) { for (const tag of (item.tags || []).filter(Boolean)) { - const lower = String(tag).toLowerCase().trim(); + const lower = String(tag).toLowerCase().trim().replace(/^#+/, ''); if (!lower) continue; - if (!tagMap.has(lower)) tagMap.set(lower, { tag: lower, count: 0, items: [] }); - const entry = tagMap.get(lower); - entry.count++; - entry.items.push(item); + if (!tagMap.has(lower)) tagMap.set(lower, { tag: lower, count: 0 }); + tagMap.get(lower).count++; } } - const selectedTag = (ctx.query.tag || '').toLowerCase().trim(); - sectionData = { tags: [...tagMap.values()].sort((a, b) => b.count - a.count), selectedTag, filteredItems: selectedTag && tagMap.has(selectedTag) ? tagMap.get(selectedTag).items : [] }; + sectionData = { tags: [...tagMap.values()].sort((a, b) => b.count - a.count) }; } else if (section === 'maps') { const [allMaps, tribeChain] = await Promise.all([ mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []), @@ -6613,23 +6837,22 @@ router } else if (section === 'search') { const sq = (ctx.query.q || '').trim().toLowerCase(); let results = []; - if (sq.length >= 2) { + if (sq.length >= 2 && sq.startsWith('#')) { + const wanted = sq.replace(/^#+/, ''); + results = wanted ? (await tribeTaggable()).filter(item => (item.tags || []).some(t => String(t).toLowerCase().trim().replace(/^#+/, '') === wanted)) : []; + results.sort((a, b) => (Date.parse(b.createdAt) || 0) - (Date.parse(a.createdAt) || 0)); + } else if (sq.length >= 2) { const allContent = await listByTribeAllChain(tribe.id, null); results = allContent.filter(item => (item.title || '').toLowerCase().includes(sq) || (item.description || '').toLowerCase().includes(sq) || (item.tags || []).join(' ').toLowerCase().includes(sq)); } + const fromTs = ctx.query.from ? new Date(ctx.query.from).getTime() : NaN; + const toTs = ctx.query.to ? new Date(ctx.query.to).getTime() : NaN; + const byInhabitant = String(ctx.query.inhabitant || '').trim(); + const tsOfItem = (item) => Date.parse(item.createdAt) || Number(item._ts) || 0; + if (Number.isFinite(fromTs)) results = results.filter(item => tsOfItem(item) >= fromTs); + if (Number.isFinite(toTs)) results = results.filter(item => tsOfItem(item) <= toTs); + if (byInhabitant) results = results.filter(item => String(item.author || '') === byInhabitant); sectionData = { query: ctx.query.q || '', results }; - } else if (section === 'opinions') { - const allContent = await listByTribeAllChain(tribe.id, null); - const opinionated = allContent.filter(i => i.opinions && Object.keys(i.opinions).length > 0).sort((a, b) => { - const sum = o => Object.values(o.opinions || {}).reduce((s, n) => s + n, 0); - return sum(b) - sum(a); - }); - sectionData = { items: allContent.filter(i => i.contentType !== 'forum-reply' && i.contentType !== 'pixelia'), opinionated }; - } else if (section === 'pixelia') { - const pixels = await listByTribeAllChain(tribe.id, 'pixelia'); - const coordMap = new Map(); - for (const px of pixels) { const existing = coordMap.get(px.title); if (!existing || (Date.parse(px.createdAt) || 0) > (Date.parse(existing.createdAt) || 0)) coordMap.set(px.title, px); } - sectionData = { pixels: [...coordMap.values()] }; } else if (section === 'overview') { const events = await listByTribeAllChain(tribe.id, 'event').catch(() => []); const tasks = await listByTribeAllChain(tribe.id, 'task').catch(() => []); @@ -6852,11 +7075,11 @@ router const profileFilterType = String(ctx.query.type || '').toLowerCase(); const profileSpreadable = new Set(['post','audio','video','image','document','torrent','file','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','housing','job','report','chat','chatMessage','pad','padEntry','room','wikiPage','emergency','mailingList','logisticsRoute','podcast','podcastEpisode','campaign','forum','map','schoolCourse']); const profileSpreadKeys = profileActionsOf(allActions, myFeedId).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && profileSpreadable.has(a.type)).map(a => a.id); - const profilePage = await renderedPage(async () => authorView({ feedId: myFeedId, oasisVersion: OASIS_VERSION || await getOasisVersion(myFeedId), messages: sanitizeMessages(messages), firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship: { me: true }, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, stats, baseUrl, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap: new Map(), fediverseConfigured: fediverseModel.hasAccount() })); + const profilePage = await renderedPage(async () => authorView({ feedId: myFeedId, oasisVersion: OASIS_VERSION || await getOasisVersion(myFeedId), messages: sanitizeMessages(messages), firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship: { me: true }, ecoAddress, karmaScore: bankData.karmaScore, karmaReceived: bankData.karmaReceived || null, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, stats, baseUrl, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap: new Map(), fediverseConfigured: fediverseModel.hasAccount() })); const profilePageKeys = new Set(); for (const it of profilePage) { const c = (it && it.content) || {}; for (const k of [it && it.id, it && it.rootId, it && it.tipId, c.threadId, c.chatRoot, c.root && c.root.id, ...(Array.isArray(c.replies) ? c.replies.map(r => r && r.id) : [])]) if (k) profilePageKeys.add(k); } const spreadMap = await spreads.forMessages(profileSpreadKeys.filter(k => profilePageKeys.has(k))).catch(() => new Map()); - ctx.body = await authorView({ feedId: myFeedId, oasisVersion: OASIS_VERSION || await getOasisVersion(myFeedId), messages: sanitizeMessages(messages), firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship: { me: true }, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, stats, baseUrl, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap, fediverseConfigured: fediverseModel.hasAccount() }); + ctx.body = await authorView({ feedId: myFeedId, oasisVersion: OASIS_VERSION || await getOasisVersion(myFeedId), messages: sanitizeMessages(messages), firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship: { me: true }, ecoAddress, karmaScore: bankData.karmaScore, karmaReceived: bankData.karmaReceived || null, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, stats, baseUrl, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap, fediverseConfigured: fediverseModel.hasAccount() }); }) .get("/profile/:feedId/gpg.asc", async (ctx) => { const feedId = String(ctx.params.feedId || '').trim(); @@ -7065,6 +7288,16 @@ router }; ctx.body = await json(message); }) + .get("/blob/sealed/:manifestId", async (ctx) => { + if (config.public) { ctx.status = 404; return; } + const pointer = sealedPointerOf(ctx.params.manifestId, ctx.query); + if (!pointer || !(await fileshareModel.ensureAvailable(pointer))) { ctx.status = 404; return; } + ctx.type = pointer.mime; + ctx.set('X-Content-Type-Options', 'nosniff'); + ctx.set('Cache-Control', 'private, max-age=3600'); + ctx.set('Content-Disposition', contentDisposition(SEALED_INLINE.test(pointer.mime) ? 'inline' : 'attachment', pointer.filename)); + ctx.body = fileshareModel.readShareStream(pointer); + }) .get("/blob/:blobId", async (ctx) => { blobCacheModel.touch(ctx.params.blobId); return serveBlob(ctx); }) .get("/c/blob/:cnBlobId", async (ctx) => { const blobId = ctx.params.cnBlobId; @@ -7237,6 +7470,9 @@ router ctx.body = await settingsView({ filter: String(ctx.query.filter || 'oasis'), nextcloudAccount: nextcloudModel.getAccount(), theme, version: version.toString(), aiPrompt: cfg.ai?.prompt || "", aiExportCount, blobCache, fediverseAccount: fediverseModel.getAccount(), telegramAccount: fediverseModel.telegram.getAccount(), telegramLogin: fediverseModel.telegram.loginState(), peertubeAccount: fediverseModel.peertube.getAccount(), verification, rebuild }); }) .get("/peers", async (ctx) => { + const peersFilter = String(ctx.query.filter || 'status').toLowerCase(); + const peersAdvanced = !config.public && isLoopbackRequest(ctx); + if ((peersFilter === 'health' || peersFilter === 'lan') && !peersAdvanced) { ctx.redirect('/peers'); return; } const { discoveredPeers, unknownPeers } = await meta.discovered(); const lanBroadcastActive = !ssbConfig.pub && getConfig().lanBroadcasting !== false; const technicalPeers = await listConnectionPeers(); @@ -7249,6 +7485,10 @@ router } } for (const tp of technicalPeers) if (tp && tp.key) versionKeys.add(tp.key); + if (peersFilter === 'lan') { + await refreshLanPeers(true); + for (const k of viewerFilters.lanPeersSeen().keys()) versionKeys.add(k); + } const versions = {}; const lastErrors = await readPeerLastErrors(); const onionKeys = await onionPeerKeys(); @@ -7257,7 +7497,35 @@ router versions[k] = String(k) === String(getViewerId()) ? OASIS_VERSION : await getOasisVersion(k).catch(() => null); if (!versions[k] && await feedHasMessages(k)) staleKeys.push(k); })); - ctx.body = await peersView({ onlinePeers: onlinePeersList, discoveredPeers, unknownPeers, lanBroadcastActive, technicalPeers, versions, staleKeys, onionKeys: [...onionKeys], ownVersion: OASIS_VERSION, lastErrors, paused: getConfig().networkPaused === true || process.env.OASIS_NETWORK_PAUSED === '1' }); + const peerNameOf = async (k) => { try { return await about.name(k); } catch (_) { return ''; } }; + let health = null; + let lan = null; + if (peersFilter === 'health') { + const { failures } = await peerFailures(await cooler.open()); + const dead = await deadPeerKeys(); + const rows = await Promise.all(technicalPeers.map(async (tp) => { + const ck = peerHealth.canonicalKey(tp.key); + const err = lastErrors[ck] || null; + return { + key: tp.key, name: await peerNameOf(tp.key), host: tp.host || '', port: tp.port || null, + state: tp.state || 'idle', stateChange: tp.stateChange || null, + tor: onionKeys.has(ck) || /\.onion$/i.test(String(tp.host || '')), + failures: failures.get(ck) || 0, failingSince: peerHealth.failingSince(tp.key), dead: dead.has(ck), + reason: err && err.reason ? err.reason : null + }; + })); + const order = { connected: 0 }; + rows.sort((x, y) => ((order[x.state] ?? 1) - (order[y.state] ?? 1)) || (Number(x.dead) - Number(y.dead)) || (Number(y.failures) - Number(x.failures))); + health = { rows }; + } + if (peersFilter === 'lan') { + const onlineKeys = connectedPeerKeys(onlinePeersList, technicalPeers); + const rows = []; + for (const [key, seenAt] of viewerFilters.lanPeersSeen()) rows.push({ key, name: await peerNameOf(key), seenAt, online: onlineKeys.has(peerHealth.canonicalKey(key)) }); + rows.sort((x, y) => (Number(y.online) - Number(x.online)) || ((y.seenAt || 0) - (x.seenAt || 0))); + lan = { rows, broadcasting: lanBroadcastActive }; + } + ctx.body = await peersView({ onlinePeers: onlinePeersList, discoveredPeers, unknownPeers, lanBroadcastActive, technicalPeers, versions, staleKeys, onionKeys: [...onionKeys], ownVersion: OASIS_VERSION, lastErrors, paused: getConfig().networkPaused === true || process.env.OASIS_NETWORK_PAUSED === '1', filter: peersFilter, advanced: peersAdvanced, health, lan }); }) .get("/graphos", async (ctx) => { if (!checkMod(ctx, 'graphosMod')) return ctx.redirect('/modules'); @@ -7536,7 +7804,7 @@ router .get("/mentions", async (ctx) => { const filter = String(ctx.query.filter || 'ALL'); const q = String(ctx.query.q || '').trim(); - const all = await mentionsModel.listMentions('ALL', { q }); + const all = await mentionsAllowed(await mentionsModel.listMentions('ALL', { q })); const counts = await mentionsModel.countTypes(all); const items = filter === 'ALL' ? all : all.filter(x => x.type === filter); await warmAuthorNames(pageOf(ctx, items)); @@ -7596,7 +7864,7 @@ router let trendingTags = []; let activeUsers = []; if (uxFeed) { - try { trendingTags = ((await tagsModel.listTags('top')) || []).slice(0, 10); } catch (_) {} + try { const hvTags = await hiddenView(); trendingTags = ((await tagsModel.listTags('top', '', { hiddenAuthors: hvTags.authors, hiddenIds: hvTags.ids })) || []).slice(0, 10); } catch (_) {} try { const ids = []; const seen = new Set(); @@ -7626,7 +7894,7 @@ router .get("/feed/:feedId", async (ctx) => { const feed = await feedModel.getFeedById(ctx.params.feedId); if (!feed) { ctx.redirect('/feed'); return; } - const comments = await feedModel.getComments(ctx.params.feedId).catch(() => []); + const comments = await dropHidden(await feedModel.getComments(ctx.params.feedId).catch(() => [])); const singleCensus = await censusOf('feeds', () => feedModel.listFeeds({ filter: 'ALL', q: '', tag: '' })).catch(() => []); if (feed) feed.clearnet = await clearnetPublic('feed', feed).catch(() => false); ctx.body = singleFeedView(feed, comments, { censusList: singleCensus, spreads: await spreads.forMessage(feed.key).catch(() => null) }); @@ -7969,12 +8237,12 @@ router const filter = String(ctx.query.filter || 'ALL').toUpperCase(); const q = String(ctx.query.q || '').trim(); const reason = String(ctx.query.reason || '').trim(); - const [{ matches, total, hasProfile, kindsAvail, anyMatches, reasonsAvail, reason: activeReason }, cohesion] = await Promise.all([ - dataModel.listMatches(filter, { q, reason }), + const [{ matches, total, hasProfile, kindsAvail, kindsMatched, anyMatches, reasonsAvail, reason: activeReason }, cohesion] = await Promise.all([ + dataModel.listMatches(filter, { q, reason, hidden: await hiddenView() }), dataModel.cohesion().catch(() => null) ]); await warmAuthorNames(matches); - ctx.body = await dataView({ filter, q, matches, total, hasProfile, cohesion, kindsAvail, anyMatches, reasonsAvail, reason: activeReason }); + ctx.body = await dataView({ filter, q, matches, total, hasProfile, cohesion, kindsAvail, kindsMatched, anyMatches, reasonsAvail, reason: activeReason }); }) .get('/polls', async ctx => { if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } @@ -8001,6 +8269,11 @@ router if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } const poll = await pollsModel.getPollById(ctx.params.pollId, getViewerId()); if (poll.chatId) { ctx.redirect(`/chats/${encodeURIComponent(poll.chatId)}`); return; } + if (poll.tribeId) { + const gate = await pollTribeGate(ctx.params.pollId); + if (!gate.tribe) { ctx.redirect('/tribes'); return; } + if (!gate.ok) { ctx.body = tribeAccessDeniedView(gate.tribe); return; } + } const comments = await getVoteComments(poll.id); const fav = await contentFavorites.getFavoriteSet('polls'); await enrichItemLifetime(poll, { key: poll.id }); @@ -8049,6 +8322,7 @@ router }) .post('/polls/vote/:id', koaBody(), async ctx => { if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + if (!(await pollTribeGate(ctx.params.id)).ok) { ctx.status = 403; ctx.redirect('/tribes'); return; } const choices = [].concat(ctx.request.body.choices || []).filter(Boolean); try { await pollsModel.vote(ctx.params.id, choices); } catch (err) { sendErrorPage(ctx, err.message || String(err), { status: 400 }); return; } @@ -8070,12 +8344,19 @@ router }) .post('/polls/opinions/:pollId/:category', koaBody(), async ctx => { if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + if ((await pollTribeGate(ctx.params.pollId)).poll?.tribeId) { ctx.status = 403; ctx.redirect(safeReturnTo(ctx, '/tribes', ['/tribe', '/polls'])); return; } try { await pollsModel.createOpinion(ctx.params.pollId, ctx.params.category); } catch (e) { return /already/i.test(String(e && e.message)) ? failWith(ctx, 'opinionAlreadyGiven') : actionFail(ctx); } try { activityModel.invalidateCache(); } catch (_) {} ctx.redirect(safeReturnTo(ctx, `/polls/${encodeURIComponent(ctx.params.pollId)}`, ['/polls'])); }) - .post('/polls/:pollId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'polls', 'pollId')) - .post('/polls/favorites/add/:id', koaBody(), async ctx => favAction(ctx, 'polls', 'add')) + .post('/polls/:pollId/comments', koaBodyMiddleware, async ctx => { + if ((await pollTribeGate(ctx.params.pollId)).poll?.tribeId) { ctx.status = 403; ctx.redirect(safeReturnTo(ctx, '/tribes', ['/tribe', '/polls'])); return; } + return commentAction(ctx, 'polls', 'pollId'); + }) + .post('/polls/favorites/add/:id', koaBody(), async ctx => { + if (!(await pollTribeGate(ctx.params.id)).ok) { ctx.status = 403; ctx.redirect('/tribes'); return; } + return favAction(ctx, 'polls', 'add'); + }) .post('/polls/favorites/remove/:id', koaBody(), async ctx => favAction(ctx, 'polls', 'remove')) .get('/blogs', async ctx => { if (!checkMod(ctx, 'blogsMod')) { ctx.redirect('/modules'); return; } @@ -8117,7 +8398,8 @@ router const blobMarkdown = await handleBlobUploads(ctx, 'blob', 9); if (blobMarkdown.length) text += blobMarkdown.join(''); const allowComments = [].concat(b.allowComments).includes('1'); - ctx.body = await blogView([], 'CREATE', { draft: { text, subject, allowComments, clearnet: String(b.clearnet || '') === '1' } }); + const tags = String(b.tags || '').split(',').map(t => t.trim()).filter(Boolean); + ctx.body = await blogView([], 'CREATE', { draft: { text, subject, allowComments, tags, clearnet: String(b.clearnet || '') === '1' } }); }) .post('/blogs/create', koaBody({ multipart: true, urlencoded: true, formidable: { multiples: true, maxFileSize: maxSize } }), async ctx => { if (!checkMod(ctx, 'blogsMod')) { ctx.redirect('/modules'); return; } @@ -8127,17 +8409,18 @@ router const blobMarkdown = await handleBlobUploads(ctx, 'blob', 9); if (blobMarkdown.length) text += blobMarkdown.join(''); const allowComments = [].concat(b.allowComments).includes('1'); + const tags = String(b.tags || '').split(',').map(t => t.trim()).filter(Boolean); const keepBlog = async (err) => { ctx.status = 400; ctx.state.inlineError = publishErrorText(err); - ctx.body = await blogView([], 'CREATE', { draft: { text, subject, allowComments, clearnet: String(b.clearnet || '') === '1' } }); + ctx.body = await blogView([], 'CREATE', { draft: { text, subject, allowComments, tags, clearnet: String(b.clearnet || '') === '1' } }); }; if (longText.tooLong(text)) { await keepBlog(new Error('Text too long')); return; } let mentions = []; try { mentions = await extractMentions(text); } catch (_) { mentions = []; } let createdBlog = null; try { - createdBlog = await blogModel.createBlog({ text, subject, mentions, allowComments }); + createdBlog = await blogModel.createBlog({ text, subject, mentions, allowComments, tags }); } catch (err) { await keepBlog(err); return; } try { await applyClearnetChoice(ctx, 'posts', createdBlog); } catch (err) { sendErrorPage(ctx, err.message || String(err), { status: 400 }); return; } try { activityModel.invalidateCache(); } catch (_) {} @@ -8253,7 +8536,8 @@ router const aiNavEnabled = cfg.modules && cfg.modules.aiNavMod === 'on'; const chatsEnabled = cfg.modules && cfg.modules.chatsMod === 'on'; const phoneEnabled = !!(cfg.modules && cfg.modules.phoneMod !== 'off'); - const next = (v === 'ainav' && aiNavEnabled) ? 'ainav' : (v === 'chats' && chatsEnabled) ? 'chats' : v === 'feed' ? 'feed' : (v === 'phone' && phoneEnabled) ? 'phone' : 'blocks'; + const roomsEnabled = !!(cfg.modules && cfg.modules.roomsMod !== 'off'); + const next = (v === 'ainav' && aiNavEnabled) ? 'ainav' : (v === 'chats' && chatsEnabled) ? 'chats' : v === 'feed' ? 'feed' : (v === 'phone' && phoneEnabled) ? 'phone' : (v === 'rooms' && roomsEnabled) ? 'rooms' : 'blocks'; cfg.ux = { ...(cfg.ux && typeof cfg.ux === 'object' ? cfg.ux : {}), current: next }; saveConfig(cfg); try { onboardingModel.markStep('ux'); } catch (_) {} @@ -8794,7 +9078,49 @@ router ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); return; } - ctx.body = await clearnetRoomView(room); + const preview = !config.public && await clearnetRoomPreview(ctx, room); + const live = preview || (config.public && (await roomsModel.viewerRooms()).includes(room.rid)); + ctx.body = await clearnetRoomView(room, { live, sound: !preview, base: `/c/rooms/${encodeURIComponent(ctx.params.id)}` }); + }) + .get('/c/rooms/:id/:stream', async (ctx) => { + const feed = String(ctx.params.stream || ''); + if (feed !== 'live' && feed !== 'sound') { ctx.status = 404; ctx.body = ''; return; } + const room = await roomsModel.getRoomById(await clearnetIdFor('rooms', ctx.params.id)).catch(() => null); + if (!(await clearnetRoomOk(room)) || (clearnetRoomWatchers.get(room.rid) || 0) >= CLEARNET_ROOM_WATCHERS_MAX) { ctx.status = 404; ctx.body = ''; return; } + const source = config.public + ? await roomsModel.publicFeed(room.rid, feed === 'sound' ? 'sound' : 'grid').catch(() => null) + : feed === 'live' && await clearnetRoomPreview(ctx, room) ? await phoneModel.videoFrames('grid').catch(() => null) : null; + if (!source) { ctx.status = 404; ctx.body = ''; return; } + const { PassThrough } = require('stream'); + const body = new PassThrough(); + const rid = room.rid; + clearnetRoomWatchers.set(rid, (clearnetRoomWatchers.get(rid) || 0) + 1); + let released = false; + const release = () => { if (released) return; released = true; const n = (clearnetRoomWatchers.get(rid) || 1) - 1; if (n > 0) clearnetRoomWatchers.set(rid, n); else clearnetRoomWatchers.delete(rid); }; + ctx.status = 200; + ctx.set('Cache-Control', 'no-store'); + ctx.set('X-Content-Type-Options', 'nosniff'); + if (feed === 'sound') { + ctx.set('Content-Type', 'audio/wav'); + const h = Buffer.alloc(44); + h.write('RIFF', 0); h.writeUInt32LE(0xffffffff, 4); h.write('WAVE', 8); + h.write('fmt ', 12); h.writeUInt32LE(16, 16); h.writeUInt16LE(1, 20); h.writeUInt16LE(1, 22); + h.writeUInt32LE(SOUND_RATE, 24); h.writeUInt32LE(SOUND_RATE * 2, 28); h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34); + h.write('data', 36); h.writeUInt32LE(0xffffffff - 36, 40); + body.write(h); + } else { + ctx.set('Content-Type', 'multipart/x-mixed-replace; boundary=oasisframe'); + } + ctx.body = body; + const sink = pull.drain((chunk) => { + if (body.destroyed) { sink.abort(); return; } + if (feed === 'sound') { if (body.writableLength < SOUND_BACKLOG_MAX) body.write(Buffer.from(chunk)); return; } + body.write(`--oasisframe\r\nContent-Type: image/jpeg\r\nContent-Length: ${chunk.length}\r\n\r\n`); + body.write(Buffer.from(chunk)); + body.write('\r\n'); + }, () => { release(); try { body.end(); } catch (_) {} }); + ctx.req.on('close', () => { release(); try { sink.abort(); } catch (_) {} }); + pull(source, sink); }) .get('/c/maps/:id', async (ctx) => { const mapItem = await mapsModel.getMapById(await clearnetIdFor('maps', ctx.params.id)).catch(() => null); @@ -9116,7 +9442,8 @@ router author: authorId, authorName, contentWarning: content.contentWarning || '', - sentAt: msg.timestamp || content.sentAt + sentAt: msg.timestamp || content.sentAt, + tags: Array.isArray(content.tags) ? content.tags : [] }); }) .get("/clearnet", async (ctx) => { ctx.redirect("/c"); }) @@ -9396,7 +9723,7 @@ router if (!isOpen && chat.author !== uid && !members.includes(uid)) { ctx.redirect('/chats?filter=all'); return; } } const fav = await contentFavorites.getFavoriteSet('chats'); - const messages = await chatsModel.listMessages(chat.rootId || chat.key); + const messages = await dropHidden(await chatsModel.listMessages(chat.rootId || chat.key), (m) => m && (m.author || (m.value && m.value.author))); const isTribeMember = !!parentTribe; const pollsEnabled = checkMod(ctx, 'pollsMod'); const chatPolls = pollsEnabled @@ -10102,7 +10429,7 @@ router .get("/favorites", async (ctx) => { const filter = qf(ctx), q = String(ctx.query.q || '').trim(); const data = await favoritesModel.listAll({ filter }); - const items = applyTextSearch(data.items, q, ['title', 'name', 'description', 'category', 'url', 'tags']); + const items = applyTextSearch(await dropHidden(data.items), q, ['title', 'name', 'description', 'category', 'url', 'tags']); ctx.body = await favoritesView(items, filter, data.counts, q, { spreadMap: await spreads.forMessages(pageOf(ctx, items).map(x => x && x.favId)).catch(() => new Map()) }); }) .get("/logs", async (ctx) => { @@ -10203,9 +10530,12 @@ router if (target) { ctx.redirect(target); return; } } const thread = async (message) => { - const messages = await post.fromThread(message); - const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); - return threadView({ messages, spreadMap }); + const all = (await post.fromThread(message)) || []; + const hv = await hiddenView(); + const messages = all.filter(m => !(m?.value?.author && hv.authors.has(m.value.author)) && !(m?.key && hv.ids.has(m.key))); + const hiddenRoot = all.length > 0 && !messages.includes(all[0]); + const spreadMap = await spreads.forMessages(messages.map(m => m && m.key)).catch(() => new Map()); + return threadView({ messages, spreadMap, hiddenRoot }); }; ctx.body = await thread(message); }) @@ -10696,7 +11026,7 @@ router } if (recipientsArr.length === 0) { ctx.throw(400, 'No valid recipients'); return; } const cfgNow = getConfig(); - if (cfgNow.pmVisibility === 'mutuals') { + if (cfgNow.pmVisibility === 'mutuals' || cfgNow.pmVisibility === 'following') { const viewer = getViewerId(); for (const rid of recipientsArr) { if (rid === viewer) continue; @@ -10781,7 +11111,7 @@ router const recipient = String(b.recipient || '').trim(); if (!ssbRef.isFeedId(recipient)) { cleanup(); fileShareError(ctx, 'recipient'); return; } const cfgNow = getConfig(); - if (cfgNow.pmVisibility === 'mutuals' && recipient !== getViewerId()) { + if ((cfgNow.pmVisibility === 'mutuals' || cfgNow.pmVisibility === 'following') && recipient !== getViewerId()) { let rel = null; try { rel = await friend.getRelationship(recipient); } catch (_) { rel = null; } if (!pmPolicy.isRecipientAllowed({ pmVisibility: cfgNow.pmVisibility, viewerId: getViewerId(), recipientId: recipient, relationship: rel })) { @@ -10815,7 +11145,7 @@ router const recipient = String(b.recipient || '').trim(); if (!ssbRef.isFeedId(recipient)) { cleanup(); fileShareError(ctx, 'recipient'); return; } const cfgNow = getConfig(); - if (cfgNow.pmVisibility === 'mutuals' && recipient !== getViewerId()) { + if ((cfgNow.pmVisibility === 'mutuals' || cfgNow.pmVisibility === 'following') && recipient !== getViewerId()) { let rel = null; try { rel = await friend.getRelationship(recipient); } catch (_) { rel = null; } if (!pmPolicy.isRecipientAllowed({ pmVisibility: cfgNow.pmVisibility, viewerId: getViewerId(), recipientId: recipient, relationship: rel })) { @@ -11001,8 +11331,31 @@ router ctx.body = await friend.unblock(ctx.params.feed); safeRefererRedirect(ctx, '/inhabitants'); }) + .post("/mute/:feed", koaBody(), async (ctx) => { + const feed = String(ctx.params.feed || ''); + if (ssbRef.isFeedId(feed) && feed !== getViewerId()) viewerFilters.mute(feed); + safeRefererRedirect(ctx, '/inhabitants'); + }) + .post("/unmute/:feed", koaBody(), async (ctx) => { + viewerFilters.unmute(String(ctx.params.feed || '')); + ctx.redirect(safeReturnTo(ctx, '/settings?filter=content#hidden', ['/settings', '/author', '/inhabitant'])); + }) + .post("/hide/:id", koaBody(), async (ctx) => { + const id = String(ctx.params.id || ''); + const b = ctx.request.body || {}; + const href = isLocalPath(String(b.href || '')) ? String(b.href) : ''; + if (isMsgKey(id)) viewerFilters.hideContent({ id, href, label: String(b.label || '') }); + const rt = safeReturnTo(ctx, '/activity', ['/']); + const samePage = href && rt.split(/[?#]/)[0] === href.split(/[?#]/)[0]; + ctx.redirect(samePage ? '/activity' : rt); + }) + .post("/unhide/:id", koaBody(), async (ctx) => { + viewerFilters.unhideContent(String(ctx.params.id || '')); + ctx.redirect(safeReturnTo(ctx, '/settings?filter=content#hidden', ['/settings'])); + }) .post("/spread/:message", koaBody(), async (ctx) => { const { message } = ctx.params; + if (await denyTribeOutsider(ctx, message)) return; const ref = ctx.request.header.referer; let target = '/blogs'; try { @@ -11258,14 +11611,28 @@ router .post('/trending/:contentId/:category', async (ctx) => { const { contentId, category } = ctx.params, voterId = SSBconfig?.keys?.id; - if ((await trendingModel.getMessageById(contentId))?.content?.opinions_inhabitants?.includes(voterId)) { + if (await denyTribeContent(ctx, contentId)) return; + const target = await trendingModel.getMessageById(contentId); + if (target?.content?.type === 'post') { + try { await blogModel.createOpinion(contentId, category); } catch (_) { return failWith(ctx, 'opinionAlreadyGiven', '/trending'); } + ctx.redirect('/trending'); + return; + } + if (target?.content?.opinions_inhabitants?.includes(voterId)) { return failWith(ctx, 'opinionAlreadyGiven', '/trending'); } await trendingModel.createVote(contentId, category); ctx.redirect('/trending'); }) .post('/opinions/:contentId/:category', async (ctx) => { const { contentId, category } = ctx.params, voterId = SSBconfig?.keys?.id; - if ((await opinionsModel.getMessageById(contentId))?.content?.opinions_inhabitants?.includes(voterId)) { + if (await denyTribeContent(ctx, contentId)) return; + const target = await opinionsModel.getMessageById(contentId); + if (target?.content?.type === 'post') { + try { await blogModel.createOpinion(contentId, category); } catch (_) { return failWith(ctx, 'opinionAlreadyGiven', '/opinions'); } + ctx.redirect('/opinions'); + return; + } + if (target?.content?.opinions_inhabitants?.includes(voterId)) { return failWith(ctx, 'opinionAlreadyGiven', '/opinions'); } await opinionsModel.createVote(contentId, category); ctx.redirect('/opinions'); @@ -11423,7 +11790,7 @@ router const t = await tribesModel.getTribeById(b.tribeId).catch(() => null); if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } } - const imageId = extractBlobId(await handleBlobUpload(ctx, 'image')) || ""; + const imageId = extractBlobId(await handleBlobUpload(ctx, 'image', sealIf(b.tribeId))) || ""; const newMap = await mapsModel.createMap(b.lat, b.lng, stripDangerousTags(b.description), b.mapType, b.tags, stripDangerousTags(b.title), b.tribeId || null, stripDangerousTags(b.markerLabel), imageId); const createdMap = await mapsModel.getMapById(newMap.key, getViewerId()).catch(() => null); await applyClearnetChoice(ctx, 'maps', createdMap || newMap, { allowed: CLEARNET_REACH_OK.maps(createdMap) }); @@ -11441,7 +11808,7 @@ router if (target && target.author === getViewerId() && (clearnetReachConflict('maps', b) || (String(b.clearnet || '') === '1' && !CLEARNET_REACH_OK.maps({ ...target, mapType: b.mapType || target.mapType })))) { return renderClearnetConflict(ctx, () => mapsView([target], 'edit', target.key, { lat: b.lat, lng: b.lng, zoom: b.zoom, title: b.title, description: b.description, tags: b.tags, clearnet: '1', reach: String(b.mapType || ''), returnTo: String(b.returnTo || '') })); } - const imageId = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) || "" : ""; + const imageId = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image', sealIf(target && target.tribeId))) || "" : ""; await mapsModel.updateMapById(ctx.params.id, b.lat, b.lng, stripDangerousTags(b.description), b.mapType, b.tags, stripDangerousTags(b.title), imageId || undefined); await applyClearnetChoice(ctx, 'maps', target ? (target.rootId || target.key) : ctx.params.id, { recheck: true, allowed: CLEARNET_REACH_OK.maps(await mapsModel.getMapById(ctx.params.id, getViewerId()).catch(() => null)) }); ctx.redirect(safeReturnTo(ctx, '/maps?filter=mine', ['/maps'])); @@ -11498,7 +11865,7 @@ router } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } } const b = ctx.request.body; - const imageBlobId = extractBlobId(await handleBlobUpload(ctx, 'image')) || ""; + const imageBlobId = extractBlobId(await handleBlobUpload(ctx, 'image', sealIf(mapItem.tribeId))) || ""; await mapsModel.addMarker(ctx.params.mapId, b.mkLat, b.mkLng, stripDangerousTags(b.label), imageBlobId); ctx.redirect(safeReturnTo(ctx, `/maps/${encodeURIComponent(ctx.params.mapId)}`, ['/maps'])); }) @@ -11753,7 +12120,7 @@ router const b = ctx.request.body; if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return; if (!['strict', 'open'].includes(b.inviteMode)) { ctx.redirect('/tribes'); return; } - const image = await handleBlobUpload(ctx, 'image'); + const image = await handleBlobUpload(ctx, 'image', sealIf(b.isAnonymous === 'true')); const tribeRes = await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, b.isAnonymous === 'true', b.inviteMode, null, 'OPEN', stripDangerousTags(b.mapUrl)); try { if (tribeRes?.key) await parliamentModel.tribe.publishInitialTerm(tribeRes.key); } catch (e) { console.error('publishInitialTerm failed:', e); } ctx.redirect('/tribes'); @@ -11768,9 +12135,9 @@ router if (!canCreate) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=subtribes`); return; } const b = ctx.request.body; if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return; - const image = await handleBlobUpload(ctx, 'image'); const parentEffective = await tribesModel.getEffectiveStatus(ctx.params.id).catch(() => ({ isPrivate: false })); const effectiveAnonymous = !!(parentEffective.isPrivate || parentTribe.isAnonymous); + const image = await handleBlobUpload(ctx, 'image', sealIf(effectiveAnonymous)); await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, effectiveAnonymous, b.inviteMode || 'open', ctx.params.id, 'OPEN', stripDangerousTags(b.mapUrl)); ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=subtribes`); }) @@ -11783,7 +12150,7 @@ router if (b.inviteMode && !['strict', 'open'].includes(b.inviteMode)) { ctx.redirect('/tribes'); return; } const tags = b.tags ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : []; const isSub = !!tribe.parentTribeId; - const updateFields = { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), image: await handleBlobUpload(ctx, 'image'), location: stripDangerousTags(b.location), tags, inviteMode: b.inviteMode || tribe.inviteMode, status: b.status || tribe.status || 'OPEN' }; + const updateFields = { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), image: await handleBlobUpload(ctx, 'image', sealIf(isSub ? !!tribe.isAnonymous : b.isAnonymous === 'true')), location: stripDangerousTags(b.location), tags, inviteMode: b.inviteMode || tribe.inviteMode, status: b.status || tribe.status || 'OPEN' }; if (isSub) { updateFields.isAnonymous = !!tribe.isAnonymous; } else { @@ -12007,8 +12374,8 @@ router const url = stripDangerousTags(b.url || ''); await tribeExposeCreated(ctx, tribe, await tribesContentModel.create(tribe.id, 'media', { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), mediaType: 'bookmark', url })); } else { - const blobMarkdownMedia = await handleBlobUpload(ctx, 'media'); - blobRef = blobMarkdownMedia ? ((blobMarkdownMedia.match(/\((&[^)]+)\)/) || [])[1] || blobMarkdownMedia) : null; + const blobMarkdownMedia = await handleBlobUpload(ctx, 'media', tribeSeal); + blobRef = blobMarkdownMedia ? (extractBlobId(blobMarkdownMedia) || blobMarkdownMedia) : null; await tribeExposeCreated(ctx, tribe, await tribesContentModel.create(tribe.id, 'media', { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), mediaType, image: blobRef, license: formLicense(b) })); } ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=${returnSection}`); @@ -13338,14 +13705,15 @@ router if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {}); } - const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null; + const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image', sealIf(tribeId))) : null; await chatsModel.createChat(stripDangerousTags(b.title), stripDangerousTags(b.description), imageBlob, b.category, b.status, b.tags, tribeId); ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=chats` : safeReturnTo(ctx, '/chats?filter=mine', ['/chats'])); }) .post("/chats/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body; - const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : undefined; + const chatTarget = ctx.request.files?.image ? await chatsModel.getChatById(ctx.params.id).catch(() => null) : null; + const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image', sealIf(chatTarget && chatTarget.tribeId))) : undefined; const patch = { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), category: b.category, status: b.status, tags: b.tags }; if (imageBlob !== undefined) patch.image = imageBlob; await chatsModel.updateChatById(ctx.params.id, patch); @@ -13449,7 +13817,7 @@ router } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } } const text = stripDangerousTags(String(ctx.request.body.text || '').trim()); - const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null; + const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image', sealIf(chat && (chat.tribeId || chat.status === 'INVITE-ONLY')))) : null; const uploadEntry = ctx.request.files?.image; const uploadFile = Array.isArray(uploadEntry) ? uploadEntry[0] : uploadEntry; let uploadMime = imageBlob && uploadFile ? String(uploadFile.mimetype || '') : null; @@ -13495,7 +13863,7 @@ router await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {}); } let body = stripDangerousTags(String(b.body || "")); - const blobMarkdown = await handleBlobUpload(ctx, 'blob'); + const blobMarkdown = await handleBlobUpload(ctx, 'blob', sealIf(tribeId)); if (blobMarkdown) body += blobMarkdown; try { const title = stripDangerousTags(String(b.title || "")); @@ -13526,7 +13894,7 @@ router if (!tribe || !tribe.members.includes(getViewerId())) { ctx.redirect('/tribes'); return; } } let body = stripDangerousTags(String(b.body || "")); - const blobMarkdown = await handleBlobUpload(ctx, 'blob'); + const blobMarkdown = await handleBlobUpload(ctx, 'blob', sealIf(tribeId)); if (blobMarkdown) body += blobMarkdown; const draft = { title: stripDangerousTags(String(b.title || "")).slice(0, 100), body, tags: stripDangerousTags(String(b.tags || "")), status: String(b.status || "OPEN"), license: formLicense(b), summary: stripDangerousTags(String(b.summary || "")), clearnet: String(b.clearnet || "") === "1" }; const censusList = await wikiModel.listPages({ tribeId, filter: 'all' }).catch(() => []); @@ -13538,7 +13906,8 @@ router if (!checkMod(ctx, 'wikiMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body || {}; let body = stripDangerousTags(String(b.body || "")); - const blobMarkdown = await handleBlobUpload(ctx, 'blob'); + const wikiTarget = ctx.request.files?.blob ? await wikiModel.getPage(ctx.params.id).catch(() => null) : null; + const blobMarkdown = await handleBlobUpload(ctx, 'blob', sealIf((wikiTarget && wikiTarget.tribeId) || b.tribeId)); if (blobMarkdown) body += blobMarkdown; try { const res = await wikiModel.updatePage(ctx.params.id, { title: stripDangerousTags(String(b.title || "")), body, tags: b.tags || "", editPolicy: b.status || b.editPolicy, license: formLicense(b, { keep: true }), summary: stripDangerousTags(String(b.summary || "")) }); @@ -13672,7 +14041,7 @@ router if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {}); } - const image = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : ''; + const image = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image', sealIf(tribeId))) : ''; const msg = await roomsModel.createRoom({ title: stripDangerousTags(String(b.title || '')), description: stripDangerousTags(String(b.description || '')), image: image || '', status: b.status, tags: b.tags || '', tribeId }); const createdRoom = await roomsModel.getRoomById(msg.key).catch(() => null); await applyClearnetChoice(ctx, 'rooms', createdRoom || msg, { allowed: CLEARNET_REACH_OK.rooms(createdRoom) }); @@ -13684,7 +14053,7 @@ router const room = await roomsModel.getRoomById(ctx.params.id).catch(() => null); if (!room || room.author !== getViewerId()) { ctx.redirect('/rooms'); return; } if (clearnetReachConflict('rooms', b) || (String(b.clearnet || '') === '1' && !CLEARNET_REACH_OK.rooms(room))) return renderClearnetConflict(ctx, () => roomsView([], 'edit', clearnetDraft(b, room), {})); - const image = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null; + const image = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image', sealIf(room.tribeId))) : null; const patch = { title: stripDangerousTags(String(b.title || '')), description: stripDangerousTags(String(b.description || '')), tags: b.tags || '' }; if (image) patch.image = image; if (room.hub !== getViewerId() && !(await roomsModel.occupancy(room))) patch.refreshHub = true; @@ -13698,6 +14067,11 @@ router await applyClearnetChoice(ctx, 'rooms', ctx.params.id, { recheck: true, allowed: false }); ctx.redirect(`/rooms/${encodeURIComponent(ctx.params.id)}`); }) + .post("/rooms/reopen/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'roomsMod')) { ctx.redirect('/modules'); return; } + try { await roomsModel.reopenRoomById(ctx.params.id); } catch (_) { return actionFail(ctx); } + ctx.redirect(`/rooms/${encodeURIComponent(ctx.params.id)}`); + }) .post("/rooms/line/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'roomsMod')) { ctx.redirect('/modules'); return; } try { await roomsModel.setRoomLine(ctx.params.id, String((ctx.request.body || {}).line || '')); } catch (_) { return actionFail(ctx); } @@ -13753,6 +14127,42 @@ router if ((ctx.request.body || {}).returnTo) ctx.redirect(safeReturnTo(ctx, '/rooms', ['/rooms', '/tribe'])); else safeRefererRedirect(ctx, '/rooms'); }) + .post("/rooms/stream", koaBody(), async (ctx) => { + if (config.public || !checkMod(ctx, 'roomsMod')) { ctx.redirect('/modules'); return; } + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const b = ctx.request.body || {}; + const returnTo = streamReturnTo(ctx); + if (String(b.stop) === '1') { + await roomsModel.stream({ stop: true }).catch(() => null); + ctx.redirect(returnTo); + return; + } + const roots = streamRoots(); + let candidates = []; + if (b.file) { + try { const asked = String(b.file); if (path.isAbsolute(asked)) candidates = [fs.realpathSync(asked)]; } catch (_) { candidates = []; } + } else { + const name = path.basename(String(b.video || '').replace(/\\/g, '/')); + if (name && STREAM_VIDEO_EXT.test(name)) candidates = await findStreamFiles(name, roots); + } + candidates = [...new Set(candidates)].filter(f => insideStreamRoots(f, roots) && STREAM_VIDEO_EXT.test(f)); + if (process.env.OASIS_MOBILE === '1') { + const copies = candidates.filter(f => f.includes(`${path.sep}cache${path.sep}uploads${path.sep}`)); + if (copies.length && copies.length < candidates.length) candidates = candidates.filter(f => !copies.includes(f)); + } + if (candidates.length > 1) { + const files = []; + for (const f of candidates) { const st = await promisesFs.stat(f).catch(() => null); if (st && st.isFile()) files.push({ path: f, size: st.size }); } + ctx.body = await roomStreamMatchesView({ files, returnTo }); + return; + } + const file = candidates[0]; + const st = file ? await promisesFs.stat(file).catch(() => null) : null; + const kind = st && st.isFile() ? await FileType.fromFile(file).catch(() => null) : null; + const started = kind && /^video\//.test(kind.mime) ? await roomsModel.stream({ file, name: path.basename(file) }).catch(() => null) : null; + if (!started) { failWith(ctx, file ? 'actionFailed' : 'roomStreamNotFound', returnTo); return; } + ctx.redirect(returnTo); + }) .post("/rooms/hand", koaBody(), async (ctx) => { if (!checkMod(ctx, 'roomsMod')) { ctx.redirect('/modules'); return; } await roomsModel.hand(String((ctx.request.body || {}).hand) === '1').catch(() => null); @@ -14273,8 +14683,8 @@ router ctx.cookies.set("language", lang, { maxAge: 365 * 24 * 60 * 60 * 1000, httpOnly: true, sameSite: 'strict', secure: ctx.secure }); safeRefererRedirect(ctx, '/settings'); }) - .post("/peers/pause", koaBody(), async ctx => { await setNetworkPaused(true); ctx.redirect("/peers"); }) - .post("/peers/resume", koaBody(), async ctx => { await setNetworkPaused(false); ctx.redirect("/peers"); }) + .post("/peers/pause", koaBody(), async ctx => { await setNetworkPaused(true); ctx.redirect("/peers?filter=connections"); }) + .post("/peers/resume", koaBody(), async ctx => { await setNetworkPaused(false); ctx.redirect("/peers?filter=connections"); }) .post("/settings/invite/accept", koaBody(), async ctx => acceptPubInvite(ctx, String(ctx.request.body.invite || ''))) .post("/invites/join", koaBody(), async (ctx) => { const code = String((ctx.request.body || {}).code || '').trim(); @@ -14362,13 +14772,13 @@ router sendErrorPage(ctx, String(t.peersConnectFailed || '').replace('{reason}', t[peerHealth.NET_REASON_KEYS[reason]] || t.peerErrOther), { status: 400, exact: true, to: '/peers' }); return; } - ctx.redirect("/peers"); + ctx.redirect("/peers?filter=connections"); }) .post("/peers/disconnect", koaBody(), async (ctx) => { const { key, host, port } = ctx.request.body || {}; - if (!key) return ctx.redirect("/peers"); + if (!key) return ctx.redirect("/peers?filter=connections"); const keyStr = String(key).trim(); - if (!/^@[A-Za-z0-9+/_\-]{43}=\.ed25519$/.test(keyStr)) return ctx.redirect("/peers"); + if (!/^@[A-Za-z0-9+/_\-]{43}=\.ed25519$/.test(keyStr)) return ctx.redirect("/peers?filter=connections"); const kcanon = canonicalKey(keyStr); const ssb = await cooler.open(); const candidates = new Set(); @@ -14403,7 +14813,7 @@ router try { await new Promise(res => ssb.conn.disconnect(addr, res)); } catch {} try { ssb.conn.forget(addr); } catch {} } - ctx.redirect("/peers"); + ctx.redirect("/peers?filter=connections"); }) .post("/invites/refresh-pubs", koaBody(), async (ctx) => { try { @@ -14428,13 +14838,13 @@ router ctx.redirect("/invites"); }) .post("/invites/clear-unreachable", koaBody(), async (ctx) => { - try { - const dead = await deadPeerKeys(); - const pubKeys = new Set(readJSON(gossipPath).map(p => p && peerHealth.canonicalKey(p.key)).filter(Boolean)); - await forgetPeers([...dead].filter(k => pubKeys.has(k))); - } catch (_) {} + await forgetDeadPubs(); ctx.redirect("/invites"); }) + .post("/peers/clear-unreachable", koaBody(), async (ctx) => { + await forgetDeadPubs(); + ctx.redirect("/peers?filter=health"); + }) .get("/invites/export-pubs", async (ctx) => { const lines = []; lines.push('# Oasis pubs — multiserver addresses, one per line'); @@ -14508,13 +14918,22 @@ router try { if (ssb.lan && typeof ssb.lan.stop === 'function') ssb.lan.stop(); } catch (_) {} try { if (ssb.lan && typeof ssb.lan.start === 'function') ssb.lan.start(); } catch (_) {} } catch (_) {} - const returnTo = String((ctx.query && ctx.query.returnTo) || (ctx.request.body && ctx.request.body.returnTo) || ''); - ctx.redirect(['/peers', '/graphos'].includes(returnTo) ? returnTo : '/peers'); + ctx.redirect(safeReturnTo(ctx, '/peers', ['/peers', '/graphos'])); }) .post("/peers/prune", koaBody(), async (ctx) => { - try { await forgetPeers(await deadPeerKeys()); } catch (_) {} - const returnTo = String((ctx.query && ctx.query.returnTo) || (ctx.request.body && ctx.request.body.returnTo) || ''); - ctx.redirect(['/peers', '/graphos'].includes(returnTo) ? returnTo : '/peers'); + if (String((ctx.request.body || {}).scope || '') === 'lan') { + try { + const online = connectedPeerKeys(await meta.onlinePeers(), await listConnectionPeers()); + viewerFilters.forgetLanPeers([...viewerFilters.lanPeersSeen().keys()].filter(k => !online.has(peerHealth.canonicalKey(k)))); + } catch (_) {} + } else { + try { await forgetPeers(await deadPeerKeys()); } catch (_) {} + } + ctx.redirect(safeReturnTo(ctx, '/peers', ['/peers', '/graphos'])); + }) + .post("/peers/lan/forget", koaBody(), async (ctx) => { + try { viewerFilters.forgetLanPeers([String((ctx.request.body || {}).key || '')]); } catch (_) {} + ctx.redirect('/peers?filter=lan'); }) .get("/peers/export", async (ctx) => { const lines = []; @@ -14591,7 +15010,7 @@ router raw = raw ? (raw + '\n' + buf) : buf; } } catch (_) {} - if (!raw.trim()) return ctx.redirect("/peers"); + if (!raw.trim()) return ctx.redirect("/peers?filter=connections"); let ssb = null; try { ssb = await cooler.open(); } catch (_) {} const pubs = readJSON(gossipPath); @@ -14617,7 +15036,7 @@ router } } writeJSON(gossipPath, pubs); - ctx.redirect("/peers"); + ctx.redirect("/peers?filter=connections"); }) .post("/settings/ssb-logstream", koaBody(), async (ctx) => { const logLimit = parseInt(ctx.request.body.ssb_log_limit, 10); @@ -14635,19 +15054,19 @@ router cfg.blobCache = { ...(cfg.blobCache || {}), maxMB }; saveConfig(cfg); } - ctx.redirect("/settings#blobcache"); + ctx.redirect("/settings?filter=content#blobcache"); }) .post("/settings/blob-cache/collect", koaBody(), async (ctx) => { let res = null; try { res = await blobCacheModel.collect({ maxBytes: blobCacheModel.maxBytesFor(getConfig()) }); } catch (_) { res = null; } - ctx.redirect(`/settings?cleaned=${res ? res.deleted : 0}&freed=${res ? res.freed : 0}#blobcache`); + ctx.redirect(`/settings?filter=content&cleaned=${res ? res.deleted : 0}&freed=${res ? res.freed : 0}#blobcache`); }) .post("/settings/replication", koaBody(), async (ctx) => { const hops = parseInt(ctx.request.body.hops, 10); if (Number.isFinite(hops) && hops >= 0 && hops <= 6) { try { require('../configs/config-manager').saveServerConfig({ friends: { hops } }); } catch (_) {} } - ctx.redirect("/settings#replication"); + ctx.redirect("/settings?filter=content#replication"); }) .post("/settings/home-page", koaBody(), async (ctx) => { const cfg = getConfig(); @@ -14662,10 +15081,11 @@ router const aiNavEnabled = cfg.modules && cfg.modules.aiNavMod === 'on'; const chatsEnabled = cfg.modules && cfg.modules.chatsMod === 'on'; const phoneEnabled = !!(cfg.modules && cfg.modules.phoneMod !== 'off'); - const next = (v === "ainav" && aiNavEnabled) ? "ainav" : (v === "chats" && chatsEnabled) ? "chats" : v === "feed" ? "feed" : (v === "phone" && phoneEnabled) ? "phone" : "blocks"; + const roomsEnabled = !!(cfg.modules && cfg.modules.roomsMod !== 'off'); + const next = (v === "ainav" && aiNavEnabled) ? "ainav" : (v === "chats" && chatsEnabled) ? "chats" : v === "feed" ? "feed" : (v === "phone" && phoneEnabled) ? "phone" : (v === "rooms" && roomsEnabled) ? "rooms" : "blocks"; cfg.ux = { ...(cfg.ux && typeof cfg.ux === 'object' ? cfg.ux : {}), current: next }; saveConfig(cfg); - ctx.redirect(next === "ainav" ? "/" : next === "chats" ? "/chats" : next === "feed" ? "/feed" : next === "phone" ? "/phone" : "/settings"); + ctx.redirect(next === "ainav" ? "/" : next === "chats" ? "/chats" : next === "feed" ? "/feed" : next === "phone" ? "/phone" : next === "rooms" ? "/rooms" : "/settings"); }) .post("/settings/lan-broadcasting", koaBody(), async (ctx) => { const enabled = !!(ctx.request.body && (ctx.request.body.lanBroadcasting === 'on' || ctx.request.body.lanBroadcasting === '1' || ctx.request.body.lanBroadcasting === 'true')); @@ -14679,7 +15099,7 @@ router if (!enabled && typeof ssb.lan.stop === 'function') { try { ssb.lan.stop(); } catch (_) {} } } } catch (_) {} - ctx.redirect("/settings#lan"); + ctx.redirect(safeReturnTo(ctx, "/settings#lan", ["/settings", "/peers"])); }) .post("/inhabitants/follow/accept", koaBody(), async (ctx) => { const b = ctx.request.body || {}; @@ -14703,7 +15123,7 @@ router const v = String(ctx.request.body.wish || '').trim(); cfg.wish = WISH_LEVELS.includes(v) ? v : 'whole'; saveConfig(cfg); - ctx.redirect("/settings#wish"); + ctx.redirect("/settings?filter=content#wish"); }) .post("/settings/phone", koaBody(), async (ctx) => { const cfg = getConfig(); @@ -14725,15 +15145,23 @@ router cfg.inboxMutedBots = known.filter(k => !on.has(k)); saveConfig(cfg); try { await refreshInboxCount(); } catch (_) {} - ctx.redirect("/settings#inbox-bots"); + ctx.redirect("/settings?filter=content#inbox-bots"); }) .post("/settings/pm-visibility", koaBody(), async (ctx) => { const cfg = getConfig(); const v = String(ctx.request.body.pmVisibility || '').trim(); - cfg.pmVisibility = v === 'mutuals' ? 'mutuals' : 'whole'; + cfg.pmVisibility = ['mutuals', 'following'].includes(v) ? v : 'whole'; saveConfig(cfg); const returnTo = String((ctx.query && ctx.query.returnTo) || (ctx.request.body && ctx.request.body.returnTo) || ''); - ctx.redirect(['/settings', '/inbox'].includes(returnTo) ? returnTo : '/settings'); + ctx.redirect(['/settings', '/inbox'].includes(returnTo) ? returnTo : '/settings?filter=content#pm-visibility'); + }) + .post("/settings/mentions-visibility", koaBody(), async (ctx) => { + const cfg = getConfig(); + const v = String(ctx.request.body.mentionsVisibility || '').trim(); + cfg.mentionsVisibility = ['mutuals', 'following'].includes(v) ? v : 'whole'; + saveConfig(cfg); + try { await refreshMentionsCount(); } catch (_) {} + ctx.redirect('/settings?filter=content#mentions-visibility'); }) .post("/settings/rebuild", async ctx => { try { settingsReports.rebuild = await backupModel.rebuildIndexes(); } @@ -15204,6 +15632,11 @@ const startDesktopNotices = async () => { try { const dec = ssb.private.unbox({ key: kvt.key, value: v, timestamp: kvt.timestamp }); c = dec && dec.value && dec.value.content; } catch (_) {} if (!c || c.private !== true || !Array.isArray(c.to) || !c.to.includes(me)) return; if (cfg.pmVisibility === 'mutuals' && !(await isMutual(v.author))) return; + if (cfg.pmVisibility === 'following') { + let rel = null; + try { rel = await friend.getRelationship(v.author); } catch (_) { rel = null; } + if (!(rel && rel.following)) return; + } if (c.type === 'pam') { if (cfg.modules.phoneMod !== 'off') desktopNotify(i18n.phoneTitle, `${i18n.notifyNewPamFrom} ${await nameFor(v.author)}`); return; @@ -15290,6 +15723,41 @@ async function runWalletWork(kind) { fs.writeFileSync(ubiNoticePath, JSON.stringify({ epochId: avail.epochId, at: new Date().toISOString() })); } catch (_) {} } +const CLEARNET_ROOMS_SYNC_MS = 60 * 1000; +const CLEARNET_ROOM_WATCHERS_MAX = 50; +const SOUND_RATE = require('../server/phone_module').SOUND_RATE; +const SOUND_BACKLOG_MAX = SOUND_RATE * 2 * 2; +const clearnetRoomWatchers = new Map(); +const clearnetRoomOk = async (room) => { + if (!room || room.isClosed || !CLEARNET_REACH_OK.rooms(room)) return false; + const prefs = await about.visibilityPrefs(room.author).catch(() => null); + return clearnetPublicSync('rooms', room, room.author, prefs, await clearnetDecisions()); +}; +const clearnetRoomPreview = async (ctx, room) => { + if (!room || !isLoopbackRequest(ctx) || !checkMod(ctx, 'phoneMod')) return false; + const state = await roomsModel.liveState().catch(() => null); + return !!state && state.phase === 'live' && state.rid === room.rid && Array.isArray(state.gridOrder) && state.gridOrder.length > 0; +}; +let clearnetRoomsSyncing = false; +const syncClearnetRooms = async () => { + if (clearnetRoomsSyncing || !config.public) return; + clearnetRoomsSyncing = true; + try { + const rooms = await roomsModel.listAll({ filter: 'all', viewerId: getViewerId() }).catch(() => []); + const wanted = []; + for (const room of rooms || []) if (await clearnetRoomOk(room)) wanted.push(room); + const rids = new Set(wanted.map(r => r.rid)); + for (const rid of await roomsModel.viewerRooms()) if (!rids.has(rid)) await roomsModel.viewerLeave(rid).catch(() => null); + const inside = new Set(await roomsModel.viewerRooms()); + for (const room of wanted) if (!inside.has(room.rid)) await roomsModel.viewerJoin(room).catch(() => null); + } catch (_) {} + finally { clearnetRoomsSyncing = false; } +}; +if (config.public) { + setTimeout(syncClearnetRooms, 20000); + const clearnetRoomsTimer = setInterval(syncClearnetRooms, CLEARNET_ROOMS_SYNC_MS); + if (clearnetRoomsTimer.unref) clearnetRoomsTimer.unref(); +} const PUB_INVITES_REFRESH_MS = 24 * 60 * 60 * 1000; if (ssbConfig.pub === true) { const announcePubInvites = async () => { try { await pubInvites.announce(await cooler.open()); } catch (_) {} }; @@ -15582,25 +16050,6 @@ async function checkPoliticalChanges() { politicalCheckRunning = false; } } -async function logClearnetStatus() { - try { - const ssbClient = await cooler.open(); - if (!ssbClient || !ssbClient.id) return; - const prefs = await about.visibilityPrefs(ssbClient.id).catch(() => null); - const { CLEARNET_MODULES } = require('../views/main_views'); - const active = prefs ? CLEARNET_MODULES.filter(m => prefs[m.prefKey] === true).map(m => m.label) : []; - try { - const { setClearnetModules } = require('../server/ssb_metadata'); - setClearnetModules(active); - } catch (_) {} - } catch (_) { - try { - const { setClearnetModules } = require('../server/ssb_metadata'); - setClearnetModules([]); - } catch (_) {} - } -} -setTimeout(() => { logClearnetStatus(); }, 8000); app._close = () => { if (pubEngineTimer) clearInterval(pubEngineTimer); diff --git a/src/backend/blobHandler.js b/src/backend/blobHandler.js index abee774..0de44ea 100644 --- a/src/backend/blobHandler.js +++ b/src/backend/blobHandler.js @@ -222,7 +222,9 @@ const oggMime = (buffer) => { return 'video/ogg'; }; -const storeUploadedFile = async function (blobUpload) { +const sealedRef = (sealed, mime, name) => `/blob/sealed/${encodeURIComponent(sealed.manifestBlobId)}?k=${encodeURIComponent(sealed.key)}&t=${encodeURIComponent(mime)}&n=${encodeURIComponent(name)}`; + +const storeUploadedFile = async function (blobUpload, seal = null) { if (!blobUpload || !blobUpload.filepath) return null; let data = await promisesFs.readFile(blobUpload.filepath); @@ -270,14 +272,19 @@ const storeUploadedFile = async function (blobUpload) { data = stripPdfMetadata(data); } - const ssbClient = await cooler.open(); - - blob.id = await new Promise((resolve, reject) => { - pull( - pull.values([data]), - ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref))) - ); - }); + if (typeof seal === 'function') { + await promisesFs.writeFile(blobUpload.filepath, data); + const sealed = await seal({ filepath: blobUpload.filepath, size: data.length, name: blob.name, mime: blob.mime }); + blob.id = sealedRef(sealed, blob.mime, blob.name); + } else { + const ssbClient = await cooler.open(); + blob.id = await new Promise((resolve, reject) => { + pull( + pull.values([data]), + ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref))) + ); + }); + } if (blob.mime.startsWith("image/")) return `\n![image:${blob.name}](${blob.id})`; if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`; @@ -288,20 +295,20 @@ const storeUploadedFile = async function (blobUpload) { return `\n[${blob.name}](${blob.id})`; }; -const handleBlobUpload = async function (ctx, fileFieldName) { +const handleBlobUpload = async function (ctx, fileFieldName, seal = null) { if (!ctx.request.files || !ctx.request.files[fileFieldName]) return null; const entry = ctx.request.files[fileFieldName]; const first = Array.isArray(entry) ? entry[0] : entry; - return storeUploadedFile(first); + return storeUploadedFile(first, seal); }; -const handleBlobUploads = async function (ctx, fileFieldName, max = 8) { +const handleBlobUploads = async function (ctx, fileFieldName, max = 8, seal = null) { if (!ctx.request.files || !ctx.request.files[fileFieldName]) return []; const entry = ctx.request.files[fileFieldName]; const files = (Array.isArray(entry) ? entry : [entry]).slice(0, Math.max(0, max)); const out = []; for (const file of files) { - const stored = await storeUploadedFile(file); + const stored = await storeUploadedFile(file, seal); if (stored) out.push(stored); } return out; diff --git a/src/backend/opinion_categories.js b/src/backend/opinion_categories.js index f55ea30..0779724 100644 --- a/src/backend/opinion_categories.js +++ b/src/backend/opinion_categories.js @@ -46,12 +46,21 @@ const all = [...positive, ...constructive, ...moderation]; all.positive = positive; all.constructive = constructive; all.moderation = moderation; +const karmaNeutral = ["adultOnly", "nsfw", "triggering", "duplicate", "offTopic", "needsSources"]; +const karmaModeration = [...moderation.filter(c => !karmaNeutral.includes(c)), "propaganda"]; +all.karma = { + positive, + neutral: karmaNeutral, + constructive: constructive.filter(c => !karmaNeutral.includes(c) && !karmaModeration.includes(c)), + moderation: karmaModeration +}; all.messageTypes = { bookmark: 'bookmarkOpinion', votes: 'votesOpinion', poll: 'pollOpinion', transfer: 'transferOpinion', feed: 'feedOpinion', + post: 'blogOpinion', image: 'imageOpinion', audio: 'audioOpinion', video: 'videoOpinion', diff --git a/src/backend/pm_policy.js b/src/backend/pm_policy.js index d6dd9e5..54f89fe 100644 --- a/src/backend/pm_policy.js +++ b/src/backend/pm_policy.js @@ -6,8 +6,9 @@ const isMutual = (relationship) => !!(relationship && relationship.following && const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => { if (recipientId && viewerId && recipientId === viewerId) return true; - if (pmVisibility !== 'mutuals') return true; - return isMutual(relationship); + if (pmVisibility === 'mutuals') return isMutual(relationship); + if (pmVisibility === 'following') return !!(relationship && relationship.following); + return true; }; const announceSeenPath = () => { diff --git a/src/backend/renderStyledText.js b/src/backend/renderStyledText.js index baf8879..40945d0 100644 --- a/src/backend/renderStyledText.js +++ b/src/backend/renderStyledText.js @@ -26,12 +26,12 @@ const UNDERLINE_RE = /(? `${opts.blobPrefix || '/blob/'}${encodeURIComponent(id)}`; + const blobHref = (id) => String(id).startsWith('/blob/sealed/') ? String(id) : `${opts.blobPrefix || '/blob/'}${encodeURIComponent(id)}`; const linksOn = opts.links !== false; const internalOn = opts.internalLinks !== false; const plain = (value) => opts.plainUrlClass ? span({ class: opts.plainUrlClass }, value) : value; diff --git a/src/backend/sanitizeHtml.js b/src/backend/sanitizeHtml.js index bb8f6ed..f23d66d 100644 --- a/src/backend/sanitizeHtml.js +++ b/src/backend/sanitizeHtml.js @@ -2,7 +2,7 @@ const SAFE_SRC = /^\/(?:blob|image)\/[^\s"'<>]+$/i; const SAFE_HREF = /^(?:https?:\/\/|\/(?!\/)|#|mailto:)/i; -const SAFE_CLASS = /^(?:rt-[a-z0-9-]+|styled-link|mention|tag-link|zoom-link|pm-quote|wiki-link(?:-missing)?|internal-link(?:-id|-kind)?|lightbox(?:-[a-z]+)?|post-(?:audio|image|pdf|video))$/; +const SAFE_CLASS = /^(?:rt-[a-z0-9-]+|styled-link|mention|tag-link|tag-inline|zoom-link|pm-quote|wiki-link(?:-missing)?|internal-link(?:-id|-kind)?|lightbox(?:-[a-z]+)?|post-(?:audio|image|pdf|video))$/; let purifier = null; const getPurify = () => { if (purifier) return purifier; diff --git a/src/client/assets/images/ux-rooms.png b/src/client/assets/images/ux-rooms.png new file mode 100644 index 0000000000000000000000000000000000000000..cdf711eb2ea062c58dabe070dd26699ff93d0a5d GIT binary patch literal 11953 zcmZvCby$>b)AynXC?VaUOM^7hh;)O5#M06%AuX-6q;$6n5)0B2OGrwGw1m{sE4k9& z#eF~T_defoe1GhD?7?;IFf->lGr#%G`SF#8BK{-lM<5UgUs*{`8wA2w0Y1-g?g4*E z&PL;dK-}+?<)n4r&F|;<)LHABAcNWwqy8o69YmQ**h`j|FN-6eU>b8(IJ01f+ilqp zl7GoR({{8fRTqDj#@+J}5)z5uKdbJ2KbZKbU?gF(`IFE{!bm2oFQ0q$QHzIoekikB z*RrV3JL=wT=tY$6)BvQ^7j@u%;GKuM*8QwlQI4VQ+tZWn_76JuK^-1=5I&-Dg*14P3KQq^r-gb0 zC1TIyP7NgFr+*J(g5Lk2uG32UJGvpg=(O_tpG)a2MhJUL$6eEJ3bu?mZ!na{D< zI3AN8_fxB45Gn%fxR5#SO=qY%aT?@{{OQ#)NB@ag5p66v@qeW+)K7CUxfWlD@RZ{A+ z3NuCL?ifjt@aV6MgPzfj7}`wF7fqh=KAEN_DoLh?8nxJ4hob8*H!pbYdXlNcUM2Bd z_0Frvs-aFeJJmD2KMT6b>0fKD%^L;70F^rmpMd4Gt9bM|o{!Q@=jYTSOuYlHKiBKA zKQC@*aIs!T*Jo{F@oni11fl0o8JY`u_qi6&hsX#oJqk;6uNDTOCTGtdl6!%^f(|O` zU%Xv;MJQ*VNiNT~j^8|LK2kn|aT1>_}jHswLOyw_E zuxy&N7e%J~{B&|!9_U9wg@>Jfq)tO=Z~ItbHM<4|?1?dfR}bghddQSqL0@O8r>A!Z zr3vd7)NOhsf}MU0RV1rSR@Qr2Ti?z#x)(V?%k_Fa%oc=&Bc+WL>-!~-K3Uf;46mrk zg>xHtQE;{V$~G0l2hEoAy2G?pCzlS^Iv1N0ClIDQQN*Lis^TC5@DrZ)V{ zjF_USC<&{c!~zpjCV`Jd-@s`HjZIWdR-pHWDo&B_a#22}S}_~;-Xp|Xl;N>^ZP8q2qSYiZ8>x3KMEHk}u|UKVQBzvKi13t10l0m8S*m z9bSdk_lg$Pr}9Z0SZ_A6Me)bb=@=pF_zy0{+M6&K?KH<9~-5OqSw% z_PNse8rWcW&hwHnVZNzOo%jjH>YbUrXwmq;+W3x;^%}e$NGjwD8$I z+MaC0>N9$@n~yQO5E-fM5D*ZnRLJHvG7+hrH6l8CT*wA762CFYqxLk)YT$3Xa|Yd< zKb;`a1N!S-3sfIN9{gD}msh`HG$A{&YL$=vzLnxRwY?pP92_}PQ#WpKcHKeA$-j5c z2&PNglcKV*{=+gy<8m)IZoHxXHK%2B)qd8(Z`CCS)&jksYUkJtdd6tZ-4Q~=`5jC7 zevzZJt(j#LpEcIR0Jc7ij(cGf8c-MOjhCd%VE&;2Y@#>Ch5a+raV7TXt5q^aduX4Y zf}dH7W2H+7x8#LYR?y8hLb$y~{Xs@{vhT}kCgPg7I+n_DKPf82#bUh!lae#&tKD{- zn-MjO$D4z83)}7@J-`^xJjL! zEFI^4lC+v*CVL7_nn0+ZK3>LIy4e0Z=^WS>Qzn0>xx6=I(IG zC`3len60jyEJv|nB1O)o(HxT-D+SE*1aM~P@9kZ&Qk1w|{c~4R99w=mq7_JoRCM(0c&AtL1kIL>S z#dt5h{J4inj&)&R<|yB~d}Wj*A|uZEOMQa_6Zao4PZ27w2n~HcBGy%}_H$g{;WX3Q zH)B!k!r3$zFx!So-#6aO4b)hYuC!VLIy1}U*PZqC^>Jl=;@Pd-U@cN?=}7EANMt0R zeB;90aHYW3b2DFj5uCjvU6g<{m412bGu1{PqSACX+1PHx?G!Y&yY=?s52v>;SWPYT zCreAa|@8=s+qt(fUOpJrcCZEy=!`(O5L9!buC9TbB^ zO#!1>w&R57&M7l^`?MK0@b{>gEa9UPWwHnxOLCPoLeHZR9(bMs zPw8$w;h`QR1Kh~i%9yQyE_jj%lUuruA18av7vz6>alBkZjqNrfQJl@r2@}g9r$1$X zTskzAZJnWQ3e$F~&qQ+GLu{&6J*BFaj{a1&vBT=M>=W>r%-(Zzk@U^hcFZ59ID((Q z#9vUfU%!S&(DNz^dAwaxIe&t4EYUUdT!6$j1!+&KpRApQWjzLMJE7j`QWVR5W%IYt zdLeRO-TOlM>kmh2wWXr_@du_ltVUOVv5|=EQA4ewWNj5gC}G?%!H6jMP~!Fq`ybZ{ zSdEoPB`EWeFO5JNLehWer2hc%>i-sP3l6KX2Z8XYfsnXc%*TK)|12!p&K?>T7Y8@g z&s2cm$hS4u=-jZ_wxa!!F+i4E>~{Xu-2fK@^j=ysyT-$fb?tj%iG9=Ry?CYmhj33_ zy-#|{n5)|+DF>K9D4yV$v#>av&CZ-$r;%y4{gT*`@!Kv_mwEbhcPlRG!btHn%^B_d zS2zgKHDw@3a;AKb7teZ=P#kPACXP9hQP8@=fB?5&{Ft-k<|ic-1s^ZM>GYpvaMC-Q zp8VV!DcV^R#ztNH|78D0{Yb_hbRlRh>NoO!y6lPuX>6!Xbva`(hHQ2h-;l*ehaB5)(Kh2R~e^;X|vLWYjzk%lwAjCu}7v|03d8CuC1{_#KQ{Y2 z6qFvR^vF%r#JA(7N3(J1Z^C+j8IFD#Ao*Ku$(O8JcimXbl4m1yBOj653Prh0)zcRh zW&$L&$V=12?o(f`!J5-kn^bENmSQW{_6M*pIY%ViLO~Z%XDY*U1nlzSXOC$C%x$j+1K?HyhIu&MDi1)uFF z^J!2Il z<18CzdBUcG&uG+n0^L;j_Y6NqDQjQ+?z5$+dJ>sC_EV9QShYcR+5OAAWoX6eit<>T#8Ln^|a_L?J&8#vXUT$lL!uk;Eg+ zK{>1q=cl>`fOJRKe3f#1ebkTBehMFE;zuAGeJvf~a#FdS-lhs)h~N=d$v%O)6NYvVOYDs@h`gm_jV(t~u1pRB7 zPVV*XxHcuV(L~ycWa=e@QU8(P7ZBX$D9aNKjxR)fG<4J-?Li^A-XZipr!hwYhkr+h zt}<~xZnH~Sm#8`o^&>m-kzJVq>Z}VpBP5xv<4mG*O7=Tmf5llnRxIiI@IG{(kh+eL z$@BS}h#|B2Y%F#cxK{aFhQ^%w1h>a4qDa6K9;3>hg)slH6fXf*VdBZjsQlyB{NFYy zDy~|j(~!?1 z7xmeW`Q+Lf3JsY$S*lgdSoVr-$$y`$TiS?Ur2Q(jH-U>(3Oj)X9k&I}s@{9Ho*w@> z;<=p6i?@)4nTe&@P*Eq_V6|dRzT9Zl}6+_jr6UB89+yML8Yk zBiLvx>qW-O!@Pij6d{f$bd!qh#^;^Oh^?6yb=n4ca+C&rq;e(p4Q*|F9eOWex+RlN z_4910b)OoCfuP%u*ORkbX$;d-(%<5m>ACeaV4a2W9~bfaeJ(#qL51p@xp!(_PknHq zKh-afqdJfn!B#KTD_?Y|_mtwXO3^kH3NQv0jFWP&*dJUOK%7L^zzWQ5id?pKa;C66W*Us(hc-Do*y4O&NQ=2a%xbj@-DtA;- znQh46Qo=pC<=j*0^H!0t-pzV5tZIowPsR`qS))9VNHJEdO0zK}m)C+NVt%Nq=jJky ztEiW^XkIj}ni1$-QF5U?@s?~(=(la(4!X{qAC+JXI9NMYk?$v>0(*<0I^`-pK2K2O zE~07seLLrQID?w1H&EdL)fShYDMy^7EPP+SY}pv~XS9*$B!h;<%DxhkkGBEuc(p7# z>qGZ_Apfa8J|n`l!Qx%{qXD_Q)q*-Vi}5T#`8yfvTi5ilbDb+Qsf^-ir&rwPUal{0 zb1Oj~R$(oFn_(0!fv7Pr=`g)joI} z|H6PB=^Pf>ud@z) z`T`Ll-K+DNH0DXkhr36k8?;g35G41>dpesgwZ1=K*Jsl)|2%*_7ho^`o;aj1v;cQCb_TGqeE|cHPKQ&6Es*=STXVPU=J!fak6w#-HY93u)7}Paf~P7ym$G#?pKqV z>s?>HME`;WstM?{Lv~fGiK3U-io!n<%rOb)n~GCwkTq4S^7BHgdjHHB>U}+&A`WHV zSChOQeZ1&Xqb6*bHl{{-5@U>`O?yoAf+F>7=9dmVL@8b(z-F-ruODgeMkTqP?>8iW z)+^qs!uB-{BYequr2Mrl?X-As*!H=fGg$3|aFHwr=c9miVJx(rCds4F5dcF%OAKct zGvM{p)a&`k-kf;nFI5cFQhyM=Uj37`lPMo1Aj>ON%jIjJ@ z#=VMq`*|`V%9E>Z!>FaRxxdQGlSgk79rgGT1B_*3;!^Z

yxYPI_v7*n*Q^Zr@N6 z!wdLxzl9uhrnGng)2A%dk?G~Z4v$2O_j#uPa#&rpQvh{8UUhc- z2LruH<@2^}0vGb7em<>r+#SXH0O&1|%#7qH80wF*)cp=LXZfSG_Dp0TkUMVNEkG9n zj1EA7@Y=Ob=SY7-VVIBOHygWIKA@L0n%)Z)WWq4Fz;CX2=u=lJ*3|6SK8%;DsKH=U z6Q{>9p|qZ**g7WO*wz#YP!Eu=NxtE_mf)qj0nC$39)t~N@EIxGd+M|?j0eH*WLrVj z`MG4yCDQ+{IQr;Ym5431)1Hk=@xAOOlPAK&(0)?Ozesd=-63iVU)(b*n333iUQrZx zwpKCb25vvDzKC_%LaYy*Nan?#%`p8LODYa=^;k{z{TVV;o|kKSI{liP(>jl-Cj2Z}&@EKRAf z=v=}U1>YR}r;%|WZf$O!*Dr%XoU9C+18!6~9nSuCUhbjH5P?0iN3rC5YdOC^SI3o+ zi7g!luF0N`E>Z87J9}?=QsL-wkijK~a!MWuh$F>v;`qcR_U~kJFh{R_p4lapZ}a11 zhz3}NMb2+&ht}9bYxKtNROCLz=dKS$8;lFbzgrgB!_|^pdd>Xls2Z)2M~21nq8I{& z7nr0m1e>~lo-xOgtb{L3Yi%j-4JP0R^tmz>l*q0tGZPmt>fN)jAxcA%n=O)t)U*Xs z`VJ?U-skOw(1NYP?rDM^(1sqPEz!nwqL=HQX9Q;OYeBaBabwU zZ&hTz(dB%vC3^8XwS1;LRv{pZ06U-Jy(#ZjC(+Zgngyry_AVT{2Jq z6@Egel$yGei^OlGboXg0Ahj6=B=S(6e2MU0Mz4y{zoUL#NflLyBTe>L!z;X|M53H)zGh9n5@eg(pk8N=06&B)3J>nKN}36Q+0^hKJ^`X zH}ePqItiAJ&y0=T?4VIj(H^!Lq>T1w`PZxq=@;s?r>C(P4IJzMq2gRVrEp01uWb_O zD&6ua=lc1L^OTlb__M+3X$|~y0?d~@fI$rytYw4(0KBZ37NYh9&)>w#`TTiXGHGHY zzLDMK!1bRU46yqzq}bl|8#Ut+85zqrY*RkxD6qC?@$YNe?$xF|!!^&M3RRdN-ZO)m zco7j*Pm_mjd-)+uM=3+x?EZB_=p`YER^!6HR|^C>IthUi5xdG;WV?gESB&}nyXB?$ zrye#8$x7048;;SVYF(gzt+XDqxO=WcKl%r#EdNWpY~~^Ifbh3RQ1q(!{ku)R;@^26 z1;6D`r?IT5h*(V>!}XO0js=UQAE~@nGM-7Y*Gd}=M^BChjVl$fVPjCxnHxc7Mu1ed z9*VuhJQwMg8c&!c74wop&Q9EXl_r$ch}B-()ro(~xL^&p^Zm zn}&Q%Gb84A&peBqMPHX1zpockZvh7b4AQ?+D2TSb7IKk%$LnTH2GBosvUeL`KDH2h z+?#IyJhp`H-}jcJl_Zqz!lLPZxd>q89q0Q060iT82-5SVqa!2PU|O}VUgkDHQFOrg zPsYoaZYg97e)2>HJM*DjH~Ky@yPh17&#R1JaTo)~DWy*d6$#g>Omf#|I`g@_Z9ucZ zLZ0lMiUAgWc({Ao!IqU>8z4U?rkog98J;`=WV~7vULJ67I#a3LzNjr9MHaaV`|{&e z5r7o*$#r7Bb0UywQxYFYcc|IV?+)ezlezk3e^ta1TNHTo*h!@`^}Z@hyAwl3Cp06+ zJx8N2{mrE_#$Ap~GTrDzx@Olc4AZ61P3OyZAGfN&oZ(zi{^iQ#0%ZW6O5vz6P)Pv2E2o15-ZBH~Pjt4qJ@tS`-9b z(>{v>oaL_=HBW_hE9jSqeVN{D`j(pGIo=*w+NL|Or5|^Z4 zDH^qEa&Vp-XvNMwIAP;nwCJ3w)!|G`%{qV8tvVd!t8(!(f-DVgkeH$25lP4{Hm`l_ zA94Gerc3h|vD}ByrAnD?`7sZ#1CQ}aT0COx$FHJ`Xl2l-c5{1svs|S8JdGl3$5kY4 zngi#%NLCa&U(2ImvR!#qtva7 ztSe-z5BozQpQ>8Gt9v;Izd5mGCr}?bh~*DELnv~^>V>R`K5Z8Q^qF9y!r~(Xg`uY)8na zRr#MDy(kY&Q_=J~X}{c(dRzD$2@mEsiK;;~!>3GZmNmP2mb3~8zh42h*uTTc{l@eA zFe5^BQ`txxTxI@#^l!bhz3<qeFZVzb`ux=%uNBuLgFX1E>RR$Hn;( zgI__w7IP|DH6tsN;BinWDG4^fMD6>XMpu>IPBva|SPi-rMHg-F{n}nN7Nrcw*p*H@ z#Y#)l-#LY8XEmk&N=CFUI>X^~jnfYBM${@qOYf8C)lmtI#}9I`g{!VjlsISlib+mQ zsAwT>H{M^=fABV1)9W=O@ml`2;hkxO??Mx)h zU|UMe3BwXWX>+>C77cC2lBvs($FyKIr|SLIsH< zG%b8HTcDR$hm=`9Ayzn;TYyGo+qtoJ?N$Cir9p({Dg3hp;i#Ag6R3kt;9=$uk%9g7 zXY)F%+c{8<8|dv&|hM%eiCqYp{vDB_)cvSg%Z@E~NxU z|8Yo}T@{#X7Lmao2>TgFua70>ynJp>^hUm9e#bzE`GYRw9N(6eo_s=t#ZOd|z%^B! zc9fwgP+)PAZ{2F(x$l$%Pak_!wo0^n^bk%~H#8rL|5j|H4LX8qZRu=11ZG1WpAP)X zis)M%1JPyE?S69PP}aD2S6KV$q32LErb~0>S&s|$;A_Pi*@+@G9OyooeB0%d=5`0K zA!E2u&9g-ZLUR@*o4^%x)T!-kj!TN1U(WiYhCC^u;l*x{hUx7Gn2t$G=pQ>!$I3zL z_!@(cBTAje@OHKF^zxSPVqS6yb0C8+fuz}E>ZYIMDDR!y*;#wl_3u7tN=}?HMS_Ti zFJdGazd7spInRsJ#M`liy8;q`X0|w2ZPqZm)_XhkynL7*d+E<>J`4~pwYU^2>cB5> zbNzVf*SE7`UL58#RwPT)Z`0m1hPs*d-#-=Gv#(aMQy)(+3L(kyZN{$QtLB?eA}bbK z9S|jU@S?!ozRaqdaj(EA!6d!zt3{~|Xjh>sg{fUT>ebB+kDdf=KM1rX9Bw^;XEydV z=-Qpt^aObdbs^ySg_Ma;21}Mo6)2uY(YlnPuXQ|zRWp}Gx0n4jKVdHPjQ`VR&h%ll zFpBSJ@zo35+^8Oz`Rfe|{m?hE3HFbVVv*cGoExm#yV^id-Qbh|+f9 zZ9ez6ncq63sd5PTy|C2RMr`_R@8~0gCzIUBn43I7-h|85yCbu_mcweJ!i1^-N|-d- zE%3)q1tT<;bqT0_iDqO~vdO(jy` z>#4^q(ob|G6pFsq8N1%1RmXR87yV8`qKxAIe&k_PTktKq8hGQqAuGV)RoU@5ovkE- zfrES5#aXJ7@fIV)jwp?3N(Hu=-G!CLN7PphQ2EAvggBer?Flq(Z|5!`ZC2b?fQ7m_Qzi9P*_H{R(3ygIg6AFx=5P? zj}iIomrn(I+!GxCWko{#&4iM+$!=L(T>5 zz_~C>WQOf*zPysabDg_d=1qp?*GV|8Eu3_RKsyss63r~~1WN61E(K~=U5;T!Njo)lKH^P%21%v2Z z)eN9wIn%r;4jAd=xrs?mEUT*Mg_ao)-X1x+Bv-(j*fa+R&*@_3ixh3fWY*cwSJINo z^X3=l=xEQMUY`#CP|%0#2^%hQfrmGnQI}ui`LswM{GuANb}RDdnfYlZzSq7IwbyL) zeM7GJ_35lm-Sh^9#8Xyn;l&NsPHt6yjbHijBHw3uf77-sZ)tD)b^Ln7&W-#Vs@GXP z(2`4e0nL#3UwW!m=}l~pJo*k{A}b^28m=-aFK_;koW@^&ua>$9m29m{<?;W_x~fC55J`EP(5St6lHwulCV%aJSEX1$(WBhAQ#7z7#81Kph7j!8y&o zNgAuXdiD^+YhK9sa9yBLfr9%-UhC%{a?7HXnqIn)w>G_(tisvUmGXB}0Mzxljp@6p zcYRSf_VuzjQXP6IA_`S77|teO#nrwyFrr$y=Z`7?D~F~#Dm2oeZ#Osp^1LuH*)8*B z!aBeMQG$QGRAV*Td9Xa=cl}uS@$~+oXM;2GuGq8jn`TOF)Rp5#rsiwLyn8MY*ZfIe zY!sR5=e2Vwrk%aNrM@99JgtoKZ}Mnw@|YqVuTH(04lu1PDNDeW$S%JJqQtO{6@ss8 zJ?4-9mY;vf6G#4Z!sDHK&?$HOoDSz}eH#-mrl?holDQdZ_YEFIZuQ%$PhjSm7cFKT zm^GQqc$)7}P`)iW=;JUH^j=G=StZlmf#q~sr60}oP_EphClgGJ`9ZsEYJO{X3erv_ zNaT9gzr9KGQzp$@;%1Wp&$i%$C~a+Bh0)Bx(wKJthb%x*4cvchrSa|uVaA7VtJ|9?7%aU0?1KvzOmM>&v3a=^fTpV^zq_Yu#6R$QXlO5cq0z<1zSLrf zKl>7AoKqps`G3DxHDS&(QCQPVb5i>h2+AkyW|So#c>YV(kPj6_TD+27fR6_JW}?_t*UZK zRt#~WdqV;E#t`S9*ABQs;uxfUUz*bPW%<@c+QH%Vit+Tzd?feK7@%kTjDbY@&iYO0 zCB}WPzT*)%yl*HMx#1ItLMToMh5-YUj&<->Eq%apfTBY^1YwE`&}r&SBg(lHIU2SB z0bP)2dEijX@W%mA6kh06nJm*9+AOZwZ Mme-K0lrazfKhH#5FaQ7m literal 0 HcmV?d00001 diff --git a/src/client/assets/styles/mobile.css b/src/client/assets/styles/mobile.css index d21d651..d2d2cae 100644 --- a/src/client/assets/styles/mobile.css +++ b/src/client/assets/styles/mobile.css @@ -842,6 +842,7 @@ a.create-button { .peers-list .block-info-table, .peers-technical-block .block-info-table, +.viewer-hidden-block .block-info-table, .parliament-rows-table, .gov-overview { display: block !important; @@ -853,6 +854,7 @@ a.create-button { } .peers-list .block-info-table tbody, .peers-technical-block .block-info-table tbody, +.viewer-hidden-block .block-info-table tbody, .parliament-rows-table tbody, .gov-overview tbody { display: block !important; @@ -861,11 +863,13 @@ a.create-button { .gov-overview thead, .parliament-rows-table thead { display: none !important; } .peers-list .block-info-table tr:first-child, -.peers-technical-block .block-info-table tr:first-child { +.peers-technical-block .block-info-table tr:first-child, +.viewer-hidden-block .block-info-table tr:first-child { display: none !important; } .peers-list .block-info-table tr, .peers-technical-block .block-info-table tr, +.viewer-hidden-block .block-info-table tr, .parliament-rows-table tbody tr, .gov-overview tbody tr { display: block !important; @@ -879,6 +883,7 @@ a.create-button { } .peers-list .block-info-table td, .peers-technical-block .block-info-table td, +.viewer-hidden-block .block-info-table td, .parliament-rows-table tbody td, .gov-overview tbody td { display: flex !important; @@ -895,6 +900,7 @@ a.create-button { } .peers-list .block-info-table td::before, .peers-technical-block .block-info-table td::before, +.viewer-hidden-block .block-info-table td::before, .parliament-rows-table tbody td::before, .gov-overview tbody td::before { content: attr(data-label); @@ -908,8 +914,10 @@ a.create-button { white-space: nowrap !important; word-break: normal !important; } -.peers-technical-block .block-info-table td[data-label=""] { justify-content: center !important; } -.peers-technical-block .block-info-table td[data-label=""]::before { content: none !important; } +.peers-technical-block .block-info-table td[data-label=""], +.viewer-hidden-block .block-info-table td[data-label=""] { justify-content: center !important; } +.peers-technical-block .block-info-table td[data-label=""]::before, +.viewer-hidden-block .block-info-table td[data-label=""]::before { content: none !important; } .peers-list .block-info-table td .peer-key, .peers-technical-block .block-info-table td .peer-key { text-align: right !important; } .phone-history td.phone-dir, diff --git a/src/client/assets/styles/style.css b/src/client/assets/styles/style.css index 3b5835a..f3c8297 100644 --- a/src/client/assets/styles/style.css +++ b/src/client/assets/styles/style.css @@ -1042,6 +1042,11 @@ article img { .feed-container .post-image{max-width:100%;width:auto;max-height:480px;height:auto} .feed-container .post-video,.feed-container .video-container video{width:100%;max-width:720px;max-height:480px;height:auto} .feed-container .post-audio,.feed-container .audio-container audio{width:100%} +.feed-detail-card .post-image{width:auto;max-height:480px;margin-left:auto;margin-right:auto} +.feed-detail-card .post-video{max-height:480px;margin-left:auto;margin-right:auto} +.feed-detail-card .post-audio{width:100%} +.feed-detail-card .zoom-link:not(.zoom-inline){display:block;text-align:center} +.feed-detail-card .zoom-link:not(.zoom-inline) img{display:inline-block} .fediverse-network{border-radius:8px;padding:18px 22px;margin-top:10px;display:flex;flex-direction:column;gap:16px} .fediverse-network > p,.fediverse-network form > p{margin:0} @@ -1521,6 +1526,7 @@ button.create-button:hover { color: #e74c3c; } +.karma-line-main{display:block;line-height:1.25}.profile-sensors-box .karma-line.karma-line-stacked,.inhabitant-left .karma-line.karma-line-stacked,.inhabitant-karma-ubi .karma-line.karma-line-stacked,.karma-line.karma-line-stacked{display:flex;flex-direction:column;align-items:center;gap:1px}.karma-received-row{display:flex;justify-content:center;align-items:center;gap:14px;margin-top:0;line-height:1.25}.karma-received{display:inline-flex;align-items:center;gap:4px;font-weight:600;white-space:nowrap;line-height:1}.karma-arrow{font-size:.85em;line-height:1;display:inline-block}.karma-received-up{color:#2ecc71}.karma-received-down{color:#e74c3c}.karma-value-positive{color:#2ecc71 !important}.karma-value-neutral{color:#ffd700 !important}.karma-value-negative{color:#e74c3c !important} .module-stats-line .stat-label-green{color:#2ecc71} .module-stats-line .stat-label-orange{color:#f39c12} .module-stats-line .stat-label-red{color:#e74c3c} @@ -2508,6 +2514,11 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0; text-decoration: underline; } +.feed-main a.tag-link, +.feed-main a.tag-link:hover { + text-decoration: none; +} + .votes-wrapper { margin-top: 16px; padding-top: 12px; @@ -6005,6 +6016,12 @@ a:hover .map-cluster-dot { fill: #ffffff; } .chat-pin-toggle{position:absolute;top:4px;right:6px} .chat-has-pin-toggle{padding-right:38px} .chat-pinned-link{margin-right:8px} +.btn-singleview .btn-icon{display:flex;align-items:center;justify-content:center;width:1em;height:1em;line-height:0} +.btn-singleview .btn-icon svg{display:block;width:100%;height:100%} +.btn-singleview .btn-count{margin-left:5px;line-height:1;font-size:.9em} +.btn-singleview:has(> .btn-icon:only-child){width:36px;min-width:36px;height:36px;padding:0;flex-shrink:0} +.btn-singleview.btn-content{background:#ffa300;color:#1e1f23;border:1px solid #ffa300} +.btn-singleview.btn-content:hover{background:#FFD700;color:#1e1f23;border-color:#FFD700} .btn-singleview.btn-delete{color:#ff6b6b !important;border-color:rgba(255,107,107,.55) !important} .btn-singleview.btn-delete:hover{background:rgba(255,107,107,.15) !important} .feed-opinions-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:transparent !important;border:0 !important;padding:0 !important;margin:6px 0;box-shadow:none} @@ -6209,8 +6226,8 @@ details[open] > .school-lesson-summary .school-lesson-summary-title::after{conte .torrent-table{width:100%;border-collapse:collapse;margin-top:12px} .torrent-table th,.torrent-table td{padding:8px 12px;text-align:left} .torrent-table td:has(.filter-btn){width:1%;white-space:nowrap} -.torrent-table td:nth-child(3){max-width:380px;overflow-wrap:anywhere} -.torrent-table td:nth-child(4),.torrent-table td:nth-child(6){white-space:nowrap;width:1%} +.torrent-table td:nth-child(2){width:auto;overflow-wrap:anywhere} +.torrent-table th:nth-child(1),.torrent-table td:nth-child(1),.torrent-table td:nth-child(3),.torrent-table td:nth-child(4),.torrent-table td:nth-child(5){white-space:nowrap;width:1%} .torrent-table td .filter-btn{margin:0} .torrent-table th{background:#1a1a1a;color:#FFA500;font-weight:bold} .torrent-download{margin:12px 0;background:transparent;border:0;padding:0;box-shadow:none} @@ -6318,6 +6335,7 @@ details[open] > .school-lesson-summary .school-lesson-summary-title::after{conte .stats-toplist .stats-toplist-num{flex:0 0 auto;color:#FFDD44;font-size:13px;min-width:36px;text-align:right} .peer-key{word-break:break-all;font-size:12px} .peers-list .block-info-table{table-layout:fixed;width:100%} +.viewer-hidden-block .block-info-table{width:100%}.viewer-hidden-block .block-info-table td{overflow-wrap:anywhere}.viewer-hidden-block .block-info-table td:nth-child(2),.viewer-hidden-block .block-info-table td:nth-child(3){white-space:nowrap;width:1%} .peers-list .block-info-table td{overflow-wrap:anywhere} .peers-list .block-info-table td:nth-child(1){width:18%} .peers-list .block-info-table td:nth-child(2){width:47%} @@ -6326,6 +6344,8 @@ details[open] > .school-lesson-summary .school-lesson-summary-title::after{conte .peers-list .block-info-table td:nth-child(5){width:15%} .peers-technical-block{margin-top:16px} .peers-conn-actions{margin-bottom:12px} +.activity-toolbar-row .peers-conn-actions{margin:0;padding:0;border:0;background:transparent;box-shadow:none;display:flex;gap:6px;flex-wrap:wrap;margin-left:auto} +.activity-toolbar-row .peers-conn-actions form{margin:0} .invites-pubs-actions{margin:12px 0} .larp-cycle-banner{display:flex;flex-wrap:wrap;align-items:center;gap:8px;background:#1A1A1A;border:1px solid #FFA500;border-radius:6px;padding:8px 14px;margin:12px 0;font-family:monospace} .larp-post-submit{width:auto;display:inline-block;padding:6px 18px;align-self:flex-start} @@ -8088,7 +8108,6 @@ details[open] > .chat-poll-summary::after { content: " \25B4"; } .blockexplorer-search-actions input[type="text"], .logs-toolbar input[type="text"], .search-filters-row input[type="text"], -.tribe-search-form input[type="text"], .filter-box input[type="text"], .ui-toolbar input[type="text"] { margin: 0; @@ -8327,7 +8346,9 @@ details[open] > .chat-poll-summary::after { content: " \25B4"; } .school-progress-failed{font-size:0.85em;color:#e57373;cursor:help} .school-lessons .school-lesson,.school-exams .school-lesson{background:transparent !important;border:0 !important;box-shadow:none;padding:0 !important;margin:0 0 16px 0} .school-exam-question{background:transparent !important;border:0 !important;padding:0 !important;box-shadow:none;margin:0 0 14px 0} -.top-bar-center .ai-ask-form{flex:1 1 auto;min-width:220px} +.top-bar-center .ai-ask-form{flex:1 1 auto;min-width:0} +.top-bar-center:not(.top-bar-center-ainav){container-type:inline-size} +@container (max-width:180px){.ai-ask-row .ai-ask-form{display:none}} .ai-suggestion-inline{display:inline-flex;align-items:center;gap:6px;background:transparent !important;border:0 !important;padding:0 !important;margin:0 0 0 12px !important;box-shadow:none;font-size:.85em;white-space:nowrap;flex:0 1 auto;min-width:0;overflow:hidden} .ai-suggestion-inline .update-banner-link{display:inline-block;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle} .ai-suggestion-inline .welcome-banner-close{margin:0;padding:0;background:transparent;border:0;box-shadow:none;display:inline} @@ -8694,7 +8715,7 @@ label.wallet-create-transfer input[type="checkbox"]{width:auto !important;flex:0 .peertube-description{white-space:pre-wrap;word-break:break-word} .peertube-nostream p{margin:8px 0 0} .torrent-table td{vertical-align:middle} -.torrent-spread-cell{text-align:center;white-space:nowrap} +.torrent-spread-cell,.torrent-table th.torrent-spread-cell,.torrent-table td.torrent-spread-cell{text-align:center;white-space:nowrap} .torrent-spread-cell .spread-form{display:inline-block;margin:0;vertical-align:middle} .torrent-spread-cell .btn-singleview{margin:0;vertical-align:middle} .torrent-detail-section{margin:0 0 18px} @@ -8743,6 +8764,7 @@ label.wallet-create-transfer input[type="checkbox"]{width:auto !important;flex:0 .room-peer .phone-action-form .tribe-action-btn{padding:2px 8px;font-size:11px} .phone-call-peers{margin:8px 0 0 0} .block-info-table .pm-exposition-chip{white-space:nowrap} +.torrent-table td > .pm-exposition-chip-link,.torrent-table td > .pm-exposition-chip,.file-table td > .pm-exposition-chip-link,.file-table td > .pm-exposition-chip{margin-left:10px;vertical-align:middle;position:relative;top:-3px} .peers-technical-block .inline-form .filter-btn{white-space:nowrap} .peers-technical-block .peer-port{white-space:nowrap} .peers-technical-block .peer-host{overflow-wrap:normal;word-break:normal} @@ -8836,7 +8858,7 @@ label.wallet-create-transfer input[type="checkbox"]{width:auto !important;flex:0 .oasis-pager-btn:hover,.oasis-pager-btn.active{border-color:#FFD700;color:#FFD700;text-decoration:none} .card-title-link{color:inherit;text-decoration:none} .card-title-link:hover{text-decoration:underline} -.graphos-zoom-row{display:flex;justify-content:flex-end;margin:0 0 8px;padding:0;border:0;background:none;box-shadow:none} +.graphos-statsbar{margin:12px 0 14px}.graphos-canvas{position:relative}.graphos-canvas-svg{margin:0;padding:0;border:0 !important;background:transparent !important;box-shadow:none}.graphos-canvas .graphos-zoom-btn{position:absolute;top:8px;right:8px;margin:0;white-space:nowrap;z-index:1}.graphos-kpi .stats-kpi-label{text-transform:uppercase;white-space:nowrap}.graphos-kpi .stats-kpi-value{font-weight:bold} .graphos-lightbox .graphos-canvas-zoom{width:96vw;height:92vh;overflow:auto;background:#111;border:1px solid #2a2a2a;border-radius:8px;box-sizing:border-box;padding:0;margin:0} .graphos-lightbox .graphos-zoom-stage,.graphos-lightbox .graphos-zoom-svg{padding:0;margin:0;border:0;border-radius:0;background:none;box-shadow:none} .graphos-lightbox .graphos-svg{width:100%;height:auto;max-height:calc(92vh - 4px);min-height:0;display:block} @@ -8848,24 +8870,46 @@ label.wallet-create-transfer input[type="checkbox"]{width:auto !important;flex:0 .stats-block .tag-table,.stats-card .tag-table{width:100%;border-collapse:collapse;table-layout:fixed} .stats-block .tag-table th:first-child,.stats-block .tag-table td:first-child,.stats-card .tag-table th:first-child,.stats-card .tag-table td:first-child{width:70%} .stats-block .tag-table th:last-child,.stats-block .tag-table td:last-child,.stats-card .tag-table th:last-child,.stats-card .tag-table td:last-child{width:30%;text-align:right} -.video-grid{display:flex;flex-wrap:wrap;gap:8px;margin:18px 0 0;padding:0;border:0;background:transparent;box-shadow:none} -.video-tile{display:flex;flex-direction:column;gap:0;padding:0;margin:0;width:320px;max-width:100%;border:1px solid #333;border-radius:6px;overflow:hidden;background:#000} +.video-grid{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin:18px 0 0;padding:0;border:0;background:transparent;box-shadow:none} +.video-tile{display:flex;flex-direction:column;gap:0;padding:0;margin:0;width:420px;max-width:100%;border:1px solid #333;border-radius:6px;overflow:hidden;background:#000} +.video-grid-solo .video-tile{width:800px} .video-feed{display:block;width:100%;aspect-ratio:4/3;object-fit:cover;background:#000} .video-tile .user-link,.video-tile-name{display:block;margin:0;padding:4px 8px;font-size:12px;text-align:left;background:#1a1a1a} .video-tile-me .video-feed{transform:scaleX(-1)} .room-peer-cam{flex:0 0 auto;color:#ffa300;font-size:11px;font-weight:700;border:1px solid #ffa300;border-radius:4px;padding:1px 6px} -.room-cam-btn-on{background:#ffa500;color:#000} -.video-grid-one{flex-direction:column;align-items:flex-start} -.video-feed-grid{width:auto;max-width:100%;aspect-ratio:auto;border:1px solid #333;border-radius:6px} +.tribe-action-btn.room-cam-btn-on{background:#ffa500 !important;color:#000 !important;border-color:#ffa500 !important} +.room-stream-chip{color:#ffa300;font-size:11px;font-weight:700;border:1px solid #ffa300;border-radius:4px;padding:1px 6px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.room-public-warning{margin:8px 0 0;font-size:12px;color:#ffa300} +.video-tile-film{color:#ffa300} +.room-stream-form{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;position:relative} +.room-live-controls>.room-stream-form{flex:0 1 auto;max-width:100%} +.room-stream-input{position:absolute;left:0;top:0;width:1px;height:1px;opacity:0;pointer-events:none;clip-path:inset(50%)} +.room-stream-pick{cursor:pointer} +.room-stream-input:invalid ~ .room-stream-go,.room-stream-input:valid ~ .room-stream-pick{display:none} +.room-stream-go{order:2} +.room-stream-form .room-stream-input:valid{position:static !important;width:auto !important;height:auto !important;max-width:160px !important;opacity:1 !important;pointer-events:auto !important;clip-path:none !important;order:1;margin:0 !important;padding:1px 6px !important;border:1px solid #ffa300 !important;border-radius:4px !important;background:transparent !important;color:#ffa300 !important;font-size:11px !important;font-weight:700;line-height:1.6;cursor:pointer} +.room-stream-input::file-selector-button{display:none} +.room-stream-input::-webkit-file-upload-button{display:none} +.stream-picker-list{list-style:none;margin:10px 0 0;padding:0;display:flex;flex-direction:column;gap:6px} +.stream-picker-item{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;padding:6px 10px;border:1px solid rgba(128,128,128,.4);border-radius:6px} +.stream-picker-link{display:flex;align-items:center;gap:8px;min-width:0;text-decoration:none} +.stream-picker-link .btn-icon{display:flex;flex:0 0 auto;width:18px;height:18px} +.stream-picker-link .btn-icon svg{display:block;width:100%;height:100%} +.stream-picker-name{overflow-wrap:anywhere} +.stream-picker-size{flex:0 0 auto;font-size:12px;opacity:.7;white-space:nowrap} +.stream-picker-item .phone-action-form{flex:0 0 auto;margin:0} +.video-grid-one{flex-direction:column;align-items:center} +.video-feed-grid{width:100%;max-width:100%;aspect-ratio:auto;border:1px solid #333;border-radius:6px} +.video-grid-solo .video-feed-grid{max-width:800px;margin:0 auto} .video-grid-names{display:flex;flex-wrap:wrap;gap:6px;padding:0;margin:0;border:0;background:transparent;box-shadow:none} .video-grid-names .user-link,.video-grid-names .video-tile-name{padding:4px 8px;font-size:12px;background:#1a1a1a;border-radius:4px} .file-table{width:100%;border-collapse:collapse;margin-top:12px} .file-table th,.file-table td{padding:8px 12px;text-align:left;vertical-align:middle} .file-table th{background:#1a1a1a;color:#FFA500;font-weight:bold} -.file-table td:nth-child(1),.file-table td:nth-child(4),.file-table td:nth-child(5),.file-table td:nth-child(6),.file-table td:nth-child(7){white-space:nowrap;width:1%} -.file-table td:nth-child(3){overflow-wrap:anywhere} +.file-table th:nth-child(1),.file-table td:nth-child(1),.file-table td:nth-child(3),.file-table td:nth-child(4),.file-table td:nth-child(5),.file-table td:nth-child(6){white-space:nowrap;width:1%} +.file-table td:nth-child(2){width:auto;overflow-wrap:anywhere} .file-table td .filter-btn{margin:0} -.file-table td.torrent-spread-cell{text-align:center} +.file-table th.torrent-spread-cell,.file-table td.torrent-spread-cell{text-align:center} .forum-inline-reply{padding:0;margin:6px 0 0;border:0;background:transparent;box-shadow:none} .forum-inline-reply-toggle{cursor:pointer;display:inline-block;padding:3px 10px;border:1px solid #ffa500;border-radius:10px;background:#1a1a1a;color:#ffa500;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;list-style:none;width:max-content} .forum-inline-reply-toggle::-webkit-details-marker{display:none} @@ -8874,3 +8918,9 @@ label.wallet-create-transfer input[type="checkbox"]{width:auto !important;flex:0 .video-tile-me .lightbox-image{transform:scaleX(-1)} .phone-call-panel .live-lightbox{background:rgba(0,0,0,.85)} .live-lightbox .lightbox-image{width:min(80vw,880px);height:auto;max-width:none;max-height:80vh;object-fit:contain;cursor:zoom-out} +.live-fullscreen-btn{position:absolute;top:15px;right:112px;width:42px;height:42px;display:flex;align-items:center;justify-content:center;box-sizing:border-box;background:rgba(0,0,0,.6);border-radius:50%;color:#fff;text-decoration:none} +.live-fullscreen-btn .btn-icon{display:flex;width:20px;height:20px} +.live-fullscreen-btn .btn-icon svg{display:block;width:100%;height:100%} +.live-lightbox.live-fullscreen{background:#000;z-index:10050} +.live-fullscreen .lightbox-image-link{display:flex;align-items:center;justify-content:center;width:100vw;height:100vh} +.live-fullscreen .lightbox-image{width:100vw;height:100vh;max-width:100vw;max-height:100vh;object-fit:contain;border-radius:0} diff --git a/src/client/assets/themes/Clear-SNH.css b/src/client/assets/themes/Clear-SNH.css index 28d53ba..3c4b2cc 100644 --- a/src/client/assets/themes/Clear-SNH.css +++ b/src/client/assets/themes/Clear-SNH.css @@ -318,8 +318,8 @@ a.user-link:focus, button.user-link:focus { .btn-singleview:hover { background: #E0E0E0 !important; color: #FF8F00 !important; } .btn-spread-on, .content-actions .btn-pin-on { background: #FF6F00 !important; color: #FFFFFF !important; } .btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #FF8F00 !important; color: #FFFFFF !important; } -.content-actions .btn-singleview.btn-content { border: 1px solid #FF6F00 !important; } -.content-actions .btn-singleview.btn-content:hover { background: rgba(255, 111, 0, .18) !important; border-color: #FF6F00 !important; } +.btn-singleview.btn-content { background: #FF6F00 !important; color: #F0F0F0 !important; border: 1px solid #FF6F00 !important; } +.btn-singleview.btn-content:hover { background: #FF8F00 !important; color: #FFFFFF !important; border-color: #FF8F00 !important; } .btn-back { background: #FF6F00 !important; color: #FFFFFF !important; } .btn-back:hover { background: #FF8F00 !important; color: #FFFFFF !important; } .block-info-table td, .pm-info-table td { border-color: #E0E0E0 !important; } @@ -344,6 +344,7 @@ a.user-link:focus, button.user-link:focus { .tribe-members-count { color: #FF6F00 !important; } .tribe-card-actions { border-color: #E0E0E0 !important; background: #F8F8F8 !important; } .tribe-action-btn { border-color: #FF6F00 !important; color: #FF6F00 !important; } +button.tribe-action-btn { background: transparent !important; border: 1px solid #FF6F00 !important; } .tribe-action-btn:hover { background: #FF6F00 !important; color: #fff !important; } .tribe-subtribe-link { background: #F0F0F0 !important; border-color: #E0E0E0 !important; color: #FF6F00 !important; } .tribe-thumb-link { border-color: #E0E0E0 !important; } diff --git a/src/client/assets/themes/Dark-SNH.css b/src/client/assets/themes/Dark-SNH.css index 335c1d4..6fe0855 100644 --- a/src/client/assets/themes/Dark-SNH.css +++ b/src/client/assets/themes/Dark-SNH.css @@ -318,8 +318,8 @@ a.user-link:focus, button.user-link:focus { .btn-singleview:hover { background: #2d2e34 !important; color: #FFD700 !important; } .btn-spread-on, .content-actions .btn-pin-on { background: #ffa300 !important; color: #000000 !important; } .btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #ffb733 !important; color: #000000 !important; } -.content-actions .btn-singleview.btn-content { border: 1px solid #ffa300 !important; } -.content-actions .btn-singleview.btn-content:hover { background: rgba(255, 163, 0, .18) !important; border-color: #ffa300 !important; } +.btn-singleview.btn-content { background: #ffa300 !important; color: #1e1f23 !important; border: 1px solid #ffa300 !important; } +.btn-singleview.btn-content:hover { background: #FFD700 !important; color: #1e1f23 !important; border-color: #FFD700 !important; } .btn-back { background: #21232b !important; color: #ffa300 !important; } .btn-back:hover { background: #292b36 !important; color: #FFD700 !important; } .block-info-table td, .pm-info-table td { border-color: #444 !important; } diff --git a/src/client/assets/themes/Matrix-SNH.css b/src/client/assets/themes/Matrix-SNH.css index ebf4f2d..1daec99 100644 --- a/src/client/assets/themes/Matrix-SNH.css +++ b/src/client/assets/themes/Matrix-SNH.css @@ -320,8 +320,8 @@ a.user-link:focus, button.user-link:focus { .btn-singleview:hover { background: #00FF00 !important; color: #000000 !important; } .btn-spread-on, .content-actions .btn-pin-on { background: #00FF00 !important; color: #000000 !important; } .btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #66FF66 !important; color: #000000 !important; } -.content-actions .btn-singleview.btn-content { border: 1px solid #00FF00 !important; } -.content-actions .btn-singleview.btn-content:hover { background: rgba(0, 255, 0, .18) !important; border-color: #00FF00 !important; } +.btn-singleview.btn-content { background: #00FF00 !important; color: #000000 !important; border: 1px solid #00FF00 !important; } +.btn-singleview.btn-content:hover { background: #000000 !important; color: #00FF00 !important; border-color: #00FF00 !important; } .btn-back { background: #000000 !important; color: #00FF00 !important; border: 1px solid #00FF00 !important; } .btn-back:hover { background: #00FF00 !important; color: #000000 !important; } .block-info-table td, .pm-info-table td { border-color: #00FF00 !important; } diff --git a/src/client/assets/themes/OasisMobile.css b/src/client/assets/themes/OasisMobile.css index 924729f..687c4e2 100644 --- a/src/client/assets/themes/OasisMobile.css +++ b/src/client/assets/themes/OasisMobile.css @@ -1064,3 +1064,116 @@ body:has(.oasis-nav-side-toggle) { padding-top: 84px !important; } .oasis-nav-side-toggle ~ .header .top-bar-left nav, .oasis-nav-side-toggle ~ .header .top-bar-right nav, .oasis-nav-side-toggle ~ .header .top-bar-left a.logo-icon { display: none !important; } + +/* ========================================================================== + Upstream 1.3.3: piezas nuevas (karma del perfil, directo a pantalla completa, + salas en vivo, graphos). Copiado tal cual del tema oficial: el resto de ese + tema es otro aspecto y no se trae. + ========================================================================== */ +.main-column .live-lightbox.live-fullscreen { + top: 0 !important; + bottom: 0 !important; + z-index: 10050 !important; + background: #000 !important; +} +.main-column .live-lightbox .live-fullscreen-btn { + top: 8px !important; + right: 64px !important; + z-index: 2 !important; + color: #FFD700 !important; +} + +.profile-sensors-box span.karma-line.karma-line-stacked { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + gap: 1px !important; +} +.profile-sensors-box span.karma-line .karma-line-main { + display: block !important; + line-height: 1.25 !important; +} +.profile-sensors-box span.karma-line .karma-received-row { + display: flex !important; + justify-content: center !important; + align-items: center !important; + gap: 14px !important; + margin-top: 0 !important; + line-height: 1.25 !important; +} +.profile-sensors-box span.karma-line .karma-received { + display: inline-flex !important; + align-items: center !important; + gap: 4px !important; +} + +.main-column .room-live-panel .room-live-head { + gap: 6px !important; +} +.main-column .room-live-head .room-live-controls { + gap: 6px !important; +} +.main-column .room-live-controls .tribe-action-btn { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + min-height: 0 !important; + height: auto !important; + padding: 5px 10px !important; + font-size: 12px !important; + line-height: 1.2 !important; + letter-spacing: 0 !important; + border-radius: 8px !important; +} +.main-column .room-live-controls .room-stream-input:invalid ~ .room-stream-go, +.main-column .room-live-controls .room-stream-input:valid ~ .room-stream-pick { + display: none !important; +} +.main-column .room-stream-chip { + display: inline-flex !important; + align-items: center !important; + gap: 4px !important; + max-width: 100% !important; + padding: 2px 10px !important; + border-radius: 12px !important; + font-size: 12px !important; + line-height: 1.4 !important; + background: rgba(255, 163, 0, 0.1) !important; +} +.main-column .room-live-panel .video-grid { + margin: 10px 0 !important; +} +.main-column .room-live-panel #room-video { + scroll-margin-top: 80px !important; +} +.main-column .room-live-panel .phone-contacts { + gap: 4px !important; +} +.main-column .phone-contact.room-peer { + min-height: 0 !important; + padding: 4px 6px !important; + gap: 6px !important; +} +.main-column .phone-contact.room-peer .user-link { + padding: 4px 10px !important; + font-size: 11px !important; +} +.main-column .phone-contact.room-peer :is(.room-peer-you, .room-peer-cam, .room-peer-muted, .room-peer-hand) { + padding: 1px 5px !important; + font-size: 10px !important; +} +.main-column .phone-contact.room-peer :is(button, .tribe-action-btn) { + min-height: 0 !important; + padding: 3px 8px !important; + font-size: 11px !important; +} +.main-column .live-lightbox.live-fullscreen { + top: 0 !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + width: 100vw !important; + height: 100vh !important; +} +.main-column .video-grid-solo .video-tile { width: 100% !important; } +.main-column .inhabitants-grid .inhabitant-card { padding-bottom: 12px !important; } diff --git a/src/client/assets/themes/Purple-SNH.css b/src/client/assets/themes/Purple-SNH.css index 8ebb4a1..0f50ff7 100644 --- a/src/client/assets/themes/Purple-SNH.css +++ b/src/client/assets/themes/Purple-SNH.css @@ -355,8 +355,8 @@ a.user-link:focus, button.user-link:focus { .btn-singleview:hover { background: #5A1A85 !important; color: #FFD600 !important; } .btn-spread-on, .content-actions .btn-pin-on { background: #B86ADE !important; color: #2A0E42 !important; } .btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #CE8CE9 !important; color: #2A0E42 !important; } -.content-actions .btn-singleview.btn-content { border: 1px solid #B86ADE !important; } -.content-actions .btn-singleview.btn-content:hover { background: rgba(184, 106, 222, .18) !important; border-color: #B86ADE !important; } +.btn-singleview.btn-content { background: #B86ADE !important; color: #4B1A72 !important; border: 1px solid #B86ADE !important; } +.btn-singleview.btn-content:hover { background: #FFD600 !important; color: #4B1A72 !important; border-color: #FFD600 !important; } .btn-back { background: #A34AD8 !important; color: #FFFFFF !important; } .btn-back:hover { background: #751E9F !important; color: #FFFFFF !important; } .block-info-table td, .pm-info-table td { border-color: #B86ADE !important; } @@ -381,6 +381,7 @@ a.user-link:focus, button.user-link:focus { .tribe-members-count { color: #FFD600 !important; } .tribe-card-actions { border-color: #B86ADE !important; background: #2D0B47 !important; } .tribe-action-btn { border-color: #B86ADE !important; color: #B86ADE !important; } +button.tribe-action-btn { background: transparent !important; } .tribe-action-btn:hover { background: #B86ADE !important; color: #000 !important; } .tribe-subtribe-link { background: #4B1A72 !important; border-color: #B86ADE !important; color: #FFD600 !important; } .tribe-thumb-link { border-color: #B86ADE !important; } diff --git a/src/client/assets/translations/oasis_ar.js b/src/client/assets/translations/oasis_ar.js index 029a94a..c706560 100644 --- a/src/client/assets/translations/oasis_ar.js +++ b/src/client/assets/translations/oasis_ar.js @@ -187,7 +187,7 @@ module.exports = { peersTorColumn: "TOR", peersTorYes: "يمكن الوصول إليه عبر Tor", peersTorNo: "لا يمكن الوصول إليه عبر Tor", - peersOasisId: "Oasis ID", + peersOasisId: "ID", peersOasisVersion: "الإصدار", graphos: "غرافوس", graphosDescription: "خريطة تفاعلية للشبكة من حولك.", @@ -602,6 +602,21 @@ module.exports = { peerVersionUnannounced: "لا يعلن عن إصداره: Oasis قديم", peersTorMissing: "هذا الـPUB لا يسمح إلا بالاتصالات عبر TOR.", peerConnectionsTitle: "الاتصالات", + peersOnlineHint: "العقد المتصلة بك الآن.", + peersDiscoveredHint: "عقد ذات عنوان معروف غير متصلة حاليًا.", + peersUnknownHint: "عقد في شبكتك الاجتماعية بدون عنوان معروف.", + peersConnectionsHint: "العناوين التي يمكن لهذه العقدة الاتصال بها وحالة كل اتصال.", + peersTabLan: "الشبكة المحلية", + peersTabHealth: "الحالة", + peersHealthHint: "حال كل pub أو نظير معروف، داخل الشبكة المحلية وخارجها.", + peersHealthReachable: "يمكن الوصول إليه", + peersHealthFailing: "يفشل", + peersHealthFailures: "الإخفاقات", + peersFailingSince: "يفشل منذ", + peersHealthForget: "نسيان غير القابلة للوصول", + peersLanHint: "العقد التي تم العثور عليها في شبكتك المحلية.", + peersLanLastSeen: "آخر ظهور", + peersLanEmpty: "لم يُعثر على أي عقدة في الشبكة المحلية بعد.", online: "متصل", offline: "غير متصل", discovered: 'مُكتشَف', @@ -1650,8 +1665,6 @@ module.exports = { dataTermsTitle: "الكلمات المفتاحية", dataTermsHint: "المصطلحات التي تظهر في أكثر المحتويات.", dataConnections: "تطابقات مرتبطة", - dataTopicTitle: "كل ما يتعلق بـ", - dataTopicHint: "ما يربطه الخوارزم بهذا الموضوع", dataNoMatches: "لا توجد بيانات متقاطعة لعرضها.", dataNoProfile: "انشر محتوى أو اكتب سيرتك الذاتية ليتعلم الخوارزم ما يهمك.", pollsDescription: "وحدة لسؤال الشبكة وعدّ الإجابات.", @@ -2204,9 +2217,7 @@ module.exports = { tribeTrendingEngagement: "التفاعل", tribeTopCategory: "الأكثر تصويتًا", tribeTagsEmpty: "لم يتم العثور على وسوم.", - tribeSearchPlaceholder: "ابحث في محتوى القبيلة...", tribeSearchEmpty: "لم يتم العثور على نتائج.", - tribeSearchResults: "النتائج", tribeSearchMinChars: "أدخل حرفين على الأقل للبحث.", agendaTitle: "الأجندة", agendaDescription: "هنا يمكنك العثور على جميع العناصر المسندة إليك.", @@ -3835,6 +3846,7 @@ module.exports = { roomUpdate: "تحديث", roomDelete: "حذف", roomClose: "إغلاق", + roomReopen: "إعادة فتح", roomCreateSectionTitle: "إنشاء غرفة", roomUpdateSectionTitle: "تحديث الغرفة", roomTitlePlaceholder: "عنوان الغرفة", @@ -3853,6 +3865,15 @@ module.exports = { roomMuted: "مكتوم", roomRec: "تسجيل الصوت", roomRecStop: "إيقاف التسجيل", + roomStreamFile: "بثّ", + roomStreamStop: "إيقاف", + roomStreaming: "جارٍ البث", + liveFullscreen: "ملء الشاشة", + roomStreamPickTitle: "اختر فيديو", + roomStreamPickHint: "مقاطع الفيديو على هذا الجهاز. يُبث الفيديو الذي تختاره من مكانه دون نسخه أو نشره.", + roomStreamGo: "بثّه في الغرفة", + roomStreamNotFound: "هذا الفيديو ليس في مجلداتك.", + roomPublicWarning: "تُبث هذه الغرفة على الويب العام: يمكن لأي شخص رؤية وسماع ما يحدث فيها.", roomRecording: "REC", roomNotificationsOn: "ON", roomNotificationsOff: "OFF", @@ -4333,7 +4354,7 @@ module.exports = { phoneHistoryTitle: "السجل", phoneLatestTitle: "آخر المكالمات", phoneCreateButton: "مكالمة جديدة", - phoneLimitsHint: "حتى 7 من السكان لكل مكالمة؛ أكثر من ساكن واحد يجعلها مكالمة مشتركة.", + phoneLimitsHint: "حتى {n} من السكان لكل مكالمة؛ أكثر من ساكن واحد يجعلها مكالمة مشتركة.", phoneErrorPub: "لا يمكن الاتصال بخادم pub.", phoneJointCall: "مكالمة مشتركة", phoneWith: "مع", @@ -4478,6 +4499,22 @@ module.exports = { settingsPmVisibilityDesc: "قم بتكوين مستوى التعرض للرسائل الخاصة في الشبكة.", settingsPmVisibilityWhole: "الكون المتعدد", settingsPmVisibilityMutuals: "الدعم المتبادل فقط", + mute: "كتم", + unmute: "إلغاء الكتم", + relationshipMuted: "مكتوم من قبلك", + hideContent: "إخفاء عني", + unhideContent: "إظهار مجددًا", + hiddenByYou: "مخفي من قبلك", + settingsTabContent: "المحتوى", + settingsHiddenTitle: "المحتوى المخفي", + settingsHiddenDesc: "السكان الذين كتمتهم والمحتوى الذي أخفيته.", + settingsMutedLabel: "السكان المكتومون", + settingsHiddenLabel: "المحتوى المخفي", + settingsMutedEmpty: "لا أحد مكتوم.", + settingsHiddenEmpty: "لا شيء مخفي.", + settingsPmVisibilityFollowing: "فقط من أتابعهم", + settingsMentionsVisibilityTitle: "الإشارات", + settingsMentionsVisibilityDesc: "اختر من تصلك إشاراته.", pmMutualNotice: "الإرسال حاجز UX. الاستقبال فلتر انتباه.", inhabitantsPendingFollowsTitle: "طلبات الدعم المعلقة", inhabitantsPendingAccept: "قبول", @@ -4610,7 +4647,7 @@ module.exports = { bankRulesFloor: "الحد الأدنى لكل مستخدم (إجمالي)", bankRulesGraceDays: "فترة السماح للمطالبة", bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", - bankRulesKarmaNote: "تُشتق الكارما من أفعالك (المنشورات، والأصوات، والمشاركة في البرلمان/المحاكم، وغير ذلك) مع معامل تناقص بمرور الوقت، مطروحًا منها غرامات الكربون التي تولّدها تغذيتك.", + bankRulesKarmaNote: "تُشتق الكارما من أفعالك (المنشورات، والأصوات، والمشاركة في البرلمان/المحاكم، وغير ذلك) مع معامل تناقص بمرور الوقت، مطروحًا منها غرامات الكربون التي تولّدها تغذيتك. ما يتلقاه محتواك يُحتسب أيضًا: الآراء والأصوات والتفاعلات والتشغيلات الإيجابية تضيف كارما، والآراء أو الأصوات السلبية تخصمها عندما يتفق عدة سكان.", bankRulesKarmaTitle: "الكارما (درجة تفاعل المستخدم)", bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", @@ -4696,7 +4733,7 @@ module.exports = { larpRulesTestEntryText: "في اختبار WILL يمنح كل خيار وزنًا لبيت أو أكثر؛ وعند الإرسال يجمع النظام النقاط ويضمّك تلقائيًا إلى البيت الأعلى نقاطًا.", larpWillTestHint: "بمجرد الانتهاء، ستُسند إليك البيت الذي يتطابق بشكل أفضل مع إجاباتك. تتم معالجة إجاباتك الفردية محلياً ولا يتم تخزينها أبداً — يُنشر في خلاصتك فقط نتيجة تعيين البيت.", larpWillTestTitle: "اختبار WILL", - settingsLanDesc: "الإعلان بشكل دوري عن هذا النظير لمثيلات Oasis الأخرى على نفس الشبكة المحلية. تعطيل لإيقاف بث UDP.", + settingsLanDesc: "الإعلان بشكل دوري عن هذا النظير لمثيلات Oasis الأخرى على نفس الشبكة المحلية.", settingsLanEnable: "تفعيل بث الشبكة المحلية", settingsLanTitle: "بث الشبكة المحلية", settingsReplicationTitle: "النسخ", diff --git a/src/client/assets/translations/oasis_ca.js b/src/client/assets/translations/oasis_ca.js index 0129a4e..bc12bde 100644 --- a/src/client/assets/translations/oasis_ca.js +++ b/src/client/assets/translations/oasis_ca.js @@ -187,7 +187,7 @@ module.exports = { peersTorColumn: "TOR", peersTorYes: "Accessible per Tor", peersTorNo: "No accessible per Tor", - peersOasisId: "Oasis ID", + peersOasisId: "ID", peersOasisVersion: "Versió", graphos: "Graphos", graphosDescription: "Mapa interactiu de la xarxa al teu voltant.", @@ -594,6 +594,21 @@ module.exports = { peerVersionUnannounced: "No anuncia la seva versió: un Oasis antic", peersTorMissing: "Aquest PUB només permet connexions des de TOR.", peerConnectionsTitle: "Connexions", + peersOnlineHint: "Nodes connectats amb tu ara mateix.", + peersDiscoveredHint: "Nodes amb adreça coneguda que no estan connectats ara.", + peersUnknownHint: "Nodes del teu graf social sense adreça coneguda.", + peersConnectionsHint: "Adreces a les quals pot connectar aquest node i l'estat de cada connexió.", + peersTabLan: "LAN", + peersTabHealth: "Salut", + peersHealthHint: "Com està cada pub o node conegut, a la xarxa local i fora.", + peersHealthReachable: "Accessible", + peersHealthFailing: "Fallant", + peersHealthFailures: "Errors", + peersFailingSince: "Falla des de", + peersHealthForget: "Oblidar inaccessibles", + peersLanHint: "Nodes trobats a la teva xarxa local.", + peersLanLastSeen: "Vist per última vegada", + peersLanEmpty: "Encara no s'ha trobat cap node a la xarxa local.", online: "Connectat", offline: "Desconnectat", discovered: "Descoberts", @@ -1639,8 +1654,6 @@ module.exports = { dataTermsTitle: "Paraules clau", dataTermsHint: "Els termes que apareixen en més continguts.", dataConnections: "Coincidències enllaçades", - dataTopicTitle: "Tot el relacionat amb", - dataTopicHint: "El que l'algorisme relaciona amb aquest tema", dataNoMatches: "No hi ha dades creuades per mostrar.", dataNoProfile: "Publica contingut o escriu el teu CV perquè l'algorisme aprengui què t'interessa.", pollsDescription: "Mòdul per preguntar a la xarxa i comptar les respostes.", @@ -2194,9 +2207,7 @@ module.exports = { tribeTrendingEngagement: "interacció", tribeTopCategory: "Més votat", tribeTagsEmpty: "No s'han trobat etiquetes.", - tribeSearchPlaceholder: "Cercar contingut de la tribu...", tribeSearchEmpty: "Sense resultats.", - tribeSearchResults: "Resultats", tribeSearchMinChars: "Introdueix almenys 2 caràcters per cercar.", agendaTitle: "Agenda", agendaDescription: "Aquí pots trobar tots els teus elements assignats.", @@ -3833,6 +3844,7 @@ module.exports = { roomUpdate: "Actualitzar", roomDelete: "Eliminar", roomClose: "Tancar", + roomReopen: "Reobrir", roomCreateSectionTitle: "Crear sala", roomUpdateSectionTitle: "Actualitzar sala", roomTitlePlaceholder: "Títol de la sala", @@ -3851,6 +3863,15 @@ module.exports = { roomMuted: "silenciat", roomRec: "Gravar àudio", roomRecStop: "Atura la gravació", + roomStreamFile: "Emet", + roomStreamStop: "Atura", + roomStreaming: "Emetent", + liveFullscreen: "Pantalla completa", + roomStreamPickTitle: "Tria un vídeo", + roomStreamPickHint: "Vídeos d'aquest dispositiu. El que triïs s'emet des d'on és, sense copiar-lo ni publicar-lo.", + roomStreamGo: "Emet a la sala", + roomStreamNotFound: "Aquest vídeo no és a les teves carpetes.", + roomPublicWarning: "Aquesta sala es retransmet a la web pública: qualsevol pot veure i sentir el que hi passa.", roomRecording: "REC", roomNotificationsOn: "ON", roomNotificationsOff: "OFF", @@ -4332,7 +4353,7 @@ module.exports = { phoneHistoryTitle: "Historial", phoneLatestTitle: "Últimes trucades", phoneCreateButton: "Nova trucada", - phoneLimitsHint: "Fins a 7 habitants per trucada; amb més d'un és una trucada conjunta.", + phoneLimitsHint: "Fins a {n} habitants per trucada; amb més d'un és una trucada conjunta.", phoneErrorPub: "No es pot trucar a un pub.", phoneJointCall: "Trucada conjunta", phoneWith: "Amb", @@ -4477,6 +4498,22 @@ module.exports = { settingsPmVisibilityDesc: "Configura el nivell d'exposició a missatges privats que vols tenir a la xarxa.", settingsPmVisibilityWhole: "Multivers", settingsPmVisibilityMutuals: "Només suport mutu", + mute: "Silenciar", + unmute: "Deixar de silenciar", + relationshipMuted: "Silenciat per tu", + hideContent: "Amagar per a mi", + unhideContent: "Mostrar de nou", + hiddenByYou: "Amagat per tu", + settingsTabContent: "Contingut", + settingsHiddenTitle: "Contingut amagat", + settingsHiddenDesc: "Habitants que has silenciat i contingut que has amagat.", + settingsMutedLabel: "Habitants silenciats", + settingsHiddenLabel: "Contingut amagat", + settingsMutedEmpty: "Ningú silenciat.", + settingsHiddenEmpty: "Res amagat.", + settingsPmVisibilityFollowing: "Només gent que segueixo", + settingsMentionsVisibilityTitle: "Mencions", + settingsMentionsVisibilityDesc: "Tria de qui t'arriben les mencions.", pmMutualNotice: "L'enviament és una barrera d'UX. La recepció és un filtre d'atenció (el xifratge ja està replicat).", inhabitantsPendingFollowsTitle: "Sol·licituds de suport pendents", inhabitantsPendingAccept: "Acceptar", @@ -4583,7 +4620,7 @@ module.exports = { bankRulesEcoTaxFormula: "Taxa ECO(usuari) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", bankRulesEcoTaxNote: "L'impost ECO es dedueix de la RBU bruta quan es calcula l'assignació de l'època. Els ECO deduïts no es redistribueixen en aquesta època — es queden al saldo del pub i alimenten el fons de la pròxima època a través dels límits reserveMin / alpha.", bankRulesKarmaFormula: "karma = log10(1 + actions) + reputation", - bankRulesKarmaNote: "El karma es deriva de les teves accions (publicacions, vots, participació al parlament/tribunals, etc.) amb un factor de decaïment temporal, menys els grams de carboni que genera el teu feed.", + bankRulesKarmaNote: "El karma es deriva de les teves accions (publicacions, vots, participació al parlament/tribunals, etc.) amb un factor de decaïment temporal, menys els grams de carboni que genera el teu feed. El que rep el teu contingut també compta: les opinions, vots, reaccions i reproduccions positives sumen karma, i les opinions o vots negatius el resten quan diversos habitants coincideixen.", bankRulesNetFormula: "net_UBI = floor + max(0, gross_UBI − floor) − ECO Tax − ARCH Tax", bankRulesPoolFormula: "pool = min(pubBal − reserveMin, capPerEpoch, alpha · pubBal)", bankRulesShareFormula: "share = clamp(1 + karma/100, w_min, w_max) / sum(weights)", @@ -4618,7 +4655,7 @@ module.exports = { larpRulesTestEntryText: "Al test WILL cada opció pondera una o més cases; en enviar-lo, el sistema suma les puntuacions i t'admet automàticament a la casa amb més punts.", larpWillTestHint: "Un cop completat, se t'assignarà la casa que més s'ajusti a les teves respostes. Les teves respostes individuals es processen localment i no s'emmagatzemen mai — només es publica al teu feed l'assignació de casa resultant.", larpWillTestTitle: "Test WILL", - settingsLanDesc: "Anuncia periòdicament aquest node a altres instàncies d'Oasis de la mateixa xarxa local. Desactiva-ho per aturar les difusions UDP.", + settingsLanDesc: "Anuncia periòdicament aquest node a altres instàncies d'Oasis de la mateixa xarxa local.", settingsLanEnable: "Activar la difusió LAN", settingsLanTitle: "Difusió LAN", settingsReplicationTitle: "Replicació", diff --git a/src/client/assets/translations/oasis_de.js b/src/client/assets/translations/oasis_de.js index 075db92..7132912 100644 --- a/src/client/assets/translations/oasis_de.js +++ b/src/client/assets/translations/oasis_de.js @@ -187,7 +187,7 @@ module.exports = { peersTorColumn: "TOR", peersTorYes: "Über Tor erreichbar", peersTorNo: "Nicht über Tor erreichbar", - peersOasisId: "Oasis ID", + peersOasisId: "ID", peersOasisVersion: "Version", graphos: "Graphos", graphosDescription: "Interaktive Karte des Netzwerks um dich herum.", @@ -598,6 +598,21 @@ module.exports = { peerVersionUnannounced: "Gibt seine Version nicht bekannt: ein altes Oasis", peersTorMissing: "Dieser PUB erlaubt nur Verbindungen über TOR.", peerConnectionsTitle: "Verbindungen", + peersOnlineHint: "Knoten, die gerade mit dir verbunden sind.", + peersDiscoveredHint: "Knoten mit bekannter Adresse, die gerade nicht verbunden sind.", + peersUnknownHint: "Knoten in deinem sozialen Graphen ohne bekannte Adresse.", + peersConnectionsHint: "Adressen, die dieser Knoten anwählen kann, und der Zustand jeder Verbindung.", + peersTabLan: "LAN", + peersTabHealth: "Zustand", + peersHealthHint: "Wie es jedem bekannten Pub oder Peer geht, im lokalen Netz und darüber hinaus.", + peersHealthReachable: "Erreichbar", + peersHealthFailing: "Fehlschlagend", + peersHealthFailures: "Fehlschläge", + peersFailingSince: "Fehlschlagend seit", + peersHealthForget: "Unerreichbare vergessen", + peersLanHint: "Knoten, die in deinem lokalen Netz gefunden wurden.", + peersLanLastSeen: "Zuletzt gesehen", + peersLanEmpty: "Noch kein Knoten im lokalen Netz gefunden.", online: "Online", offline: "Offline", discovered: 'Entdeckt', @@ -1644,8 +1659,6 @@ module.exports = { dataTermsTitle: "Schlagwörter", dataTermsHint: "Die Begriffe, die in den meisten Inhalten vorkommen.", dataConnections: "Verknüpfte Treffer", - dataTopicTitle: "Alles zu", - dataTopicHint: "Was der Algorithmus mit diesem Thema verbindet", dataNoMatches: "Keine Querdaten vorhanden.", dataNoProfile: "Veröffentliche Inhalte oder schreib deinen Lebenslauf, damit der Algorithmus lernt, was dich interessiert.", pollsDescription: "Modul, um das Netzwerk zu fragen und Antworten zu zählen.", @@ -2201,9 +2214,7 @@ module.exports = { tribeTrendingEngagement: "Beteiligung", tribeTopCategory: "Meistgewählt", tribeTagsEmpty: "Keine Tags gefunden.", - tribeSearchPlaceholder: "Stamminhalt durchsuchen...", tribeSearchEmpty: "Keine Ergebnisse gefunden.", - tribeSearchResults: "Ergebnisse", tribeSearchMinChars: "Mindestens 2 Zeichen eingeben, um zu suchen.", agendaTitle: "Agenda", agendaDescription: "Hier findest du alle dir zugewiesenen Einträge.", @@ -3833,6 +3844,7 @@ module.exports = { roomUpdate: "Aktualisieren", roomDelete: "Löschen", roomClose: "Schließen", + roomReopen: "Wieder öffnen", roomCreateSectionTitle: "Raum erstellen", roomUpdateSectionTitle: "Raum aktualisieren", roomTitlePlaceholder: "Titel des Raums", @@ -3851,6 +3863,15 @@ module.exports = { roomMuted: "stumm", roomRec: "Audio aufnehmen", roomRecStop: "Aufnahme beenden", + roomStreamFile: "Streamen", + roomStreamStop: "Stoppen", + roomStreaming: "Wird gestreamt", + liveFullscreen: "Vollbild", + roomStreamPickTitle: "Video auswählen", + roomStreamPickHint: "Videos auf diesem Gerät. Das gewählte wird von seinem Speicherort aus gestreamt, ohne es zu kopieren oder zu veröffentlichen.", + roomStreamGo: "Im Raum abspielen", + roomStreamNotFound: "Dieses Video ist nicht in deinen Ordnern.", + roomPublicWarning: "Dieser Raum wird im öffentlichen Web übertragen: Jeder kann sehen und hören, was darin passiert.", roomRecording: "REC", roomNotificationsOn: "ON", roomNotificationsOff: "OFF", @@ -4328,7 +4349,7 @@ module.exports = { phoneHistoryTitle: "Verlauf", phoneLatestTitle: "Letzte Anrufe", phoneCreateButton: "Neuer Anruf", - phoneLimitsHint: "Bis zu 7 Bewohner pro Anruf; mit mehr als einem ist es ein gemeinsamer Anruf.", + phoneLimitsHint: "Bis zu {n} Bewohner pro Anruf; mit mehr als einem ist es ein gemeinsamer Anruf.", phoneErrorPub: "Pubs können nicht angerufen werden.", phoneJointCall: "Gemeinsamer Anruf", phoneWith: "Mit", @@ -4473,6 +4494,22 @@ module.exports = { settingsPmVisibilityDesc: "Konfiguriere dein Niveau der Offenlegung für private Nachrichten.", settingsPmVisibilityWhole: "Multiversum", settingsPmVisibilityMutuals: "Nur gegenseitige Unterstützung", + mute: "Stummschalten", + unmute: "Stummschaltung aufheben", + relationshipMuted: "Von dir stummgeschaltet", + hideContent: "Für mich ausblenden", + unhideContent: "Wieder anzeigen", + hiddenByYou: "Von dir ausgeblendet", + settingsTabContent: "Inhalt", + settingsHiddenTitle: "Ausgeblendete Inhalte", + settingsHiddenDesc: "Stummgeschaltete Bewohner und ausgeblendete Inhalte.", + settingsMutedLabel: "Stummgeschaltete Bewohner", + settingsHiddenLabel: "Ausgeblendete Inhalte", + settingsMutedEmpty: "Niemand stummgeschaltet.", + settingsHiddenEmpty: "Nichts ausgeblendet.", + settingsPmVisibilityFollowing: "Nur Leute, denen ich folge", + settingsMentionsVisibilityTitle: "Erwähnungen", + settingsMentionsVisibilityDesc: "Wähle, wessen Erwähnungen dich erreichen.", pmMutualNotice: "Ausgang ist ein UX-Schutz. Eingang ist ein Aufmerksamkeitsfilter.", inhabitantsPendingFollowsTitle: "Ausstehende Unterstützungsanfragen", inhabitantsPendingAccept: "Akzeptieren", @@ -4579,7 +4616,7 @@ module.exports = { bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", bankRulesEcoTaxNote: "Die ECO-Steuer wird vom Brutto-UBI abgezogen, wenn die Zuteilung der Epoche berechnet wird. Die abgezogenen ECO werden in dieser Epoche nicht umverteilt — sie bleiben im Pub-Guthaben und speisen den Pool der nächsten Epoche über die Obergrenzen reserveMin / alpha.", bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", - bankRulesKarmaNote: "Karma ergibt sich aus deinen Aktionen (Beiträge, Stimmen, Beteiligung an Parlament/Gerichten usw.) mit einem zeitlichen Abklingfaktor, abzüglich der von deinem Feed erzeugten Gramm CO2.", + bankRulesKarmaNote: "Karma ergibt sich aus deinen Aktionen (Beiträge, Stimmen, Beteiligung an Parlament/Gerichten usw.) mit einem zeitlichen Abklingfaktor, abzüglich der von deinem Feed erzeugten Gramm CO2. Was deine Inhalte erhalten, zählt ebenfalls: positive Meinungen, Stimmen, Reaktionen und Wiedergaben geben Karma, negative Meinungen oder Stimmen ziehen es ab, sobald mehrere Bewohner übereinstimmen.", bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)", @@ -4613,7 +4650,7 @@ module.exports = { larpRulesTestEntryText: "Im WILL-Test gewichtet jede Option ein oder mehrere Häuser; beim Absenden zählt das System die Punkte und nimmt dich automatisch im Haus mit der höchsten Punktzahl auf.", larpWillTestHint: "Nach Abschluss wirst du dem Haus zugewiesen, das am besten zu deinen Antworten passt. Deine einzelnen Antworten werden lokal verarbeitet und nie gespeichert — nur die resultierende Hauszuweisung wird in deinem Feed veröffentlicht.", larpWillTestTitle: "WILL-Test", - settingsLanDesc: "Diesen Peer periodisch an andere Oasis-Instanzen im selben lokalen Netzwerk ankündigen. Deaktivieren, um UDP-Broadcasts zu stoppen.", + settingsLanDesc: "Diesen Peer periodisch an andere Oasis-Instanzen im selben lokalen Netzwerk ankündigen.", settingsLanEnable: "LAN-Broadcasting aktivieren", settingsLanTitle: "LAN-Broadcasting", settingsReplicationTitle: "Replikation", diff --git a/src/client/assets/translations/oasis_en.js b/src/client/assets/translations/oasis_en.js index 32a6205..6361b35 100644 --- a/src/client/assets/translations/oasis_en.js +++ b/src/client/assets/translations/oasis_en.js @@ -187,7 +187,7 @@ module.exports = { peersTorColumn: "TOR", peersTorYes: "Reachable over Tor", peersTorNo: "Not reachable over Tor", - peersOasisId: "Oasis ID", + peersOasisId: "ID", peersOasisVersion: "Version", graphos: "Graphos", graphosDescription: "Interactive map of the network around you.", @@ -500,7 +500,7 @@ module.exports = { ssbLogStreamDescription: "Configure the message limit for Blockchain streams.", logWindowChip: "Last {n} blockchain streams", settingsLanTitle: "LAN Broadcasting", - settingsLanDesc: "Periodically announce this peer to other Oasis instances on the same local network. Disable to stop UDP broadcasts.", + settingsLanDesc: "Periodically announce this peer to other Oasis instances on the same local network.", settingsReplicationTitle: "Replication", settingsReplicationDesc: "Configure the number of hops your peer follows out from your own feed when replicating content.", settingsReplicationHopsLabel: "Hops", @@ -626,6 +626,21 @@ module.exports = { peerVersionUnannounced: "It does not announce its version: an old Oasis", peersTorMissing: "This PUB only allows connections through TOR.", peerConnectionsTitle: "Connections", + peersOnlineHint: "Nodes connected to you right now.", + peersDiscoveredHint: "Nodes with a known address that are not connected right now.", + peersUnknownHint: "Nodes in your social graph with no known address.", + peersConnectionsHint: "Addresses this node can dial and the state of each connection.", + peersTabLan: "LAN", + peersTabHealth: "Health", + peersHealthHint: "How each known pub or peer is doing, on the local network and beyond.", + peersHealthReachable: "Reachable", + peersHealthFailing: "Failing", + peersHealthFailures: "Failures", + peersFailingSince: "Failing since", + peersHealthForget: "Forget unreachable", + peersLanHint: "Nodes met on your local network.", + peersLanLastSeen: "Last seen", + peersLanEmpty: "No nodes met on the local network yet.", online: "Online", offline: "Offline", discovered: 'Discovered', @@ -1134,7 +1149,7 @@ module.exports = { bankRulesWeightFormula: "w = clamp(1 + karma/100, w_min, w_max)", bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)", bankRulesKarmaTitle: "Karma (user engagement score)", - bankRulesKarmaNote: "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed.", + bankRulesKarmaNote: "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed. What your content receives counts too: positive opinions, votes, reactions and plays add karma, and negative opinions or votes subtract it once several inhabitants agree.", bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", bankRulesEcoTaxTitle: "ECO Tax", bankRulesEcoTaxRate: "Rate", @@ -1765,8 +1780,6 @@ module.exports = { dataTermsTitle: "Keywords", dataTermsHint: "The terms that appear across the most content.", dataConnections: "Linked matches", - dataTopicTitle: "Everything related to", - dataTopicHint: "What the algorithm relates to this topic", dataNoMatches: "No cross-data to show.", dataNoProfile: "Publish content or write your CV so the algorithm can learn what interests you.", pollsDescription: "Module to ask the network and count the answers.", @@ -2461,9 +2474,7 @@ module.exports = { tribeTrendingEngagement: "engagement", tribeTopCategory: "More Voted", tribeTagsEmpty: "No tags found.", - tribeSearchPlaceholder: "Search tribe content...", tribeSearchEmpty: "No results found.", - tribeSearchResults: "Results", tribeSearchMinChars: "Enter at least 2 characters to search.", agendaTitle: "Agenda", agendaDescription: "Here you can find all your assigned items.", @@ -4089,6 +4100,7 @@ module.exports = { roomUpdate: "Update", roomDelete: "Delete", roomClose: "Close", + roomReopen: "Reopen", roomCreateSectionTitle: "Create Room", roomUpdateSectionTitle: "Update Room", roomTitlePlaceholder: "Room title", @@ -4107,6 +4119,15 @@ module.exports = { roomMuted: "muted", roomRec: "Record audio", roomRecStop: "Stop recording", + roomStreamFile: "Stream", + roomStreamStop: "Stop", + roomStreaming: "Streaming", + liveFullscreen: "Full screen", + roomStreamPickTitle: "Choose a video", + roomStreamPickHint: "Videos on this device. The one you choose is streamed from where it is, without copying or publishing it.", + roomStreamGo: "Play in the room", + roomStreamNotFound: "That video is not in your folders.", + roomPublicWarning: "This room is broadcast on the public web: anyone can see and hear what happens in it.", roomRecording: "REC", roomNotificationsOn: "ON", roomNotificationsOff: "OFF", @@ -4649,7 +4670,7 @@ module.exports = { phoneHistoryTitle: "History", phoneLatestTitle: "Latest Calls", phoneCreateButton: "New call", - phoneLimitsHint: "Up to 7 inhabitants per call; more than one makes a joint call.", + phoneLimitsHint: "Up to {n} inhabitants per call; more than one makes a joint call.", phoneErrorPub: "Pubs cannot be called.", phoneJointCall: "Joint call", phoneWith: "With", @@ -4794,6 +4815,22 @@ module.exports = { settingsPmVisibilityDesc: "Configure the level of exposition to private messages that you want to have in the network.", settingsPmVisibilityWhole: "Multiverse", settingsPmVisibilityMutuals: "Only mutual-support", + mute: "Mute", + unmute: "Unmute", + relationshipMuted: "Muted by you", + hideContent: "Hide for me", + unhideContent: "Show again", + hiddenByYou: "Hidden by you", + settingsTabContent: "Content", + settingsHiddenTitle: "Hidden Content", + settingsHiddenDesc: "Inhabitants you muted and content you hid.", + settingsMutedLabel: "Muted inhabitants", + settingsHiddenLabel: "Hidden content", + settingsMutedEmpty: "Nobody muted.", + settingsHiddenEmpty: "Nothing hidden.", + settingsPmVisibilityFollowing: "Only people I follow", + settingsMentionsVisibilityTitle: "Mentions", + settingsMentionsVisibilityDesc: "Choose whose mentions reach you.", pmMutualNotice: "Outbound is a UX guardrail. Inbound is an attention filter (ciphertext is still replicated).", inhabitantsPendingFollowsTitle: "Pending follow requests", inhabitantsPendingAccept: "Accept", diff --git a/src/client/assets/translations/oasis_es.js b/src/client/assets/translations/oasis_es.js index 1db3b16..cb106b8 100644 --- a/src/client/assets/translations/oasis_es.js +++ b/src/client/assets/translations/oasis_es.js @@ -187,7 +187,7 @@ module.exports = { peersTorColumn: "TOR", peersTorYes: "Accesible por Tor", peersTorNo: "No accesible por Tor", - peersOasisId: "Oasis ID", + peersOasisId: "ID", peersOasisVersion: "Versión", graphos: "Graphos", graphosDescription: "Mapa interactivo de la red alrededor de ti.", @@ -594,6 +594,21 @@ module.exports = { peerVersionUnannounced: "No anuncia su versión: un Oasis antiguo", peersTorMissing: "Este PUB solamente permite conexiones desde TOR.", peerConnectionsTitle: "Conexiones", + peersOnlineHint: "Nodos conectados contigo ahora mismo.", + peersDiscoveredHint: "Nodos con dirección conocida que no están conectados ahora.", + peersUnknownHint: "Nodos de tu grafo social sin dirección conocida.", + peersConnectionsHint: "Direcciones a las que puede conectar este nodo y el estado de cada conexión.", + peersTabLan: "LAN", + peersTabHealth: "Salud", + peersHealthHint: "Cómo está cada pub o nodo conocido, en la red local y fuera de ella.", + peersHealthReachable: "Alcanzable", + peersHealthFailing: "Fallando", + peersHealthFailures: "Fallos", + peersFailingSince: "Falla desde", + peersHealthForget: "Olvidar inalcanzables", + peersLanHint: "Nodos encontrados en tu red local.", + peersLanLastSeen: "Visto por última vez", + peersLanEmpty: "Aún no se ha encontrado ningún nodo en la red local.", online: "Conectado", offline: "Desconectado", discovered: 'Descubiertos', @@ -1639,8 +1654,6 @@ module.exports = { dataTermsTitle: "Palabras clave", dataTermsHint: "Los términos que aparecen en más contenidos.", dataConnections: "Coincidencias enlazadas", - dataTopicTitle: "Todo lo relacionado con", - dataTopicHint: "Lo que el algoritmo relaciona con este tema", dataNoMatches: "No hay datos cruzados que mostrar.", dataNoProfile: "Publica contenido o escribe tu CV para que el algoritmo aprenda qué te interesa.", pollsDescription: "Módulo para preguntar a la red y contar las respuestas.", @@ -2194,9 +2207,7 @@ module.exports = { tribeTrendingEngagement: "interacción", tribeTopCategory: "Más votado", tribeTagsEmpty: "No se encontraron etiquetas.", - tribeSearchPlaceholder: "Buscar contenido de la tribu...", tribeSearchEmpty: "Sin resultados.", - tribeSearchResults: "Resultados", tribeSearchMinChars: "Introduce al menos 2 caracteres para buscar.", agendaTitle: "Agenda", agendaDescription: "Aquí puedes encontrar todos tus elementos asignados.", @@ -3833,6 +3844,7 @@ module.exports = { roomUpdate: "Actualizar", roomDelete: "Eliminar", roomClose: "Cerrar", + roomReopen: "Reabrir", roomCreateSectionTitle: "Crear sala", roomUpdateSectionTitle: "Actualizar sala", roomTitlePlaceholder: "Título de la sala", @@ -3851,6 +3863,15 @@ module.exports = { roomMuted: "silenciado", roomRec: "Grabar audio", roomRecStop: "Parar grabación", + roomStreamFile: "Emitir", + roomStreamStop: "Parar", + roomStreaming: "Emitiendo", + liveFullscreen: "Pantalla completa", + roomStreamPickTitle: "Elige un vídeo", + roomStreamPickHint: "Vídeos de este dispositivo. El que elijas se emite desde donde está, sin copiarlo ni publicarlo.", + roomStreamGo: "Emitir en la sala", + roomStreamNotFound: "Ese vídeo no está en tus carpetas.", + roomPublicWarning: "Esta sala se retransmite en la web pública: cualquiera puede ver y oír lo que pasa en ella.", roomRecording: "REC", roomNotificationsOn: "ON", roomNotificationsOff: "OFF", @@ -4332,7 +4353,7 @@ module.exports = { phoneHistoryTitle: "Historial", phoneLatestTitle: "Últimas llamadas", phoneCreateButton: "Nueva llamada", - phoneLimitsHint: "Hasta 7 habitantes por llamada; con más de uno es una llamada conjunta.", + phoneLimitsHint: "Hasta {n} habitantes por llamada; con más de uno es una llamada conjunta.", phoneErrorPub: "No se puede llamar a un pub.", phoneJointCall: "Llamada conjunta", phoneWith: "Con", @@ -4477,6 +4498,22 @@ module.exports = { settingsPmVisibilityDesc: "Configura el nivel de exposición a mensajes privados que deseas tener en la red.", settingsPmVisibilityWhole: "Multiverso", settingsPmVisibilityMutuals: "Solo soporte mutuo", + mute: "Silenciar", + unmute: "Dejar de silenciar", + relationshipMuted: "Silenciado por ti", + hideContent: "Ocultar para mí", + unhideContent: "Mostrar de nuevo", + hiddenByYou: "Ocultado por ti", + settingsTabContent: "Contenido", + settingsHiddenTitle: "Contenido oculto", + settingsHiddenDesc: "Habitantes que has silenciado y contenido que has ocultado.", + settingsMutedLabel: "Habitantes silenciados", + settingsHiddenLabel: "Contenido oculto", + settingsMutedEmpty: "Nadie silenciado.", + settingsHiddenEmpty: "Nada oculto.", + settingsPmVisibilityFollowing: "Solo gente a la que sigo", + settingsMentionsVisibilityTitle: "Menciones", + settingsMentionsVisibilityDesc: "Elige de quién te llegan las menciones.", pmMutualNotice: "El envío es una barrera de UX. La recepción es un filtro de atención (el cifrado ya está replicado).", inhabitantsPendingFollowsTitle: "Solicitudes de apoyo pendientes", inhabitantsPendingAccept: "Aceptar", @@ -4583,7 +4620,7 @@ module.exports = { bankRulesEcoTaxFormula: "ECO Tax(habitante) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", bankRulesEcoTaxNote: "El impuesto ECO se deduce de la RBU bruta cuando se calcula la asignación de la época. Los ECO deducidos no se redistribuyen en esta época — permanecen en el saldo del pub y alimentan el fondo de la próxima época a través de los topes reserveMin / alpha.", bankRulesKarmaFormula: "karma = log10(1 + actions) + reputation", - bankRulesKarmaNote: "El karma se deriva de tus acciones (publicaciones, votos, participación en el parlamento/tribunales, etc.) con un factor de decaimiento temporal, menos los gramos de carbono que genera tu feed.", + bankRulesKarmaNote: "El karma se deriva de tus acciones (publicaciones, votos, participación en el parlamento/tribunales, etc.) con un factor de decaimiento temporal, menos los gramos de carbono que genera tu feed. Lo que recibe tu contenido también cuenta: las opiniones, votos, reacciones y reproducciones positivas suman karma, y las opiniones o votos negativos lo restan cuando varios habitantes coinciden.", bankRulesNetFormula: "net_UBI = floor + max(0, gross_UBI − floor) − ECO Tax − ARCH Tax", bankRulesPoolFormula: "pool = min(pubBal − reserveMin, capPerEpoch, alpha · pubBal)", bankRulesShareFormula: "share = clamp(1 + karma/100, w_min, w_max) / sum(weights)", @@ -4618,7 +4655,7 @@ module.exports = { larpRulesTestEntryText: "En el test WILL cada opción pondera una o más casas; al enviarlo, el sistema suma las puntuaciones y te admite automáticamente en la casa con más puntos.", larpWillTestHint: "Una vez completado, se te asignará la casa que más se ajuste a tus respuestas. Tus respuestas individuales se procesan localmente y nunca se almacenan — solo se publica en tu feed la asignación de casa resultante.", larpWillTestTitle: "Test WILL", - settingsLanDesc: "Anuncia periódicamente este peer a otras instancias de Oasis en la misma red local. Desactivar para detener las difusiones UDP.", + settingsLanDesc: "Anuncia periódicamente este peer a otras instancias de Oasis en la misma red local.", settingsLanEnable: "Activar difusión LAN", settingsLanTitle: "Difusión LAN", settingsReplicationTitle: "Replicación", diff --git a/src/client/assets/translations/oasis_eu.js b/src/client/assets/translations/oasis_eu.js index 31e64c4..c5fc2a2 100644 --- a/src/client/assets/translations/oasis_eu.js +++ b/src/client/assets/translations/oasis_eu.js @@ -187,7 +187,7 @@ module.exports = { peersTorColumn: "TOR", peersTorYes: "Tor bidez irisgarria", peersTorNo: "Ez da Tor bidez irisgarria", - peersOasisId: "Oasis ID", + peersOasisId: "ID", peersOasisVersion: "Bertsioa", graphos: "Graphos", graphosDescription: "Sarearen mapa interaktiboa zure inguruan.", @@ -596,6 +596,21 @@ module.exports = { peerVersionUnannounced: "Ez du bere bertsioa iragartzen: Oasis zahar bat", peersTorMissing: "PUB honek TOR bidezko konexioak soilik onartzen ditu.", peerConnectionsTitle: "Konexioak", + peersOnlineHint: "Orain zurekin konektatuta dauden nodoak.", + peersDiscoveredHint: "Helbide ezaguna duten baina orain konektatuta ez dauden nodoak.", + peersUnknownHint: "Zure grafo sozialeko nodoak, helbide ezagunik gabe.", + peersConnectionsHint: "Nodo honek dei ditzakeen helbideak eta konexio bakoitzaren egoera.", + peersTabLan: "LAN", + peersTabHealth: "Osasuna", + peersHealthHint: "Nola dagoen pub edo nodo ezagun bakoitza, sare lokalean eta kanpoan.", + peersHealthReachable: "Eskuragarri", + peersHealthFailing: "Huts egiten", + peersHealthFailures: "Hutsak", + peersFailingSince: "Huts egiten noiztik", + peersHealthForget: "Ahaztu eskuraezinak", + peersLanHint: "Zure sare lokalean aurkitutako nodoak.", + peersLanLastSeen: "Azkenekoz ikusia", + peersLanEmpty: "Oraindik ez da nodorik aurkitu sare lokalean.", online: "Linean", offline: "Lineaz kanpo", discovered: 'Aurkituak', @@ -1680,8 +1695,6 @@ module.exports = { dataTermsTitle: "Gako-hitzak", dataTermsHint: "Eduki gehienetan agertzen diren terminoak.", dataConnections: "Lotutako bat-etortzeak", - dataTopicTitle: "Honekin lotutako guztia:", - dataTopicHint: "Algoritmoak gai honekin lotzen duena", dataNoMatches: "Ez dago datu gurutzaturik erakusteko.", dataNoProfile: "Argitaratu edukia edo idatzi zure CVa, algoritmoak zure interesak ikas ditzan.", pollsDescription: "Sareari galdetzeko eta erantzunak zenbatzeko modulua.", @@ -2198,9 +2211,7 @@ module.exports = { tribeTrendingEngagement: "parte-hartzea", tribeTopCategory: "Gehien bozkatua", tribeTagsEmpty: "Ez da etiketarik aurkitu.", - tribeSearchPlaceholder: "Bilatu tribuaren edukian...", tribeSearchEmpty: "Emaitzarik ez.", - tribeSearchResults: "Emaitzak", tribeSearchMinChars: "Idatzi gutxienez 2 karaktere bilatzeko.", agendaTitle: "Agenda", agendaDescription: "Eskeituta dauzkazun elementu guztiak aurki ditzakezu hemen.", @@ -3833,6 +3844,7 @@ module.exports = { roomUpdate: "Eguneratu", roomDelete: "Ezabatu", roomClose: "Itxi", + roomReopen: "Berriro ireki", roomCreateSectionTitle: "Sortu gela", roomUpdateSectionTitle: "Eguneratu gela", roomTitlePlaceholder: "Gelaren izenburua", @@ -3851,6 +3863,15 @@ module.exports = { roomMuted: "isilduta", roomRec: "Audioa grabatu", roomRecStop: "Grabazioa gelditu", + roomStreamFile: "Igorri", + roomStreamStop: "Gelditu", + roomStreaming: "Igortzen", + liveFullscreen: "Pantaila osoa", + roomStreamPickTitle: "Aukeratu bideo bat", + roomStreamPickHint: "Gailu honetako bideoak. Aukeratzen duzuna dagoen tokitik igortzen da, kopiatu edo argitaratu gabe.", + roomStreamGo: "Gelan igorri", + roomStreamNotFound: "Bideo hori ez dago zure karpetetan.", + roomPublicWarning: "Gela hau web publikoan igortzen da: edonork ikus eta entzun dezake bertan gertatzen dena.", roomRecording: "REC", roomNotificationsOn: "ON", roomNotificationsOff: "OFF", @@ -4331,7 +4352,7 @@ module.exports = { phoneHistoryTitle: "Historia", phoneLatestTitle: "Azken deiak", phoneCreateButton: "Dei berria", - phoneLimitsHint: "Dei bakoitzeko 7 bizilagun gehienez; bat baino gehiagorekin dei bateratua da.", + phoneLimitsHint: "Dei bakoitzeko {n} bizilagun gehienez; bat baino gehiagorekin dei bateratua da.", phoneErrorPub: "Ezin zaio pub bati deitu.", phoneJointCall: "Dei bateratua", phoneWith: "Norekin", @@ -4476,6 +4497,22 @@ module.exports = { settingsPmVisibilityDesc: "Konfiguratu sarean izan nahi duzun mezu pribatuen esposizio maila.", settingsPmVisibilityWhole: "Multibertsoa", settingsPmVisibilityMutuals: "Elkar laguntza soilik", + mute: "Isilarazi", + unmute: "Isilarazteari utzi", + relationshipMuted: "Zuk isilarazia", + hideContent: "Niretzat ezkutatu", + unhideContent: "Berriro erakutsi", + hiddenByYou: "Zuk ezkutatua", + settingsTabContent: "Edukia", + settingsHiddenTitle: "Ezkutatutako edukia", + settingsHiddenDesc: "Isilarazi dituzun biztanleak eta ezkutatu duzun edukia.", + settingsMutedLabel: "Isilarazitako biztanleak", + settingsHiddenLabel: "Ezkutatutako edukia", + settingsMutedEmpty: "Inor ez isilarazita.", + settingsHiddenEmpty: "Ezer ez ezkutatuta.", + settingsPmVisibilityFollowing: "Jarraitzen dudan jendea bakarrik", + settingsMentionsVisibilityTitle: "Aipamenak", + settingsMentionsVisibilityDesc: "Aukeratu noren aipamenak iristen zaizkizun.", pmMutualNotice: "Bidalketa UX babes bat da. Jasotzea arreta-iragazkia.", inhabitantsPendingFollowsTitle: "Onespen zain dauden eskaerak", inhabitantsPendingAccept: "Onartu", @@ -4608,7 +4645,7 @@ module.exports = { bankRulesFloor: "Erabiltzaile bakoitzeko gutxienekoa (gordina)", bankRulesGraceDays: "Eskaeraren grazia-epea", bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", - bankRulesKarmaNote: "Karma zure ekintzetatik ateratzen da (argitalpenak, botoak, parlamentuan/epaitegietan parte hartzea, etab.), denborarekin ahultzen den faktore batekin, zure jarioak sortutako karbono-gramoak kenduta.", + bankRulesKarmaNote: "Karma zure ekintzetatik ateratzen da (argitalpenak, botoak, parlamentuan/epaitegietan parte hartzea, etab.), denborarekin ahultzen den faktore batekin, zure jarioak sortutako karbono-gramoak kenduta. Zure edukiak jasotzen duenak ere balio du: iritzi, boto, erreakzio eta erreprodukzio positiboek karma gehitzen dute, eta iritzi edo boto negatiboek kentzen dute hainbat biztanle ados daudenean.", bankRulesKarmaTitle: "Karma (erabiltzailearen konpromiso-puntuazioa)", bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", @@ -4697,7 +4734,7 @@ module.exports = { larpRulesTestEntryText: "WILL testean aukera bakoitzak etxe bat edo gehiago haztatzen ditu; bidaltzean, sistemak puntuazioak batzen ditu eta puntu gehien dituen etxean onartzen zaitu automatikoki.", larpWillTestHint: "Behin osatutakoan, zure erantzunekin ondoen bat datorren etxera esleituko zaitu. Zure erantzun indibidualak lokalki prozesatzen dira eta inoiz ez dira gordetzen — soilik etxe-esleipenaren emaitza argitaratzen da zure jarioan.", larpWillTestTitle: "WILL proba", - settingsLanDesc: "Aldian behin iragarri pareko hau sare lokal bereko beste Oasis instantziei. Desgaitu UDP igorpenak gelditzeko.", + settingsLanDesc: "Aldian behin iragarri pareko hau sare lokal bereko beste Oasis instantziei.", settingsLanEnable: "Gaitu LAN igorpena", settingsLanTitle: "LAN igorpena", settingsReplicationTitle: "Erreplikazioa", diff --git a/src/client/assets/translations/oasis_fr.js b/src/client/assets/translations/oasis_fr.js index dfac9d8..00cfd95 100644 --- a/src/client/assets/translations/oasis_fr.js +++ b/src/client/assets/translations/oasis_fr.js @@ -187,7 +187,7 @@ module.exports = { peersTorColumn: "TOR", peersTorYes: "Joignable via Tor", peersTorNo: "Non joignable via Tor", - peersOasisId: "Oasis ID", + peersOasisId: "ID", peersOasisVersion: "Version", graphos: "Graphos", graphosDescription: "Carte interactive du réseau autour de toi.", @@ -594,6 +594,21 @@ module.exports = { peerVersionUnannounced: "N'annonce pas sa version : un ancien Oasis", peersTorMissing: "Ce PUB n'accepte que des connexions via TOR.", peerConnectionsTitle: "Connexions", + peersOnlineHint: "Nœuds connectés à vous en ce moment.", + peersDiscoveredHint: "Nœuds avec une adresse connue, non connectés pour l'instant.", + peersUnknownHint: "Nœuds de votre graphe social sans adresse connue.", + peersConnectionsHint: "Adresses que ce nœud peut joindre et l'état de chaque connexion.", + peersTabLan: "LAN", + peersTabHealth: "Santé", + peersHealthHint: "L'état de chaque pub ou pair connu, sur le réseau local et au-delà.", + peersHealthReachable: "Joignable", + peersHealthFailing: "En échec", + peersHealthFailures: "Échecs", + peersFailingSince: "En échec depuis", + peersHealthForget: "Oublier les injoignables", + peersLanHint: "Nœuds rencontrés sur votre réseau local.", + peersLanLastSeen: "Vu pour la dernière fois", + peersLanEmpty: "Aucun nœud rencontré sur le réseau local pour l'instant.", online: "En ligne", offline: "Hors ligne", discovered: 'Découverts', @@ -1638,8 +1653,6 @@ module.exports = { dataTermsTitle: "Mots-clés", dataTermsHint: "Les termes présents dans le plus de contenus.", dataConnections: "Correspondances liées", - dataTopicTitle: "Tout ce qui touche à", - dataTopicHint: "Ce que l'algorithme relie à ce sujet", dataNoMatches: "Aucune donnée croisée à afficher.", dataNoProfile: "Publiez du contenu ou rédigez votre CV pour que l'algorithme apprenne ce qui vous intéresse.", pollsDescription: "Module pour interroger le réseau et compter les réponses.", @@ -2187,9 +2200,7 @@ module.exports = { tribeTrendingEngagement: "engagement", tribeTopCategory: "Plus voté", tribeTagsEmpty: "Aucun tag trouvé.", - tribeSearchPlaceholder: "Rechercher dans la tribu...", tribeSearchEmpty: "Aucun résultat.", - tribeSearchResults: "Résultats", tribeSearchMinChars: "Entrez au moins 2 caractères pour rechercher.", agendaTitle: "Agenda", agendaDescription: "Ici vous pouvez trouver tous vos éléments assignés.", @@ -3825,6 +3836,7 @@ module.exports = { roomUpdate: "Modifier", roomDelete: "Supprimer", roomClose: "Fermer", + roomReopen: "Rouvrir", roomCreateSectionTitle: "Créer un salon", roomUpdateSectionTitle: "Modifier le salon", roomTitlePlaceholder: "Titre du salon", @@ -3843,6 +3855,15 @@ module.exports = { roomMuted: "micro coupé", roomRec: "Enregistrer l'audio", roomRecStop: "Arrêter l'enregistrement", + roomStreamFile: "Diffuser", + roomStreamStop: "Arrêter", + roomStreaming: "En diffusion", + liveFullscreen: "Plein écran", + roomStreamPickTitle: "Choisir une vidéo", + roomStreamPickHint: "Vidéos de cet appareil. Celle que vous choisissez est diffusée depuis son emplacement, sans être copiée ni publiée.", + roomStreamGo: "Diffuser dans le salon", + roomStreamNotFound: "Cette vidéo n'est pas dans vos dossiers.", + roomPublicWarning: "Ce salon est diffusé sur le web public : tout le monde peut voir et entendre ce qui s'y passe.", roomRecording: "REC", roomNotificationsOn: "ON", roomNotificationsOff: "OFF", @@ -4323,7 +4344,7 @@ module.exports = { phoneHistoryTitle: "Historique", phoneLatestTitle: "Derniers appels", phoneCreateButton: "Nouvel appel", - phoneLimitsHint: "Jusqu'à 7 habitants par appel ; à plus d'un, c'est un appel conjoint.", + phoneLimitsHint: "Jusqu'à {n} habitants par appel ; à plus d'un, c'est un appel conjoint.", phoneErrorPub: "Impossible d'appeler un pub.", phoneJointCall: "Appel conjoint", phoneWith: "Avec", @@ -4468,6 +4489,22 @@ module.exports = { settingsPmVisibilityDesc: "Configurez votre niveau d'exposition aux messages privés.", settingsPmVisibilityWhole: "Multivers", settingsPmVisibilityMutuals: "Soutien mutuel uniquement", + mute: "Mettre en sourdine", + unmute: "Réactiver", + relationshipMuted: "En sourdine pour vous", + hideContent: "Masquer pour moi", + unhideContent: "Afficher à nouveau", + hiddenByYou: "Masqué par vous", + settingsTabContent: "Contenu", + settingsHiddenTitle: "Contenu masqué", + settingsHiddenDesc: "Habitants mis en sourdine et contenus masqués.", + settingsMutedLabel: "Habitants en sourdine", + settingsHiddenLabel: "Contenu masqué", + settingsMutedEmpty: "Personne en sourdine.", + settingsHiddenEmpty: "Rien de masqué.", + settingsPmVisibilityFollowing: "Seulement les personnes que je suis", + settingsMentionsVisibilityTitle: "Mentions", + settingsMentionsVisibilityDesc: "Choisissez de qui vous recevez des mentions.", pmMutualNotice: "L'envoi est un garde-fou UX. La réception est un filtre d'attention.", inhabitantsPendingFollowsTitle: "Demandes de soutien en attente", inhabitantsPendingAccept: "Accepter", @@ -4574,7 +4611,7 @@ module.exports = { bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", bankRulesEcoTaxNote: "La taxe ECO est déduite de la RBU brute lors du calcul de l'allocation de l'époque. Les ECO déduits ne sont pas redistribués pendant cette époque — ils restent dans le solde du pub et alimentent le fonds de l'époque suivante via les plafonds reserveMin / alpha.", bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", - bankRulesKarmaNote: "Le karma est calculé à partir de vos actions (publications, votes, participation au parlement/aux tribunaux, etc.) avec un facteur de décroissance temporelle, moins les grammes de carbone générés par votre fil.", + bankRulesKarmaNote: "Le karma est calculé à partir de vos actions (publications, votes, participation au parlement/aux tribunaux, etc.) avec un facteur de décroissance temporelle, moins les grammes de carbone générés par votre fil. Ce que reçoit votre contenu compte aussi : les avis, votes, réactions et écoutes positifs ajoutent du karma, et les avis ou votes négatifs en retirent dès que plusieurs habitants sont d'accord.", bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)", @@ -4617,7 +4654,7 @@ module.exports = { larpRulesTestEntryText: "Dans le test WILL, chaque option pondère une ou plusieurs maisons ; à l'envoi, le système additionne les scores et vous admet automatiquement dans la maison la mieux notée.", larpWillTestHint: "Une fois terminé, on t'assignera la maison qui correspond le mieux à tes réponses. Tes réponses individuelles sont traitées localement et ne sont jamais stockées — seule l'attribution de maison résultante est publiée dans ton flux.", larpWillTestTitle: "Test WILL", - settingsLanDesc: "Annoncer périodiquement ce pair aux autres instances d'Oasis sur le même réseau local. Désactiver pour arrêter les diffusions UDP.", + settingsLanDesc: "Annoncer périodiquement ce pair aux autres instances d'Oasis sur le même réseau local.", settingsLanEnable: "Activer la diffusion LAN", settingsLanTitle: "Diffusion LAN", settingsReplicationTitle: "Réplication", diff --git a/src/client/assets/translations/oasis_gl.js b/src/client/assets/translations/oasis_gl.js index 76d39db..1b55db8 100644 --- a/src/client/assets/translations/oasis_gl.js +++ b/src/client/assets/translations/oasis_gl.js @@ -187,7 +187,7 @@ module.exports = { peersTorColumn: "TOR", peersTorYes: "Accesible por Tor", peersTorNo: "Non accesible por Tor", - peersOasisId: "Oasis ID", + peersOasisId: "ID", peersOasisVersion: "Versión", graphos: "Graphos", graphosDescription: "Mapa interactivo da rede ao teu redor.", @@ -594,6 +594,21 @@ module.exports = { peerVersionUnannounced: "Non anuncia a súa versión: un Oasis antigo", peersTorMissing: "Este PUB só permite conexións dende TOR.", peerConnectionsTitle: "Conexións", + peersOnlineHint: "Nodos conectados contigo agora mesmo.", + peersDiscoveredHint: "Nodos con enderezo coñecido que non están conectados agora.", + peersUnknownHint: "Nodos do teu grafo social sen enderezo coñecido.", + peersConnectionsHint: "Enderezos aos que pode conectar este nodo e o estado de cada conexión.", + peersTabLan: "LAN", + peersTabHealth: "Saúde", + peersHealthHint: "Como está cada pub ou nodo coñecido, na rede local e fóra dela.", + peersHealthReachable: "Alcanzable", + peersHealthFailing: "Fallando", + peersHealthFailures: "Fallos", + peersFailingSince: "Falla desde", + peersHealthForget: "Esquecer inalcanzables", + peersLanHint: "Nodos atopados na túa rede local.", + peersLanLastSeen: "Visto por última vez", + peersLanEmpty: "Aínda non se atopou ningún nodo na rede local.", online: "Conectado", offline: "Desconectado", discovered: "Descubertos", @@ -1639,8 +1654,6 @@ module.exports = { dataTermsTitle: "Palabras clave", dataTermsHint: "Os termos que aparecen en máis contidos.", dataConnections: "Coincidencias ligadas", - dataTopicTitle: "Todo o relacionado con", - dataTopicHint: "O que o algoritmo relaciona con este tema", dataNoMatches: "Non hai datos cruzados que amosar.", dataNoProfile: "Publica contido ou escribe o teu CV para que o algoritmo aprenda que che interesa.", pollsDescription: "Módulo para preguntarlle á rede e contar as respostas.", @@ -2194,9 +2207,7 @@ module.exports = { tribeTrendingEngagement: "interacción", tribeTopCategory: "Máis votado", tribeTagsEmpty: "Non se atoparon etiquetas.", - tribeSearchPlaceholder: "Buscar contido da tribo...", tribeSearchEmpty: "Sen resultados.", - tribeSearchResults: "Resultados", tribeSearchMinChars: "Introduce polo menos 2 caracteres para buscar.", agendaTitle: "Axenda", agendaDescription: "Aquí podes atopar todos os teus elementos asignados.", @@ -3833,6 +3844,7 @@ module.exports = { roomUpdate: "Actualizar", roomDelete: "Eliminar", roomClose: "Pechar", + roomReopen: "Reabrir", roomCreateSectionTitle: "Crear sala", roomUpdateSectionTitle: "Actualizar sala", roomTitlePlaceholder: "Título da sala", @@ -3851,6 +3863,15 @@ module.exports = { roomMuted: "silenciado", roomRec: "Gravar audio", roomRecStop: "Parar a gravación", + roomStreamFile: "Emitir", + roomStreamStop: "Parar", + roomStreaming: "Emitindo", + liveFullscreen: "Pantalla completa", + roomStreamPickTitle: "Escolle un vídeo", + roomStreamPickHint: "Vídeos deste dispositivo. O que escollas emítese desde onde está, sen copialo nin publicalo.", + roomStreamGo: "Emitir na sala", + roomStreamNotFound: "Ese vídeo non está nos teus cartafoles.", + roomPublicWarning: "Esta sala retransmítese na web pública: calquera pode ver e oír o que pasa nela.", roomRecording: "REC", roomNotificationsOn: "ON", roomNotificationsOff: "OFF", @@ -4332,7 +4353,7 @@ module.exports = { phoneHistoryTitle: "Historial", phoneLatestTitle: "Últimas chamadas", phoneCreateButton: "Nova chamada", - phoneLimitsHint: "Ata 7 habitantes por chamada; con máis dun é unha chamada conxunta.", + phoneLimitsHint: "Ata {n} habitantes por chamada; con máis dun é unha chamada conxunta.", phoneErrorPub: "Non se pode chamar a un pub.", phoneJointCall: "Chamada conxunta", phoneWith: "Con", @@ -4477,6 +4498,22 @@ module.exports = { settingsPmVisibilityDesc: "Configura o nivel de exposición a mensaxes privadas que queres ter na rede.", settingsPmVisibilityWhole: "Multiverso", settingsPmVisibilityMutuals: "Só apoio mutuo", + mute: "Silenciar", + unmute: "Deixar de silenciar", + relationshipMuted: "Silenciado por ti", + hideContent: "Agochar para min", + unhideContent: "Mostrar de novo", + hiddenByYou: "Agochado por ti", + settingsTabContent: "Contido", + settingsHiddenTitle: "Contido agochado", + settingsHiddenDesc: "Habitantes que silenciaches e contido que agochaches.", + settingsMutedLabel: "Habitantes silenciados", + settingsHiddenLabel: "Contido agochado", + settingsMutedEmpty: "Ninguén silenciado.", + settingsHiddenEmpty: "Nada agochado.", + settingsPmVisibilityFollowing: "Só xente á que sigo", + settingsMentionsVisibilityTitle: "Mencións", + settingsMentionsVisibilityDesc: "Escolle de quen che chegan as mencións.", pmMutualNotice: "O envío é unha barreira de UX. A recepción é un filtro de atención (o cifrado xa está replicado).", inhabitantsPendingFollowsTitle: "Solicitudes de apoio pendentes", inhabitantsPendingAccept: "Aceptar", @@ -4583,7 +4620,7 @@ module.exports = { bankRulesEcoTaxFormula: "Taxa ECO(usuario) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", bankRulesEcoTaxNote: "O imposto ECO dedúcese da RBU bruta cando se calcula a asignación da época. Os ECO deducidos non se redistribúen nesta época — quedan no saldo do pub e alimentan o fondo da próxima época a través dos límites reserveMin / alpha.", bankRulesKarmaFormula: "karma = log10(1 + actions) + reputation", - bankRulesKarmaNote: "O karma derívase das túas accións (publicacións, votos, participación no parlamento/tribunais, etc.) cun factor de decaemento temporal, menos os gramos de carbono que xera o teu feed.", + bankRulesKarmaNote: "O karma derívase das túas accións (publicacións, votos, participación no parlamento/tribunais, etc.) cun factor de decaemento temporal, menos os gramos de carbono que xera o teu feed. O que recibe o teu contido tamén conta: as opinións, votos, reaccións e reproducións positivas suman karma, e as opinións ou votos negativos réstano cando varios habitantes coinciden.", bankRulesNetFormula: "net_UBI = floor + max(0, gross_UBI − floor) − ECO Tax − ARCH Tax", bankRulesPoolFormula: "pool = min(pubBal − reserveMin, capPerEpoch, alpha · pubBal)", bankRulesShareFormula: "share = clamp(1 + karma/100, w_min, w_max) / sum(weights)", @@ -4618,7 +4655,7 @@ module.exports = { larpRulesTestEntryText: "No test WILL cada opción pondera unha ou máis casas; ao envialo, o sistema suma as puntuacións e admíteche automaticamente na casa con máis puntos.", larpWillTestHint: "Unha vez completado, asignaráseche a casa que máis se axuste ás túas respostas. As túas respostas individuais procésanse localmente e nunca se almacenan — só se publica no teu feed a asignación de casa resultante.", larpWillTestTitle: "Test WILL", - settingsLanDesc: "Anuncia periodicamente este nodo a outras instancias de Oasis na mesma rede local. Desactívao para deter as difusións UDP.", + settingsLanDesc: "Anuncia periodicamente este nodo a outras instancias de Oasis na mesma rede local.", settingsLanEnable: "Activar a difusión LAN", settingsLanTitle: "Difusión LAN", settingsReplicationTitle: "Replicación", diff --git a/src/client/assets/translations/oasis_hi.js b/src/client/assets/translations/oasis_hi.js index 91474e7..67dbe0e 100644 --- a/src/client/assets/translations/oasis_hi.js +++ b/src/client/assets/translations/oasis_hi.js @@ -187,7 +187,7 @@ module.exports = { peersTorColumn: "TOR", peersTorYes: "Tor से पहुंचा जा सकता है", peersTorNo: "Tor से नहीं पहुंचा जा सकता", - peersOasisId: "Oasis ID", + peersOasisId: "ID", peersOasisVersion: "संस्करण", graphos: "ग्राफोस", graphosDescription: "आपके आसपास के नेटवर्क का इंटरैक्टिव मानचित्र।", @@ -602,6 +602,21 @@ module.exports = { peerVersionUnannounced: "अपना संस्करण घोषित नहीं करता: पुराना Oasis", peersTorMissing: "यह PUB केवल TOR से कनेक्शन की अनुमति देता है।", peerConnectionsTitle: "कनेक्शन", + peersOnlineHint: "इस समय आपसे जुड़े नोड।", + peersDiscoveredHint: "ज्ञात पते वाले नोड जो अभी जुड़े नहीं हैं।", + peersUnknownHint: "आपके सामाजिक ग्राफ़ के नोड जिनका पता ज्ञात नहीं है।", + peersConnectionsHint: "वे पते जिनसे यह नोड जुड़ सकता है और हर कनेक्शन की स्थिति।", + peersTabLan: "LAN", + peersTabHealth: "स्वास्थ्य", + peersHealthHint: "हर ज्ञात pub या नोड की स्थिति, स्थानीय नेटवर्क में और उसके बाहर।", + peersHealthReachable: "पहुँच योग्य", + peersHealthFailing: "विफल हो रहा", + peersHealthFailures: "विफलताएँ", + peersFailingSince: "कब से विफल", + peersHealthForget: "अप्राप्य को भूलें", + peersLanHint: "आपके स्थानीय नेटवर्क में मिले नोड।", + peersLanLastSeen: "अंतिम बार देखा गया", + peersLanEmpty: "स्थानीय नेटवर्क में अभी कोई नोड नहीं मिला।", online: "ऑनलाइन", offline: "ऑफ़लाइन", discovered: 'खोजे गए', @@ -1650,8 +1665,6 @@ module.exports = { dataTermsTitle: "मुख्य शब्द", dataTermsHint: "वे शब्द जो सबसे अधिक सामग्री में आते हैं।", dataConnections: "जुड़े मिलान", - dataTopicTitle: "इससे संबंधित सब कुछ:", - dataTopicHint: "एल्गोरिद्म इस विषय से क्या जोड़ता है", dataNoMatches: "दिखाने के लिए क्रॉस-डेटा नहीं।", dataNoProfile: "सामग्री प्रकाशित करें या अपना CV लिखें ताकि एल्गोरिद्म जान सके कि आपकी रुचि किसमें है।", pollsDescription: "नेटवर्क से पूछने और उत्तर गिनने का मॉड्यूल।", @@ -2204,9 +2217,7 @@ module.exports = { tribeTrendingEngagement: "सहभागिता", tribeTopCategory: "अधिक मतदान", tribeTagsEmpty: "कोई टैग नहीं मिला।", - tribeSearchPlaceholder: "जनजाति सामग्री खोजें...", tribeSearchEmpty: "कोई परिणाम नहीं मिला।", - tribeSearchResults: "परिणाम", tribeSearchMinChars: "खोजने के लिए कम से कम 2 अक्षर दर्ज करें।", agendaTitle: "एजेंडा", agendaDescription: "यहाँ आप अपने सभी सौंपे गए आइटम पा सकते हैं।", @@ -3835,6 +3846,7 @@ module.exports = { roomUpdate: "अपडेट करें", roomDelete: "हटाएं", roomClose: "बंद करें", + roomReopen: "फिर से खोलें", roomCreateSectionTitle: "रूम बनाएं", roomUpdateSectionTitle: "रूम अपडेट करें", roomTitlePlaceholder: "रूम का शीर्षक", @@ -3853,6 +3865,15 @@ module.exports = { roomMuted: "म्यूट", roomRec: "ऑडियो रिकॉर्ड करें", roomRecStop: "रिकॉर्डिंग रोकें", + roomStreamFile: "स्ट्रीम करें", + roomStreamStop: "रोकें", + roomStreaming: "स्ट्रीम हो रहा है", + liveFullscreen: "पूर्ण स्क्रीन", + roomStreamPickTitle: "वीडियो चुनें", + roomStreamPickHint: "इस डिवाइस के वीडियो। आप जो चुनते हैं वह अपनी जगह से ही स्ट्रीम होता है, बिना कॉपी या प्रकाशित किए।", + roomStreamGo: "रूम में चलाएँ", + roomStreamNotFound: "यह वीडियो आपके फ़ोल्डरों में नहीं है।", + roomPublicWarning: "यह रूम सार्वजनिक वेब पर प्रसारित होता है: इसमें जो होता है उसे कोई भी देख और सुन सकता है।", roomRecording: "REC", roomNotificationsOn: "ON", roomNotificationsOff: "OFF", @@ -4333,7 +4354,7 @@ module.exports = { phoneHistoryTitle: "इतिहास", phoneLatestTitle: "हाल की कॉल", phoneCreateButton: "नई कॉल", - phoneLimitsHint: "प्रति कॉल अधिकतम 7 निवासी; एक से अधिक होने पर यह संयुक्त कॉल है।", + phoneLimitsHint: "प्रति कॉल अधिकतम {n} निवासी; एक से अधिक होने पर यह संयुक्त कॉल है।", phoneErrorPub: "पब को कॉल नहीं किया जा सकता।", phoneJointCall: "संयुक्त कॉल", phoneWith: "किसके साथ", @@ -4478,6 +4499,22 @@ module.exports = { settingsPmVisibilityDesc: "नेटवर्क में निजी संदेश एक्सपोज़र स्तर कॉन्फ़िगर करें।", settingsPmVisibilityWhole: "मल्टीवर्स", settingsPmVisibilityMutuals: "केवल पारस्परिक समर्थन", + mute: "म्यूट करें", + unmute: "म्यूट हटाएँ", + relationshipMuted: "आपके द्वारा म्यूट", + hideContent: "मेरे लिए छिपाएँ", + unhideContent: "फिर से दिखाएँ", + hiddenByYou: "आपके द्वारा छिपाया गया", + settingsTabContent: "सामग्री", + settingsHiddenTitle: "छिपी हुई सामग्री", + settingsHiddenDesc: "जिन निवासियों को आपने म्यूट किया और जो सामग्री आपने छिपाई।", + settingsMutedLabel: "म्यूट किए गए निवासी", + settingsHiddenLabel: "छिपाई गई सामग्री", + settingsMutedEmpty: "कोई म्यूट नहीं।", + settingsHiddenEmpty: "कुछ भी छिपा नहीं।", + settingsPmVisibilityFollowing: "केवल वे लोग जिन्हें मैं फ़ॉलो करता हूँ", + settingsMentionsVisibilityTitle: "उल्लेख", + settingsMentionsVisibilityDesc: "चुनें कि किसके उल्लेख आप तक पहुँचें।", pmMutualNotice: "आउटबाउंड UX गार्डरेल है। इनबाउंड ध्यान फ़िल्टर है।", inhabitantsPendingFollowsTitle: "लंबित समर्थन अनुरोध", inhabitantsPendingAccept: "स्वीकार", @@ -4610,7 +4647,7 @@ module.exports = { bankRulesFloor: "प्रति उपयोगकर्ता न्यूनतम (सकल)", bankRulesGraceDays: "दावे की अनुग्रह अवधि", bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", - bankRulesKarmaNote: "कर्म आपकी गतिविधियों (पोस्ट, वोट, संसद/अदालतों में भागीदारी आदि) से समय के साथ घटने वाले गुणक सहित निकाला जाता है, जिसमें से आपकी फ़ीड से पैदा हुए कार्बन ग्राम घटा दिए जाते हैं।", + bankRulesKarmaNote: "कर्म आपकी गतिविधियों (पोस्ट, वोट, संसद/अदालतों में भागीदारी आदि) से समय के साथ घटने वाले गुणक सहित निकाला जाता है, जिसमें से आपकी फ़ीड से पैदा हुए कार्बन ग्राम घटा दिए जाते हैं। आपकी सामग्री को जो मिलता है वह भी गिना जाता है: सकारात्मक राय, वोट, प्रतिक्रियाएँ और प्ले कर्म बढ़ाते हैं, और जब कई निवासी सहमत होते हैं तो नकारात्मक राय या वोट उसे घटाते हैं।", bankRulesKarmaTitle: "कर्म (उपयोगकर्ता सहभागिता स्कोर)", bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", @@ -4696,7 +4733,7 @@ module.exports = { larpRulesTestEntryText: "WILL परीक्षण में हर विकल्प एक या अधिक घरों को भार देता है; भेजने पर सिस्टम अंक जोड़ता है और सबसे अधिक अंक वाले घर में आपको स्वतः प्रवेश देता है।", larpWillTestHint: "पूरा होने पर, आपको वह गृह सौंपा जाएगा जो आपके उत्तरों के साथ सबसे अच्छा मेल खाता है। आपके व्यक्तिगत उत्तरों को स्थानीय रूप से संसाधित किया जाता है और कभी संग्रहीत नहीं किया जाता — केवल परिणामी गृह असाइनमेंट आपकी फ़ीड पर प्रकाशित होता है।", larpWillTestTitle: "WILL परीक्षण", - settingsLanDesc: "इस पीयर को समय-समय पर उसी स्थानीय नेटवर्क में अन्य Oasis इंस्टेंस को घोषित करें। UDP प्रसारण रोकने के लिए अक्षम करें।", + settingsLanDesc: "इस पीयर को समय-समय पर उसी स्थानीय नेटवर्क में अन्य Oasis इंस्टेंस को घोषित करें।", settingsLanEnable: "LAN प्रसारण सक्षम करें", settingsLanTitle: "LAN प्रसारण", settingsReplicationTitle: "प्रतिकृति", diff --git a/src/client/assets/translations/oasis_it.js b/src/client/assets/translations/oasis_it.js index fbe10dc..c84d3af 100644 --- a/src/client/assets/translations/oasis_it.js +++ b/src/client/assets/translations/oasis_it.js @@ -187,7 +187,7 @@ module.exports = { peersTorColumn: "TOR", peersTorYes: "Raggiungibile tramite Tor", peersTorNo: "Non raggiungibile tramite Tor", - peersOasisId: "Oasis ID", + peersOasisId: "ID", peersOasisVersion: "Versione", graphos: "Graphos", graphosDescription: "Mappa interattiva della rete attorno a te.", @@ -600,6 +600,21 @@ module.exports = { peerVersionUnannounced: "Non annuncia la sua versione: un Oasis vecchio", peersTorMissing: "Questo PUB accetta solo connessioni tramite TOR.", peerConnectionsTitle: "Connessioni", + peersOnlineHint: "Nodi connessi con te in questo momento.", + peersDiscoveredHint: "Nodi con indirizzo noto che non sono connessi ora.", + peersUnknownHint: "Nodi del tuo grafo sociale senza indirizzo noto.", + peersConnectionsHint: "Indirizzi che questo nodo può contattare e lo stato di ogni connessione.", + peersTabLan: "LAN", + peersTabHealth: "Salute", + peersHealthHint: "Come sta ogni pub o nodo conosciuto, nella rete locale e oltre.", + peersHealthReachable: "Raggiungibile", + peersHealthFailing: "In errore", + peersHealthFailures: "Errori", + peersFailingSince: "In errore da", + peersHealthForget: "Dimentica irraggiungibili", + peersLanHint: "Nodi incontrati nella tua rete locale.", + peersLanLastSeen: "Visto l'ultima volta", + peersLanEmpty: "Nessun nodo incontrato nella rete locale per ora.", online: "Online", offline: "Offline", discovered: 'Scoperti', @@ -1648,8 +1663,6 @@ module.exports = { dataTermsTitle: "Parole chiave", dataTermsHint: "I termini che compaiono nel maggior numero di contenuti.", dataConnections: "Corrispondenze collegate", - dataTopicTitle: "Tutto ciò che riguarda", - dataTopicHint: "Ciò che l'algoritmo collega a questo tema", dataNoMatches: "Nessun dato incrociato da mostrare.", dataNoProfile: "Pubblica contenuti o scrivi il tuo CV così l'algoritmo impara cosa ti interessa.", pollsDescription: "Modulo per interrogare la rete e contare le risposte.", @@ -2202,9 +2215,7 @@ module.exports = { tribeTrendingEngagement: "coinvolgimento", tribeTopCategory: "Più votato", tribeTagsEmpty: "Nessun tag trovato.", - tribeSearchPlaceholder: "Cerca nei contenuti della tribù...", tribeSearchEmpty: "Nessun risultato trovato.", - tribeSearchResults: "Risultati", tribeSearchMinChars: "Inserisci almeno 2 caratteri per cercare.", agendaTitle: "Agenda", agendaDescription: "Qui trovi tutti gli elementi assegnati a te.", @@ -3834,6 +3845,7 @@ module.exports = { roomUpdate: "Aggiorna", roomDelete: "Elimina", roomClose: "Chiudi", + roomReopen: "Riapri", roomCreateSectionTitle: "Crea stanza", roomUpdateSectionTitle: "Aggiorna stanza", roomTitlePlaceholder: "Titolo della stanza", @@ -3852,6 +3864,15 @@ module.exports = { roomMuted: "microfono disattivato", roomRec: "Registra audio", roomRecStop: "Ferma la registrazione", + roomStreamFile: "Trasmetti", + roomStreamStop: "Ferma", + roomStreaming: "In trasmissione", + liveFullscreen: "Schermo intero", + roomStreamPickTitle: "Scegli un video", + roomStreamPickHint: "Video di questo dispositivo. Quello che scegli viene trasmesso da dove si trova, senza copiarlo né pubblicarlo.", + roomStreamGo: "Trasmetti nella stanza", + roomStreamNotFound: "Quel video non è nelle tue cartelle.", + roomPublicWarning: "Questa stanza è trasmessa sul web pubblico: chiunque può vedere e sentire ciò che vi accade.", roomRecording: "REC", roomNotificationsOn: "ON", roomNotificationsOff: "OFF", @@ -4332,7 +4353,7 @@ module.exports = { phoneHistoryTitle: "Cronologia", phoneLatestTitle: "Ultime chiamate", phoneCreateButton: "Nuova chiamata", - phoneLimitsHint: "Fino a 7 abitanti per chiamata; con più di uno è una chiamata congiunta.", + phoneLimitsHint: "Fino a {n} abitanti per chiamata; con più di uno è una chiamata congiunta.", phoneErrorPub: "Non si può chiamare un pub.", phoneJointCall: "Chiamata congiunta", phoneWith: "Con", @@ -4477,6 +4498,22 @@ module.exports = { settingsPmVisibilityDesc: "Configura il livello di esposizione ai messaggi privati.", settingsPmVisibilityWhole: "Multiverso", settingsPmVisibilityMutuals: "Solo supporto reciproco", + mute: "Silenzia", + unmute: "Riattiva", + relationshipMuted: "Silenziato da te", + hideContent: "Nascondi per me", + unhideContent: "Mostra di nuovo", + hiddenByYou: "Nascosto da te", + settingsTabContent: "Contenuti", + settingsHiddenTitle: "Contenuti nascosti", + settingsHiddenDesc: "Abitanti silenziati e contenuti nascosti.", + settingsMutedLabel: "Abitanti silenziati", + settingsHiddenLabel: "Contenuti nascosti", + settingsMutedEmpty: "Nessuno silenziato.", + settingsHiddenEmpty: "Niente di nascosto.", + settingsPmVisibilityFollowing: "Solo persone che seguo", + settingsMentionsVisibilityTitle: "Menzioni", + settingsMentionsVisibilityDesc: "Scegli da chi ricevere le menzioni.", pmMutualNotice: "L'invio è un guardrail UX. La ricezione è un filtro di attenzione.", inhabitantsPendingFollowsTitle: "Richieste di supporto in attesa", inhabitantsPendingAccept: "Accetta", @@ -4583,7 +4620,7 @@ module.exports = { bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", bankRulesEcoTaxNote: "La tassa ECO viene dedotta dal RBU lordo quando si calcola l'assegnazione dell'epoca. Gli ECO dedotti non vengono ridistribuiti in questa epoca — restano nel saldo del pub e alimentano il fondo dell'epoca successiva tramite i limiti reserveMin / alpha.", bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", - bankRulesKarmaNote: "Il karma deriva dalle tue azioni (post, voti, partecipazione a parlamento/tribunali, ecc.) con un fattore di decadimento nel tempo, meno i grammi di carbonio generati dal tuo feed.", + bankRulesKarmaNote: "Il karma deriva dalle tue azioni (post, voti, partecipazione a parlamento/tribunali, ecc.) con un fattore di decadimento nel tempo, meno i grammi di carbonio generati dal tuo feed. Conta anche ciò che ricevono i tuoi contenuti: opinioni, voti, reazioni e riproduzioni positive aggiungono karma, mentre opinioni o voti negativi lo tolgono quando più abitanti concordano.", bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)", @@ -4618,7 +4655,7 @@ module.exports = { larpRulesTestEntryText: "Nel test WILL ogni opzione pondera una o più case; all'invio il sistema somma i punteggi e ti ammette automaticamente nella casa con il punteggio più alto.", larpWillTestHint: "Una volta completato, ti verrà assegnata la casa che meglio corrisponde alle tue risposte. Le tue risposte individuali vengono elaborate localmente e mai memorizzate — solo l'assegnazione di casa risultante viene pubblicata nel tuo feed.", larpWillTestTitle: "Test WILL", - settingsLanDesc: "Annuncia periodicamente questo peer ad altre istanze di Oasis sulla stessa rete locale. Disabilita per interrompere le trasmissioni UDP.", + settingsLanDesc: "Annuncia periodicamente questo peer ad altre istanze di Oasis sulla stessa rete locale.", settingsLanEnable: "Abilita trasmissione LAN", settingsLanTitle: "Trasmissione LAN", settingsReplicationTitle: "Replicazione", diff --git a/src/client/assets/translations/oasis_pt.js b/src/client/assets/translations/oasis_pt.js index 2180f61..c3dd6d3 100644 --- a/src/client/assets/translations/oasis_pt.js +++ b/src/client/assets/translations/oasis_pt.js @@ -187,7 +187,7 @@ module.exports = { peersTorColumn: "TOR", peersTorYes: "Acessível por Tor", peersTorNo: "Não acessível por Tor", - peersOasisId: "Oasis ID", + peersOasisId: "ID", peersOasisVersion: "Versão", graphos: "Graphos", graphosDescription: "Mapa interativo da rede ao seu redor.", @@ -600,6 +600,21 @@ module.exports = { peerVersionUnannounced: "Não anuncia a sua versão: um Oasis antigo", peersTorMissing: "Este PUB só permite ligações através do TOR.", peerConnectionsTitle: "Ligações", + peersOnlineHint: "Nós ligados a ti neste momento.", + peersDiscoveredHint: "Nós com endereço conhecido que não estão ligados agora.", + peersUnknownHint: "Nós do teu grafo social sem endereço conhecido.", + peersConnectionsHint: "Endereços a que este nó pode ligar e o estado de cada ligação.", + peersTabLan: "LAN", + peersTabHealth: "Saúde", + peersHealthHint: "Como está cada pub ou nó conhecido, na rede local e fora dela.", + peersHealthReachable: "Alcançável", + peersHealthFailing: "A falhar", + peersHealthFailures: "Falhas", + peersFailingSince: "A falhar desde", + peersHealthForget: "Esquecer inalcançáveis", + peersLanHint: "Nós encontrados na tua rede local.", + peersLanLastSeen: "Visto pela última vez", + peersLanEmpty: "Ainda não foi encontrado nenhum nó na rede local.", online: "Online", offline: "Offline", discovered: 'Descobertos', @@ -1648,8 +1663,6 @@ module.exports = { dataTermsTitle: "Palavras-chave", dataTermsHint: "Os termos que aparecem em mais conteúdos.", dataConnections: "Correspondências ligadas", - dataTopicTitle: "Tudo o que se relaciona com", - dataTopicHint: "O que o algoritmo relaciona com este tema", dataNoMatches: "Sem dados cruzados para mostrar.", dataNoProfile: "Publica conteúdo ou escreve o teu CV para que o algoritmo aprenda o que te interessa.", pollsDescription: "Módulo para perguntar à rede e contar as respostas.", @@ -2202,9 +2215,7 @@ module.exports = { tribeTrendingEngagement: "envolvimento", tribeTopCategory: "Mais votado", tribeTagsEmpty: "Nenhum tag encontrado.", - tribeSearchPlaceholder: "Pesquisar conteúdo da tribo...", tribeSearchEmpty: "Nenhum resultado encontrado.", - tribeSearchResults: "Resultados", tribeSearchMinChars: "Introduz pelo menos 2 caracteres para pesquisar.", agendaTitle: "Agenda", agendaDescription: "Aqui podes encontrar todos os teus itens atribuídos.", @@ -3834,6 +3845,7 @@ module.exports = { roomUpdate: "Atualizar", roomDelete: "Eliminar", roomClose: "Fechar", + roomReopen: "Reabrir", roomCreateSectionTitle: "Criar sala", roomUpdateSectionTitle: "Atualizar sala", roomTitlePlaceholder: "Título da sala", @@ -3852,6 +3864,15 @@ module.exports = { roomMuted: "silenciado", roomRec: "Gravar áudio", roomRecStop: "Parar a gravação", + roomStreamFile: "Transmitir", + roomStreamStop: "Parar", + roomStreaming: "A transmitir", + liveFullscreen: "Ecrã inteiro", + roomStreamPickTitle: "Escolhe um vídeo", + roomStreamPickHint: "Vídeos deste dispositivo. O que escolheres é transmitido a partir de onde está, sem ser copiado nem publicado.", + roomStreamGo: "Transmitir na sala", + roomStreamNotFound: "Esse vídeo não está nas tuas pastas.", + roomPublicWarning: "Esta sala é transmitida na web pública: qualquer pessoa pode ver e ouvir o que nela acontece.", roomRecording: "REC", roomNotificationsOn: "ON", roomNotificationsOff: "OFF", @@ -4332,7 +4353,7 @@ module.exports = { phoneHistoryTitle: "Histórico", phoneLatestTitle: "Últimas chamadas", phoneCreateButton: "Nova chamada", - phoneLimitsHint: "Até 7 habitantes por chamada; com mais de um é uma chamada conjunta.", + phoneLimitsHint: "Até {n} habitantes por chamada; com mais de um é uma chamada conjunta.", phoneErrorPub: "Não é possível ligar para um pub.", phoneJointCall: "Chamada conjunta", phoneWith: "Com", @@ -4477,6 +4498,22 @@ module.exports = { settingsPmVisibilityDesc: "Configure seu nível de exposição a mensagens privadas.", settingsPmVisibilityWhole: "Multiverso", settingsPmVisibilityMutuals: "Só apoio mútuo", + mute: "Silenciar", + unmute: "Deixar de silenciar", + relationshipMuted: "Silenciado por ti", + hideContent: "Ocultar para mim", + unhideContent: "Mostrar de novo", + hiddenByYou: "Ocultado por ti", + settingsTabContent: "Conteúdo", + settingsHiddenTitle: "Conteúdo oculto", + settingsHiddenDesc: "Habitantes que silenciaste e conteúdo que ocultaste.", + settingsMutedLabel: "Habitantes silenciados", + settingsHiddenLabel: "Conteúdo oculto", + settingsMutedEmpty: "Ninguém silenciado.", + settingsHiddenEmpty: "Nada oculto.", + settingsPmVisibilityFollowing: "Só pessoas que sigo", + settingsMentionsVisibilityTitle: "Menções", + settingsMentionsVisibilityDesc: "Escolhe de quem te chegam as menções.", pmMutualNotice: "O envio é uma barreira de UX. O recebimento é um filtro de atenção.", inhabitantsPendingFollowsTitle: "Pedidos de apoio pendentes", inhabitantsPendingAccept: "Aceitar", @@ -4595,7 +4632,7 @@ module.exports = { bankRulesFloor: "Mínimo por utilizador (bruto)", bankRulesGraceDays: "Período de carência da reivindicação", bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", - bankRulesKarmaNote: "O karma deriva das tuas ações (publicações, votos, participação no parlamento/tribunais, etc.) com um fator de decaimento temporal, menos os gramas de carbono gerados pelo teu feed.", + bankRulesKarmaNote: "O karma deriva das tuas ações (publicações, votos, participação no parlamento/tribunais, etc.) com um fator de decaimento temporal, menos os gramas de carbono gerados pelo teu feed. O que o teu conteúdo recebe também conta: opiniões, votos, reações e reproduções positivas somam karma, e opiniões ou votos negativos subtraem-no quando vários habitantes coincidem.", bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", bankRulesReserveMin: "Reserva mínima (mantida no saldo do pub)", @@ -4633,7 +4670,7 @@ module.exports = { larpRulesTestEntryText: "No teste WILL cada opção pondera uma ou mais casas; ao enviar, o sistema soma as pontuações e admite-te automaticamente na casa com mais pontos.", larpWillTestHint: "Uma vez concluído, ser-te-á atribuída a casa que melhor corresponde às tuas respostas. As tuas respostas individuais são processadas localmente e nunca armazenadas — apenas a atribuição de casa resultante é publicada no teu feed.", larpWillTestTitle: "Teste WILL", - settingsLanDesc: "Anuncia periodicamente este peer a outras instâncias do Oasis na mesma rede local. Desativar para parar as difusões UDP.", + settingsLanDesc: "Anuncia periodicamente este peer a outras instâncias do Oasis na mesma rede local.", settingsLanEnable: "Ativar difusão LAN", settingsLanTitle: "Difusão LAN", settingsReplicationTitle: "Replicação", diff --git a/src/client/assets/translations/oasis_ru.js b/src/client/assets/translations/oasis_ru.js index c16775f..910d3d6 100644 --- a/src/client/assets/translations/oasis_ru.js +++ b/src/client/assets/translations/oasis_ru.js @@ -188,7 +188,7 @@ module.exports = { peersTorColumn: "TOR", peersTorYes: "Доступен через Tor", peersTorNo: "Недоступен через Tor", - peersOasisId: "Oasis ID", + peersOasisId: "ID", peersOasisVersion: "Версия", graphos: "Графос", graphosDescription: "Интерактивная карта сети вокруг вас.", @@ -611,6 +611,21 @@ module.exports = { peerVersionUnannounced: "Не сообщает свою версию: старый Oasis", peersTorMissing: "Этот PUB принимает подключения только через TOR.", peerConnectionsTitle: "Подключения", + peersOnlineHint: "Узлы, подключённые к вам прямо сейчас.", + peersDiscoveredHint: "Узлы с известным адресом, не подключённые сейчас.", + peersUnknownHint: "Узлы вашего социального графа без известного адреса.", + peersConnectionsHint: "Адреса, к которым может подключаться этот узел, и состояние каждого соединения.", + peersTabLan: "LAN", + peersTabHealth: "Состояние", + peersHealthHint: "Как дела у каждого известного паба или узла, в локальной сети и за её пределами.", + peersHealthReachable: "Доступен", + peersHealthFailing: "Сбоит", + peersHealthFailures: "Сбои", + peersFailingSince: "Сбоит с", + peersHealthForget: "Забыть недоступные", + peersLanHint: "Узлы, найденные в вашей локальной сети.", + peersLanLastSeen: "Последний раз замечен", + peersLanEmpty: "В локальной сети пока не найдено ни одного узла.", online: "В сети", offline: "Не в сети", discovered: 'Обнаружен', @@ -1658,8 +1673,6 @@ module.exports = { dataTermsTitle: "Ключевые слова", dataTermsHint: "Термины, встречающиеся в наибольшем числе материалов.", dataConnections: "Связанные совпадения", - dataTopicTitle: "Всё, что связано с", - dataTopicHint: "Что алгоритм связывает с этой темой", dataNoMatches: "Нет перекрёстных данных.", dataNoProfile: "Публикуйте контент или заполните CV, чтобы алгоритм узнал, что вам интересно.", pollsDescription: "Модуль, чтобы спросить сеть и подсчитать ответы.", @@ -2218,9 +2231,7 @@ module.exports = { tribeTrendingEngagement: "вовлечённость", tribeTopCategory: "Больше голосов", tribeTagsEmpty: "Тегов не найдено.", - tribeSearchPlaceholder: "Поиск контента племени...", tribeSearchEmpty: "Результаты не найдены.", - tribeSearchResults: "Результаты", tribeSearchMinChars: "Введите не менее 2 символов для поиска.", agendaTitle: "Повестка дня", agendaDescription: "Здесь вы можете найти все назначенные вам элементы.", @@ -3835,6 +3846,7 @@ module.exports = { roomUpdate: "Обновить", roomDelete: "Удалить", roomClose: "Закрыть", + roomReopen: "Открыть снова", roomCreateSectionTitle: "Создать комнату", roomUpdateSectionTitle: "Обновить комнату", roomTitlePlaceholder: "Название комнаты", @@ -3853,6 +3865,15 @@ module.exports = { roomMuted: "микрофон выключен", roomRec: "Записать аудио", roomRecStop: "Остановить запись", + roomStreamFile: "Транслировать", + roomStreamStop: "Остановить", + roomStreaming: "Идёт трансляция", + liveFullscreen: "Во весь экран", + roomStreamPickTitle: "Выберите видео", + roomStreamPickHint: "Видео на этом устройстве. Выбранное транслируется прямо с места хранения, без копирования и публикации.", + roomStreamGo: "Показать в комнате", + roomStreamNotFound: "Этого видео нет в ваших папках.", + roomPublicWarning: "Эта комната транслируется в открытом интернете: любой может видеть и слышать, что в ней происходит.", roomRecording: "REC", roomNotificationsOn: "ON", roomNotificationsOff: "OFF", @@ -4333,7 +4354,7 @@ module.exports = { phoneHistoryTitle: "История", phoneLatestTitle: "Последние звонки", phoneCreateButton: "Новый звонок", - phoneLimitsHint: "До 7 жителей на звонок; если больше одного — это совместный звонок.", + phoneLimitsHint: "До {n} жителей на звонок; если больше одного — это совместный звонок.", phoneErrorPub: "Пабам звонить нельзя.", phoneJointCall: "Совместный звонок", phoneWith: "С кем", @@ -4478,6 +4499,22 @@ module.exports = { settingsPmVisibilityDesc: "Настройте уровень открытости личных сообщений.", settingsPmVisibilityWhole: "Мультивселенная", settingsPmVisibilityMutuals: "Только взаимная поддержка", + mute: "Заглушить", + unmute: "Снять заглушение", + relationshipMuted: "Заглушён вами", + hideContent: "Скрыть для меня", + unhideContent: "Показать снова", + hiddenByYou: "Скрыто вами", + settingsTabContent: "Контент", + settingsHiddenTitle: "Скрытый контент", + settingsHiddenDesc: "Заглушённые вами жители и скрытый вами контент.", + settingsMutedLabel: "Заглушённые жители", + settingsHiddenLabel: "Скрытый контент", + settingsMutedEmpty: "Никто не заглушён.", + settingsHiddenEmpty: "Ничего не скрыто.", + settingsPmVisibilityFollowing: "Только те, на кого я подписан", + settingsMentionsVisibilityTitle: "Упоминания", + settingsMentionsVisibilityDesc: "Выберите, чьи упоминания доходят до вас.", pmMutualNotice: "Исходящие — защита UX. Входящие — фильтр внимания.", inhabitantsPendingFollowsTitle: "Ожидающие запросы на поддержку", inhabitantsPendingAccept: "Принять", @@ -4610,7 +4647,7 @@ module.exports = { bankRulesFloor: "Минимум на пользователя (брутто)", bankRulesGraceDays: "Льготный период для заявки", bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", - bankRulesKarmaNote: "Карма вычисляется по вашим действиям (публикации, голоса, участие в парламенте/судах и т. д.) с коэффициентом затухания во времени, за вычетом граммов углерода, созданных вашей лентой.", + bankRulesKarmaNote: "Карма вычисляется по вашим действиям (публикации, голоса, участие в парламенте/судах и т. д.) с коэффициентом затухания во времени, за вычетом граммов углерода, созданных вашей лентой. То, что получает ваш контент, тоже учитывается: положительные мнения, голоса, реакции и прослушивания добавляют карму, а отрицательные мнения или голоса отнимают её, когда несколько жителей согласны.", bankRulesKarmaTitle: "Карма (оценка вовлечённости)", bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", @@ -4696,7 +4733,7 @@ module.exports = { larpRulesTestEntryText: "В тесте WILL каждый вариант даёт вес одному или нескольким домам; при отправке система суммирует баллы и автоматически принимает вас в дом с наибольшим счётом.", larpWillTestHint: "После завершения вам будет назначен дом, который лучше всего соответствует вашим ответам. Ваши индивидуальные ответы обрабатываются локально и никогда не сохраняются — в ваш канал публикуется только результат назначения дома.", larpWillTestTitle: "WILL-тест", - settingsLanDesc: "Периодически анонсировать этот узел другим экземплярам Oasis в той же локальной сети. Отключите, чтобы остановить UDP-вещание.", + settingsLanDesc: "Периодически анонсировать этот узел другим экземплярам Oasis в той же локальной сети.", settingsLanEnable: "Включить LAN-вещание", settingsLanTitle: "LAN-вещание", settingsReplicationTitle: "Репликация", diff --git a/src/client/assets/translations/oasis_zh.js b/src/client/assets/translations/oasis_zh.js index f55987b..e030108 100644 --- a/src/client/assets/translations/oasis_zh.js +++ b/src/client/assets/translations/oasis_zh.js @@ -187,7 +187,7 @@ module.exports = { peersTorColumn: "TOR", peersTorYes: "可通过 Tor 访问", peersTorNo: "无法通过 Tor 访问", - peersOasisId: "Oasis ID", + peersOasisId: "ID", peersOasisVersion: "版本", graphos: "Graphos", graphosDescription: "你周围网络的互动地图。", @@ -602,6 +602,21 @@ module.exports = { peerVersionUnannounced: "未公布其版本:旧版 Oasis", peersTorMissing: "此 PUB 仅允许通过 TOR 连接。", peerConnectionsTitle: "连接", + peersOnlineHint: "当前与你相连的节点。", + peersDiscoveredHint: "地址已知但当前未连接的节点。", + peersUnknownHint: "你的社交图谱中没有已知地址的节点。", + peersConnectionsHint: "本节点可以拨号的地址及每个连接的状态。", + peersTabLan: "局域网", + peersTabHealth: "健康", + peersHealthHint: "每个已知 pub 或节点的状态,包括局域网内外。", + peersHealthReachable: "可达", + peersHealthFailing: "失败中", + peersHealthFailures: "失败次数", + peersFailingSince: "失败开始于", + peersHealthForget: "忘记不可达节点", + peersLanHint: "在你的局域网中发现的节点。", + peersLanLastSeen: "最后出现", + peersLanEmpty: "局域网中尚未发现任何节点。", online: "在线", offline: "离线", discovered: '已发现', @@ -1651,8 +1666,6 @@ module.exports = { dataTermsTitle: "关键词", dataTermsHint: "出现在最多内容中的词条。", dataConnections: "关联匹配", - dataTopicTitle: "与此相关的一切:", - dataTopicHint: "算法与该主题关联的内容", dataNoMatches: "没有可显示的交叉数据。", dataNoProfile: "发布内容或填写简历,让算法了解你的兴趣。", pollsDescription: "用于向网络提问并统计答案的模块。", @@ -2205,9 +2218,7 @@ module.exports = { tribeTrendingEngagement: "参与度", tribeTopCategory: "最多投票", tribeTagsEmpty: "未找到标签。", - tribeSearchPlaceholder: "搜索部落内容...", tribeSearchEmpty: "未找到结果。", - tribeSearchResults: "结果", tribeSearchMinChars: "请输入至少2个字符进行搜索。", agendaTitle: "日程", agendaDescription: "在这里你可以找到所有分配给你的项目。", @@ -3836,6 +3847,7 @@ module.exports = { roomUpdate: "更新", roomDelete: "删除", roomClose: "关闭", + roomReopen: "重新开放", roomCreateSectionTitle: "创建房间", roomUpdateSectionTitle: "更新房间", roomTitlePlaceholder: "房间标题", @@ -3854,6 +3866,15 @@ module.exports = { roomMuted: "已静音", roomRec: "录制音频", roomRecStop: "停止录音", + roomStreamFile: "播放", + roomStreamStop: "停止", + roomStreaming: "正在播放", + liveFullscreen: "全屏", + roomStreamPickTitle: "选择视频", + roomStreamPickHint: "此设备上的视频。所选视频将从其所在位置直接播放,不会被复制或发布。", + roomStreamGo: "在房间中播放", + roomStreamNotFound: "你的文件夹中没有这个视频。", + roomPublicWarning: "此房间在公共网络上直播:任何人都能看到和听到其中发生的一切。", roomRecording: "REC", roomNotificationsOn: "ON", roomNotificationsOff: "OFF", @@ -4334,7 +4355,7 @@ module.exports = { phoneHistoryTitle: "历史记录", phoneLatestTitle: "最近通话", phoneCreateButton: "新通话", - phoneLimitsHint: "每次通话最多 7 位居民;多于一位即为联合通话。", + phoneLimitsHint: "每次通话最多 {n} 位居民;多于一位即为联合通话。", phoneErrorPub: "不能呼叫 pub。", phoneJointCall: "联合通话", phoneWith: "对象", @@ -4479,6 +4500,22 @@ module.exports = { settingsPmVisibilityDesc: "配置您希望在网络中的私信曝光级别。", settingsPmVisibilityWhole: "多元宇宙", settingsPmVisibilityMutuals: "仅限互相支持", + mute: "静音", + unmute: "取消静音", + relationshipMuted: "已被你静音", + hideContent: "对我隐藏", + unhideContent: "重新显示", + hiddenByYou: "已被你隐藏", + settingsTabContent: "内容", + settingsHiddenTitle: "隐藏的内容", + settingsHiddenDesc: "你静音的居民和你隐藏的内容。", + settingsMutedLabel: "已静音的居民", + settingsHiddenLabel: "已隐藏的内容", + settingsMutedEmpty: "没有静音任何人。", + settingsHiddenEmpty: "没有隐藏任何内容。", + settingsPmVisibilityFollowing: "仅限我关注的人", + settingsMentionsVisibilityTitle: "提及", + settingsMentionsVisibilityDesc: "选择谁的提及可以送达你。", pmMutualNotice: "出站是 UX 保护。入站是关注过滤器。", inhabitantsPendingFollowsTitle: "待处理的支持请求", inhabitantsPendingAccept: "接受", @@ -4611,7 +4648,7 @@ module.exports = { bankRulesFloor: "每位用户的保底(总额)", bankRulesGraceDays: "领取宽限期", bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", - bankRulesKarmaNote: "业力来自你的行为(发帖、投票、参与议会/法庭等),带有随时间衰减的系数,并减去你的动态所产生的碳克数。", + bankRulesKarmaNote: "业力来自你的行为(发帖、投票、参与议会/法庭等),带有随时间衰减的系数,并减去你的动态所产生的碳克数。 你的内容收到的反馈同样计入:正面的评价、投票、反应和播放会增加业力,当多位居民一致给出负面评价或投票时则会扣减。", bankRulesKarmaTitle: "业力(用户参与度评分)", bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", @@ -4697,7 +4734,7 @@ module.exports = { larpRulesTestEntryText: "在 WILL 测试中,每个选项会为一个或多个家族加权;提交后系统统计得分,并自动把你录入得分最高的家族。", larpWillTestHint: "完成后,你将被分配到与你的回答最匹配的家族。你的单个回答在本地处理,从不存储 — 只有最终的家族分配会发布到你的信息流。", larpWillTestTitle: "WILL 测试", - settingsLanDesc: "定期向同一局域网中的其他 Oasis 实例广播此节点。禁用以停止 UDP 广播。", + settingsLanDesc: "定期向同一局域网中的其他 Oasis 实例广播此节点。", settingsLanEnable: "启用局域网广播", settingsLanTitle: "局域网广播", settingsReplicationTitle: "复制", diff --git a/src/configs/config-manager.js b/src/configs/config-manager.js index 8615fcb..2a4582c 100644 --- a/src/configs/config-manager.js +++ b/src/configs/config-manager.js @@ -161,13 +161,14 @@ const readConfig = () => { const configData = fs.readFileSync(configFilePath); const cfg = JSON.parse(configData); if (!['whole', 'mutuals', 'only-lan', 'local'].includes(cfg.wish)) cfg.wish = 'whole'; - if (cfg.pmVisibility !== 'whole' && cfg.pmVisibility !== 'mutuals') cfg.pmVisibility = 'whole'; + if (!['whole', 'mutuals', 'following'].includes(cfg.pmVisibility)) cfg.pmVisibility = 'whole'; + if (!['whole', 'mutuals', 'following'].includes(cfg.mentionsVisibility)) cfg.mentionsVisibility = 'whole'; if (!cfg.phone || typeof cfg.phone !== 'object') cfg.phone = {}; cfg.phone = { visibility: cfg.phone.visibility === 'whole' ? 'whole' : 'mutuals', dnd: cfg.phone.dnd === true, relay: cfg.phone.relay !== false, video: cfg.phone.video !== false, ...(typeof cfg.phone.videoDevice === 'string' && cfg.phone.videoDevice.trim() ? { videoDevice: cfg.phone.videoDevice.trim() } : {}) }; if (typeof cfg.ux === 'string') cfg.ux = { current: cfg.ux }; if (!cfg.ux || typeof cfg.ux !== 'object') cfg.ux = { current: 'blocks' }; if (cfg.ux.current === 'menus') cfg.ux.current = 'blocks'; - if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav' && cfg.ux.current !== 'chats' && cfg.ux.current !== 'feed' && cfg.ux.current !== 'phone') cfg.ux.current = 'blocks'; + if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav' && cfg.ux.current !== 'chats' && cfg.ux.current !== 'feed' && cfg.ux.current !== 'phone' && cfg.ux.current !== 'rooms') cfg.ux.current = 'blocks'; if (cfg.ux.current === 'ainav' && cfg.modules && cfg.modules.aiNavMod !== 'on') cfg.ux.current = 'blocks'; if (cfg.ux.current === 'chats' && cfg.modules && cfg.modules.chatsMod !== 'on') cfg.ux.current = 'blocks'; @@ -182,6 +183,7 @@ const readConfig = () => { delete cfg.bottomBarPin; if (cfg.ux.current === 'phone' && cfg.modules && cfg.modules.phoneMod === 'off') cfg.ux.current = 'blocks'; if (cfg.ux.current === 'feed' && cfg.modules && cfg.modules.feedMod === 'off') cfg.ux.current = 'blocks'; + if (cfg.ux.current === 'rooms' && cfg.modules && cfg.modules.roomsMod === 'off') cfg.ux.current = 'blocks'; if (!cfg.blobCache || typeof cfg.blobCache !== 'object' || !Number.isFinite(Number(cfg.blobCache.maxMB))) cfg.blobCache = { maxMB: 0 }; if (!Number.isFinite(Number(cfg.blobCache.pubMaxMB))) cfg.blobCache.pubMaxMB = 0; if (cfg.modules && typeof cfg.modules === 'object') { diff --git a/src/configs/state-manager.js b/src/configs/state-manager.js index c9f0773..5a2aa7e 100644 --- a/src/configs/state-manager.js +++ b/src/configs/state-manager.js @@ -18,6 +18,7 @@ const FOLDERS = { 'content_favorites.json': 'content', 'blob-access.json': 'content', 'follow_state.json': 'content', + 'viewer_hidden.json': 'content', 'invites-used.json': 'content', 'agenda-config.json': 'content', 'AI-history.json': 'ai', diff --git a/src/models/banking_model.js b/src/models/banking_model.js index eaf96b2..f5092e2 100644 --- a/src/models/banking_model.js +++ b/src/models/banking_model.js @@ -432,10 +432,6 @@ const UBI_MAX_ATTEMPTS = 6; const UBI_RETRY_AFTER_MS = 60 * 60 * 1000; const isUbiPayoutConcept = (c) => UBI_PAYMENT_PREFIXES.some(p => String(c || "").startsWith(p)); const TXID_RE = /\b([0-9a-f]{64})\b/i; -const OWN_SCORE_CAP = 200; -const RECEIVED_AUTHOR_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000; -const NEGATIVE_OPINIONS = new Set(["boring", "spam", "propaganda"]); -const ACTIONS_CACHE_TTL_MS = 60 * 1000; function resolveUserId(maybeId) { const s = String(maybeId || "").trim(); @@ -443,7 +439,7 @@ function resolveUserId(maybeId) { return config?.keys?.id || ""; } -let FEED_SRC = "none"; + module.exports = ({ services } = {}) => { const transfersRepo = { @@ -477,6 +473,9 @@ module.exports = ({ services } = {}) => { return ssbInstance; } + const karma = require("./karma_model")({ services, ranges: { normalizeRange, widenRange, filterByRange } }); + const { listAllActions, fetchUserActions, scoreFromActions, getCarbonGramsForUser, getUserEngagementScore, getLastKarmaScore, listKarmaHistory } = karma; + const BANKING_TYPES = ["wallet", "karmaScore", "ubiClaim", "ubiClaimResult", "ubiAllocation", "ubiRefuse", "pubAvailability", "tombstone"]; async function scanLogStream() { @@ -612,316 +611,6 @@ module.exports = ({ services } = {}) => { return strip(A) === strip(B); } - function inferType(c = {}) { - if (c.vote) return "vote"; - if (c.votes) return "votes"; - if (c.address && c.coin === "ECO" && c.type === "wallet") return "bankWallet"; - if (c.type === "ubiClaimResult" && c.txid && c.epochId) return "ubiClaimResult"; - if (typeof c.amount !== "undefined" && c.epochId && c.allocationId) return "bankClaim"; - if (typeof c.item_type !== "undefined" && typeof c.status !== "undefined") return "market"; - if (typeof c.goal !== "undefined" && typeof c.progress !== "undefined") return "project"; - if (typeof c.members !== "undefined" && typeof c.isAnonymous !== "undefined") return "tribe"; - if (typeof c.date !== "undefined" && typeof c.location !== "undefined") return "event"; - if (typeof c.priority !== "undefined" && typeof c.status !== "undefined" && c.title) return "task"; - if (typeof c.confirmations !== "undefined" && typeof c.severity !== "undefined") return "report"; - if (typeof c.job_type !== "undefined" && typeof c.status !== "undefined") return "job"; - if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "audio") return "audio"; - if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "video") return "video"; - if (typeof c.url !== "undefined" && c.title && c.key) return "document"; - if (typeof c.text !== "undefined" && typeof c.refeeds !== "undefined") return "feed"; - if (typeof c.text !== "undefined" && typeof c.contentWarning !== "undefined") return "post"; - if (typeof c.contact !== "undefined") return "contact"; - if (typeof c.about !== "undefined") return "about"; - if (typeof c.concept !== "undefined" && typeof c.amount !== "undefined" && c.status) return "transfer"; - return ""; - } - - function normalizeType(a) { - const t = a.type || a.content?.type || inferType(a.content) || ""; - return String(t).toLowerCase(); - } - - function priorityBump(p) { - const s = String(p || "").toUpperCase(); - if (s === "HIGH") return 3; - if (s === "MEDIUM") return 1; - return 0; - } - - function severityBump(s) { - const x = String(s || "").toUpperCase(); - if (x === "CRITICAL") return 6; - if (x === "HIGH") return 4; - if (x === "MEDIUM") return 2; - return 0; - } - - function scoreMarket(c) { - const st = String(c.status || "").toUpperCase(); - let s = 5; - if (st === "SOLD") s += 8; - else if (st === "ACTIVE") s += 3; - const bids = Array.isArray(c.auctions_poll) ? c.auctions_poll.length : 0; - s += Math.min(10, bids); - return s; - } - - function scoreProject(c) { - const st = String(c.status || "ACTIVE").toUpperCase(); - const prog = Number(c.progress || 0); - let s = 8 + Math.min(10, prog / 10); - if (st === "FUNDED") s += 10; - return s; - } - - function calculateOpinionScore(content) { - const cats = content?.opinions || {}; - let s = 0; - for (const k in cats) { - if (!Object.prototype.hasOwnProperty.call(cats, k)) continue; - if (k === "interesting" || k === "inspiring") s += 5; - else if (k === "boring" || k === "spam" || k === "propaganda") s -= 3; - else s += 1; - } - return s; - } - - let actionsCache = null; - async function listAllActions() { - const ssb = await openSsb(); - if (!ssb) { - FEED_SRC = "none"; - return []; - } - const ownSeq = await ownFeedSeq(ssb); - if (actionsCache && actionsCache.ownSeq === ownSeq && Date.now() - actionsCache.ts < ACTIONS_CACHE_TTL_MS) return actionsCache.value; - const msgs = await readTyped(ssb, CONTENT_TYPES, { limit: Math.max(getLogLimit(), 20000) }); - FEED_SRC = "messagesByType"; - const value = msgs.map(m => { - const v = m.value || {}; - const c = v.content || {}; - return { - id: v.key || m.key, - author: v.author, - type: (c.type || "").toLowerCase(), - value: v, - content: c, - receivedAt: Number(m.timestamp) || 0 - }; - }); - actionsCache = { ts: Date.now(), ownSeq, value }; - return value; - } - - - -async function ownFeedSeq(ssb) { - return new Promise((res) => { - try { - pull( - ssb.createUserStream({ id: ssb.id, reverse: true, limit: 1 }), - pull.collect((err, msgs) => res(err || !msgs || !msgs.length ? 0 : (msgs[0].value && msgs[0].value.sequence) || 0)) - ); - } catch (_) { res(0); } - }); -} - -async function publishKarmaScore(userId, karmaScore) { - const ssb = await openSsb(); - if (!ssb || !ssb.publish) return false; - if (!(await ownFeedSeq(ssb))) return false; - const timestamp = new Date().toISOString(); - const content = { type: "karmaScore", karmaScore, userId, timestamp }; - return new Promise((resolve, reject) => { - ssb.publish(content, (err, msg) => err ? reject(err) : resolve(msg)); - }); -} - -async function fetchUserActions(userId) { - const me = resolveUserId(userId); - const actions = await listAllActions(); - const authored = actions.filter(a => - (a.author && a.author === me) || (a.value?.author && a.value.author === me) - ); - if (authored.length) return authored; - return actions.filter(a => { - const c = a.content || {}; - const fields = [c.author, c.organizer, c.seller, c.about, c.contact]; - return fields.some(f => f && f === me); - }); -} - -function basePointsFor(action) { - const t = normalizeType(action); - const c = action.content || {}; - const rawType = String(c.type || "").toLowerCase(); - if (t === "post") return 10; - else if (t === "comment") return 5; - else if (t === "like") return 2; - else if (t === "image") return 8; - else if (t === "video") return 12; - else if (t === "audio") return 8; - else if (t === "document") return 6; - else if (t === "bookmark") return 2; - else if (t === "feed") return 6; - else if (t === "forum") return (c.root ? 5 : 10); - else if (t === "vote") return (3 + calculateOpinionScore(c)); - else if (t === "votes") return Math.min(10, Number(c.totalVotes || 0)); - else if (t === "market") return scoreMarket(c); - else if (t === "project") return scoreProject(c); - else if (t === "tribe") return (6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0)); - else if (t === "event") return (4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0)); - else if (t === "task") return (3 + priorityBump(c.priority)); - else if (t === "report") return (4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity)); - else if (t === "curriculum") return 5; - else if (t === "wikipage") return (c.replaces ? 2 : 6); - else if (t === "emergency") return (c.replaces ? 1 : 5); - else if (t === "emergencyconfirm") return 1; - else if (t === "emergencyupdate") return 2; - else if (t === "mailinglist") return (c.replaces ? 1 : 3); - else if (t === "logisticsroute") return (c.replaces ? 1 : 4); - else if (t === "logisticsrating") return 1; - else if (t === "podcast") return (c.replaces ? 1 : 5); - else if (t === "podcastepisode") return (c.replaces ? 1 : 4); - else if (t === "podcastopinion" || t === "podcastplay" || t === "campaignopinion" || t === "logisticsopinion") return 1; - else if (t === "campaign") return (c.replaces ? 1 : 5); - else if (t === "campaignsignature") return 1; - else if (t === "campaignupdate") return 2; - else if (t === "aiexchange") return (Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0); - else if (t === "job") return (4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0)); - else if (t === "transfer") return 1; - else if (t === "about") return (1 + ((c.visibilityPrefs && c.visibilityPrefs.fediverseHandle) ? 8 : 0)); - else if (t === "contact") return 1; - else if (t === "pub") return 1; - else if (t === "parliamentcandidature" || rawType === "parliamentcandidature") return 12; - else if (t === "parliamentterm" || rawType === "parliamentterm") return 25; - else if (t === "parliamentproposal" || rawType === "parliamentproposal") return 8; - else if (t === "parliamentlaw" || rawType === "parliamentlaw") return 16; - else if (t === "parliamentrevocation" || rawType === "parliamentrevocation") return 10; - else if (t === "courts_case" || t === "courtscase" || rawType === "courts_case") return 4; - else if (t === "courts_evidence" || t === "courtsevidence" || rawType === "courts_evidence") return 3; - else if (t === "courts_answer" || t === "courtsanswer" || rawType === "courts_answer") return 4; - else if (t === "courts_verdict" || t === "courtsverdict" || rawType === "courts_verdict") return 10; - else if (t === "courts_settlement" || t === "courtssettlement" || rawType === "courts_settlement") return 8; - else if (t === "courts_nomination" || t === "courtsnomination" || rawType === "courts_nomination") return 6; - else if (t === "courts_nom_vote" || t === "courtsnomvote" || rawType === "courts_nom_vote") return 3; - else if (t === "courts_public_pref" || t === "courtspublicpref" || rawType === "courts_public_pref") return 1; - else if (t === "courts_mediators" || t === "courtsmediators" || rawType === "courts_mediators") return 6; - else if (t === "courts_open_support" || t === "courtsopensupport" || rawType === "courts_open_support") return 2; - else if (t === "courts_verdict_vote" || t === "courtsverdictvote" || rawType === "courts_verdict_vote") return 3; - else if (t === "courts_judge_assign" || t === "courtsjudgeassign" || rawType === "courts_judge_assign") return 5; - else if (t === "larpjoinhouse") return 15; - else if (t === "larphousepost") return 6; - else if (t === "larptestattempt") return 3; - else if (t === "torrent") return 6; - else if (t === "file") return 6; - else if (t === "shop" || t === "shopproduct") return 6; - else if (t === "shop-purchase") return 2; - else if (t === "pad" || t === "padentry") return 3; - else if (t === "room") return 3; - else if (t === "roommember") return 1; - else if (t === "calendar" || t === "calendarnote" || t === "calendardate") return 3; - else if (t === "chat") return 1; - else if (t === "gamescore") return 2; - else if (t === "pixelia") return 2; - else if (rawType === "industry") return 8; - else if (rawType === "industryblueprint") return 6; - else if (rawType === "industrybuild") return 8; - else if (rawType === "industrycontribution") return 4; - else if (rawType === "industryallocation") return 10; - else if (rawType === "industryvote") return 2; - else if (rawType === "industrymember") return 2; - else if (rawType === "industryopinion") return 1; - else if (rawType === "schoolcourse") return 10; - else if (rawType === "schoollesson") return 6; - else if (rawType === "schoolenroll") return 3; - else if (rawType === "schoolcertificate") return 12; - else if (rawType === "schoolexam") return 6; - else if (rawType === "schoolprogress") return 1; - else if (rawType === "schoolopinion") return 2; - return 0; -} - -function decayFor(ts, nowMs) { - const ageDays = ts ? (nowMs - ts) / 86400000 : Infinity; - return ageDays <= 30 ? 1.0 : ageDays <= 90 ? 0.5 : 0.25; -} - -function scoreFromActions(actions) { - let score = 0; - const nowMs = Date.now(); - const perTypeDay = new Map(); - for (const action of actions) { - const pts = basePointsFor(action); - if (!pts) continue; - const ts = action.value?.timestamp; - const dayKey = `${normalizeType(action)}:${ts ? new Date(ts).toISOString().slice(0, 10) : "?"}`; - const n = (perTypeDay.get(dayKey) || 0) + 1; - perTypeDay.set(dayKey, n); - score += pts * decayFor(ts, nowMs) / n; - } - return Math.max(0, Math.round(Math.min(OWN_SCORE_CAP, score))); -} - -function receivedScoreFor(userId, actions) { - const nowMs = Date.now(); - const mine = new Set(); - const firstTs = new Map(); - for (const a of actions) { - if (!a.author) continue; - const ts = Number(a.receivedAt) || 0; - if (ts && (!firstTs.has(a.author) || ts < firstTs.get(a.author))) firstTs.set(a.author, ts); - if (a.author === userId && a.id) mine.add(a.id); - } - const seen = new Set(); - const perTarget = new Map(); - let score = 0; - const add = (author, kind, target, pts, ts, capPerTarget = Infinity) => { - const k = `${kind}:${author}:${target}`; - if (seen.has(k)) return; - const n = perTarget.get(`${kind}:${target}`) || 0; - if (n >= capPerTarget) return; - seen.add(k); - perTarget.set(`${kind}:${target}`, n + 1); - score += pts * decayFor(ts, nowMs); - }; - for (const a of actions) { - const b = a.author; - if (!b || b === userId) continue; - if (!firstTs.has(b) || nowMs - firstTs.get(b) < RECEIVED_AUTHOR_MIN_AGE_MS) continue; - const c = a.content || {}; - const type = String(c.type || ""); - const ts = a.value?.timestamp; - if (/Opinion$/.test(type) && type !== "pollOpinion" && mine.has(c.target)) add(b, "opinion", c.target, NEGATIVE_OPINIONS.has(String(c.category || "").toLowerCase()) ? -3 : 3, ts); - else if (type === "vote" && c.vote && mine.has(c.vote.link)) add(b, "opinion", c.vote.link, Number(c.vote.value) > 0 ? 3 : -3, ts); - else if (type === "feed-action" && c.action === "vote" && mine.has(c.root || c.target)) add(b, "opinion", c.root || c.target, 3, ts); - else if (type === "feed-action" && c.action === "comment" && mine.has(c.root)) add(b, "comment", c.root, 2, ts, 10); - else if (type === "feed-action" && c.action === "refeed" && mine.has(c.root)) add(b, "refeed", c.root, 2, ts, 10); - else if (type === "post" && c.root && mine.has(c.root)) add(b, "comment", c.root, 2, ts, 10); - else if (type === "contact" && c.following === true && c.contact === userId) add(b, "follow", userId, 2, ts); - else if (type === "marketPurchase" && mine.has(c.target)) add(b, "purchase", c.target, 5, ts); - else if (type === "shop-purchase" && c.seller === userId) add(b, "purchase", c.productId || a.id, 5, ts); - else if (type === "schoolEnroll" && c.value === true && mine.has(c.courseId)) add(b, "enroll", c.courseId, 5, ts); - else if (type === "transferConfirm" && mine.has(c.target)) add(b, "confirm", c.target, 3, ts); - } - return Math.max(0, Math.round(score)); -} - -async function getCarbonGramsForUser(userId) { - const ssb = await openSsb(); - if (!ssb || !userId) return 0; - return new Promise((resolve) => { - let bytes = 0; - pull( - ssb.createUserStream({ id: userId }), - pull.drain( - (m) => { try { bytes += Buffer.byteLength(JSON.stringify(m && m.value), 'utf8'); } catch (_) {} }, - () => resolve((bytes / (1024 * 1024)) * 0.095) - ) - ); - }); -} - async function getBytesForUser(userId) { const ssb = await openSsb(); if (!ssb || !userId) return 0; @@ -1134,85 +823,6 @@ async function getSchoolBalance(userId) { return { received: Number(received.toFixed(6)), lifetime: Number(lifetime.toFixed(6)), net: Number(received.toFixed(6)) }; } -async function getUserEngagementScore(userId) { - const ssb = await openSsb(); - const uid = resolveUserId(userId); - const actions = await fetchUserActions(uid); - const all = await listAllActions(); - const rawKarma = scoreFromActions(actions) + receivedScoreFor(uid, all); - const carbonGrams = await getCarbonGramsForUser(uid).catch(() => 0); - const karmaScore = Math.max(0, Math.round(rawKarma - carbonGrams)); - - const prev = await getLastKarmaScore(uid); - const lastPublishedTimestamp = await getLastPublishedTimestamp(uid); - - const isSelf = idsEqual(uid, ssb.id); - const hasSSB = !!(ssb && ssb.publish); - - const changed = (prev === null) || (karmaScore !== prev); - const nowMs = Date.now(); - const lastMs = lastPublishedTimestamp ? new Date(lastPublishedTimestamp).getTime() : 0; - const cooldownOk = (nowMs - lastMs) >= 24 * 60 * 60 * 1000; - - if (isSelf && hasSSB && changed && cooldownOk) { - await publishKarmaScore(uid, karmaScore); - } - return karmaScore; -} - -async function getLastKarmaScore(userId) { - const ssb = await openSsb(); - if (!ssb) return null; - return new Promise((resolve) => { - const source = ssb.messagesByType - ? ssb.messagesByType({ type: "karmaScore", reverse: true }) - : ssb.createLogStream && ssb.createLogStream({ reverse: true }); - if (!source) return resolve(null); - pull( - source, - pull.filter(msg => { - const v = msg.value || msg; - const c = v.content || {}; - return c && c.type === "karmaScore" && c.userId === userId; - }), - pull.take(1), - pull.collect((err, arr) => { - if (err || !arr || !arr.length) return resolve(null); - const v = arr[0].value || arr[0]; - const c = v.content || {}; - resolve(Number(c.karmaScore) || 0); - }) - ); - }); -} - -async function getLastPublishedTimestamp(userId) { - const ssb = await openSsb(); - if (!ssb) return new Date(0).toISOString(); - const fallback = new Date(0).toISOString(); - return new Promise((resolve) => { - const source = ssb.messagesByType - ? ssb.messagesByType({ type: "karmaScore", reverse: true }) - : ssb.createLogStream && ssb.createLogStream({ reverse: true }); - if (!source) return resolve(fallback); - pull( - source, - pull.filter(msg => { - const v = msg.value || msg; - const c = v.content || {}; - return c && c.type === "karmaScore" && c.userId === userId; - }), - pull.take(1), - pull.collect((err, arr) => { - if (err || !arr || !arr.length) return resolve(fallback); - const v = arr[0].value || arr[0]; - const c = v.content || {}; - resolve(c.timestamp || fallback); - }) - ); - }); -} - function computePoolVars(pubBal, rules) { const alphaCap = (rules.alpha ?? DEFAULT_RULES.alpha) * pubBal; const available = Math.max(0, pubBal - (rules.reserveMin ?? DEFAULT_RULES.reserveMin)); @@ -1820,20 +1430,6 @@ async function getLastPublishedTimestamp(userId) { return { points: filterByRange(all, effective).slice(-240), hasAny: all.length >= 2, range: effective }; } - async function listKarmaHistory(userId, range = "all", explicit = true) { - const ssb = await openSsb(); - if (!ssb) return { points: [], hasAny: false, range: normalizeRange(range) }; - const uid = resolveUserId(userId); - const msgs = await readTyped(ssb, ["karmaScore"], { limit: Math.max(getLogLimit(), 5000) }); - const all = msgs - .filter(m => m.value && m.value.author === uid && m.value.content && m.value.content.type === "karmaScore") - .map(m => ({ ts: Date.parse(m.value.content.timestamp || "") || Number(m.value.timestamp) || 0, score: Number(m.value.content.karmaScore) || 0 })) - .filter(x => x.ts > 0) - .sort((a, b) => a.ts - b.ts); - const effective = explicit ? normalizeRange(range) : widenRange(all, range); - return { points: filterByRange(all, effective).slice(-120), hasAny: all.length >= 2, range: effective }; - } - async function listUbiPubs() { const latestByPub = await scanUbiAnnouncements(); const trusted = await trustedPubIds(); @@ -2197,6 +1793,8 @@ async function getLastPublishedTimestamp(userId) { async function getBankingData(userId) { const ecoValue = await perRequest("ecoinValue", () => calculateEcoinValue()); const karmaScore = await getUserEngagementScore(userId); + const received = await karma.getKarmaBreakdown(userId); + const karmaReceived = { positive: received.positive, negative: received.negative }; let estimatedUBI = 0; try { const pubBal = isPubNode() ? await perRequest("pubBalance", () => safeGetBalance("pub")) : 0; @@ -2227,6 +1825,7 @@ async function getLastPublishedTimestamp(userId) { return { ecoValue, karmaScore, + karmaReceived, estimatedUBI, estimatedUBIBeforeTax: Number(estimatedUBIBeforeTax.toFixed(6)), userEcoinTax: Number(userEcoinTax.toFixed(6)), @@ -2636,6 +2235,7 @@ async function getLastPublishedTimestamp(userId) { computeEpoch, executeEpoch, getUserEngagementScore, + getKarmaReceived: async (userId) => { const r = karma.receivedBreakdownFor(String(userId || config.keys.id), await listAllActions()); return { positive: r.positive, negative: r.negative }; }, publishBankClaim, publishUbiAllocation, publishUbiClaim, diff --git a/src/models/blog_model.js b/src/models/blog_model.js index 61edcfb..c0fa690 100644 --- a/src/models/blog_model.js +++ b/src/models/blog_model.js @@ -65,6 +65,7 @@ module.exports = ({ cooler, isPublic = false }) => { text: longText.joinText(c.text || '', c.chunks, longText.lookupIn(idx.chunks), node.author), mentions: Array.isArray(c.mentions) ? c.mentions : [], allowComments: c.allowComments !== false, + tags: Array.isArray(c.tags) ? c.tags.map(t => String(t || '').trim().replace(/^#+/, '')).filter(Boolean) : [], createdAt: new Date(node.ts).toISOString(), commentCount: idx.commentsByRoot.get(node.key) || 0, opinions: op.counts, @@ -99,7 +100,8 @@ module.exports = ({ cooler, isPublic = false }) => { const q = String(opts.q || '').trim().toLowerCase(); if (q) out = out.filter(b => - b.text.toLowerCase().includes(q) || b.subject.toLowerCase().includes(q)); + b.text.toLowerCase().includes(q) || b.subject.toLowerCase().includes(q) || + b.tags.some(t => t.toLowerCase().includes(q.replace(/^#/, '')))); if (f === 'TOP') { out.sort((a, b) => (opinionScore(b) + b.commentCount) - (opinionScore(a) + a.commentCount) || @@ -118,7 +120,7 @@ module.exports = ({ cooler, isPublic = false }) => { return blog; }, - async createBlog({ text, subject = '', mentions = [], allowComments = true } = {}) { + async createBlog({ text, subject = '', mentions = [], allowComments = true, tags = [] } = {}) { if (isPublic) throw new Error('Not available in public mode'); const body = String(text || '').trim(); if (!body) throw new Error('Blog text is required'); @@ -128,6 +130,7 @@ module.exports = ({ cooler, isPublic = false }) => { type: 'post', text: body, allowComments: allowComments !== false, + ...(Array.isArray(tags) && tags.length ? { tags: Array.from(new Set(tags.map(t => String(t || '').trim().replace(/^#+/, '')).filter(Boolean))) } : {}), ...(Array.isArray(mentions) && mentions.length ? { mentions } : {}), ...(String(subject || '').trim() ? { contentWarning: String(subject).trim() } : {}) }, 'text', { publish }); diff --git a/src/models/chats_model.js b/src/models/chats_model.js index 3d06479..5f60594 100644 --- a/src/models/chats_model.js +++ b/src/models/chats_model.js @@ -367,11 +367,15 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => { if (c.type !== "chatMessage") return null let text = c.text || "" + let image = c.image || null + let mimeType = typeof c.mimeType === "string" ? c.mimeType : null if (tribeCrypto && c.encryptedText) { const candidateKeys = [...tribeKeys, ...lookupKeys(chatRootId)] for (const keyHex of candidateKeys) { try { text = tribeCrypto.decryptWithKey(c.encryptedText, keyHex) + if (c.encryptedImage) image = tribeCrypto.decryptWithKey(c.encryptedImage, keyHex) + if (c.encryptedMime) mimeType = tribeCrypto.decryptWithKey(c.encryptedMime, keyHex) break } catch (_) {} } @@ -381,8 +385,8 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => { key: node.key, chatId: c.chatId || "", text, - image: c.image || null, - mimeType: typeof c.mimeType === "string" ? c.mimeType : null, + image, + mimeType, replyTo: typeof c.replyTo === "string" ? c.replyTo : null, author: node.author, createdAt: c.createdAt || new Date(node.ts).toISOString() @@ -962,6 +966,14 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => { if (!encKey) encKey = lookupKey(chat.rootId) if (!encKey) throw new Error(`Missing chat key for ${chat.rootId} — cannot send message`) content.encryptedText = tribeCrypto.encryptWithKey(safeText(text), encKey) + if (content.image) { + content.encryptedImage = tribeCrypto.encryptWithKey(String(content.image), encKey) + delete content.image + if (content.mimeType) { + content.encryptedMime = tribeCrypto.encryptWithKey(String(content.mimeType), encKey) + delete content.mimeType + } + } if (chat.tribeId) content.tribeId = chat.tribeId } else { content.text = safeText(text) diff --git a/src/models/data_model.js b/src/models/data_model.js index fec5bc3..f51ca0b 100644 --- a/src/models/data_model.js +++ b/src/models/data_model.js @@ -40,9 +40,15 @@ const KINDS = { mailing: { type: 'mailingList', href: (id) => `/mailing/${encodeURIComponent(id)}` }, logistics: { type: 'logisticsRoute', href: (id) => `/logistics/${encodeURIComponent(id)}` }, podcasts: { type: 'podcast', href: (id) => `/podcasts/${encodeURIComponent(id)}` }, - campaigns: { type: 'campaign', href: (id) => `/campaigns/${encodeURIComponent(id)}` } + campaigns: { type: 'campaign', href: (id) => `/campaigns/${encodeURIComponent(id)}` }, + blogs: { type: 'post', href: (id) => `/blogs/${encodeURIComponent(id)}` } }; +const isRootBlogPost = (c) => + !!c && c.type === 'post' && typeof c.text === 'string' && + !c.root && !c.fork && !c.branch && !c.about && !c.target && + c.private !== true && !Array.isArray(c.recps); + const KIND_BY_TYPE = Object.fromEntries(Object.entries(KINDS).map(([k, v]) => [v.type, k])); const cvSkills = (c) => [ @@ -103,7 +109,7 @@ const opinionsTotalOf = (c) => { const excerpt = (s) => String(s || '').replace(/!?\[[^\]]*\]\([^)]*\)/g, ' ').replace(/[#*_>`~|]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 90); const titleOf = (kind, c, author) => { if (kind === 'inhabitants') return c.name || ''; - return c.title || c.name || c.question || c.concept || c.subject || excerpt(typeof c.text === 'string' ? c.text : '') || excerpt(typeof c.description === 'string' ? c.description : '') || ''; + return c.title || c.name || c.question || c.concept || c.subject || c.contentWarning || excerpt(typeof c.text === 'string' ? c.text : '') || excerpt(typeof c.description === 'string' ? c.description : '') || ''; }; const jaccard = (a, b) => { @@ -147,6 +153,7 @@ module.exports = ({ cooler, favoriteIdsFor = null }) => { continue; } if (c.tribeId && c.type !== 'tribe') continue; + if (c.type === 'post' && !isRootBlogPost(c)) continue; const kind = KIND_BY_TYPE[c.type]; if (!kind) continue; versions.push(versionEntry(m)); @@ -360,12 +367,20 @@ module.exports = ({ cooler, favoriteIdsFor = null }) => { out.push({ ...strip(n), score, common, connections: common.length, reasons }); } + if (opts.hidden && (opts.hidden.authors || opts.hidden.ids)) { + const ha = opts.hidden.authors instanceof Set ? opts.hidden.authors : new Set(); + const hi = opts.hidden.ids instanceof Set ? opts.hidden.ids : new Set(); + out = out.filter(s => !ha.has(String(s.author)) && !hi.has(String(s.id))); + } const anyMatches = out.length > 0; const kindsAvail = {}; for (const n of use) { if (String(n.author) !== String(viewerId)) kindsAvail[n.kind] = true; } + const kindsMatched = {}; + for (const s of out) kindsMatched[s.kind] = true; + if (f === 'RECENT') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = out.filter(s => s.ts >= cutoff); @@ -393,6 +408,7 @@ module.exports = ({ cooler, favoriteIdsFor = null }) => { hasProfile: myTermSet.size > 0, myTerms: [...myTermSet], kindsAvail, + kindsMatched, anyMatches, reasonsAvail, reason: reasonsAvail.includes(reason) ? reason : '' diff --git a/src/models/karma_model.js b/src/models/karma_model.js new file mode 100644 index 0000000..c500986 --- /dev/null +++ b/src/models/karma_model.js @@ -0,0 +1,591 @@ +const pull = require("../server/node_modules/pull-stream"); +const { readTyped, CONTENT_TYPES } = require("./typed_log"); +const { getConfig } = require("../configs/config-manager.js"); +const { config } = require("../server/SSB_server.js"); + +function getLogLimit() { + return getConfig().ssbLogStream?.limit || 1000; +} + +const OWN_SCORE_CAP = 200; +const RECEIVED_AUTHOR_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000; +const opinionCategories = require("../backend/opinion_categories"); +const CONSTRUCTIVE_OPINIONS = new Set(opinionCategories.karma.constructive); +const MODERATION_OPINIONS = new Set(opinionCategories.karma.moderation); +const NEUTRAL_OPINIONS = new Set(opinionCategories.karma.neutral); +const NEGATIVE_MIN_VOTERS = 3; +const KARMA_PERIOD_MS = 30 * 24 * 60 * 60 * 1000; +const NEGATIVE_PATTERN_MIN_VOTERS = 5; +const NEGATIVE_PATTERN_POINTS = 2; +const POSITIVE_PER_VOTER_CAP = 10; +const TRUST_MIN_FOLLOWERS = 2; +const REACTIONS_PER_MESSAGE_CAP = 10; +const PLAYS_PER_EPISODE_CAP = 20; +const ACTIONS_CACHE_TTL_MS = 60 * 1000; + +function resolveUserId(maybeId) { + const s = String(maybeId || "").trim(); + if (s) return s; + return config?.keys?.id || ""; +} + +function idsEqual(a, b) { + if (!a || !b) return false; + const A = String(a).trim(); + const B = String(b).trim(); + if (A === B) return true; + const strip = s => s.replace(/^@/, "").replace(/\.ed25519$/, ""); + return strip(A) === strip(B); +} + + function inferType(c = {}) { + if (c.vote) return "vote"; + if (c.votes) return "votes"; + if (c.address && c.coin === "ECO" && c.type === "wallet") return "bankWallet"; + if (c.type === "ubiClaimResult" && c.txid && c.epochId) return "ubiClaimResult"; + if (typeof c.amount !== "undefined" && c.epochId && c.allocationId) return "bankClaim"; + if (typeof c.item_type !== "undefined" && typeof c.status !== "undefined") return "market"; + if (typeof c.goal !== "undefined" && typeof c.progress !== "undefined") return "project"; + if (typeof c.members !== "undefined" && typeof c.isAnonymous !== "undefined") return "tribe"; + if (typeof c.date !== "undefined" && typeof c.location !== "undefined") return "event"; + if (typeof c.priority !== "undefined" && typeof c.status !== "undefined" && c.title) return "task"; + if (typeof c.confirmations !== "undefined" && typeof c.severity !== "undefined") return "report"; + if (typeof c.job_type !== "undefined" && typeof c.status !== "undefined") return "job"; + if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "audio") return "audio"; + if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "video") return "video"; + if (typeof c.url !== "undefined" && c.title && c.key) return "document"; + if (typeof c.text !== "undefined" && typeof c.refeeds !== "undefined") return "feed"; + if (typeof c.text !== "undefined" && typeof c.contentWarning !== "undefined") return "post"; + if (typeof c.contact !== "undefined") return "contact"; + if (typeof c.about !== "undefined") return "about"; + if (typeof c.concept !== "undefined" && typeof c.amount !== "undefined" && c.status) return "transfer"; + return ""; + } + + function normalizeType(a) { + const t = a.type || a.content?.type || inferType(a.content) || ""; + return String(t).toLowerCase(); + } + + function priorityBump(p) { + const s = String(p || "").toUpperCase(); + if (s === "HIGH") return 3; + if (s === "MEDIUM") return 1; + return 0; + } + + function severityBump(s) { + const x = String(s || "").toUpperCase(); + if (x === "CRITICAL") return 6; + if (x === "HIGH") return 4; + if (x === "MEDIUM") return 2; + return 0; + } + + function scoreMarket(c) { + const st = String(c.status || "").toUpperCase(); + let s = 5; + if (st === "SOLD") s += 8; + else if (st === "ACTIVE") s += 3; + const bids = Array.isArray(c.auctions_poll) ? c.auctions_poll.length : 0; + s += Math.min(10, bids); + return s; + } + + function scoreProject(c) { + const st = String(c.status || "ACTIVE").toUpperCase(); + const prog = Number(c.progress || 0); + let s = 8 + Math.min(10, prog / 10); + if (st === "FUNDED") s += 10; + return s; + } + + function calculateOpinionScore(content) { + const cats = content?.opinions || {}; + let s = 0; + for (const k in cats) { + if (!Object.prototype.hasOwnProperty.call(cats, k)) continue; + if (NEUTRAL_OPINIONS.has(k)) continue; + if (k === "interesting" || k === "inspiring") s += 5; + else if (MODERATION_OPINIONS.has(k)) s -= 5; + else if (CONSTRUCTIVE_OPINIONS.has(k)) s -= 3; + else s += 1; + } + return s; + } + +function basePointsFor(action) { + const t = normalizeType(action); + const c = action.content || {}; + const rawType = String(c.type || "").toLowerCase(); + if (t === "post") return 10; + else if (t === "comment") return 5; + else if (t === "like") return 2; + else if (t === "image") return 8; + else if (t === "video") return 12; + else if (t === "audio") return 8; + else if (t === "document") return 6; + else if (t === "bookmark") return 2; + else if (t === "feed") return 6; + else if (t === "forum") return (c.root ? 5 : 10); + else if (t === "vote") return (3 + calculateOpinionScore(c)); + else if (t === "votes") return Math.min(10, Number(c.totalVotes || 0)); + else if (t === "market") return scoreMarket(c); + else if (t === "project") return scoreProject(c); + else if (t === "tribe") return (6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0)); + else if (t === "event") return (4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0)); + else if (t === "task") return (3 + priorityBump(c.priority)); + else if (t === "report") return (4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity)); + else if (t === "curriculum") return 5; + else if (t === "wikipage") return (c.replaces ? 2 : 6); + else if (t === "emergency") return (c.replaces ? 1 : 5); + else if (t === "emergencyconfirm") return 1; + else if (t === "emergencyupdate") return 2; + else if (t === "mailinglist") return (c.replaces ? 1 : 3); + else if (t === "logisticsroute") return (c.replaces ? 1 : 4); + else if (t === "logisticsrating") return 1; + else if (t === "podcast") return (c.replaces ? 1 : 5); + else if (t === "podcastepisode") return (c.replaces ? 1 : 4); + else if (t === "podcastopinion" || t === "podcastplay" || t === "campaignopinion" || t === "logisticsopinion") return 1; + else if (t === "campaign") return (c.replaces ? 1 : 5); + else if (t === "campaignsignature") return 1; + else if (t === "campaignupdate") return 2; + else if (t === "aiexchange") return (Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0); + else if (t === "job") return (4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0)); + else if (t === "transfer") return 1; + else if (t === "about") return (1 + ((c.visibilityPrefs && c.visibilityPrefs.fediverseHandle) ? 8 : 0)); + else if (t === "contact") return 1; + else if (t === "pub") return 1; + else if (t === "parliamentcandidature" || rawType === "parliamentcandidature") return 12; + else if (t === "parliamentterm" || rawType === "parliamentterm") return 25; + else if (t === "parliamentproposal" || rawType === "parliamentproposal") return 8; + else if (t === "parliamentlaw" || rawType === "parliamentlaw") return 16; + else if (t === "parliamentrevocation" || rawType === "parliamentrevocation") return 10; + else if (t === "courts_case" || t === "courtscase" || rawType === "courts_case") return 4; + else if (t === "courts_evidence" || t === "courtsevidence" || rawType === "courts_evidence") return 3; + else if (t === "courts_answer" || t === "courtsanswer" || rawType === "courts_answer") return 4; + else if (t === "courts_verdict" || t === "courtsverdict" || rawType === "courts_verdict") return 10; + else if (t === "courts_settlement" || t === "courtssettlement" || rawType === "courts_settlement") return 8; + else if (t === "courts_nomination" || t === "courtsnomination" || rawType === "courts_nomination") return 6; + else if (t === "courts_nom_vote" || t === "courtsnomvote" || rawType === "courts_nom_vote") return 3; + else if (t === "courts_public_pref" || t === "courtspublicpref" || rawType === "courts_public_pref") return 1; + else if (t === "courts_mediators" || t === "courtsmediators" || rawType === "courts_mediators") return 6; + else if (t === "courts_open_support" || t === "courtsopensupport" || rawType === "courts_open_support") return 2; + else if (t === "courts_verdict_vote" || t === "courtsverdictvote" || rawType === "courts_verdict_vote") return 3; + else if (t === "courts_judge_assign" || t === "courtsjudgeassign" || rawType === "courts_judge_assign") return 5; + else if (t === "larpjoinhouse") return 15; + else if (t === "larphousepost") return 6; + else if (t === "larptestattempt") return 3; + else if (t === "torrent") return 6; + else if (t === "file") return 6; + else if (t === "shop" || t === "shopproduct") return 6; + else if (t === "shop-purchase") return 2; + else if (t === "pad" || t === "padentry") return 3; + else if (t === "room") return 3; + else if (t === "roommember") return 1; + else if (t === "calendar" || t === "calendarnote" || t === "calendardate") return 3; + else if (t === "chat") return 1; + else if (t === "gamescore") return 2; + else if (t === "pixelia") return 2; + else if (rawType === "industry") return 8; + else if (rawType === "industryblueprint") return 6; + else if (rawType === "industrybuild") return 8; + else if (rawType === "industrycontribution") return 4; + else if (rawType === "industryallocation") return 10; + else if (rawType === "industryvote") return 2; + else if (rawType === "industrymember") return 2; + else if (rawType === "industryopinion") return 1; + else if (rawType === "schoolcourse") return 10; + else if (rawType === "schoollesson") return 6; + else if (rawType === "schoolenroll") return 3; + else if (rawType === "schoolcertificate") return 12; + else if (rawType === "schoolexam") return 6; + else if (rawType === "schoolprogress") return 1; + else if (rawType === "schoolopinion") return 2; + return 0; +} + +function decayFor(ts, nowMs) { + const ageDays = ts ? (nowMs - ts) / 86400000 : Infinity; + return ageDays <= 30 ? 1.0 : ageDays <= 90 ? 0.5 : 0.25; +} + +function scoreFromActions(actions) { + let score = 0; + const nowMs = Date.now(); + const perTypeDay = new Map(); + for (const action of actions) { + const pts = basePointsFor(action); + if (!pts) continue; + const ts = action.value?.timestamp; + const dayKey = `${normalizeType(action)}:${ts ? new Date(ts).toISOString().slice(0, 10) : "?"}`; + const n = (perTypeDay.get(dayKey) || 0) + 1; + perTypeDay.set(dayKey, n); + score += pts * decayFor(ts, nowMs) / n; + } + return Math.max(0, Math.round(Math.min(OWN_SCORE_CAP, score))); +} + +function receivedBreakdownFor(userId, actions) { + const nowMs = Date.now(); + const mine = new Set(); + const firstTs = new Map(); + const follows = new Map(); + for (const a of actions) { + if (!a.author) continue; + const ts = Number(a.receivedAt) || 0; + if (ts && (!firstTs.has(a.author) || ts < firstTs.get(a.author))) firstTs.set(a.author, ts); + if (a.author === userId && a.id) mine.add(a.id); + const c = a.content || {}; + if (c.type === "contact" && typeof c.contact === "string" && c.contact !== a.author) { + const k = `${a.author}|${c.contact}`; + const at = Number(a.value?.timestamp) || ts; + const prev = follows.get(k); + if (!prev || at >= prev.at) follows.set(k, { on: c.following === true && c.blocking !== true, at }); + } + } + const followersOf = new Map(); + for (const [k, f] of follows) { + if (!f.on) continue; + const [from, to] = k.split("|"); + if (!followersOf.has(to)) followersOf.set(to, new Set()); + followersOf.get(to).add(from); + } + const known = (id) => firstTs.has(id) && nowMs - firstTs.get(id) >= RECEIVED_AUTHOR_MIN_AGE_MS; + const trusted = (voter, sameSide) => { + let n = 0; + for (const f of followersOf.get(voter) || []) { + if (f === userId || sameSide.has(f) || !known(f)) continue; + if (++n >= TRUST_MIN_FOLLOWERS) return true; + } + return false; + }; + const seen = new Set(); + const perTarget = new Map(); + const negatives = new Map(); + const votes = new Map(); + const reactions = new Map(); + const credits = []; + let positive = 0; + let negative = 0; + const add = (author, kind, target, pts, ts, capPerTarget = Infinity) => { + const k = `${kind}:${author}:${target}`; + if (seen.has(k)) return; + const n = perTarget.get(`${kind}:${target}`) || 0; + if (n >= capPerTarget) return; + seen.add(k); + perTarget.set(`${kind}:${target}`, n + 1); + credits.push({ voter: author, pts, ts: Number(ts) || 0 }); + }; + const addNegative = (author, target, pts, ts) => { + const k = `neg:${author}:${target}`; + if (seen.has(k)) return; + seen.add(k); + if (!negatives.has(target)) negatives.set(target, []); + negatives.get(target).push({ voter: author, pts, ts: Number(ts) || 0 }); + }; + for (const a of actions) { + const b = a.author; + if (!b || b === userId) continue; + if (!firstTs.has(b) || nowMs - firstTs.get(b) < RECEIVED_AUTHOR_MIN_AGE_MS) continue; + const c = a.content || {}; + const type = String(c.type || ""); + const ts = a.value?.timestamp; + if (/Opinion$/.test(type) && type !== "pollOpinion" && mine.has(c.target)) { + const cat = String(c.category || ""); + if (NEUTRAL_OPINIONS.has(cat)) continue; + if (MODERATION_OPINIONS.has(cat)) addNegative(b, c.target, -5, ts); + else if (CONSTRUCTIVE_OPINIONS.has(cat)) addNegative(b, c.target, -3, ts); + else add(b, "opinion", c.target, 3, ts); + } + else if (type === "vote" && c.vote && mine.has(c.vote.link)) { + const k = `${b}:${c.vote.link}`; + const prev = votes.get(k); + if (!prev || (Number(ts) || 0) >= prev.ts) votes.set(k, { author: b, target: c.vote.link, value: Number(c.vote.value) || 0, ts: Number(ts) || 0 }); + } + else if (type === "feed-action" && c.action === "vote" && mine.has(c.root || c.target)) add(b, "opinion", c.root || c.target, 3, ts); + else if (type === "feed-action" && c.action === "comment" && mine.has(c.root)) add(b, "comment", c.root, 2, ts, 10); + else if (type === "feed-action" && c.action === "refeed" && mine.has(c.root)) add(b, "refeed", c.root, 2, ts, 10); + else if (type === "post" && c.root && mine.has(c.root)) add(b, "comment", c.root, 2, ts, 10); + else if (type === "contact" && c.following === true && c.contact === userId) add(b, "follow", userId, 2, ts); + else if (type === "chatReaction" && mine.has(c.target)) { + const k = `${b}:${c.target}:${c.emoji}`; + const prev = reactions.get(k); + if (!prev || (Number(ts) || 0) >= prev.ts) reactions.set(k, { author: b, target: c.target, emoji: c.emoji, on: c.on !== false, ts: Number(ts) || 0 }); + } + else if (type === "podcastPlay" && mine.has(c.target)) add(b, "play", c.target, 1, ts, PLAYS_PER_EPISODE_CAP); + else if (type === "marketPurchase" && mine.has(c.target)) add(b, "purchase", c.target, 5, ts); + else if (type === "shop-purchase" && c.seller === userId) add(b, "purchase", c.productId || a.id, 5, ts); + else if (type === "schoolEnroll" && c.value === true && mine.has(c.courseId)) add(b, "enroll", c.courseId, 5, ts); + else if (type === "transferConfirm" && mine.has(c.target)) add(b, "confirm", c.target, 3, ts); + } + for (const r of reactions.values()) { + if (!r.on) continue; + if (r.emoji === "down") addNegative(r.author, r.target, -3, r.ts); + else add(r.author, "reaction", r.target, 1, r.ts, REACTIONS_PER_MESSAGE_CAP); + } + for (const v of votes.values()) { + if (v.value > 0) add(v.author, "opinion", v.target, 3, v.ts); + else if (v.value < 0) addNegative(v.author, v.target, -3, v.ts); + } + const vouched = new Map(); + const isTrusted = (voter, side, cache) => { + if (!cache.has(voter)) cache.set(voter, trusted(voter, side)); + return cache.get(voter); + }; + const usedByVoter = new Map(); + for (const c of credits.sort((x, y) => x.ts - y.ts)) { + if (!isTrusted(c.voter, new Set(), vouched)) continue; + const k = `${c.voter}:${Math.floor(c.ts / KARMA_PERIOD_MS)}`; + const used = usedByVoter.get(k) || 0; + const take = Math.min(c.pts, POSITIVE_PER_VOTER_CAP - used); + if (take <= 0) continue; + usedByVoter.set(k, used + take); + positive += take * decayFor(c.ts, nowMs); + } + const blaming = new Set(); + for (const list of negatives.values()) for (const n of list) blaming.add(n.voter); + const vouchedAgainst = new Map(); + const pattern = new Map(); + for (const all of negatives.values()) { + const list = all.filter(n => isTrusted(n.voter, blaming, vouchedAgainst)); + if (list.length >= NEGATIVE_MIN_VOTERS) { + for (const n of list) negative += Math.abs(n.pts) * decayFor(n.ts, nowMs); + continue; + } + for (const n of list) { + if (!n.ts || nowMs - n.ts > KARMA_PERIOD_MS) continue; + if ((pattern.get(n.voter) || 0) < n.ts) pattern.set(n.voter, n.ts); + } + } + if (pattern.size >= NEGATIVE_PATTERN_MIN_VOTERS) { + for (const ts of pattern.values()) negative += NEGATIVE_PATTERN_POINTS * decayFor(ts, nowMs); + } + positive = Math.round(positive); + negative = Math.round(negative); + return { score: positive - negative, positive, negative }; +} + +function receivedScoreFor(userId, actions) { + return receivedBreakdownFor(userId, actions).score; +} + +module.exports = ({ services, ranges } = {}) => { + const normalizeRange = ranges && ranges.normalizeRange ? ranges.normalizeRange : (r) => String(r || "all"); + const widenRange = ranges && ranges.widenRange ? ranges.widenRange : (arr, r) => normalizeRange(r); + const filterByRange = ranges && ranges.filterByRange ? ranges.filterByRange : (arr) => arr; + + let ssbInstance; + async function openSsb() { + if (ssbInstance) return ssbInstance; + if (services?.cooler?.open) ssbInstance = await services.cooler.open(); + else if (global.ssb) ssbInstance = global.ssb; + else { + try { + const srv = require("../server/SSB_server.js"); + ssbInstance = srv?.ssb || srv?.server || srv?.default || null; + } catch (_) { + ssbInstance = null; + } + } + return ssbInstance; + } + + let actionsCache = null; + async function listAllActions() { + const ssb = await openSsb(); + if (!ssb) { + return []; + } + const ownSeq = await ownFeedSeq(ssb); + if (actionsCache && actionsCache.ownSeq === ownSeq && Date.now() - actionsCache.ts < ACTIONS_CACHE_TTL_MS) return actionsCache.value; + const msgs = await readTyped(ssb, CONTENT_TYPES, { limit: Math.max(getLogLimit(), 20000) }); + const value = msgs.map(m => { + const v = m.value || {}; + const c = v.content || {}; + return { + id: v.key || m.key, + author: v.author, + type: (c.type || "").toLowerCase(), + value: v, + content: c, + receivedAt: Number(m.timestamp) || 0 + }; + }); + actionsCache = { ts: Date.now(), ownSeq, value }; + return value; + } + +async function ownFeedSeq(ssb) { + return new Promise((res) => { + try { + pull( + ssb.createUserStream({ id: ssb.id, reverse: true, limit: 1 }), + pull.collect((err, msgs) => res(err || !msgs || !msgs.length ? 0 : (msgs[0].value && msgs[0].value.sequence) || 0)) + ); + } catch (_) { res(0); } + }); +} + +async function publishKarmaScore(userId, karmaScore) { + const ssb = await openSsb(); + if (!ssb || !ssb.publish) return false; + if (!(await ownFeedSeq(ssb))) return false; + const timestamp = new Date().toISOString(); + const content = { type: "karmaScore", karmaScore, userId, timestamp }; + return new Promise((resolve, reject) => { + ssb.publish(content, (err, msg) => err ? reject(err) : resolve(msg)); + }); +} + +async function fetchUserActions(userId) { + const me = resolveUserId(userId); + const actions = await listAllActions(); + const authored = actions.filter(a => + (a.author && a.author === me) || (a.value?.author && a.value.author === me) + ); + if (authored.length) return authored; + return actions.filter(a => { + const c = a.content || {}; + const fields = [c.author, c.organizer, c.seller, c.about, c.contact]; + return fields.some(f => f && f === me); + }); +} + +async function getCarbonGramsForUser(userId) { + const ssb = await openSsb(); + if (!ssb || !userId) return 0; + return new Promise((resolve) => { + let bytes = 0; + pull( + ssb.createUserStream({ id: userId }), + pull.drain( + (m) => { try { bytes += Buffer.byteLength(JSON.stringify(m && m.value), 'utf8'); } catch (_) {} }, + () => resolve((bytes / (1024 * 1024)) * 0.095) + ) + ); + }); +} + +async function getUserEngagementScore(userId) { + const ssb = await openSsb(); + const uid = resolveUserId(userId); + const actions = await fetchUserActions(uid); + const all = await listAllActions(); + const rawKarma = scoreFromActions(actions) + receivedScoreFor(uid, all); + const carbonGrams = await getCarbonGramsForUser(uid).catch(() => 0); + const karmaScore = Math.max(0, Math.round(rawKarma - carbonGrams)); + + const prev = await getLastKarmaScore(uid); + const lastPublishedTimestamp = await getLastPublishedTimestamp(uid); + + const isSelf = idsEqual(uid, ssb.id); + const hasSSB = !!(ssb && ssb.publish); + + const changed = (prev === null) || (karmaScore !== prev); + const nowMs = Date.now(); + const lastMs = lastPublishedTimestamp ? new Date(lastPublishedTimestamp).getTime() : 0; + const cooldownOk = (nowMs - lastMs) >= 24 * 60 * 60 * 1000; + + if (isSelf && hasSSB && changed && cooldownOk) { + await publishKarmaScore(uid, karmaScore); + } + return karmaScore; +} + +async function getLastKarmaScore(userId) { + const ssb = await openSsb(); + if (!ssb) return null; + return new Promise((resolve) => { + const source = ssb.messagesByType + ? ssb.messagesByType({ type: "karmaScore", reverse: true }) + : ssb.createLogStream && ssb.createLogStream({ reverse: true }); + if (!source) return resolve(null); + pull( + source, + pull.filter(msg => { + const v = msg.value || msg; + const c = v.content || {}; + return c && c.type === "karmaScore" && c.userId === userId; + }), + pull.take(1), + pull.collect((err, arr) => { + if (err || !arr || !arr.length) return resolve(null); + const v = arr[0].value || arr[0]; + const c = v.content || {}; + resolve(Number(c.karmaScore) || 0); + }) + ); + }); +} + +async function getLastPublishedTimestamp(userId) { + const ssb = await openSsb(); + if (!ssb) return new Date(0).toISOString(); + const fallback = new Date(0).toISOString(); + return new Promise((resolve) => { + const source = ssb.messagesByType + ? ssb.messagesByType({ type: "karmaScore", reverse: true }) + : ssb.createLogStream && ssb.createLogStream({ reverse: true }); + if (!source) return resolve(fallback); + pull( + source, + pull.filter(msg => { + const v = msg.value || msg; + const c = v.content || {}; + return c && c.type === "karmaScore" && c.userId === userId; + }), + pull.take(1), + pull.collect((err, arr) => { + if (err || !arr || !arr.length) return resolve(fallback); + const v = arr[0].value || arr[0]; + const c = v.content || {}; + resolve(c.timestamp || fallback); + }) + ); + }); +} + + async function getKarmaBreakdown(userId) { + const uid = resolveUserId(userId); + const received = receivedBreakdownFor(uid, await listAllActions()); + return { karmaScore: await getUserEngagementScore(uid), positive: received.positive, negative: received.negative }; + } + + async function listKarmaHistory(userId, range = "all", explicit = true) { + const ssb = await openSsb(); + if (!ssb) return { points: [], hasAny: false, range: normalizeRange(range) }; + const uid = resolveUserId(userId); + const msgs = await readTyped(ssb, ["karmaScore"], { limit: Math.max(getLogLimit(), 5000) }); + const all = msgs + .filter(m => m.value && m.value.author === uid && m.value.content && m.value.content.type === "karmaScore") + .map(m => ({ ts: Date.parse(m.value.content.timestamp || "") || Number(m.value.timestamp) || 0, score: Number(m.value.content.karmaScore) || 0 })) + .filter(x => x.ts > 0) + .sort((a, b) => a.ts - b.ts); + const effective = explicit ? normalizeRange(range) : widenRange(all, range); + return { points: filterByRange(all, effective).slice(-120), hasAny: all.length >= 2, range: effective }; + } + + return { + listAllActions, + fetchUserActions, + scoreFromActions, + receivedBreakdownFor, + receivedScoreFor, + getCarbonGramsForUser, + getUserEngagementScore, + getKarmaBreakdown, + getLastKarmaScore, + getLastPublishedTimestamp, + publishKarmaScore, + listKarmaHistory, + basePointsFor, + decayFor, + NEGATIVE_MIN_VOTERS, + NEGATIVE_PATTERN_MIN_VOTERS, + NEGATIVE_PATTERN_POINTS, + POSITIVE_PER_VOTER_CAP, + KARMA_PERIOD_MS, + TRUST_MIN_FOLLOWERS + }; +}; diff --git a/src/models/main_models.js b/src/models/main_models.js index f74df52..477692c 100644 --- a/src/models/main_models.js +++ b/src/models/main_models.js @@ -624,6 +624,12 @@ models.blob = { }; models.friend = { + blockedIds: async () => { + const ssb = await cooler.open(); + const graph = await new Promise((resolve) => ssb.friends.graph((err, g) => resolve(err || !g ? {} : g))); + const mine = graph[ssb.id] || {}; + return new Set(Object.keys(mine).filter(feed => Math.round(mine[feed]) === -1)); + }, setRelationship: async ({ feedId, following, blocking }) => { if (following && blocking) { throw new Error("Cannot follow and block at the same time"); diff --git a/src/models/opinions_model.js b/src/models/opinions_model.js index a4a801a..90e7236 100644 --- a/src/models/opinions_model.js +++ b/src/models/opinions_model.js @@ -3,6 +3,12 @@ const { getConfig } = require('../configs/config-manager.js'); const categories = require('../backend/opinion_categories'); const { buildValidatedTombstoneSet } = require('./tombstone_validator'); const { readTyped, CONTENT_TYPES, requestScope } = require('./typed_log'); +const longText = require('../backend/long_text'); +const isRootBlogPost = (c) => + !!c && c.type === 'post' && typeof c.text === 'string' && + !c.root && !c.fork && !c.branch && !c.about && !c.target && + c.private !== true && !Array.isArray(c.recps); + const { buildVoteTally } = require('../backend/vote_tally'); const { versionGroups, versionEntry } = require('../backend/dedupe'); const logLimit = getConfig().ssbLogStream?.limit || 1000; @@ -24,13 +30,14 @@ module.exports = ({ cooler }) => { const validTypes = [ 'bookmark', 'votes', 'transfer', - 'feed', 'image', 'audio', 'video', 'document', 'torrent', 'file', + 'feed', 'post', 'image', 'audio', 'video', 'document', 'torrent', 'file', 'industry', 'project', 'report', 'task', 'event', 'shopProduct', 'housing', 'market', 'schoolCourse', 'podcast', 'podcastEpisode', 'campaign', 'logisticsRoute' ]; const getPreview = c => { if (c.type === 'bookmark' && c.bookmark) return `🔖 ${c.bookmark}`; + if (c.type === 'post') return c.contentWarning || c.text || ''; return c.text || c.description || c.title || c.name || ''; }; @@ -83,6 +90,18 @@ module.exports = ({ cooler }) => { if (c.type === 'feed' && !byId.has(key)) { byId.set(key, { key, value: { ...msg.value, content: c, preview: getPreview(c) } }); } + if (isRootBlogPost(c) && !byId.has(key)) { + byId.set(key, { key, value: { ...msg.value, content: c, preview: getPreview(c) } }); + } + } + + const chunkLookup = longText.lookupIn(longText.indexChunks(messages)); + for (const [key, m] of Array.from(byId.entries())) { + const c = m.value?.content; + if (c?.type === 'post' && longText.hasChunks(c)) { + const content = longText.resolveField(c, 'text', m.value.author, chunkLookup); + byId.set(key, { key, value: { ...m.value, content, preview: getPreview(content) } }); + } } const vg = versionGroups(Array.from(byId.values()).map(versionEntry)); diff --git a/src/models/peer_health.js b/src/models/peer_health.js index 48fda93..e7a75b3 100644 --- a/src/models/peer_health.js +++ b/src/models/peer_health.js @@ -54,6 +54,8 @@ const isDead = ({ key, failures = 0, now = Date.now() } = {}) => { return Math.max(observed, Number(failures) * RETRY_CAP_MS) >= DEAD_AFTER_MS; }; +const failingSince = (key) => Number((load()[canonicalKey(key)] || {}).failingSince) || 0; + const addressOf = (item) => { const m = Array.isArray(item) ? String(item[0] || '').match(/^(?:net|onion):([^:~]+):(\d+)/) : null; if (m) return `${m[1]}:${m[2]}`; @@ -100,4 +102,4 @@ const NET_REASON_KEYS = { notfound: 'peerErrNotFound', unreachable: 'peerErrUnreachable', timeout: 'peerErrTimeout', dropped: 'peerErrDropped', other: 'peerErrOther', silent: 'peerErrSilent' }; -module.exports = { canonicalKey, observe, isDead, failuresByKey, classifyNetError, NET_REASON_KEYS, DEAD_AFTER_MS }; +module.exports = { canonicalKey, observe, isDead, failingSince, failuresByKey, classifyNetError, NET_REASON_KEYS, DEAD_AFTER_MS }; diff --git a/src/models/pixelia_model.js b/src/models/pixelia_model.js index 245cc18..04c91ff 100644 --- a/src/models/pixelia_model.js +++ b/src/models/pixelia_model.js @@ -27,18 +27,41 @@ const resolvePixels = (messages) => { } const replaced = new Set(); - for (const { m, coordinateKey } of byKey.values()) { + const parentOf = (k) => { + const { m, coordinateKey } = byKey.get(k); const prev = byKey.get(m.value.content.replaces); - if (prev && prev.coordinateKey === coordinateKey) replaced.add(prev.m.key); + return prev && prev.coordinateKey === coordinateKey ? prev.m.key : null; + }; + for (const k of byKey.keys()) { + const parent = parentOf(k); + if (parent) replaced.add(parent); } + const depthOf = new Map(); + const depth = (key) => { + const chain = []; + const inChain = new Set(); + let k = key; + while (k && !depthOf.has(k) && !inChain.has(k)) { + chain.push(k); + inChain.add(k); + k = parentOf(k); + } + let d = k && depthOf.has(k) ? depthOf.get(k) + 1 : 0; + for (let i = chain.length - 1; i >= 0; i--) depthOf.set(chain[i], d++); + return depthOf.get(key); + }; + const latest = new Map(); for (const [k, { m, coordinateKey }] of byKey) { if (replaced.has(k) || tombstoned.has(k)) continue; const cur = latest.get(coordinateKey); + if (!cur) { latest.set(coordinateKey, m); continue; } + const d = depth(k); + const curD = depth(cur.key); const ts = m.value.timestamp || 0; - const curTs = cur ? cur.value.timestamp || 0 : 0; - if (!cur || ts > curTs || (ts === curTs && k > cur.key)) latest.set(coordinateKey, m); + const curTs = cur.value.timestamp || 0; + if (d > curD || (d === curD && (ts > curTs || (ts === curTs && k > cur.key)))) latest.set(coordinateKey, m); } return latest; diff --git a/src/models/rooms_model.js b/src/models/rooms_model.js index f72abef..2de9adb 100644 --- a/src/models/rooms_model.js +++ b/src/models/rooms_model.js @@ -24,7 +24,8 @@ const normalizeTags = (raw) => { return String(raw).split(",").map(t => t.trim()).filter(Boolean) } const normalizeStatus = (v) => (STATUSES.includes(String(v || "").toUpperCase()) ? String(v).toUpperCase() : "OPEN") -const blobRef = (v) => (BLOB_ID.test(safeText(v)) ? safeText(v) : "") +const SEALED_REF = /^\/blob\/sealed\/%26[A-Za-z0-9%]+\.sha256\?k=[0-9a-f]{64}&t=[^\s"<>]*$/ +const blobRef = (v) => (BLOB_ID.test(safeText(v)) || SEALED_REF.test(safeText(v)) ? safeText(v) : "") const asP = (fn, ...args) => new Promise((resolve, reject) => fn(...args, (err, v) => err ? reject(err) : resolve(v))) const rpcValue = (fn, ...args) => new Promise((resolve) => { try { @@ -352,6 +353,8 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => { return { title: "", description: "", image: "", tags: [], rid: "", _undec: true } } + const roomTypeOf = (c) => c.status === "INVITE-ONLY" || (c.status === "CLOSED" && c.encrypted === true && !c.tribeId) ? "INVITE-ONLY" : "OPEN" + const buildRoom = (node, rootId, tribeKeys, members) => { const c = node.c || {} if (c.type !== "room") return null @@ -368,7 +371,7 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => { image: f.image, tags: f.tags, status: ["OPEN", "INVITE-ONLY", "CLOSED"].includes(c.status) ? c.status : "OPEN", - type: c.status === "INVITE-ONLY" || (c.status === "CLOSED" && c.encrypted === true && !c.tribeId) ? "INVITE-ONLY" : "OPEN", + type: roomTypeOf(c), author, members: Array.isArray(members) ? members : (Array.isArray(c.members) ? c.members : []), invites: Array.isArray(c.invites) ? c.invites : [], @@ -555,6 +558,10 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => { return republish(id, (c) => ({ ...c, status: "CLOSED" })) }, + async reopenRoomById(id) { + return republish(id, (c) => ({ ...c, status: roomTypeOf(c) })) + }, + async setRoomLine(id, line) { return republish(id, (c) => ({ ...c, line: line === "SWITCHBOARD" ? "SWITCHBOARD" : "DND" })) }, @@ -782,6 +789,38 @@ module.exports = ({ cooler, tribeCrypto, roomCrypto, tribesModel }) => { return asP(ph.camera, !!flag) }, + async stream(opts) { + const ph = await phone() + if (!ph || typeof ph.stream !== "function") return null + return asP(ph.stream, opts || {}) + }, + + async viewerJoin(room) { + const ph = await phone() + if (!ph || typeof ph.viewerJoin !== "function") return null + const params = await this.liveParams(room) + if (!params || params.secrets) return null + return asP(ph.viewerJoin, params) + }, + + async viewerLeave(rid) { + const ph = await phone() + if (!ph || typeof ph.viewerLeave !== "function") return null + return asP(ph.viewerLeave, { rid: String(rid || "") }) + }, + + async viewerRooms() { + const ph = await phone() + if (!ph || typeof ph.viewerRooms !== "function") return [] + const list = await rpcValue(ph.viewerRooms) + return Array.isArray(list) ? list : [] + }, + + async publicFeed(rid, kind) { + const ph = await phone() + return ph && typeof ph.publicFeed === "function" ? ph.publicFeed({ rid: String(rid || ""), kind: kind === "sound" ? "sound" : "grid" }) : null + }, + async recStart() { const ph = await phone() if (!ph || typeof ph.roomRecStart !== "function") return null diff --git a/src/models/stats_model.js b/src/models/stats_model.js index ef20e57..fed5fa6 100644 --- a/src/models/stats_model.js +++ b/src/models/stats_model.js @@ -6,6 +6,10 @@ const { getConfig } = require('../configs/config-manager.js'); const { buildValidatedTombstoneSet } = require('./tombstone_validator'); const { readTyped, CONTENT_TYPES } = require('./typed_log'); const { versionGroups, versionEntry } = require('../backend/dedupe'); +const opinionCategories = require('../backend/opinion_categories'); +const CONSTRUCTIVE_OPINIONS = new Set(opinionCategories.karma.constructive); +const MODERATION_OPINIONS = new Set(opinionCategories.karma.moderation); +const NEUTRAL_OPINIONS = new Set(opinionCategories.karma.neutral); const logLimit = getConfig().ssbLogStream?.limit || 1000; const DISK_USAGE_TTL_MS = 10 * 60 * 1000; @@ -223,8 +227,10 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { let s = 0; for (const k in cats) { if (!Object.prototype.hasOwnProperty.call(cats, k)) continue; + if (NEUTRAL_OPINIONS.has(k)) continue; if (k === 'interesting' || k === 'inspiring') s += 5; - else if (k === 'boring' || k === 'spam' || k === 'propaganda') s -= 3; + else if (MODERATION_OPINIONS.has(k)) s -= 5; + else if (CONSTRUCTIVE_OPINIONS.has(k)) s -= 3; else s += 1; } return s; diff --git a/src/models/tags_model.js b/src/models/tags_model.js index 45f7f38..f88a6ae 100644 --- a/src/models/tags_model.js +++ b/src/models/tags_model.js @@ -107,8 +107,10 @@ module.exports = ({ cooler, padsModel, tribesModel }) => { }; return { - async listTags(filter = 'all', search = '') { + async listTags(filter = 'all', search = '', opts = {}) { const ssbClient = await openSsb(); + const hiddenAuthors = opts.hiddenAuthors instanceof Set ? opts.hiddenAuthors : null; + const hiddenIds = opts.hiddenIds instanceof Set ? opts.hiddenIds : null; const messages = await readTyped(ssbClient, CONTENT_TYPES, { limit: logLimit, withWindow: true }); @@ -140,6 +142,8 @@ module.exports = ({ cooler, padsModel, tribesModel }) => { let filtered = Array.from(latestByKey.values()).filter(msg => { const c = msg?.value?.content; if (!c || c.type === 'tombstone') return false; + if (hiddenAuthors && hiddenAuthors.has(String(msg?.value?.author || ''))) return false; + if (hiddenIds && hiddenIds.has(msg.key)) return false; if (tombstoned.has(msg.key)) return false; if (c.encryptedPayload) return false; if (!Array.isArray(c.tags) || !c.tags.filter(Boolean).length) return false; @@ -185,6 +189,8 @@ module.exports = ({ cooler, padsModel, tribesModel }) => { const viewerId = ssbClient2.id; const pads = await padsModel.listAll({ filter: 'all', viewerId }).catch(() => []); for (const pad of pads) { + if (hiddenAuthors && hiddenAuthors.has(String(pad.author || ''))) continue; + if (hiddenIds && (hiddenIds.has(pad.id) || hiddenIds.has(pad.key) || hiddenIds.has(pad.rootId))) continue; if (pad.status === 'INVITE-ONLY' && pad.author !== viewerId && !(Array.isArray(pad.members) && pad.members.includes(viewerId))) continue; if (!Array.isArray(pad.tags)) continue; const uniquePadTags = new Set(pad.tags.map(tagKey).filter(Boolean)); diff --git a/src/models/trending_model.js b/src/models/trending_model.js index 9b4929c..e25dd9d 100644 --- a/src/models/trending_model.js +++ b/src/models/trending_model.js @@ -4,6 +4,12 @@ const logLimit = getConfig().ssbLogStream?.limit || 1000; const opinionCategories = require('../backend/opinion_categories'); const { buildValidatedTombstoneSet } = require('./tombstone_validator'); const { readTyped, CONTENT_TYPES, requestScope } = require('./typed_log'); +const longText = require('../backend/long_text'); +const isRootBlogPost = (c) => + !!c && c.type === 'post' && typeof c.text === 'string' && + !c.root && !c.fork && !c.branch && !c.about && !c.target && + c.private !== true && !Array.isArray(c.recps); + const { buildVoteTally } = require('../backend/vote_tally'); const { versionGroups, versionEntry } = require('../backend/dedupe'); @@ -21,7 +27,7 @@ module.exports = ({ cooler }) => { }; const types = [ - 'bookmark', 'votes', 'poll', 'feed', + 'bookmark', 'votes', 'poll', 'feed', 'post', 'image', 'audio', 'video', 'document', 'torrent', 'file', 'transfer', 'industry', 'project', 'report', 'task', 'event', 'shopProduct', 'housing', 'market', 'schoolCourse', 'podcast', 'podcastEpisode', 'campaign', 'logisticsRoute' @@ -52,6 +58,15 @@ module.exports = ({ cooler }) => { if (c.opinions && !['task', 'event', 'report'].includes(c.type)) itemsById.set(k, m); if (c.type === 'feed' && !itemsById.has(k)) itemsById.set(k, m); + if (isRootBlogPost(c) && !itemsById.has(k)) itemsById.set(k, m); + } + + const chunkLookup = longText.lookupIn(longText.indexChunks(messages)); + for (const [k, m] of Array.from(itemsById.entries())) { + const c = m.value?.content; + if (c?.type === 'post' && longText.hasChunks(c)) { + itemsById.set(k, { ...m, value: { ...m.value, content: longText.resolveField(c, 'text', m.value.author, chunkLookup) } }); + } } const vg = versionGroups(Array.from(itemsById.values()).map(versionEntry)); diff --git a/src/models/tribes_content_model.js b/src/models/tribes_content_model.js index 9245de2..048f3e9 100644 --- a/src/models/tribes_content_model.js +++ b/src/models/tribes_content_model.js @@ -7,7 +7,7 @@ const longText = require('../backend/long_text'); const { versionGroups } = require('../backend/dedupe'); const TRIBE_LOG_TYPES = ['tribe', 'tribe-msg', 'tribe-keys-distrib', 'tribe-invite-msg', 'tribe-invite-tombstone', 'tribe-open-invite-tombstone', 'tombstone', 'contact']; -const VALID_CONTENT_TYPES = ['event', 'task', 'report', 'votation', 'forum', 'forum-reply', 'market', 'job', 'project', 'media', 'feed', 'pixelia']; +const VALID_CONTENT_TYPES = ['event', 'task', 'report', 'votation', 'forum', 'forum-reply', 'market', 'job', 'project', 'media', 'feed']; const categories = require('../backend/opinion_categories'); const VALID_STATUSES = ['OPEN', 'CLOSED', 'IN-PROGRESS']; const VALID_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; diff --git a/src/models/viewer_filters.js b/src/models/viewer_filters.js index 98c9fec..d4dc5b7 100644 --- a/src/models/viewer_filters.js +++ b/src/models/viewer_filters.js @@ -21,9 +21,74 @@ const loadState = () => { }; const saveState = (s) => writeJson(statePath, s); +const hiddenPath = require('../configs/state-manager').statePath('viewer_hidden.json'); +let hiddenState = null; +const loadHidden = () => { + if (!hiddenState) { + const raw = readJson(hiddenPath, {}); + const muted = Array.isArray(raw && raw.muted) ? raw.muted : []; + const hidden = Array.isArray(raw && raw.hidden) ? raw.hidden : []; + hiddenState = { + muted: muted.map(x => (typeof x === 'string' ? { id: x, at: 0 } : x)).filter(x => x && typeof x.id === 'string'), + hidden: hidden.filter(x => x && typeof x.id === 'string') + }; + } + return hiddenState; +}; +const saveHidden = () => writeJson(hiddenPath, loadHidden()); + +const mutedList = () => loadHidden().muted.slice(); +const mutedIds = () => new Set(loadHidden().muted.map(x => x.id)); +const isMuted = (id) => !!id && loadHidden().muted.some(x => x.id === id); +const mute = (id) => { + const s = loadHidden(); + if (!id || s.muted.some(x => x.id === id)) return false; + s.muted.push({ id, at: Date.now() }); + saveHidden(); + return true; +}; +const unmute = (id) => { + const s = loadHidden(); + const before = s.muted.length; + s.muted = s.muted.filter(x => x.id !== id); + if (s.muted.length !== before) saveHidden(); + return s.muted.length !== before; +}; + +const hiddenList = () => loadHidden().hidden.slice(); +const hiddenIds = () => new Set(loadHidden().hidden.map(x => x.id)); +const isHidden = (id) => !!id && loadHidden().hidden.some(x => x.id === id); +const hideContent = ({ id, href = '', label = '' } = {}) => { + const s = loadHidden(); + if (!id || s.hidden.some(x => x.id === id)) return false; + s.hidden.push({ id, href: String(href || '').slice(0, 300), label: String(label || '').slice(0, 120), at: Date.now() }); + saveHidden(); + return true; +}; +const unhideContent = (id) => { + const s = loadHidden(); + const before = s.hidden.length; + s.hidden = s.hidden.filter(x => x.id !== id); + if (s.hidden.length !== before) saveHidden(); + return s.hidden.length !== before; +}; + +const idsOfItem = (item) => { + if (!item || typeof item !== 'object') return []; + return [item.id, item.key, item.rootId, item.favId].filter(v => typeof v === 'string'); +}; +const isItemHidden = (item, authorOf, { authors, ids } = {}) => { + if (!item) return false; + const a = typeof authorOf === 'function' ? authorOf(item) : null; + if (a && authors && authors.has(String(a))) return true; + if (ids && ids.size && idsOfItem(item).some(id => ids.has(id))) return true; + return false; +}; + const lanPeersPath = require('../configs/state-manager').statePath('lan-peers.json'); const FEED_ID = /^@[A-Za-z0-9+/]{43}=\.ed25519$/; -let lanKnown = null; +const LAN_SEEN_STEP_MS = 60 * 1000; +let lanSeen = null; const canonicalFeedId = (key) => { let k = String(key || '').trim(); @@ -62,22 +127,41 @@ const lanKeysFromConn = (entries) => { return out; }; -const lanPeers = () => { - if (!lanKnown) { +const lanPeersSeen = () => { + if (!lanSeen) { const list = readJson(lanPeersPath, []); - lanKnown = new Set((Array.isArray(list) ? list : []).map(canonicalFeedId).filter(Boolean)); + lanSeen = new Map(); + for (const item of (Array.isArray(list) ? list : [])) { + const id = canonicalFeedId(typeof item === 'string' ? item : item && item.key); + if (id) lanSeen.set(id, Number(item && item.seenAt) || 0); + } } - return lanKnown; + return lanSeen; }; -const rememberLanPeers = (keys) => { - const set = lanPeers(); +const lanPeers = () => new Set(lanPeersSeen().keys()); + +const rememberLanPeers = (keys, now = Date.now()) => { + const seen = lanPeersSeen(); let changed = false; for (const k of keys || []) { const id = canonicalFeedId(k); - if (id && !set.has(id)) { set.add(id); changed = true; } + if (!id) continue; + const prev = seen.get(id); + if (prev === undefined || now - prev >= LAN_SEEN_STEP_MS) { seen.set(id, now); changed = true; } } - if (changed) writeJson(lanPeersPath, [...set]); + if (changed) writeJson(lanPeersPath, [...seen].map(([key, seenAt]) => ({ key, seenAt }))); + return changed; +}; + +const forgetLanPeers = (keys) => { + const seen = lanPeersSeen(); + let changed = false; + for (const k of keys || []) { + const id = canonicalFeedId(k); + if (id && seen.delete(id)) changed = true; + } + if (changed) writeJson(lanPeersPath, [...seen].map(([key, seenAt]) => ({ key, seenAt }))); return changed; }; @@ -104,7 +188,8 @@ const filterByWish = async (items, { wish, viewer, authorOf: author, isOwn, isMu const wishMutualsOnly = () => getConfig().wish === 'mutuals'; const pmMutualsOnly = () => getConfig().pmVisibility === 'mutuals'; -const isFrictionActive = () => wishMutualsOnly() || pmMutualsOnly(); +const pmFollowingOnly = () => getConfig().pmVisibility === 'following'; +const isFrictionActive = () => wishMutualsOnly() || pmMutualsOnly() || pmFollowingOnly(); const listPending = () => loadState().pending; const enqueuePending = (followerId, extra = {}) => { @@ -183,8 +268,13 @@ const applyMutualSupportFilter = async (items, viewerId, friendModel) => { }; const canSendPmTo = async (viewerId, recipientId, friendModel) => { - if (!pmMutualsOnly()) return { allowed: true }; if (viewerId === recipientId) return { allowed: true }; + if (pmFollowingOnly()) { + let rel = null; + try { rel = friendModel && typeof friendModel.getRelationship === 'function' ? await friendModel.getRelationship(recipientId) : null; } catch (_) { rel = null; } + return rel && rel.following ? { allowed: true } : { allowed: false, reason: 'not-following' }; + } + if (!pmMutualsOnly()) return { allowed: true }; const isMutual = makeMutualCache(friendModel); if (await isMutual(recipientId)) return { allowed: true }; return { allowed: false, reason: 'non-mutual' }; @@ -194,6 +284,7 @@ module.exports = { COOLDOWN_MS, wishMutualsOnly, pmMutualsOnly, + pmFollowingOnly, isFrictionActive, listPending, enqueuePending, @@ -211,6 +302,19 @@ module.exports = { isPrivateHost, lanKeysFromConn, lanPeers, + lanPeersSeen, rememberLanPeers, + forgetLanPeers, filterByWish, + mutedList, + mutedIds, + isMuted, + mute, + unmute, + hiddenList, + hiddenIds, + isHidden, + hideContent, + unhideContent, + isItemHidden, }; diff --git a/src/server/SSB_server.js b/src/server/SSB_server.js index 95693c9..ea12deb 100644 --- a/src/server/SSB_server.js +++ b/src/server/SSB_server.js @@ -80,7 +80,7 @@ const Server = SecretStack({ caps }) .use(require('ssb-unix-socket')) .use(require('ssb-no-auth')) .use(require('./snapshot_plugin')) - .use((() => { const pm = require('./phone_module'); try { if (process.env.OASIS_MOBILE === '1') { pm.setAudioFactory(require('./phone_audio_android')); pm.setVideoFactory(require('./phone_video_android')); } } catch (_) {} return pm; })()) + .use((() => { const pm = require('./phone_module'); try { if (process.env.OASIS_MOBILE === '1') { pm.setAudioFactory(require('./phone_audio_android')); pm.setVideoFactory(require('./phone_video_android')); pm.setStreamFactory(require('./phone_stream_android')); } } catch (_) {} return pm; })()) .use(require('./network_pause')); if (!config.pub) { diff --git a/src/server/package-lock.json b/src/server/package-lock.json index cdd8f3f..734be1b 100644 --- a/src/server/package-lock.json +++ b/src/server/package-lock.json @@ -1,6 +1,6 @@ { "name": "@krakenslab/oasis", - "version": "1.3.1", + "version": "1.3.3", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/src/server/package.json b/src/server/package.json index f8fc610..3acea84 100644 --- a/src/server/package.json +++ b/src/server/package.json @@ -1,6 +1,6 @@ { "name": "@krakenslab/oasis", - "version": "1.3.1", + "version": "1.3.3", "description": "Oasis - Social Networking Utopia", "repository": { "type": "git", diff --git a/src/server/phone_module.js b/src/server/phone_module.js index fdec57d..32a6f41 100644 --- a/src/server/phone_module.js +++ b/src/server/phone_module.js @@ -24,7 +24,7 @@ const RING_PER_WINDOW = 20; const SEEN_MAX = 5000; const HUB_ROOMS_MAX = 256; const HUB_RIDS_PER_ROOM = 4; -const GROUP_MAX = 7; +const GROUP_MAX = 10; const MIX_QUEUE = 10; const ROOM_MAX = 50; const ROOM_QUIET_AT = 10; @@ -59,12 +59,18 @@ const K_VIDEO = 4; const VIDEO_CHUNK = 8 * 1024; const VIDEO_PAD = 1024; const VIDEO_HDR = 6; +const VIDEO_FILM = 0x80; const VIDEO_FRAME_MAX = 256 * 1024; const VIDEO_FPS = 8; const VIDEO_SIZE = { w: 320, h: 240 }; +const STREAM_SIZE = { w: 640, h: 480 }; +const GRID_TILE_LARGE = { w: 640, h: 480 }; +const GRID_LARGE_MAX = 4; +const ROOM_VIDEO_CHUNK_EXTRA = 45; const VIDEO_HOLD_MS = 3000; const ROOM_VIDEO_FRAME_MAX = 10 * 1024; const ROOM_VIDEO_BYTES_PER_S = 200 * 1024; +const ROOM_VIDEO_SEND_MAX = Math.floor(ROOM_VIDEO_BYTES_PER_S * 0.9); const VAD_MIN = 200; const VAD_FLOOR_MAX = 1500; const VAD_HANG = 15; @@ -215,7 +221,71 @@ const systemVideo = (server, device) => { }; let videoFactory = systemVideo; -const videoChunks = (seq, jpeg) => { +const hasBin = (bin) => (path.isAbsolute(String(bin || '')) ? fs.existsSync(bin) : onPath(bin)); +const gstQuote = (s) => `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +const STREAM_BACKENDS = [ + { + bin: () => process.env.OASIS_FFMPEG || 'ffmpeg', + retryWithoutAudio: true, + args: (file, withAudio) => [ + '-loglevel', 'quiet', '-protocol_whitelist', 'file,crypto', '-re', '-i', file, + '-map', '0:v:0', '-vf', `fps=${VIDEO_FPS},scale=${STREAM_SIZE.w}:${STREAM_SIZE.h}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${STREAM_SIZE.w}:${STREAM_SIZE.h}:(ow-iw)/2:(oh-ih)/2`, + '-c:v', 'mjpeg', '-q:v', '9', '-f', 'image2pipe', 'pipe:1', + ...(withAudio ? ['-map', '0:a:0', '-ac', '1', '-ar', String(RATE), '-f', 's16le', 'pipe:3'] : []) + ] + }, + { + bin: () => process.env.OASIS_GST_LAUNCH || 'gst-launch-1.0', + args: (file) => [ + '-q', 'filesrc', `location=${gstQuote(file)}`, '!', 'decodebin', 'name=d', + 'd.', '!', 'queue', '!', 'videoconvert', '!', 'videoscale', 'add-borders=true', '!', `video/x-raw,width=${STREAM_SIZE.w},height=${STREAM_SIZE.h},pixel-aspect-ratio=1/1`, '!', 'videorate', '!', `video/x-raw,framerate=${VIDEO_FPS}/1`, '!', 'jpegenc', 'quality=50', '!', 'fdsink', 'fd=1', 'sync=true', + 'd.', '!', 'queue', '!', 'audioconvert', '!', 'audioresample', '!', `audio/x-raw,format=S16LE,rate=${RATE},channels=1`, '!', 'fdsink', 'fd=3', 'sync=true', 'async=false' + ] + } +]; +const STREAM_STALL_MS = 5000; +const STREAM_START_MS = 20000; +const systemStream = () => { + const backend = STREAM_BACKENDS.find(b => hasBin(b.bin())); + if (!backend) return null; + return { + open(file, { onJpeg, onPcm, onEnd }) { + let proc = null; + let frames = 0; + let lastAt = 0; + let ended = false; + const startedAt = Date.now(); + const watch = setInterval(() => { + const now = Date.now(); + if (frames ? now - lastAt > STREAM_STALL_MS : now - startedAt > STREAM_START_MS) finish(); + }, 1000); + if (watch.unref) watch.unref(); + const halt = () => { ended = true; clearInterval(watch); try { if (proc) proc.kill('SIGTERM'); } catch (_) {} }; + const finish = () => { if (ended) return; halt(); onEnd(); }; + const run = (withAudio) => { + const p = spawn(backend.bin(), backend.args(file, withAudio), { stdio: ['ignore', 'pipe', 'ignore', 'pipe'] }); + proc = p; + p.on('error', () => {}); + p.stdout.on('error', () => {}); + p.stdout.on('data', jpegSplitter((jpeg) => { if (ended || proc !== p) return; frames++; lastAt = Date.now(); onJpeg(jpeg); })); + if (p.stdio[3]) { + p.stdio[3].on('error', () => {}); + p.stdio[3].on('data', (pcm) => { if (!ended && proc === p) onPcm(pcm); }); + } + p.on('close', () => { + if (ended || proc !== p) return; + if (withAudio && !frames && backend.retryWithoutAudio) return run(false); + finish(); + }); + }; + run(true); + return { stop() { if (!ended) halt(); } }; + } + }; +}; +let streamFactory = systemStream; + +const videoChunks = (seq, jpeg, track = 0) => { const n = Math.ceil(jpeg.length / VIDEO_CHUNK); const out = []; for (let i = 0; i < n; i++) { @@ -223,7 +293,7 @@ const videoChunks = (seq, jpeg) => { const plain = Buffer.alloc(Math.ceil((VIDEO_HDR + data.length) / VIDEO_PAD) * VIDEO_PAD); plain.writeUInt16BE(seq & 0xffff, 0); plain[2] = i; - plain[3] = n; + plain[3] = n | (track ? VIDEO_FILM : 0); plain.writeUInt16BE(data.length, 4); data.copy(plain, VIDEO_HDR); nodeCrypto.randomFillSync(plain, VIDEO_HDR + data.length); @@ -231,12 +301,13 @@ const videoChunks = (seq, jpeg) => { } return out; }; -const videoOff = () => { const plain = Buffer.alloc(VIDEO_PAD); nodeCrypto.randomFillSync(plain, VIDEO_HDR); return plain; }; +const videoOff = (track = 0) => { const plain = Buffer.alloc(VIDEO_PAD); plain[3] = track ? VIDEO_FILM : 0; nodeCrypto.randomFillSync(plain, VIDEO_HDR); return plain; }; +const videoTrack = (plain) => (Buffer.isBuffer(plain) && plain.length >= VIDEO_HDR && (plain[3] & VIDEO_FILM) ? 1 : 0); const videoAssembler = () => { let cur = null; return (plain) => { if (!Buffer.isBuffer(plain) || plain.length < VIDEO_HDR) return null; - const seq = plain.readUInt16BE(0), idx = plain[2], n = plain[3], len = plain.readUInt16BE(4); + const seq = plain.readUInt16BE(0), idx = plain[2], n = plain[3] & ~VIDEO_FILM, len = plain.readUInt16BE(4); if (!n) { cur = null; return 'off'; } if (idx >= n || VIDEO_HDR + len > plain.length || n * VIDEO_CHUNK > VIDEO_FRAME_MAX) return null; if (!cur || cur.seq !== seq) cur = { seq, n, parts: new Array(n), got: 0 }; @@ -418,7 +489,8 @@ module.exports = { version: '1.0.0', manifest: { ring: 'async', answer: 'async', hangup: 'async', audio: 'duplex', relay: 'async', relayAudio: 'duplex', video: 'duplex', relayVideo: 'duplex', - call: 'async', accept: 'async', reject: 'async', end: 'async', mute: 'async', silence: 'async', dismiss: 'async', camera: 'async', videoFrames: 'source', + call: 'async', accept: 'async', reject: 'async', end: 'async', mute: 'async', silence: 'async', dismiss: 'async', camera: 'async', stream: 'async', videoFrames: 'source', + viewerJoin: 'async', viewerLeave: 'async', viewerRooms: 'sync', publicFeed: 'source', recordStop: 'async', recordCancel: 'async', roomHub: 'duplex', roomInfo: 'async', roomAdmits: 'async', roomJoin: 'async', roomLeave: 'async', roomMute: 'async', roomHand: 'async', roomCount: 'async', roomRecStart: 'async', roomRecStop: 'async', roomNotify: 'async', roomClearEvents: 'async', @@ -453,6 +525,7 @@ module.exports = { const roomMax = Number(policy().roomMax) > 0 ? Math.min(ROOM_MAX, Number(policy().roomMax)) : ROOM_MAX; const roomQuietAt = Number(policy().roomQuietAt) > 0 ? Number(policy().roomQuietAt) : ROOM_QUIET_AT; const video = videoFactory(server, policy().videoDevice); + const streamer = streamFactory(server); const videoAllowed = () => policy().video !== false; const videoOn = () => !!video && videoAllowed(); const codecsOffered = () => [...(OpusScript && policy().opus !== false ? ['opus'] : []), ...(videoOn() ? ['mjpeg'] : [])]; @@ -568,11 +641,14 @@ module.exports = { }; const MOBILE_STATE = path.join(__dirname, '..', '..', '.phone_state.json'); - const writeMobileState = (s) => { + let mobileCall = null; + let mobileRoom = null; + const writeMobileState = () => { if (process.env.OASIS_MOBILE !== '1') return; - try { fs.writeFileSync(MOBILE_STATE, JSON.stringify(s || null)); } catch (_) {} + const s = mobileCall || mobileRoom ? { ...(mobileCall || {}), room: mobileRoom ? { phase: mobileRoom.phase, cam: !!mobileRoom.cam } : null } : null; + try { fs.writeFileSync(MOBILE_STATE, JSON.stringify(s)); } catch (_) {} }; - writeMobileState(null); + writeMobileState(); const emit = (ev) => { for (const p of subscribers) p.push(ev); }; const snapshot = () => call ? { id: call.id, peer: call.peer, dir: call.dir, phase: call.phase, @@ -582,7 +658,7 @@ module.exports = { video: !!call.video, cam: !!call.cam, peerCam: !!call.peerCam, videoAvailable: videoOn(), ...(call.group ? { group: true, peers: call.legs.map(l => ({ id: l.peer, phase: l.phase, reached: !!l.reached, silenced: silenced.has(l.peer) })) } : {}) } : null; - const changed = () => { const s = snapshot(); writeMobileState(s); emit({ type: 'state', state: s }); }; + const changed = () => { const s = snapshot(); mobileCall = s; writeMobileState(); emit({ type: 'state', state: s }); }; const stopTone = (c) => { if (c.tone) { c.tone.stop(); c.tone = null; } }; const stopRecorder = (c) => { clearTimeout(c.recDelay); clearTimeout(c.recWarn); if (c.recorder) { c.recorder.stop(); c.recorder = null; } clearTimeout(c.recTimer); }; const startRecording = (c) => { @@ -946,7 +1022,8 @@ module.exports = { if (slot !== from) deliver(m, frame, droppable); } }; - const hubAttach = (rid, id, out, scope = null) => { + const hubHumans = (h) => [...h.members.values()].filter(m => !m.viewer).length; + const hubAttach = (rid, id, out, scope = null, viewer = false) => { let r = hubRooms.get(rid); if (!r) { if (hubRooms.size >= HUB_ROOMS_MAX) return null; @@ -960,8 +1037,8 @@ module.exports = { if (r.members.size >= roomMax) return null; let slot = 0; while (r.members.has(slot)) slot++; - const peers = [...r.members].map(([s, m]) => ({ slot: s, id: m.id })); - const m = { id, out, sent: 0, acked: 0 }; + const peers = [...r.members].map(([s, m]) => (m.viewer ? { slot: s, id: m.id, viewer: true } : { slot: s, id: m.id })); + const m = { id, out, sent: 0, acked: 0, viewer: !!viewer }; m.leave = () => { if (r.members.get(slot) !== m) return; r.members.delete(slot); @@ -1002,7 +1079,7 @@ module.exports = { }; r.members.set(slot, m); deliver(m, ctrlFrame({ t: 'hello', slot, peers, max: roomMax, video: policy().video !== false }), false); - hubBroadcast(r, slot, ctrlFrame({ t: 'join', slot, id }), false); + hubBroadcast(r, slot, ctrlFrame(viewer ? { t: 'join', slot, id, viewer: true } : { t: 'join', slot, id }), false); return m; }; const hubDuplex = (from, opts) => { @@ -1022,7 +1099,7 @@ module.exports = { } const admit = () => { if (closed) return; - member = hubAttach(rid, from, out, roomId ? `${opts.owner}|${roomId}` : null); + member = hubAttach(rid, from, out, roomId ? `${opts.owner}|${roomId}` : null, opts.viewer === true); if (!member) refuse('full'); }; if (opts.owner === server.id || from === server.id) admit(); @@ -1030,24 +1107,29 @@ module.exports = { return { source: out, sink }; }; + const viewers = new Map(); + const active = (r) => !!r && (r === room || viewers.get(r.rid) === r); + const touched = (r) => { if (r === room) roomChanged(); }; + const humansIn = (r) => [...r.peers.values()].filter(p => !p.viewer).length; const roomSnapshot = () => { const r = room; if (!r) return null; const now = Date.now(); return { rid: r.rid, ref: r.ref, title: r.title, phase: r.phase, joinedAt: r.joinedAt, muted: !!r.muted, hand: !!r.hand, handSince: r.hand ? r.handAt : 0, - secure: !!r.kid, count: r.peers.size + 1, max: r.max, speaking: now - r.sentAt < 1000, + secure: !!r.kid, count: humansIn(r) + 1, max: r.max, public: [...r.peers.values()].some(p => p.viewer), speaking: now - r.sentAt < 1000, cam: !!r.cam, videoAvailable: videoOn() && r.hubVideo === true, grid: !!sharpLib, gridOrder: gridIds(r), + streaming: r.stream ? r.stream.name : null, streamAvailable: fileStreamOn() && r.hubVideo === true, notify: !!r.notify, recording: !!r.rec, recordingSince: r.rec ? r.rec.at : 0, recordingBy: [...r.recPeers], events: r.notify ? r.events.slice(-ROOM_EVENTS_MAX) : [], - peers: [...r.peers.values()].map(p => ({ id: p.id, speaking: now - p.heardAt < 1000, muted: now - p.mutedAt < MUTE_TTL_MS, hand: now - (p.handBeat || 0) < MUTE_TTL_MS, handSince: p.handSince || 0, silenced: silenced.has(p.id), recording: r.recPeers.has(p.id), cam: now - (p.frameAt || 0) < VIDEO_HOLD_MS })) + peers: [...r.peers.values()].map(p => ({ id: p.id, speaking: now - p.heardAt < 1000, muted: now - p.mutedAt < MUTE_TTL_MS, hand: now - (p.handBeat || 0) < MUTE_TTL_MS, handSince: p.handSince || 0, silenced: silenced.has(p.id), recording: r.recPeers.has(p.id), cam: now - (p.frameAt || 0) < VIDEO_HOLD_MS, film: now - (p.filmAt || 0) < VIDEO_HOLD_MS, viewer: !!p.viewer })) }; }; - const roomChanged = () => emit({ type: 'room', room: roomSnapshot() }); + const roomChanged = () => { const r = roomSnapshot(); mobileRoom = r; writeMobileState(); emit({ type: 'room', room: r }); }; const pushEvent = (r, t, id) => { r.events.push({ t, id, ts: Date.now() }); if (r.events.length > ROOM_EVENTS_MAX) r.events.splice(0, r.events.length - ROOM_EVENTS_MAX); - roomChanged(); + touched(r); }; const startRoomRec = (r) => { if (!r || r.rec || r.phase !== 'live') return false; @@ -1102,8 +1184,8 @@ module.exports = { const sealed = seal({ rid: r.rid, kid: r.kid, key: b64(key), from: server.id, ts, sig: signParts(keys, ['roomkey', r.rid, r.kid, p.id, ts]) }, p.id); if (sealed) r.out.push(Buffer.concat([Buffer.from([K_KEY, slot]), Buffer.from(sealed)])); }; - const peerEntry = (id) => ({ id, queue: [], openers: new Map(), heardAt: 0, mutedAt: 0, dec: null, opus: false, greeted: false }); - const dropPeer = (r, slot) => { const p = r.peers.get(slot); if (p) { if (p.dec) p.dec.free(); clearTimeout(p.vtimer); endFrames(p.id); gridNote(p.id, null); } r.peers.delete(slot); }; + const peerEntry = (id, viewer = false) => ({ id, viewer: !!viewer, queue: [], openers: new Map(), heardAt: 0, mutedAt: 0, dec: null, opus: false, greeted: false }); + const dropPeer = (r, slot) => { const p = r.peers.get(slot); if (p) { if (p.dec) p.dec.free(); clearTimeout(p.vtimer); clearTimeout(p.ftimer); endFrames(r.tag + p.id); endFrames(r.tag + filmKey(p.id)); gridNote(r, p.id, null); gridNote(r, filmKey(p.id), null); } r.peers.delete(slot); }; const sendCodecs = (r) => { if (r.out && r.sealer && codecsOffered().length) sendRoomData(r, { t: 'codecs', codecs: codecsOffered() }); }; const onRoomCtrl = (r, msg) => { if (msg.t === 'hello' && r.slot === null) { @@ -1112,7 +1194,7 @@ module.exports = { r.hubVideo = msg.video === true; for (const p of Array.isArray(msg.peers) ? msg.peers : []) { if (!p || !isFeedId(p.id)) continue; - r.peers.set(Number(p.slot), peerEntry(p.id)); + r.peers.set(Number(p.slot), peerEntry(p.id, p.viewer === true)); r.order.push(Number(p.slot)); } r.order.push(r.slot); @@ -1123,10 +1205,10 @@ module.exports = { } else if (msg.t === 'join' && isFeedId(msg.id)) { const slot = Number(msg.slot); dropPeer(r, slot); - r.peers.set(slot, peerEntry(msg.id)); + r.peers.set(slot, peerEntry(msg.id, msg.viewer === true)); r.order = r.order.filter(s => s !== slot).concat(slot); if (!r.static && keeperSlot(r) === r.slot) sendRoomKey(r, slot); - if (r.notify) playChime(audio, 'join'); + if (r.notify && msg.viewer !== true) playChime(audio, 'join'); pushEvent(r, 'join', msg.id); } else if (msg.t === 'leave') { const slot = Number(msg.slot); @@ -1146,7 +1228,7 @@ module.exports = { } else if (msg.t === 'full' || msg.t === 'refused') { if (r.ready) return r.ready(new Error(msg.t)); } - roomChanged(); + touched(r); }; const onRoomKey = (r, slot, payload) => { const p = r.peers.get(slot); @@ -1157,7 +1239,7 @@ module.exports = { const key = Buffer.from(String(msg.key || ''), 'base64'); if (key.length !== 32) return; useKey(r, msg.kid, key); - roomChanged(); + touched(r); }; const openRoomFrame = (r, p, payload) => { if (!p || payload.length < 44) return null; @@ -1187,11 +1269,11 @@ module.exports = { const noteMute = (r, p, on, quiet = false) => { if (!!p.muted === on) return; p.muted = on; - if (quiet) return roomChanged(); + if (quiet) return touched(r); const now = Date.now(); const announce = now - (p.muteToggleAt || 0) >= REC_TOGGLE_MIN_MS; p.muteToggleAt = now; - if (!announce) return roomChanged(); + if (!announce) return touched(r); if (r.notify) playChime(audio, on ? 'mute' : 'unmute'); pushEvent(r, on ? 'mute' : 'unmute', p.id); }; @@ -1200,10 +1282,10 @@ module.exports = { p.hand = on; const now = Date.now(); p.handSince = on ? (quiet && Number.isFinite(at) && at > 0 ? Math.min(at, now) : now) : 0; - if (!on || quiet) return roomChanged(); + if (!on || quiet) return touched(r); const announce = now - (p.handToggleAt || 0) >= REC_TOGGLE_MIN_MS; p.handToggleAt = now; - if (!announce) return roomChanged(); + if (!announce) return touched(r); if (r.notify) playChime(audio, 'hand'); pushEvent(r, 'hand', p.id); }; @@ -1226,7 +1308,7 @@ module.exports = { p.recToggleAt = now; if (msg.on) r.recPeers.add(p.id); else r.recPeers.delete(p.id); if (announce) { playChime(audio, msg.on ? 'recStart' : 'recStop'); pushEvent(r, msg.on ? 'recStart' : 'recStop', p.id); } - else roomChanged(); + else touched(r); return; } if (msg && msg.t === 'hand') { @@ -1240,64 +1322,146 @@ module.exports = { }; const gridIds = (r) => { const now = Date.now(); - const ids = r.cam ? [server.id] : []; - for (const slot of r.order) { const p = r.peers.get(slot); if (p && now - (p.frameAt || 0) < VIDEO_HOLD_MS) ids.push(p.id); } + const ids = []; + if (r.cam) ids.push(server.id); + if (r.stream) ids.push(filmKey(server.id)); + for (const slot of r.order) { + const p = r.peers.get(slot); + if (!p) continue; + if (now - (p.frameAt || 0) < VIDEO_HOLD_MS) ids.push(p.id); + if (now - (p.filmAt || 0) < VIDEO_HOLD_MS) ids.push(filmKey(p.id)); + } return ids; }; - const gridFrames = new Map(); - let gridBusy = false; - let gridDirty = false; - let gridTimer = null; + const newGrid = () => ({ frames: new Map(), busy: false, dirty: false, timer: null }); const composeGrid = async (r) => { - const ids = gridIds(r).filter(id => gridFrames.has(id)); - if (!ids.length) return; + const g = r.grid; + const ids = gridIds(r).filter(id => g.frames.has(id)); + if (!ids.length) { + if (r.tag && !g.empty) { g.empty = true; const b = await blankFrame(); if (b && active(r)) publishFrame(r.tag + 'grid', b); } + return; + } + g.empty = false; + if (ids.length === 1 && ids[0] !== server.id) { if (active(r)) publishFrame(r.tag + 'grid', g.frames.get(ids[0])); return; } + const size = ids.length <= GRID_LARGE_MAX ? GRID_TILE_LARGE : VIDEO_SIZE; const cols = Math.ceil(Math.sqrt(ids.length)); const rows = Math.ceil(ids.length / cols); const tiles = []; for (let i = 0; i < ids.length; i++) { try { - let tile = sharpLib(gridFrames.get(ids[i])).resize(VIDEO_SIZE.w, VIDEO_SIZE.h, { fit: 'cover' }); + let tile = sharpLib(g.frames.get(ids[i])).resize(size.w, size.h, { fit: 'contain', background: '#000000' }); if (ids[i] === server.id) tile = tile.flop(); - tiles.push({ input: await tile.toBuffer(), left: (i % cols) * VIDEO_SIZE.w, top: Math.floor(i / cols) * VIDEO_SIZE.h }); + tiles.push({ input: await tile.toBuffer(), left: (i % cols) * size.w, top: Math.floor(i / cols) * size.h }); } catch (_) {} } if (!tiles.length) return; - const jpeg = await sharpLib({ create: { width: cols * VIDEO_SIZE.w, height: rows * VIDEO_SIZE.h, channels: 3, background: '#000000' } }).composite(tiles).jpeg({ quality: 70 }).toBuffer(); - if (room === r) publishFrame('grid', jpeg); + const jpeg = await sharpLib({ create: { width: cols * size.w, height: rows * size.h, channels: 3, background: '#000000' } }).composite(tiles).jpeg({ quality: 80 }).toBuffer(); + if (active(r)) publishFrame(r.tag + 'grid', jpeg); }; - const gridTick = () => { - const r = room; - if (!r || !sharpLib || gridBusy || !gridDirty || !frameWatchers.has('grid')) return; - gridDirty = false; - gridBusy = true; - composeGrid(r).catch(() => {}).then(() => { gridBusy = false; }); + const gridTick = (r) => { + const g = r && r.grid; + if (!g || !active(r) || !sharpLib || g.busy || !g.dirty || !frameWatchers.has(r.tag + 'grid')) return; + g.dirty = false; + g.busy = true; + composeGrid(r).catch(() => {}).then(() => { g.busy = false; }); }; - const gridNote = (id, jpeg) => { if (jpeg) gridFrames.set(id, jpeg); else gridFrames.delete(id); gridDirty = true; }; - const stopGrid = () => { clearInterval(gridTimer); gridTimer = null; gridFrames.clear(); gridDirty = false; endFrames('grid'); }; + const gridNote = (r, id, jpeg) => { if (!r || !r.grid) return; if (jpeg) r.grid.frames.set(id, jpeg); else r.grid.frames.delete(id); r.grid.dirty = true; }; + const startGrid = (r) => { if (!sharpLib || !r.grid || r.grid.timer) return; r.grid.timer = setInterval(() => gridTick(r), Math.round(1000 / VIDEO_FPS)); if (r.grid.timer.unref) r.grid.timer.unref(); }; + const stopGrid = (r) => { if (!r || !r.grid) return; clearInterval(r.grid.timer); r.grid.timer = null; r.grid.frames.clear(); r.grid.dirty = false; endFrames(r.tag + 'grid'); }; const onRoomVideo = (r, slot, payload) => { const p = r.peers.get(slot); if (!videoAllowed()) return; const plain = openRoomFrame(r, p, payload); if (!plain) return; - if (!p.vasm) p.vasm = videoAssembler(); - const got = p.vasm(plain); - if (got === 'off') { clearTimeout(p.vtimer); if (p.frameAt) { p.frameAt = 0; endFrames(p.id); gridNote(p.id, null); roomChanged(); } return; } + const film = videoTrack(plain) === 1; + const key = film ? filmKey(p.id) : p.id; + const at = film ? 'filmAt' : 'frameAt'; + const timer = film ? 'ftimer' : 'vtimer'; + const asm = film ? 'fasm' : 'vasm'; + if (!p[asm]) p[asm] = videoAssembler(); + const got = p[asm](plain); + if (got === 'off') { clearTimeout(p[timer]); if (p[at]) { p[at] = 0; endFrames(r.tag + key); gridNote(r, key, null); touched(r); } return; } if (!got) return; - const fresh = !(Date.now() - (p.frameAt || 0) < VIDEO_HOLD_MS); - p.frameAt = Date.now(); - clearTimeout(p.vtimer); - p.vtimer = setTimeout(() => { if (room === r) { endFrames(p.id); gridNote(p.id, null); roomChanged(); } }, VIDEO_HOLD_MS); - if (p.vtimer.unref) p.vtimer.unref(); - const hold = setTimeout(() => { if (room !== r || !(Date.now() - (p.frameAt || 0) < VIDEO_HOLD_MS)) return; publishFrame(p.id, got); gridNote(p.id, got); }, p.queue.length * FRAME_MS); + const fresh = !(Date.now() - (p[at] || 0) < VIDEO_HOLD_MS); + p[at] = Date.now(); + clearTimeout(p[timer]); + p[timer] = setTimeout(() => { if (active(r)) { endFrames(r.tag + key); gridNote(r, key, null); touched(r); } }, VIDEO_HOLD_MS); + if (p[timer].unref) p[timer].unref(); + const hold = setTimeout(() => { if (!active(r) || !(Date.now() - (p[at] || 0) < VIDEO_HOLD_MS)) return; publishFrame(r.tag + key, got); gridNote(r, key, got); }, p.queue.length * FRAME_MS); if (hold.unref) hold.unref(); - if (fresh) roomChanged(); + if (fresh) touched(r); }; const sendRoomVideo = (r, plain) => { if (!r.sealer || !r.hubVideo) return; r.out.push(Buffer.concat([Buffer.from([K_VIDEO]), Buffer.from(r.kid, 'hex'), r.sealer(plain)])); }; + const fileStreamOn = () => videoAllowed() && !!streamer; + const streamFile = (file) => { + const raw = String(file || ''); + if (!path.isAbsolute(raw)) return null; + try { const full = fs.realpathSync(raw); return fs.statSync(full).isFile() ? full : null; } catch (_) { return null; } + }; + const filmKey = (id) => `film:${id}`; + const videoBudget = (r, chunks) => { + const bytes = chunks.reduce((n, c) => n + c.length + ROOM_VIDEO_CHUNK_EXTRA, 0); + const now = Date.now(); + const sent = r.vsent || (r.vsent = []); + while (sent.length && now - sent[0][0] >= 1000) sent.shift(); + if (sent.reduce((n, e) => n + e[1], 0) + bytes > ROOM_VIDEO_SEND_MAX) return false; + sent.push([now, bytes]); + return true; + }; + const sendRoomJpeg = (r, seq, jpeg, track = 0) => { + const chunks = videoChunks(seq, jpeg, track); + if (!videoBudget(r, chunks)) return; + for (const plain of chunks) sendRoomVideo(r, plain); + }; + const stopRoomStream = (r, notify = true) => { + const s = r && r.stream; + if (!s) return false; + r.stream = null; + try { if (s.handle) s.handle.stop(); } catch (_) {} + endFrames('film:me'); + gridNote(r, filmKey(server.id), null); + if (room === r) sendRoomVideo(r, videoOff(1)); + if (notify) roomChanged(); + return true; + }; + const startRoomStream = (r, file, name) => { + if (!r || r.phase !== 'live' || !r.hubVideo || !fileStreamOn()) return false; + stopRoomStream(r, false); + const s = { name: String(name || '').slice(0, 120), pcm: Buffer.alloc(0), seq: 0, handle: null }; + r.stream = s; + try { + s.handle = streamer.open(file, { + onJpeg: (jpeg) => { + if (r.stream !== s || room !== r || !Buffer.isBuffer(jpeg) || jpeg.length > VIDEO_FRAME_MAX) return; + s.seq = (s.seq + 1) & 0xffff; + publishFrame('film:me', jpeg); + gridNote(r, filmKey(server.id), jpeg); + sendRoomJpeg(r, s.seq, jpeg, 1); + }, + onPcm: (pcm) => { + if (r.stream !== s || !pcm || !pcm.length) return; + s.pcm = s.pcm.length ? Buffer.concat([s.pcm, pcm]) : Buffer.from(pcm); + const drop = (s.pcm.length - RATE * 2) & ~1; + if (drop > 0) s.pcm = s.pcm.subarray(drop); + }, + onEnd: () => { if (r.stream === s) stopRoomStream(r); } + }); + } catch (_) { s.handle = null; } + if (!s.handle || r.stream !== s) { if (r.stream === s) r.stream = null; return false; } + return true; + }; + const streamFrame = (r) => { + const s = r.stream; + if (!s || s.pcm.length < FRAME_BYTES) return null; + const f = s.pcm.subarray(0, FRAME_BYTES); + s.pcm = s.pcm.subarray(FRAME_BYTES); + return f; + }; const onRoomFrame = (r, frame) => { - if (room !== r) return; + if (!active(r)) return; const buf = Buffer.isBuffer(frame) ? frame : Buffer.from(frame || []); if (!buf.length) return; r.received = (r.received || 0) + 1; @@ -1327,6 +1491,8 @@ module.exports = { pending = pending.subarray(FRAME_BYTES); const acc = new Int32Array(samples); for (const p of r.peers.values()) { const f = p.queue.shift(); if (f && !silenced.has(p.id)) mixInto(acc, f); } + const film = streamFrame(r); + if (film) mixInto(acc, film); player.write(clampPcm(acc)); if (r.rec) { const both = Int32Array.from(acc); @@ -1339,11 +1505,14 @@ module.exports = { const voiced = gate(mic); if (r.muted && r.sealer && Date.now() - (r.beaconAt || 0) >= MUTE_BEACON_MS) sendRoomData(r, { t: 'mute', on: true, beacon: true }); if (r.hand && r.sealer && Date.now() - (r.handBeaconAt || 0) >= MUTE_BEACON_MS) { sendRoomData(r, { t: 'hand', on: true, at: r.handAt, beacon: true }); r.handBeaconAt = Date.now(); } - if (r.muted || !r.sealer || !voiced) continue; + const talk = !r.muted && voiced; + if (!r.sealer || (!talk && !film)) continue; r.sentAt = Date.now(); + let send = mic; + if (film) { const mix = new Int32Array(samples); mixInto(mix, film); if (talk) mixInto(mix, mic); send = clampPcm(mix); } const opus = codecsOffered().includes('opus') && r.peers.size > 0 && [...r.peers.values()].every(p => p.opus); if (opus && !r.enc) r.enc = makeEncoder('opus'); - r.out.push(Buffer.concat([Buffer.from([K_AUDIO]), Buffer.from(r.kid, 'hex'), r.sealer(opus ? r.enc.encode(mic) : ulawEncode(mic))])); + r.out.push(Buffer.concat([Buffer.from([K_AUDIO]), Buffer.from(r.kid, 'hex'), r.sealer(opus ? r.enc.encode(send) : ulawEncode(send))])); } }); r.media = { stop() { capture.stop(); player.stop(); } }; @@ -1359,9 +1528,10 @@ module.exports = { stopRoomRec(r); room = null; clearTimeout(r.timer); + stopRoomStream(r, false); if (r.cam) { r.cam = false; stopCam(); } - for (const p of r.peers.values()) { clearTimeout(p.vtimer); endFrames(p.id); } - stopGrid(); + for (const p of r.peers.values()) { clearTimeout(p.vtimer); clearTimeout(p.ftimer); endFrames(p.id); endFrames(filmKey(p.id)); } + stopGrid(r); if (r.media) r.media.stop(); if (r.enc) { r.enc.free(); r.enc = null; } for (const p of r.peers.values()) if (p.dec) p.dec.free(); @@ -1370,13 +1540,57 @@ module.exports = { if (outcome) emit({ type: 'roomEnded', room: { rid: r.rid, ref: r.ref, title: r.title, joinedAt: r.joinedAt, endedAt: Date.now(), outcome } }); roomChanged(); }; + const openHub = (opts, req, attach) => { + if (opts.hub === server.id) return attach(hubDuplex(server.id, req)); + const viaRpc = (rpc) => attach(rpc && rpc.phone && typeof rpc.phone.roomHub === 'function' ? rpc.phone.roomHub(req, () => {}) : null); + const live = liveRpc(opts.hub); + if (live) return viaRpc(live); + if (typeof opts.address === 'string' && opts.address) { + try { + return server.conn.connect(opts.address, (err, rpc) => { + const got = !err && rpc && rpc.phone ? rpc : liveRpc(opts.hub); + return got ? viaRpc(got) : reach(opts.hub, viaRpc); + }); + } catch (_) {} + } + reach(opts.hub, viaRpc); + }; + let blank = null; + const blankFrame = async () => { + if (blank || !sharpLib) return blank; + try { blank = await sharpLib({ create: { width: GRID_TILE_LARGE.w, height: GRID_TILE_LARGE.h, channels: 3, background: '#000000' } }).jpeg().toBuffer(); } catch (_) { blank = null; } + return blank; + }; + const soundKey = (r) => r.tag + 'sound'; + const dropViewer = (r) => { + if (!r || viewers.get(r.rid) !== r) return; + viewers.delete(r.rid); + clearTimeout(r.timer); + clearInterval(r.mixTimer); + stopGrid(r); + endFrames(soundKey(r)); + for (const p of r.peers.values()) { if (p.dec) p.dec.free(); clearTimeout(p.vtimer); clearTimeout(p.ftimer); } + try { r.out.push(ctrlFrame({ t: 'bye' })); } catch (_) {} + try { r.out.end(); } catch (_) {} + }; + const startViewerMedia = (r) => { + const samples = FRAME_BYTES / 2; + r.mixTimer = setInterval(() => { + if (viewers.get(r.rid) !== r) return; + const acc = new Int32Array(samples); + for (const p of r.peers.values()) { const f = p.queue.shift(); if (f) mixInto(acc, f); } + if (frameWatchers.has(soundKey(r))) publishFrame(soundKey(r), clampPcm(acc)); + if (r.sealer && Date.now() - (r.beaconAt || 0) >= MUTE_BEACON_MS) sendRoomData(r, { t: 'mute', on: true, beacon: true }); + }, FRAME_MS); + if (r.mixTimer.unref) r.mixTimer.unref(); + }; const staticKey = (rid, secret) => { const key = Buffer.from(sodium.crypto_hash_sha256(Buffer.concat([Buffer.from('oasis-room|'), Buffer.from(rid, 'hex'), Buffer.from(String(secret))]))); return { kid: Buffer.from(sodium.crypto_hash_sha256(key)).subarray(0, 4).toString('hex'), key }; }; if (server.close && typeof server.close.hook === 'function') { - server.close.hook(function (fn, args) { finish('ended'); leaveRoom(room, 'ended'); stopCam(); return fn.apply(this, args); }); + server.close.hook(function (fn, args) { finish('ended'); leaveRoom(room, 'ended'); stopCam(); for (const v of [...viewers.values()]) dropViewer(v); return fn.apply(this, args); }); } return { @@ -1566,12 +1780,12 @@ module.exports = { if (r && r.phase === 'live') { if (on === !!r.cam) return cb(null, roomSnapshot()); if (on) { - if (!r.hubVideo || !startCam((jpeg, seq) => { if (room !== r) return; gridNote(server.id, jpeg); for (const plain of videoChunks(seq, jpeg)) sendRoomVideo(r, plain); })) return cb(new Error('unavailable')); + if (!r.hubVideo || !startCam((jpeg, seq) => { if (room !== r) return; gridNote(r, server.id, jpeg); sendRoomJpeg(r, seq, jpeg, 0); })) return cb(new Error('unavailable')); r.cam = true; } else { r.cam = false; stopCam(); - gridNote(server.id, null); + gridNote(r, server.id, null); sendRoomVideo(r, videoOff()); } roomChanged(); @@ -1579,13 +1793,78 @@ module.exports = { } cb(new Error('no-call')); }, + stream(opts, cb) { + const o = opts && typeof opts === 'object' ? opts : {}; + const r = room; + if (!r || r.phase !== 'live') return cb(new Error('no-room')); + if (o.stop) { stopRoomStream(r); return cb(null, roomSnapshot()); } + const file = streamFile(o.file); + if (!file) return cb(new Error('invalid')); + if (!startRoomStream(r, file, o.name || path.basename(file))) return cb(new Error('unavailable')); + roomChanged(); + cb(null, roomSnapshot()); + }, + viewerJoin(opts, cb) { + const o = opts && typeof opts === 'object' ? opts : {}; + const rid = String(o.rid || ''); + if (!RID.test(rid) || !isFeedId(o.owner) || !isFeedId(o.hub) || typeof o.token !== 'string') return cb(new Error('invalid')); + if (viewers.has(rid)) return cb(null, true); + if (room && room.rid === rid) return cb(new Error('busy')); + const r = { + rid, ref: String(o.ref || ''), title: '', hub: o.hub, phase: 'joining', joinedAt: Date.now(), muted: true, slot: null, max: ROOM_MAX, + peers: new Map(), order: [], keys: new Map(), kid: null, sealer: null, static: false, sentAt: 0, + events: [], notify: false, rec: null, recPeers: new Set(), hand: false, handAt: 0, tag: `pub:${rid}:`, grid: newGrid() + }; + viewers.set(rid, r); + r.out = Pushable(() => {}); + r.ready = (err) => { + r.ready = null; + clearTimeout(r.timer); + if (err) { dropViewer(r); return cb(err); } + startViewerMedia(r); + startGrid(r); + cb(null, true); + }; + r.timer = setTimeout(() => { if (r.ready) r.ready(new Error('unreachable')); }, ROOM_JOIN_MS); + const attach = (duplex) => { + if (viewers.get(rid) !== r || !r.ready) return closeDuplex(duplex); + if (!duplex) return r.ready(new Error('unreachable')); + pull(r.out, duplex.sink); + pull(duplex.source, pull.drain((frame) => onRoomFrame(r, frame), () => { + if (viewers.get(rid) !== r) return; + if (r.ready) r.ready(new Error('unreachable')); + else dropViewer(r); + })); + }; + openHub(o, { rid, owner: o.owner, token: o.token, viewer: true, ...(RID.test(String(o.roomId || '')) ? { roomId: String(o.roomId) } : {}) }, attach); + }, + viewerLeave(opts, cb) { + dropViewer(viewers.get(String((opts && opts.rid) || ''))); + cb(null, true); + }, + viewerRooms() { return [...viewers.values()].filter(r => r.phase === 'live').map(r => r.rid); }, + publicFeed(opts) { + const rid = String((opts && opts.rid) || ''); + const kind = opts && opts.kind === 'sound' ? 'sound' : 'grid'; + const r = viewers.get(rid); + const target = r ? r.tag + kind : ''; + const p = Pushable(() => { const set = frameWatchers.get(target); if (set) { set.delete(p); if (!set.size) frameWatchers.delete(target); } }); + if (!r || r.phase !== 'live' || (kind === 'grid' && !sharpLib)) { p.end(); return p; } + if (!frameWatchers.has(target)) frameWatchers.set(target, new Set()); + frameWatchers.get(target).add(p); + if (kind === 'grid') { r.grid.dirty = true; r.grid.empty = false; } + return p; + }, videoFrames(opts) { const target = String((opts && opts.peer) || ''); const p = Pushable(() => { const set = frameWatchers.get(target); if (set) { set.delete(p); if (!set.size) frameWatchers.delete(target); } }); const now = Date.now(); + const filmOf = target.startsWith('film:') && target !== 'film:me' ? target.slice(5) : null; const live = target === 'me' ? !!cam + : target === 'film:me' ? !!(room && room.stream) : target === 'grid' ? !!(room && sharpLib && gridIds(room).length) + : filmOf ? !!room && [...room.peers.values()].some(x => x.id === filmOf && now - (x.filmAt || 0) < VIDEO_HOLD_MS) : call ? (call.peer === target && !!call.peerCam) : room ? [...room.peers.values()].some(x => x.id === target && now - (x.frameAt || 0) < VIDEO_HOLD_MS) : false; @@ -1694,7 +1973,7 @@ module.exports = { roomHub(opts) { return hubDuplex(this && this.id, opts); }, roomInfo(opts, cb) { const r = hubRooms.get(String((opts && opts.rid) || '')); - cb(null, { count: r ? r.members.size : 0, max: roomMax, video: policy().video !== false }); + cb(null, { count: r ? hubHumans(r) : 0, max: roomMax, video: policy().video !== false }); }, roomAdmits(opts, cb) { const from = this && this.id; @@ -1705,13 +1984,14 @@ module.exports = { if (!audio) return cb(new Error('unavailable')); if (call || room) return cb(new Error('busy')); const rid = String((opts && opts.rid) || ''); + if (viewers.has(rid)) dropViewer(viewers.get(rid)); if (!RID.test(rid) || !isFeedId(opts.owner) || !isFeedId(opts.hub) || typeof opts.token !== 'string') return cb(new Error('invalid')); const secrets = (Array.isArray(opts.secrets) ? opts.secrets : []).filter(x => typeof x === 'string' && x); const r = { rid, ref: String(opts.ref || ''), title: String(opts.title || '').slice(0, 200), hub: opts.hub, phase: 'joining', joinedAt: Date.now(), muted: false, slot: null, max: ROOM_MAX, peers: new Map(), order: [], keys: new Map(), kid: null, sealer: null, static: secrets.length > 0, sentAt: 0, - events: [], notify: true, rec: null, recPeers: new Set(), hand: false, handAt: 0 + events: [], notify: true, rec: null, recPeers: new Set(), hand: false, handAt: 0, tag: '', grid: newGrid() }; if (r.static) { for (const secret of secrets.slice().reverse()) { const k = staticKey(rid, secret); r.keys.set(k.kid, k.key); } @@ -1726,7 +2006,7 @@ module.exports = { clearTimeout(r.timer); if (err) { leaveRoom(r, null); return cb(err); } startRoomMedia(r); - if (sharpLib) { gridTimer = setInterval(gridTick, Math.round(1000 / VIDEO_FPS)); if (gridTimer.unref) gridTimer.unref(); } + startGrid(r); roomChanged(); cb(null, roomSnapshot()); }; @@ -1741,20 +2021,7 @@ module.exports = { else leaveRoom(r, 'lost'); })); }; - const req = { rid, owner: opts.owner, token: opts.token, ...(RID.test(String(opts.roomId || '')) ? { roomId: String(opts.roomId) } : {}) }; - if (opts.hub === server.id) return attach(hubDuplex(server.id, req)); - const viaRpc = (rpc) => attach(rpc && rpc.phone && typeof rpc.phone.roomHub === 'function' ? rpc.phone.roomHub(req, () => {}) : null); - const live = liveRpc(opts.hub); - if (live) return viaRpc(live); - if (typeof opts.address === 'string' && opts.address) { - try { - return server.conn.connect(opts.address, (err, rpc) => { - const got = !err && rpc && rpc.phone ? rpc : liveRpc(opts.hub); - return got ? viaRpc(got) : reach(opts.hub, viaRpc); - }); - } catch (_) {} - } - reach(opts.hub, viaRpc); + openHub(opts, { rid, owner: opts.owner, token: opts.token, ...(RID.test(String(opts.roomId || '')) ? { roomId: String(opts.roomId) } : {}) }, attach); }, roomLeave(cb) { leaveRoom(room, 'left'); @@ -1787,8 +2054,8 @@ module.exports = { roomCount(opts, cb) { const rid = String((opts && opts.rid) || ''); if (!RID.test(rid) || !isFeedId(opts.hub)) return cb(null, null); - if (room && room.rid === rid && room.phase === 'live') return cb(null, { count: room.peers.size + 1, max: room.max }); - if (opts.hub === server.id) { const h = hubRooms.get(rid); return cb(null, { count: h ? h.members.size : 0, max: roomMax }); } + if (room && room.rid === rid && room.phase === 'live') return cb(null, { count: humansIn(room) + 1, max: room.max }); + if (opts.hub === server.id) { const h = hubRooms.get(rid); return cb(null, { count: h ? hubHumans(h) : 0, max: roomMax }); } const rpc = liveRpc(opts.hub); if (!rpc || typeof rpc.phone.roomInfo !== 'function') return cb(null, null); let done = false; @@ -1840,6 +2107,7 @@ module.exports = { module.exports.setAudioFactory = (factory) => { audioFactory = typeof factory === 'function' ? factory : systemAudio; }; module.exports.setVideoFactory = (factory) => { videoFactory = typeof factory === 'function' ? factory : systemVideo; }; +module.exports.setStreamFactory = (factory) => { streamFactory = typeof factory === 'function' ? factory : systemStream; }; module.exports.jpegSplitter = jpegSplitter; module.exports.VIDEO_CHUNK = VIDEO_CHUNK; module.exports.VIDEO_HOLD_MS = VIDEO_HOLD_MS; @@ -1853,3 +2121,4 @@ module.exports.RING_PER_WINDOW = RING_PER_WINDOW; module.exports.chimeMs = chimeMs; module.exports.recordingPrefix = recordingPrefix; module.exports.VOICEMAIL_MAX_MS = VOICEMAIL_MAX_MS; +module.exports.SOUND_RATE = RATE; diff --git a/src/server/phone_stream_android.js b/src/server/phone_stream_android.js new file mode 100644 index 0000000..f2c525c --- /dev/null +++ b/src/server/phone_stream_android.js @@ -0,0 +1,53 @@ +const fs = require('fs'); +const net = require('net'); +const path = require('path'); + +const MARKER = path.join(__dirname, '..', '..', '.audio_bridge'); +const FRAME_MAX = 4 * 1024 * 1024; + +const bridgePort = () => { + try { + const port = Number(String(fs.readFileSync(MARKER, 'utf8')).trim()); + return Number.isFinite(port) && port > 0 ? port : 0; + } catch (_) { + return 0; + } +}; + +module.exports = () => ({ + open(file, { onJpeg, onPcm, onEnd }) { + let ended = false; + const finish = () => { + if (ended) return; + ended = true; + try { onEnd(); } catch (_) {} + }; + const port = bridgePort(); + if (!port) { + setImmediate(finish); + return { stop() { ended = true; } }; + } + const sock = net.connect(port, '127.0.0.1'); + let buf = Buffer.alloc(0); + sock.setNoDelay(true); + sock.on('error', finish); + sock.on('close', finish); + sock.on('connect', () => sock.write(Buffer.concat([Buffer.from('S'), Buffer.from(`${file}\n`, 'utf8')]))); + sock.on('data', (chunk) => { + buf = buf.length ? Buffer.concat([buf, chunk]) : chunk; + while (buf.length >= 5) { + const len = buf.readUInt32BE(1); + if (len > FRAME_MAX) { sock.destroy(); return; } + if (buf.length < 5 + len) return; + const type = buf[0]; + const payload = Buffer.from(buf.subarray(5, 5 + len)); + buf = buf.subarray(5 + len); + if (ended) continue; + if (type === 0x4a) onJpeg(payload); + else if (type === 0x41) onPcm(payload); + else if (type === 0x45) { finish(); sock.destroy(); return; } + } + }); + return { stop() { ended = true; try { sock.destroy(); } catch (_) {} } }; + } +}); diff --git a/src/server/ssb_config.js b/src/server/ssb_config.js index 1f1a47f..881a94d 100644 --- a/src/server/ssb_config.js +++ b/src/server/ssb_config.js @@ -47,9 +47,9 @@ config.blobs.max = 75 * megabyte; config.db2 = { automigrate: false, dangerouslyKillFlumeWhenMigrated: false, ...(config.db2 || {}) }; const lanOn = (() => { try { return require('../configs/config-manager').getConfig().lanBroadcasting !== false; } catch (_) { return true; } })(); -const bindHost = config.pub || lanOn ? '0.0.0.0' : '127.0.0.1'; +const bindHost = config.pub ? '0.0.0.0' : lanOn ? null : '127.0.0.1'; for (const entry of (config.connections && config.connections.incoming && config.connections.incoming.net) || []) { - if (entry && typeof entry === 'object' && !entry.host) entry.host = bindHost; + if (entry && typeof entry === 'object' && !entry.host && bindHost) entry.host = bindHost; } config.account = account; diff --git a/src/server/ssb_metadata.js b/src/server/ssb_metadata.js index a25dd62..1646ebd 100644 --- a/src/server/ssb_metadata.js +++ b/src/server/ssb_metadata.js @@ -24,16 +24,9 @@ const blobUsageOnDisk = (ssbPath) => { let printed = false; let checkedForUpdate = false; -let pendingClearnetModules = null; -let clearnetReady = false; let pendingWarmup = null; let warmupReady = false; -function setClearnetModules(modules) { - pendingClearnetModules = Array.isArray(modules) ? modules : []; - clearnetReady = true; -} - function setWarmupTime(str) { pendingWarmup = str; warmupReady = true; @@ -81,19 +74,6 @@ async function checkForUpdate() { await updater.getRemoteVersion(); } -function waitForClearnet(timeoutMs = 25000) { - return new Promise((resolve) => { - if (clearnetReady) return resolve(pendingClearnetModules || []); - const started = Date.now(); - const tick = () => { - if (clearnetReady) return resolve(pendingClearnetModules || []); - if (Date.now() - started >= timeoutMs) return resolve(null); - setTimeout(tick, 250); - }; - tick(); - }); -} - async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, httpHost = 'localhost', offline = false, isPublic = false) { if (printed) return; printed = true; @@ -151,8 +131,6 @@ async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, htt console.log(`- Media cache limit: ${cacheMb > 0 ? formatMB(cacheMb * 1024 * 1024) : 'Unlimited'}`); console.log(`- Current usage: ${formatMB(cacheUsage.bytes, 1)} · ${cacheUsage.files} files`); console.log(`- LAN Broadcasting (UDP): ${localDiscovery ? 'enabled' : 'disabled'}`); - const clearnetModules = await waitForClearnet(); - const clearnetStatus = (clearnetModules && clearnetModules.length > 0) ? clearnetModules.join(', ') : 'disabled'; const multiverse = (() => { if ((oasisCfg.modules || {}).fediverseMod === 'off') return []; try { @@ -164,12 +142,10 @@ async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, htt ].filter(Boolean); } catch (_) { return []; } })(); - console.log(`- Internet Broadcasting:`); - console.log(` - Clearnet: ${clearnetStatus}`); - console.log(` - Multiverse: ${multiverse.length ? multiverse.join(', ') : 'disabled'}`); + console.log(`- Multiverse: ${multiverse.length ? multiverse.join(', ') : 'disabled'}`); if (hasHttp) { const en = (() => { try { return require('../client/assets/translations/i18n').en || {}; } catch (_) { return {}; } })(); - const uxNames = { blocks: en.uxModeMenus, ainav: en.uxModeAINav, chats: en.uxModeChats, feed: en.uxModeFeed, phone: en.phoneTitle }; + const uxNames = { blocks: en.uxModeMenus, ainav: en.uxModeAINav, chats: en.uxModeChats, feed: en.uxModeFeed, phone: en.phoneTitle, rooms: en.roomsTitle }; const wishNames = { whole: en.settingsWishWhole, mutuals: en.settingsWishMutuals, 'only-lan': en.settingsWishOnlyLan, local: en.settingsWishLocal }; const workflow = (() => { try { @@ -202,7 +178,6 @@ async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, htt module.exports = { printMetadata, - setClearnetModules, setWarmupTime, colors }; diff --git a/src/views/activity_view.js b/src/views/activity_view.js index 04a92a0..842979a 100644 --- a/src/views/activity_view.js +++ b/src/views/activity_view.js @@ -837,7 +837,11 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e ) : '', contentWarning ? h2({ class: 'content-warning' }, contentWarning) : '', - bodyNode + bodyNode, + Array.isArray(content?.tags) && content.tags.filter(Boolean).length + ? div({ class: 'card-tags' }, content.tags.filter(Boolean).map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))) + : '' ) ); } @@ -1883,6 +1887,7 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e ), renderContentActions(msgId, detailHref, { author: action.author, + contact: type === 'about', reportTitle: (content && (content.title || content.question || content.concept || content.name)) || '', spread: SPREADABLE_TYPES.has(type) ? (spreadMap.get(action.id) || null) : undefined, ...(type === 'torrent' || type === 'file' ? { spreadTitle: i18n.seedAction } : {}), diff --git a/src/views/audio_view.js b/src/views/audio_view.js index cbe2e75..83e9b6b 100644 --- a/src/views/audio_view.js +++ b/src/views/audio_view.js @@ -18,7 +18,7 @@ const { const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } = require("./comments_view"); const { renderLicenseChip, renderLicenseSelect, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require('./clearnet_view'); -const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions , renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views"); +const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf } = require("./main_views"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const { renderStyledText } = require("../backend/renderStyledText") @@ -61,7 +61,7 @@ const renderAudioPlayer = (audioObj, opts = {}) => audioObj?.url ? div( { class: "audio-container" }, - audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(audioObj.url)}`, preload: "metadata" }), + audioHyperaxe({ controls: true, src: blobUrlOf(audioObj.url), preload: "metadata" }), opts.skipTranscode ? null : renderTranscodeButton(audioObj) ) : p(i18n.audioNoFile); @@ -236,7 +236,6 @@ exports.audioView = async (audios, filter = "all", audioId = null, params = {}) { class: "filters" }, form( { method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" }, - input({ type: "hidden", name: "q", value: q }), input({ type: "hidden", name: "sort", value: sort }), ...(emptyMod ? [] : [ button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterAll).toUpperCase()), @@ -380,7 +379,6 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params { class: "filters" }, form( { method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" }, - input({ type: "hidden", name: "q", value: q }), input({ type: "hidden", name: "sort", value: sort }), button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterAll).toUpperCase()), ...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterRecent).toUpperCase())] : []), diff --git a/src/views/banking_views.js b/src/views/banking_views.js index 44337f0..0db52a8 100644 --- a/src/views/banking_views.js +++ b/src/views/banking_views.js @@ -1,6 +1,6 @@ const { div, h2, h3, p, section, button, form, a, input, span, pre, table, thead, tbody, tr, td, th, br, strong, label, ul, li } = require("../server/node_modules/hyperaxe"); const RAW_HTML = Symbol.for('oasis.rawHtml'); -const { template, i18n, userLink, formatCarbon, renderStateChip, renderModuleStats, renderWalletChip, paged } = require("../views/main_views"); +const { template, i18n, userLink, formatCarbon, renderStateChip, renderModuleStats, renderWalletChip, paged, renderIcon } = require("../views/main_views"); const moment = require("../server/node_modules/moment"); const FILTER_LABELS = { @@ -489,7 +489,7 @@ const renderUbiPubs = (pubs, hasValidWallet) => { userLink(p.pubId), p.isThisNode || p.isConnected ? span({ class: "status you" }, p.isThisNode ? i18n.bankUbiPubThisNode : i18n.bankUbiPubConnected) : null, hasValidWallet && p.address - ? div({ class: "content-actions" }, form({ method: "GET", action: "/banking/fund", class: "content-action-form" }, input({ type: "hidden", name: "pub", value: p.pubId }), button({ type: "submit", class: "btn-singleview btn-donate", title: i18n.bankDonateEco }, "\u2744"))) + ? div({ class: "content-actions" }, form({ method: "GET", action: "/banking/fund", class: "content-action-form" }, input({ type: "hidden", name: "pub", value: p.pubId }), button({ type: "submit", class: "btn-singleview btn-donate", title: i18n.bankDonateEco }, renderIcon("donate")))) : null ), div({ class: "bank-summary ubi-pub-summary" }, diff --git a/src/views/blockchain_view.js b/src/views/blockchain_view.js index f3d1318..2c9ae2e 100644 --- a/src/views/blockchain_view.js +++ b/src/views/blockchain_view.js @@ -1,5 +1,5 @@ const { div, h2, h3, p, section, button, form, a, input, span, pre, table, tr, td, strong } = require("../server/node_modules/hyperaxe"); -const { template, i18n, userLink, paged } = require("../views/main_views"); +const { template, i18n, userLink, paged, renderIcon } = require("../views/main_views"); const moment = require("../server/node_modules/moment"); const FILTER_LABELS = { @@ -175,7 +175,9 @@ const getViewDetailsAction = (type, block) => { case 'event': return `/events/${encodeURIComponent(block.id)}`; case 'task': return `/tasks/${encodeURIComponent(block.id)}`; case 'about': return `/author/${encodeURIComponent(block.author)}`; - case 'post': return `/thread/${encodeURIComponent(block.id)}#${encodeURIComponent(block.id)}`; + case 'post': return block.content && typeof block.content.root === 'string' + ? `/thread/${encodeURIComponent(block.content.root)}#${encodeURIComponent(block.id)}` + : `/blogs/${encodeURIComponent(block.id)}`; case 'vote': return `/thread/${encodeURIComponent(block.content.vote.link)}#${encodeURIComponent(block.content.vote.link)}`; case 'contact': return `/inhabitants`; case 'pub': return `/invites`; @@ -424,7 +426,7 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi button({ type:'submit', class:'filter-btn' }, `← ${i18n.blockchainBack}`) ), !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ? - a({ href:getViewDetailsAction(block.type, block), class:'btn-singleview btn-content', title:i18n.visitContent }, '↗') + a({ href:getViewDetailsAction(block.type, block), class:'btn-singleview btn-content', title:i18n.visitContent }, renderIcon('open')) : (block.isTombstoned || block.isReplaced) ? div({ class: 'deleted-label' }, i18n.blockchainContentDeleted || "This content has been deleted." @@ -534,10 +536,10 @@ const renderBlockchainView = (blocks, filter, userId, search = {}, extras = {}) .map(block=> div({ class:'block' }, div({ class:'block-buttons' }, - block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails }, '⦿'), - block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram }, '⊞'), + block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails }, renderIcon('block')), + block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram }, renderIcon('datagram')), !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ? - a({ href:getViewDetailsAction(block.type, block), class:'btn-singleview btn-content', title:i18n.visitContent }, '↗') + a({ href:getViewDetailsAction(block.type, block), class:'btn-singleview btn-content', title:i18n.visitContent }, renderIcon('open')) : (block.isTombstoned || block.isReplaced) ? div({ class: 'deleted-label' }, i18n.blockchainContentDeleted || "This content has been deleted." diff --git a/src/views/blog_view.js b/src/views/blog_view.js index df19bcb..d9bbfe1 100644 --- a/src/views/blog_view.js +++ b/src/views/blog_view.js @@ -21,9 +21,16 @@ const FILTERS = [ const safeText = (v) => String(v == null ? "" : v).trim(); +const renderBlogTags = (tags) => { + const list = (Array.isArray(tags) ? tags : []).map(t => String(t || "").trim().replace(/^#+/, "")).filter(Boolean); + return list.length + ? div({ class: "card-tags" }, list.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))) + : null; +}; + const excerpt = (text, max = 420) => { const s = safeText(text); - return s.length > max ? s.slice(0, max) + "…" : s; + return s.length > max ? s.slice(0, max).replace(/\s+$/, "") + "…" : s; }; const renderFilterBar = (filter, q, showSearch = true, total = null, censusList = null) => { @@ -77,6 +84,7 @@ const renderBlogCard = (blog, filter, spreadInfo) => { : null, blog.clearnet === true ? div({ class: "card-chips-row" }, renderReachChip(true, i18n, clearnetItemHref("blog", blog.subject || blog.text, blog.id))) : null, div({ class: "blog-card-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(excerpt(blog.text))) }), + renderBlogTags(blog.tags), p({ class: "card-footer" }, span({ class: "date-link" }, `${moment(blog.createdAt).format("YYYY/MM/DD HH:mm")}`), userLink(blog.author) @@ -89,13 +97,15 @@ const renderCreateForm = (draft = null) => { const subjectValue = draft ? safeText(draft.subject) : ""; const textValue = draft ? String(draft.text || "") : ""; const allowComments = draft ? draft.allowComments !== false : true; + const tagsValue = draft ? (Array.isArray(draft.tags) ? draft.tags.join(", ") : safeText(draft.tags)) : ""; return section( draft ? section({ class: "post-preview blog-preview" }, div({ class: "preview-content" }, h2(i18n.messagePreview), subjectValue ? h2({ class: "tribe-card-title" }, subjectValue) : null, - div({ class: "blog-detail-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(textValue)) }) + div({ class: "blog-detail-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(textValue)) }), + renderBlogTags(draft.tags) ) ) : null, @@ -105,17 +115,19 @@ const renderCreateForm = (draft = null) => { br(), input({ type: "text", name: "subject", id: "subject", maxlength: "150", placeholder: i18n.contentWarningPlaceholder, value: subjectValue }), br(), - br(), label({ for: "text" }, i18n.blogMessage), br(), textarea({ required: true, name: "text", id: "text", rows: "8", class: "publish-textarea", maxlength: String(TEXT_CAP), placeholder: i18n.publishWarningPlaceholder }, textValue), br(), - br(), label({ for: "blob" }, i18n.blogMedia), br(), input({ type: "file", id: "blob", name: "blob", multiple: true, accept: "image/*,video/*,audio/*,application/pdf" }), br(), br(), + label({ for: "tags" }, i18n.tagsLabel || "Tags"), + br(), + input({ type: "text", name: "tags", id: "tags", placeholder: i18n.tagsPlaceholder || "", value: tagsValue }), + br(), div({ class: "blog-allow-comments" }, input({ type: "hidden", name: "allowComments", value: "0" }), label( @@ -206,7 +218,8 @@ exports.singleBlogView = async (blog, comments = [], params = {}) => { const blogMain = div({ class: "tribe-main" }, div({ class: "job-section" }, - div({ class: "blog-detail-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(blog.text || "")) }) + div({ class: "blog-detail-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(blog.text || "")) }), + renderBlogTags(blog.tags) ), renderEngagement(blog.id, renderOpinionsVoting('/blogs/opinions', blog.id, blog.opinions, href, blog.opinions_inhabitants), diff --git a/src/views/bookmark_view.js b/src/views/bookmark_view.js index e979039..8b6d2eb 100644 --- a/src/views/bookmark_view.js +++ b/src/views/bookmark_view.js @@ -227,7 +227,6 @@ exports.bookmarkView = async (bookmarks, filter = "all", bookmarkId = null, para { class: "filters" }, form( { method: "GET", action: "/bookmarks", class: "ui-toolbar ui-toolbar--filters" }, - input({ type: "hidden", name: "q", value: q }), input({ type: "hidden", name: "sort", value: sort }), ...(emptyMod ? [] : [ button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.bookmarkFilterAll).toUpperCase()), @@ -369,7 +368,6 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par { class: "filters" }, form( { method: "GET", action: "/bookmarks", class: "ui-toolbar ui-toolbar--filters" }, - input({ type: "hidden", name: "q", value: q }), input({ type: "hidden", name: "sort", value: sort }), button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.bookmarkFilterAll).toUpperCase()), ...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.bookmarkFilterRecent).toUpperCase())] : []), diff --git a/src/views/campaigns_view.js b/src/views/campaigns_view.js index 9b0cc88..d3758fb 100644 --- a/src/views/campaigns_view.js +++ b/src/views/campaigns_view.js @@ -76,7 +76,6 @@ const renderFilters = (filter, q, census, emptyMod) => section( div({ class: "filters" }, form({ method: "GET", action: "/campaigns", class: "ui-toolbar ui-toolbar--filters" }, - input({ type: "hidden", name: "q", value: q || "" }), ...(emptyMod ? [] : [ ...chipButtons(BASE_FILTERS, filter, census, filterLabel), ...chipButtons(STATUSES, filter, census, statusLabel), diff --git a/src/views/chats_view.js b/src/views/chats_view.js index afafbda..bb8202f 100644 --- a/src/views/chats_view.js +++ b/src/views/chats_view.js @@ -21,6 +21,7 @@ const catLabel = (c) => i18n[catKey(c)] || c const blobSrcOf = (value) => { const s = String(value || "").trim() + if (s.startsWith("/blob/sealed/")) return s if (s.startsWith("&")) return `/blob/${encodeURIComponent(s)}` const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) return mImg ? `/blob/${encodeURIComponent(mImg[1])}` : null @@ -30,9 +31,10 @@ const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => { if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null const s = String(value).trim() if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null + if (s.startsWith("/blob/sealed/")) return img({ loading: 'lazy', src: s, ...attrs }) if (s.startsWith("&")) return img({ loading: 'lazy', src: `/blob/${encodeURIComponent(s)}`, ...attrs }) - const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) - if (mImg) return img({ loading: 'lazy', src: `/blob/${encodeURIComponent(mImg[1])}`, ...attrs }) + const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256|\/blob\/sealed\/[^)\s]+)\s*\)/) + if (mImg) return img({ loading: 'lazy', src: mImg[1].startsWith("/blob/sealed/") ? mImg[1] : `/blob/${encodeURIComponent(mImg[1])}`, ...attrs }) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null } diff --git a/src/views/clearnet_view.js b/src/views/clearnet_view.js index a46c7e3..a54687d 100644 --- a/src/views/clearnet_view.js +++ b/src/views/clearnet_view.js @@ -238,6 +238,47 @@ const renderClearnetUrlBlock = ({ baseUrl = '', path, i18nObj = {} }) => { ); }; +const CLEARNET_ZOOM_CSS = ` +.cn-zoom{display:contents} +.cn-zoom img{cursor:zoom-in} +.cn-lightbox{display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.9);align-items:center;justify-content:center} +.cn-lightbox:target{display:flex} +.cn-lightbox-link{display:flex;align-items:center;justify-content:center;width:100%;height:100%;cursor:zoom-out} +.cn-lightbox-img{display:block;max-width:92vw;max-height:88vh;object-fit:contain} +.cn-lightbox-full{background:#000} +.cn-lightbox-full .cn-lightbox-img{width:100vw;height:100vh;max-width:100vw;max-height:100vh} +.cn-lightbox-close,.cn-lightbox-fs{position:absolute;top:14px;z-index:1;width:42px;height:42px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;text-decoration:none;font-size:24px;line-height:1} +.cn-lightbox-close{right:16px} +.cn-lightbox-fs{right:66px} +.cn-lightbox-fs svg{display:block;width:20px;height:20px} +`; +const CN_FULLSCREEN_ICON = ''; +const cnZoom = (id, src, alt = '', { full = false, imgClass = '' } = {}) => { + const zid = `cnz-${String(id).replace(/[^A-Za-z0-9_-]/g, '')}`; + const a = escapeHtml(alt); + const u = escapeHtml(src); + const cls = imgClass ? ` class="${escapeHtml(imgClass)}"` : ''; + const fs = full ? `${CN_FULLSCREEN_ICON}` : ''; + return `` + + `

×${fs}${a}
` + + (full ? `` : ''); +}; +const CN_ZOOM_SKIP = /\bcn-(lightbox-img|footer-logo)\b/; +const zoomClearnetImages = (html) => { + let depth = 0; + let n = 0; + return String(html || '').replace(/]*>|<\/a\s*>|]*>/gi, (tag) => { + if (/^ 0) return tag; + const cls = (tag.match(/\sclass="([^"]*)"/i) || [])[1] || ''; + const src = (tag.match(/\ssrc="([^"]*)"/i) || [])[1] || ''; + if (!src || CN_ZOOM_SKIP.test(cls)) return tag; + const alt = (tag.match(/\salt="([^"]*)"/i) || [])[1] || ''; + const zid = `cnz-i${++n}`; + return `${tag}×${alt}`; + }); +}; const CLEARNET_TEXT_CSS = ` .cn-tags{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0} .cn-hub-priceline{margin:10px 0 0 0} @@ -266,8 +307,11 @@ a.tag-link:hover{text-decoration:underline} .rt-rule{display:block;border-top:1px solid currentColor;opacity:.4;margin:10px 0} .rt-code{font-family:monospace;background:rgba(128,128,128,.18);padding:1px 4px;border-radius:3px;word-break:break-word} .rt-code-block{display:block;font-family:monospace;white-space:pre-wrap;background:rgba(128,128,128,.18);padding:10px;border-radius:5px;margin:8px 0;overflow-x:auto;word-break:break-word} -.post-image{display:block;max-width:100%;height:auto;margin:12px 0;border:1px solid var(--border);border-radius:6px} -.post-video,.post-audio{display:block;width:100%;max-width:100%;margin:12px 0;border-radius:6px;background:#000} +.post-image{display:block;width:auto;max-width:100%;max-height:480px;height:auto;margin:12px auto;border:1px solid var(--border);border-radius:6px} +.post-video{display:block;width:100%;max-width:100%;max-height:480px;margin:12px auto;border-radius:6px;background:#000} +.post-audio{display:block;width:100%;max-width:100%;margin:12px 0} +.cn-media-frame img{max-height:480px;width:auto;margin:0 auto} +.cn-media-frame video{max-height:480px} .post-pdf{display:inline-block;margin:8px 0;padding:8px 14px;background:var(--bg-sub);border:1px solid var(--border);border-radius:6px;color:var(--fg);text-decoration:none} .post-pdf:hover{border-color:var(--fg)} `; @@ -346,7 +390,10 @@ footer.cn-footer .cn-footer-logo{width:56px;height:auto;display:block;margin:0 a footer.cn-footer .cn-footer-line{margin:3px 0} `; -const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null, extraCss = '', body, headerExtra = '', hubFeedId = null }) => { +const renderClearnetHubSearch = ({ type = '', query = '' } = {}) => + ``; + +const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null, extraCss = '', body, headerExtra = null, hubFeedId = null }) => { const safeTitle = escapeHtml(title || 'Oasis'); const safeOgTitle = escapeHtml(ogTitle || title || 'Oasis'); const safeOgDesc = escapeHtml(ogDescription || ''); @@ -368,7 +415,7 @@ const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null -