Lo del dev, tal cual desde el nodejs-project de su APK oficial 1.3.5, mezclado a tres bandas con la 1.3.3 como base. Lo que no habiamos tocado se coge entero. Conflictos: - backend.js: su middleware nuevo de media va delante de nuestro router. - main_views.js y bookmark_view.js: lo suyo. Su menu de tarjeta (⋯) ya aparta borrar del pulgar, que era lo que hacia nuestro cambio. - settings_view.js y tribes_view.js: lo suyo mas nuestra linea de enganche. La 1.3.5 trae una cabecera de modulo para el movil (apkModbar) que se lleva la primera caja de fichas, el buscador y los contadores. Con el skin Activista su CSS (OasisMobile.css) no se carga: se le da estilo en mobile_ux.css, los avisos suben encima con order y se ajusta la cabecera nueva de las tarjetas. Filtrar va ahora dentro de la caja de fichas (casilla + etiqueta) para viajar con ella. Perfiles: Minimo (6 modulos, de serie), Acampada (16), Activista (24, con tribus, votaciones, campañas, encuestas y tareas) y despues los del dev por numero de modulos.
427 lines
18 KiB
JavaScript
427 lines
18 KiB
JavaScript
const pull = require('../server/node_modules/pull-stream');
|
|
const { getConfig } = require('../configs/config-manager.js');
|
|
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
|
const { readTyped, memoIndex } = require('./typed_log');
|
|
const { versionGroups } = require('../backend/dedupe');
|
|
const opinionRules = require('../backend/opinion_categories');
|
|
|
|
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
|
|
|
const POLL_TYPE = 'poll';
|
|
const VOTE_TYPE = 'pollVote';
|
|
const OPINION_TYPE = 'pollOpinion';
|
|
|
|
const { MAX_OPTIONS, MIN_OPTIONS, MAX_OPTION_LENGTH } = require('./polls_model_limits');
|
|
|
|
const clean = (v) => String(v == null ? '' : v).trim();
|
|
|
|
const normalizeOptions = (raw) => {
|
|
const list = Array.isArray(raw) ? raw : String(raw || '').split('\n');
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const entry of list) {
|
|
const value = clean(entry).slice(0, MAX_OPTION_LENGTH);
|
|
if (!value) continue;
|
|
const key = value.toLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(value);
|
|
if (out.length >= MAX_OPTIONS) break;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const normalizeTags = (raw) => {
|
|
const list = Array.isArray(raw) ? raw : String(raw || '').split(',');
|
|
return list.map(t => clean(t)).filter(Boolean).slice(0, 20);
|
|
};
|
|
|
|
module.exports = ({ cooler, isPublic = false, cryptoModel = null, chatsModel = null }) => {
|
|
let ssb;
|
|
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
|
|
|
const POLLS_TYPES = [POLL_TYPE, VOTE_TYPE, OPINION_TYPE, 'pollClose', 'post', 'tombstone'];
|
|
|
|
const getAllMessages = async (ssbClient) => readTyped(ssbClient, POLLS_TYPES, { limit: logLimit });
|
|
|
|
const decryptField = (value, keys) => {
|
|
if (!cryptoModel || typeof value !== 'string' || !value) return null;
|
|
for (const k of keys) {
|
|
try {
|
|
const out = cryptoModel.decryptWithKey(value, k);
|
|
if (typeof out === 'string' && out) return out;
|
|
} catch (_) {}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const scopeKey = async (scope) => {
|
|
if (!scope || !chatsModel) return null;
|
|
if (!scope.chatId && !scope.tribeId) return null;
|
|
try { return await chatsModel.encryptionKeyFor(scope.chatId || null, scope.tribeId || null); } catch (_) { return null; }
|
|
};
|
|
|
|
const buildIndex = (messages) => {
|
|
const tomb = buildValidatedTombstoneSet(messages);
|
|
const nodes = new Map();
|
|
const votesByRoot = new Map();
|
|
const opinionsByRoot = new Map();
|
|
const closedRoots = new Set();
|
|
const commentsByRoot = new Map();
|
|
const others = [];
|
|
|
|
for (const m of messages) {
|
|
const v = m && m.value;
|
|
const c = v && v.content;
|
|
if (!c || typeof c !== 'object') continue;
|
|
if (c.type === POLL_TYPE) nodes.set(m.key, { key: m.key, author: v.author, seq: v.sequence || 0, ts: v.timestamp || m.timestamp || 0, c });
|
|
else others.push(m);
|
|
}
|
|
|
|
const vg = versionGroups(Array.from(nodes.values()).map(n => ({ key: n.key, author: n.author, seq: n.seq, ts: n.ts, replaces: typeof n.c.replaces === 'string' ? n.c.replaces : null, claimedAuthor: typeof n.c.author === 'string' ? n.c.author : null })));
|
|
const rootOf = vg.rootOf;
|
|
const tipOf = vg.tipOf;
|
|
|
|
for (const m of others) {
|
|
const v = m.value;
|
|
const c = v.content;
|
|
if (c.type === VOTE_TYPE && typeof c.target === 'string') {
|
|
const target = rootOf(c.target);
|
|
const entry = votesByRoot.get(target) || new Map();
|
|
const list = entry.get(v.author) || [];
|
|
list.push({
|
|
ts: v.timestamp || m.timestamp || 0,
|
|
choices: Array.isArray(c.choices) ? c.choices : [],
|
|
encryptedChoices: typeof c.encryptedChoices === 'string' ? c.encryptedChoices : null
|
|
});
|
|
entry.set(v.author, list);
|
|
votesByRoot.set(target, entry);
|
|
continue;
|
|
}
|
|
if (c.type === OPINION_TYPE && typeof c.target === 'string') {
|
|
const target = rootOf(c.target);
|
|
const entry = opinionsByRoot.get(target) || { counts: {}, voters: [] };
|
|
if (!entry.voters.includes(v.author)) {
|
|
entry.voters.push(v.author);
|
|
entry.counts[c.category] = (entry.counts[c.category] || 0) + 1;
|
|
}
|
|
opinionsByRoot.set(target, entry);
|
|
continue;
|
|
}
|
|
if (c.type === 'pollClose' && typeof c.target === 'string') {
|
|
closedRoots.add(`${v.author}|${rootOf(c.target)}`);
|
|
continue;
|
|
}
|
|
if (c.type === 'post' && typeof c.root === 'string') {
|
|
const target = rootOf(c.root);
|
|
commentsByRoot.set(target, (commentsByRoot.get(target) || 0) + 1);
|
|
}
|
|
}
|
|
|
|
return { tomb, nodes, vg, votesByRoot, opinionsByRoot, closedRoots, commentsByRoot, rootOf, tipOf };
|
|
};
|
|
|
|
const isExpired = (c) => {
|
|
if (!c.deadline) return false;
|
|
const t = Date.parse(c.deadline);
|
|
return Number.isFinite(t) && t <= Date.now();
|
|
};
|
|
|
|
const buildPoll = (idx, rootId, viewerId, keys = []) => {
|
|
const tipId = idx.tipOf(rootId);
|
|
const node = idx.nodes.get(tipId);
|
|
if (!node) return null;
|
|
const c = node.c || {};
|
|
let question = clean(c.question);
|
|
let rawOptions = c.options;
|
|
let undecryptable = false;
|
|
if (c.encryptedQuestion) {
|
|
const q = decryptField(c.encryptedQuestion, keys);
|
|
const o = decryptField(c.encryptedOptions, keys);
|
|
if (q === null) undecryptable = true;
|
|
question = q || '';
|
|
try { rawOptions = o ? JSON.parse(o) : []; } catch (_) { rawOptions = []; }
|
|
}
|
|
const options = normalizeOptions(rawOptions);
|
|
const opensAt = (idx.nodes.get(rootId) || {}).ts || 0;
|
|
const closesAt = c.deadline ? Date.parse(c.deadline) : NaN;
|
|
const voteMap = new Map();
|
|
for (const [author, list] of (idx.votesByRoot.get(rootId) || new Map()).entries()) {
|
|
const inWindow = list.filter(b => b.ts >= opensAt && (!Number.isFinite(closesAt) || b.ts <= closesAt));
|
|
if (inWindow.length) voteMap.set(author, inWindow.reduce((a, b) => (b.ts >= a.ts ? b : a)));
|
|
}
|
|
|
|
const counts = {};
|
|
for (const o of options) counts[o] = 0;
|
|
let totalVoters = 0;
|
|
const voters = [];
|
|
let myChoices = [];
|
|
for (const [author, entry] of voteMap.entries()) {
|
|
let choices = Array.isArray(entry.choices) ? entry.choices : [];
|
|
if (entry.encryptedChoices) {
|
|
const plain = decryptField(entry.encryptedChoices, keys);
|
|
try { choices = plain ? JSON.parse(plain) : []; } catch (_) { choices = []; }
|
|
}
|
|
const picked = (Array.isArray(choices) ? choices : []).filter(o => options.includes(o));
|
|
if (!picked.length) continue;
|
|
totalVoters += 1;
|
|
voters.push(author);
|
|
for (const o of picked) counts[o] += 1;
|
|
if (viewerId && author === viewerId) myChoices = picked;
|
|
}
|
|
|
|
const closed = idx.closedRoots.has(`${node.author}|${rootId}`) || isExpired(c);
|
|
const op = idx.opinionsByRoot.get(rootId) || { counts: {}, voters: [] };
|
|
|
|
return {
|
|
id: rootId,
|
|
rootId,
|
|
tipId,
|
|
author: node.author,
|
|
question,
|
|
options,
|
|
encrypted: !!c.encryptedQuestion,
|
|
undecryptable,
|
|
chatId: c.chatId || null,
|
|
anonymous: c.anonymous === true,
|
|
multiple: c.multiple === true,
|
|
deadline: c.deadline || '',
|
|
tags: normalizeTags(c.tags),
|
|
tribeId: c.tribeId || null,
|
|
houseKey: c.houseKey || null,
|
|
createdAt: c.createdAt || new Date(node.ts).toISOString(),
|
|
updatedAt: c.updatedAt || null,
|
|
status: closed ? 'CLOSED' : 'OPEN',
|
|
counts,
|
|
totalVoters,
|
|
voters: c.anonymous === true ? [] : voters,
|
|
votersHidden: c.anonymous === true,
|
|
myChoices,
|
|
hasVoted: myChoices.length > 0,
|
|
commentCount: idx.commentsByRoot.get(rootId) || 0,
|
|
opinions: op.counts,
|
|
opinions_inhabitants: op.voters
|
|
};
|
|
};
|
|
|
|
const collect = async (viewerId) => {
|
|
const ssbClient = await openSsb();
|
|
const messages = await getAllMessages(ssbClient);
|
|
const idx = memoIndex('polls', messages, buildIndex);
|
|
|
|
const keyCache = new Map();
|
|
const keysFor = async (c) => {
|
|
if (!c || (!c.chatId && !c.tribeId)) return [];
|
|
const cacheKey = `${c.chatId || ''}|${c.tribeId || ''}`;
|
|
if (keyCache.has(cacheKey)) return keyCache.get(cacheKey);
|
|
const k = await scopeKey({ chatId: c.chatId || null, tribeId: c.tribeId || null });
|
|
const keys = k ? [k] : [];
|
|
keyCache.set(cacheKey, keys);
|
|
return keys;
|
|
};
|
|
|
|
const list = [];
|
|
for (const rootId of idx.vg.roots) {
|
|
const tipId = idx.tipOf(rootId);
|
|
if (idx.tomb.has(tipId) || idx.tomb.has(rootId)) continue;
|
|
const node = idx.nodes.get(tipId);
|
|
const keys = await keysFor(node && node.c);
|
|
const poll = buildPoll(idx, rootId, viewerId || ssbClient.id, keys);
|
|
if (!poll) continue;
|
|
if (poll.undecryptable) { list.push(poll); continue; }
|
|
if (!poll.question || poll.options.length < MIN_OPTIONS) continue;
|
|
list.push(poll);
|
|
}
|
|
return { list, idx, viewerId: viewerId || ssbClient.id };
|
|
};
|
|
|
|
return {
|
|
POLL_TYPE,
|
|
VOTE_TYPE,
|
|
MAX_OPTIONS,
|
|
MIN_OPTIONS,
|
|
|
|
async listAll(filter = 'ALL', opts = {}) {
|
|
const { list, viewerId } = await collect();
|
|
const f = String(filter || 'ALL').toUpperCase();
|
|
const scopeChat = opts.chatId || null;
|
|
const scopeTribe = opts.tribeId || null;
|
|
const scopeHouse = opts.houseKey || null;
|
|
let out = list.filter(p =>
|
|
scopeChat ? p.chatId === scopeChat
|
|
: scopeTribe ? (p.tribeId === scopeTribe && !p.chatId)
|
|
: scopeHouse ? p.houseKey === scopeHouse
|
|
: (!p.tribeId && !p.chatId && !p.houseKey));
|
|
|
|
if (f === 'MINE') out = out.filter(p => String(p.author) === String(viewerId));
|
|
else if (f === 'OPEN') out = out.filter(p => p.status === 'OPEN');
|
|
else if (f === 'CLOSED') out = out.filter(p => p.status === 'CLOSED');
|
|
else if (f === 'VOTED') out = out.filter(p => p.hasVoted);
|
|
else if (f === 'RECENT') out = out.filter(p => (Date.parse(p.createdAt || '') || 0) >= Date.now() - 86400000);
|
|
else if (f === 'FAVORITES') {
|
|
const fav = new Set((opts.favorites || []).map(String));
|
|
out = out.filter(p => fav.has(String(p.id)));
|
|
}
|
|
|
|
const q = clean(opts.q).toLowerCase();
|
|
if (q) out = out.filter(p =>
|
|
p.question.toLowerCase().includes(q) ||
|
|
p.options.some(o => o.toLowerCase().includes(q)) ||
|
|
p.tags.some(t => t.toLowerCase().includes(q)));
|
|
|
|
if (f === 'TOP') out.sort((a, b) => b.totalVoters - a.totalVoters || new Date(b.createdAt) - new Date(a.createdAt));
|
|
else out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
|
|
return out;
|
|
},
|
|
|
|
async getPollById(id, viewerId = null) {
|
|
const { list, idx } = await collect(viewerId);
|
|
const rootId = idx.rootOf(id);
|
|
const poll = list.find(p => p.id === rootId || p.id === id || p.tipId === id);
|
|
if (!poll) throw new Error('Poll not found');
|
|
return poll;
|
|
},
|
|
|
|
async resolveRootId(id) {
|
|
const { idx } = await collect();
|
|
return idx.rootOf(id);
|
|
},
|
|
|
|
async createPoll(data = {}) {
|
|
if (isPublic) throw new Error('Not available in public mode');
|
|
const question = clean(data.question);
|
|
if (!question) throw new Error('Question is required');
|
|
const options = normalizeOptions(data.options);
|
|
if (options.length < MIN_OPTIONS) throw new Error(`A poll needs at least ${MIN_OPTIONS} options`);
|
|
const deadline = clean(data.deadline);
|
|
if (deadline) {
|
|
const t = Date.parse(deadline);
|
|
if (!Number.isFinite(t)) throw new Error('Invalid deadline');
|
|
if (t <= Date.now()) throw new Error('Deadline must be in the future');
|
|
}
|
|
const ssbClient = await openSsb();
|
|
const content = {
|
|
type: POLL_TYPE,
|
|
anonymous: data.anonymous === true || data.anonymous === 'true' || data.anonymous === '1',
|
|
multiple: data.multiple === true || data.multiple === 'true' || data.multiple === '1',
|
|
deadline,
|
|
tags: normalizeTags(data.tags),
|
|
createdAt: new Date().toISOString(),
|
|
...(data.tribeId ? { tribeId: String(data.tribeId) } : {}),
|
|
...(data.chatId ? { chatId: String(data.chatId) } : {}),
|
|
...(data.houseKey ? { houseKey: String(data.houseKey) } : {})
|
|
};
|
|
const key = await scopeKey({ chatId: data.chatId, tribeId: data.tribeId });
|
|
if (key && cryptoModel) {
|
|
content.encryptedQuestion = cryptoModel.encryptWithKey(question, key);
|
|
content.encryptedOptions = cryptoModel.encryptWithKey(JSON.stringify(options), key);
|
|
} else {
|
|
content.question = question;
|
|
content.options = options;
|
|
}
|
|
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
|
|
},
|
|
|
|
async updatePoll(id, data = {}) {
|
|
const ssbClient = await openSsb();
|
|
const userId = ssbClient.id;
|
|
const poll = await this.getPollById(id, userId);
|
|
if (poll.author !== userId) throw new Error('Not the author');
|
|
if (poll.status === 'CLOSED') throw new Error('Cannot edit a closed poll');
|
|
if (poll.totalVoters > 0) throw new Error('Cannot edit a poll that already has votes');
|
|
|
|
const options = data.options !== undefined ? normalizeOptions(data.options) : poll.options;
|
|
if (options.length < MIN_OPTIONS) throw new Error(`A poll needs at least ${MIN_OPTIONS} options`);
|
|
const deadline = data.deadline !== undefined ? clean(data.deadline) : poll.deadline;
|
|
if (deadline) {
|
|
const t = Date.parse(deadline);
|
|
if (!Number.isFinite(t)) throw new Error('Invalid deadline');
|
|
}
|
|
const question = data.question !== undefined ? clean(data.question) : poll.question;
|
|
opinionRules.assertMediaKept(poll, question !== poll.question || JSON.stringify(options) !== JSON.stringify(poll.options));
|
|
const content = {
|
|
type: POLL_TYPE,
|
|
replaces: poll.tipId,
|
|
anonymous: data.anonymous !== undefined
|
|
? (data.anonymous === true || data.anonymous === 'true' || data.anonymous === '1')
|
|
: poll.anonymous,
|
|
multiple: data.multiple !== undefined
|
|
? (data.multiple === true || data.multiple === 'true' || data.multiple === '1')
|
|
: poll.multiple,
|
|
deadline,
|
|
tags: data.tags !== undefined ? normalizeTags(data.tags) : poll.tags,
|
|
createdAt: poll.createdAt,
|
|
updatedAt: new Date().toISOString(),
|
|
...(poll.tribeId ? { tribeId: poll.tribeId } : {}),
|
|
...(poll.chatId ? { chatId: poll.chatId } : {}),
|
|
...(poll.houseKey ? { houseKey: poll.houseKey } : {})
|
|
};
|
|
const key = await scopeKey({ chatId: poll.chatId, tribeId: poll.tribeId });
|
|
if (key && cryptoModel) {
|
|
content.encryptedQuestion = cryptoModel.encryptWithKey(question, key);
|
|
content.encryptedOptions = cryptoModel.encryptWithKey(JSON.stringify(options), key);
|
|
} else {
|
|
content.question = question;
|
|
content.options = options;
|
|
}
|
|
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
|
|
},
|
|
|
|
async vote(id, choicesRaw) {
|
|
const ssbClient = await openSsb();
|
|
const userId = ssbClient.id;
|
|
const poll = await this.getPollById(id, userId);
|
|
if (poll.status === 'CLOSED') throw new Error('This poll is closed');
|
|
|
|
const wanted = (Array.isArray(choicesRaw) ? choicesRaw : [choicesRaw])
|
|
.map(c => clean(c))
|
|
.filter(c => poll.options.includes(c));
|
|
const unique = [...new Set(wanted)];
|
|
if (!unique.length) throw new Error('Pick at least one option');
|
|
if (!poll.multiple && unique.length > 1) throw new Error('This poll only accepts one option');
|
|
|
|
const content = {
|
|
type: VOTE_TYPE,
|
|
target: poll.rootId,
|
|
createdAt: new Date().toISOString(),
|
|
...(poll.chatId ? { chatId: poll.chatId } : {})
|
|
};
|
|
const key = await scopeKey({ chatId: poll.chatId, tribeId: poll.tribeId });
|
|
if (key && cryptoModel) content.encryptedChoices = cryptoModel.encryptWithKey(JSON.stringify(unique), key);
|
|
else content.choices = unique;
|
|
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
|
|
},
|
|
|
|
async closePoll(id) {
|
|
const ssbClient = await openSsb();
|
|
const userId = ssbClient.id;
|
|
const poll = await this.getPollById(id, userId);
|
|
if (poll.author !== userId) throw new Error('Not the author');
|
|
if (poll.status === 'CLOSED') return { status: 'already_closed' };
|
|
const content = { type: 'pollClose', target: poll.rootId, createdAt: new Date().toISOString() };
|
|
await new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
|
|
return { status: 'ok' };
|
|
},
|
|
|
|
async deletePoll(id) {
|
|
const ssbClient = await openSsb();
|
|
const userId = ssbClient.id;
|
|
const poll = await this.getPollById(id, userId);
|
|
if (poll.author !== userId) throw new Error('Not the author');
|
|
const content = { type: 'tombstone', target: poll.tipId, deletedAt: new Date().toISOString(), author: userId };
|
|
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
|
|
},
|
|
|
|
async createOpinion(id, category) {
|
|
const categories = require('../backend/opinion_categories');
|
|
if (!categories.includes(category)) throw new Error('Invalid opinion category');
|
|
const ssbClient = await openSsb();
|
|
const userId = ssbClient.id;
|
|
const poll = await this.getPollById(id, userId);
|
|
if (poll.opinions_inhabitants.includes(userId)) throw new Error('Already opined');
|
|
const content = { type: OPINION_TYPE, target: poll.rootId, category, createdAt: new Date().toISOString() };
|
|
return new Promise((res, rej) => ssbClient.publish(content, (err, result) => err ? rej(err) : res(result)));
|
|
}
|
|
};
|
|
};
|