main_models.js 70 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258
  1. "use strict";
  2. const debug = require("../server/node_modules/debug")("oasis");
  3. const { isRoot, isReply: isComment } = require("../server/node_modules/ssb-thread-schema");
  4. const lodash = require("../server/node_modules/lodash");
  5. const prettyMs = require("../server/node_modules/pretty-ms");
  6. const pullAbortable = require("../server/node_modules/pull-abortable");
  7. const pullParallelMap = require("../server/node_modules/pull-paramap");
  8. const pull = require("../server/node_modules/pull-stream");
  9. const pullSort = require("../server/node_modules/pull-sort");
  10. const path = require('path');
  11. const fs = require('fs/promises');
  12. const os = require('os');
  13. const ssbRef = require("../server/node_modules/ssb-ref");
  14. const nameCache = require('../backend/nameCache');
  15. const { getConfig } = require('../configs/config-manager.js');
  16. const logLimit = getConfig().ssbLogStream?.limit || 1000;
  17. const isEncrypted = (message) => typeof message.value.content === "string";
  18. const isNotEncrypted = (message) => isEncrypted(message) === false;
  19. const isDecrypted = (message) =>
  20. lodash.get(message, "value.meta.private", false);
  21. const isPrivate = (message) => isEncrypted(message) || isDecrypted(message);
  22. const isNotPrivate = (message) => isPrivate(message) === false;
  23. const hasRoot = (message) =>
  24. ssbRef.isMsg(lodash.get(message, "value.content.root", null));
  25. const hasFork = (message) =>
  26. ssbRef.isMsg(lodash.get(message, "value.content.fork", null));
  27. const hasNoRoot = (message) => hasRoot(message) === false;
  28. const hasNoFork = (message) => hasFork(message) === false;
  29. const isPost = (message) =>
  30. lodash.get(message, "value.content.type") === "post" &&
  31. typeof lodash.get(message, "value.content.text") === "string";
  32. const isBlogPost = (message) =>
  33. lodash.get(message, "value.content.type") === "blog" &&
  34. typeof lodash.get(message, "value.content.title") === "string" &&
  35. ssbRef.isBlob(lodash.get(message, "value.content.blog", null));
  36. const isTextLike = (message) => isPost(message) || isBlogPost(message);
  37. const isSubtopic = require("../server/node_modules/ssb-thread-schema/post/nested-reply/validator");
  38. const nullImage = `&${"0".repeat(43)}=.sha256`;
  39. const defaultOptions = {
  40. private: true,
  41. reverse: true,
  42. meta: true,
  43. };
  44. const publicOnlyFilter = pull.filter(isNotPrivate);
  45. const configure = (...customOptions) =>
  46. Object.assign({}, defaultOptions, ...customOptions);
  47. // PEERS
  48. const ebtDir = path.join(os.homedir(), '.ssb', 'ebt');
  49. const unfollowedPath = path.join(os.homedir(), '.ssb', 'gossip_unfollowed.json');
  50. async function loadPeersFromEbt() {
  51. let result = [];
  52. try {
  53. await fs.access(ebtDir);
  54. const files = await fs.readdir(ebtDir);
  55. for (const file of files) {
  56. if (!file.endsWith('.ed25519')) continue;
  57. const base = file.replace(/^@/, '').replace('.ed25519', '');
  58. let core = base.replace(/_/g, '/').replace(/-/g, '+');
  59. if (!core.endsWith('=')) core += '=';
  60. const filePath = path.join(ebtDir, file);
  61. try {
  62. const data = await fs.readFile(filePath, 'utf8');
  63. const users = JSON.parse(data);
  64. const userList = Object.keys(users).map(u => ({
  65. id: u,
  66. link: `/author/${encodeURIComponent(u)}`
  67. }));
  68. result.push({
  69. pub: `@${core}.ed25519`,
  70. users: userList
  71. });
  72. } catch {}
  73. }
  74. } catch {}
  75. return result;
  76. }
  77. async function loadConnectedUsersFromEbt(pubId) {
  78. const filePath = path.join(ebtDir, `@${pubId.replace(/\//g, '_')}.ed25519`);
  79. try {
  80. const data = await fs.readFile(filePath, 'utf8');
  81. const users = JSON.parse(data);
  82. return Object.keys(users).map(userId => ({
  83. id: userId,
  84. link: `/author/${encodeURIComponent(userId)}`
  85. }));
  86. } catch {
  87. return [];
  88. }
  89. }
  90. const canonicalizePubId = (s) => {
  91. const core0 = String(s).replace(/^@/, '').replace(/\.ed25519$/, '');
  92. let core = core0.replace(/_/g, '/').replace(/-/g, '+');
  93. if (!core.endsWith('=')) core += '=';
  94. return `@${core}.ed25519`;
  95. };
  96. const parseRemote = (remote) => {
  97. let m = /^net:([^:]+):\d+~shs:([^=]+)=/.exec(remote);
  98. if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) };
  99. m = /^wss?:\/\/([^:/]+)(?::\d+)?.*~shs:([^=]+)=/.exec(remote);
  100. if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) };
  101. m = /~shs:([^=]+)=/.exec(remote);
  102. if (m) return { host: null, pubId: canonicalizePubId(m[1]) };
  103. return { host: null, pubId: null };
  104. };
  105. async function ensureJSONFile(p, initial = []) {
  106. await fs.mkdir(path.dirname(p), { recursive: true });
  107. try { await fs.access(p) } catch { await fs.writeFile(p, JSON.stringify(initial, null, 2), 'utf8') }
  108. }
  109. async function readJSON(p) {
  110. await ensureJSONFile(p, []);
  111. try { return JSON.parse((await fs.readFile(p, 'utf8')) || '[]') } catch { return [] }
  112. }
  113. function canonicalKey(key) {
  114. let core = String(key).replace(/^@/, '').replace(/\.ed25519$/, '').replace(/-/g, '+').replace(/_/g, '/');
  115. if (!core.endsWith('=')) core += '=';
  116. return `@${core}.ed25519`;
  117. }
  118. async function loadUnfollowedSet() {
  119. const list = await readJSON(unfollowedPath);
  120. return new Set(list.map(x => canonicalKey(x && x.key)));
  121. }
  122. function toLegacyInvite(s) {
  123. const t = String(s || '').trim();
  124. if (/^[^:]+:\d+:@[^~]+~[^~]+$/.test(t)) return t;
  125. let m = t.match(/^net:([^:]+):(\d+)~shs:([^~]+)~invite:([^~]+)$/);
  126. if (!m) m = t.match(/^([^:]+):(\d+)~shs:([^~]+)~invite:([^~]+)$/);
  127. if (!m) return t;
  128. let key = m[3].replace(/^@/, '');
  129. if (!/\.ed25519$/.test(key)) key += '.ed25519';
  130. return `${m[1]}:${m[2]}:@${key}~${m[4]}`;
  131. }
  132. // CORE MODEL
  133. module.exports = ({ cooler, isPublic }) => {
  134. const models = {};
  135. const getAbout = async ({ key, feedId }) => {
  136. const ssb = await cooler.open();
  137. const source = ssb.backlinks.read({
  138. reverse: true,
  139. query: [
  140. {
  141. $filter: {
  142. dest: feedId,
  143. value: {
  144. author: feedId,
  145. content: { type: "about", about: feedId },
  146. },
  147. },
  148. },
  149. ],
  150. });
  151. return new Promise((resolve, reject) =>
  152. pull(
  153. source,
  154. pull.find(
  155. (message) => message.value.content[key] !== undefined,
  156. (err, message) => {
  157. if (err) {
  158. reject(err);
  159. } else {
  160. if (message === null) {
  161. resolve(null);
  162. } else {
  163. resolve(message.value.content[key]);
  164. }
  165. }
  166. }
  167. )
  168. )
  169. );
  170. };
  171. const feeds_to_name = {};
  172. let all_the_names = {};
  173. let dirty = false;
  174. let running = false;
  175. const transposeLookupTable = () => {
  176. if (!dirty) return;
  177. if (running) return;
  178. running = true;
  179. all_the_names = {};
  180. const allFeeds = Object.keys(feeds_to_name);
  181. console.log(`- Synced-peers: [ ${allFeeds.length} ]`);
  182. console.time("- Sync-time");
  183. const lookups = [];
  184. for (const feed of allFeeds) {
  185. const e = feeds_to_name[feed];
  186. let pair = { feed, name: e.name };
  187. lookups.push(enhanceFeedInfo(pair));
  188. }
  189. Promise.all(lookups)
  190. .then(() => {
  191. dirty = false;
  192. running = false;
  193. console.timeEnd("- Sync-time");
  194. })
  195. .catch((err) => {
  196. running = false;
  197. console.warn("- Lookup Sync failed: ", err);
  198. });
  199. };
  200. const enhanceFeedInfo = ({ feed, name }) => {
  201. return new Promise((resolve, reject) => {
  202. getAbout({ feedId: feed, key: "image" })
  203. .then((img) => {
  204. if (
  205. img !== null &&
  206. typeof img !== "string" &&
  207. typeof img === "object" &&
  208. typeof img.link === "string"
  209. ) {
  210. img = img.link;
  211. } else if (img === null) {
  212. img = nullImage;
  213. }
  214. models.friend
  215. .getRelationship(feed)
  216. .then((rel) => {
  217. let feeds_named = all_the_names[name] || [];
  218. feeds_named.push({ feed, name, rel, img });
  219. all_the_names[name.toLowerCase()] = feeds_named;
  220. resolve();
  221. })
  222. .catch(reject);
  223. })
  224. .catch(reject);
  225. });
  226. };
  227. async function enrichEntries(entries) {
  228. const ebtList = await loadPeersFromEbt();
  229. const ebtMap = new Map(ebtList.map(e => [e.pub, e.users]));
  230. const ssb = await cooler.open();
  231. return Promise.all(
  232. entries.map(async ([remote, data]) => {
  233. const { host, pubId } = parseRemote(remote);
  234. const effectiveKey = pubId || (data && data.key ? canonicalizePubId(data.key) : null);
  235. const name = host || (effectiveKey ? await models.about.name(effectiveKey).catch(() => (effectiveKey || '').slice(0, 10)) : remote);
  236. const users = effectiveKey && ebtMap.has(effectiveKey) ? ebtMap.get(effectiveKey) : [];
  237. const usersWithNames = await Promise.all(
  238. users.map(async (user) => {
  239. const userName = await models.about.name(user.id).catch(() => user.id);
  240. return { ...user, name: userName };
  241. })
  242. );
  243. return [
  244. remote,
  245. {
  246. ...data,
  247. key: effectiveKey || remote,
  248. name,
  249. users: usersWithNames
  250. }
  251. ];
  252. })
  253. );
  254. };
  255. // ABOUT MODEL
  256. models.about = {
  257. publicWebHosting: async (feedId) => {
  258. const result = await getAbout({
  259. key: "publicWebHosting",
  260. feedId,
  261. });
  262. return result === true;
  263. },
  264. visibilityPrefs: async (feedId) => {
  265. const result = await getAbout({ key: "visibilityPrefs", feedId });
  266. if (!result || typeof result !== 'object') return null;
  267. return {
  268. activity: result.activity === true,
  269. device: result.device === true,
  270. karma: result.karma !== false,
  271. ubi: result.ubi === true,
  272. wallet: result.wallet === true,
  273. ecoTax: result.ecoTax !== false,
  274. clearnet: result.clearnet === true,
  275. clearnetShops: result.clearnetShops === true,
  276. clearnetJobs: result.clearnetJobs === true,
  277. clearnetEvents: result.clearnetEvents === true,
  278. clearnetProjects: result.clearnetProjects === true,
  279. clearnetPosts: result.clearnetPosts === true,
  280. clearnetAudios: result.clearnetAudios === true,
  281. clearnetVideos: result.clearnetVideos === true,
  282. clearnetImages: result.clearnetImages === true,
  283. clearnetDocuments: result.clearnetDocuments === true,
  284. clearnetTorrents: result.clearnetTorrents === true,
  285. profileShops: result.profileShops === true,
  286. profileJobs: result.profileJobs === true,
  287. profileEvents: result.profileEvents === true,
  288. profileProjects: result.profileProjects === true,
  289. profilePosts: result.profilePosts === true,
  290. profileAudios: result.profileAudios === true,
  291. profileVideos: result.profileVideos === true,
  292. profileImages: result.profileImages === true,
  293. profileDocuments: result.profileDocuments === true,
  294. profileTorrents: result.profileTorrents === true
  295. };
  296. },
  297. name: async (feedId) => {
  298. if (isPublic && (await models.about.publicWebHosting(feedId)) === false) {
  299. return "Redacted";
  300. }
  301. const resolved = await getAbout({ key: "name", feedId });
  302. if (resolved) nameCache.set(feedId, resolved, Date.now());
  303. return resolved || feedId.slice(1, 1 + 8);
  304. },
  305. nameSync: (feedId) => {
  306. if (!feedId) return null;
  307. const cached = nameCache.get(feedId);
  308. if (cached) return cached;
  309. const local = feeds_to_name[feedId];
  310. return local && local.name ? local.name : null;
  311. },
  312. named: (name) => {
  313. let found = [];
  314. let matched = Object.keys(all_the_names).filter((n) => {
  315. return n.startsWith(name.toLowerCase());
  316. });
  317. for (const m of matched) {
  318. found = found.concat(all_the_names[m]);
  319. }
  320. return found;
  321. },
  322. image: async (feedId) => {
  323. if (isPublic && (await models.about.publicWebHosting(feedId)) === false) {
  324. return nullImage;
  325. }
  326. const timeoutPromise = (timeout) => new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout));
  327. try {
  328. const raw = await Promise.race([
  329. getAbout({
  330. key: "image",
  331. feedId,
  332. }),
  333. timeoutPromise(5000),
  334. ]);
  335. if (raw == null || raw.link == null) {
  336. return nullImage;
  337. }
  338. if (typeof raw.link === "string") {
  339. return raw.link;
  340. }
  341. return raw;
  342. } catch (error) {
  343. return '/assets/images/default-avatar.png';
  344. }
  345. },
  346. description: async (feedId) => {
  347. if (isPublic && (await models.about.publicWebHosting(feedId)) === false) {
  348. return "Redacted";
  349. }
  350. const raw =
  351. (await getAbout({
  352. key: "description",
  353. feedId,
  354. })) || "";
  355. return raw;
  356. },
  357. _startNameWarmup() {
  358. const abortable = pullAbortable();
  359. let intervals = [];
  360. cooler.open().then((ssb) => {
  361. const _warmupStart = Date.now();
  362. pull(
  363. ssb.query.read({
  364. live: true,
  365. query: [
  366. {
  367. $filter: {
  368. value: {
  369. content: {
  370. type: "about",
  371. name: { $is: "string" },
  372. },
  373. },
  374. },
  375. },
  376. ],
  377. }),
  378. abortable,
  379. pull.filter((msg) => {
  380. if (msg.sync && msg.sync === true) {
  381. const _elapsed = Date.now() - _warmupStart;
  382. const _fmt = _elapsed >= 1000 ? `${(_elapsed/1000).toFixed(2)}s` : `${_elapsed}ms`;
  383. console.log(`- Warmup-time: ${_fmt}`);
  384. transposeLookupTable();
  385. intervals.push(setInterval(transposeLookupTable, 1000 * 60));
  386. return false;
  387. }
  388. return msg.value.author == msg.value.content.about;
  389. }),
  390. pull.drain((msg) => {
  391. const name = msg.value.content.name;
  392. const ts = msg.value.timestamp;
  393. const feed = msg.value.author;
  394. const newEntry = { name, ts };
  395. const currentEntry = feeds_to_name[feed];
  396. if (typeof currentEntry == "undefined") {
  397. dirty = true;
  398. feeds_to_name[feed] = newEntry;
  399. nameCache.set(feed, name, ts);
  400. } else if (currentEntry.ts < ts) {
  401. dirty = true;
  402. feeds_to_name[feed] = newEntry;
  403. nameCache.set(feed, name, ts);
  404. }
  405. }, (err) => {
  406. console.error(err);
  407. })
  408. );
  409. });
  410. return {
  411. close: () => {
  412. abortable.abort();
  413. intervals.forEach((i) => clearInterval(i));
  414. },
  415. };
  416. },
  417. };
  418. // BLOBS MODEL
  419. function blobIdToHexPath(blobId) {
  420. const homeDir = os.homedir();
  421. const m = /^&([A-Za-z0-9+/=]+)\.sha256$/.exec(blobId);
  422. if (!m) throw new Error('Invalid blobId: ' + blobId);
  423. const b64 = m[1];
  424. const buf = Buffer.from(b64, 'base64');
  425. const hex = buf.toString('hex');
  426. const prefix = hex.slice(0, 2);
  427. return path.join(homeDir, '.ssb', 'blobs', 'sha256', prefix, hex);
  428. }
  429. async function checkLocalBlob(blobId) {
  430. const filePath = blobIdToHexPath(blobId);
  431. try {
  432. const buf = await fs.readFile(filePath);
  433. if (buf && buf.length) return buf;
  434. } catch (_) { /* not found */ }
  435. return null;
  436. }
  437. models.blob = {
  438. getResolved: async ({ blobId, timeout = 30000 }) => {
  439. let buf = await checkLocalBlob(blobId);
  440. if (buf) return buf;
  441. const ssb = await cooler.open();
  442. await new Promise((resolve, reject) => {
  443. ssb.blobs.want(blobId, (err) => {
  444. if (err) reject(err);
  445. else resolve();
  446. });
  447. });
  448. return new Promise((resolve, reject) => {
  449. let timer = setTimeout(() => resolve(null), timeout);
  450. pull(
  451. ssb.blobs.get(blobId),
  452. pull.collect(async (err, bufs) => {
  453. clearTimeout(timer);
  454. if (err || !bufs || !bufs.length) return resolve(null);
  455. const buffer = Buffer.concat(bufs);
  456. try {
  457. const filePath = blobIdToHexPath(blobId);
  458. await fs.mkdir(path.dirname(filePath), { recursive: true });
  459. await fs.writeFile(filePath, buffer);
  460. } catch (e) { /* ignore */ }
  461. resolve(buffer);
  462. })
  463. );
  464. });
  465. },
  466. want: async ({ blobId }) => {
  467. const ssb = await cooler.open();
  468. return new Promise((resolve, reject) => {
  469. ssb.blobs.want(blobId, (err) => {
  470. if (err) reject(new Error(`Failed to request blob: ${blobId}`));
  471. else resolve();
  472. });
  473. });
  474. }
  475. };
  476. // FRIENDS MODEL
  477. models.friend = {
  478. setRelationship: async ({ feedId, following, blocking }) => {
  479. if (following && blocking) {
  480. throw new Error("Cannot follow and block at the same time");
  481. }
  482. const current = await models.friend.getRelationship(feedId);
  483. const alreadySet =
  484. current.following === following && current.blocking === blocking;
  485. if (alreadySet) {
  486. return;
  487. }
  488. const ssb = await cooler.open();
  489. const content = {
  490. type: "contact",
  491. contact: feedId,
  492. following,
  493. blocking,
  494. };
  495. transposeLookupTable();
  496. return new Promise((resolve, reject) => {
  497. ssb.publish(content, (err, msg) => {
  498. if (err) reject(err);
  499. else resolve(msg);
  500. });
  501. });
  502. },
  503. follow: (feedId) =>
  504. models.friend.setRelationship({
  505. feedId,
  506. following: true,
  507. blocking: false,
  508. }),
  509. unfollow: (feedId) =>
  510. models.friend.setRelationship({
  511. feedId,
  512. following: false,
  513. blocking: false,
  514. }),
  515. block: (feedId) =>
  516. models.friend.setRelationship({
  517. feedId,
  518. blocking: true,
  519. following: false,
  520. }),
  521. unblock: (feedId) =>
  522. models.friend.setRelationship({
  523. feedId,
  524. blocking: false,
  525. following: false,
  526. }),
  527. getRelationship: async (feedId) => {
  528. const ssb = await cooler.open();
  529. const { id } = ssb;
  530. if (feedId === id) {
  531. return {
  532. me: true,
  533. following: false,
  534. blocking: false,
  535. followsMe: false,
  536. };
  537. }
  538. const isFollowing = await new Promise((resolve, reject) => {
  539. ssb.friends.isFollowing({ source: id, dest: feedId }, (err, val) => {
  540. if (err) reject(err);
  541. else resolve(val);
  542. });
  543. });
  544. const isBlocking = await new Promise((resolve, reject) => {
  545. ssb.friends.isBlocking({ source: id, dest: feedId }, (err, val) => {
  546. if (err) reject(err);
  547. else resolve(val);
  548. });
  549. });
  550. const followsMe = await new Promise((resolve, reject) => {
  551. ssb.friends.isFollowing({ source: feedId, dest: id }, (err, val) => {
  552. if (err) reject(err);
  553. else resolve(val);
  554. });
  555. });
  556. return {
  557. me: false,
  558. following: isFollowing,
  559. blocking: isBlocking,
  560. followsMe,
  561. };
  562. },
  563. };
  564. // META MODEL
  565. models.meta = {
  566. myFeedId: async () => {
  567. const ssb = await cooler.open();
  568. const { id } = ssb;
  569. return id;
  570. },
  571. get: async (msgId) => {
  572. const ssb = await cooler.open();
  573. return new Promise((resolve, reject) => {
  574. ssb.get(
  575. {
  576. id: msgId,
  577. meta: true,
  578. private: true,
  579. },
  580. (err, msg) => {
  581. if (err) reject(err);
  582. else resolve(msg);
  583. }
  584. );
  585. });
  586. },
  587. status: async () => {
  588. const ssb = await cooler.open();
  589. return ssb.status();
  590. },
  591. peers: async () => {
  592. const ssb = await cooler.open();
  593. return new Promise((resolve, reject) => {
  594. pull(
  595. ssb.conn.peers(),
  596. pull.take(1),
  597. pull.collect((err, [entries]) => {
  598. if (err) return reject(err);
  599. resolve(entries || []);
  600. })
  601. );
  602. });
  603. },
  604. connectedPeers: async () => {
  605. const ssb = await cooler.open();
  606. const connEntries = await models.meta.peers();
  607. const seen = new Set();
  608. const result = [];
  609. const lookupAddr = (key) => {
  610. for (const [addr, data] of connEntries) {
  611. if (data && data.key === key) return addr;
  612. }
  613. return null;
  614. };
  615. const lookupConnData = (key) => {
  616. for (const [, data] of connEntries) {
  617. if (data && data.key === key) return data;
  618. }
  619. return null;
  620. };
  621. try {
  622. const livePeers = ssb && ssb.peers && typeof ssb.peers === "object" ? ssb.peers : {};
  623. for (const rawKey of Object.keys(livePeers)) {
  624. if (!rawKey || rawKey === ssb.id) continue;
  625. const rpcs = livePeers[rawKey];
  626. if (!Array.isArray(rpcs) || rpcs.length === 0) continue;
  627. const key = canonicalizePubId(rawKey);
  628. if (seen.has(key)) continue;
  629. seen.add(key);
  630. const existing = lookupConnData(key) || {};
  631. const addr = (rpcs[0] && rpcs[0].stream && rpcs[0].stream.address) || lookupAddr(key) || `live:${key}`;
  632. result.push([addr, { ...existing, key, state: "connected", source: "rpc" }]);
  633. }
  634. } catch {}
  635. for (const [addr, data] of connEntries) {
  636. if (!data || data.state !== "connected" || !data.key || seen.has(data.key)) continue;
  637. seen.add(data.key);
  638. result.push([addr, data]);
  639. }
  640. try {
  641. const gp = ssb.gossip && typeof ssb.gossip.peers === "function" ? ssb.gossip.peers() : [];
  642. const RECENT_MS = 30 * 60 * 1000;
  643. const now = Date.now();
  644. for (const p of (gp || [])) {
  645. if (!p || !p.key) continue;
  646. const key = canonicalizePubId(p.key);
  647. if (seen.has(key)) continue;
  648. const isConnected = p.state === "connected";
  649. const recentlyConnected =
  650. !isConnected &&
  651. (p.failure === 0 || p.failure === undefined || p.failure === null) &&
  652. typeof p.stateChange === "number" &&
  653. (now - p.stateChange) < RECENT_MS;
  654. if (!isConnected && !recentlyConnected) continue;
  655. let addr = p.address;
  656. if (!addr && p.host && p.port) {
  657. const core = String(p.key).replace(/^@/, "").replace(/\.ed25519$/, "");
  658. addr = `net:${p.host}:${p.port}~shs:${core}`;
  659. }
  660. if (!addr) continue;
  661. seen.add(key);
  662. result.push([addr, { ...p, key, state: "connected", source: isConnected ? "gossip" : "recent" }]);
  663. }
  664. } catch {}
  665. try {
  666. const myId = ssb.id;
  667. const status = ssb.ebt && typeof ssb.ebt.peerStatus === "function" ? ssb.ebt.peerStatus(myId) : null;
  668. const ebtPeers = (status && status.peers) ? Object.keys(status.peers) : [];
  669. for (const rawKey of ebtPeers) {
  670. if (!rawKey) continue;
  671. const key = canonicalizePubId(rawKey);
  672. if (seen.has(key)) continue;
  673. let addr = lookupAddr(key);
  674. if (!addr) {
  675. const core = String(key).replace(/^@/, "").replace(/\.ed25519$/, "");
  676. addr = `ebt:${core}`;
  677. }
  678. seen.add(key);
  679. result.push([addr, { key, state: "connected", source: "ebt" }]);
  680. }
  681. } catch {}
  682. return result;
  683. },
  684. onlinePeers: async () => {
  685. const entries = await models.meta.connectedPeers();
  686. return enrichEntries(entries);
  687. },
  688. discovered: async () => {
  689. const ssb = await cooler.open();
  690. const snapshot = await ssb.conn.dbPeers();
  691. const gossipPath = path.join(os.homedir(), '.ssb', 'gossip.json');
  692. let gossipMap = new Map();
  693. try {
  694. const gossipData = JSON.parse(await fs.readFile(gossipPath, 'utf8'));
  695. if (Array.isArray(gossipData)) {
  696. for (const g of gossipData) {
  697. if (g.key) gossipMap.set(canonicalizePubId(g.key), g);
  698. }
  699. }
  700. } catch {}
  701. let stagedEntries = [];
  702. try {
  703. if (ssb.conn && typeof ssb.conn.stagedPeers === 'function') {
  704. stagedEntries = await new Promise((resolve) => {
  705. try {
  706. pull(
  707. ssb.conn.stagedPeers(),
  708. pull.take(1),
  709. pull.collect((err, results) => {
  710. if (err || !results || !results[0]) return resolve([]);
  711. resolve(Array.isArray(results[0]) ? results[0] : []);
  712. })
  713. );
  714. } catch (_) { resolve([]); }
  715. });
  716. }
  717. } catch {}
  718. const dbKeys = new Set(
  719. snapshot
  720. .map(([, d]) => d && d.key ? canonicalizePubId(d.key) : null)
  721. .filter(Boolean)
  722. );
  723. const mergedSnapshot = snapshot.slice();
  724. for (const entry of stagedEntries) {
  725. const data = Array.isArray(entry) ? entry[1] : entry;
  726. const addr = Array.isArray(entry) ? entry[0] : null;
  727. if (!data || !data.key) continue;
  728. const ck = canonicalizePubId(data.key);
  729. if (dbKeys.has(ck)) continue;
  730. let host = data.host, port = data.port;
  731. if ((!host || !port) && addr) {
  732. const m = String(addr).match(/^net:([^:]+):(\d+)/);
  733. if (m) { host = host || m[1]; port = port || Number(m[2]); }
  734. }
  735. mergedSnapshot.push([addr, { key: data.key, host, port, source: data.type || 'staged', verified: data.verified }]);
  736. dbKeys.add(ck);
  737. }
  738. const allDbPeers = await enrichEntries(mergedSnapshot);
  739. for (const [, peerData] of allDbPeers) {
  740. if ((!peerData.announcers || peerData.announcers === 0) && gossipMap.has(peerData.key)) {
  741. const gossipEntry = gossipMap.get(peerData.key);
  742. if (gossipEntry.announcers) peerData.announcers = gossipEntry.announcers;
  743. }
  744. }
  745. const connectedEntries = await models.meta.connectedPeers();
  746. const onlineKeys = new Set(
  747. connectedEntries
  748. .map(([, d]) => d && d.key ? canonicalizePubId(d.key) : null)
  749. .filter(Boolean)
  750. );
  751. const discoveredPeers = allDbPeers.filter(([, d]) => !onlineKeys.has(canonicalizePubId(d.key)));
  752. const discoveredIds = new Set(allDbPeers.map(([, d]) => canonicalizePubId(d.key)));
  753. const ebtList = await loadPeersFromEbt();
  754. const ebtMap = new Map(ebtList.map(e => [e.pub, e.users]));
  755. const unknownPeers = [];
  756. for (const { pub } of ebtList) {
  757. if (!discoveredIds.has(pub) && !onlineKeys.has(pub)) {
  758. const name = await models.about.name(pub).catch(() => pub);
  759. unknownPeers.push([pub, { key: pub, name, users: ebtMap.get(pub) || [] }]);
  760. }
  761. }
  762. return { discoveredPeers, unknownPeers };
  763. },
  764. connStop: async () => {
  765. const ssb = await cooler.open();
  766. try {
  767. const result = await ssb.conn.stop();
  768. return result;
  769. } catch (e) {
  770. const expectedName = "TypeError";
  771. const expectedMessage = "Cannot read property 'close' of null";
  772. if (e.name === expectedName && e.message === expectedMessage) {
  773. debug("ssbConn is already stopped -- caught error");
  774. } else {
  775. throw new Error(e);
  776. }
  777. }
  778. },
  779. connStart: async () => {
  780. const ssb = await cooler.open();
  781. const result = await ssb.conn.start();
  782. return result;
  783. },
  784. connRestart: async () => {
  785. await models.meta.connStop();
  786. await models.meta.connStart();
  787. },
  788. sync: async () => {
  789. const ssb = await cooler.open();
  790. const progress = await ssb.progress();
  791. let previousTarget = progress.indexes.target;
  792. let keepGoing = true;
  793. const timeoutInterval = setTimeout(() => {
  794. keepGoing = false;
  795. }, 5 * 60 * 1000);
  796. await ssb.conn.start();
  797. const diff = async () =>
  798. new Promise((resolve) => {
  799. setTimeout(async () => {
  800. const currentProgress = await ssb.progress();
  801. const currentTarget = currentProgress.indexes.target;
  802. const difference = currentTarget - previousTarget;
  803. previousTarget = currentTarget;
  804. debug(`Difference: ${difference} bytes`);
  805. resolve(difference);
  806. }, 5000);
  807. });
  808. debug("Starting sync, waiting for new messages...");
  809. while (keepGoing && (await diff()) === 0) {
  810. debug("Received no new messages.");
  811. }
  812. debug("Finished waiting for first new message.");
  813. while (keepGoing && (await diff()) > 0) {
  814. debug(`Still receiving new messages...`);
  815. }
  816. debug("Finished waiting for last new message.");
  817. clearInterval(timeoutInterval);
  818. await ssb.conn.stop();
  819. },
  820. acceptInvite: async (invite) => {
  821. const ssb = await cooler.open();
  822. const code = toLegacyInvite(String(invite || ''));
  823. return await new Promise((resolve, reject) => {
  824. ssb.invite.accept(code, (err, res) => err ? reject(err) : resolve(res));
  825. });
  826. },
  827. rebuild: async () => {
  828. const ssb = await cooler.open();
  829. return ssb.rebuild();
  830. },
  831. };
  832. const isLooseRoot = (message) => {
  833. const conditions = [
  834. isPost(message),
  835. hasNoRoot(message),
  836. hasNoFork(message),
  837. ];
  838. return conditions.every((x) => x);
  839. };
  840. const isLooseSubtopic = (message) => {
  841. const conditions = [isPost(message), hasRoot(message), hasFork(message)];
  842. return conditions.every((x) => x);
  843. };
  844. const isLooseComment = (message) => {
  845. const conditions = [isPost(message), hasRoot(message), hasNoFork(message)];
  846. return conditions.every((x) => x === true);
  847. };
  848. const maxMessages = 30; // change it to control post overloading
  849. const getMessages = async ({
  850. myFeedId,
  851. customOptions,
  852. ssb,
  853. query,
  854. filter = null,
  855. }) => {
  856. const source = ssb.createLogStream({ reverse: true, limit: logLimit });
  857. return new Promise((resolve, reject) => {
  858. pull(
  859. source,
  860. pull.filter((msg) => {
  861. return msg.value.content.type === "post";
  862. }),
  863. pull.collect((err, collectedMessages) => {
  864. if (err) {
  865. reject(err);
  866. } else {
  867. resolve(collectedMessages);
  868. }
  869. })
  870. );
  871. });
  872. };
  873. const socialFilter = async ({
  874. following = null,
  875. blocking = false,
  876. me = null,
  877. } = {}) => {
  878. const ssb = await cooler.open();
  879. const { id } = ssb;
  880. const relationshipObject = await new Promise((resolve, reject) => {
  881. ssb.friends.graph((err, graph) => {
  882. if (err) {
  883. console.error(err);
  884. reject(err);
  885. }
  886. resolve(graph[id] || {});
  887. });
  888. });
  889. const followingList = Object.entries(relationshipObject)
  890. .filter(([, val]) => val >= 0)
  891. .map(([key]) => key);
  892. const blockingList = Object.entries(relationshipObject)
  893. .filter(([, val]) => val === -1)
  894. .map(([key]) => key);
  895. return pull.filter((message) => {
  896. if (message.value.author === id) {
  897. return me !== false;
  898. } else {
  899. return (
  900. (following === null ||
  901. followingList.includes(message.value.author) === following) &&
  902. (blocking === null ||
  903. blockingList.includes(message.value.author) === blocking)
  904. );
  905. }
  906. });
  907. };
  908. const getUserInfo = async (feedId) => {
  909. const pendingName = models.about.name(feedId);
  910. const pendingAvatarMsg = models.about.image(feedId);
  911. const pending = [pendingName, pendingAvatarMsg];
  912. const [name, avatarMsg] = await Promise.all(pending);
  913. const avatarId =
  914. avatarMsg != null && typeof avatarMsg.link === "string"
  915. ? avatarMsg.link || nullImage
  916. : avatarMsg || nullImage;
  917. const avatarUrl = `/image/64/${encodeURIComponent(avatarId)}`;
  918. return { name, feedId, avatarId, avatarUrl };
  919. };
  920. function getRecipientFeedId(recipient) {
  921. if (typeof recipient === "string") {
  922. return recipient;
  923. } else {
  924. return recipient.link;
  925. }
  926. }
  927. const transform = (ssb, messages, myFeedId) =>
  928. Promise.all(
  929. messages.map(async (msg) => {
  930. try {
  931. debug("transforming %s", msg.key);
  932. if (msg == null) {
  933. return null;
  934. }
  935. const filterQuery = {
  936. $filter: {
  937. dest: msg.key,
  938. },
  939. };
  940. const referenceStream = ssb.backlinks.read({
  941. query: [filterQuery],
  942. index: "DTA",
  943. private: true,
  944. meta: true,
  945. });
  946. if (lodash.get(msg, "value.content.type") === "blog") {
  947. const blogTitle = msg.value.content.title;
  948. const blogSummary = lodash.get(msg, "value.content.summary", null);
  949. const blobId = msg.value.content.blog;
  950. const blogContent = await models.blob.getResolved({ blobId });
  951. let textElements = [`# ${blogTitle}`, blogContent];
  952. if (blogSummary) {
  953. textElements.splice(1, 0, `**${blogSummary}**`);
  954. }
  955. lodash.set(msg, "value.content.text", textElements.join("\n\n"));
  956. }
  957. const rawVotes = await new Promise((resolve, reject) => {
  958. pull(
  959. referenceStream,
  960. pull.filter(
  961. (ref) =>
  962. isNotEncrypted(ref) &&
  963. ref.value.content.type === "vote" &&
  964. ref.value.content.vote &&
  965. typeof ref.value.content.vote.value === "number" &&
  966. ref.value.content.vote.value >= 0 &&
  967. ref.value.content.vote.link === msg.key
  968. ),
  969. pull.collect((err, collectedMessages) => {
  970. if (err) {
  971. reject(err);
  972. } else {
  973. resolve(collectedMessages);
  974. }
  975. })
  976. );
  977. });
  978. const reducedVotes = rawVotes.reduce((acc, vote) => {
  979. acc[vote.value.author] = vote.value.content.vote.value;
  980. return acc;
  981. }, {});
  982. const voters = Object.entries(reducedVotes)
  983. .filter(([, value]) => value === 1)
  984. .map(([key]) => key);
  985. const pendingVoterNames = voters.map(async (author) => ({
  986. name: await models.about.name(author),
  987. key: author,
  988. }));
  989. const voterNames = await Promise.all(pendingVoterNames);
  990. const { name, avatarId, avatarUrl } = await getUserInfo(
  991. msg.value.author
  992. );
  993. if (isPublic) {
  994. const publicOptIn = await models.about.publicWebHosting(
  995. msg.value.author
  996. );
  997. if (publicOptIn === false) {
  998. lodash.set(
  999. msg,
  1000. "value.content.text",
  1001. "This is a public message that has been redacted because Oasis is running in public mode. This redaction is only meant to make Oasis consistent with other public SSB viewers. Please do not mistake this for privacy. All public messages are public. Any peer on the Oasis network can see this message."
  1002. );
  1003. if (msg.value.content.contentWarning != null) {
  1004. msg.value.content.contentWarning = "Redacted";
  1005. }
  1006. }
  1007. }
  1008. const ts = new Date(msg.value.timestamp);
  1009. let isoTs;
  1010. try {
  1011. isoTs = ts.toISOString();
  1012. } catch (e) {
  1013. const receivedTs = new Date(msg.timestamp);
  1014. isoTs = receivedTs.toISOString();
  1015. }
  1016. lodash.set(msg, "value.meta.timestamp.received.iso8601", isoTs);
  1017. const ago = Date.now() - Number(ts);
  1018. const prettyAgo = prettyMs(ago, { compact: true });
  1019. lodash.set(msg, "value.meta.timestamp.received.since", prettyAgo);
  1020. lodash.set(msg, "value.meta.author.name", name);
  1021. lodash.set(msg, "value.meta.author.avatar", {
  1022. id: avatarId,
  1023. url: avatarUrl,
  1024. });
  1025. if (isTextLike(msg) && hasNoRoot(msg) && hasNoFork(msg)) {
  1026. lodash.set(msg, "value.meta.postType", "post");
  1027. } else if (isTextLike(msg) && hasRoot(msg) && hasNoFork(msg)) {
  1028. lodash.set(msg, "value.meta.postType", "comment");
  1029. } else if (isTextLike(msg) && hasRoot(msg) && hasFork(msg)) {
  1030. lodash.set(msg, "value.meta.postType", "subtopic");
  1031. } else {
  1032. lodash.set(msg, "value.meta.postType", "mystery");
  1033. }
  1034. lodash.set(msg, "value.meta.votes", voterNames);
  1035. lodash.set(msg, "value.meta.voted", voters.includes(myFeedId));
  1036. if (isPrivate(msg)) {
  1037. msg.value.meta.recpsInfo = await Promise.all(
  1038. msg.value.content.recps.map((recipient) => {
  1039. return getUserInfo(getRecipientFeedId(recipient));
  1040. })
  1041. );
  1042. }
  1043. const { blocking } = await models.friend.getRelationship(
  1044. msg.value.author
  1045. );
  1046. lodash.set(msg, "value.meta.blocking", blocking);
  1047. return msg;
  1048. } catch (err) {
  1049. return null;
  1050. }
  1051. })
  1052. );
  1053. const getLimitPost = async (feedId, reverse) => {
  1054. const ssb = await cooler.open();
  1055. const source = ssb.createUserStream({ id: feedId, reverse: reverse });
  1056. const messages = await new Promise((resolve, reject) => {
  1057. pull(
  1058. source,
  1059. pull.filter((msg) => isDecrypted(msg) === false && isPost(msg)),
  1060. pull.take(1),
  1061. pull.collect((err, collectedMessages) => {
  1062. if (err) {
  1063. reject(err);
  1064. } else {
  1065. resolve(transform(ssb, collectedMessages, feedId));
  1066. }
  1067. })
  1068. );
  1069. });
  1070. return messages.length ? messages[0] : undefined;
  1071. };
  1072. // POST MODEL
  1073. const post = {
  1074. firstBy: async (feedId) => {
  1075. return getLimitPost(feedId, false);
  1076. },
  1077. latestBy: async (feedId) => {
  1078. return getLimitPost(feedId, true);
  1079. },
  1080. fromPublicFeed: async (feedId, gt = -1, lt = -1, customOptions = {}) => {
  1081. const ssb = await cooler.open();
  1082. const myFeedId = ssb.id;
  1083. let defaultOptions = { id: feedId };
  1084. if (lt >= 0) defaultOptions.lt = lt;
  1085. if (gt >= 0) defaultOptions.gt = gt;
  1086. defaultOptions.reverse = !(gt >= 0 && lt < 0);
  1087. const options = configure(defaultOptions, customOptions);
  1088. const { blocking } = await models.friend.getRelationship(feedId);
  1089. if (blocking) {
  1090. return [];
  1091. }
  1092. const source = ssb.createUserStream(options);
  1093. const messages = await new Promise((resolve, reject) => {
  1094. pull(
  1095. source,
  1096. pull.filter((msg) => isDecrypted(msg) === false && isTextLike(msg)),
  1097. pull.take(maxMessages),
  1098. pull.collect((err, collectedMessages) => {
  1099. if (err) {
  1100. reject(err);
  1101. } else {
  1102. resolve(transform(ssb, collectedMessages, myFeedId));
  1103. }
  1104. })
  1105. );
  1106. });
  1107. if (!defaultOptions.reverse) return messages.reverse();
  1108. else return messages;
  1109. },
  1110. mentionsMe: async (customOptions = {}) => {
  1111. const ssb = await cooler.open();
  1112. const myFeedId = ssb.id;
  1113. const { name: myUsername } = await getUserInfo(myFeedId);
  1114. const query = [
  1115. {
  1116. $filter: {
  1117. "value.content.type": "post",
  1118. },
  1119. },
  1120. ];
  1121. const messages = await getMessages({
  1122. myFeedId,
  1123. customOptions,
  1124. ssb,
  1125. query,
  1126. filter: (msg) => {
  1127. const content = msg.value.content;
  1128. if (content.mentions) {
  1129. if (Array.isArray(content.mentions)) {
  1130. return content.mentions.some(m => m.link === myFeedId || m.name === myUsername || m.name === '@' + myUsername);
  1131. }
  1132. if (typeof content.mentions === 'object' && !Array.isArray(content.mentions)) {
  1133. const values = Object.values(content.mentions);
  1134. return values.some(v => v.link === myFeedId || v.name === myUsername || v.name === '@' + myUsername);
  1135. }
  1136. }
  1137. const mentionsText = lodash.get(content, "text", "");
  1138. if (mentionsText.includes(myFeedId) || mentionsText.includes(myFeedId.slice(1))) return true;
  1139. const mdMentionRegex = /\[@[^\]]*\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g;
  1140. let match;
  1141. while ((match = mdMentionRegex.exec(mentionsText))) {
  1142. if ('@' + match[1] === myFeedId || match[1] === myFeedId.slice(1)) return true;
  1143. }
  1144. return false;
  1145. },
  1146. });
  1147. return { messages, myFeedId };
  1148. },
  1149. fromHashtag: async (hashtag, customOptions = {}) => {
  1150. const ssb = await cooler.open();
  1151. const myFeedId = ssb.id;
  1152. const query = [
  1153. {
  1154. $filter: {
  1155. dest: `#${hashtag}`,
  1156. },
  1157. },
  1158. ];
  1159. const messages = await getMessages({
  1160. myFeedId,
  1161. customOptions,
  1162. ssb,
  1163. query,
  1164. });
  1165. return messages;
  1166. },
  1167. topicComments: async (rootId, customOptions = {}) => {
  1168. const ssb = await cooler.open();
  1169. const myFeedId = ssb.id;
  1170. const query = [
  1171. {
  1172. $filter: {
  1173. value: {
  1174. content: {
  1175. type: "post",
  1176. root: rootId,
  1177. },
  1178. },
  1179. },
  1180. },
  1181. ];
  1182. const messages = await getMessages({
  1183. myFeedId,
  1184. customOptions,
  1185. ssb,
  1186. query,
  1187. });
  1188. const fullMessages = await Promise.all(
  1189. messages.map(async (msg) => {
  1190. if (typeof msg === "string") {
  1191. return new Promise((resolve, reject) => {
  1192. ssb.get({ id: msg, meta: true, private: true }, (err, fullMsg) => {
  1193. if (err) reject(err);
  1194. else resolve(fullMsg);
  1195. });
  1196. });
  1197. }
  1198. return msg;
  1199. })
  1200. );
  1201. return fullMessages;
  1202. },
  1203. likes: async ({ feed }, customOptions = {}) => {
  1204. const ssb = await cooler.open();
  1205. const query = [
  1206. {
  1207. $filter: {
  1208. value: {
  1209. author: feed,
  1210. timestamp: { $lte: Date.now() },
  1211. content: {
  1212. type: 'vote',
  1213. },
  1214. },
  1215. },
  1216. },
  1217. ];
  1218. const options = { ...defaultOptions, query, reverse: true, ...customOptions };
  1219. const source = await ssb.query.read(options);
  1220. const messages = await new Promise((resolve, reject) => {
  1221. pull(
  1222. source,
  1223. pull.filter((msg) => {
  1224. return (
  1225. isNotEncrypted(msg) &&
  1226. msg.value.author === feed &&
  1227. typeof msg.value.content.vote === 'object' &&
  1228. typeof msg.value.content.vote.link === 'string'
  1229. );
  1230. }),
  1231. pull.take(maxMessages),
  1232. pull.unique((message) => message.value.content.vote.link),
  1233. pullParallelMap(async (val, cb) => {
  1234. const msg = await post.get(val.value.content.vote.link);
  1235. cb(null, msg);
  1236. }),
  1237. pull.filter((message) =>
  1238. message.value.meta.votes.map((voter) => voter.key).includes(feed)
  1239. ),
  1240. pull.collect((err, collectedMessages) => {
  1241. if (err) {
  1242. reject(err);
  1243. } else {
  1244. resolve(collectedMessages);
  1245. }
  1246. })
  1247. );
  1248. });
  1249. return messages;
  1250. },
  1251. search: async ({ query }) => {
  1252. const ssb = await cooler.open();
  1253. const myFeedId = ssb.id;
  1254. const options = configure({
  1255. query,
  1256. });
  1257. const source = await ssb.search.query(options);
  1258. const basicSocialFilter = await socialFilter();
  1259. const messages = await new Promise((resolve, reject) => {
  1260. pull(
  1261. source,
  1262. basicSocialFilter,
  1263. pull.filter(isNotPrivate),
  1264. pull.take(maxMessages),
  1265. pull.collect((err, collectedMessages) => {
  1266. if (err) {
  1267. reject(err);
  1268. } else {
  1269. resolve(transform(ssb, collectedMessages, myFeedId));
  1270. }
  1271. })
  1272. );
  1273. });
  1274. return messages;
  1275. },
  1276. latest: async () => {
  1277. const ssb = await cooler.open();
  1278. const myFeedId = ssb.id;
  1279. const source = ssb.query.read(
  1280. configure({
  1281. query: [
  1282. {
  1283. $filter: {
  1284. value: {
  1285. timestamp: { $lte: Date.now() },
  1286. content: {
  1287. type: { $in: ["post", "blog"] },
  1288. },
  1289. },
  1290. },
  1291. },
  1292. ],
  1293. })
  1294. );
  1295. const followingFilter = await socialFilter({ following: true });
  1296. const messages = await new Promise((resolve, reject) => {
  1297. pull(
  1298. source,
  1299. followingFilter,
  1300. publicOnlyFilter,
  1301. pull.take(maxMessages),
  1302. pull.collect((err, collectedMessages) => {
  1303. if (err) {
  1304. reject(err);
  1305. } else {
  1306. resolve(transform(ssb, collectedMessages, myFeedId));
  1307. }
  1308. })
  1309. );
  1310. });
  1311. return messages;
  1312. },
  1313. latestExtended: async () => {
  1314. const ssb = await cooler.open();
  1315. const myFeedId = ssb.id;
  1316. const source = ssb.query.read(
  1317. configure({
  1318. query: [
  1319. {
  1320. $filter: {
  1321. value: {
  1322. timestamp: { $lte: Date.now() },
  1323. content: {
  1324. type: { $in: ["post", "blog"] },
  1325. },
  1326. },
  1327. },
  1328. },
  1329. ],
  1330. })
  1331. );
  1332. const extendedFilter = await socialFilter({
  1333. following: false,
  1334. me: false,
  1335. });
  1336. const messages = await new Promise((resolve, reject) => {
  1337. pull(
  1338. source,
  1339. publicOnlyFilter,
  1340. extendedFilter,
  1341. pull.take(maxMessages),
  1342. pull.collect((err, collectedMessages) => {
  1343. if (err) {
  1344. reject(err);
  1345. } else {
  1346. resolve(transform(ssb, collectedMessages, myFeedId));
  1347. }
  1348. })
  1349. );
  1350. });
  1351. return messages;
  1352. },
  1353. latestTopics: async () => {
  1354. const ssb = await cooler.open();
  1355. const myFeedId = ssb.id;
  1356. const source = ssb.query.read(
  1357. configure({
  1358. query: [
  1359. {
  1360. $filter: {
  1361. value: {
  1362. timestamp: { $lte: Date.now() },
  1363. content: {
  1364. type: { $in: ["post", "blog"] },
  1365. },
  1366. },
  1367. },
  1368. },
  1369. ],
  1370. })
  1371. );
  1372. const extendedFilter = await socialFilter({
  1373. following: true,
  1374. });
  1375. const messages = await new Promise((resolve, reject) => {
  1376. pull(
  1377. source,
  1378. publicOnlyFilter,
  1379. pull.filter(hasNoRoot),
  1380. extendedFilter,
  1381. pull.take(maxMessages),
  1382. pull.collect((err, collectedMessages) => {
  1383. if (err) {
  1384. reject(err);
  1385. } else {
  1386. resolve(transform(ssb, collectedMessages, myFeedId));
  1387. }
  1388. })
  1389. );
  1390. });
  1391. return messages;
  1392. },
  1393. latestSummaries: async () => {
  1394. const ssb = await cooler.open();
  1395. const myFeedId = ssb.id;
  1396. const options = configure({
  1397. type: "post",
  1398. private: false,
  1399. });
  1400. const source = ssb.messagesByType(options);
  1401. const extendedFilter = await socialFilter({
  1402. following: true,
  1403. });
  1404. const messages = await new Promise((resolve, reject) => {
  1405. pull(
  1406. source,
  1407. pull.filter((message) => isNotPrivate(message) && hasNoRoot(message)),
  1408. extendedFilter,
  1409. pull.take(maxMessages),
  1410. pullParallelMap(async (message, cb) => {
  1411. const thread = await post.fromThread(message.key);
  1412. lodash.set(
  1413. message,
  1414. "value.meta.thread",
  1415. await transform(ssb, thread, myFeedId)
  1416. );
  1417. cb(null, message);
  1418. }),
  1419. pull.collect((err, collectedMessages) => {
  1420. if (err) {
  1421. reject(err);
  1422. } else {
  1423. resolve(transform(ssb, collectedMessages, myFeedId));
  1424. }
  1425. })
  1426. );
  1427. });
  1428. return messages;
  1429. },
  1430. latestThreads: async () => {
  1431. const ssb = await cooler.open();
  1432. const myFeedId = ssb.id;
  1433. const source = ssb.query.read(
  1434. configure({
  1435. query: [
  1436. {
  1437. $filter: {
  1438. value: {
  1439. timestamp: { $lte: Date.now() },
  1440. content: {
  1441. type: { $in: ["post", "blog"] },
  1442. },
  1443. },
  1444. },
  1445. },
  1446. ],
  1447. })
  1448. );
  1449. const basicSocialFilter = await socialFilter();
  1450. const messages = await new Promise((resolve, reject) => {
  1451. pull(
  1452. source,
  1453. basicSocialFilter,
  1454. pull.filter((message) => isNotPrivate(message) && hasNoRoot(message)),
  1455. pull.take(maxMessages),
  1456. pullParallelMap(async (message, cb) => {
  1457. const thread = await post.fromThread(message.key);
  1458. lodash.set(
  1459. message,
  1460. "value.meta.thread",
  1461. await transform(ssb, thread, myFeedId)
  1462. );
  1463. cb(null, message);
  1464. }),
  1465. pull.filter((message) => message.value.meta.thread.length > 1),
  1466. pull.collect((err, collectedMessages) => {
  1467. if (err) {
  1468. reject(err);
  1469. } else {
  1470. resolve(transform(ssb, collectedMessages, myFeedId));
  1471. }
  1472. })
  1473. );
  1474. });
  1475. return messages;
  1476. },
  1477. popular: async ({ period }) => {
  1478. const ssb = await cooler.open();
  1479. const periodDict = {
  1480. day: 1,
  1481. week: 7,
  1482. month: 30.42,
  1483. year: 365,
  1484. };
  1485. if (period in periodDict === false) {
  1486. throw new Error("invalid period");
  1487. }
  1488. const myFeedId = ssb.id;
  1489. const now = new Date();
  1490. const earliest = Number(now) - 1000 * 60 * 60 * 24 * periodDict[period];
  1491. const source = ssb.query.read(
  1492. configure({
  1493. query: [
  1494. {
  1495. $filter: {
  1496. value: {
  1497. timestamp: { $gte: earliest },
  1498. content: {
  1499. type: "vote",
  1500. },
  1501. },
  1502. },
  1503. },
  1504. ],
  1505. })
  1506. );
  1507. const basicSocialFilter = await socialFilter();
  1508. const messages = await new Promise((resolve, reject) => {
  1509. pull(
  1510. source,
  1511. publicOnlyFilter,
  1512. pull.filter((msg) => {
  1513. return (
  1514. isNotEncrypted(msg) &&
  1515. typeof msg.value.content.vote === "object" &&
  1516. typeof msg.value.content.vote.link === "string" &&
  1517. typeof msg.value.content.vote.value === "number"
  1518. );
  1519. }),
  1520. pull.reduce(
  1521. (acc, cur) => {
  1522. const author = cur.value.author;
  1523. const target = cur.value.content.vote.link;
  1524. const value = cur.value.content.vote.value;
  1525. if (acc[author] == null) {
  1526. acc[author] = {};
  1527. }
  1528. acc[author][target] = Math.max(-1, Math.min(1, value));
  1529. return acc;
  1530. },
  1531. {},
  1532. (err, obj) => {
  1533. if (err) {
  1534. return reject(err);
  1535. }
  1536. const adjustedObj = Object.entries(obj).reduce(
  1537. (acc, [author, values]) => {
  1538. if (author === myFeedId) {
  1539. return acc;
  1540. }
  1541. const entries = Object.entries(values);
  1542. const total = 1 + Math.log(entries.length);
  1543. entries.forEach(([link, value]) => {
  1544. if (acc[link] == null) {
  1545. acc[link] = 0;
  1546. }
  1547. acc[link] += value / total;
  1548. });
  1549. return acc;
  1550. },
  1551. []
  1552. );
  1553. const arr = Object.entries(adjustedObj);
  1554. const length = arr.length;
  1555. pull(
  1556. pull.values(arr),
  1557. pullSort(([, aVal], [, bVal]) => bVal - aVal),
  1558. pull.take(Math.min(length, maxMessages)),
  1559. pull.map(([key]) => key),
  1560. pullParallelMap(async (key, cb) => {
  1561. try {
  1562. const msg = await post.get(key);
  1563. cb(null, msg);
  1564. } catch (e) {
  1565. cb(null, null);
  1566. }
  1567. }),
  1568. pull.filter(
  1569. (message) =>
  1570. message &&
  1571. isNotPrivate(message) &&
  1572. (message.value.content.type === "post" ||
  1573. message.value.content.type === "blog")
  1574. ),
  1575. basicSocialFilter,
  1576. pull.collect((collectErr, collectedMessages) => {
  1577. if (collectErr) {
  1578. reject(collectErr);
  1579. } else {
  1580. resolve(collectedMessages);
  1581. }
  1582. })
  1583. );
  1584. }
  1585. )
  1586. );
  1587. });
  1588. return messages;
  1589. },
  1590. fromThread: async (msgId, customOptions) => {
  1591. const ssb = await cooler.open();
  1592. const myFeedId = ssb.id;
  1593. const options = configure({ id: msgId }, customOptions);
  1594. const rawMsg = await new Promise((resolve, reject) => {
  1595. ssb.get(options, (err, msg) => {
  1596. if (err) reject(err);
  1597. else resolve(msg);
  1598. });
  1599. });
  1600. const parents = [];
  1601. const getRootAncestor = (msg) =>
  1602. new Promise((resolve, reject) => {
  1603. if (msg.key == null) {
  1604. resolve(parents);
  1605. } else if (isEncrypted(msg)) {
  1606. if (parents.length > 0) {
  1607. resolve(parents);
  1608. } else {
  1609. resolve(msg);
  1610. }
  1611. } else if (msg.value.content.type !== "post") {
  1612. resolve(msg);
  1613. } else if (isLooseSubtopic(msg) && ssbRef.isMsg(msg.value.content.fork)) {
  1614. ssb.get(
  1615. { id: msg.value.content.fork, meta: true, private: true },
  1616. (err, fork) => {
  1617. if (err) reject(err);
  1618. else getRootAncestor(fork).then(resolve).catch(reject);
  1619. }
  1620. );
  1621. } else if (isLooseComment(msg) && ssbRef.isMsg(msg.value.content.root)) {
  1622. ssb.get(
  1623. { id: msg.value.content.root, meta: true, private: true },
  1624. (err, root) => {
  1625. if (err) reject(err);
  1626. else getRootAncestor(root).then(resolve).catch(reject);
  1627. }
  1628. );
  1629. } else if (isLooseRoot(msg)) {
  1630. resolve(msg);
  1631. } else {
  1632. resolve(msg);
  1633. }
  1634. });
  1635. const getDirectDescendants = (key) =>
  1636. new Promise((resolve, reject) => {
  1637. const filterQuery = {
  1638. $filter: {
  1639. dest: key,
  1640. },
  1641. };
  1642. const referenceStream = ssb.backlinks.read({
  1643. query: [filterQuery],
  1644. index: "DTA",
  1645. });
  1646. pull(
  1647. referenceStream,
  1648. pull.filter((msg) => {
  1649. if (!isTextLike(msg)) return false;
  1650. const root = lodash.get(msg, "value.content.root");
  1651. const fork = lodash.get(msg, "value.content.fork");
  1652. if (root !== key && fork !== key) return false;
  1653. if (fork === key) return false;
  1654. return true;
  1655. }),
  1656. pull.collect((err, messages) => {
  1657. if (err) reject(err);
  1658. else resolve(messages || undefined);
  1659. })
  1660. );
  1661. });
  1662. const flattenDeep = (arr1) =>
  1663. arr1.reduce(
  1664. (acc, val) =>
  1665. Array.isArray(val)
  1666. ? acc.concat(flattenDeep(val))
  1667. : acc.concat(val),
  1668. []
  1669. );
  1670. const getDeepDescendants = (key) =>
  1671. new Promise((resolve, reject) => {
  1672. const oneDeeper = async (descendantKey, depth) => {
  1673. const descendants = await getDirectDescendants(descendantKey);
  1674. if (descendants.length === 0) return descendants;
  1675. return Promise.all(
  1676. descendants.map(async (descendant) => {
  1677. const deeperDescendants = await oneDeeper(descendant.key, depth + 1);
  1678. lodash.set(descendant, "value.meta.thread.depth", depth);
  1679. lodash.set(descendant, "value.meta.thread.subtopic", true);
  1680. return [descendant, deeperDescendants];
  1681. })
  1682. );
  1683. };
  1684. oneDeeper(key, 0)
  1685. .then((nested) => {
  1686. const nestedDescendants = [...nested];
  1687. const deepDescendants = flattenDeep(nestedDescendants);
  1688. resolve(deepDescendants);
  1689. })
  1690. .catch(reject);
  1691. });
  1692. const rootAncestor = await getRootAncestor(rawMsg);
  1693. const deepDescendants = await getDeepDescendants(rootAncestor.key);
  1694. const allMessages = [rootAncestor, ...deepDescendants].map((message) => {
  1695. const isThreadTarget = message.key === msgId;
  1696. lodash.set(message, "value.meta.thread.target", isThreadTarget);
  1697. return message;
  1698. });
  1699. return await transform(ssb, allMessages, myFeedId);
  1700. },
  1701. get: async (msgId, customOptions) => {
  1702. const ssb = await cooler.open();
  1703. const myFeedId = ssb.id;
  1704. const options = configure({ id: msgId }, customOptions);
  1705. const rawMsg = await new Promise((resolve, reject) => {
  1706. ssb.get(options, (err, msg) => {
  1707. if (err) reject(err);
  1708. else resolve(msg);
  1709. });
  1710. });
  1711. const transformed = await transform(ssb, [rawMsg], myFeedId);
  1712. return transformed[0];
  1713. },
  1714. publish: async (options) => {
  1715. const ssb = await cooler.open();
  1716. const body = { type: "post", ...options };
  1717. return new Promise((resolve, reject) => {
  1718. ssb.publish(body, (err, msg) => {
  1719. if (err) reject(err);
  1720. else resolve(msg);
  1721. });
  1722. });
  1723. },
  1724. publishProfileEdit: async ({ name, description, image, visibilityPrefs }) => {
  1725. const ssb = await cooler.open();
  1726. const normalizePrefs = (raw) => {
  1727. const r = raw || {};
  1728. return {
  1729. activity: r.activity === true,
  1730. device: r.device === true,
  1731. karma: r.karma !== false,
  1732. ubi: r.ubi === true,
  1733. wallet: r.wallet === true,
  1734. ecoTax: r.ecoTax !== false,
  1735. clearnet: r.clearnet === true,
  1736. clearnetShops: r.clearnetShops === true,
  1737. clearnetJobs: r.clearnetJobs === true,
  1738. clearnetEvents: r.clearnetEvents === true,
  1739. clearnetProjects: r.clearnetProjects === true,
  1740. clearnetPosts: r.clearnetPosts === true,
  1741. clearnetAudios: r.clearnetAudios === true,
  1742. clearnetVideos: r.clearnetVideos === true,
  1743. clearnetImages: r.clearnetImages === true,
  1744. clearnetDocuments: r.clearnetDocuments === true,
  1745. clearnetTorrents: r.clearnetTorrents === true,
  1746. profileShops: r.profileShops === true,
  1747. profileJobs: r.profileJobs === true,
  1748. profileEvents: r.profileEvents === true,
  1749. profileProjects: r.profileProjects === true,
  1750. profilePosts: r.profilePosts === true,
  1751. profileAudios: r.profileAudios === true,
  1752. profileVideos: r.profileVideos === true,
  1753. profileImages: r.profileImages === true,
  1754. profileDocuments: r.profileDocuments === true,
  1755. profileTorrents: r.profileTorrents === true
  1756. };
  1757. };
  1758. const prefs = visibilityPrefs ? normalizePrefs(visibilityPrefs) : undefined;
  1759. const baseFields = { type: "about", about: ssb.id, name, description };
  1760. if (prefs) baseFields.visibilityPrefs = prefs;
  1761. if (image && image.length > 0) {
  1762. const megabyte = Math.pow(2, 20);
  1763. const maxSize = 50 * megabyte;
  1764. if (image.length > maxSize) {
  1765. throw new Error("File is too big, maximum size is 50 megabytes");
  1766. }
  1767. return new Promise((resolve, reject) => {
  1768. pull(
  1769. pull.values([image]),
  1770. ssb.blobs.add((err, blobId) => {
  1771. if (err) {
  1772. reject(err);
  1773. } else {
  1774. const content = { ...baseFields, image: blobId };
  1775. ssb.publish(content, (err, msg) => {
  1776. if (err) reject(err);
  1777. else resolve(msg);
  1778. });
  1779. }
  1780. })
  1781. );
  1782. });
  1783. } else {
  1784. return new Promise((resolve, reject) => {
  1785. ssb.publish(baseFields, (err, msg) => {
  1786. if (err) reject(err);
  1787. else resolve(msg);
  1788. });
  1789. });
  1790. }
  1791. },
  1792. publishCustom: async (options) => {
  1793. const ssb = await cooler.open();
  1794. return new Promise((resolve, reject) => {
  1795. ssb.publish(options, (err, msg) => {
  1796. if (err) reject(err);
  1797. else resolve(msg);
  1798. });
  1799. });
  1800. },
  1801. subtopic: async ({ parent, message }) => {
  1802. message = { ...message };
  1803. message.root = parent.key;
  1804. message.fork = lodash.get(parent, "value.content.root");
  1805. message.branch = await post.branch({ root: parent.key });
  1806. message.type = "post";
  1807. if (!Array.isArray(message.mentions)) message.mentions = [];
  1808. if (isSubtopic(message) !== true) {
  1809. const messageString = JSON.stringify(message, null, 2);
  1810. throw new Error(`message should be valid subtopic: ${messageString}`);
  1811. }
  1812. return post.publish(message);
  1813. },
  1814. root: async (options) => {
  1815. const message = { type: "post", ...options };
  1816. if (isRoot(message) !== true) {
  1817. const messageString = JSON.stringify(message, null, 2);
  1818. }
  1819. return post.publish(message);
  1820. },
  1821. comment: async ({ parent, message }) => {
  1822. if (!parent || !parent.value) {
  1823. throw new Error("Invalid parent message: Missing 'value'");
  1824. }
  1825. const parentKey = parent.key;
  1826. const parentFork = lodash.get(parent, "value.content.fork");
  1827. const parentRoot = lodash.get(parent, "value.content.root", parentKey);
  1828. if (isDecrypted(parent)) {
  1829. message.recps = lodash
  1830. .get(parent, "value.content.recps", [])
  1831. .map((recipient) => {
  1832. if (
  1833. typeof recipient === "object" &&
  1834. typeof recipient.link === "string" &&
  1835. recipient.link.length
  1836. ) {
  1837. return recipient.link;
  1838. } else {
  1839. return recipient;
  1840. }
  1841. });
  1842. if (message.recps.length === 0) {
  1843. throw new Error("Refusing to publish message with no recipients");
  1844. }
  1845. }
  1846. const parentHasFork = parentFork != null;
  1847. message.root = parentHasFork ? parentKey : parentRoot;
  1848. message.branch = await post.branch({ root: parent.key });
  1849. message.type = "post";
  1850. if (isComment(message) !== true) {
  1851. const messageString = JSON.stringify(message, null, 2);
  1852. throw new Error(`Message should be a valid comment: ${messageString}`);
  1853. }
  1854. return post.publish(message);
  1855. },
  1856. branch: async ({ root }) => {
  1857. const ssb = await cooler.open();
  1858. return new Promise((resolve, reject) => {
  1859. ssb.tangle.branch(root, (err, keys) => {
  1860. if (err) {
  1861. return reject(err);
  1862. }
  1863. resolve(keys);
  1864. });
  1865. });
  1866. },
  1867. channels: async () => {
  1868. const ssb = await cooler.open();
  1869. const source = ssb.createUserStream({ id: ssb.id });
  1870. const messages = await new Promise((resolve, reject) => {
  1871. pull(
  1872. source,
  1873. pull.filter((message) => {
  1874. return lodash.get(message, "value.content.type") === "channel"
  1875. ? true
  1876. : false;
  1877. }),
  1878. pull.collect((err, collectedMessages) => {
  1879. if (err) {
  1880. reject(err);
  1881. } else {
  1882. resolve(transform(ssb, collectedMessages, ssb.id));
  1883. }
  1884. })
  1885. );
  1886. });
  1887. const channels = messages.map((msg) => {
  1888. return {
  1889. channel: msg.value.content.channel,
  1890. subscribed: msg.value.content.subscribed,
  1891. };
  1892. });
  1893. let subbedChannels = [];
  1894. channels.forEach((ch) => {
  1895. if (ch.subscribed && !subbedChannels.includes(ch.channel)) {
  1896. subbedChannels.push(ch.channel);
  1897. }
  1898. if (ch.subscribed === false && subbedChannels.includes(ch.channel)) {
  1899. subbedChannels = lodash.pull(subbedChannels, ch.channel);
  1900. }
  1901. });
  1902. return subbedChannels;
  1903. },
  1904. inbox: async () => {
  1905. const ssb = await cooler.open();
  1906. const myFeedId = ssb.id;
  1907. const rawMessages = await new Promise((resolve, reject) => {
  1908. pull(
  1909. ssb.createLogStream({ reverse: true, limit: logLimit }),
  1910. pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
  1911. );
  1912. });
  1913. const decryptedMessages = rawMessages.map(msg => {
  1914. try {
  1915. return ssb.private.unbox(msg);
  1916. } catch {
  1917. return null;
  1918. }
  1919. }).filter(Boolean);
  1920. const tombstoneTargets = new Set(
  1921. decryptedMessages
  1922. .filter(msg => msg.value?.content?.type === 'tombstone')
  1923. .map(msg => msg.value.content.target)
  1924. );
  1925. return decryptedMessages.filter(msg => {
  1926. if (tombstoneTargets.has(msg.key)) return false;
  1927. const content = msg.value?.content;
  1928. const author = msg.value?.author;
  1929. return content?.type === 'post' && content?.private === true && (author === myFeedId || content.to?.includes(myFeedId));
  1930. });
  1931. }
  1932. };
  1933. models.post = post;
  1934. // SPREAD MODEL
  1935. models.vote = {
  1936. publish: async ({ messageKey, value, recps }) => {
  1937. const ssb = await cooler.open();
  1938. const branch = await new Promise((resolve, reject) => {
  1939. ssb.tangle.branch(messageKey, (err, result) => {
  1940. if (err) {
  1941. console.error("Error fetching branch:", err);
  1942. reject(err);
  1943. } else {
  1944. resolve(result);
  1945. }
  1946. });
  1947. });
  1948. const content = {
  1949. type: "vote",
  1950. vote: {
  1951. link: messageKey,
  1952. value: Number(value),
  1953. },
  1954. branch,
  1955. recps,
  1956. };
  1957. return new Promise((resolve, reject) => {
  1958. ssb.publish(content, (err, msg) => {
  1959. if (err) {
  1960. console.error("Publish error:", err);
  1961. reject(err);
  1962. } else {
  1963. resolve(msg);
  1964. }
  1965. });
  1966. });
  1967. },
  1968. };
  1969. models.lifetime = (() => {
  1970. const FRESH_GREEN_DAYS = 14;
  1971. const FRESH_ORANGE_DAYS = 182.5;
  1972. const norm = (t) => (t && t < 1e12 ? t * 1000 : t || 0);
  1973. const bucketOf = (ts) => {
  1974. if (!ts) return { bucket: 'red', range: '≥6m' };
  1975. const days = Math.max(0, Date.now() - ts) / 86400000;
  1976. if (days < FRESH_GREEN_DAYS) return { bucket: 'green', range: '<2w' };
  1977. if (days < FRESH_ORANGE_DAYS) return { bucket: 'orange', range: '2w–6m' };
  1978. return { bucket: 'red', range: '≥6m' };
  1979. };
  1980. const lastAuthorTs = async (feedId) => {
  1981. if (!feedId) return null;
  1982. const ssbClient = await cooler.open();
  1983. return new Promise((resolve) => {
  1984. try {
  1985. pull(
  1986. ssbClient.createUserStream({ id: feedId, reverse: true }),
  1987. pull.filter(m => m && m.value && m.value.content && m.value.content.type !== 'tombstone'),
  1988. pull.take(1),
  1989. pull.collect((err, arr) => {
  1990. if (err || !arr || !arr.length) return resolve(null);
  1991. const m = arr[0];
  1992. resolve(norm((m.value && m.value.timestamp) || m.timestamp) || null);
  1993. })
  1994. );
  1995. } catch (_) { resolve(null); }
  1996. });
  1997. };
  1998. const lastBacklinkTs = async (msgKey) => {
  1999. if (!msgKey) return null;
  2000. const ssbClient = await cooler.open();
  2001. return new Promise((resolve) => {
  2002. try {
  2003. pull(
  2004. ssbClient.backlinks.read({ query: [{ $filter: { dest: msgKey } }], index: 'DTA', reverse: true, limit: 1 }),
  2005. pull.collect((err, arr) => {
  2006. if (err || !arr || !arr.length) return resolve(null);
  2007. const m = arr[0];
  2008. resolve(norm((m.value && m.value.timestamp) || m.timestamp) || null);
  2009. })
  2010. );
  2011. } catch (_) { resolve(null); }
  2012. });
  2013. };
  2014. return {
  2015. bucket: bucketOf,
  2016. lastAuthorTs,
  2017. lastBacklinkTs,
  2018. async forContent({ key, author, createdAt } = {}) {
  2019. const [authorTs, interactionTs] = await Promise.all([
  2020. author ? lastAuthorTs(author) : null,
  2021. key ? lastBacklinkTs(key) : null
  2022. ]);
  2023. const createdTs = createdAt ? new Date(createdAt).getTime() : null;
  2024. const candidates = [authorTs, interactionTs, createdTs].filter(x => typeof x === 'number' && x > 0);
  2025. const lastTs = candidates.length ? Math.max(...candidates) : null;
  2026. const { bucket, range } = bucketOf(lastTs);
  2027. return { bucket, range, lastTs, authorTs, interactionTs, createdTs };
  2028. },
  2029. async enrichAndFilter(items, opts = {}) {
  2030. const { includeDead = false, getKey = (x) => x.id || x.key, getAuthor = (x) => x.author, getCreatedAt = (x) => x.createdAt } = opts;
  2031. const authorCache = new Map();
  2032. const out = [];
  2033. for (const item of items) {
  2034. const author = getAuthor(item);
  2035. let authorTs;
  2036. if (author && authorCache.has(author)) {
  2037. authorTs = authorCache.get(author);
  2038. } else {
  2039. authorTs = author ? await lastAuthorTs(author) : null;
  2040. if (author) authorCache.set(author, authorTs);
  2041. }
  2042. const key = getKey(item);
  2043. const interactionTs = key ? await lastBacklinkTs(key) : null;
  2044. const createdAt = getCreatedAt(item);
  2045. const createdTs = createdAt ? new Date(createdAt).getTime() : null;
  2046. const candidates = [authorTs, interactionTs, createdTs].filter(x => typeof x === 'number' && x > 0);
  2047. const lastTs = candidates.length ? Math.max(...candidates) : null;
  2048. const { bucket, range } = bucketOf(lastTs);
  2049. if (!includeDead && bucket === 'red') continue;
  2050. out.push({ ...item, lifetime: { bucket, range, lastTs, authorTs, interactionTs, createdTs } });
  2051. }
  2052. return out;
  2053. }
  2054. };
  2055. })();
  2056. models.spreads = {
  2057. /**
  2058. * Returns { count, voters: [{ key, name }], alreadySpread } for a given msgKey.
  2059. * A "spread" is a vote with value=1 referencing msgKey AND with msgKey in branch.
  2060. */
  2061. forMessage: async (msgKey) => {
  2062. if (!msgKey || typeof msgKey !== 'string') return { count: 0, voters: [], alreadySpread: false };
  2063. const ssb = await cooler.open();
  2064. const myId = ssb.id;
  2065. return new Promise((resolve) => {
  2066. pull(
  2067. ssb.backlinks.read({
  2068. query: [{ $filter: { dest: msgKey, value: { content: { type: 'vote' } } } }],
  2069. index: 'DTA',
  2070. meta: true
  2071. }),
  2072. pull.filter(ref => {
  2073. if (!ref || !ref.value || !ref.value.content) return false;
  2074. const c = ref.value.content;
  2075. if (!c.vote || c.vote.link !== msgKey || Number(c.vote.value) !== 1) return false;
  2076. const br = Array.isArray(c.branch) ? c.branch : (typeof c.branch === 'string' ? [c.branch] : []);
  2077. return br.includes(msgKey);
  2078. }),
  2079. pull.collect(async (err, refs) => {
  2080. if (err) return resolve({ count: 0, voters: [], alreadySpread: false });
  2081. const byAuthor = new Map();
  2082. for (const r of refs) byAuthor.set(r.value.author, true);
  2083. const authors = Array.from(byAuthor.keys());
  2084. const voters = await Promise.all(authors.map(async (k) => ({
  2085. key: k,
  2086. name: await models.about.name(k).catch(() => k.slice(1, 9))
  2087. })));
  2088. resolve({ count: voters.length, voters, alreadySpread: authors.includes(myId) });
  2089. })
  2090. );
  2091. });
  2092. }
  2093. };
  2094. return models;
  2095. };