OASIS_MOBILE/src/models/polls_model.js
hacklab 83f26ba19e integrar la 1.3.5 del dev y perfiles Minimo, Acampada y Activista
Lo del dev, tal cual desde el nodejs-project de su APK oficial 1.3.5, mezclado
a tres bandas con la 1.3.3 como base. Lo que no habiamos tocado se coge entero.

Conflictos:
- backend.js: su middleware nuevo de media va delante de nuestro router.
- main_views.js y bookmark_view.js: lo suyo. Su menu de tarjeta (⋯) ya aparta
  borrar del pulgar, que era lo que hacia nuestro cambio.
- settings_view.js y tribes_view.js: lo suyo mas nuestra linea de enganche.

La 1.3.5 trae una cabecera de modulo para el movil (apkModbar) que se lleva la
primera caja de fichas, el buscador y los contadores. Con el skin Activista su
CSS (OasisMobile.css) no se carga: se le da estilo en mobile_ux.css, los avisos
suben encima con order y se ajusta la cabecera nueva de las tarjetas. Filtrar
va ahora dentro de la caja de fichas (casilla + etiqueta) para viajar con ella.

Perfiles: Minimo (6 modulos, de serie), Acampada (16), Activista (24, con
tribus, votaciones, campañas, encuestas y tareas) y despues los del dev por
numero de modulos.
2026-10-22 12:12:20 +02:00

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)));
}
};
};