opinions_model.js 6.4 KB

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