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).
This commit is contained in:
parent
bc64565e9c
commit
948b6460d9
95 changed files with 3613 additions and 1356 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 : ''
|
||||
|
|
|
|||
591
src/models/karma_model.js
Normal file
591
src/models/karma_model.js
Normal file
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue