opinions_model.js 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. const pull = require('../server/node_modules/pull-stream');
  2. const { getConfig } = require('../configs/config-manager.js');
  3. const categories = require('../backend/opinion_categories');
  4. const logLimit = getConfig().ssbLogStream?.limit || 1000;
  5. module.exports = ({ cooler }) => {
  6. let ssb;
  7. const openSsb = async () => {
  8. if (!ssb) ssb = await cooler.open();
  9. return ssb;
  10. };
  11. const hasBlob = async (ssbClient, url) => {
  12. return new Promise(resolve => {
  13. ssbClient.blobs.has(url, (err, has) => {
  14. resolve(!err && has);
  15. });
  16. });
  17. };
  18. const validTypes = [
  19. 'bookmark', 'votes', 'transfer',
  20. 'feed', 'image', 'audio', 'video', 'document'
  21. ];
  22. const getPreview = c => {
  23. if (c.type === 'bookmark' && c.bookmark) return `🔖 ${c.bookmark}`;
  24. return c.text || c.description || c.title || '';
  25. };
  26. const createVote = async (contentId, category) => {
  27. const ssbClient = await openSsb();
  28. const userId = ssbClient.id;
  29. if (!categories.includes(category)) throw new Error("Invalid voting category.");
  30. const msg = await new Promise((resolve, reject) =>
  31. ssbClient.get(contentId, (err, value) => err ? reject(err) : resolve(value))
  32. );
  33. if (!msg || !msg.content) throw new Error("Opinion not found.");
  34. const type = msg.content.type;
  35. if (!validTypes.includes(type) || ['task', 'event', 'report'].includes(type)) {
  36. throw new Error("Voting not allowed on this content type.");
  37. }
  38. if (msg.content.opinions_inhabitants?.includes(userId)) throw new Error("Already voted.");
  39. const tombstone = {
  40. type: 'tombstone',
  41. target: contentId,
  42. deletedAt: new Date().toISOString()
  43. };
  44. const updated = {
  45. ...msg.content,
  46. opinions: {
  47. ...msg.content.opinions,
  48. [category]: (msg.content.opinions?.[category] || 0) + 1
  49. },
  50. opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
  51. updatedAt: new Date().toISOString(),
  52. replaces: contentId
  53. };
  54. await new Promise((resolve, reject) =>
  55. ssbClient.publish(tombstone, err => err ? reject(err) : resolve())
  56. );
  57. return new Promise((resolve, reject) =>
  58. ssbClient.publish(updated, (err, result) => err ? reject(err) : resolve(result))
  59. );
  60. };
  61. const listOpinions = async (filter = 'ALL', category = '') => {
  62. const ssbClient = await openSsb();
  63. const userId = ssbClient.id;
  64. const messages = await new Promise((res, rej) => {
  65. pull(
  66. ssbClient.createLogStream({ limit: logLimit }),
  67. pull.collect((err, msgs) => err ? rej(err) : res(msgs))
  68. );
  69. });
  70. const tombstoned = new Set();
  71. const replaces = new Map();
  72. const byId = new Map();
  73. for (const msg of messages) {
  74. const key = msg.key;
  75. const c = msg.value?.content;
  76. if (!c) continue;
  77. if (c.type === 'tombstone' && c.target) {
  78. tombstoned.add(c.target);
  79. byId.delete(c.target);
  80. continue;
  81. }
  82. if (c.opinions && !tombstoned.has(key) && !['task', 'event', 'report'].includes(c.type)) {
  83. if (c.replaces) replaces.set(c.replaces, key);
  84. byId.set(key, {
  85. key,
  86. value: {
  87. ...msg.value,
  88. content: c,
  89. preview: getPreview(c)
  90. }
  91. });
  92. }
  93. if (c.type === 'feed' && !tombstoned.has(key) && !byId.has(key)) {
  94. if (c.replaces) replaces.set(c.replaces, key);
  95. byId.set(key, { key, value: { ...msg.value, content: c, preview: getPreview(c) } });
  96. }
  97. }
  98. for (const replacedId of replaces.keys()) {
  99. byId.delete(replacedId);
  100. }
  101. let filtered = Array.from(byId.values()).filter(m => validTypes.includes(m.value?.content?.type));
  102. const blobTypes = ['document', 'image', 'audio', 'video'];
  103. const blobCheckCache = new Map();
  104. filtered = await Promise.all(
  105. filtered.map(async m => {
  106. const c = m.value.content;
  107. if (blobTypes.includes(c.type) && c.url) {
  108. if (!blobCheckCache.has(c.url)) {
  109. const valid = await hasBlob(ssbClient, c.url);
  110. blobCheckCache.set(c.url, valid);
  111. }
  112. if (!blobCheckCache.get(c.url)) return null;
  113. }
  114. return m;
  115. })
  116. );
  117. filtered = filtered.filter(Boolean);
  118. const signatureOf = (m) => {
  119. const c = m.value?.content || {};
  120. switch (c.type) {
  121. case 'document':
  122. case 'image':
  123. case 'audio':
  124. case 'video':
  125. return `${c.type}::${(c.url || '').trim()}`;
  126. case 'bookmark': {
  127. const u = (c.url || c.bookmark || '').trim().toLowerCase();
  128. return `bookmark::${u}`;
  129. }
  130. case 'feed': {
  131. const t = (c.text || '').replace(/\s+/g, ' ').trim();
  132. return `feed::${t}`;
  133. }
  134. case 'votes': {
  135. const q = (c.question || '').replace(/\s+/g, ' ').trim();
  136. return `votes::${q}`;
  137. }
  138. case 'transfer': {
  139. const concept = (c.concept || '').trim();
  140. const amount = c.amount || '';
  141. const from = c.from || '';
  142. const to = c.to || '';
  143. const deadline = c.deadline || '';
  144. return `transfer::${concept}|${amount}|${from}|${to}|${deadline}`;
  145. }
  146. default:
  147. return `key::${m.key}`;
  148. }
  149. };
  150. const bySig = new Map();
  151. for (const m of filtered) {
  152. const sig = signatureOf(m);
  153. const prev = bySig.get(sig);
  154. if (!prev || (m.value?.timestamp || 0) > (prev.value?.timestamp || 0)) {
  155. bySig.set(sig, m);
  156. }
  157. }
  158. filtered = Array.from(bySig.values());
  159. if (filter === 'MINE') {
  160. filtered = filtered.filter(m => m.value.author === userId);
  161. } else if (filter === 'RECENT') {
  162. const now = Date.now();
  163. filtered = filtered.filter(m => now - m.value.timestamp < 24 * 60 * 60 * 1000);
  164. } else if (filter === 'TOP') {
  165. filtered = filtered.sort((a, b) => {
  166. const sum = v => Object.values(v.content.opinions || {}).reduce((acc, x) => acc + x, 0);
  167. return sum(b.value) - sum(a.value);
  168. });
  169. } else if (categories.includes(filter)) {
  170. filtered = filtered
  171. .filter(m => m.value.content.opinions?.[filter])
  172. .sort((a, b) =>
  173. (b.value.content.opinions[filter] || 0) - (a.value.content.opinions[filter] || 0)
  174. );
  175. }
  176. return filtered;
  177. };
  178. const getMessageById = async id => {
  179. const ssbClient = await openSsb();
  180. return new Promise((resolve, reject) =>
  181. ssbClient.get(id, (err, msg) =>
  182. err ? reject(new Error("Error fetching opinion: " + err)) :
  183. !msg?.content ? reject(new Error("Opinion not found")) :
  184. resolve(msg)
  185. )
  186. );
  187. };
  188. return {
  189. createVote,
  190. listOpinions,
  191. getMessageById,
  192. categories
  193. };
  194. };