opinions_model.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  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. continue;
  80. }
  81. if (c.opinions && !tombstoned.has(key) && !['task', 'event', 'report'].includes(c.type)) {
  82. if (c.replaces) replaces.set(c.replaces, key);
  83. byId.set(key, {
  84. key,
  85. value: {
  86. ...msg.value,
  87. content: c,
  88. preview: getPreview(c)
  89. }
  90. });
  91. }
  92. }
  93. for (const replacedId of replaces.keys()) {
  94. byId.delete(replacedId);
  95. }
  96. let filtered = Array.from(byId.values());
  97. const blobTypes = ['document', 'image', 'audio', 'video'];
  98. const blobCheckCache = new Map();
  99. filtered = await Promise.all(
  100. filtered.map(async m => {
  101. const c = m.value.content;
  102. if (blobTypes.includes(c.type) && c.url) {
  103. if (!blobCheckCache.has(c.url)) {
  104. const valid = await hasBlob(ssbClient, c.url);
  105. blobCheckCache.set(c.url, valid);
  106. }
  107. if (!blobCheckCache.get(c.url)) return null;
  108. }
  109. return m;
  110. })
  111. );
  112. filtered = filtered.filter(Boolean);
  113. const signatureOf = (m) => {
  114. const c = m.value?.content || {};
  115. switch (c.type) {
  116. case 'document':
  117. case 'image':
  118. case 'audio':
  119. case 'video':
  120. return `${c.type}::${(c.url || '').trim()}`;
  121. case 'bookmark': {
  122. const u = (c.url || c.bookmark || '').trim().toLowerCase();
  123. return `bookmark::${u}`;
  124. }
  125. case 'feed': {
  126. const t = (c.text || '').replace(/\s+/g, ' ').trim();
  127. return `feed::${t}`;
  128. }
  129. case 'votes': {
  130. const q = (c.question || '').replace(/\s+/g, ' ').trim();
  131. return `votes::${q}`;
  132. }
  133. case 'transfer': {
  134. const concept = (c.concept || '').trim();
  135. const amount = c.amount || '';
  136. const from = c.from || '';
  137. const to = c.to || '';
  138. const deadline = c.deadline || '';
  139. return `transfer::${concept}|${amount}|${from}|${to}|${deadline}`;
  140. }
  141. default:
  142. return `key::${m.key}`;
  143. }
  144. };
  145. const bySig = new Map();
  146. for (const m of filtered) {
  147. const sig = signatureOf(m);
  148. const prev = bySig.get(sig);
  149. if (!prev || (m.value?.timestamp || 0) > (prev.value?.timestamp || 0)) {
  150. bySig.set(sig, m);
  151. }
  152. }
  153. filtered = Array.from(bySig.values());
  154. if (filter === 'MINE') {
  155. filtered = filtered.filter(m => m.value.author === userId);
  156. } else if (filter === 'RECENT') {
  157. const now = Date.now();
  158. filtered = filtered.filter(m => now - m.value.timestamp < 24 * 60 * 60 * 1000);
  159. } else if (filter === 'TOP') {
  160. filtered = filtered.sort((a, b) => {
  161. const sum = v => Object.values(v.content.opinions || {}).reduce((acc, x) => acc + x, 0);
  162. return sum(b.value) - sum(a.value);
  163. });
  164. } else if (categories.includes(filter)) {
  165. filtered = filtered
  166. .filter(m => m.value.content.opinions?.[filter])
  167. .sort((a, b) =>
  168. (b.value.content.opinions[filter] || 0) - (a.value.content.opinions[filter] || 0)
  169. );
  170. }
  171. return filtered;
  172. };
  173. const getMessageById = async id => {
  174. const ssbClient = await openSsb();
  175. return new Promise((resolve, reject) =>
  176. ssbClient.get(id, (err, msg) =>
  177. err ? reject(new Error("Error fetching opinion: " + err)) :
  178. !msg?.content ? reject(new Error("Opinion not found")) :
  179. resolve(msg)
  180. )
  181. );
  182. };
  183. return {
  184. createVote,
  185. listOpinions,
  186. getMessageById,
  187. categories
  188. };
  189. };