upstream: integrar Oasis 1.3.1
Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.
Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).
Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
This commit is contained in:
parent
8d838dd8b1
commit
c332afd6ea
265 changed files with 80970 additions and 29167 deletions
|
|
@ -2,11 +2,30 @@ const pull = require('../server/node_modules/pull-stream');
|
|||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
const tribeLogLimit = Math.max(logLimit, 100000);
|
||||
const { readTyped } = require('./typed_log');
|
||||
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 categories = require('../backend/opinion_categories');
|
||||
const VALID_STATUSES = ['OPEN', 'CLOSED', 'IN-PROGRESS'];
|
||||
const VALID_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
|
||||
const EXPOSURE_TYPE = 'tribeExposure';
|
||||
const REACH_LEVELS = ['tribe', 'oasis', 'clearnet'];
|
||||
const REACH_RANK = { tribe: 0, oasis: 1, clearnet: 2 };
|
||||
const SNAPSHOT_FIELDS = ['contentType', 'title', 'description', 'status', 'date', 'location', 'price', 'salary', 'priority', 'options', 'category', 'tags', 'image', 'mediaType', 'license', 'url', 'deadline', 'goal', 'dates', 'lat', 'lng', 'markers', 'createdAt', 'updatedAt'];
|
||||
const snapshotOf = (item, replies = []) => {
|
||||
const out = {};
|
||||
for (const f of SNAPSHOT_FIELDS) {
|
||||
const v = item[f];
|
||||
if (v === undefined || v === null || v === '' || (Array.isArray(v) && !v.length)) continue;
|
||||
out[f] = v;
|
||||
}
|
||||
out.author = item.author;
|
||||
if (item.contentType === 'forum') out.replies = (replies || []).map(r => ({ author: r.author, description: r.description || '', createdAt: r.createdAt || null }));
|
||||
return out;
|
||||
};
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
||||
let ssb;
|
||||
|
|
@ -14,12 +33,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
|
||||
const readLog = async () => {
|
||||
const client = await openSsb();
|
||||
return new Promise((resolve, reject) =>
|
||||
pull(
|
||||
client.createLogStream({ limit: tribeLogLimit }),
|
||||
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
|
||||
)
|
||||
);
|
||||
return readTyped(client, TRIBE_LOG_TYPES, { limit: tribeLogLimit });
|
||||
};
|
||||
|
||||
const fingerprintsForRoot = (rootId) => {
|
||||
|
|
@ -38,6 +52,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const publishContent = async (rootId, body) => {
|
||||
const publishChunk = (c) => wrapAndPublishContent(rootId, { k: longText.CHUNK_TYPE, rootId, text: c.text, index: c.index, total: c.total, group: c.group });
|
||||
const sealed = await longText.chunkContent(body, 'description', { maxBytes: longText.CONTENT_BYTES.sealed, extra: { rootId }, publish: publishChunk });
|
||||
return wrapAndPublishContent(rootId, sealed);
|
||||
};
|
||||
|
||||
const decodeContentMsgs = async (msgs, opts) => {
|
||||
const fpIdx = tribeCrypto.buildFingerprintIndex();
|
||||
const targetRootId = opts && opts.rootId ? opts.rootId : null;
|
||||
|
|
@ -45,6 +65,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const allowedFps = targetRootId ? fingerprintsForRoot(targetRootId) : null;
|
||||
|
||||
const content = new Map();
|
||||
const chunks = new Map();
|
||||
const tombRequests = [];
|
||||
const collabMsgs = [];
|
||||
|
||||
|
|
@ -62,36 +83,30 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
tombRequests.push({ target: b.target, author: m.value.author });
|
||||
continue;
|
||||
}
|
||||
if (b.k === longText.CHUNK_TYPE) {
|
||||
const ch = longText.chunkOf(m.value.author, b);
|
||||
if (ch) chunks.set(m.key, ch);
|
||||
continue;
|
||||
}
|
||||
if (b.k === 'tc-collab') {
|
||||
collabMsgs.push({ sub: b.sub, target: b.target, author: m.value.author, ts: m.value.timestamp || 0, option: b.option, on: b.on, category: b.category });
|
||||
collabMsgs.push({ sub: b.sub, target: b.target, author: m.value.author, ts: m.value.timestamp || 0, seq: m.value.sequence || 0, option: b.option, on: b.on, category: b.category });
|
||||
continue;
|
||||
}
|
||||
if (b.k !== 'tribe-content') continue;
|
||||
content.set(m.key, { author: m.value.author, body: b, ts: m.value.timestamp });
|
||||
content.set(m.key, { author: m.value.author, body: b, ts: m.value.timestamp, seq: m.value.sequence || 0 });
|
||||
}
|
||||
|
||||
const naiveNext = new Map();
|
||||
const strictNext = new Map();
|
||||
for (const [key, node] of content) {
|
||||
const t = node.body.replaces;
|
||||
if (!t) continue;
|
||||
naiveNext.set(t, key);
|
||||
const orig = content.get(t);
|
||||
if (orig && orig.author === node.author) strictNext.set(t, key);
|
||||
if (targetRootId && tribesModel && typeof tribesModel.departures === 'function') {
|
||||
const gone = await tribesModel.departures(targetRootId).catch(() => new Map());
|
||||
const afterLeaving = (author, ts, seq) => {
|
||||
const d = gone.get(author);
|
||||
return !!d && ((d.seq !== undefined && seq > d.seq) || (d.ts !== undefined && (Number(ts) || 0) > d.ts));
|
||||
};
|
||||
for (const [key, node] of [...content]) if (afterLeaving(node.author, node.ts, node.seq)) content.delete(key);
|
||||
for (let i = collabMsgs.length - 1; i >= 0; i--) if (afterLeaving(collabMsgs[i].author, collabMsgs[i].ts, collabMsgs[i].seq)) collabMsgs.splice(i, 1);
|
||||
}
|
||||
for (const [t, key] of Array.from(naiveNext.entries())) {
|
||||
const orig = content.get(t);
|
||||
if (!orig) { naiveNext.delete(t); continue; }
|
||||
const node = content.get(key);
|
||||
if (!node || node.author !== orig.author) { naiveNext.delete(t); content.delete(key); }
|
||||
}
|
||||
const naivePrev = new Map();
|
||||
for (const [t, key] of naiveNext) naivePrev.set(key, t);
|
||||
const followTo = (start, nextMap) => { let x = start, g = 0; while (nextMap.has(x) && g++ < 100000) x = nextMap.get(x); return x; };
|
||||
const rootOf = (key) => { let x = key, g = 0; while (naivePrev.has(x) && content.has(naivePrev.get(x)) && g++ < 100000) x = naivePrev.get(x); return x; };
|
||||
|
||||
const roots = new Set();
|
||||
for (const [key, node] of content) { const t = node.body.replaces; if (!t || !content.has(t)) roots.add(key); }
|
||||
const vg = versionGroups([...content].map(([key, node]) => ({ key, author: node.author, seq: node.seq, ts: node.ts, replaces: typeof node.body.replaces === 'string' ? node.body.replaces : null, claimedAuthor: typeof node.body.author === 'string' ? node.body.author : null })));
|
||||
|
||||
const tombstoned = new Set();
|
||||
for (const t of tombRequests) { const orig = content.get(t.target); if (orig && t.author === orig.author) tombstoned.add(t.target); }
|
||||
|
|
@ -100,7 +115,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const getCB = (root) => { if (!collabByRoot.has(root)) collabByRoot.set(root, { vote: new Map(), attend: new Map(), assign: new Map(), refeed: new Set(), opinion: new Map() }); return collabByRoot.get(root); };
|
||||
for (const cm of collabMsgs) {
|
||||
if (!content.has(cm.target)) continue;
|
||||
const a = getCB(rootOf(cm.target));
|
||||
const a = getCB(vg.rootOf(cm.target));
|
||||
if (cm.sub === 'vote' && Number.isInteger(cm.option)) { if (!a.vote.has(cm.author)) a.vote.set(cm.author, cm.option); }
|
||||
else if (cm.sub === 'attend') { const p = a.attend.get(cm.author); if (!p || cm.ts >= p.ts) a.attend.set(cm.author, { on: cm.on !== false, ts: cm.ts }); }
|
||||
else if (cm.sub === 'assign') { const p = a.assign.get(cm.author); if (!p || cm.ts >= p.ts) a.assign.set(cm.author, { on: cm.on !== false, ts: cm.ts }); }
|
||||
|
|
@ -112,15 +127,15 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const tipOf = new Map();
|
||||
const EMPTY = { vote: new Map(), attend: new Map(), assign: new Map(), refeed: new Set(), opinion: new Map() };
|
||||
|
||||
for (const root of roots) {
|
||||
const contentTip = followTo(root, strictNext);
|
||||
for (const root of vg.roots) {
|
||||
const contentTip = vg.tipOf(root);
|
||||
const tipNode = content.get(contentTip);
|
||||
if (!tipNode) continue;
|
||||
const body = tipNode.body;
|
||||
const lb = tipNode.body;
|
||||
const a = collabByRoot.get(root) || EMPTY;
|
||||
|
||||
const item = { id: contentTip, ...body, author: tipNode.author, _ts: tipNode.ts };
|
||||
const item = { id: contentTip, ...body, description: longText.joinText(body.description, body.chunks, longText.lookupIn(chunks), tipNode.author), author: tipNode.author, _ts: tipNode.ts, originId: root };
|
||||
|
||||
const voteAuthors = new Set(); const votes = {};
|
||||
const legacyVotes = lb.votes && typeof lb.votes === 'object' ? lb.votes : {};
|
||||
|
|
@ -145,8 +160,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
for (const [au, cat] of a.opinion) if (!opInh.has(au)) { opInh.add(au); opinions[cat] = (opinions[cat] || 0) + 1; }
|
||||
item.opinions_inhabitants = [...opInh]; item.opinions = opinions;
|
||||
|
||||
let cur = root, g = 0; tipOf.set(root, contentTip);
|
||||
while (naiveNext.has(cur) && g++ < 100000) { cur = naiveNext.get(cur); tipOf.set(cur, contentTip); }
|
||||
for (const k of (vg.groups.get(root) || [root])) tipOf.set(k, contentTip);
|
||||
|
||||
if (tombstoned.has(contentTip)) continue;
|
||||
if (targetRootId && body.rootId !== targetRootId) continue;
|
||||
|
|
@ -157,7 +171,23 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
return { items, tipOf, tombstoned };
|
||||
};
|
||||
|
||||
const chainIsPublic = async (tribeId) => {
|
||||
let cur;
|
||||
try { cur = await tribesModel.getTribeById(tribeId); } catch (_) { return false; }
|
||||
const seen = new Set();
|
||||
while (cur) {
|
||||
if (cur.isAnonymous) return false;
|
||||
if (!cur.parentTribeId) return true;
|
||||
if (seen.has(cur.parentTribeId)) return false;
|
||||
seen.add(cur.parentTribeId);
|
||||
try { cur = await tribesModel.getTribeById(cur.parentTribeId); } catch (_) { return false; }
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
chainIsPublic,
|
||||
|
||||
async create(tribeId, contentType, data) {
|
||||
if (!VALID_CONTENT_TYPES.includes(contentType)) throw new Error('Invalid content type');
|
||||
if (data.status && !VALID_STATUSES.includes(data.status)) throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
|
||||
|
|
@ -187,6 +217,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
tags: data.tags || [],
|
||||
image: data.image || null,
|
||||
mediaType: data.mediaType || null,
|
||||
...(data.license ? { license: data.license } : {}),
|
||||
url: data.url || null,
|
||||
attendees: data.attendees || [],
|
||||
deadline: data.deadline || null,
|
||||
|
|
@ -200,7 +231,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
return wrapAndPublishContent(rootId, body);
|
||||
return publishContent(rootId, body);
|
||||
},
|
||||
|
||||
async update(contentId, data, existing) {
|
||||
|
|
@ -215,7 +246,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
k: 'tribe-content',
|
||||
rootId,
|
||||
contentType: existing.contentType,
|
||||
replaces: contentId,
|
||||
replaces: existing.id || contentId,
|
||||
title: data.title !== undefined ? data.title : existing.title,
|
||||
description: data.description !== undefined ? data.description : existing.description,
|
||||
status: data.status !== undefined ? data.status : existing.status,
|
||||
|
|
@ -232,6 +263,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
tags: data.tags !== undefined ? data.tags : existing.tags,
|
||||
image: data.image !== undefined ? data.image : existing.image,
|
||||
mediaType: data.mediaType !== undefined ? data.mediaType : existing.mediaType,
|
||||
...((data.license !== undefined ? data.license : existing.license) ? { license: data.license !== undefined ? data.license : existing.license } : {}),
|
||||
url: data.url !== undefined ? data.url : existing.url,
|
||||
attendees: data.attendees !== undefined ? data.attendees : existing.attendees,
|
||||
deadline: data.deadline !== undefined ? data.deadline : existing.deadline,
|
||||
|
|
@ -245,7 +277,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
createdAt: existing.createdAt,
|
||||
updatedAt: now
|
||||
};
|
||||
return wrapAndPublishContent(rootId, body);
|
||||
return publishContent(rootId, body);
|
||||
},
|
||||
|
||||
async deleteById(contentId) {
|
||||
|
|
@ -256,7 +288,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const body = {
|
||||
k: 'tombstone',
|
||||
rootId,
|
||||
target: contentId,
|
||||
target: existing.id || contentId,
|
||||
author: client.id,
|
||||
deletedAt: new Date().toISOString()
|
||||
};
|
||||
|
|
@ -347,6 +379,103 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
return wrapAndPublishContent(item.rootId, { k: 'tc-collab', sub: 'opinion', rootId: item.rootId, target: item.id, category });
|
||||
},
|
||||
|
||||
reachLevels: REACH_LEVELS,
|
||||
|
||||
async publishExposure(item, level, replies = []) {
|
||||
if (!REACH_LEVELS.includes(level)) throw new Error('Invalid level');
|
||||
if (!item || !item.rootId) throw new Error('Content not found');
|
||||
const client = await openSsb();
|
||||
const publish = (c) => new Promise((resolve, reject) => client.publish(c, (err, r) => err ? reject(err) : resolve(r)));
|
||||
const content = {
|
||||
type: EXPOSURE_TYPE,
|
||||
tribeId: item.rootId,
|
||||
origin: item.originId || item.id,
|
||||
contentType: item.contentType,
|
||||
level,
|
||||
item: null,
|
||||
at: new Date().toISOString()
|
||||
};
|
||||
if (level !== 'tribe') {
|
||||
const { replies: snapReplies, ...snapshot } = snapshotOf(item, replies);
|
||||
const budget = longText.CONTENT_BYTES.plain - longText.contentBytes(content);
|
||||
const sealed = await longText.chunkContent(snapshot, 'description', { maxBytes: budget, publish });
|
||||
if (Array.isArray(snapReplies)) {
|
||||
sealed.replies = snapReplies.slice();
|
||||
while (sealed.replies.length && longText.contentBytes({ ...content, item: sealed }) > longText.CONTENT_BYTES.plain) sealed.replies.pop();
|
||||
}
|
||||
content.item = sealed;
|
||||
}
|
||||
return publish(content);
|
||||
},
|
||||
|
||||
async exposureState({ ignorePrivacy = false } = {}) {
|
||||
const client = await openSsb();
|
||||
const msgs = await readTyped(client, [EXPOSURE_TYPE, longText.CHUNK_TYPE], { limit: tribeLogLimit });
|
||||
const lookup = longText.lookupIn(longText.indexChunks(msgs));
|
||||
const byOrigin = new Map();
|
||||
for (const m of msgs) {
|
||||
const c = m && m.value && m.value.content;
|
||||
if (!c || c.type !== EXPOSURE_TYPE || typeof c.origin !== 'string' || typeof c.tribeId !== 'string' || !REACH_LEVELS.includes(c.level)) continue;
|
||||
if (!byOrigin.has(c.origin)) byOrigin.set(c.origin, []);
|
||||
byOrigin.get(c.origin).push({ by: m.value.author, ts: Number(m.value.timestamp) || 0, c });
|
||||
}
|
||||
const memberCache = new Map();
|
||||
const isMember = async (author, tribeId) => {
|
||||
const k = `${tribeId}|${author}`;
|
||||
if (!memberCache.has(k)) memberCache.set(k, await tribesModel.isTribeMember(author, tribeId).catch(() => false));
|
||||
return memberCache.get(k);
|
||||
};
|
||||
const everCache = new Map();
|
||||
const wasMember = async (author, tribeId) => {
|
||||
const k = `${tribeId}|${author}`;
|
||||
if (!everCache.has(k)) everCache.set(k, await tribesModel.wasEverMember(author, tribeId).catch(() => false));
|
||||
return everCache.get(k);
|
||||
};
|
||||
const privacyCache = new Map();
|
||||
const isPrivate = async (tribeId) => {
|
||||
if (!privacyCache.has(tribeId)) privacyCache.set(tribeId, !(await chainIsPublic(tribeId)));
|
||||
return privacyCache.get(tribeId);
|
||||
};
|
||||
const state = new Map();
|
||||
for (const [origin, list] of byOrigin) {
|
||||
list.sort((a, b) => (b.ts - a.ts) || (REACH_RANK[a.c.level] - REACH_RANK[b.c.level]));
|
||||
let chosen = null;
|
||||
for (const e of list) {
|
||||
if (await isMember(e.by, e.c.tribeId)) { chosen = e; break; }
|
||||
if (e.c.level === 'tribe' && await wasMember(e.by, e.c.tribeId)) { chosen = e; break; }
|
||||
}
|
||||
if (!chosen) continue;
|
||||
const priv = await isPrivate(chosen.c.tribeId);
|
||||
const level = !ignorePrivacy && priv ? 'tribe' : chosen.c.level;
|
||||
state.set(origin, { origin, tribeId: chosen.c.tribeId, contentType: chosen.c.contentType, level, item: level === 'tribe' || !chosen.c.item ? null : longText.resolveField(chosen.c.item, 'description', chosen.by, lookup), by: chosen.by, ts: chosen.ts });
|
||||
}
|
||||
return state;
|
||||
},
|
||||
|
||||
async levelOf(item, state = null) {
|
||||
const st = state || await this.exposureState();
|
||||
const e = st.get(item && (item.originId || item.id));
|
||||
return e ? e.level : 'tribe';
|
||||
},
|
||||
|
||||
async listExposed(tribeId, contentType = null, minLevel = 'oasis', state = null) {
|
||||
const rootId = await tribesModel.getRootId(tribeId).catch(() => tribeId);
|
||||
const st = state || await this.exposureState();
|
||||
const out = [];
|
||||
for (const e of st.values()) {
|
||||
if (e.tribeId !== rootId || !e.item || REACH_RANK[e.level] < REACH_RANK[minLevel]) continue;
|
||||
const base = { ...e.item, id: e.origin, originId: e.origin, rootId, reach: e.level, exposedBy: e.by, _ts: e.ts, attendees: [], assignees: [], votes: {}, opinions: {}, opinions_inhabitants: [], refeeds: 0, refeeds_inhabitants: [] };
|
||||
if (contentType === 'forum-reply') {
|
||||
if (e.item.contentType !== 'forum') continue;
|
||||
(e.item.replies || []).forEach((r, i) => out.push({ id: `${e.origin}:${i}`, originId: `${e.origin}:${i}`, rootId, contentType: 'forum-reply', parentId: e.origin, description: r.description, author: r.author, createdAt: r.createdAt, reach: e.level, attendees: [], assignees: [], votes: {}, opinions: {}, opinions_inhabitants: [], refeeds_inhabitants: [] }));
|
||||
continue;
|
||||
}
|
||||
if (contentType && e.item.contentType !== contentType) continue;
|
||||
out.push(base);
|
||||
}
|
||||
return out.sort((a, b) => (Date.parse(b.updatedAt || b.createdAt) || b._ts || 0) - (Date.parse(a.updatedAt || a.createdAt) || a._ts || 0));
|
||||
},
|
||||
|
||||
async getThread(forumId) {
|
||||
const msgs = await readLog();
|
||||
const { items } = await decodeContentMsgs(msgs, {});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue