Browse Source

Oasis release 0.6.0

psy 1 day ago
parent
commit
66614ae77c

+ 14 - 0
docs/CHANGELOG.md

@@ -13,6 +13,20 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.6.0 - 2025-11-29
+
+### Changed
+
+ + Added more opinion categories (Opinions plugin).
+ 
+### Fixed
+
+ + Tag counters (Tags plugin).
+ + Duplicated content when searching (Search plugin).
+ + Inhabitant-linked styles for Contact and PUB (Activity plugin).
+ + Old posts retrieving at inhabitant profile (Core plugin).
+ + Fixed threading comments (Core plugin).
+
 ## v0.5.9 - 2025-11-28
 
 ### Added

+ 50 - 0
src/backend/opinion_categories.js

@@ -0,0 +1,50 @@
+const positive = [
+  "interesting",
+  "necessary",
+  "useful",
+  "informative",
+  "wellResearched",
+  "accurate",
+  "insightful",
+  "actionable",
+  "creative",
+  "inspiring",
+  "love",
+  "funny",
+  "clear",
+  "uplifting"
+];
+
+const constructive = [
+  "unnecessary",
+  "rejected",
+  "needsSources",
+  "wrong",
+  "lowQuality",
+  "confusing",
+  "misleading",
+  "offTopic",
+  "duplicate",
+  "clickbait",
+  "propaganda"
+];
+
+const moderation = [
+  "spam",
+  "troll",
+  "adultOnly",
+  "nsfw",
+  "violent",
+  "toxic",
+  "harassment",
+  "hate",
+  "scam",
+  "triggering"
+];
+
+const all = [...positive, ...constructive, ...moderation];
+all.positive = positive;
+all.constructive = constructive;
+all.moderation = moderation;
+
+module.exports = all;

+ 61 - 20
src/client/assets/translations/oasis_en.js

@@ -1301,17 +1301,7 @@ module.exports = {
     author:           "By",
     createdAtLabel:   "Created at",
     FeedshareYourOpinions: "Discover and share short-texts in your network.",
-    voteInteresting:  "Interesting",
-    voteNecessary:    "Necessary",
-    voteFunny:        "Funny",
-    voteDisgusting:   "Disgusting",
-    voteSensible:     "Sensible",
-    votePropaganda:   "Propaganda",
-    voteAdultOnly:    "Adult Only",
-    voteBoring:       "Boring",
-    voteConfusing:    "Confusing",
-    voteInspiring:    "Inspiring",
-    voteSpam:         "Spam",
+    //feed
     refeedButton:     "Refeed",
     alreadyRefeeded: "You already refeeded this.",
     //activity
@@ -1408,7 +1398,7 @@ module.exports = {
     bankTx:               "Tx",
     bankEpochShort:       "Epoch",
     bankAllocId:          "Allocation ID",
-    bankingUserEngagementScore: "User Engagement Score",
+    bankingUserEngagementScore: "KARMA Scoring",
     viewDetails:          "View details",
     link:                 "Link",
     aiSnippetsLearned:    "Snippets learned",
@@ -1685,19 +1675,72 @@ module.exports = {
     confusingButton:      "CONFUSING",
     inspiringButton:      "INSPIRING",
     spamButton:           "SPAM",
+    usefulButton: "USEFUL",
+    informativeButton: "INFORMATIVE",
+    wellResearchedButton: "WELL RESEARCHED",
+    accurateButton: "ACCURATE",
+    needsSourcesButton: "NEEDS SOURCES",
+    wrongButton: "WRONG",
+    lowQualityButton: "LOW QUALITY",
+    creativeButton: "CREATIVE",
+    insightfulButton: "INSIGHTFUL",
+    actionableButton: "ACTIONABLE",
+    inspiringButton: "INSPIRING",
+    loveButton: "LOVE",
+    clearButton: "CLEAR",
+    upliftingButton: "UPLIFTING",
+    unnecessaryButton: "UNNECESSARY",
+    rejectedButton: "REJECTED",
+    misleadingButton: "MISLEADING",
+    offTopicButton: "OFF TOPIC",
+    duplicateButton: "DUPLICATE",
+    clickbaitButton: "CLICKBAIT",
+    spamButton: "SPAM",
+    trollButton: "TROLL",
+    nsfwButton: "NSFW",
+    violentButton: "VIOLENT",
+    toxicButton: "TOXIC",
+    harassmentButton: "HARASSMENT",
+    hateButton: "HATE",
+    scamButton: "SCAM",
+    triggeringButton: "TRIGGERING",
     opinionsCreatedAt: "Created At",
     opinionsTotalCount: "Total Opinions",
     voteInteresting: "Interesting",
     voteNecessary: "Necessary",
-    voteFunny: "Funny",
-    voteDisgusting: "Disgusting",
-    voteSensible: "Sensible",
+    voteUseful: "Useful",
+    voteInformative: "Informative",
+    voteWellResearched: "Well researched",
+    voteNeedsSources: "Needs sources",
+    voteWrong: "Wrong",
+    voteLowQuality: "Low quality",
+    voteLove: "Love",
+    voteClear: "Clear",
+    voteMisleading: "Misleading",
+    voteOffTopic: "Off topic",
+    voteDuplicate: "Duplicate",
+    voteClickbait: "Clickbait",
     votePropaganda: "Propaganda",
-    voteAdultOnly: "Adult-Only",
-    voteBoring: "Boring",
-    voteConfusing: "Confusing",
+    voteFunny: "Funny",
     voteInspiring: "Inspiring",
+    voteUplifting: "Uplifting",
+    voteUnnecessary: "Unnecessary",
+    voteRejected: "Rejected",
+    voteConfusing: "Confusing",
+    voteTroll: "Troll",
+    voteNsfw: "NSFW",
+    voteViolent: "Violent",
+    voteToxic: "Toxic",
+    voteHarassment: "Harassment",
+    voteHate: "Hate",
+    voteScam: "Scam",
+    voteTriggering: "Triggering",
+    voteInsightful: "Insightful",
+    voteAccurate: "Accurate",
+    voteActionable: "Actionable",
+    voteCreative: "Creative",
     voteSpam: "Spam",
+    voteAdultOnly: "Adult Only",
     //inbox
     publishBlog: "Publish Blog",
     privateMessage: "PM",
@@ -1815,7 +1858,6 @@ module.exports = {
     bankMyAddress: 'Your address',
     bankRemoveMyAddress: 'Remove my address',
     bankNotRemovableOasis: 'Addresses cannot be removed locally',
-    bankingUserEngagementScore: "KARMA Score",
     bankingFutureUBI: "Estimated UBI Allocation",
     bankExchange: 'Exchange',
     bankExchangeCurrentValue: 'ECOin Value (1h)',
@@ -1931,7 +1973,6 @@ module.exports = {
     statsTombstoneRatio: "Tombstone ratio (%)",
     statsAITraining: "AI Training",
     statsAIExchanges: "Exchanges",
-    bankingUserEngagementScore: "KARMA Score",
     statsParliamentCandidature: "Parliament candidatures",
     statsParliamentTerm: "Parliament terms",
     statsParliamentProposal: "Parliament proposals",

+ 87 - 46
src/client/assets/translations/oasis_es.js

@@ -1296,19 +1296,9 @@ module.exports = {
     author:           "Por",
     createdAtLabel:   "Creado el",
     FeedshareYourOpinions: "Descubre y comparte textos breves en tu red.",
-    voteInteresting:  "Interesante",
-    voteNecessary:    "Necesario",
-    voteFunny:        "Divertido",
-    voteDisgusting:   "Asqueroso",
-    voteSensible:     "Sensible",
-    votePropaganda:   "Propaganda",
-    voteAdultOnly:    "Solo Adultos",
-    voteBoring:       "Aburrido",
-    voteConfusing:    "Confuso",
-    voteInspiring:    "Inspirador",
-    voteSpam:         "Spam",
-    refeedButton:     "Refeed",
-    alreadyRefeeded: "Ya has hecho refeed de esto.",
+    //feed
+    refeedButton: "Realimentar",
+    alreadyRefeeded: "Ya has realimentado esto.",
     //activity
     activityTitle:        "Actividad",
     yourActivity:         "Tu actividad",
@@ -1403,7 +1393,6 @@ module.exports = {
     bankTx:               "Tx",
     bankEpochShort:       "Época",
     bankAllocId:          "ID de asignación",
-    bankingUserEngagementScore: "Puntuación de compromiso",
     viewDetails:          "Ver detalles",
     link:                 "Enlace",
     aiSnippetsLearned:    "Fragmentos aprendidos",
@@ -1659,40 +1648,93 @@ module.exports = {
     agendaTransferAmount: "Cantidad",
     agendaTransferDeadline: "Plazo",
     //opinions
-    opinionsTitle:        "Opiniones",
-    shareYourOpinions:    "Descubre y vota por opiniones en tu red.",
-    author:               "Por",
-    voteNow:              "Vota ahora",
-    alreadyVoted:         "Ya has opinado.",
-    noOpinionsFound:      "No se encontraron opiniones.",
-    ALLButton:            "TODOS",
-    MINEButton:           "MÍAS",
-    RECENTButton:         "RECIENTES",
-    TOPButton:            "TOP",
-    interestingButton:    "INTERESANTE",
-    necessaryButton:      "NECESARIO",
-    funnyButton:          "DIVERTIDO",
-    disgustingButton:     "ASQUEROSO",
-    sensibleButton:       "SENSATO",
-    propagandaButton:     "PROPAGANDA",
-    adultOnlyButton:      "SOLO ADULTOS",
-    boringButton:         "ABURRIDO",
-    confusingButton:      "CONFUSO",
-    inspiringButton:      "INSPIRADOR",
-    spamButton:           "SPAM",
-    opinionsCreatedAt: "Creado el",
-    opinionsTotalCount: "Total de Opiniones",
+    opinionsTitle: "Opiniones",
+    shareYourOpinions: "Descubre y vota por opiniones en tu red.",
+    author: "Por",
+    voteNow: "Vota ahora",
+    alreadyVoted: "Ya has opinado.",
+    noOpinionsFound: "No se encontraron opiniones.",
+    ALLButton: "TODOS",
+    MINEButton: "MIOS",
+    RECENTButton: "RECIENTES",
+    TOPButton: "TOP",
+    interestingButton: "INTERESANTE",
+    necessaryButton: "NECESARIO",
+    funnyButton: "DIVERTIDO",
+    disgustingButton: "ASQUEROSO",
+    sensibleButton: "SENSIBLE",
+    propagandaButton: "PROPAGANDA",
+    adultOnlyButton: "SOLO ADULTOS",
+    boringButton: "ABURRIDO",
+    confusingButton: "CONFUSO",
+    inspiringButton: "INSPIRADOR",
+    spamButton: "SPAM",
+    usefulButton: "ÚTIL",
+    informativeButton: "INFORMATIVO",
+    wellResearchedButton: "BIEN INVESTIGADO",
+    accurateButton: "CONCISO",
+    needsSourcesButton: "NECESITA FUENTES",
+    wrongButton: "INCORRECTO",
+    lowQualityButton: "BAJA CALIDAD",
+    creativeButton: "CREATIVO",
+    insightfulButton: "PERSPECTIVO",
+    actionableButton: "ACCIONABLE",
+    inspiringButton: "INSPIRADOR",
+    loveButton: "AMOR",
+    clearButton: "CLARO",
+    upliftingButton: "EMPODERANTE",
+    unnecessaryButton: "INNECESARIO",
+    rejectedButton: "RECHAZADO",
+    misleadingButton: "ENGAÑOSO",
+    offTopicButton: "FUERA DE TEMA",
+    duplicateButton: "DUPLICADO",
+    clickbaitButton: "CLICKBAIT",
+    spamButton: "SPAM",
+    trollButton: "TROLL",
+    nsfwButton: "NSFW",
+    violentButton: "VIOLENTO",
+    toxicButton: "TOXICO",
+    harassmentButton: "ACOSO",
+    hateButton: "ODIO",
+    scamButton: "ESTAFA",
+    triggeringButton: "PROVOCADOR",
+    opinionsCreatedAt: "Creado en",
+    opinionsTotalCount: "Total de opiniones",
     voteInteresting: "Interesante",
     voteNecessary: "Necesario",
-    voteFunny: "Divertido",
-    voteDisgusting: "Asqueroso",
-    voteSensible: "Sensible",
+    voteUseful: "Útil",
+    voteInformative: "Informativo",
+    voteWellResearched: "Bien investigado",
+    voteNeedsSources: "Necesita fuentes",
+    voteWrong: "Incorrecto",
+    voteLowQuality: "Baja calidad",
+    voteLove: "Amor",
+    voteClear: "Claro",
+    voteMisleading: "Engañoso",
+    voteOffTopic: "Fuera de tema",
+    voteDuplicate: "Duplicado",
+    voteClickbait: "Clickbait",
     votePropaganda: "Propaganda",
-    voteAdultOnly: "Solo Adultos",
-    voteBoring: "Aburrido",
-    voteConfusing: "Confuso",
+    voteFunny: "Divertido",
     voteInspiring: "Inspirador",
+    voteUplifting: "Empoderante",
+    voteUnnecessary: "Innecesario",
+    voteRejected: "Rechazado",
+    voteConfusing: "Confuso",
+    voteTroll: "Troll",
+    voteNsfw: "NSFW",
+    voteViolent: "Violento",
+    voteToxic: "Tóxico",
+    voteHarassment: "Acoso",
+    voteHate: "Odio",
+    voteScam: "Estafa",
+    voteTriggering: "Provocador",
+    voteInsightful: "Perspicaz",
+    voteAccurate: "Conciso",
+    voteActionable: "Accionable",
+    voteCreative: "Creativo",
     voteSpam: "Spam",
+    voteAdultOnly: "Solo adultos",
     //inbox
     publishBlog: "Publicar Blog",
     privateMessage: "MP",
@@ -1715,7 +1757,7 @@ module.exports = {
     pmReply: "Responder",
     pmPreview: "Previsualizar",
     pmPreviewTitle: "Vista previa",
-    performed: "realizado",
+    performed: "",
     pmFromLabel: "De:",
     pmToLabel: "Para:",
     pmInvalidMessage: "Mensaje no válido",
@@ -1814,7 +1856,6 @@ module.exports = {
     bankMyAddress: 'Tu dirección',
     bankRemoveMyAddress: 'Eliminar mi dirección',
     bankNotRemovableOasis: 'Las direcciones no se pueden eliminar localmente',
-    bankingUserEngagementScore: "Puntuación de KARMA",
     bankingFutureUBI: "Asignación Estimada de UBI",
     bankExchange: 'Intercambio',
     bankExchangeCurrentValue: 'Valor de ECOin (1h)',
@@ -1930,7 +1971,7 @@ module.exports = {
     statsTombstoneRatio: "Ratio de eliminados (%)",
     statsAITraining: "Entrenamiento de IA",
     statsAIExchanges: "Intercambios",
-    bankingUserEngagementScore: "Puntuación de participación del usuario",
+    bankingUserEngagementScore: "Puntuación de KARMA",
     statsParliamentCandidature: "Candidaturas de parlamento",
     statsParliamentTerm: "Mandatos de parlamento",
     statsParliamentProposal: "Propuestas de parlamento",

+ 88 - 47
src/client/assets/translations/oasis_eu.js

@@ -1297,18 +1297,8 @@ module.exports = {
     author:           "Nork",
     createdAtLabel:   "Noiz",
     FeedshareYourOpinions: "Aurkitu eta partekatu testu laburrak zure sarean.",
-    voteInteresting:  "Interegarria",
-    voteNecessary:    "Beharrezkoa",
-    voteFunny:        "Barregarria",
-    voteDisgusting:   "Nazkagarria",
-    voteSensible:     "Hunkigarria",
-    votePropaganda:   "Propaganda",
-    voteAdultOnly:    "Helduentzat",
-    voteBoring:       "Aspergarria",
-    voteConfusing:    "Nahasgarria",
-    voteInspiring:    "Inspiratzailea",
-    voteSpam:         "Spam",
-    refeedButton:     "Berjariotu",
+    //feed
+    refeedButton: "Realimentatu",
     alreadyRefeeded:  "Berjaiotu duzu jada.",
     //activity
     activityTitle:        "Jarduera",
@@ -1404,7 +1394,6 @@ module.exports = {
     bankTx:               "Tx",
     bankEpochShort:       "Epoka",
     bankAllocId:          "Esleipen IDa",
-    bankingUserEngagementScore: "Erabiltzaile inplikazio puntuazioa",
     viewDetails:          "Xehetasunak ikusi",
     link:                 "Esteka",
     aiSnippetsLearned:    "Ikasitako zatiak",
@@ -1613,40 +1602,93 @@ module.exports = {
     agendaTransferAmount: "Kopurua",
     agendaTransferDeadline: "Epemuga",  
     //opinions
-    opinionsTitle:        "Iritziak",
-    shareYourOpinions:    "Aurkitu eta bozkatu iritziak zure sarean.",
-    author:               "Nork",
-    voteNow:              "Bozkatu orain",
-    alreadyVoted:         "Bozkatu duzu jada",
-    noOpinionsFound:      "Iritzirik ez.",
-    ALLButton:            "GUZTIAK",
-    MINEButton:           "NEUREA",
-    RECENTButton:         "BERRIA",
-    TOPButton:            "GORENA",
-    interestingButton:    "INTERESGARRIA",
-    necessaryButton:      "BEHARREZKOA",
-    funnyButton:          "BARREGARRIA",
-    disgustingButton:     "NAZKAGARRIA",
-    sensibleButton:       "HUNKIGARRIA",
-    propagandaButton:     "PROPAGANDA",
-    adultOnlyButton:      "HELDUENTZAT",
-    boringButton:         "ASPERGARRIA",
-    confusingButton:      "NAHASGARRIA",
-    inspiringButton:      "INSIPIRATZAILEA",
-    spamButton:           "SPAMA",
-    opinionsCreatedAt: "Noiz",
-    opinionsTotalCount: "Iritziak Guztira",
-    voteInteresting: "Interesgrria",
+    opinionsTitle: "Iritziak",
+    shareYourOpinions: "Deskubritu eta bozkatu zure sareko iritziak.",
+    author: "Egilea",
+    voteNow: "Bozkatu orain",
+    alreadyVoted: "Jada zure iritzia eman duzu.",
+    noOpinionsFound: "Ez da iritzirik aurkitu.",
+    ALLButton: "GUZTIAK",
+    MINEButton: "NIREAK",
+    RECENTButton: "AZKENAK",
+    TOPButton: "GORDEAK",
+    interestingButton: "INTERESGARRIA",
+    necessaryButton: "BEHARREZKOA",
+    funnyButton: "BARREGARRIA",
+    disgustingButton: "LOREZTUGARRIA",
+    sensibleButton: "SENDEKOA",
+    propagandaButton: "PROPAGANDA",
+    adultOnlyButton: "ADULTO BAKARRIK",
+    boringButton: "NEKAGARRIA",
+    confusingButton: "ZAILA",
+    inspiringButton: "INSPIRATZAILEA",
+    spamButton: "SPAM",
+    usefulButton: "ERABILI",
+    informativeButton: "INFORMATIBOA",
+    wellResearchedButton: "ONDO IKERTUA",
+    accurateButton: "EGOKITUA",
+    needsSourcesButton: "ITURRIK BEHAR DITU",
+    wrongButton: "OKERREKOA",
+    lowQualityButton: "BEHERA KALITATEA",
+    creativeButton: "KREABO",
+    insightfulButton: "ARRETAZKOA",
+    actionableButton: "ERABILI DAITEKE",
+    inspiringButton: "INSPIRATZAILEA",
+    loveButton: "MAITASUN",
+    clearButton: "ARGI",
+    upliftingButton: "EGOITZAILEA",
+    unnecessaryButton: "EZINBESTEKOA",
+    rejectedButton: "UKATUA",
+    misleadingButton: "ENGAINEZKOA",
+    offTopicButton: "GAITIK AT",
+    duplicateButton: "DUPLIKATU",
+    clickbaitButton: "KLIK-APURKA",
+    spamButton: "SPAM",
+    trollButton: "TROLL",
+    nsfwButton: "NSFW",
+    violentButton: "BIOLENTOA",
+    toxicButton: "TOXIKOA",
+    harassmentButton: "BAZTERKETA",
+    hateButton: "ARRETA",
+    scamButton: "ENGAINU",
+    triggeringButton: "HAUSKORRA",
+    opinionsCreatedAt: "Sortu zen",
+    opinionsTotalCount: "Iritzi guztiak",
+    voteInteresting: "Interesgarria",
     voteNecessary: "Beharrezkoa",
-    voteFunny: "Barregarria",
-    voteDisgusting: "Nazkagarria",
-    voteSensible: "Hunkigarria",
+    voteUseful: "Erabilgarria",
+    voteInformative: "Informatiboa",
+    voteWellResearched: "Ondo ikertua",
+    voteNeedsSources: "Iturririk behar du",
+    voteWrong: "Okerrak",
+    voteLowQuality: "Behera kalitatea",
+    voteLove: "Maitasuna",
+    voteClear: "Argi",
+    voteMisleading: "Engainagarria",
+    voteOffTopic: "Gaiaz kanpo",
+    voteDuplicate: "Bikoiztu",
+    voteClickbait: "Klik-apurkoa",
     votePropaganda: "Propaganda",
-    voteAdultOnly: "Helduentzat",
-    voteBoring: "Aspergarria",
-    voteConfusing: "Nahasgarria",
+    voteFunny: "Barregarria",
     voteInspiring: "Inspiratzailea",
-    voteSpam: "Spama",
+    voteUplifting: "Igokuntzailea",
+    voteUnnecessary: "Ezinezkoa",
+    voteRejected: "Ukatu",
+    voteConfusing: "Konfusio",
+    voteTroll: "Troll",
+    voteNsfw: "NSFW",
+    voteViolent: "Indarkeria",
+    voteToxic: "Toxikoa",
+    voteHarassment: "Jazarpena",
+    voteHate: "Arraza",
+    voteScam: "Estafa",
+    voteTriggering: "Hauskorra",
+    voteInsightful: "Arretazkoa",
+    voteAccurate: "Egokia",
+    voteActionable: "Erabili daiteke",
+    voteCreative: "Sortzailea",
+    voteSpam: "Spam",
+    voteAdultOnly: "Ados bakarrik",
     //inbox
     publishBlog: "Bloga argitaratu",
     privateMessage: "MP",
@@ -1669,7 +1711,7 @@ module.exports = {
     pmReply: "Erantzun",
     pmPreview: "Aurrebista",
     pmPreviewTitle: "Mezuaren aurrebista",
-    performed: "egin da",
+    performed: "",
     pmFromLabel: "Nork:",
     pmToLabel: "Nori:",
     pmInvalidMessage: "Mezu baliogabea",
@@ -1768,7 +1810,7 @@ module.exports = {
     bankMyAddress: 'Zure helbidea',
     bankRemoveMyAddress: 'Nire helbidea kendu',
     bankNotRemovableOasis: 'Oasis helbideak ezin dira lokalki kendu',
-    bankingUserEngagementScore: "KARMA Puntuazioa",
+    bankingUserEngagementScore: "KARMA puntuazioa",
     bankingFutureUBI: "RBUren Estimatutako Esleipena",
     bankExchange: 'Trukea',
     bankExchangeCurrentValue: 'ECOin Balioa (1h)',
@@ -1884,7 +1926,6 @@ module.exports = {
     statsTombstoneRatio: "Ezabaketa ratioa (%)",
     statsAITraining: "IA prestakuntza",
     statsAIExchanges: "Trukeak",
-    bankingUserEngagementScore: "Erabiltzaile inplikazioaren puntuazioa",
     statsParliamentCandidature: "Parlamenturako hautagaitzak",
     statsParliamentTerm: "Parlamentuko agintaldiak",
     statsParliamentProposal: "Parlamentuko proposamenak",

+ 86 - 45
src/client/assets/translations/oasis_fr.js

@@ -1296,19 +1296,9 @@ module.exports = {
     author:           "Par",
     createdAtLabel:   "Créé le",
     FeedshareYourOpinions: "Découvrez et partagez de courts textes dans votre réseau.",
-    voteInteresting:  "Intéressant",
-    voteNecessary:    "Nécessaire",
-    voteFunny:        "Amusant",
-    voteDisgusting:   "Dégoûtant",
-    voteSensible:     "Sensible",
-    votePropaganda:   "Propagande",
-    voteAdultOnly:    "Adultes uniquement",
-    voteBoring:       "Ennuyeux",
-    voteConfusing:    "Confus",
-    voteInspiring:    "Inspirant",
-    voteSpam:         "Spam",
-    refeedButton:     "Refeed",
-    alreadyRefeeded: "Vous avez déjà fait un refeed de ceci.",
+    //feed
+    refeedButton: "Refeed",
+    alreadyRefeeded: "Vous avez déjà realimenté ceci.",
     //activity
     activityTitle:        "Activité",
     yourActivity:         "Votre activité",
@@ -1403,7 +1393,6 @@ module.exports = {
     bankTx:               "Tx",
     bankEpochShort:       "Époque",
     bankAllocId:          "Identifiant d’allocation",
-    bankingUserEngagementScore: "Score d’engagement utilisateur",
     viewDetails:          "Voir les détails",
     link:                 "Lien",
     aiSnippetsLearned:    "Extraits appris",
@@ -1659,40 +1648,93 @@ module.exports = {
     agendaTransferAmount: "Montant",
     agendaTransferDeadline: "Date limite",
     //opinions
-    opinionsTitle:        "Avis",
-    shareYourOpinions:    "Découvrez et votez des avis dans votre réseau.",
-    author:               "Par",
-    voteNow:              "Votez maintenant",
-    alreadyVoted:         "Vous avez déjà voté.",
-    noOpinionsFound:      "Aucun avis trouvé.",
-    ALLButton:            "TOUS",
-    MINEButton:           "MIENS",
-    RECENTButton:         "RÉCENTS",
-    TOPButton:            "TOP",
-    interestingButton:    "INTÉRESSANT",
-    necessaryButton:      "NÉCESSAIRE",
-    funnyButton:          "AMUSANT",
-    disgustingButton:     "DÉGOÛTANT",
-    sensibleButton:       "SENSÉ",
-    propagandaButton:     "PROPAGANDE",
-    adultOnlyButton:      "ADULTES UNIQUEMENT",
-    boringButton:         "ENNUIEUX",
-    confusingButton:      "CONFUS",
-    inspiringButton:      "INSPIRANT",
-    spamButton:           "SPAM",
-    opinionsCreatedAt: "Créé le",
-    opinionsTotalCount: "Total d’avis",
+    opinionsTitle: "Opinions",
+    shareYourOpinions: "Découvrez et votez pour les opinions dans votre réseau.",
+    author: "Par",
+    voteNow: "Votez maintenant",
+    alreadyVoted: "Vous avez déjà donné votre avis.",
+    noOpinionsFound: "Aucune opinion trouvée.",
+    ALLButton: "TOUT",
+    MINEButton: "MIEN",
+    RECENTButton: "RÉCENT",
+    TOPButton: "TOP",
+    interestingButton: "INTÉRESSANT",
+    necessaryButton: "NÉCESSAIRE",
+    funnyButton: "AMUSANT",
+    disgustingButton: "DÉGOUTANT",
+    sensibleButton: "SENSIBLE",
+    propagandaButton: "PROPAGANDE",
+    adultOnlyButton: "ADULTE SEULEMENT",
+    boringButton: "ENNUIYEUX",
+    confusingButton: "CONFUS",
+    inspiringButton: "INSPIRANT",
+    spamButton: "SPAM",
+    usefulButton: "UTILE",
+    informativeButton: "INFORMATIF",
+    wellResearchedButton: "BIEN RECHERCHÉ",
+    accurateButton: "PRÉCIS",
+    needsSourcesButton: "BESOIN DE SOURCES",
+    wrongButton: "FAUX",
+    lowQualityButton: "FAIBLE QUALITÉ",
+    creativeButton: "CRÉATIF",
+    insightfulButton: "PERSPECTIF",
+    actionableButton: "RÉALISABLE",
+    inspiringButton: "INSPIRANT",
+    loveButton: "AMOUR",
+    clearButton: "CLAIR",
+    upliftingButton: "ÉLÉVANT",
+    unnecessaryButton: "INUTILE",
+    rejectedButton: "REJETÉ",
+    misleadingButton: "TROMPEUR",
+    offTopicButton: "HORS SUJET",
+    duplicateButton: "DUPE",
+    clickbaitButton: "APPÂT À CLIC",
+    spamButton: "SPAM",
+    trollButton: "TROLL",
+    nsfwButton: "NSFW",
+    violentButton: "VIOLENT",
+    toxicButton: "TOXIQUE",
+    harassmentButton: "HARCÈLEMENT",
+    hateButton: "HAINE",
+    scamButton: "ESCROQUERIE",
+    triggeringButton: "PROVOCANT",
+    opinionsCreatedAt: "Créé à",
+    opinionsTotalCount: "Total des opinions",
     voteInteresting: "Intéressant",
     voteNecessary: "Nécessaire",
-    voteFunny: "Amusant",
-    voteDisgusting: "Dégoûtant",
-    voteSensible: "Sensé",
+    voteUseful: "Utile",
+    voteInformative: "Informative",
+    voteWellResearched: "Bien recherché",
+    voteNeedsSources: "Besoin de sources",
+    voteWrong: "Faux",
+    voteLowQuality: "Faible qualité",
+    voteLove: "Amour",
+    voteClear: "Clair",
+    voteMisleading: "Trompeur",
+    voteOffTopic: "Hors sujet",
+    voteDuplicate: "Dupe",
+    voteClickbait: "Appât à clic",
     votePropaganda: "Propagande",
-    voteAdultOnly: "Adultes uniquement",
-    voteBoring: "Ennuyeux",
-    voteConfusing: "Confus",
+    voteFunny: "Amusant",
     voteInspiring: "Inspirant",
+    voteUplifting: "Élevant",
+    voteUnnecessary: "Inutile",
+    voteRejected: "Rejeté",
+    voteConfusing: "Confus",
+    voteTroll: "Troll",
+    voteNsfw: "NSFW",
+    voteViolent: "Violent",
+    voteToxic: "Toxique",
+    voteHarassment: "Harcèlement",
+    voteHate: "Haine",
+    voteScam: "Escroquerie",
+    voteTriggering: "Provocant",
+    voteInsightful: "Perspicace",
+    voteAccurate: "Précis",
+    voteActionable: "Réalisable",
+    voteCreative: "Créatif",
     voteSpam: "Spam",
+    voteAdultOnly: "Adult Only",
     //inbox
     publishBlog: "Publier un blog",
     privateMessage: "MP",
@@ -1814,7 +1856,7 @@ module.exports = {
     bankMyAddress: 'Votre adresse',
     bankRemoveMyAddress: 'Supprimer mon adresse',
     bankNotRemovableOasis: 'Les adresses ne peuvent pas être supprimées localement',
-    bankingUserEngagementScore: "Score de KARMA",
+    bankingUserEngagementScore: "Score KARMA",
     bankingFutureUBI: "Allocation UBI estimée",
     bankExchange: 'Échange',
     bankExchangeCurrentValue: 'Valeur d’ECOin (1h)',
@@ -1930,7 +1972,6 @@ module.exports = {
     statsTombstoneRatio: "Taux de suppressions (%)",
     statsAITraining: "Entraînement IA",
     statsAIExchanges: "Échanges",
-    bankingUserEngagementScore: "Score d'engagement utilisateur",
     statsParliamentCandidature: "Candidatures au parlement",
     statsParliamentTerm: "Mandats parlementaires",
     statsParliamentProposal: "Propositions parlementaires",

+ 2 - 0
src/models/audios_model.js

@@ -1,6 +1,7 @@
 const pull = require('../server/node_modules/pull-stream')
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
+const categories = require('../backend/opinion_categories')
 
 module.exports = ({ cooler }) => {
   let ssb
@@ -160,6 +161,7 @@ module.exports = ({ cooler }) => {
 
     async createOpinion(id, category) {
       const ssbClient = await openSsb()
+      if (!categories.includes(category)) return reject(new Error('Invalid voting category'))
       return new Promise((resolve, reject) => {
         ssbClient.get(id, (err, msg) => {
           if (err || !msg || msg.content?.type !== 'audio') return reject(new Error('Audio not found'))

+ 2 - 0
src/models/bookmarking_model.js

@@ -1,6 +1,7 @@
 const pull = require('../server/node_modules/pull-stream')
 const moment = require('../server/node_modules/moment')
 const { getConfig } = require('../configs/config-manager.js');
+const categories = require('../backend/opinion_categories')
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
@@ -173,6 +174,7 @@ module.exports = ({ cooler }) => {
     },
 
     async createOpinion(bookmarkId, category) {
+      if (!categories.includes(category)) return Promise.reject(new Error('Invalid voting category'))
       const ssbClient = await openSsb()
       const userId = ssbClient.id
       return new Promise((resolve, reject) => {

+ 77 - 74
src/models/documents_model.js

@@ -1,27 +1,28 @@
-const pull = require('../server/node_modules/pull-stream')
+const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
+const categories = require('../backend/opinion_categories');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 const extractBlobId = str => {
-  if (!str || typeof str !== 'string') return null
-  const match = str.match(/\(([^)]+\.sha256)\)/)
-  return match ? match[1] : str.trim()
-}
+  if (!str || typeof str !== 'string') return null;
+  const match = str.match(/\(([^)]+\.sha256)\)/);
+  return match ? match[1] : str.trim();
+};
 
-const parseCSV = str => str ? str.split(',').map(s => s.trim()).filter(Boolean) : []
+const parseCSV = str => str ? str.split(',').map(s => s.trim()).filter(Boolean) : [];
 
 module.exports = ({ cooler }) => {
-  let ssb
-  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
+  let ssb;
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
   return {
     type: 'document',
 
     async createDocument(blobMarkdown, tagsRaw, title, description) {
-      const ssbClient = await openSsb()
-      const userId = ssbClient.id
-      const blobId = extractBlobId(blobMarkdown)
-      const tags = parseCSV(tagsRaw)
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const blobId = extractBlobId(blobMarkdown);
+      const tags = parseCSV(tagsRaw);
       const content = {
         type: 'document',
         url: blobId,
@@ -32,22 +33,22 @@ module.exports = ({ cooler }) => {
         description: description || '',
         opinions: {},
         opinions_inhabitants: []
-      }
+      };
       return new Promise((resolve, reject) => {
-        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res))
-      })
+        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res));
+      });
     },
 
     async updateDocumentById(id, blobMarkdown, tagsRaw, title, description) {
-      const ssbClient = await openSsb()
-      const userId = ssbClient.id
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
       return new Promise((resolve, reject) => {
         ssbClient.get(id, (err, oldMsg) => {
-          if (err || !oldMsg || oldMsg.content?.type !== 'document') return reject(new Error('Document not found'))
-          if (Object.keys(oldMsg.content.opinions || {}).length > 0) return reject(new Error('Cannot edit document after it has received opinions.'))
-          if (oldMsg.content.author !== userId) return reject(new Error('Not the author'))
-          const tags = parseCSV(tagsRaw)
-          const blobId = extractBlobId(blobMarkdown)
+          if (err || !oldMsg || oldMsg.content?.type !== 'document') return reject(new Error('Document not found'));
+          if (Object.keys(oldMsg.content.opinions || {}).length > 0) return reject(new Error('Cannot edit document after it has received opinions.'));
+          if (oldMsg.content.author !== userId) return reject(new Error('Not the author'));
+          const tags = parseCSV(tagsRaw);
+          const blobId = extractBlobId(blobMarkdown);
           const updated = {
             ...oldMsg.content,
             replaces: id,
@@ -56,53 +57,55 @@ module.exports = ({ cooler }) => {
             title: title || '',
             description: description || '',
             updatedAt: new Date().toISOString()
-          }
-          ssbClient.publish(updated, (err3, result) => err3 ? reject(err3) : resolve(result))
-        })
-      })
+          };
+          ssbClient.publish(updated, (err3, result) => err3 ? reject(err3) : resolve(result));
+        });
+      });
     },
 
     async deleteDocumentById(id) {
-      const ssbClient = await openSsb()
-      const userId = ssbClient.id
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
       return new Promise((resolve, reject) => {
         ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'document') return reject(new Error('Document not found'))
-          if (msg.content.author !== userId) return reject(new Error('Not the author'))
+          if (err || !msg || msg.content?.type !== 'document') return reject(new Error('Document not found'));
+          if (msg.content.author !== userId) return reject(new Error('Not the author'));
           const tombstone = {
             type: 'tombstone',
             target: id,
             deletedAt: new Date().toISOString(),
             author: userId
-          }
-          ssbClient.publish(tombstone, (err2, res) => err2 ? reject(err2) : resolve(res))
-        })
-      })
+          };
+          ssbClient.publish(tombstone, (err2, res) => err2 ? reject(err2) : resolve(res));
+        });
+      });
     },
 
     async listAll(filter = 'all') {
-      const ssbClient = await openSsb()
-      const userId = ssbClient.id
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
       const messages = await new Promise((res, rej) => {
         pull(
           ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
-        )
-      })
+        );
+      });
+
       const tombstoned = new Set(
         messages
           .filter(m => m.value.content?.type === 'tombstone')
           .map(m => m.value.content.target)
-      )
-      const replaces = new Map()
-      const latest = new Map()
+      );
+
+      const replaces = new Map();
+      const latest = new Map();
 
       for (const m of messages) {
-        const k = m.key
-        const c = m.value?.content
-        if (!c || c.type !== 'document') continue
-        if (tombstoned.has(k)) continue
-        if (c.replaces) replaces.set(c.replaces, k)
+        const k = m.key;
+        const c = m.value?.content;
+        if (!c || c.type !== 'document') continue;
+        if (tombstoned.has(k)) continue;
+        if (c.replaces) replaces.set(c.replaces, k);
         latest.set(k, {
           key: k,
           url: c.url,
@@ -114,41 +117,40 @@ module.exports = ({ cooler }) => {
           description: c.description || '',
           opinions: c.opinions || {},
           opinions_inhabitants: c.opinions_inhabitants || []
-        })
+        });
       }
 
-      for (const oldId of replaces.keys()) {
-        latest.delete(oldId)
-      }
-      let documents = Array.from(latest.values())
+      for (const oldId of replaces.keys()) latest.delete(oldId);
+
+      let documents = Array.from(latest.values());
+
       if (filter === 'mine') {
-        documents = documents.filter(d => d.author === userId)
+        documents = documents.filter(d => d.author === userId);
       } else {
-        documents = documents.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+        documents = documents.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
       }
+
       const hasBlob = (blobId) => {
         return new Promise((resolve) => {
-          ssbClient.blobs.has(blobId, (err, has) => {
-            resolve(!err && has);
-          });
+          ssbClient.blobs.has(blobId, (err, has) => resolve(!err && has));
         });
       };
-     documents = await Promise.all(
+
+      documents = await Promise.all(
         documents.map(async (doc) => {
           const ok = await hasBlob(doc.url);
           return ok ? doc : null;
         })
       );
-      documents = documents.filter(Boolean);
 
-      return documents
+      return documents.filter(Boolean);
     },
 
     async getDocumentById(id) {
-      const ssbClient = await openSsb()
+      const ssbClient = await openSsb();
       return new Promise((resolve, reject) => {
         ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'document') return reject(new Error('Document not found'))
+          if (err || !msg || msg.content?.type !== 'document') return reject(new Error('Document not found'));
           resolve({
             key: id,
             url: msg.content.url,
@@ -160,18 +162,19 @@ module.exports = ({ cooler }) => {
             description: msg.content.description || '',
             opinions: msg.content.opinions || {},
             opinions_inhabitants: msg.content.opinions_inhabitants || []
-          })
-        })
-      })
+          });
+        });
+      });
     },
 
     async createOpinion(id, category) {
-      const ssbClient = await openSsb()
-      const userId = ssbClient.id
+      if (!categories.includes(category)) return Promise.reject(new Error('Invalid voting category'));
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
       return new Promise((resolve, reject) => {
         ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'document') return reject(new Error('Document not found'))
-          if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'))
+          if (err || !msg || msg.content?.type !== 'document') return reject(new Error('Document not found'));
+          if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'));
           const updated = {
             ...msg.content,
             replaces: id,
@@ -181,11 +184,11 @@ module.exports = ({ cooler }) => {
             },
             opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
             updatedAt: new Date().toISOString()
-          }
-          ssbClient.publish(updated, (err3, result) => err3 ? reject(err3) : resolve(result))
-        })
-      })
+          };
+          ssbClient.publish(updated, (err3, result) => err3 ? reject(err3) : resolve(result));
+        });
+      });
     }
-  }
-}
+  };
+};
 

+ 55 - 30
src/models/feed_model.js

@@ -1,11 +1,39 @@
 const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
+const categories = require('../backend/opinion_categories');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
+  const getMsg = (ssbClient, id) =>
+    new Promise((resolve, reject) => {
+      ssbClient.get(id, (err, msg) => err ? reject(err) : resolve(msg));
+    });
+
+  const getAllMessages = (ssbClient) =>
+    new Promise((resolve, reject) => {
+      pull(
+        ssbClient.createLogStream({ limit: logLimit }),
+        pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
+      );
+    });
+
+  const resolveCurrentId = async (id) => {
+    const ssbClient = await openSsb();
+    const messages = await getAllMessages(ssbClient);
+    const forward = new Map();
+    for (const m of messages) {
+      const c = m.value?.content;
+      if (!c) continue;
+      if (c.type === 'feed' && c.replaces) forward.set(c.replaces, m.key);
+    }
+    let cur = id;
+    while (forward.has(cur)) cur = forward.get(cur);
+    return cur;
+  };
+
   const createFeed = async (text) => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
@@ -28,35 +56,36 @@ module.exports = ({ cooler }) => {
   const createRefeed = async (contentId) => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
-    const msg = await new Promise((resolve, reject) => {
-      ssbClient.get(contentId, (err, value) => {
-        if (err) return reject(err);
-        resolve(value);
-      });
-    });
+    const tipId = await resolveCurrentId(contentId);
+    const msg = await getMsg(ssbClient, tipId);
     if (!msg || !msg.content || msg.content.type !== 'feed') throw new Error("Invalid feed");
     if (msg.content.refeeds_inhabitants?.includes(userId)) throw new Error("Already refeeded");
-    const tombstone = { type: 'tombstone', target: contentId, deletedAt: new Date().toISOString() };
+
+    const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
     const updated = {
       ...msg.content,
       refeeds: (msg.content.refeeds || 0) + 1,
       refeeds_inhabitants: [...(msg.content.refeeds_inhabitants || []), userId],
       updatedAt: new Date().toISOString(),
-      replaces: contentId
+      replaces: tipId
     };
+
     await new Promise((res, rej) => ssbClient.publish(tombstone, err => err ? rej(err) : res()));
     return new Promise((resolve, reject) => {
-      ssbClient.publish(updated, (err2, msg) => err2 ? reject(err2) : resolve(msg));
+      ssbClient.publish(updated, (err2, out) => err2 ? reject(err2) : resolve(out));
     });
   };
 
   const addOpinion = async (contentId, category) => {
+    if (!categories.includes(category)) throw new Error('Invalid voting category');
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
-    const msg = await ssbClient.get(contentId);
+    const tipId = await resolveCurrentId(contentId);
+    const msg = await getMsg(ssbClient, tipId);
     if (!msg || !msg.content || msg.content.type !== 'feed') throw new Error("Invalid feed");
     if (msg.content.opinions_inhabitants?.includes(userId)) throw new Error("Already voted");
-    const tombstone = { type: 'tombstone', target: contentId, deletedAt: new Date().toISOString() };
+
+    const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
     const updated = {
       ...msg.content,
       opinions: {
@@ -65,8 +94,9 @@ module.exports = ({ cooler }) => {
       },
       opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
       updatedAt: new Date().toISOString(),
-      replaces: contentId
+      replaces: tipId
     };
+
     await new Promise((res, rej) => ssbClient.publish(tombstone, err => err ? rej(err) : res()));
     return new Promise((resolve, reject) => {
       ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
@@ -77,12 +107,7 @@ module.exports = ({ cooler }) => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
     const now = Date.now();
-    const messages = await new Promise((res, rej) => {
-      pull(
-        ssbClient.createLogStream({ limit: logLimit }),
-        pull.collect((err, msgs) => err ? rej(err) : res(msgs))
-      );
-    });
+    const messages = await getAllMessages(ssbClient);
 
     const tombstoned = new Set();
     const replaces = new Map();
@@ -103,25 +128,24 @@ module.exports = ({ cooler }) => {
       }
     }
 
-    for (const replaced of replaces.keys()) {
-      byId.delete(replaced);
-    }
+    for (const replaced of replaces.keys()) byId.delete(replaced);
 
     let feeds = Array.from(byId.values());
     const seenTexts = new Map();
-	for (const feed of feeds) {
-	  const text = feed.value.content.text;
-	  const existing = seenTexts.get(text);
-	  if (!existing || feed.value.timestamp > existing.value.timestamp) {
-	    seenTexts.set(text, feed);
-	  }
-	}
+    for (const feed of feeds) {
+      const text = feed.value?.content?.text;
+      if (typeof text !== 'string') continue;
+      const existing = seenTexts.get(text);
+      if (!existing || (feed.value.timestamp || 0) > (existing.value.timestamp || 0)) {
+        seenTexts.set(text, feed);
+      }
+    }
     feeds = Array.from(seenTexts.values());
 
     if (filter === 'MINE') {
-      feeds = feeds.filter(m => m.value.content.author === userId);
+      feeds = feeds.filter(m => m.value?.content?.author === userId);
     } else if (filter === 'TODAY') {
-      feeds = feeds.filter(m => now - m.value.timestamp < 86400000);
+      feeds = feeds.filter(m => now - (m.value.timestamp || 0) < 86400000);
     } else if (filter === 'TOP') {
       feeds = feeds.sort((a, b) => {
         const aVotes = Object.values(a.value.content.opinions || {}).reduce((sum, x) => sum + x, 0);
@@ -140,3 +164,4 @@ module.exports = ({ cooler }) => {
     listFeeds
   };
 };
+

+ 97 - 92
src/models/images_model.js

@@ -1,25 +1,26 @@
-const pull = require('../server/node_modules/pull-stream')
+const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
+const categories = require('../backend/opinion_categories');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
-  let ssb
-  let userId
+  let ssb;
+  let userId;
 
   const openSsb = async () => {
     if (!ssb) {
-      ssb = await cooler.open()
-      userId = ssb.id
+      ssb = await cooler.open();
+      userId = ssb.id;
     }
-    return ssb
-  }
+    return ssb;
+  };
 
   return {
     async createImage(blobMarkdown, tagsRaw, title, description, meme) {
-      const ssbClient = await openSsb()
-      const match = blobMarkdown?.match(/\(([^)]+)\)/)
-      const blobId = match ? match[1] : blobMarkdown
-      const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : []
+      const ssbClient = await openSsb();
+      const match = blobMarkdown?.match(/\(([^)]+)\)/);
+      const blobId = match ? match[1] : blobMarkdown;
+      const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : [];
       const content = {
         type: 'image',
         url: blobId,
@@ -31,84 +32,87 @@ module.exports = ({ cooler }) => {
         meme: !!meme,
         opinions: {},
         opinions_inhabitants: []
-      }
+      };
       return new Promise((resolve, reject) => {
-        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res))
-      })
+        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res));
+      });
     },
 
     async updateImageById(id, blobMarkdown, tagsRaw, title, description, meme) {
-      const ssbClient = await openSsb()
+      const ssbClient = await openSsb();
       return new Promise((resolve, reject) => {
         ssbClient.get(id, (err, oldMsg) => {
-          if (err || !oldMsg || oldMsg.content?.type !== 'image') return reject(new Error('Image not found'))
-          if (oldMsg.content.author !== userId) return reject(new Error('Not the author'))
-          const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : oldMsg.content.tags
-          const match = blobMarkdown?.match(/\(([^)]+)\)/)
-          const blobId = match ? match[1] : blobMarkdown
-	  const updated = {
-	    ...oldMsg.content,
-	    replaces: id,
-	    url: blobId || oldMsg.content.url,
-	    tags,
-	    title: title ?? oldMsg.content.title,
-	    description: description ?? oldMsg.content.description,
-	    meme: meme != null ? !!meme : !!oldMsg.content.meme,
-	    updatedAt: new Date().toISOString()
-	  }
-          ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result))
-        })
-      })
+          if (err || !oldMsg || oldMsg.content?.type !== 'image') return reject(new Error('Image not found'));
+          if (Object.keys(oldMsg.content.opinions || {}).length > 0) return reject(new Error('Cannot edit image after it has received opinions.'));
+          if (oldMsg.content.author !== userId) return reject(new Error('Not the author'));
+          const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : oldMsg.content.tags;
+          const match = blobMarkdown?.match(/\(([^)]+)\)/);
+          const blobId = match ? match[1] : blobMarkdown;
+          const updated = {
+            ...oldMsg.content,
+            replaces: id,
+            url: blobId || oldMsg.content.url,
+            tags,
+            title: title ?? oldMsg.content.title,
+            description: description ?? oldMsg.content.description,
+            meme: meme != null ? !!meme : !!oldMsg.content.meme,
+            updatedAt: new Date().toISOString()
+          };
+          ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
+        });
+      });
     },
 
     async deleteImageById(id) {
-      const ssbClient = await openSsb()
-      const author = ssbClient.id
+      const ssbClient = await openSsb();
+      const author = ssbClient.id;
       const getMsg = (mid) => new Promise((resolve, reject) => {
-        ssbClient.get(mid, (err, msg) => err || !msg ? reject(new Error('Image not found')) : resolve(msg))
-      })
+        ssbClient.get(mid, (err, msg) => err || !msg ? reject(new Error('Image not found')) : resolve(msg));
+      });
       const publishTomb = (target) => new Promise((resolve, reject) => {
         ssbClient.publish({
           type: 'tombstone',
           target,
           deletedAt: new Date().toISOString(),
           author
-        }, (err, res) => err ? reject(err) : resolve(res))
-      })
-      const tip = await getMsg(id)
-      if (tip.content?.type !== 'image') throw new Error('Image not found')
-      if (tip.content.author !== author) throw new Error('Not the author')
-      let currentId = id
+        }, (err, res) => err ? reject(err) : resolve(res));
+      });
+      const tip = await getMsg(id);
+      if (tip.content?.type !== 'image') throw new Error('Image not found');
+      if (tip.content.author !== author) throw new Error('Not the author');
+      let currentId = id;
       while (currentId) {
-        const msg = await getMsg(currentId)
-        await publishTomb(currentId)
-        currentId = msg.content?.replaces || null
+        const msg = await getMsg(currentId);
+        await publishTomb(currentId);
+        currentId = msg.content?.replaces || null;
       }
-      return { ok: true }
+      return { ok: true };
     },
 
     async listAll(filter = 'all') {
-      const ssbClient = await openSsb()
-      const userId = ssbClient.id
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
       const messages = await new Promise((res, rej) => {
         pull(
           ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
-        )
-      })
+        );
+      });
+
       const tombstoned = new Set(
         messages
           .filter(m => m.value.content?.type === 'tombstone')
           .map(m => m.value.content.target)
-      )
-      const replaces = new Map()
-      const latest = new Map()
+      );
+
+      const replaces = new Map();
+      const latest = new Map();
       for (const m of messages) {
-        const k = m.key
-        const c = m.value?.content
-        if (!c || c.type !== 'image') continue
-        if (c.replaces) replaces.set(c.replaces, k)
-        if (tombstoned.has(k)) continue
+        const k = m.key;
+        const c = m.value?.content;
+        if (!c || c.type !== 'image') continue;
+        if (c.replaces) replaces.set(c.replaces, k);
+        if (tombstoned.has(k)) continue;
         latest.set(k, {
           key: k,
           url: c.url,
@@ -121,39 +125,39 @@ module.exports = ({ cooler }) => {
           meme: !!c.meme,
           opinions: c.opinions || {},
           opinions_inhabitants: c.opinions_inhabitants || []
-        })
+        });
       }
-      for (const oldId of replaces.keys()) {
-        latest.delete(oldId)
-      }
-      for (const delId of tombstoned) {
-        latest.delete(delId)
-      }
-      let images = Array.from(latest.values())
+
+      for (const oldId of replaces.keys()) latest.delete(oldId);
+      for (const delId of tombstoned) latest.delete(delId);
+
+      let images = Array.from(latest.values());
+
       if (filter === 'mine') {
-        images = images.filter(img => img.author === userId)
+        images = images.filter(img => img.author === userId);
       } else if (filter === 'recent') {
-        const now = Date.now()
-        images = images.filter(img => new Date(img.createdAt).getTime() >= (now - 24 * 60 * 60 * 1000))
+        const now = Date.now();
+        images = images.filter(img => new Date(img.createdAt).getTime() >= (now - 24 * 60 * 60 * 1000));
       } else if (filter === 'meme') {
-        images = images.filter(img => img.meme === true)
+        images = images.filter(img => img.meme === true);
       } else if (filter === 'top') {
         images = images.sort((a, b) => {
-          const sumA = Object.values(a.opinions).reduce((sum, v) => sum + v, 0)
-          const sumB = Object.values(b.opinions).reduce((sum, v) => sum + v, 0)
-          return sumB - sumA
-        })
+          const sumA = Object.values(a.opinions).reduce((sum, v) => sum + v, 0);
+          const sumB = Object.values(b.opinions).reduce((sum, v) => sum + v, 0);
+          return sumB - sumA;
+        });
       } else {
-        images = images.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+        images = images.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
       }
-      return images
+
+      return images;
     },
 
     async getImageById(id) {
-      const ssbClient = await openSsb()
+      const ssbClient = await openSsb();
       return new Promise((resolve, reject) => {
         ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'image') return reject(new Error('Image not found'))
+          if (err || !msg || msg.content?.type !== 'image') return reject(new Error('Image not found'));
           resolve({
             key: id,
             url: msg.content.url,
@@ -166,18 +170,19 @@ module.exports = ({ cooler }) => {
             meme: !!msg.content.meme,
             opinions: msg.content.opinions || {},
             opinions_inhabitants: msg.content.opinions_inhabitants || []
-          })
-        })
-      })
+          });
+        });
+      });
     },
 
     async createOpinion(id, category) {
-      const ssbClient = await openSsb()
-      const userId = ssbClient.id
+      if (!categories.includes(category)) return Promise.reject(new Error('Invalid voting category'));
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
       return new Promise((resolve, reject) => {
         ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'image') return reject(new Error('Image not found'))
-          if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'))
+          if (err || !msg || msg.content?.type !== 'image') return reject(new Error('Image not found'));
+          if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'));
           const updated = {
             ...msg.content,
             replaces: id,
@@ -187,11 +192,11 @@ module.exports = ({ cooler }) => {
             },
             opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
             updatedAt: new Date().toISOString()
-          }
-          ssbClient.publish(updated, (err3, result) => err3 ? reject(err3) : resolve(result))
-        })
-      })
+          };
+          ssbClient.publish(updated, (err3, result) => err3 ? reject(err3) : resolve(result));
+        });
+      });
     }
-  }
-}
+  };
+};
 

+ 33 - 36
src/models/opinions_model.js

@@ -1,5 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
+const categories = require('../backend/opinion_categories');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
@@ -8,7 +9,7 @@ module.exports = ({ cooler }) => {
     if (!ssb) ssb = await cooler.open();
     return ssb;
   };
-  
+
   const hasBlob = async (ssbClient, url) => {
     return new Promise(resolve => {
       ssbClient.blobs.has(url, (err, has) => {
@@ -17,11 +18,6 @@ module.exports = ({ cooler }) => {
     });
   };
 
-  const categories = [
-    "interesting", "necessary", "funny", "disgusting", "sensible",
-    "propaganda", "adultOnly", "boring", "confusing", "inspiring", "spam"
-  ];
-
   const validTypes = [
     'bookmark', 'votes', 'transfer',
     'feed', 'image', 'audio', 'video', 'document'
@@ -124,38 +120,39 @@ module.exports = ({ cooler }) => {
       })
     );
     filtered = filtered.filter(Boolean);
+
     const signatureOf = (m) => {
-    const c = m.value?.content || {};
-    switch (c.type) {
-      case 'document':
-      case 'image':
-      case 'audio':
-      case 'video':
-        return `${c.type}::${(c.url || '').trim()}`;
-      case 'bookmark': {
-        const u = (c.url || c.bookmark || '').trim().toLowerCase();
-        return `bookmark::${u}`;
-      }
-      case 'feed': {
-        const t = (c.text || '').replace(/\s+/g, ' ').trim();
-        return `feed::${t}`;
-      }
-      case 'votes': {
-        const q = (c.question || '').replace(/\s+/g, ' ').trim();
-        return `votes::${q}`;
-      }
-      case 'transfer': {
-        const concept = (c.concept || '').trim();
-        const amount = c.amount || '';
-        const from = c.from || '';
-        const to = c.to || '';
-        const deadline = c.deadline || '';
-        return `transfer::${concept}|${amount}|${from}|${to}|${deadline}`;
+      const c = m.value?.content || {};
+      switch (c.type) {
+        case 'document':
+        case 'image':
+        case 'audio':
+        case 'video':
+          return `${c.type}::${(c.url || '').trim()}`;
+        case 'bookmark': {
+          const u = (c.url || c.bookmark || '').trim().toLowerCase();
+          return `bookmark::${u}`;
+        }
+        case 'feed': {
+          const t = (c.text || '').replace(/\s+/g, ' ').trim();
+          return `feed::${t}`;
+        }
+        case 'votes': {
+          const q = (c.question || '').replace(/\s+/g, ' ').trim();
+          return `votes::${q}`;
+        }
+        case 'transfer': {
+          const concept = (c.concept || '').trim();
+          const amount = c.amount || '';
+          const from = c.from || '';
+          const to = c.to || '';
+          const deadline = c.deadline || '';
+          return `transfer::${concept}|${amount}|${from}|${to}|${deadline}`;
+        }
+        default:
+          return `key::${m.key}`;
       }
-      default:
-        return `key::${m.key}`;
-    }
-  };
+    };
 
     const bySig = new Map();
     for (const m of filtered) {

+ 155 - 6
src/models/search_model.js

@@ -72,9 +72,154 @@ module.exports = ({ cooler }) => {
     }
   };
 
+  const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
+
+  const getDedupeKey = (msg) => {
+    const c = msg?.value?.content || {};
+    const t = c?.type || 'unknown';
+    const author = c.author || msg?.value?.author || '';
+
+    if (t === 'post') return `post:${msg.key}`;
+
+    if (t === 'about') return `about:${c.about || author || msg.key}`;
+    if (t === 'curriculum') return `curriculum:${c.author || msg?.value?.author || msg.key}`;
+    if (t === 'contact') return `contact:${c.contact || msg.key}`;
+    if (t === 'vote') return `vote:${c?.vote?.link || msg.key}`;
+    if (t === 'pub') return `pub:${c?.address?.key || c?.address?.host || msg.key}`;
+    if (t === 'bankWallet') return `bankWallet:${c?.address || msg.key}`;
+    if (t === 'bankClaim') return `bankClaim:${c?.txid || `${c?.epochId || ''}:${c?.allocationId || ''}:${c?.amount || ''}` || msg.key}`;
+
+    if (t === 'document') return `document:${c.key || c.url || `${author}|${norm(c.title)}` || msg.key}`;
+    if (t === 'image') return `image:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
+    if (t === 'audio') return `audio:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
+    if (t === 'video') return `video:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
+    if (t === 'bookmark') return `bookmark:${author}|${c.url || norm(c.description) || msg.key}`;
+
+    if (t === 'tribe') {
+      return [
+        'tribe',
+        author,
+        norm(c.title),
+        norm(c.location),
+        norm(c.image)
+      ].join('|');
+    }
+
+    if (t === 'event') {
+      return [
+        'event',
+        c.organizer || author,
+        norm(c.title),
+        norm(c.date),
+        norm(c.location)
+      ].join('|');
+    }
+
+    if (t === 'task') {
+      return [
+        'task',
+        c.author || author,
+        norm(c.title),
+        norm(c.startTime),
+        norm(c.endTime),
+        norm(c.location)
+      ].join('|');
+    }
+
+    if (t === 'report') {
+      return [
+        'report',
+        c.author || author,
+        norm(c.title),
+        norm(c.category),
+        norm(c.severity)
+      ].join('|');
+    }
+
+    if (t === 'votes') {
+      return [
+        'votes',
+        c.createdBy || author,
+        norm(c.question),
+        norm(c.deadline)
+      ].join('|');
+    }
+
+    if (t === 'market') {
+      return [
+        'market',
+        c.seller || author,
+        norm(c.title),
+        norm(c.deadline),
+        norm(c.item_type),
+        norm(c.image)
+      ].join('|');
+    }
+
+    if (t === 'transfer') {
+      const txid = c.txid || c.transactionId || c.id;
+      if (txid) return `transfer:${txid}`;
+      return [
+        'transfer',
+        norm(c.from),
+        norm(c.to),
+        norm(c.amount),
+        norm(c.concept),
+        norm(c.deadline)
+      ].join('|');
+    }
+
+    if (t === 'feed') {
+      return [
+        'feed',
+        c.author || author,
+        norm(c.text)
+      ].join('|');
+    }
+
+    if (t === 'project') {
+      return [
+        'project',
+        c.activityActor || author,
+        norm(c.title),
+        norm(c.deadline),
+        norm(c.goal)
+      ].join('|');
+    }
+
+    if (t === 'job') {
+      return [
+        'job',
+        author,
+        norm(c.title),
+        norm(c.location),
+        norm(c.salary),
+        norm(c.job_type)
+      ].join('|');
+    }
+
+    if (t === 'forum') {
+      return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`;
+    }
+
+    return `${t}:${msg.key}`;
+  };
+
+  const dedupeKeepLatest = (msgs) => {
+    const map = new Map();
+    for (const msg of msgs) {
+      const k = getDedupeKey(msg);
+      const prev = map.get(k);
+      const ts = msg?.value?.timestamp || 0;
+      const pts = prev?.value?.timestamp || 0;
+      if (!prev || ts > pts) map.set(k, msg);
+    }
+    return Array.from(map.values());
+  };
+
   const search = async ({ query, types = [], resultsPerPage = "10" }) => {
     const ssbClient = await openSsb();
-    const queryLower = query.toLowerCase();
+    const queryLower = String(query || '').toLowerCase();
 
     const messages = await new Promise((res, rej) => {
       pull(
@@ -101,22 +246,25 @@ module.exports = ({ cooler }) => {
       latestByKey.delete(oldId);
     }
 
-    const filtered = Array.from(latestByKey.values()).filter(msg => {
+    let filtered = Array.from(latestByKey.values()).filter(msg => {
       const c = msg?.value?.content;
       const t = c?.type;
       if (!t || (types.length > 0 && !types.includes(t))) return false;
       if (t === 'market') {
         if (c.stock === 0 && c.status !== 'SOLD') return false;
       }
-      if (query.startsWith('@') && query.length > 1) return (t === 'about' && c?.about === query);
+      if (!queryLower) return true;
+      if (queryLower.startsWith('@') && queryLower.length > 1) return (t === 'about' && c?.about === query);
       const fields = getRelevantFields(t, c);
-      if (query.startsWith('#') && query.length > 1) {
-        const tag = query.substring(1).toLowerCase();
-        return (c?.tags || []).some(t => t.toLowerCase() === tag);
+      if (queryLower.startsWith('#') && queryLower.length > 1) {
+        const tag = queryLower.substring(1);
+        return (c?.tags || []).some(x => String(x).toLowerCase() === tag);
       }
       return fields.filter(Boolean).map(String).some(field => field.toLowerCase().includes(queryLower));
     });
 
+    filtered = dedupeKeepLatest(filtered);
+
     filtered.sort((a, b) => (b?.value?.timestamp || 0) - (a?.value?.timestamp || 0));
 
     const grouped = filtered.reduce((acc, msg) => {
@@ -136,3 +284,4 @@ module.exports = ({ cooler }) => {
 
   return { search };
 };
+

+ 149 - 35
src/models/tags_model.js

@@ -1,5 +1,4 @@
 const pull = require('../server/node_modules/pull-stream');
-const moment = require('../server/node_modules/moment');
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
@@ -10,47 +9,162 @@ module.exports = ({ cooler }) => {
     return ssb;
   };
 
+  const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
+
+  const normalizeTag = (tag) => String(tag == null ? '' : tag).trim().replace(/^#/, '');
+  const tagKey = (tag) => normalizeTag(tag).toLowerCase();
+
+  const getDedupeKey = (msg) => {
+    const c = msg?.value?.content || {};
+    const t = c?.type || 'unknown';
+    const author = c.author || msg?.value?.author || '';
+
+    if (t === 'post') return `post:${msg.key}`;
+
+    if (t === 'about') return `about:${c.about || author || msg.key}`;
+    if (t === 'curriculum') return `curriculum:${c.author || msg?.value?.author || msg.key}`;
+    if (t === 'contact') return `contact:${c.contact || msg.key}`;
+    if (t === 'vote') return `vote:${c?.vote?.link || msg.key}`;
+    if (t === 'pub') return `pub:${c?.address?.key || c?.address?.host || msg.key}`;
+    if (t === 'bankWallet') return `bankWallet:${c?.address || msg.key}`;
+    if (t === 'bankClaim') return `bankClaim:${c?.txid || `${c?.epochId || ''}:${c?.allocationId || ''}:${c?.amount || ''}` || msg.key}`;
+
+    if (t === 'document') return `document:${c.key || c.url || `${author}|${norm(c.title)}` || msg.key}`;
+    if (t === 'image') return `image:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
+    if (t === 'audio') return `audio:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
+    if (t === 'video') return `video:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
+    if (t === 'bookmark') return `bookmark:${author}|${c.url || norm(c.description) || msg.key}`;
+
+    if (t === 'tribe') {
+      return ['tribe', author, norm(c.title), norm(c.location), norm(c.image)].join('|');
+    }
+
+    if (t === 'event') {
+      return ['event', c.organizer || author, norm(c.title), norm(c.date), norm(c.location)].join('|');
+    }
+
+    if (t === 'task') {
+      return ['task', c.author || author, norm(c.title), norm(c.startTime), norm(c.endTime), norm(c.location)].join('|');
+    }
+
+    if (t === 'report') {
+      return ['report', c.author || author, norm(c.title), norm(c.category), norm(c.severity)].join('|');
+    }
+
+    if (t === 'votes') {
+      return ['votes', c.createdBy || author, norm(c.question), norm(c.deadline)].join('|');
+    }
+
+    if (t === 'market') {
+      return ['market', c.seller || author, norm(c.title), norm(c.deadline), norm(c.item_type), norm(c.image)].join('|');
+    }
+
+    if (t === 'transfer') {
+      const txid = c.txid || c.transactionId || c.id;
+      if (txid) return `transfer:${txid}`;
+      return ['transfer', norm(c.from), norm(c.to), norm(c.amount), norm(c.concept), norm(c.deadline)].join('|');
+    }
+
+    if (t === 'feed') {
+      return ['feed', c.author || author, norm(c.text)].join('|');
+    }
+
+    if (t === 'project') {
+      return ['project', c.activityActor || author, norm(c.title), norm(c.deadline), norm(c.goal)].join('|');
+    }
+
+    if (t === 'job') {
+      return ['job', author, norm(c.title), norm(c.location), norm(c.salary), norm(c.job_type)].join('|');
+    }
+
+    if (t === 'forum') {
+      return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`;
+    }
+
+    return `${t}:${msg.key}`;
+  };
+
+  const dedupeKeepLatest = (msgs) => {
+    const map = new Map();
+    for (const msg of msgs) {
+      const k = getDedupeKey(msg);
+      const prev = map.get(k);
+      const ts = msg?.value?.timestamp || 0;
+      const pts = prev?.value?.timestamp || 0;
+      if (!prev || ts > pts) map.set(k, msg);
+    }
+    return Array.from(map.values());
+  };
+
   return {
     async listTags(filter = 'all') {
       const ssbClient = await openSsb();
-      return new Promise((resolve, reject) => {
+
+      const messages = await new Promise((resolve, reject) => {
         pull(
           ssbClient.createLogStream({ limit: logLimit }),
-          pull.filter(msg => {
-            const c = msg.value.content;
-            return c && Array.isArray(c.tags) && c.tags.length && c.type !== 'tombstone'; 
-          }),
-          pull.collect((err, results) => {
-            if (err) return reject(new Error(`Error retrieving tags: ${err.message}`));
-            const counts = {};
-            const seenKeys = new Set(); 
-            results.forEach(record => {
-              const c = record.value.content;
-              const key = record.key; 
-              const timestamp = c.timestamp;
-              if (c.replaces && seenKeys.has(c.replaces)) {
-                return;
-              }
-              if (!seenKeys.has(key)) {
-                seenKeys.add(key); 
-                c.tags.filter(Boolean).forEach(tag => {
-                  counts[tag] = (counts[tag] || 0) + 1;
-                });
-              }
-            });
-            let tags = Object.entries(counts).map(([name, count]) => ({ name, count }));
-            if (filter === 'top') {
-              tags.sort((a, b) => b.count - a.count);
-            } else if (filter === 'cloud') {
-              const max = Math.max(...tags.map(t => t.count), 1);
-              tags = tags.map(t => ({ ...t, weight: t.count / max }));
-            } else {
-              tags.sort((a, b) => a.name.localeCompare(b.name));
-            }
-            resolve(tags);
-          })
+          pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
         );
       });
+
+      const tombstoned = new Set(
+        messages
+          .filter(m => m?.value?.content?.type === 'tombstone')
+          .map(m => m.value.content.target)
+          .filter(Boolean)
+      );
+
+      const replacesMap = new Map();
+      const latestByKey = new Map();
+
+      for (const msg of messages) {
+        const k = msg?.key;
+        const c = msg?.value?.content;
+        const t = c?.type;
+        if (!k || !c || !t) continue;
+        if (tombstoned.has(k)) continue;
+        if (t === 'tombstone') continue;
+        if (c.replaces) replacesMap.set(c.replaces, k);
+        latestByKey.set(k, msg);
+      }
+
+      for (const oldId of replacesMap.keys()) latestByKey.delete(oldId);
+
+      let filtered = Array.from(latestByKey.values()).filter(msg => {
+        const c = msg?.value?.content;
+        if (!c || c.type === 'tombstone') return false;
+        if (tombstoned.has(msg.key)) return false;
+        return Array.isArray(c.tags) && c.tags.filter(Boolean).length > 0;
+      });
+
+      filtered = dedupeKeepLatest(filtered);
+
+      const counts = new Map();
+
+      for (const record of filtered) {
+        const tagsArr = record?.value?.content?.tags || [];
+        const uniqueTags = new Set(tagsArr.map(tagKey).filter(Boolean));
+        for (const k of uniqueTags) {
+          const display = normalizeTag(tagsArr.find(t => tagKey(t) === k) || k) || k;
+          const prev = counts.get(k);
+          if (!prev) counts.set(k, { name: display, count: 1 });
+          else counts.set(k, { name: prev.name || display, count: prev.count + 1 });
+        }
+      }
+
+      let tags = Array.from(counts.values());
+
+      if (filter === 'top') {
+        tags.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
+      } else if (filter === 'cloud') {
+        const max = Math.max(...tags.map(t => t.count), 1);
+        tags = tags.map(t => ({ ...t, weight: t.count / max }));
+      } else {
+        tags.sort((a, b) => a.name.localeCompare(b.name));
+      }
+
+      return tags;
     }
   };
 };
+

+ 144 - 85
src/models/transfers_model.js

@@ -1,25 +1,51 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const { getConfig } = require('../configs/config-manager.js');
+const categories = require('../backend/opinion_categories');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
+  const getAllMessages = async (ssbClient) =>
+    new Promise((resolve, reject) => {
+      pull(
+        ssbClient.createLogStream({ limit: logLimit }),
+        pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
+      );
+    });
+
+  const resolveCurrentId = async (id) => {
+    const ssbClient = await openSsb();
+    const messages = await getAllMessages(ssbClient);
+    const forward = new Map();
+    for (const m of messages) {
+      const c = m.value?.content;
+      if (!c) continue;
+      if (c.type === 'transfer' && c.replaces) forward.set(c.replaces, m.key);
+    }
+    let cur = id;
+    while (forward.has(cur)) cur = forward.get(cur);
+    return cur;
+  };
+
+  const isValidId = (to) => /^@[A-Za-z0-9+/]+={0,2}\.ed25519$/.test(String(to || ''));
+
   return {
     type: 'transfer',
 
     async createTransfer(to, concept, amount, deadline, tagsRaw = []) {
-      const ssb = await openSsb();
-      const userId = ssb.id;
-      if (!/^@[A-Za-z0-9+\/]+= {0,2}\.ed25519$/.test(to)) throw new Error('Invalid recipient ID');
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      if (!isValidId(to)) throw new Error('Invalid recipient ID');
       const num = typeof amount === 'string' ? parseFloat(amount.replace(',', '.')) : amount;
       if (isNaN(num) || num <= 0) throw new Error('Amount must be positive');
       const dl = moment(deadline, moment.ISO_8601, true);
       if (!dl.isValid() || dl.isBefore(moment())) throw new Error('Deadline must be in the future');
-      const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
+      const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(',').map(t => t.trim()).filter(Boolean);
       const isSelf = to === userId;
+
       const content = {
         type: 'transfer',
         from: userId,
@@ -34,29 +60,37 @@ module.exports = ({ cooler }) => {
         opinions: {},
         opinions_inhabitants: []
       };
+
       return new Promise((resolve, reject) => {
-        ssb.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
+        ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
       });
     },
 
     async updateTransferById(id, to, concept, amount, deadline, tagsRaw = []) {
-      const ssb = await openSsb();
-      const userId = ssb.id;
-      const old = await new Promise((res, rej) => ssb.get(id, (err, msg) => err || !msg?.content ? rej(err || new Error()) : res(msg)));
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const tipId = await resolveCurrentId(id);
+
+      const old = await new Promise((res, rej) =>
+        ssbClient.get(tipId, (err, msg) => err || !msg?.content ? rej(err || new Error()) : res(msg))
+      );
+
+      if (old.content.type !== 'transfer') throw new Error('Transfer not found');
       if (Object.keys(old.content.opinions || {}).length > 0) throw new Error('Cannot edit transfer after it has received opinions.');
       if (old.content.from !== userId) throw new Error('Not the author');
       if (old.content.status !== 'UNCONFIRMED') throw new Error('Can only edit unconfirmed');
+      if (!isValidId(to)) throw new Error('Invalid recipient ID');
 
-      const tomb = { type: 'tombstone', id, deletedAt: new Date().toISOString() };
-      await new Promise((res, rej) => ssb.publish(tomb, err => err ? rej(err) : res()));
-
-      if (!/^@[A-Za-z0-9+\/]+= {0,2}\.ed25519$/.test(to)) throw new Error('Invalid recipient ID');
       const num = typeof amount === 'string' ? parseFloat(amount.replace(',', '.')) : amount;
       if (isNaN(num) || num <= 0) throw new Error('Amount must be positive');
       const dl = moment(deadline, moment.ISO_8601, true);
       if (!dl.isValid() || dl.isBefore(moment())) throw new Error('Deadline must be in the future');
-      const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
+      const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(',').map(t => t.trim()).filter(Boolean);
       const isSelf = to === userId;
+
+      const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, err => err ? rej(err) : res()));
+
       const updated = {
         type: 'transfer',
         from: userId,
@@ -71,103 +105,120 @@ module.exports = ({ cooler }) => {
         opinions: {},
         opinions_inhabitants: [],
         updatedAt: new Date().toISOString(),
-        replaces: id
+        replaces: tipId
       };
+
       return new Promise((resolve, reject) => {
-        ssb.publish(updated, (err, msg) => err ? reject(err) : resolve(msg));
+        ssbClient.publish(updated, (err, msg) => err ? reject(err) : resolve(msg));
       });
     },
 
     async confirmTransferById(id) {
-      const ssb = await openSsb();
-      const userId = ssb.id;
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const tipId = await resolveCurrentId(id);
+
       return new Promise((resolve, reject) => {
-        ssb.get(id, async (err, msg) => {
-          if (err || !msg?.content) return reject(new Error('Not found'));
+        ssbClient.get(tipId, async (err, msg) => {
+          if (err || !msg?.content || msg.content.type !== 'transfer') return reject(new Error('Not found'));
           const t = msg.content;
           if (t.status !== 'UNCONFIRMED') return reject(new Error('Not unconfirmed'));
           if (t.to !== userId) return reject(new Error('Not the recipient'));
 
-          const newConfirmed = [...t.confirmedBy, userId].filter((v, i, a) => a.indexOf(v) === i);
+          const newConfirmed = [...(t.confirmedBy || []), userId].filter((v, i, a) => a.indexOf(v) === i);
           const newStatus = newConfirmed.length >= 2 ? 'CLOSED' : 'UNCONFIRMED';
-          const upd = { ...t, confirmedBy: newConfirmed, status: newStatus, updatedAt: new Date().toISOString(), replaces: id };
-          const tombstone = { type: 'tombstone', id, deletedAt: new Date().toISOString() };
-          await new Promise((res, rej) => ssb.publish(tombstone, (err) => err ? rej(err) : res()));
-          ssb.publish(upd, (err, result) => err ? reject(err) : resolve(result));
+
+          const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
+          await new Promise((res, rej) => ssbClient.publish(tombstone, e => e ? rej(e) : res()));
+
+          const upd = { ...t, confirmedBy: newConfirmed, status: newStatus, updatedAt: new Date().toISOString(), replaces: tipId };
+          ssbClient.publish(upd, (e2, result) => e2 ? reject(e2) : resolve(result));
         });
       });
     },
 
     async deleteTransferById(id) {
-      const ssb = await openSsb();
-      const userId = ssb.id;
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const tipId = await resolveCurrentId(id);
+
       return new Promise((resolve, reject) => {
-        ssb.get(id, (err, msg) => {
-          if (err || !msg?.content) return reject(new Error('Not found'));
+        ssbClient.get(tipId, (err, msg) => {
+          if (err || !msg?.content || msg.content.type !== 'transfer') return reject(new Error('Not found'));
           const t = msg.content;
           if (t.from !== userId) return reject(new Error('Not the author'));
-          if (t.status !== 'UNCONFIRMED' || t.confirmedBy.length >= 2) return reject(new Error('Not editable'));
+          if (t.status !== 'UNCONFIRMED' || (t.confirmedBy || []).length >= 2) return reject(new Error('Not editable'));
 
-          const tomb = { type: 'tombstone', id, deletedAt: new Date().toISOString() };
-          ssb.publish(tomb, err => err ? reject(err) : resolve());
+          const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
+          ssbClient.publish(tombstone, err2 => err2 ? reject(err2) : resolve());
         });
       });
     },
 
-	async listAll(filter = 'all') {
-	  const ssb = await openSsb();
-	  return new Promise((resolve, reject) => {
-	    pull(
-	      ssb.createLogStream({ limit: logLimit }),
-	      pull.collect(async (err, results) => {
-		if (err) return reject(err);
-		const tombstoned = new Set();
-		const replaces = new Map();
-		const transfersById = new Map();
-		const now = moment();
-
-		for (const r of results) {
-		  const c = r.value?.content;
-		  const k = r.key;
-		  if (!c) continue;
-		  if (c.type === 'tombstone' && c.id) {
-		    tombstoned.add(c.id);
-		    continue;
-		  }
-		  if (c.type === 'transfer') {
-		    if (tombstoned.has(k)) continue;
-		    if (c.replaces) replaces.set(c.replaces, k);
-		    transfersById.set(k, { id: k, ...c });
-		  }
-		}
-
-		for (const replacedId of replaces.keys()) {
-		  transfersById.delete(replacedId);
-		}
-
-		const deduped = Array.from(transfersById.values());
-
-		for (const item of deduped) {
-		  const dl = moment(item.deadline);
-		  if (item.status === 'UNCONFIRMED' && dl.isBefore(now)) {
-		    item.status = (item.confirmedBy || []).length >= 2 ? 'CLOSED' : 'DISCARDED';
-		  }
-		}
-
-		resolve(deduped);
-	      })
-	    );
-	  });
-	},
+    async listAll(filter = 'all') {
+      const ssbClient = await openSsb();
+      const messages = await getAllMessages(ssbClient);
+
+      const tombstoned = new Set();
+      const replaces = new Map();
+      const latest = new Map();
+
+      for (const m of messages) {
+        const c = m.value?.content;
+        const k = m.key;
+        if (!c) continue;
+
+        if (c.type === 'tombstone') {
+          const tgt = c.target || c.id;
+          if (tgt) tombstoned.add(tgt);
+          continue;
+        }
+
+        if (c.type !== 'transfer') continue;
+
+        if (c.replaces) replaces.set(c.replaces, k);
+        latest.set(k, {
+          id: k,
+          from: c.from,
+          to: c.to,
+          concept: c.concept,
+          amount: c.amount,
+          createdAt: c.createdAt,
+          deadline: c.deadline,
+          confirmedBy: c.confirmedBy || [],
+          status: c.status,
+          tags: c.tags || [],
+          opinions: c.opinions || {},
+          opinions_inhabitants: c.opinions_inhabitants || []
+        });
+      }
+
+      for (const oldId of replaces.keys()) latest.delete(oldId);
+      for (const delId of tombstoned.values()) latest.delete(delId);
+
+      const now = moment();
+      const out = Array.from(latest.values());
+
+      for (const item of out) {
+        const dl = moment(item.deadline);
+        if (item.status === 'UNCONFIRMED' && dl.isValid() && dl.isBefore(now)) {
+          item.status = (item.confirmedBy || []).length >= 2 ? 'CLOSED' : 'DISCARDED';
+        }
+      }
+
+      return out;
+    },
 
     async getTransferById(id) {
-      const ssb = await openSsb();
+      const ssbClient = await openSsb();
+      const tipId = await resolveCurrentId(id);
+
       return new Promise((resolve, reject) => {
-        ssb.get(id, (err, msg) => {
-          if (err || !msg?.content || msg.content.type === 'tombstone') return reject(new Error('Not found'));
+        ssbClient.get(tipId, (err, msg) => {
+          if (err || !msg?.content || msg.content.type !== 'transfer') return reject(new Error('Not found'));
           const c = msg.content;
           resolve({
-            id,
+            id: tipId,
             from: c.from,
             to: c.to,
             concept: c.concept,
@@ -185,12 +236,16 @@ module.exports = ({ cooler }) => {
     },
 
     async createOpinion(id, category) {
-      const ssb = await openSsb();
-      const userId = ssb.id;
+      if (!categories.includes(category)) throw new Error('Invalid voting category');
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const tipId = await resolveCurrentId(id);
+
       return new Promise((resolve, reject) => {
-        ssb.get(id, (err, msg) => {
+        ssbClient.get(tipId, async (err, msg) => {
           if (err || !msg || msg.content?.type !== 'transfer') return reject(new Error('Transfer not found'));
           if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'));
+
           const updated = {
             ...msg.content,
             opinions: {
@@ -199,9 +254,13 @@ module.exports = ({ cooler }) => {
             },
             opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
             updatedAt: new Date().toISOString(),
-            replaces: id
+            replaces: tipId
           };
-          ssb.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
+
+          const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
+          await new Promise((res, rej) => ssbClient.publish(tombstone, e => e ? rej(e) : res()));
+
+          ssbClient.publish(updated, (e2, result) => e2 ? reject(e2) : resolve(result));
         });
       });
     }

+ 38 - 31
src/models/trending_model.js

@@ -1,6 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
+const opinionCategories = require('../backend/opinion_categories');
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -11,9 +12,7 @@ module.exports = ({ cooler }) => {
 
   const hasBlob = async (ssbClient, url) => {
     return new Promise(resolve => {
-      ssbClient.blobs.has(url, (err, has) => {
-        resolve(!err && has);
-      });
+      ssbClient.blobs.has(url, (err, has) => resolve(!err && has));
     });
   };
 
@@ -22,14 +21,12 @@ module.exports = ({ cooler }) => {
     'image', 'audio', 'video', 'document', 'transfer'
   ];
 
-  const categories = [
-    'interesting', 'necessary', 'funny', 'disgusting', 'sensible',
-    'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'
-  ];
+  const categories = opinionCategories;
 
   const listTrending = async (filter = 'ALL') => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
+
     const messages = await new Promise((res, rej) => {
       pull(
         ssbClient.createLogStream({ limit: logLimit }),
@@ -45,10 +42,12 @@ module.exports = ({ cooler }) => {
       const k = m.key;
       const c = m.value?.content;
       if (!c) continue;
+
       if (c.type === 'tombstone' && c.target) {
         tombstoned.add(c.target);
         continue;
       }
+
       if (c.opinions && !tombstoned.has(k) && !['task', 'event', 'report'].includes(c.type)) {
         if (c.replaces) replaces.set(c.replaces, k);
         itemsById.set(k, m);
@@ -73,26 +72,28 @@ module.exports = ({ cooler }) => {
       })
     );
     items = items.filter(Boolean);
+
     const signatureOf = (m) => {
-    const c = m.value?.content || {};
-    switch (c.type) {
-      case 'document':
-      case 'image':
-      case 'audio':
-      case 'video':
-        return `${c.type}::${(c.url || '').trim()}`;
-      case 'bookmark':
-        return `bookmark::${(c.url || '').trim().toLowerCase()}`;
-      case 'feed':
-        return `feed::${(c.text || '').replace(/\s+/g, ' ').trim()}`;
-      case 'votes':
-       return `votes::${(c.question || '').replace(/\s+/g, ' ').trim()}`;
-      case 'transfer':
-        return `transfer::${(c.concept || '')}|${c.amount || ''}|${c.from || ''}|${c.to || ''}|${c.deadline || ''}`;
-      default:
-        return `key::${m.key}`;
-    }
+      const c = m.value?.content || {};
+      switch (c.type) {
+        case 'document':
+        case 'image':
+        case 'audio':
+        case 'video':
+          return `${c.type}::${(c.url || '').trim()}`;
+        case 'bookmark':
+          return `bookmark::${(c.url || '').trim().toLowerCase()}`;
+        case 'feed':
+          return `feed::${(c.text || '').replace(/\s+/g, ' ').trim()}`;
+        case 'votes':
+          return `votes::${(c.question || '').replace(/\s+/g, ' ').trim()}`;
+        case 'transfer':
+          return `transfer::${(c.concept || '')}|${c.amount || ''}|${c.from || ''}|${c.to || ''}|${c.deadline || ''}`;
+        default:
+          return `key::${m.key}`;
+      }
     };
+
     const bySig = new Map();
     for (const m of items) {
       const sig = signatureOf(m);
@@ -136,7 +137,7 @@ module.exports = ({ cooler }) => {
     return { filtered: items };
   };
 
-  const getMessageById = async id => {
+  const getMessageById = async (id) => {
     const ssbClient = await openSsb();
     return new Promise((res, rej) => {
       ssbClient.get(id, (err, msg) => err ? rej(err) : res(msg));
@@ -146,28 +147,34 @@ module.exports = ({ cooler }) => {
   const createVote = async (contentId, category) => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
+
     if (!categories.includes(category)) throw new Error('Invalid voting category');
+
     const msg = await getMessageById(contentId);
     if (!msg || !msg.content) throw new Error('Content not found');
+
     const type = msg.content.type;
     if (!types.includes(type) || ['task', 'event', 'report'].includes(type)) {
       throw new Error('Voting not allowed on this content type');
     }
-    if (msg.content.opinions_inhabitants?.includes(userId)) throw new Error('Already voted');
+
+    const inhabitants = Array.isArray(msg.content.opinions_inhabitants) ? msg.content.opinions_inhabitants : [];
+    if (inhabitants.includes(userId)) throw new Error('Already voted');
 
     const tombstone = {
       type: 'tombstone',
       target: contentId,
-      deletedAt: new Date().toISOString()
+      deletedAt: new Date().toISOString(),
+      author: userId
     };
 
     const updated = {
       ...msg.content,
       opinions: {
-        ...msg.content.opinions,
-        [category]: (msg.content.opinions?.[category] || 0) + 1
+        ...(msg.content.opinions || {}),
+        [category]: ((msg.content.opinions && msg.content.opinions[category]) || 0) + 1
       },
-      opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
+      opinions_inhabitants: inhabitants.concat(userId),
       updatedAt: new Date().toISOString(),
       replaces: contentId
     };

+ 8 - 15
src/models/videos_model.js

@@ -1,5 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
+const categories = require('../backend/opinion_categories');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
@@ -58,8 +59,8 @@ module.exports = ({ cooler }) => {
             updatedAt: new Date().toISOString(),
             replaces: id
           };
-          ssbClient.publish(tombstone, err => {
-            if (err) return reject(err);
+          ssbClient.publish(tombstone, err1 => {
+            if (err1) return reject(err1);
             ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
           });
         });
@@ -110,7 +111,7 @@ module.exports = ({ cooler }) => {
           key: k,
           url: c.url,
           createdAt: c.createdAt,
-         updatedAt: c.updatedAt || null,
+          updatedAt: c.updatedAt || null,
           tags: c.tags || [],
           author: c.author,
           title: c.title || '',
@@ -160,32 +161,24 @@ module.exports = ({ cooler }) => {
     },
 
     async createOpinion(id, category) {
+      if (!categories.includes(category)) return Promise.reject(new Error('Invalid voting category'));
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       return new Promise((resolve, reject) => {
         ssbClient.get(id, (err, msg) => {
           if (err || !msg || msg.content?.type !== 'video') return reject(new Error('Video not found'));
           if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'));
-          const tombstone = {
-            type: 'tombstone',
-            target: id,
-            deletedAt: new Date().toISOString(),
-            author: userId
-          };
           const updated = {
             ...msg.content,
+            replaces: id,
             opinions: {
               ...msg.content.opinions,
               [category]: (msg.content.opinions?.[category] || 0) + 1
             },
             opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
-            updatedAt: new Date().toISOString(),
-            replaces: id
+            updatedAt: new Date().toISOString()
           };
-          ssbClient.publish(tombstone, err => {
-            if (err) return reject(err);
-            ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
-          });
+          ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
         });
       });
     }

+ 3 - 0
src/models/votes_model.js

@@ -1,6 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const { getConfig } = require('../configs/config-manager.js');
+const categories = require('../backend/opinion_categories')
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
@@ -205,6 +206,7 @@ module.exports = ({ cooler }) => {
       const c = oldMsg.content;
       if (!c || c.type !== TYPE) throw new Error('Invalid type');
       if (c.createdBy !== userId) throw new Error('Not the author');
+      if (Object.keys(c.opinions || {}).length > 0) throw new Error('Cannot edit vote after it has received opinions.')
 
       let newDeadline = c.deadline;
       if (deadline != null && deadline !== '') {
@@ -362,6 +364,7 @@ module.exports = ({ cooler }) => {
     },
 
     async createOpinion(id, category) {
+      if (!categories.includes(category)) throw new Error('Invalid voting category')
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       const tipId = await resolveCurrentId(id);

+ 1 - 1
src/server/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.5.9",
+  "version": "0.6.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {

+ 1 - 1
src/server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.5.9",
+  "version": "0.6.0",
   "description": "Oasis Social Networking Project Utopia",
   "repository": {
     "type": "git",

+ 2 - 2
src/views/activity_view.js

@@ -676,7 +676,7 @@ function renderActionCards(actions, userId) {
       cardBody.push(
         div({ class: 'card-section contact' },
           p({ class: 'card-field' },
-            a({ href: `/author/${encodeURIComponent(contact)}`}, contact)
+            a({ href: `/author/${encodeURIComponent(contact)}`, class: "user-link"}, contact)
           )
         )
       );
@@ -688,7 +688,7 @@ function renderActionCards(actions, userId) {
       cardBody.push(
         div({ class: 'card-section pub' },
           p({ class: 'card-field' },
-            a({ href: `/author/${encodeURIComponent(key || '')}` }, key || '')
+            a({ href: `/author/${encodeURIComponent(key || '')}`, class: "user-link" }, key || '')
           )
         )
       );

+ 17 - 18
src/views/audio_view.js

@@ -3,6 +3,7 @@ const { template, i18n } = require('./main_views');
 const moment = require("../server/node_modules/moment");
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
+const opinionCategories = require('../backend/opinion_categories');
 
 const userId = config.keys.id
 
@@ -89,10 +90,10 @@ const renderAudioCommentsSection = (audioId, comments = []) => {
   );
 };
 
-const renderCardField = (label, value) =>
-  div({ class: "card-field" }, 
-    span({ class: "card-label" }, label), 
-    span({ class: "card-value" }, value)
+const renderCardField = (labelText, valueText) =>
+  div({ class: "card-field" },
+    span({ class: "card-label" }, labelText),
+    span({ class: "card-value" }, valueText)
   );
 
 const renderAudioActions = (filter, audio) => {
@@ -130,7 +131,7 @@ const renderAudioList = (filteredAudios, filter) => {
             : p(i18n.audioNoFile),
           p(...renderUrl(audio.description)),
           audio.tags?.length
-            ? div({ class: "card-tags" }, 
+            ? div({ class: "card-tags" },
                 audio.tags.map(tag =>
                   a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
                 )
@@ -150,15 +151,13 @@ const renderAudioList = (filteredAudios, filter) => {
             a({ href: `/author/${encodeURIComponent(audio.author)}`, class: 'user-link' }, `${audio.author}`)
           ),
           div({ class: "voting-buttons" },
-            ['interesting','necessary','funny','disgusting','sensible',
-             'propaganda','adultOnly','boring','confusing','inspiring','spam']
-              .map(category =>
-                form({ method: "POST", action: `/audios/opinions/${encodeURIComponent(audio.key)}/${category}` },
-                  button({ class: "vote-btn" },
-                    `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${audio.opinions?.[category] || 0}]`
-                  )
+            opinionCategories.map(category =>
+              form({ method: "POST", action: `/audios/opinions/${encodeURIComponent(audio.key)}/${category}` },
+                button({ class: "vote-btn" },
+                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${audio.opinions?.[category] || 0}]`
                 )
               )
+            )
           )
         );
       })
@@ -178,7 +177,7 @@ const renderAudioForm = (filter, audioId, audioToEdit) => {
       label(i18n.audioTitleLabel), br(),
       input({ type: "text", name: "title", placeholder: i18n.audioTitlePlaceholder, value: audioToEdit?.title || '' }), br(), br(),
       label(i18n.audioDescriptionLabel), br(),
-      textarea({name: "description", placeholder: i18n.audioDescriptionPlaceholder, rows:"4", value: audioToEdit?.description || '' }), br(), br(),
+      textarea({ name: "description", placeholder: i18n.audioDescriptionPlaceholder, rows: "4" }, audioToEdit?.description || ''), br(), br(),
       button({ type: "submit" }, filter === 'edit' ? i18n.audioUpdateButton : i18n.audioCreateButton)
     )
   );
@@ -193,7 +192,6 @@ exports.audioView = async (audios, filter, audioId) => {
                 i18n.audioAllSectionTitle;
 
   const filteredAudios = getFilteredAudios(filter, audios, userId);
-
   const audioToEdit = audios.find(a => a.key === audioId);
 
   return template(
@@ -227,8 +225,8 @@ exports.audioView = async (audios, filter, audioId) => {
 };
 
 exports.singleAudioView = async (audio, filter, comments = []) => {
-  const isAuthor = audio.author === userId; 
-  const hasOpinions = Object.keys(audio.opinions || {}).length > 0; 
+  const isAuthor = audio.author === userId;
+  const hasOpinions = Object.keys(audio.opinions || {}).length > 0;
 
   return template(
     i18n.audioTitle,
@@ -282,9 +280,9 @@ exports.singleAudioView = async (audio, filter, comments = []) => {
         )
       ),
       div({ class: "voting-buttons" },
-        ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'].map(category =>
+        opinionCategories.map(category =>
           form({ method: "POST", action: `/audios/opinions/${encodeURIComponent(audio.key)}/${category}` },
-            button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${audio.opinions?.[category] || 0}]`)
+            button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${audio.opinions?.[category] || 0}]`)
           )
         )
       ),
@@ -292,3 +290,4 @@ exports.singleAudioView = async (audio, filter, comments = []) => {
     )
   );
 };
+

+ 15 - 11
src/views/bookmark_view.js

@@ -3,6 +3,7 @@ const { template, i18n } = require('./main_views');
 const moment = require("../server/node_modules/moment");
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
+const opinionCategories = require('../backend/opinion_categories');
 
 const userId = config.keys.id
 
@@ -103,8 +104,8 @@ const renderBookmarkList = (filteredBookmarks, filter) => {
           form({ method: "GET", action: `/bookmarks/${encodeURIComponent(bookmark.id)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           ),
-          h2(bookmark.title),
-          renderCardField(i18n.bookmarkUrlLabel + ":"), 
+          h2(bookmark.category || bookmark.url || ''),
+          renderCardField(i18n.bookmarkUrlLabel + ":", ''), 
           br,
           div(bookmark.url
             ? a({ href: bookmark.url, target: "_blank", class: "bookmark-url" }, bookmark.url)
@@ -119,7 +120,7 @@ const renderBookmarkList = (filteredBookmarks, filter) => {
             : null,
           bookmark.description
             ? [
-                renderCardField(i18n.bookmarkDescriptionLabel + ":"),
+                renderCardField(i18n.bookmarkDescriptionLabel + ":", ''),
                 p(...renderUrl(bookmark.description))
               ]
             : null,
@@ -142,9 +143,11 @@ const renderBookmarkList = (filteredBookmarks, filter) => {
             a({ href: `/author/${encodeURIComponent(bookmark.author)}`, class: 'user-link' }, `${bookmark.author}`)
           ),
           div({ class: "voting-buttons" },
-            ['interesting','necessary','funny','disgusting','sensible','propaganda','adultOnly','boring','confusing','inspiring','spam'].map(category =>
+            opinionCategories.map(category =>
               form({ method: "POST", action: `/bookmarks/opinions/${encodeURIComponent(bookmark.id)}/${category}` },
-                button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${bookmark.opinions?.[category] || 0}]`)
+                button({ class: "vote-btn" },
+                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${bookmark.opinions?.[category] || 0}]`
+                )
               )
             )
           )
@@ -165,7 +168,7 @@ const renderBookmarkForm = (filter, bookmarkId, bookmarkToEdit, tags) => {
       label(i18n.bookmarkUrlLabel), br,
       input({ type: "url", name: "url", id: "url", required: true, placeholder: i18n.bookmarkUrlPlaceholder, value: filter === 'edit' ? bookmarkToEdit.url : '' }), br, br,
       label(i18n.bookmarkDescriptionLabel), br,
-      textarea({ name: "description", id: "description", placeholder: i18n.bookmarkDescriptionPlaceholder, rows:"4" }, filter === 'edit' ? bookmarkToEdit.description : ''), br, br,
+      textarea({ name: "description", id: "description", placeholder: i18n.bookmarkDescriptionPlaceholder, rows: "4" }, filter === 'edit' ? bookmarkToEdit.description : ''), br, br,
       label(i18n.bookmarkTagsLabel), br,
       input({ type: "text", name: "tags", id: "tags", placeholder: i18n.bookmarkTagsPlaceholder, value: filter === 'edit' ? tags.join(', ') : '' }), br, br,
       label(i18n.bookmarkCategoryLabel), br,
@@ -270,8 +273,8 @@ exports.singleBookmarkView = async (bookmark, filter, comments = []) => {
             button({ class: "delete-btn", type: "submit" }, i18n.bookmarkDeleteButton)
           )
         ) : null,
-        h2(bookmark.title),
-        renderCardField(i18n.bookmarkUrlLabel + ":"), 
+        h2(bookmark.category || bookmark.url || ''),
+        renderCardField(i18n.bookmarkUrlLabel + ":", ''), 
         br,
         div(bookmark.url
           ? a({ href: bookmark.url, target: "_blank", class: "bookmark-url" }, bookmark.url)
@@ -282,7 +285,7 @@ exports.singleBookmarkView = async (bookmark, filter, comments = []) => {
           : i18n.noLastVisit
         ),
         renderCardField(i18n.bookmarkCategory + ":", bookmark.category || i18n.noCategory),
-        renderCardField(i18n.bookmarkDescriptionLabel + ":"), 
+        renderCardField(i18n.bookmarkDescriptionLabel + ":", ''), 
         p(...renderUrl(bookmark.description)),
         bookmark.tags && bookmark.tags.length
           ? div({ class: "card-tags" },
@@ -297,9 +300,9 @@ exports.singleBookmarkView = async (bookmark, filter, comments = []) => {
           a({ href: `/author/${encodeURIComponent(bookmark.author)}`, class: 'user-link' }, `${bookmark.author}`)
         ),
         div({ class: "voting-buttons" },
-          ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'].map(category =>
+          opinionCategories.map(category =>
             form({ method: "POST", action: `/bookmarks/opinions/${encodeURIComponent(bookmark.id)}/${category}` },
-              button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${bookmark.opinions?.[category] || 0}]`)
+              button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${bookmark.opinions?.[category] || 0}]`)
             )
           )
         )
@@ -308,3 +311,4 @@ exports.singleBookmarkView = async (bookmark, filter, comments = []) => {
     )
   );
 };
+

+ 16 - 20
src/views/document_view.js

@@ -3,6 +3,7 @@ const moment = require("../server/node_modules/moment");
 const { template, i18n } = require('./main_views');
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
+const opinionCategories = require('../backend/opinion_categories');
 
 const userId = config.keys.id;
 
@@ -147,14 +148,13 @@ const renderDocumentList = (filteredDocs, filter) => {
             a({ href: `/author/${encodeURIComponent(doc.author)}`, class: 'user-link' }, doc.author)
           ),
           div({ class: "voting-buttons" },
-            ['interesting','necessary','funny','disgusting','sensible','propaganda','adultOnly','boring','confusing','inspiring','spam']
-              .map(category =>
-                form({ method: "POST", action: `/documents/opinions/${encodeURIComponent(doc.key)}/${category}` },
-                  button({ class: "vote-btn" },
-                    `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${doc.opinions?.[category] || 0}]`
-                  )
+            opinionCategories.map(category =>
+              form({ method: "POST", action: `/documents/opinions/${encodeURIComponent(doc.key)}/${category}` },
+                button({ class: "vote-btn" },
+                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${doc.opinions?.[category] || 0}]`
                 )
               )
+            )
           )
         );
       })
@@ -174,7 +174,7 @@ const renderDocumentForm = (filter, documentId, docToEdit) => {
       label(i18n.documentTitleLabel), br(),
       input({ type: "text", name: "title", placeholder: i18n.documentTitlePlaceholder, value: docToEdit?.title || '' }), br(), br(),
       label(i18n.documentDescriptionLabel), br(),
-      textarea({name: "description", placeholder: i18n.documentDescriptionPlaceholder, rows:"4", value: docToEdit?.description || '' }), br(), br(),
+      textarea({ name: "description", placeholder: i18n.documentDescriptionPlaceholder, rows: "4", value: docToEdit?.description || '' }), br(), br(),
       button({ type: "submit" }, filter === 'edit' ? i18n.documentUpdateButton : i18n.documentCreateButton)
     )
   );
@@ -189,7 +189,6 @@ exports.documentView = async (documents, filter, documentId) => {
                 i18n.documentAllSectionTitle;
 
   const filteredDocs = getFilteredDocuments(filter, documents, userId);
-
   const docToEdit = documents.find(d => d.key === documentId);
   const isDocView = ['mine', 'create', 'edit', 'all', 'recent', 'top'].includes(filter);
 
@@ -217,11 +216,10 @@ exports.documentView = async (documents, filter, documentId) => {
     )
   );
 
-  return `${tpl}
-    ${isDocView
-      ? `<script type="module" src="/js/pdf.min.mjs"></script>
-         <script src="/js/pdf-viewer.js"></script>`
-      : ''}`;
+  return `${tpl}${isDocView
+    ? `<script type="module" src="/js/pdf.min.mjs"></script>
+       <script src="/js/pdf-viewer.js"></script>`
+    : ''}`;
 };
 
 exports.singleDocumentView = async (doc, filter, comments = []) => {
@@ -270,9 +268,9 @@ exports.singleDocumentView = async (doc, filter, comments = []) => {
         )
       ),
       div({ class: "voting-buttons" },
-        ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'].map(category =>
+        opinionCategories.map(category =>
           form({ method: "POST", action: `/documents/opinions/${encodeURIComponent(doc.key)}/${category}` },
-            button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${doc.opinions?.[category] || 0}]`)
+            button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${doc.opinions?.[category] || 0}]`)
           )
         )
       ),
@@ -280,9 +278,7 @@ exports.singleDocumentView = async (doc, filter, comments = []) => {
     )
   );
 
-  return `${tpl}
-    ${filter === 'mine' || filter === 'edit' || filter === 'top' || filter === 'recent' || filter === 'all'
-      ? `<script type="module" src="/js/pdf.min.mjs"></script>
-         <script src="/js/pdf-viewer.js"></script>`
-      : ''}`;
+  return `${tpl}<script type="module" src="/js/pdf.min.mjs"></script>
+<script src="/js/pdf-viewer.js"></script>`;
 };
+

+ 23 - 19
src/views/feed_view.js

@@ -1,22 +1,26 @@
 const { div, h2, p, section, button, form, a, span, textarea, br, input, h1 } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 const { config } = require('../server/SSB_server.js');
-
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
+const opinionCategories = require('../backend/opinion_categories');
 
 const generateFilterButtons = (filters, currentFilter, action) => {
+  const cur = String(currentFilter || '').toUpperCase();
   return filters.map(mode =>
     form({ method: 'GET', action },
       input({ type: 'hidden', name: 'filter', value: mode }),
-      button({ type: 'submit', class: currentFilter === mode.toLowerCase() ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
+      button(
+        { type: 'submit', class: cur === mode ? 'filter-btn active' : 'filter-btn' },
+        i18n[mode + 'Button'] || mode
+      )
     )
   );
 };
 
 const renderFeedCard = (feed, alreadyRefeeded, alreadyVoted) => {
   const content = feed.value.content;
-  const totalVotes = Object.entries(content.opinions || {});
-  const totalCount = totalVotes.reduce((sum, [, count]) => sum + count, 0);
+  const voteEntries = Object.entries(content.opinions || {});
+  const totalCount = voteEntries.reduce((sum, [, count]) => sum + count, 0);
   const createdAt = feed.value.timestamp ? new Date(feed.value.timestamp).toLocaleString() : '';
 
   return div({ class: 'feed-card' },
@@ -33,24 +37,27 @@ const renderFeedCard = (feed, alreadyRefeeded, alreadyVoted) => {
         div({ class: 'feed-text', innerHTML: renderTextWithStyles(content.text) }),
         h2(`${i18n.totalOpinions}: ${totalCount}`),
         p({ class: 'card-footer' },
-        span({ class: 'date-link' }, `${createdAt} ${i18n.performed} `),
+          span({ class: 'date-link' }, `${createdAt} ${i18n.performed} `),
           a({ href: `/author/${encodeURIComponent(feed.value.author)}`, class: 'user-link' }, `${feed.value.author}`)
         )
       )
     ),
     div({ class: 'votes-wrapper' },
-      totalVotes.length > 0
+      voteEntries.length > 0
         ? div({ class: 'votes' },
-            totalVotes.map(([category, count]) =>
+            voteEntries.map(([category, count]) =>
               span({ class: 'vote-category' }, `${category}: ${count}`)
             )
           )
         : null,
       !alreadyVoted
         ? div({ class: 'voting-buttons' },
-            ['interesting','necessary','funny','disgusting','sensible','propaganda','adultOnly','boring','confusing','inspiring','spam'].map(cat =>
+            opinionCategories.map(cat =>
               form({ method: 'POST', action: `/feed/opinions/${encodeURIComponent(feed.key)}/${cat}` },
-                button({ class: 'vote-btn' }, `${i18n['vote'+cat.charAt(0).toUpperCase()+cat.slice(1)] || cat} [${content.opinions?.[cat]||0}]`)
+                button(
+                  { class: 'vote-btn' },
+                  `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${content.opinions?.[cat] || 0}]`
+                )
               )
             )
           )
@@ -60,7 +67,7 @@ const renderFeedCard = (feed, alreadyRefeeded, alreadyVoted) => {
 };
 
 exports.feedView = (feeds, filter) => {
-  const title = 
+  const title =
     filter === 'MINE'   ? i18n.MINEButton :
     filter === 'TODAY'  ? i18n.TODAYButton :
     filter === 'TOP'    ? i18n.TOPButton :
@@ -69,7 +76,7 @@ exports.feedView = (feeds, filter) => {
                           i18n.feedTitle;
 
   if (filter !== 'TOP') {
-    feeds = feeds.sort((a, b) => b.value.timestamp - a.value.timestamp);
+    feeds = feeds.sort((a, b) => (b.value.timestamp || 0) - (a.value.timestamp || 0));
   } else {
     feeds = feeds.sort((a, b) => {
       const aRefeeds = a.value.content.refeeds || 0;
@@ -88,12 +95,9 @@ exports.feedView = (feeds, filter) => {
     section(
       header,
       div({ class: 'mode-buttons-row' },
-        generateFilterButtons(['ALL', 'MINE', 'TODAY', 'TOP'], filter, '/feed'),
+        ...generateFilterButtons(['ALL', 'MINE', 'TODAY', 'TOP'], filter, '/feed'),
         form({ method: 'GET', action: '/feed/create' },
-          button({
-            type: 'submit',
-            class: 'create-button filter-btn'
-          }, i18n.createFeedTitle || "Create Feed")
+          button({ type: 'submit', class: 'create-button filter-btn' }, i18n.createFeedTitle || "Create Feed")
         )
       ),
       section(
@@ -107,7 +111,7 @@ exports.feedView = (feeds, filter) => {
                 cols: 50
               }),
               br(),
-              button({ type: 'submit' }, i18n.createFeedButton)
+              button({ type: 'submit', class: 'create-button' }, i18n.createFeedButton)
             )
           : feeds && feeds.length > 0
             ? div({ class: 'feed-container' },
@@ -132,8 +136,8 @@ exports.feedCreateView = () => {
         h2(i18n.createFeedTitle),
         p(i18n.FeedshareYourOpinions)
       ),
-      div({ class: 'mode-buttons', style: 'display:flex; gap:8px; margin-bottom:24px;' },
-        generateFilterButtons(['ALL', 'MINE', 'TODAY', 'TOP'], 'CREATE', '/feed')
+      div({ class: 'mode-buttons-row' },
+        ...generateFilterButtons(['ALL', 'MINE', 'TODAY', 'TOP'], 'CREATE', '/feed')
       ),
       form({ method: 'POST', action: '/feed/create' },
         textarea({

+ 24 - 25
src/views/image_view.js

@@ -3,6 +3,7 @@ const moment = require("../server/node_modules/moment");
 const { template, i18n } = require('./main_views');
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
+const opinionCategories = require('../backend/opinion_categories');
 
 const userId = config.keys.id;
 
@@ -40,19 +41,19 @@ const renderImageList = (filteredImages, filter) => {
           renderImageActions(filter, imgObj),
           form({ method: "GET", action: `/images/${encodeURIComponent(imgObj.key)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-          ), 
+          ),
           imgObj.title ? h2(imgObj.title) : null,
           a({ href: `#img-${encodeURIComponent(imgObj.key)}` },
             img({ src: `/blob/${encodeURIComponent(imgObj.url)}` })
           ),
           imgObj.description ? p(...renderUrl(imgObj.description)) : null,
           imgObj.tags?.length
-            ? div({ class: "card-tags" }, 
+            ? div({ class: "card-tags" },
                 imgObj.tags.map(tag =>
-                  a({
+                  a(
+                    {
                       href: `/search?query=%23${encodeURIComponent(tag)}`,
-                      class: "tag-link",
-                      style: "margin-right: 0.8em; margin-bottom: 0.5em;"
+                      class: "tag-link"
                     },
                     `#${tag}`
                   )
@@ -79,15 +80,14 @@ const renderImageList = (filteredImages, filter) => {
             )
           ),
           div({ class: "voting-buttons" },
-            ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam']
-              .map(category =>
-                form({ method: "POST", action: `/images/opinions/${encodeURIComponent(imgObj.key)}/${category}` },
-                  button(
-                    { class: "vote-btn" },
-                    `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${imgObj.opinions?.[category] || 0}]`
-                  )
+            opinionCategories.map(category =>
+              form({ method: "POST", action: `/images/opinions/${encodeURIComponent(imgObj.key)}/${category}` },
+                button(
+                  { class: "vote-btn" },
+                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${imgObj.opinions?.[category] || 0}]`
                 )
               )
+            )
           )
         );
       })
@@ -111,7 +111,7 @@ const renderImageForm = (filter, imageId, imageToEdit) => {
       label(i18n.imageTitleLabel), br(),
       input({ type: "text", name: "title", placeholder: i18n.imageTitlePlaceholder, value: imageToEdit?.title || '' }), br(), br(),
       label(i18n.imageDescriptionLabel), br(),
-      textarea({ name: "description", placeholder: i18n.imageDescriptionPlaceholder, rows:"4", value: imageToEdit?.description || '' }), br(), br(),
+      textarea({ name: "description", placeholder: i18n.imageDescriptionPlaceholder, rows: "4", value: imageToEdit?.description || '' }), br(), br(),
       label(i18n.imageMemeLabel),
       input({ type: "checkbox", name: "meme", ...(imageToEdit?.meme ? { checked: true } : {}) }), br(), br(),
       button({ type: "submit" }, filter === 'edit' ? i18n.imageUpdateButton : i18n.imageCreateButton)
@@ -220,7 +220,6 @@ exports.imageView = async (images, filter, imageId) => {
                 i18n.imageAllSectionTitle;
 
   const filteredImages = getFilteredImages(filter, images, userId);
-
   const imageToEdit = images.find(img => img.key === imageId);
 
   return template(
@@ -288,12 +287,12 @@ exports.singleImageView = async (image, filter, comments = []) => {
         image.url ? img({ src: `/blob/${encodeURIComponent(image.url)}` }) : null,
         p(...renderUrl(image.description)),
         image.tags?.length
-          ? div({ class: "card-tags" }, 
+          ? div({ class: "card-tags" },
               image.tags.map(tag =>
-                a({
+                a(
+                  {
                     href: `/search?query=%23${encodeURIComponent(tag)}`,
-                    class: "tag-link",
-                    style: "margin-right: 0.8em; margin-bottom: 0.5em;"
+                    class: "tag-link"
                   },
                   `#${tag}`
                 )
@@ -313,17 +312,17 @@ exports.singleImageView = async (image, filter, comments = []) => {
         )
       ),
       div({ class: "voting-buttons" },
-        ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam']
-          .map(category =>
-            form({ method: "POST", action: `/images/opinions/${encodeURIComponent(image.key)}/${category}` },
-              button(
-                { class: "vote-btn" },
-                `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${image.opinions?.[category] || 0}]`
-              )
+        opinionCategories.map(category =>
+          form({ method: "POST", action: `/images/opinions/${encodeURIComponent(image.key)}/${category}` },
+            button(
+              { class: "vote-btn" },
+              `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${image.opinions?.[category] || 0}]`
             )
           )
+        )
       ),
       renderImageCommentsSection(image.key, comments)
     )
   );
 };
+

+ 78 - 40
src/views/main_views.js

@@ -858,7 +858,7 @@ const thread = (messages) => {
         lodash.get(currentMsg, "value.meta.thread.ancestorOfTarget", false)
       );
       const isBlocked = Boolean(nextMsg.value.meta.blocking);
-      const nextAuthor = lodash.get(nextMsg, "value.meta.author.name");
+      const nextAuthor = lodash.get(nextMsg, "value.meta.author.name") || (typeof nextMsg?.value?.author === "string" ? (nextMsg.value.author.startsWith("@") ? nextMsg.value.author.slice(1) : nextMsg.value.author) : "Anonymous");
       const nextSnippet = postSnippet(
         lodash.has(nextMsg, "value.content.contentWarning")
           ? lodash.get(nextMsg, "value.content.contentWarning")
@@ -970,7 +970,12 @@ const post = ({ msg, aside = false, preview = false }) => {
     const hasContentWarning = typeof msg.value?.content?.contentWarning === "string";
     const isThreadTarget = Boolean(lodash.get(msg, "value.meta.thread.target", false));
 
-    const { name } = msg.value?.meta?.author || { name: "Anonymous" };
+    const authorIdForName = msg.value?.author; 
+    const name =
+      msg.value?.meta?.author?.name ||
+      (typeof authorIdForName === "string"
+        ? (authorIdForName.startsWith("@") ? authorIdForName.slice(1) : authorIdForName)
+        : "Anonymous");
 
     const content = msg.value?.content || {};
     const contentType = String(content.type || "");
@@ -1420,6 +1425,8 @@ exports.authorView = ({
   karmaScore = 0,
   lastActivityBucket
 }) => {
+  const linkUrl = `/author/${encodeURIComponent(feedId)}`;
+
   const mention = `[@${name}](${feedId})`;
   const markdownMention = highlightJs.highlight(mention, { language: "markdown", ignoreIllegals: true }).value;
 
@@ -1560,7 +1567,7 @@ exports.authorView = ({
   }
 
   return template(i18n.profile, prefix, items);
-}
+};
 
 exports.previewCommentView = async ({
   previewData,
@@ -1593,37 +1600,68 @@ exports.commentView = async (
   text,
   contentWarning
 ) => {
-  let markdownMention;
-  const authorName = parentMessage?.value?.meta?.author?.name || "Anonymous";
-  const messageElements = await Promise.all(
-    messages.reverse().map(async (message) => {  
-    const isRootMessage = message.key === parentMessage.key;
-    const messageAuthorName = message.value?.meta?.author?.name || "Anonymous";
-    const authorFeedId = message.value?.author;
-    if (authorFeedId && authorFeedId !== myFeedId && isRootMessage) {
-      const x = `[@${messageAuthorName}](${authorFeedId})\n\n`;
-      markdownMention = x;
+  if (!parentMessage || !parentMessage.value) {
+    throw new Error("Missing parentMessage or value");
+  }
+
+  const parentKey = parentMessage.key;
+  const threadRoot = parentMessage.value?.content?.root || parentKey;
+
+  const messagesInput = Array.isArray(messages) ? messages : [];
+  const merged = [parentMessage, ...messagesInput];
+
+  const filtered = merged.filter((m) => {
+    if (!m || !m.value) return false;
+    return m.key === threadRoot || m.value?.content?.root === threadRoot;
+  });
+
+  const seen = new Set();
+  const threadMessages = [];
+  for (const m of filtered) {
+    if (m && m.key && !seen.has(m.key)) {
+      seen.add(m.key);
+      threadMessages.push(m);
     }
-      const timestamp = message?.value?.meta?.timestamp?.received;
-      const validTimestamp = moment(timestamp, moment.ISO_8601, true); 
-      const timeAgo = validTimestamp.isValid() 
-        ? validTimestamp.fromNow() 
-        : "Invalid time"; 
-      const messageId = message.key.endsWith('.sha256') ? message.key.slice(0, -7) : message.key;
-      const result = await post({ msg: { ...message, key: messageId } });
-      return result; 
-    })
-  );
+  }
+
+  const tsNum = (m) => {
+    const n1 = Number(m?.value?.timestamp);
+    if (Number.isFinite(n1) && n1 > 0) return n1;
+    const iso = m?.value?.meta?.timestamp?.received?.iso8601;
+    const raw = m?.value?.meta?.timestamp?.received;
+    const n2 = iso ? Date.parse(iso) : (raw ? Date.parse(raw) : NaN);
+    if (Number.isFinite(n2) && n2 > 0) return n2;
+    const createdAt = m?.value?.content?.createdAt;
+    const n3 = createdAt ? Date.parse(createdAt) : NaN;
+    if (Number.isFinite(n3) && n3 > 0) return n3;
+    return 0;
+  };
 
-  const action = `/comment/preview/${encodeURIComponent(parentMessage.key)}`;
+  threadMessages.sort((a, b) => tsNum(a) - tsNum(b));
+
+  const authorName = parentMessage.value?.meta?.author?.name || parentMessage.value?.author || "Anonymous";
+
+  let markdownMention = "";
+  const parentAuthorFeedId = parentMessage.value?.author;
+  const parentAuthorName =
+    parentMessage.value?.meta?.author?.name ||
+    (typeof parentAuthorFeedId === "string"
+      ? (parentAuthorFeedId.startsWith("@") ? parentAuthorFeedId.slice(1) : parentAuthorFeedId)
+      : "Anonymous");
+
+  if (parentAuthorFeedId && parentAuthorFeedId !== myFeedId) {
+    markdownMention = `[@${parentAuthorName}](${parentAuthorFeedId})\n\n`;
+  }
+
+  const messageElements = threadMessages.map((m) => post({ msg: m }));
+
+  const action = `/comment/preview/${encodeURIComponent(parentKey)}`;
   const method = "post";
-  const isPrivate = parentMessage?.value?.meta?.private;
-  const publicOrPrivate = isPrivate ? i18n.commentPrivate : i18n.commentPublic;
-  const maybeSubtopicText = isPrivate ? [null] : i18n.commentWarning;
+  const isPrivate = Boolean(parentMessage.value?.meta?.private);
 
   return template(
     i18n.commentTitle({ authorName }),
-    div({ class: "thread-container" }, messageElements),
+    div({ class: "thread-container" }, ...messageElements),
     form(
       { action, method, enctype: "multipart/form-data" },
       i18n.blogSubject,
@@ -1635,23 +1673,23 @@ exports.commentView = async (
           type: "text",
           class: "contentWarning",
           value: contentWarning ? contentWarning : "",
-          placeholder: i18n.contentWarningPlaceholder,
+          placeholder: i18n.contentWarningPlaceholder
         })
       ),
       br,
       label({ for: "text" }, i18n.blogMessage),
       br,
-      textarea(
-        {
-          autofocus: true,
-          required: true,
-          name: "text",
-          rows: "6",
-          cols: "50",
-          placeholder: i18n.publishWarningPlaceholder,
-        },
-        text ? text : isPrivate ? null : markdownMention
-      ),
+	textarea(
+	  {
+	    autofocus: true,
+	    required: true,
+	    name: "text",
+	    rows: "6",
+	    cols: "50",
+	    placeholder: i18n.publishWarningPlaceholder
+	  },
+	  text ? text : null
+	),
       br,
       label(
         { for: "blob" },

+ 91 - 46
src/views/opinions_view.js

@@ -3,6 +3,7 @@ const { template, i18n } = require('./main_views');
 const { config } = require('../server/SSB_server.js');
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { renderUrl } = require('../backend/renderUrl');
+const opinionCategories = require('../backend/opinion_categories');
 
 const seenDocumentTitles = new Set();
 
@@ -236,57 +237,53 @@ exports.opinionsView = (items, filter) => {
 
   const title = i18n.opinionsTitle;
   const baseFilters = ['RECENT', 'ALL', 'MINE', 'TOP'];
-  const categoryFilters = [
-    ['interesting', 'necessary', 'funny', 'disgusting'],
-    ['sensible', 'propaganda', 'adultOnly', 'boring'],
-    ['confusing', 'inspiring', 'spam']
-  ];
 
+  const cards = items
+    .map(item => {
+      const c = item.value.content;
+      const key = item.key;
+      const contentHtml = renderContentHtml(c, key);
+      if (!contentHtml) return null;
+      const voteEntries = Object.entries(c.opinions || {});
+      const total = voteEntries.reduce((sum, [, v]) => sum + v, 0);
+      const voted = c.opinions_inhabitants?.includes(config.keys.id);
+      const created = new Date(item.value.timestamp).toLocaleString();
+      const allCats = opinionCategories;
 
-const cards = items
-  .map(item => {
-    const c = item.value.content;
-    const key = item.key;
-    const contentHtml = renderContentHtml(c, key);
-    if (!contentHtml) return null;
-    const voteEntries = Object.entries(c.opinions || {});
-    const total = voteEntries.reduce((sum, [, v]) => sum + v, 0);
-    const voted = c.opinions_inhabitants?.includes(config.keys.id);
-    const created = new Date(item.value.timestamp).toLocaleString();
-    const allCats = categoryFilters.flat();
-    return div(
-      contentHtml,
-      p({ class: 'card-footer' },
-        span({ class: 'date-link' }, `${created} ${i18n.performed} `),
-        a({ href: `/author/${encodeURIComponent(item.value.author)}`, class: 'user-link' }, item.value.author)
-      ),
-      h2(`${i18n.totalOpinions || i18n.opinionsTotalCount}: ${total}`),
-      div({ class: 'voting-buttons' },
-        allCats.map(cat => {
-          const label = `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)]} [${c.opinions?.[cat] || 0}]`;
-          if (voted) {
-            return button({ class: 'vote-btn', type: 'button' }, label);
-          }
-          return form({ method: 'POST', action: `/opinions/${encodeURIComponent(key)}/${cat}` },
-            button({ class: 'vote-btn' }, label)
-          );
-        })
-      )
-    );
-  })
-  .filter(Boolean);
+      return div(
+        contentHtml,
+        p({ class: 'card-footer' },
+          span({ class: 'date-link' }, `${created} ${i18n.performed} `),
+          a({ href: `/author/${encodeURIComponent(item.value.author)}`, class: 'user-link' }, item.value.author)
+        ),
+        h2(`${i18n.totalOpinions || i18n.opinionsTotalCount}: ${total}`),
+        div({ class: 'voting-buttons' },
+          allCats.map(cat => {
+            const label = `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${c.opinions?.[cat] || 0}]`;
+            if (voted) {
+              return button({ class: 'vote-btn', type: 'button' }, label);
+            }
+            return form({ method: 'POST', action: `/opinions/${encodeURIComponent(key)}/${cat}` },
+              button({ class: 'vote-btn' }, label)
+            );
+          })
+        )
+      );
+    })
+    .filter(Boolean);
 
   const hasDocuments = items.some(item => item.value.content?.type === 'document');
   const header = div({ class: 'tags-header' },
     h2(title),
     p(i18n.shareYourOpinions)
   );
+
   const html = template(
     title,
     section(
       header,
-      div({ class: 'mode-buttons', style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px;' },
-        div({ style: 'display:flex;flex-direction:column;gap:8px;' },
+      div({ class: 'mode-buttons' },
+        div({ class: 'column' },
           baseFilters.map(mode =>
             form({ method: 'GET', action: '/opinions' },
               input({ type: 'hidden', name: 'filter', value: mode }),
@@ -294,13 +291,61 @@ const cards = items
             )
           )
         ),
-        ...categoryFilters.map(row =>
-          div({ style: 'display:flex;flex-direction:column;gap:8px;' },
-            row.map(mode =>
-              form({ method: 'GET', action: '/opinions' },
-                input({ type: 'hidden', name: 'filter', value: mode }),
-                button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
-              )
+        div({ class: 'column' },
+          opinionCategories.positive.slice(0, 5).map(mode =>
+            form({ method: 'GET', action: '/opinions' },
+              input({ type: 'hidden', name: 'filter', value: mode }),
+              button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
+            )
+          )
+        ),
+        div({ class: 'column' },
+          opinionCategories.positive.slice(5, 10).map(mode =>
+            form({ method: 'GET', action: '/opinions' },
+              input({ type: 'hidden', name: 'filter', value: mode }),
+              button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
+            )
+          )
+        ),
+        div({ class: 'column' },
+          opinionCategories.positive.slice(10, 15).map(mode =>
+            form({ method: 'GET', action: '/opinions' },
+              input({ type: 'hidden', name: 'filter', value: mode }),
+              button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
+            )
+          )
+        ),
+        div({ class: 'column' },
+          opinionCategories.constructive.slice(0, 5).map(mode =>
+            form({ method: 'GET', action: '/opinions' },
+              input({ type: 'hidden', name: 'filter', value: mode }),
+              button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
+            )
+          )
+        )
+      ),
+      div({ class: 'mode-buttons' },
+        div({ class: 'column' },
+          opinionCategories.constructive.slice(5, 11).map(mode =>
+            form({ method: 'GET', action: '/opinions' },
+              input({ type: 'hidden', name: 'filter', value: mode }),
+              button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
+            )
+          )
+        ),
+        div({ class: 'column' },
+          opinionCategories.moderation.slice(0, 5).map(mode =>
+            form({ method: 'GET', action: '/opinions' },
+              input({ type: 'hidden', name: 'filter', value: mode }),
+              button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
+            )
+          )
+        ),
+        div({ class: 'column' },
+          opinionCategories.moderation.slice(5, 10).map(mode =>
+            form({ method: 'GET', action: '/opinions' },
+              input({ type: 'hidden', name: 'filter', value: mode }),
+              button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
             )
           )
         )

+ 3 - 2
src/views/search_view.js

@@ -269,8 +269,8 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
         );
       case 'bookmark':
         return div({ class: 'search-bookmark' },
-          content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
-          content.url ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkUrlLabel + ':'), span({ class: 'card-value' }, a({ href: content.url, target: '_blank' }, content.url))) : null,
+          content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, br(),
+          content.url ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkUrlLabel + ':'), span({ class: 'card-value' }, a({ href: content.url, target: '_blank' }, content.url))) : null,br(),
           content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkCategory + ':'), span({ class: 'card-value' }, content.category)) : null,
           content.lastVisit ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'), span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString())) : null,
           content.tags && content.tags.length
@@ -508,3 +508,4 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
 };
 
 exports.searchView = searchView;
+

+ 5 - 6
src/views/tags_view.js

@@ -1,11 +1,10 @@
 const { form, button, div, h2, p, section, table, thead, tr, th, td, a, tbody } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
-const moment = require("../server/node_modules/moment");
 
 const getFilteredTags = (filter, tags) => {
-  let filteredTags = tags.filter(t => !t.tombstone);
+  let filteredTags = Array.isArray(tags) ? tags : [];
   if (filter === 'top') {
-    filteredTags = filteredTags.sort((a, b) => b.count - a.count); 
+    filteredTags = filteredTags.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
   } else {
     filteredTags = filteredTags.sort((a, b) => a.name.localeCompare(b.name));
   }
@@ -74,9 +73,9 @@ exports.tagsView = async (tags, filter) => {
       ),
       div({ class: 'filters' },
         form({ method: 'GET', action: '/tags' },
-          button({ type: 'submit', name: 'filter', value: 'all',    class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.tagsFilterAll),
-          button({ type: 'submit', name: 'filter', value: 'top',    class: filter === 'top' ? 'filter-btn active' : 'filter-btn' }, i18n.tagsFilterTop),
-          button({ type: 'submit', name: 'filter', value: 'cloud',  class: 'filter-btn' }, i18n.tagsFilterCloud)
+          button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.tagsFilterAll),
+          button({ type: 'submit', name: 'filter', value: 'top', class: filter === 'top' ? 'filter-btn active' : 'filter-btn' }, i18n.tagsFilterTop),
+          button({ type: 'submit', name: 'filter', value: 'cloud', class: filter === 'cloud' ? 'filter-btn active' : 'filter-btn' }, i18n.tagsFilterCloud)
         )
       ),
       div({ class: 'tags-list' },

+ 67 - 57
src/views/transfer_view.js

@@ -2,6 +2,7 @@ const { div, h2, p, section, button, form, a, input, img, textarea, br, span, la
 const { template, i18n } = require('./main_views');
 const moment = require('../server/node_modules/moment');
 const { config } = require('../server/SSB_server.js');
+const opinionCategories = require('../backend/opinion_categories');
 
 const userId = config.keys.id
 
@@ -52,7 +53,7 @@ const generateTransferCard = (transfer, userId) => {
       ),
       h2({ class: 'card-field' },
         span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
-        span({ class: 'card-value' }, `${transfer.confirmedBy.length}/2`)
+        span({ class: 'card-value' }, `${(transfer.confirmedBy || []).length}/2`)
       ),
       (transfer.status === 'UNCONFIRMED' && transfer.to === userId)
         ? form({ method: "POST", action: `/transfers/confirm/${encodeURIComponent(transfer.id)}` },
@@ -62,19 +63,22 @@ const generateTransferCard = (transfer, userId) => {
       transfer.tags && transfer.tags.length
         ? div({ class: 'card-tags' },
             transfer.tags.map(tag =>
-              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right:0.8em;margin-bottom:0.5em;" }, `#${tag}`)
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
             )
           )
-        : null, 
+        : null,
       br,
       p({ class: 'card-footer' },
         span({ class: 'date-link' }, `${transfer.createdAt} ${i18n.performed} `),
         a({ href: `/author/${encodeURIComponent(transfer.from)}`, class: 'user-link' }, `${transfer.from}`)
-      ), 
+      ),
       div({ class: "voting-buttons" },
-        ["interesting", "necessary", "funny", "disgusting", "sensible", "propaganda", "adultOnly", "boring", "confusing", "inspiring", "spam"].map(category =>
+        opinionCategories.map(category =>
           form({ method: "POST", action: `/transfers/opinions/${encodeURIComponent(transfer.id)}/${category}` },
-            button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${transfer.opinions?.[category] || 0}]`)
+            button(
+              { class: "vote-btn" },
+              `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${transfer.opinions?.[category] || 0}]`
+            )
           )
         )
       )
@@ -166,57 +170,63 @@ exports.singleTransferView = async (transfer, filter) => {
           button({ type: 'submit', name: 'filter', value: 'create', class: "create-button" }, i18n.transfersCreateButton)
         )
       ),
-	div({ class: "transfer-item" },
-	  div({ class: 'card-section transfer' },
-            div({ class: 'card-field' },
-             span({ class: 'card-label' }, `${i18n.transfersConcept}:`),
-             span({ class: 'card-value' }, transfer.concept)
-            ),
-            div({ class: 'card-field' },
-	      span({ class: 'card-label' }, `${i18n.transfersDeadline}:`),
-	      span({ class: 'card-value' }, moment(transfer.deadline).format("YYYY-MM-DD HH:mm"))
-	    ),
-	    div({ class: 'card-field' },
-	      span({ class: 'card-label' }, `${i18n.transfersStatus}:`),
-	      span({ class: 'card-value' }, i18n[`transfersStatus${transfer.status.charAt(0) + transfer.status.slice(1).toLowerCase()}`])
-	    ),
-	    div({ class: 'card-field' },
-	      span({ class: 'card-label' }, `${i18n.transfersAmount}:`),
-	      span({ class: 'card-value' }, `${transfer.amount} ECO`)
-	    ),
-            div({ class: 'card-field' },
-              span({ class: 'card-label' }, `${i18n.transfersFrom}:`),
-              span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(transfer.from)}`, target: "_blank" }, transfer.from))
-            ),
-            div({ class: 'card-field' },
-              span({ class: 'card-label' }, `${i18n.transfersTo}:`),
-              span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(transfer.to)}`, target: "_blank" }, transfer.to))
-            ),
-	    h2({ class: 'card-field' },
-	      span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
-	      span({ class: 'card-value' }, `${transfer.confirmedBy.length}/2`)
-	    )
-	  )
-	),
-      transfer.status === 'UNCONFIRMED' && transfer.to === userId
-        ? form({ method: "POST", action: `/transfers/confirm/${encodeURIComponent(transfer.id)}` },
-            button({ type: "submit" }, i18n.transfersConfirmButton), br(), br()
-          )
-        : null,
-      transfer.tags && transfer.tags.length
-          ? div({ class: 'card-tags' },
-            transfer.tags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right:0.8em;margin-bottom:0.5em;" }, `#${tag}`))
-          )
-        : null,
-       br,
-       p({ class: 'card-footer' },
-        span({ class: 'date-link' }, `${transfer.createdAt} ${i18n.performed} `),
-        a({ href: `/author/${encodeURIComponent(transfer.from)}`, class: 'user-link' }, `${transfer.from}`)
-      ),
-      div({ class: "voting-buttons" },
-        ["interesting", "necessary", "funny", "disgusting", "sensible", "propaganda", "adultOnly", "boring", "confusing", "inspiring", "spam"].map(category =>
-          form({ method: "POST", action: `/transfers/opinions/${encodeURIComponent(transfer.id)}/${category}` },
-            button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${transfer.opinions?.[category] || 0}]`)
+      div({ class: "transfer-item" },
+        div({ class: 'card-section transfer' },
+          generateTransferActions(transfer, userId),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, `${i18n.transfersConcept}:`),
+            span({ class: 'card-value' }, transfer.concept)
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, `${i18n.transfersDeadline}:`),
+            span({ class: 'card-value' }, moment(transfer.deadline).format("YYYY-MM-DD HH:mm"))
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, `${i18n.transfersStatus}:`),
+            span({ class: 'card-value' }, i18n[`transfersStatus${transfer.status.charAt(0) + transfer.status.slice(1).toLowerCase()}`])
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, `${i18n.transfersAmount}:`),
+            span({ class: 'card-value' }, `${transfer.amount} ECO`)
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, `${i18n.transfersFrom}:`),
+            span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(transfer.from)}`, target: "_blank" }, transfer.from))
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, `${i18n.transfersTo}:`),
+            span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(transfer.to)}`, target: "_blank" }, transfer.to))
+          ),
+          h2({ class: 'card-field' },
+            span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
+            span({ class: 'card-value' }, `${(transfer.confirmedBy || []).length}/2`)
+          ),
+          (transfer.status === 'UNCONFIRMED' && transfer.to === userId)
+            ? form({ method: "POST", action: `/transfers/confirm/${encodeURIComponent(transfer.id)}` },
+                button({ type: "submit" }, i18n.transfersConfirmButton), br(), br()
+              )
+            : null,
+          transfer.tags && transfer.tags.length
+            ? div({ class: 'card-tags' },
+                transfer.tags.map(tag =>
+                  a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+                )
+              )
+            : null,
+          br,
+          p({ class: 'card-footer' },
+            span({ class: 'date-link' }, `${transfer.createdAt} ${i18n.performed} `),
+            a({ href: `/author/${encodeURIComponent(transfer.from)}`, class: 'user-link' }, `${transfer.from}`)
+          ),
+          div({ class: "voting-buttons" },
+            opinionCategories.map(category =>
+              form({ method: "POST", action: `/transfers/opinions/${encodeURIComponent(transfer.id)}/${category}` },
+                button(
+                  { class: "vote-btn" },
+                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${transfer.opinions?.[category] || 0}]`
+                )
+              )
+            )
           )
         )
       )

+ 63 - 14
src/views/trending_view.js

@@ -1,8 +1,9 @@
-const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, img, video: videoHyperaxe, audio: audioHyperaxe, span } = require("../server/node_modules/hyperaxe");
+const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, img, video: videoHyperaxe, audio: audioHyperaxe, span} = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
+const opinionCategories = require('../backend/opinion_categories');
 
 const userId = config.keys.id;
 
@@ -11,15 +12,23 @@ const generateFilterButtons = (filters, currentFilter, action) =>
     filters.map(mode =>
       form({ method: 'GET', action },
         input({ type: 'hidden', name: 'filter', value: mode }),
-        button({ type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
+        button(
+          { type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' },
+          i18n[mode + 'Button'] || mode
+        )
       )
     )
   );
 
+const voteLabelFor = (cat) =>
+  i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat;
+
 const renderTrendingCard = (item, votes, categories, seenTitles) => {
   const c = item.value.content;
   const created = new Date(item.value.timestamp).toLocaleString();
+
   let contentHtml;
+
   if (c.type === 'bookmark') {
     const { url, description, lastVisit } = c;
     contentHtml = div({ class: 'trending-bookmark' },
@@ -29,7 +38,13 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         ),
         br,
         url ? h2(p(a({ href: url, target: '_blank', class: "bookmark-url" }, url))) : "",
-        lastVisit ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'), span({ class: 'card-value' }, new Date(lastVisit).toLocaleString())) : "",
+        lastVisit
+          ? div(
+              { class: 'card-field' },
+              span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'),
+              span({ class: 'card-value' }, new Date(lastVisit).toLocaleString())
+            )
+          : "",
         description ? [span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ":"), p(...renderUrl(description))] : null
       )
     );
@@ -83,6 +98,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
     const t = title?.trim();
     if (t && seenTitles.has(t)) return null;
     if (t) seenTitles.add(t);
+
     contentHtml = div({ class: 'trending-document' },
       div({ class: 'card-section document' },
         form({ method: "GET", action: `/documents/${encodeURIComponent(item.key)}` },
@@ -103,8 +119,10 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
       )
     );
   } else if (c.type === 'votes') {
-    const { question, deadline, votes, totalVotes } = c;
-    const votesList = votes && typeof votes === 'object' ? Object.entries(votes).map(([o, cnt]) => ({ option: o, count: cnt })) : [];
+    const { question, deadline, votes: vmap, totalVotes } = c;
+    const votesList = vmap && typeof vmap === 'object'
+      ? Object.entries(vmap).map(([o, cnt]) => ({ option: o, count: cnt }))
+      : [];
     contentHtml = div({ class: 'trending-votes' },
       div({ class: 'card-section votes' },
         form({ method: "GET", action: `/votes/${encodeURIComponent(item.key)}` },
@@ -120,7 +138,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
       )
     );
   } else if (c.type === 'transfer') {
-    const { from, to, concept, amount, deadline, status, confirmedBy } = c;
+    const { from, to, concept, amount, deadline, status, confirmedBy = [] } = c;
     contentHtml = div({ class: 'trending-transfer' },
       div({ class: 'card-section transfer' },
         form({ method: "GET", action: `/transfers/${encodeURIComponent(item.key)}` },
@@ -139,22 +157,42 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
   } else {
     contentHtml = div({ class: 'styled-text' },
       div({ class: 'card-section styled-text-content' },
-        div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.textContentLabel + ':'), span({ class: 'card-value', innerHTML: renderTextWithStyles(c.text || c.description || c.title || '[no content]') }))
+        div(
+          { class: 'card-field' },
+          span({ class: 'card-label' }, i18n.textContentLabel + ':'),
+          span({ class: 'card-value', innerHTML: renderTextWithStyles(c.text || c.description || c.title || '[no content]') })
+        )
       )
     );
   }
 
-  return div({ class: 'trending-card', style: 'background-color:#2c2f33;border-radius:8px;padding:16px;border:1px solid #444;' },
+  return div(
+    { class: 'trending-card', style: 'background-color:#2c2f33;border-radius:8px;padding:16px;border:1px solid #444;' },
     contentHtml,
-    p({ class: 'card-footer' }, span({ class: 'date-link' }, `${created} ${i18n.performed} `), a({ href: `/author/${encodeURIComponent(item.value.author)}`, class: 'user-link' }, item.value.author)),
+    p(
+      { class: 'card-footer' },
+      span({ class: 'date-link' }, `${created} ${i18n.performed} `),
+      a({ href: `/author/${encodeURIComponent(item.value.author)}`, class: 'user-link' }, item.value.author)
+    ),
     h2(`${i18n.trendingTotalOpinions || i18n.trendingTotalCount}: ${votes}`),
-    div({ class: 'voting-buttons' }, categories.map(cat => form({ method: 'POST', action: `/trending/${encodeURIComponent(item.key)}/${cat}` }, button({ class: 'vote-btn' }, `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)]} [${c.opinions?.[cat]||0}]`))))
+    div(
+      { class: 'voting-buttons' },
+      categories.map(cat =>
+        form({ method: 'POST', action: `/trending/${encodeURIComponent(item.key)}/${cat}` },
+          button(
+            { class: 'vote-btn' },
+            `${voteLabelFor(cat)} [${c.opinions?.[cat] || 0}]`
+          )
+        )
+      )
+    )
   );
 };
 
-exports.trendingView = (items, filter, categories) => {
+exports.trendingView = (items, filter, categories = opinionCategories) => {
   const seenDocumentTitles = new Set();
   const title = i18n.trendingTitle;
+
   const baseFilters = ['RECENT', 'ALL', 'MINE', 'TOP'];
   const contentFilters = [
     ['votes', 'feed', 'transfer'],
@@ -187,7 +225,14 @@ exports.trendingView = (items, filter, categories) => {
 
   const header = div({ class: 'tags-header' }, h2(title), p(i18n.exploreTrending));
   const cards = filteredItems
-    .map(item => renderTrendingCard(item, Object.values(item.value.content.opinions || {}).reduce((s, n) => s + n, 0), categories, seenDocumentTitles))
+    .map(item =>
+      renderTrendingCard(
+        item,
+        Object.values(item.value.content.opinions || {}).reduce((s, n) => s + (n || 0), 0),
+        categories,
+        seenDocumentTitles
+      )
+    )
     .filter(Boolean);
 
   const hasDocument = filteredItems.some(item => item.value.content.type === 'document');
@@ -196,14 +241,18 @@ exports.trendingView = (items, filter, categories) => {
     title,
     section(
       header,
-      div({ class: 'mode-buttons', style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px;' },
+      div(
+        { class: 'mode-buttons', style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px;' },
         generateFilterButtons(baseFilters, filter, '/trending'),
         ...contentFilters.map(row =>
           div({ style: 'display:flex;flex-direction:column;gap:8px;' },
             row.map(mode =>
               form({ method: 'GET', action: '/trending' },
                 input({ type: 'hidden', name: 'filter', value: mode }),
-                button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
+                button(
+                  { type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' },
+                  i18n[mode + 'Button'] || mode
+                )
               )
             )
           )

+ 13 - 11
src/views/video_view.js

@@ -3,6 +3,7 @@ const moment = require("../server/node_modules/moment");
 const { template, i18n } = require('./main_views');
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
+const opinionCategories = require('../backend/opinion_categories');
 
 const userId = config.keys.id;
 
@@ -151,15 +152,14 @@ const renderVideoList = (filteredVideos, filter) => {
             a({ href: `/author/${encodeURIComponent(video.author)}`, class: 'user-link' }, `${video.author}`)
           ),
           div({ class: "voting-buttons" },
-            ['interesting','necessary','funny','disgusting','sensible',
-             'propaganda','adultOnly','boring','confusing','inspiring','spam']
-              .map(category =>
-                form({ method: "POST", action: `/videos/opinions/${encodeURIComponent(video.key)}/${category}` },
-                  button({ class: "vote-btn" },
-                    `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${video.opinions?.[category] || 0}]`
-                  )
+            opinionCategories.map(category =>
+              form({ method: "POST", action: `/videos/opinions/${encodeURIComponent(video.key)}/${category}` },
+                button(
+                  { class: "vote-btn" },
+                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${video.opinions?.[category] || 0}]`
                 )
               )
+            )
           )
         );
       })
@@ -179,7 +179,7 @@ const renderVideoForm = (filter, videoId, videoToEdit) => {
       label(i18n.videoTitleLabel), br(),
       input({ type: "text", name: "title", placeholder: i18n.videoTitlePlaceholder, value: videoToEdit?.title || '' }), br(), br(),
       label(i18n.videoDescriptionLabel), br(),
-      textarea({name: "description", placeholder: i18n.videoDescriptionPlaceholder, rows:"4", value: videoToEdit?.description || '' }), br(), br(),
+      textarea({ name: "description", placeholder: i18n.videoDescriptionPlaceholder, rows: "4", value: videoToEdit?.description || '' }), br(), br(),
       button({ type: "submit" }, filter === 'edit' ? i18n.videoUpdateButton : i18n.videoCreateButton)
     )
   );
@@ -194,7 +194,6 @@ exports.videoView = async (videos, filter, videoId) => {
                 i18n.videoAllSectionTitle;
 
   const filteredVideos = getFilteredVideos(filter, videos, userId);
-
   const videoToEdit = videos.find(v => v.key === videoId);
 
   return template(
@@ -288,9 +287,12 @@ exports.singleVideoView = async (video, filter, comments = []) => {
         )
       ),
       div({ class: "voting-buttons" },
-        ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'].map(category =>
+        opinionCategories.map(category =>
           form({ method: "POST", action: `/videos/opinions/${encodeURIComponent(video.key)}/${category}` },
-            button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${video.opinions?.[category] || 0}]`)
+            button(
+              { class: "vote-btn" },
+              `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${video.opinions?.[category] || 0}]`
+            )
           )
         )
       ),

+ 3 - 2
src/views/vote_view.js

@@ -2,6 +2,7 @@ const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th
 const { template, i18n } = require('./main_views');
 const moment = require('../server/node_modules/moment');
 const { config } = require('../server/SSB_server.js');
+const opinionCategories = require('../backend/opinion_categories');
 
 const userId = config.keys.id;
 
@@ -105,9 +106,9 @@ const renderVoteCard = (v, voteOptions, firstRow, secondRow, userId, filter) =>
       a({ href: `/author/${encodeURIComponent(v.createdBy)}`, class: 'user-link' }, `${v.createdBy}`)
     ),
     div({ class: 'voting-buttons' },
-      ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'].map(category =>
+      opinionCategories.map(category =>
         form({ method: 'POST', action: `/votes/opinions/${encodeURIComponent(v.id)}/${category}` },
-          button({ class: 'vote-btn' }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${v.opinions?.[category] || 0}]`)
+          button({ class: 'vote-btn' }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${v.opinions?.[category] || 0}]`)
         )
       )
     )