Browse Source

Oasis release 0.4.0

psy 10 hours ago
parent
commit
71832814d2

+ 13 - 3
README.md

@@ -1,6 +1,6 @@
 # Oasis
 
-Oasis is a **free, open-source, encrypted, peer-to-peer, distributed (not decentralized!) & federated**... project networking application 
+Oasis is a **libre, open-source, encrypted, peer-to-peer, distributed (not decentralized!) & federated**... project networking application 
 that helps you follow interesting content and discover new ones.
 
   ![SNH](https://solarnethub.com/git/snh-oasis-logo3.jpg "SolarNET.HuB")
@@ -42,6 +42,12 @@ But it has others features that are also really interesting, for example:
    ![SNH](https://solarnethub.com/git/snh-clear-theme.png "SolarNET.HuB")
    ![SNH](https://solarnethub.com/git/snh-purple-theme.png "SolarNET.HuB")
    ![SNH](https://solarnethub.com/git/snh-matrix-theme.png "SolarNET.HuB")
+   
+ +  Even a complex Reddit-styled forum system.
+ 
+   ![SNH](https://solarnethub.com/git/snh-forum.png "SolarNET.HuB")
+   ![SNH](https://solarnethub.com/git/snh-forum-reply.png "SolarNET.HuB")
+   ![SNH](https://solarnethub.com/git/snh-activity-forum.png "SolarNET.HuB")
  
 And much more, that we invite you to discover by yourself ;-)
 
@@ -58,7 +64,8 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).	
  + Documents: Module to discover and manage documents.	
  + Events: Module to discover and manage events.	
- + Feed: Module to discover and share short-texts (feeds).	
+ + Feed: Module to discover and share short-texts (feeds).
+ + Forums: Module to discover and manage forums.	
  + Governance: Module to discover and manage votes.	
  + Images: Module to discover and manage images.	
  + Invites: Module to manage and apply invite codes.	
@@ -246,13 +253,16 @@ Check ['Call 4 Hackers'](https://wiki.solarnethub.com/community/hackers) for con
  + SNH Website: https://solarnethub.com
  + Kräkens.Lab: https://krakenslab.com
  + Documentation: https://wiki.solarnethub.com
- + Forum: https://forum.solarnethub.com
  + Research: https://wiki.solarnethub.com/docs/research
  + Code of Conduct: https://wiki.solarnethub.com/docs/code_of_conduct
  + The KIT: https://wiki.solarnethub.com/kit/overview
  + Ecosystem: https://wiki.solarnethub.com/socialnet/ecosystem
  + Project Network: https://wiki.solarnethub.com/socialnet/snh#the_project_network
+ + Oasis: https://wiki.solarnethub.com/socialnet/overview
  + ECOin: https://wiki.solarnethub.com/ecoin/overview
  + Role-playing (L.A.R.P): https://wiki.solarnethub.com/socialnet/roleplaying
  + Warehouse: https://wiki.solarnethub.com/stock/submit_request
  + THS: https://thehackerstyle.com
+ + PeerTube: https://video.hardlimit.com/c/thehackerstyle/videos
+ + Youtube: https://www.youtube.com/@thehackerstyle
+ + Twitch: https://twitch.tv/thehackerstyle

+ 6 - 0
docs/CHANGELOG.md

@@ -13,6 +13,12 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.4.0 - 2025-07-29
+
+### Added
+
+  + Forums: Module to discover and manage forums.
+
 ## v0.3.8 - 2025-07-21
 
 ### Added

File diff suppressed because it is too large
+ 59 - 44
src/AI/buildAIContext.js


+ 53 - 2
src/backend/backend.js

@@ -208,6 +208,7 @@ const searchModel = require('../models/search_model')({ cooler, isPublic: config
 const activityModel = require('../models/activity_model')({ cooler, isPublic: config.public });
 const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: config.public });
 const marketModel = require('../models/market_model')({ cooler, isPublic: config.public });
+const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public });
 
 // starting warmup
 about._startNameWarmup();
@@ -407,6 +408,7 @@ const { settingsView } = require("../views/settings_view");
 const { trendingView } = require("../views/trending_view");
 const { marketView, singleMarketView } = require("../views/market_view");
 const { aiView } = require("../views/AI_view");
+const { forumView, singleForumView } = require("../views/forum_view");
 
 let sharp;
 
@@ -524,7 +526,7 @@ router
     'popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 
     'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 
     'events', 'tasks', 'market', 'tribes', 'governance', 'reports', 'opinions', 'transfers', 
-    'feed', 'pixelia', 'agenda', 'ai'
+    'feed', 'pixelia', 'agenda', 'ai', 'forum'
     ];
     const moduleStates = modules.reduce((acc, mod) => {
       acc[`${mod}Mod`] = configMods[`${mod}Mod`];
@@ -1085,6 +1087,31 @@ router
   .get('/feed/create', async ctx => {
     ctx.body = feedCreateView();
   })
+  .get('/forum', async ctx => {
+    const forumMod = ctx.cookies.get("forumMod") || 'on';
+    if (forumMod !== 'on') {
+      ctx.redirect('/modules');
+      return;
+    }
+    const filter = ctx.query.filter || 'hot';
+    const forums = await forumModel.listAll(filter);
+    ctx.body = await forumView(forums, filter);
+  })
+  .get('/forum/:forumId', async ctx => {
+    const rawId = ctx.params.forumId
+    const msg = await forumModel.getMessageById(rawId)
+    const isReply = Boolean(msg.root)
+    const forumId = isReply ? msg.root : rawId
+    const highlightCommentId = isReply ? rawId   : null
+    const forum = await forumModel.getForumById(forumId)
+    const messagesData = await forumModel.getMessagesByForumId(forumId)
+    ctx.body = await singleForumView(
+      forum,
+      messagesData,
+      ctx.query.filter,
+      highlightCommentId
+    )
+  })
   .get('/legacy', async (ctx) => {
     const legacyMod = ctx.cookies.get("legacyMod") || 'on';
     if (legacyMod !== 'on') {
@@ -1539,6 +1566,30 @@ router
     };
     ctx.body = await like({ messageKey, voteValue });
     ctx.redirect(referer.href);
+  }) 
+  .post('/forum/create', koaBody(), async ctx => {
+    const { category, title, text } = ctx.request.body;
+    await forumModel.createForum(category, title, text);
+    ctx.redirect('/forum');
+  })
+  .post('/forum/:id/message', koaBody(), async ctx => {
+    const forumId = ctx.params.id;
+    const { message, parentId } = ctx.request.body;
+    const userId = SSBconfig.config.keys.id;
+    const newMessage = { text: message, author: userId, timestamp: new Date().toISOString() };
+    await forumModel.addMessageToForum(forumId, newMessage, parentId);
+    ctx.redirect(`/forum/${encodeURIComponent(forumId)}`);
+  })
+  .post('/forum/:forumId/vote', koaBody(), async ctx => {
+    const { forumId } = ctx.params;
+    const { target, value } = ctx.request.body;
+    await forumModel.voteContent(target, parseInt(value, 10));
+    const back = ctx.get('referer') || `/forum/${encodeURIComponent(forumId)}`;
+    ctx.redirect(back);
+  })
+  .post('/forum/delete/:id', koaBody(), async ctx => {
+    await forumModel.deleteForumById(ctx.params.id);
+    ctx.redirect('/forum');
   })
   .post('/legacy/export', koaBody(), async (ctx) => {
     const password = ctx.request.body.password;
@@ -2236,7 +2287,7 @@ router
     'popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet',
     'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending',
     'events', 'tasks', 'market', 'tribes', 'governance', 'reports', 'opinions', 'transfers',
-    'feed', 'pixelia', 'agenda', 'ai'
+    'feed', 'pixelia', 'agenda', 'ai', 'forum'
     ];
     const currentConfig = getConfig();
     modules.forEach(mod => {

+ 3 - 1
src/backend/renderTextWithStyles.js

@@ -13,7 +13,9 @@ function renderTextWithStyles(text) {
     .replace(/(https?:\/\/[^\s]+)/g, url =>
       `<a href="${url}" target="_blank" class="styled-link">${url}</a>`
     )
+    .replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email =>
+      `<a href="mailto:${email}" class="styled-link">${email}</a>`
+    )
 }
 
 module.exports = { renderTextWithStyles }
-

+ 270 - 1
src/client/assets/styles/style.css

@@ -674,7 +674,6 @@ button.create-button:hover {
     font-size: 14px;
     border: 1px solid #ccc;
     border-radius: 4px;
-    resize: none;
     box-sizing: border-box;
 }
 
@@ -1390,6 +1389,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   border-radius: 6px;
   color: #FFD700;
   margin-bottom: 8px;
+  margin-right: 15px;
   word-wrap: break-word;
 }
 
@@ -1596,3 +1596,272 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   border-color: #FF6A00;
   font-weight: bold;
 }
+
+/*forums*/
+.forum-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5em;
+}
+.forum-card {
+  display: flex;
+  flex-direction: row;
+  align-items: stretch;
+  border-bottom: 1px solid #262626;
+  padding: 12px 0 16px 0;
+  gap: 1.1em;
+  background: none;
+}
+.forum-score-col {
+  min-width: 78px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.forum-score-box {
+  background: #22201a;
+  border-radius: 12px;
+  box-shadow: 0 0 0.5em #111b;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-width: 56px;
+  min-height: 134px;
+  padding: 8px 0;
+}
+.forum-score-form {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 14px;
+  width: 100%;
+}
+.score-btn {
+  background: #282818;
+  color: #ffd740;
+  border: none;
+  width: 38px;
+  height: 38px;
+  font-size: 1.25em;
+  border-radius: 8px;
+  cursor: pointer;
+  transition: background 0.13s;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  line-height: 1;
+}
+.score-btn:hover {
+  background: #39391c;
+}
+.score-total {
+  font-size: 2em;
+  font-weight: bold;
+  color: #ffd740;
+  text-align: center;
+  margin: 0;
+  line-height: 1.1;
+  min-height: 38px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.forum-main-col {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 0.22em;
+}
+
+.forum-header-row {
+  display: flex;
+  align-items: center;
+  gap: 0.7em;
+  margin-bottom: 2px;
+}
+.forum-category {
+  color: #ff7300;
+  background: #282818;
+  font-weight: bold;
+  font-size: 1em;
+  text-decoration: underline;
+  padding: 2px 7px;
+  border-radius: 5px;
+  margin-right: 0.4em;
+}
+.forum-title {
+  color: #ff7300;
+  font-size: 1.13em;
+  font-weight: bold;
+  text-decoration: underline;
+  transition: color 0.15s;
+}
+.forum-title:hover {
+  color: #ffd740;
+}
+.forum-body {
+  margin: 0.15em 0 0.2em 0;
+  font-size: 1em;
+  color: #ffd740;
+}
+.forum-meta {
+  font-size: 1em;
+  color: #ffd740;
+  margin: 0.1em 0 0 0;
+  display: flex;
+  gap: 1.3em;
+}
+.forum-footer {
+  font-size: 0.97em;
+  margin-top: 6px;
+  display: flex;
+  gap: 0.5em;
+  align-items: center;
+}
+.date-link {
+  color: #ffd740;
+  font-family: monospace;
+  font-size: 1em;
+}
+.user-link {
+  color: #ffd740;
+  font-weight: bold;
+  text-decoration: underline dotted;
+}
+.mode-buttons-row {
+  display: flex;
+  flex-direction: row;
+  gap: 2em;
+  align-items: flex-start;
+  margin-bottom: 1.5em;
+}
+.mode-buttons-cols {
+  flex: 1;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.6em;
+}
+
+.forum-comment {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  background: #282828;
+  padding: 12px;
+  margin-bottom: 8px;
+  border-radius: 8px;
+  transition: background 0.3s ease;
+}
+
+.comment-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.comment-body-row {
+  display: flex;
+  flex-direction: row;
+  gap: 16px;
+}
+
+.comment-vote-col {
+  min-width: 78px;
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+}
+
+.comment-text-col {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.comment-form {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.comment-textarea {
+  resize: vertical;
+  width: 100%;
+  padding: 8px;
+  background: #1e1e1e;
+  color: #ffd740;
+  border: 1px solid #444;
+  border-radius: 6px;
+  font-size: 1em;
+}
+
+.comment-votes {
+  font-size: 0.9em;
+  color: #ffd740;
+}
+
+.comment-footer {
+  font-size: 0.85em;
+  display: flex;
+  justify-content: space-between;
+  color: #ffd740;
+}
+
+.date-link {
+  color: #ffd740;
+  font-size: 0.95em;
+}
+
+.user-link {
+  color: #ffd740;
+  font-weight: bold;
+  font-size: 1em;
+}
+
+.forum-send-btn {
+  background: #ff7300;
+  color: #fff;
+  border: none;
+  padding: 8px 16px;
+  font-size: 1em;
+  border-radius: 8px;
+  cursor: pointer;
+  transition: background 0.2s;
+}
+
+.forum-send-btn:hover {
+  background: #ffd740;
+}
+
+.forum-owner-actions {
+  margin-top: 8px;
+  text-align: left;
+}
+
+.comment-votes .votes-count {
+  margin-right: 12px;
+}
+
+.new-message-wrapper {
+  margin-top: 16px;
+  padding: 16px;
+  border-radius: 8px;
+}
+
+.highlighted-reply {
+  background-color: #ffe5b4 !important;
+}
+
+.forum-comment.highlighted-reply {
+  background-color: #ffa400 !important;
+  transition: background 0.3s ease;
+}
+
+.forum-comment.level-0 { margin-left: 0; }
+.forum-comment.level-1 { margin-left: 20px; }
+.forum-comment.level-2 { margin-left: 40px; }
+.forum-comment.level-3 { margin-left: 60px; }
+.forum-comment.level-4 { margin-left: 80px; }

+ 30 - 3
src/client/assets/translations/oasis_en.js

@@ -638,7 +638,7 @@ module.exports = {
     trendingItemStatus: "Item Status",
     trendingTotalVotes: "Total Votes",
     trendingTotalOpinions: "Total Opinions",
-    trendingNoContentMessage: "No content available",
+    trendingNoContentMessage: "No trending content available, yet.",
     trendingAuthor: "By",
     trendingCreatedAtLabel: "Created At",
     trendingTotalCount: "Total Count",
@@ -905,6 +905,30 @@ module.exports = {
     blogImage: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
     blogPublish: "Preview",
     noPopularMessages: "No popular messages published, yet",
+    // forum
+    forumTitle: "Forums",
+    forumCategoryLabel: "Category",
+    forumTitleLabel: "Title",
+    forumTitlePlaceholder: "Forum title...",
+    forumCreateButton: "Create forum",
+    forumCreateSectionTitle: "Create forum",
+    forumDescription: "Talk openly with other inhabitants in your network.",
+    forumFilterAll: "ALL",
+    forumFilterMine: "MINE",
+    forumFilterRecent: "RECENT",
+    forumFilterTop: "TOP",
+    forumMineSectionTitle: "Your Forums",
+    forumRecentSectionTitle: "Recent Forums",
+    forumAllSectionTitle: "Forums",
+    forumDeleteButton: "Delete",
+    forumParticipants: "participants",
+    forumMessages: "messages",
+    forumLastMessage: "Last message",
+    forumMessageLabel: "Message",
+    forumMessagePlaceholder: "Write your message...",
+    forumSendButton: "Send",
+    forumVisitForum: "Visit Forum",
+    noForums: "No forums found.",
     // images
     imageTitle: "Images",
     imagePluginTitle: "Title",
@@ -912,7 +936,7 @@ module.exports = {
     imageFileLabel: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
     imageDescription: "Discover and manage images in your network.",
     imageMineSectionTitle: "Your Images",
-    imageCreateSectionTitle: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    imageCreateSectionTitle: "Upload Image",
     imageUpdateSectionTitle: "Update Image",
     imageAllSectionTitle: "Images",
     imageFilterAll: "ALL",
@@ -1011,6 +1035,7 @@ module.exports = {
     typeTransfer:         "TRANSFERS",
     typeTask:             "TASKS",
     typePixelia: 	  "PIXELIA",
+    typeForum: 	          "FORUMS",
     typeReport:           "REPORTS",
     typeFeed:             "FEED",
     typeContact:          "CONTACT",
@@ -1421,7 +1446,9 @@ module.exports = {
     modulesAgendaLabel: "Agenda",
     modulesAgendaDescription: "Module to manage all your assigned items.",
     modulesAILabel: "AI",
-    modulesAIDescription: "Module to talk with a LLM called '42'."   
+    modulesAIDescription: "Module to talk with a LLM called '42'.",
+    modulesForumLabel: "Forums",
+    modulesForumDescription: "Module to discover and manage forums.",
      
      //END
     }

+ 29 - 2
src/client/assets/translations/oasis_es.js

@@ -637,7 +637,7 @@ module.exports = {
     trendingItemStatus: "Estado del Artículo",
     trendingTotalVotes: "Total de Votos",
     trendingTotalOpinions: "Total de Opiniones",
-    trendingNoContentMessage: "No hay contenido disponible",
+    trendingNoContentMessage: "No hay tendencias disponibles, aún.",
     trendingAuthor: "Por",
     trendingCreatedAtLabel: "Creado el",
     trendingTotalCount: "Total de Contadores",
@@ -904,6 +904,30 @@ module.exports = {
     blogImage: "Subir Imagen (jpeg, jpg, png, gif) (tamaño máximo: 500px x 400px)",
     blogPublish: "Vista previa",
     noPopularMessages: "No se han publicado mensajes populares, aún",
+    // forum
+    forumTitle: "Foros",
+    forumCategoryLabel: "Categoría",
+    forumTitleLabel: "Título",
+    forumTitlePlaceholder: "Título del foro...",
+    forumCreateButton: "Crear foro",
+    forumCreateSectionTitle: "Crear foro",
+    forumDescription: "Habla abiertamente con otras habitantes de tu red.",
+    forumFilterAll: "TODOS",
+    forumFilterMine: "MIAS",
+    forumFilterRecent: "RECIENTES",
+    forumFilterTop: "TOP",
+    forumMineSectionTitle: "Tus Foros",
+    forumRecentSectionTitle: "Foros Recientes",
+    forumAllSectionTitle: "Foros",
+    forumDeleteButton: "Borrar",
+    forumParticipants: "participantes",
+    forumMessages: "mensajes",
+    forumLastMessage: "Último mensaje",
+    forumMessageLabel: "Mensaje",
+    forumMessagePlaceholder: "Escribe tu mensaje...",
+    forumSendButton: "Enviar",
+    forumVisitForum: "Visitar Foro",
+    noForums: "No hay foros disponibles, aún.",
     // images
     imageTitle: "Imágenes",
     imagePluginTitle: "Título",
@@ -1010,6 +1034,7 @@ module.exports = {
     typeTransfer:         "TRANSFERENCIAS",
     typeTask:             "TAREAS",
     typePixelia:          "PIXELIA",
+    typeForum: 	          "FOROS",
     typeReport:           "INFORMES",
     typeFeed:             "FEED",
     typeContact:          "CONTACTO",
@@ -1420,7 +1445,9 @@ module.exports = {
     modulesAgendaLabel: "Agenda",
     modulesAgendaDescription: "Módulo para gestionar todos tus elementos asignados.",
     modulesAILabel: "AI",
-    modulesAIDescription: "Módulo para hablar con un LLM llamado '42'."   
+    modulesAIDescription: "Módulo para hablar con un LLM llamado '42'.",
+    modulesForumLabel: "Foros",
+    modulesForumDescription: "Módulo para descubrir y gestionar foros.",
      
      //END
     }

+ 28 - 1
src/client/assets/translations/oasis_eu.js

@@ -905,6 +905,30 @@ module.exports = {
     blogImage: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
     blogPublish: "Aurrebista",
     noPopularMessages: "Pil-pileko mezurike ez, oraindik",
+    // forum
+    forumTitle: "Foroak",
+    forumCategoryLabel: "Kategoria",
+    forumTitleLabel: "Izenburua",
+    forumTitlePlaceholder: "Foroaren izenburua...",
+    forumCreateButton: "Sortu foroa",
+    forumCreateSectionTitle: "Sortu foroa",
+    forumDescription: "Hitz egin sareko beste erabiltzaileekin libreki.",
+    forumFilterAll: "GUZTIAK",
+    forumFilterMine: "NIREAK",
+    forumFilterRecent: "BERRIENAK",
+    forumFilterTop: "GORENAK",
+    forumMineSectionTitle: "Zure Foroak",
+    forumRecentSectionTitle: "Azken Foroak",
+    forumAllSectionTitle: "Foroak",
+    forumDeleteButton: "Ezabatu",
+    forumParticipants: "parte-hartzaileak",
+    forumMessages: "mezuak",
+    forumLastMessage: "Azken mezua",
+    forumMessageLabel: "Mezua",
+    forumMessagePlaceholder: "Idatzi zure mezua...",
+    forumSendButton: "Bidali",
+    forumVisitForum: "Bisitatu Foroaren",
+    noForums: "Ez da fororik aurkitu.",
     // images
     imageTitle: "Irudiak",
     imagePluginTitle: "Izenburua",
@@ -1012,6 +1036,7 @@ module.exports = {
     typeTransfer:    "TRANSFERENTZIAK",
     typeTask:        "ATAZAK",
     typePixelia:     "PIXELIA",
+    typeForum: 	     "FOROAK",
     typeReport:      "TXOSTENAK",
     typeFeed:        "JARIOAK",
     typeContact:     "KONTAKTUA",
@@ -1421,7 +1446,9 @@ module.exports = {
     modulesAgendaLabel: "Agenda",
     modulesAgendaDescription: "Esleitu zaizkizun elementu guztiak kudeatzeko modulua.",
     modulesAILabel: "AI",
-    modulesAIDescription: "'42' izeneko LLM batekin hitz egiteko modulua."
+    modulesAIDescription: "'42' izeneko LLM batekin hitz egiteko modulua.",
+    modulesForumLabel: "Foroak",
+    modulesForumDescription: "Foroak deskubritu eta kudeatzeko modulua."
 
      //END
   }

+ 2 - 1
src/configs/config-manager.js

@@ -37,7 +37,8 @@ if (!fs.existsSync(configFilePath)) {
       "feedMod": "on",
       "pixeliaMod": "on",
       "agendaMod": "on",
-      "aiMod": "on"
+      "aiMod": "on",
+      "forumMod": "on",
     },
     "wallet": {
       "url": "http://localhost:7474",

+ 2 - 1
src/configs/oasis-config.json

@@ -31,7 +31,8 @@
     "feedMod": "on",
     "pixeliaMod": "on",
     "agendaMod": "on",
-    "aiMod": "on"
+    "aiMod": "on",
+    "forumMod": "on"
   },
   "wallet": {
     "url": "http://localhost:7474",

+ 35 - 40
src/models/activity_model.js

@@ -1,64 +1,59 @@
-const pull = require('../server/node_modules/pull-stream')
+const pull = require('../server/node_modules/pull-stream');
 
 module.exports = ({ cooler }) => {
-  let ssb
+  let ssb;
 
   const openSsb = async () => {
-    if (!ssb) ssb = await cooler.open()
-    return ssb
-  }
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
 
   return {
     async listFeed(filter = 'all') {
-      const ssbClient = await openSsb()
-      const userId = ssbClient.id
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
 
       const results = await new Promise((resolve, reject) => {
         pull(
           ssbClient.createLogStream({ reverse: true, limit: 1000 }),
           pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
-        )
-      })
+        );
+      });
 
-      const tombstoned = new Set()
-      const replaces = new Map()
-      const latest = new Map()
+      const tombstoned = new Set();
+      const replaces = new Map();
+      const latest = new Map();
 
       for (const msg of results) {
-        const k = msg.key
-        const c = msg.value?.content
-        const author = msg.value?.author
-        if (!c?.type) continue
+        const k = msg.key;
+        const c = msg.value?.content;
+        const author = msg.value?.author;
+        if (!c?.type) continue;
         if (c.type === 'tombstone' && c.target) {
-          tombstoned.add(c.target)
-          continue
+          tombstoned.add(c.target);
+          continue;
         }
-        if (c.replaces) replaces.set(c.replaces, k)
-        latest.set(k, {
-          id: k,
-          author,
-          ts: msg.value.timestamp,
-          type: c.type,
-          content: c
-        })
+        if (c.replaces) replaces.set(c.replaces, k);
+        latest.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
       }
 
-      for (const oldId of replaces.keys()) {
-        latest.delete(oldId)
-      }
-
-      for (const t of tombstoned) {
-        latest.delete(t)
-      }
+      for (const oldId of replaces.keys()) latest.delete(oldId);
+      for (const t of tombstoned) latest.delete(t);
 
-      let actions = Array.from(latest.values())
+      const actions = Array.from(latest.values()).filter(a =>
+        a.type !== 'tombstone' &&
+        !tombstoned.has(a.id) &&
+        !(a.content?.root && tombstoned.has(a.content.root)) &&
+        !(a.type === 'vote' && tombstoned.has(a.content.vote.link))
+      );
 
-      if (filter === 'mine') {
-        actions = actions.filter(a => a.author === userId)
-      }
+      if (filter === 'mine')
+        return actions
+          .filter(a => a.author === userId)
+          .sort((a, b) => b.ts - a.ts);
 
-      return actions
+      return actions.sort((a, b) => b.ts - a.ts);
     }
-  }
-}
+  };
+};
 

+ 282 - 0
src/models/forum_model.js

@@ -0,0 +1,282 @@
+const pull = require('../server/node_modules/pull-stream');
+
+module.exports = ({ cooler }) => {
+  let ssb, userId;
+
+  const openSsb = async () => {
+    if (!ssb) {
+      ssb = await cooler.open();
+      userId = ssb.id;
+    }
+    return ssb;
+  };
+
+  async function collectTombstones(ssbClient) {
+    return new Promise((resolve, reject) => {
+      const tomb = new Set();
+      pull(
+        ssbClient.createLogStream(),
+        pull.filter(m => m.value.content?.type === 'tombstone' && m.value.content.target),
+        pull.drain(m => tomb.add(m.value.content.target), err => err ? reject(err) : resolve(tomb))
+      );
+    });
+  }
+
+  async function findActiveVote(ssbClient, targetId, voter) {
+    const tombstoned = await collectTombstones(ssbClient);
+    return new Promise((resolve, reject) => {
+      pull(
+        ssbClient.links({ source: voter, dest: targetId, rel: 'vote', values: true, keys: true }),
+        pull.filter(link => !tombstoned.has(link.key)),
+        pull.collect((err, links) => err ? reject(err) : resolve(links))
+      );
+    });
+  }
+
+  async function aggregateVotes(ssbClient, targetId) {
+    const tombstoned = await collectTombstones(ssbClient);
+    return new Promise((resolve, reject) => {
+      let positives = 0, negatives = 0;
+      pull(
+        ssbClient.links({ source: null, dest: targetId, rel: 'vote', values: true, keys: true }),
+        pull.filter(link => link.value.content?.vote && !tombstoned.has(link.key)),
+        pull.drain(
+          link => link.value.content.vote.value > 0 ? positives++ : negatives++,
+          err => err ? reject(err) : resolve({ positives, negatives })
+        )
+      );
+    });
+  }
+
+  function nestReplies(flat) {
+    const lookup = new Map();
+    const roots = [];
+    for (const msg of flat) {
+      msg.children = [];
+      lookup.set(msg.key, msg);
+    }
+    for (const msg of flat) {
+      if (msg.parent && lookup.has(msg.parent)) {
+        lookup.get(msg.parent).children.push(msg);
+      } else {
+        roots.push(msg);
+      }
+    }
+    return roots;
+  }
+
+  async function getMessageById(id) {
+    const ssbClient = await openSsb();
+    const msgs = await new Promise((res, rej) =>
+      pull(ssbClient.createLogStream(), pull.collect((err, data) => err ? rej(err) : res(data)))
+    );
+    const msg = msgs.find(m => m.key === id && m.value.content?.type === 'forum');
+    if (!msg) throw new Error('Message not found');
+    return { key: msg.key, ...msg.value.content, timestamp: msg.value.timestamp };
+  }
+
+  return {
+    createForum: async (category, title, text) => {
+      const ssbClient = await openSsb();
+      const content = {
+        type: 'forum',
+        category,
+        title,
+        text,
+        createdAt: new Date().toISOString(),
+        author: userId,
+        votes: { positives: 0, negatives: 0 },
+        votes_inhabitants: []
+      };
+      return new Promise((resolve, reject) =>
+        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve({ key: res.key, ...content }))
+      );
+    },
+
+    addMessageToForum: async (forumId, message, parentId = null) => {
+      const ssbClient = await openSsb();
+      const content = {
+        ...message,
+        root: forumId,
+        type: 'forum',
+        author: userId,
+        timestamp: new Date().toISOString(),
+        votes: { positives: 0, negatives: 0 },
+        votes_inhabitants: []
+      };
+      if (parentId) content.branch = parentId;
+      return new Promise((resolve, reject) =>
+        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res))
+      );
+    },
+
+    voteContent: async (targetId, value) => {
+      const ssbClient = await openSsb();
+      const whoami = await new Promise((res, rej) =>
+        ssbClient.whoami((err, info) => err ? rej(err) : res(info))
+      );
+      const voter = whoami.id;
+      const newVal = parseInt(value, 10);
+      const existing = await findActiveVote(ssbClient, targetId, voter);
+      if (existing.length > 0) {
+        const prev = existing[0].value.content.vote.value;
+        if (prev === newVal) return existing[0];
+        await new Promise((resolve, reject) =>
+          ssbClient.publish(
+            { type: 'tombstone', target: existing[0].key, timestamp: new Date().toISOString(), author: voter },
+            err => err ? reject(err) : resolve()
+          )
+        );
+      }
+      return new Promise((resolve, reject) =>
+        ssbClient.publish(
+          {
+            type: 'vote',
+            vote: { link: targetId, value: newVal },
+            timestamp: new Date().toISOString(),
+            author: voter
+          },
+          (err, result) => err ? reject(err) : resolve(result)
+        )
+      );
+    },
+
+    deleteForumById: async id => {
+      const ssbClient = await openSsb();
+      return new Promise((resolve, reject) =>
+        ssbClient.publish(
+          { type: 'tombstone', target: id, timestamp: new Date().toISOString(), author: userId },
+          (err, res) => err ? reject(err) : resolve(res)
+        )
+      );
+    },
+
+    listAll: async filter => {
+      const ssbClient = await openSsb();
+      const msgs = await new Promise((res, rej) =>
+        pull(ssbClient.createLogStream(), pull.collect((err, data) => err ? rej(err) : res(data)))
+      );
+      const deleted = new Set(
+        msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
+      );
+      const forums = msgs
+        .filter(m => m.value.content?.type === 'forum' && !m.value.content.root && !deleted.has(m.key))
+        .map(m => ({ ...m.value.content, key: m.key }));
+      const forumsWithVotes = await Promise.all(
+        forums.map(async f => {
+          const { positives, negatives } = await aggregateVotes(ssbClient, f.key);
+          return { ...f, positiveVotes: positives, negativeVotes: negatives };
+        })
+      );
+      const repliesByRoot = {};
+      msgs.forEach(m => {
+        const c = m.value.content;
+        if (c?.type === 'forum' && c.root && !deleted.has(m.key)) {
+          repliesByRoot[c.root] = repliesByRoot[c.root] || [];
+          repliesByRoot[c.root].push({ key: m.key, text: c.text, author: c.author, timestamp: m.value.timestamp });
+        }
+      });
+      const final = await Promise.all(
+        forumsWithVotes.map(async f => {
+          const replies = repliesByRoot[f.key] || [];
+          for (let r of replies) {
+            const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key);
+            r.positiveVotes = rp;
+            r.negativeVotes = rn;
+            r.score = rp - rn;
+          }
+          const replyPos = replies.reduce((sum, r) => sum + (r.positiveVotes || 0), 0);
+          const replyNeg = replies.reduce((sum, r) => sum + (r.negativeVotes || 0), 0);
+          const positiveVotes = f.positiveVotes + replyPos;
+          const negativeVotes = f.negativeVotes + replyNeg;
+          const score = positiveVotes - negativeVotes;
+          const participants = new Set(replies.map(r => r.author).concat(f.author));
+          const messagesCount = replies.length + 1;
+          const lastMessage =
+            replies.length
+              ? replies.reduce((a, b) => (new Date(a.timestamp) > new Date(b.timestamp) ? a : b))
+              : null;
+          return {
+            ...f,
+            positiveVotes,
+            negativeVotes,
+            score,
+            participants: Array.from(participants),
+            messagesCount,
+            lastMessage,
+            messages: replies
+          };
+        })
+      );
+      const filtered =
+        filter === 'mine'
+          ? final.filter(f => f.author === userId)
+          : filter === 'recent'
+          ? final.filter(f => new Date(f.createdAt).getTime() >= Date.now() - 86400000)
+          : final;
+      return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+    },
+
+    getForumById: async id => {
+      const ssbClient = await openSsb();
+      const msgs = await new Promise((res, rej) =>
+        pull(ssbClient.createLogStream(), pull.collect((err, data) => err ? rej(err) : res(data)))
+      );
+      const deleted = new Set(
+        msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
+      );
+      const original = msgs.find(m => m.key === id && !deleted.has(m.key));
+      if (!original || original.value.content?.type !== 'forum') throw new Error('Forum not found');
+      const base = original.value.content;
+      const { positives, negatives } = await aggregateVotes(ssbClient, id);
+      return {
+        ...base,
+        key: id,
+        positiveVotes: positives,
+        negativeVotes: negatives,
+        score: positives - negatives
+      };
+    },
+
+    getMessagesByForumId: async forumId => {
+      const ssbClient = await openSsb();
+      const msgs = await new Promise((res, rej) =>
+        pull(ssbClient.createLogStream(), pull.collect((err, data) => err ? rej(err) : res(data)))
+      );
+      const deleted = new Set(
+        msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
+      );
+      const replies = msgs
+        .filter(m => m.value.content?.type === 'forum' && m.value.content.root === forumId && !deleted.has(m.key))
+        .map(m => ({
+          key: m.key,
+          text: m.value.content.text,
+          author: m.value.content.author,
+          timestamp: m.value.timestamp,
+          parent: m.value.content.branch || null
+        }));
+      for (let r of replies) {
+        const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key);
+        r.positiveVotes = rp;
+        r.negativeVotes = rn;
+        r.score = rp - rn;
+      }
+      const { positives: p, negatives: n } = await aggregateVotes(ssbClient, forumId);
+      const replyPos = replies.reduce((sum, r) => sum + (r.positiveVotes || 0), 0);
+      const replyNeg = replies.reduce((sum, r) => sum + (r.negativeVotes || 0), 0);
+      const positiveVotes = p + replyPos;
+      const negativeVotes = n + replyNeg;
+      const totalScore = positiveVotes - negativeVotes;
+      return {
+        messages: nestReplies(replies),
+        total: replies.length,
+        positiveVotes,
+        negativeVotes,
+        totalScore
+      };
+    },
+
+    getMessageById
+  };
+};
+

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

@@ -1,12 +1,12 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.3.9",
+  "version": "0.4.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@krakenslab/oasis",
-      "version": "0.3.8",
+      "version": "0.4.0",
       "hasInstallScript": true,
       "license": "AGPL-3.0",
       "dependencies": {
@@ -9694,9 +9694,9 @@
       }
     },
     "node_modules/form-data": {
-      "version": "4.0.3",
-      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
-      "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+      "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
       "license": "MIT",
       "dependencies": {
         "asynckit": "^0.4.0",
@@ -12673,9 +12673,9 @@
       "peer": true
     },
     "node_modules/koa": {
-      "version": "2.16.1",
-      "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.1.tgz",
-      "integrity": "sha512-umfX9d3iuSxTQP4pnzLOz0HKnPg0FaUUIKcye2lOiz3KPu1Y3M3xlz76dISdFPQs37P9eJz1wUpcTS6KDPn9fA==",
+      "version": "2.16.2",
+      "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.2.tgz",
+      "integrity": "sha512-+CCssgnrWKx9aI3OeZwroa/ckG4JICxvIFnSiOUyl2Uv+UTI+xIw0FfFrWS7cQFpoePpr9o8csss7KzsTzNL8Q==",
       "license": "MIT",
       "dependencies": {
         "accepts": "^1.3.5",

+ 1 - 1
src/server/package.json

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

+ 3 - 2
src/views/AI_view.js

@@ -1,5 +1,6 @@
 const { div, h2, p, section, button, form, textarea, br, span } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
+const { renderUrl } = require('../backend/renderUrl');
 
 exports.aiView = (history = [], userPrompt = '') => {
   return template(
@@ -61,7 +62,7 @@ exports.aiView = (history = [], userPrompt = '') => {
             br(),br(),
             div({ class: 'user-question', style: 'margin-bottom: 0.75em;' },
               h2(`${i18n.aiUserQuestion}:`),
-              p(entry.question)
+              p( ...renderUrl(entry.question))
             ),
             div({
               class: 'ai-response',
@@ -83,7 +84,7 @@ exports.aiView = (history = [], userPrompt = '') => {
                   paragraph
                     .split('\n')
                     .map(line =>
-                      p({ style: "margin-bottom: 1.2em;" }, line.trim())
+                      p({ style: "margin-bottom: 1.2em;" }, ...renderUrl(line.trim()))
                 )
               )
             )

+ 114 - 68
src/views/activity_view.js

@@ -1,6 +1,7 @@
 const { div, h2, p, section, button, form, a, input, img, textarea, br, span, video: videoHyperaxe, audio: audioHyperaxe, table, tr, td, th } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 const moment = require("../server/node_modules/moment");
+const { renderUrl } = require('../backend/renderUrl');
 
 function capitalize(str) {
   return typeof str === 'string' && str.length ? str[0].toUpperCase() + str.slice(1) : '';
@@ -62,27 +63,23 @@ function renderActionCards(actions) {
       cardBody.push(
         div({ class: 'card-section transfer' }, 
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.concept + ':'), span({ class: 'card-value' }, concept)),
-          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
-          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.amount + ':'), span({ class: 'card-value' }, amount)),
-          br,
-          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.from + ': '), a({ class: 'user-link', href: `/author/${encodeURIComponent(from)}`, target: "_blank" }, from)),
-          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.to + ': '), a({ class: 'user-link', href: `/author/${encodeURIComponent(to)}`, target: "_blank" }, to)),
-          div({ class: 'card-field' }, h2({ class: 'card-label' }, i18n.transfersConfirmations + ': ' + `${confirmedBy.length}/2`)),
+          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
+          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status))
         )
       );
     }
 
-	if (type === 'pixelia') {
-	  const { author } = content;
-	  cardBody.push(
-	    div({ class: 'card-section pixelia' },
-	      div({ class: 'card-field' },
-		a({ href: `/author/${encodeURIComponent(author)}`, class: 'activityVotePost' }, author)
-	      )
-	    )
-	  );
-	}
+    if (type === 'pixelia') {
+       const { author } = content;
+       cardBody.push(
+	 div({ class: 'card-section pixelia' },
+	   div({ class: 'card-field' },
+	      a({ href: `/author/${encodeURIComponent(author)}`, class: 'activityVotePost' }, author)
+	   )
+	 )
+       );
+    }
 
     if (type === 'tribe') {
       const { title, image, description, tags, isLARP, inviteMode, isAnonymous, members } = content;
@@ -92,15 +89,15 @@ function renderActionCards(actions) {
 	h2({ class: 'tribe-title' }, 
 	  a({ href: `/tribe/${encodeURIComponent(action.id)}`, class: "user-link" }, title)
 	),
-        p({ class: 'tribe-description' }, description || ''),
-          image
-            ? img({ src: `/blob/${encodeURIComponent(image)}`, class: 'feed-image tribe-image' })
-            : img({ src: '/assets/images/default-tribe.png', class: 'feed-image tribe-image' }),
-          br(),
           typeof isAnonymous === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeIsAnonymousLabel+ ':'), span({ class: 'card-value' }, isAnonymous ? i18n.tribePrivate : i18n.tribePublic)) : "",      
           inviteMode ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.tribeModeLabel) + ':'), span({ class: 'card-value' }, inviteMode.toUpperCase())) : "",
           typeof isLARP === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLARPLabel+ ':'), span({ class: 'card-value' }, isLARP ? i18n.tribeYes : i18n.tribeNo)) : "",
           Array.isArray(members) ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.tribeMembersCount) + ':'), span({ class: 'card-value' }, members.length)) : "",
+          br(),
+          image
+            ? img({ src: `/blob/${encodeURIComponent(image)}`, class: 'feed-image tribe-image' })
+            : img({ src: '/assets/images/default-tribe.png', class: 'feed-image tribe-image' }),
+          p({ class: 'tribe-description' }, ...renderUrl(description || '')),
           validTags.length
             ? div({ class: 'card-tags' }, validTags.map(tag =>
               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
@@ -110,18 +107,38 @@ function renderActionCards(actions) {
     }
 
     if (type === 'curriculum') {
-      const { author, name, description, photo, personalSkills, oasisSkills, educationalSkills, languages, professionalSkills, status, preferences} = content;
+      const { author, name, description, photo, personalSkills, oasisSkills, educationalSkills, languages, professionalSkills, status, preferences, createdAt, updatedAt} = content;
       cardBody.push(
         div({ class: 'card-section curriculum' },
           h2(a({ href: `/author/${encodeURIComponent(author)}`, class: "user-link" }, `@`, name)),
+          div(
+          { class: 'card-fields-container' },
+	  createdAt ? 
+	   div(
+	    { class: 'card-field' },
+	    span({ class: 'card-label' }, i18n.cvCreatedAt + ':'),
+	    span({ class: 'card-value' }, moment(createdAt).format('YYYY-MM-DD HH:mm:ss'))
+	  ) 
+	  : "",
+	  updatedAt ? 
+	  div(
+	    { class: 'card-field' },
+	    span({ class: 'card-label' }, i18n.cvUpdatedAt + ':'),
+	    span({ class: 'card-value' }, moment(updatedAt).format('YYYY-MM-DD HH:mm:ss'))
+	  ) 
+     	  : ""
+     	  ),
           status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvStatusLabel + ':'), span({ class: 'card-value' }, status)) : "",
           preferences ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.cvPreferencesLabel || 'Preferences') + ':'), span({ class: 'card-value' }, preferences)) : "",
-          languages ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.cvLanguagesLabel || 'Languages') + ':'), span({ class: 'card-value' }, languages)) : "",
-          br(),
-          photo ? img({ class: "cv-photo", src: `/blob/${encodeURIComponent(photo)}` }) : "",
-          br(),
-          description ? div({ class: 'card-field' }, span({ class: 'card-value' }, description)) : "",
-          br(),
+          languages ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.cvLanguagesLabel || 'Languages') + ':'), span({ class: 'card-value' }, languages.toUpperCase())) : "",
+	  photo ? 
+	  [
+	    br(),
+	    img({ class: "cv-photo", src: `/blob/${encodeURIComponent(photo)}` }),
+	    br()
+	  ]
+	: "",
+	  p(...renderUrl(description || "")),
 	  personalSkills && personalSkills.length
 	  ? div({ class: 'card-tags' }, personalSkills.map(skill =>
 	      a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`)
@@ -205,30 +222,25 @@ function renderActionCards(actions) {
     }
 
     if (type === 'bookmark') {
-      const { url, description, lastVisit } = content;
+      const { url } = content;
       cardBody.push(
         div({ class: 'card-section bookmark' },       
-          description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ':'), span({ class: 'card-value' }, description)) : "",
-          h2(url ? 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())) : ""
+          h2(url ? p(a({ href: url, target: '_blank', class: "bookmark-url" }, url)) : "")
         )
       );
     }
 
     if (type === 'event') {
-      const { title, description, date, location, price, url: eventUrl, attendees, organizer, status, isPublic } = content;
+      const { title, description, date, location, price, attendees, organizer, isPublic } = content;
       cardBody.push(
         div({ class: 'card-section event' },    
-          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, title)),
-          description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.description + ':'), span({ class: 'card-value' }, description)) : "",
-          date ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.date + ':'), span({ class: 'card-value' }, new Date(date).toLocaleString())) : "",
-          location ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.location || 'Location') + ':'), span({ class: 'card-value' }, location)) : "",
-          status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)) : "",
-          typeof isPublic === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.isPublic || 'Public') + ':'), span({ class: 'card-value' }, isPublic ? 'Yes' : 'No')) : "",
-          price ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.price || 'Price') + ':'), span({ class: 'card-value' }, price + " ECO")) : "",
-          eventUrl ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingUrl + ':'), a({ href: eventUrl, target: '_blank' }, eventUrl)) : "",
-          br,
-          organizer ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.organizer || 'Organizer') + ': '), a({ class: "user-link", href: `/author/${encodeURIComponent(organizer)}` }, organizer)) : "",
+        div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, title)),
+        date ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.date + ':'), span({ class: 'card-value' }, new Date(date).toLocaleString())) : "",
+        location ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.location || 'Location') + ':'), span({ class: 'card-value' }, location)) : "",
+        typeof isPublic === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.isPublic || 'Public') + ':'), span({ class: 'card-value' }, isPublic ? 'Yes' : 'No')) : "",
+        price ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.price || 'Price') + ':'), span({ class: 'card-value' }, price + " ECO")) : "",
+        br,
+        organizer ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.organizer || 'Organizer') + ': '), a({ class: "user-link", href: `/author/${encodeURIComponent(organizer)}` }, organizer)) : "",
           Array.isArray(attendees) ? h2({ class: 'card-label' }, (i18n.attendees || 'Attendees') + ': ' + attendees.length) : "",   
         )
       );
@@ -239,19 +251,20 @@ function renderActionCards(actions) {
       cardBody.push(
         div({ class: 'card-section task' },
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, title)),
-          status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)) : "",
           priority ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.priority || 'Priority') + ':'), span({ class: 'card-value' }, priority)) : "",
           startTime ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.taskStartTimeLabel || 'Start') + ':'), span({ class: 'card-value' }, new Date(startTime).toLocaleString())) : "",
           endTime ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.taskEndTimeLabel || 'End') + ':'), span({ class: 'card-value' }, new Date(endTime).toLocaleString())) : "",
+          status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)) : "",
         )
       );
     }
 
     if (type === 'feed') {
+      const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
       const { text, refeeds } = content;
       cardBody.push(
         div({ class: 'card-section feed' }, 
-          h2({ class: 'feed-title' }, text),
+          div({ class: 'feed-text', innerHTML: renderTextWithStyles(text) }),
           h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '), span({ class: 'card-label' }, refeeds))
         )
       );
@@ -266,6 +279,36 @@ function renderActionCards(actions) {
         )
       );
     }
+    
+    if (type === 'forum') {
+        const { root, category, title, text, key } = content;
+        if (!root) {
+            cardBody.push(
+                div({ class: 'card-section forum' },
+                    div({ class: 'card-field', style: "font-size:1.12em; margin-bottom:5px;" },
+                        span({ class: 'card-label', style: "font-weight:800;color:#ff9800;" }, i18n.title + ': '),
+                        a({ href: `/forum/${encodeURIComponent(key || action.id)}`, style: "font-weight:800;color:#4fc3f7;" }, title)
+                    ),
+                )
+            )
+        } else {
+            let parentForum = actions.find(a => a.type === 'forum' && !a.content.root && (a.id === root || a.content.key === root));
+            let parentCategory = parentForum?.content?.category || '';
+            let parentTitle = parentForum?.content?.title || '';
+            cardBody.push(
+                div({ class: 'card-section forum' },
+                    div({ class: 'card-field', style: "font-size:1.12em; margin-bottom:5px;" },
+                        span({ class: 'card-label', style: "font-weight:800;color:#ff9800;" }, i18n.title + ': '),
+                        a({ href: `/forum/${encodeURIComponent(root)}`, style: "font-weight:800;color:#4fc3f7;" }, parentTitle)
+                    ),
+                    br(),
+                    div({ class: 'card-field', style: 'margin-bottom:12px;' },
+                        p({ style: "margin:0 0 8px 0; word-break:break-all;" }, ...renderUrl(text))
+                    )
+                )
+            )
+        }
+    }
 
     if (type === 'vote') {
       const { vote } = content;
@@ -279,7 +322,7 @@ function renderActionCards(actions) {
 	}
 
     if (type === 'about') {
-      const { about, name, description, image } = content;
+      const { about, name, image } = content;
       cardBody.push(
         div({ class: 'card-section about' },
         h2(a({ href: `/author/${encodeURIComponent(about)}`, class: "user-link" }, `@`, name)),
@@ -290,28 +333,28 @@ function renderActionCards(actions) {
       );
     }
 
-	if (type === 'contact') {
-	  const { contact } = content;
-	  cardBody.push(
-	    div({ class: 'card-section contact' },
-	      p({ class: 'card-field' }, 
-		a({ href: `/author/${encodeURIComponent(contact)}`, class: 'activitySpreadInhabitant2' }, contact)
-	      )
-	    )
-	  );
-	}
+    if (type === 'contact') {
+      const { contact } = content;
+      cardBody.push(
+	div({ class: 'card-section contact' },
+	   p({ class: 'card-field' }, 
+            a({ href: `/author/${encodeURIComponent(contact)}`, class: 'activitySpreadInhabitant2' }, contact)
+	   )
+	 )
+      );
+     }
 
-	if (type === 'pub') {
-	  const { address } = content;
-	  const { host, key } = address;
-	  cardBody.push(
-	    div({ class: 'card-section pub' },
-	      p({ class: 'card-field' },
-		a({ href: `/author/${encodeURIComponent(key)}`, class: 'activitySpreadInhabitant2' }, key)
-	      )
-	    )
-	  );
-	}
+    if (type === 'pub') {
+      const { address } = content;
+      const { host, key } = address;
+      cardBody.push(
+        div({ class: 'card-section pub' },
+	   p({ class: 'card-field' },
+            a({ href: `/author/${encodeURIComponent(key)}`, class: 'activitySpreadInhabitant2' }, key)
+	   )
+        )
+      );
+    }
 
     if (type === 'market') {
       const { item_type, title, price, status, deadline, stock, image, auctions_poll } = content;
@@ -320,12 +363,12 @@ function renderActionCards(actions) {
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemTitle + ':'), span({ class: 'card-value' }, title)),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemType + ':'), span({ class: 'card-value' }, item_type.toUpperCase())),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStatus + ": " ), span({ class: 'card-value' }, status.toUpperCase())),
-          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, stock)),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : "")),
           br,
           image
             ? img({ src: `/blob/${encodeURIComponent(image)}` })
             : img({ src: '/assets/images/default-market.png', alt: title }),
+          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, stock)),
           br,
           div({ class: "market-card price" },
             p(`${i18n.marketItemPrice}: ${price} ECO`)
@@ -405,6 +448,8 @@ function getViewDetailsAction(type, action) {
     case 'image': return `/images/${encodeURIComponent(action.id)}`;
     case 'audio': return `/audios/${encodeURIComponent(action.id)}`;
     case 'video': return `/videos/${encodeURIComponent(action.id)}`;
+    case 'forum': 
+      return `/forum/${encodeURIComponent(action.content?.key || action.id)}`;
     case 'document': return `/documents/${encodeURIComponent(action.id)}`;
     case 'bookmark': return `/bookmarks/${encodeURIComponent(action.id)}`;
     case 'event': return `/events/${encodeURIComponent(action.id)}`;
@@ -439,6 +484,7 @@ exports.activityView = (actions, filter, userId) => {
     { type: 'feed', label: i18n.typeFeed },
     { type: 'post', label: i18n.typePost },
     { type: 'pixelia', label: i18n.typePixelia },
+    { type: 'forum', label: i18n.typeForum },
     { type: 'bookmark', label: i18n.typeBookmark },
     { type: 'image', label: i18n.typeImage },
     { type: 'video', label: i18n.typeVideo },

+ 9 - 9
src/views/agenda_view.js

@@ -45,12 +45,11 @@ const renderAgendaItem = (item, userId, filter) => {
   }
   if (item.type === 'market') {
   details = [
-    renderCardField(i18n.marketItemDescription + ":", item.description), 
-    renderCardField(i18n.marketItemType + ":", item.item_type),
+    renderCardField(i18n.marketItemType + ":", item.item_type.toUpperCase()),
     renderCardField(i18n.marketItemStatus + ":", item.status),
+    renderCardField(i18n.marketItemStock + ":", item.stock), 
     renderCardField(i18n.marketItemPrice + ":", `${item.price} ECO`),
     renderCardField(i18n.marketItemIncludesShipping + ":", item.includesShipping ? i18n.agendaYes : i18n.agendaNo),
-    renderCardField(i18n.marketItemStock + ":", item.stock), 
     renderCardField(i18n.deadline + ":", new Date(item.deadline).toLocaleString()),
    ];
     if (item.item_type === 'auction') {
@@ -85,20 +84,23 @@ const renderAgendaItem = (item, userId, filter) => {
 
   if (item.type === 'report') {
     details = [
-      renderCardField(i18n.agendareportDescription + ":", item.description || i18n.noDescription),
       renderCardField(i18n.agendareportStatus + ":", item.status || i18n.noStatus),
       renderCardField(i18n.agendareportCategory + ":", item.category || i18n.noCategory),
-      renderCardField(i18n.agendareportSeverity + ":", item.severity || i18n.noSeverity),
+      renderCardField(i18n.agendareportSeverity + ":", item.severity.toUpperCase() || i18n.noSeverity),
     ];
   }
 
   if (item.type === 'event') {
     details = [
-      renderCardField(i18n.eventDescriptionLabel + ":", item.description),
       renderCardField(i18n.eventDateLabel + ":", fmt(item.date)),
       renderCardField(i18n.eventLocationLabel + ":", item.location),
       renderCardField(i18n.eventPriceLabel + ":", `${item.price} ECO`),
-      renderCardField(i18n.eventUrlLabel + ":", item.url || i18n.noUrl)
+      renderCardField(
+  	i18n.eventUrlLabel + ":",
+ 	 item.url
+   	 ? p(a({href: item.url, target: "_blank" }, item.url))
+         : p(i8n.noUrl)
+         ),
     ];
 
     actionButton = actionButton || form({ method: 'POST', action: `/events/attend/${encodeURIComponent(item.id)}` },
@@ -108,13 +110,11 @@ const renderAgendaItem = (item, userId, filter) => {
 
   if (item.type === 'task') {
     details = [
-      renderCardField(i18n.taskDescriptionLabel + ":", item.description),
       renderCardField(i18n.taskStatus + ":", item.status),
       renderCardField(i18n.taskPriorityLabel + ":", item.priority),
       renderCardField(i18n.taskStartTimeLabel + ":",  new Date(item.startTime).toLocaleString()),
       renderCardField(i18n.taskEndTimeLabel + ":", new Date(item.endTime).toLocaleString()),
       renderCardField(i18n.taskLocationLabel + ":", item.location),
-
     ];
 
     const assigned = Array.isArray(item.assignees) && item.assignees.includes(userId);

+ 5 - 6
src/views/audio_view.js

@@ -1,7 +1,8 @@
-const { form, button, div, h2, p, section, input, label, br, a, audio: audioHyperaxe, span } = require("../server/node_modules/hyperaxe");
+const { form, button, div, h2, p, section, input, label, br, a, audio: audioHyperaxe, span, textarea } = require("../server/node_modules/hyperaxe");
 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 userId = config.keys.id
 
@@ -56,8 +57,7 @@ const renderAudioList = (filteredAudios, filter) => {
                 })
               )
             : p(i18n.audioNoFile),
-          audio.description?.trim() ? renderCardField(`${i18n.audioDescriptionLabel}: `, audio.description) : null,
-          br,
+          p(...renderUrl(audio.description)),
           audio.tags?.length
             ? div({ class: "card-tags" }, 
                 audio.tags.map(tag =>
@@ -99,7 +99,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(),
-      input({ type: "text", name: "description", placeholder: i18n.audioDescriptionPlaceholder, value: audioToEdit?.description || '' }), br(), br(),
+      textarea({name: "description", placeholder: i18n.audioDescriptionPlaceholder, rows:"4", value: audioToEdit?.description || '' }), br(), br(),
       button({ type: "submit" }, filter === 'edit' ? i18n.audioUpdateButton : i18n.audioCreateButton)
     )
   );
@@ -187,8 +187,7 @@ exports.singleAudioView = async (audio, filter) => {
               })
             )
           : p(i18n.audioNoFile),
-        audio.description?.trim() ? renderCardField(`${i18n.audioDescriptionLabel}: `, audio.description) : null,
-        br,
+        p(...renderUrl(audio.description)),
         audio.tags?.length
           ? div({ class: "card-tags" },
               audio.tags.map(tag =>

+ 18 - 9
src/views/bookmark_view.js

@@ -2,6 +2,7 @@ const { form, button, div, h2, p, section, input, label, textarea, br, a, span }
 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 userId = config.keys.id
 
@@ -33,8 +34,9 @@ const renderBookmarkList = (filteredBookmarks, filter) => {
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           ),
           h2(bookmark.title),
-          renderCardField(i18n.bookmarkDescriptionLabel + ":", bookmark.description),
-          renderCardField(i18n.bookmarkUrlLabel + ":", bookmark.url
+          renderCardField(i18n.bookmarkUrlLabel + ":"), 
+          br,
+          div(bookmark.url
             ? a({ href: bookmark.url, target: "_blank", class: "bookmark-url" }, bookmark.url)
             : i18n.noUrl
           ),
@@ -44,8 +46,13 @@ const renderBookmarkList = (filteredBookmarks, filter) => {
           ),
           bookmark.category?.trim()
             ? renderCardField(i18n.bookmarkCategory + ":", bookmark.category)
-            : null,
-          br,
+            : null,  
+	  bookmark.description
+	    ? [
+	      renderCardField(i18n.bookmarkDescriptionLabel + ":"),
+	      p(...renderUrl(bookmark.description))
+	    ]
+	  : null,
           bookmark.tags?.length
             ? div({ class: "card-tags" }, bookmark.tags.map(tag =>
                 a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
@@ -69,7 +76,7 @@ const renderBookmarkList = (filteredBookmarks, filter) => {
 };
 
 const renderBookmarkForm = (filter, bookmarkId, bookmarkToEdit, tags) => {
-  return div({ class: "div-center bookmark-form" },   // <-- No "card" here
+  return div({ class: "div-center bookmark-form" },
     form(
       {
         action: filter === 'edit'
@@ -80,7 +87,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 }, 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,
@@ -186,8 +193,9 @@ exports.singleBookmarkView = async (bookmark, filter) => {
           )
         ) : null,
         h2(bookmark.title),
-        renderCardField(i18n.bookmarkDescriptionLabel + ":", bookmark.description),
-        renderCardField(i18n.bookmarkUrlLabel + ":", bookmark.url
+        renderCardField(i18n.bookmarkUrlLabel + ":"), 
+        br,
+        div(bookmark.url
           ? a({ href: bookmark.url, target: "_blank", class: "bookmark-url" }, bookmark.url)
           : i18n.noUrl
         ),
@@ -196,7 +204,8 @@ exports.singleBookmarkView = async (bookmark, filter) => {
           : i18n.noLastVisit
         ),
         renderCardField(i18n.bookmarkCategory + ":", bookmark.category || i18n.noCategory),
-        br,
+        renderCardField(i18n.bookmarkDescriptionLabel + ":"), 
+        p(...renderUrl(bookmark.description)),
         bookmark.tags && bookmark.tags.length
           ? div({ class: "card-tags" },
               bookmark.tags.map(tag =>

+ 22 - 18
src/views/cv_view.js

@@ -1,5 +1,6 @@
 const { form, button, div, h2, p, section, textarea, label, input, br, img, a, select, option } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
+const { renderUrl } = require('../backend/renderUrl');
 
 const generateCVBox = (label, content, className) => {
   return div({ class: `cv-box ${className}` }, 
@@ -43,7 +44,7 @@ exports.createCVView = async (cv = {}, editMode = false) => {
             label(i18n.cvNameLabel), br(),
             input({ type: "text", name: "name", required: true, value: cv.name || "" }), br(),
             label(i18n.cvDescriptionLabel), br(),
-            textarea({ name: "description", required: true }, cv.description || ""), br(),
+            textarea({ name: "description", required: true, rows: 4  }, cv.description || ""), br(),
             label(i18n.cvLanguagesLabel), br(),
             input({ type: "text", name: "languages", value: cv.languages || "" }), br(),
             label(i18n.cvPhotoLabel), br(),
@@ -156,8 +157,7 @@ exports.cvView = async (cv) => {
               : null,
             cv.name ? h2(`${cv.name}`) : null,
             cv.contact ? p(a({ class: "user-link", href: `/author/${encodeURIComponent(cv.contact)}` }, cv.contact)) : null,
-            cv.description ? p(`${cv.description}`) : null,
-            cv.languages ? p(`${i18n.cvLanguagesLabel}: ${cv.languages}`) : null,
+            cv.description ? p(...renderUrl(`${cv.description}`)) : null,
             (cv.personalSkills && cv.personalSkills.length)
               ? div(
                   cv.personalSkills.map(tag =>
@@ -170,12 +170,16 @@ exports.cvView = async (cv) => {
                 )
               : null
           ]) : null,
-          hasOasis ? div({ class: "cv-box oasis" }, ...[
-            h2(i18n.cvOasisContributorView),
-            p(`${cv.oasisExperiences}`),
-            (cv.oasisSkills && cv.oasisSkills.length)
+          hasPersonal ? div({ class: "cv-box personal" }, ...[  
+           h2(i18n.cvLanguagesLabel),
+           cv.languages ? p(`${cv.languages.toUpperCase()}`) : null
+          ]) : null,
+          hasEducational ? div({ class: "cv-box education" }, ...[
+            h2(i18n.cvEducationalView),
+            cv.educationExperiences ? p(...renderUrl(`${cv.educationExperiences}`)) : null,
+            (cv.educationalSkills && cv.educationalSkills.length)
               ? div(
-                  cv.oasisSkills.map(tag =>
+                  cv.educationalSkills.map(tag =>
                     a({
                       href: `/search?query=%23${encodeURIComponent(tag)}`,
                       class: "tag-link",
@@ -185,12 +189,12 @@ exports.cvView = async (cv) => {
                 )
               : null
           ]) : null,
-          hasEducational ? div({ class: "cv-box education" }, ...[
-            h2(i18n.cvEducationalView),
-            cv.educationExperiences ? p(`${cv.educationExperiences}`) : null,
-            (cv.educationalSkills && cv.educationalSkills.length)
+          hasProfessional ? div({ class: "cv-box professional" }, ...[
+            h2(i18n.cvProfessionalView),
+            cv.professionalExperiences ? p(...renderUrl(`${cv.professionalExperiences}`)) : null,
+            (cv.professionalSkills && cv.professionalSkills.length)
               ? div(
-                  cv.educationalSkills.map(tag =>
+                  cv.professionalSkills.map(tag =>
                     a({
                       href: `/search?query=%23${encodeURIComponent(tag)}`,
                       class: "tag-link",
@@ -200,12 +204,12 @@ exports.cvView = async (cv) => {
                 )
               : null
           ]) : null,
-          hasProfessional ? div({ class: "cv-box professional" }, ...[
-            h2(i18n.cvProfessionalView),
-            cv.professionalExperiences ? p(`${cv.professionalExperiences}`) : null,
-            (cv.professionalSkills && cv.professionalSkills.length)
+          hasOasis ? div({ class: "cv-box oasis" }, ...[
+            h2(i18n.cvOasisContributorView),
+            p(...renderUrl(`${cv.oasisExperiences}`)),
+            (cv.oasisSkills && cv.oasisSkills.length)
               ? div(
-                  cv.professionalSkills.map(tag =>
+                  cv.oasisSkills.map(tag =>
                     a({
                       href: `/search?query=%23${encodeURIComponent(tag)}`,
                       class: "tag-link",

+ 5 - 4
src/views/document_view.js

@@ -1,7 +1,8 @@
-const { form, button, div, h2, p, section, input, label, br, a, span } = require("../server/node_modules/hyperaxe");
+const { form, button, div, h2, p, section, input, label, br, a, span, textarea } = require("../server/node_modules/hyperaxe");
 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 userId = config.keys.id;
 
@@ -44,7 +45,7 @@ const renderDocumentList = (filteredDocs, filter) => {
             class: 'pdf-viewer-container',
             'data-pdf-url': `/blob/${encodeURIComponent(doc.url)}`
           }),
-          doc.description?.trim() ? p(doc.description) : null,
+          doc.description?.trim() ? p(...renderUrl(doc.description)) : null,
           doc.tags.length
             ? div({ class: "card-tags" }, doc.tags.map(tag =>
                 a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
@@ -84,7 +85,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(),
-      input({ type: "text", name: "description", placeholder: i18n.documentDescriptionPlaceholder, 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)
     )
   );
@@ -167,7 +168,7 @@ exports.singleDocumentView = async (doc, filter) => {
           class: 'pdf-viewer-container',
           'data-pdf-url': `/blob/${encodeURIComponent(doc.url)}`
         }),
-        p(doc.description),
+        p(...renderUrl(doc.description)),
           doc.tags.length
             ? div({ class: "card-tags" }, doc.tags.map(tag =>
                 a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)

+ 7 - 4
src/views/event_view.js

@@ -2,13 +2,14 @@ const { div, h2, p, section, button, form, a, span, textarea, br, input, label,
 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 userId = config.keys.id
 
 const renderStyledField = (labelText, valueElement) =>
   div({ class: 'card-field' },
     span({ class: 'card-label' }, labelText),
-    span({ class: 'card-value' }, valueElement)
+    span({ class: 'card-value' }, ...renderUrl(valueElement))
   );
 
 const renderEventItem = (e, filter, userId) => {
@@ -41,7 +42,8 @@ const renderEventItem = (e, filter, userId) => {
     ),
     br,
     renderStyledField(i18n.eventTitleLabel + ':', e.title),
-    renderStyledField(i18n.eventDescriptionLabel + ':', e.description),
+    renderStyledField(i18n.eventDescriptionLabel + ':'),
+    p(...renderUrl(e.description)),
     renderStyledField(i18n.eventDateLabel + ':', moment(e.date).format('YYYY/MM/DD HH:mm:ss')),
     e.location?.trim() ? renderStyledField(i18n.eventLocationLabel + ':', e.location) : null,
     renderStyledField(i18n.eventPrivacyLabel + ':', e.isPublic.toUpperCase()),
@@ -145,7 +147,7 @@ exports.eventView = async (events, filter, eventId) => {
               ...(filter==='edit'?{value:eventToEdit.title}:{})
             }), br(), br(),
             label(i18n.eventDescriptionLabel), br(),
-            textarea({ name:"description", id:"description", placeholder:i18n.eventDescriptionPlaceholder}, filter === 'edit' ? eventToEdit.description : ''), br(), br(),
+            textarea({ name:"description", id:"description", placeholder:i18n.eventDescriptionPlaceholder, rows:"4"}, filter === 'edit' ? eventToEdit.description : ''), br(), br(),
             label(i18n.eventDateLabel), br(),
             input({
               type: "datetime-local",
@@ -227,7 +229,8 @@ exports.singleEventView = async (event, filter) => {
         ),
         br,
         renderStyledField(i18n.eventTitleLabel + ':', event.title),
-        renderStyledField(i18n.eventDescriptionLabel + ':', event.description),
+        renderStyledField(i18n.eventDescriptionLabel + ':'),
+        p(...renderUrl(event.description)),
         renderStyledField(i18n.eventDateLabel + ':', moment(event.date).format('YYYY/MM/DD HH:mm:ss')),
         event.location?.trim() ? renderStyledField(i18n.eventLocationLabel + ':', event.location) : null,
         renderStyledField(i18n.eventPrivacyLabel + ':', event.isPublic.toUpperCase()),

+ 1 - 1
src/views/feed_view.js

@@ -103,7 +103,7 @@ exports.feedView = (feeds, filter) => {
                 name: 'text',
                 placeholder: i18n.feedPlaceholder,
                 maxlength: 280,
-                rows: 5,
+                rows: 4,
                 cols: 50
               }),
               br(),

+ 353 - 0
src/views/forum_view.js

@@ -0,0 +1,353 @@
+const {
+  div, a, span, form, button, section, p,
+  input, label, br, select, option, h2, textarea
+} = require("../server/node_modules/hyperaxe");
+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 userId = config.keys.id;
+const BASE_FILTERS = ['hot','all','mine','recent','top'];
+const CAT_BLOCK1 = ['GENERAL','OASIS','L.A.R.P.','POLITICS','TECH'];
+const CAT_BLOCK2 = ['SCIENCE','MUSIC','ART','GAMING','BOOKS','FILMS'];
+const CAT_BLOCK3 = ['PHILOSOPHY','SOCIETY','PRIVACY','CYBERWARFARE','SURVIVALISM'];
+
+const Z = 1.96;
+function wilsonScore(pos, neg) {
+  const n = (pos||0)+(neg||0);
+  if (n === 0) return 0;
+  const phat = pos / n, z2 = Z * Z;
+  return (phat + z2/(2*n) - Z*Math.sqrt((phat*(1-phat)+z2/(4*n))/n)) / (1+z2/n);
+}
+
+function getFilteredForums(filter, forums) {
+  const now = Date.now();
+  if (filter === 'mine')    return forums.filter(f => f.author === userId);
+  if (filter === 'recent')  return forums.filter(f => new Date(f.createdAt).getTime() >= now - 86400000);
+  if (filter === 'top')     return forums.slice().sort((a,b) => b.score - a.score);
+  if (filter === 'hot')     return forums
+    .filter(f => new Date(f.createdAt).getTime() >= now - 86400000)
+    .sort((a,b) => b.score - a.score);
+  if ([...CAT_BLOCK1, ...CAT_BLOCK2, ...CAT_BLOCK3].includes(filter))
+    return forums.filter(f => f.category === filter);
+  return forums;
+}
+
+const generateFilterButtons = (filters, currentFilter, action, i18nMap = {}) =>
+  div({ class: 'filter-group' },
+    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' },
+          i18nMap[mode] || mode.toUpperCase()
+        )
+      )
+    )
+  );
+
+const renderCreateForumButton = () =>
+  div({ class: 'forum-create-col' },
+    form({ method: 'GET', action: '/forum' },
+      button({ type: 'submit', name: 'filter', value: 'create', class: 'create-button' },
+        i18n.forumCreateButton
+      )
+    )
+  );
+
+const renderVotes = (target, score, forumId) =>
+  div({ class: 'forum-score-box' },
+    form({ method: 'POST', action: `/forum/${encodeURIComponent(forumId)}/vote`, class: 'forum-score-form' },
+      button({ name: 'value', value: 1, class: 'score-btn' }, '▲'),
+      div({ class: 'score-total' }, String(score || 0)),
+      button({ name: 'value', value: -1, class: 'score-btn' }, '▼'),
+      input({ type: 'hidden', name: 'target', value: target }),
+      input({ type: 'hidden', name: 'forumId', value: forumId })
+    )
+  );
+
+const renderForumForm = () =>
+  div({ class: 'forum-form' },
+    form({ action: '/forum/create', method: 'POST' },
+      label(i18n.forumCategoryLabel), br(),
+      select({ name: 'category', required: true },
+        [...CAT_BLOCK1, ...CAT_BLOCK2, ...CAT_BLOCK3].map(cat =>
+          option({ value: cat }, cat)
+        )
+      ), br(), br(),
+      label(i18n.forumTitleLabel), br(),
+      input({
+        type: 'text',
+        name: 'title',
+        required: true,
+        placeholder: i18n.forumTitlePlaceholder
+      }), br(), br(),
+      label(i18n.forumMessageLabel), br(),
+      textarea({
+        name: 'text',
+        required: true,
+        rows: 4,
+        placeholder: i18n.forumMessagePlaceholder
+      }), br(), br(),
+      button({ type: 'submit' }, i18n.forumCreateButton)
+    )
+  );
+
+const renderThread = (nodes, level = 0, forumId) => {
+  if (!Array.isArray(nodes)) return [];
+  return [...nodes]
+    .sort((a, b) =>
+      wilsonScore(b.positiveVotes, b.negativeVotes)
+      - wilsonScore(a.positiveVotes, a.negativeVotes)
+    )
+    .flatMap((m, i) => {
+      const isTopLevelWinner = level === 0 && i === 0;
+      const classList = [
+        'forum-comment',
+        `level-${level}`,
+        isTopLevelWinner ? 'highlighted-reply' : ''
+      ].filter(Boolean).join(' ');
+
+      const commentBox = div(
+        { class: classList },
+        div({ class: 'comment-header' },
+          span({ class: 'date-link' },
+            `${moment(m.timestamp).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
+          a({
+            href: `/author/${encodeURIComponent(m.author)}`,
+            class: 'user-link',
+            style: 'margin-left:12px;'
+          }, m.author),
+          div({ class: 'comment-votes' },
+            span({ class: 'votes-count' }, `▲: ${m.positiveVotes || 0}`),
+            span({ class: 'votes-count', style: 'margin-left:12px;' },
+              `▼: ${m.negativeVotes || 0}`)
+          )
+        ),
+        div({ class: 'comment-body-row' },
+          div({ class: 'comment-vote-col' },
+            renderVotes(m.key, m.score, forumId)
+          ),
+          div({ class: 'comment-text-col' },
+            div(
+              ...(m.text || '').split('\n')
+                .map(l => l.trim())
+                .filter(l => l)
+                .map(l => p(...renderUrl(l)))
+            )
+          )
+        ),
+        div({ class: 'new-reply' },
+          form({
+            method: 'POST',
+            action: `/forum/${forumId}/message`,
+            class: 'comment-form'
+          },
+            input({ type: 'hidden', name: 'parentId', value: m.key }),
+            textarea({
+              name: 'message',
+              rows: 2,
+              required: true,
+              placeholder: i18n.forumMessagePlaceholder,
+              class: 'comment-textarea'
+            }),
+            button({ type: 'submit', class: 'forum-send-btn' }, 'Reply')
+          )
+        )
+      );
+
+      return [ commentBox, ...renderThread(m.children || [], level + 1, forumId) ];
+    });
+};
+
+const renderForumList = (forums, currentFilter) =>
+  div({ class: 'forum-list' },
+    Array.isArray(forums) && forums.length
+      ? forums.map(f =>
+        div({ class: 'forum-card' },
+          div({ class: 'forum-score-col' },
+            renderVotes(f.key, f.score, f.key)
+          ),
+          div({ class: 'forum-main-col' },
+            div({ class: 'forum-header-row' },
+              a({
+                class: 'forum-category',
+                href: `/forum?filter=${encodeURIComponent(f.category)}`
+              }, `[${f.category}]`),
+              a({
+                class: 'forum-title',
+                href: `/forum/${encodeURIComponent(f.key)}`
+              }, f.title)
+            ),
+            div({ class: 'forum-body' }, ...renderUrl(f.text || '')),
+            div({ class: 'forum-meta' },
+              span({ class: 'forum-positive-votes' },
+                `▲: ${f.positiveVotes || 0}`),
+              span({ class: 'forum-negative-votes', style: 'margin-left:12px;' },
+                `▼: ${f.negativeVotes || 0}`),
+              span({ class: 'forum-participants' },
+                `${i18n.forumParticipants.toUpperCase()}: ${f.participants?.length || 1}`),
+              span({ class: 'forum-messages' },
+                `${i18n.forumMessages.toUpperCase()}: ${f.messagesCount - 1}`)
+            ),
+            div({ class: 'forum-footer' },
+              span({ class: 'date-link' },
+                `${moment(f.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
+              a({
+                href: `/author/${encodeURIComponent(f.author)}`,
+                class: 'user-link',
+                style: 'margin-left:12px;'
+              }, f.author)
+            ),
+            currentFilter === 'mine' && f.author === userId
+              ? div({ class: 'forum-owner-actions' },
+                form({
+                  method: 'POST',
+                  action: `/forum/delete/${f.key}`,
+                  class: 'forum-delete-form'
+                },
+                  button({ type: 'submit', class: 'delete-btn' },
+                    i18n.forumDeleteButton)
+                )
+              )
+              : null
+          )
+        )
+      )
+      : p(i18n.noForums)
+  );
+
+exports.forumView = async (forums, currentFilter) =>
+  template(i18n.forumTitle,
+    section(
+      div({ class: 'tags-header' },
+        h2(currentFilter === 'create'
+          ? i18n.forumCreateSectionTitle
+          : i18n.forumTitle),
+        p(i18n.forumDescription)
+      ),
+      div({ class: 'mode-buttons-cols' },
+        generateFilterButtons(BASE_FILTERS, currentFilter, '/forum', {
+          hot: i18n.forumFilterHot,
+          all: i18n.forumFilterAll,
+          mine: i18n.forumFilterMine,
+          recent: i18n.forumFilterRecent,
+          top: i18n.forumFilterTop
+        }),
+        generateFilterButtons(CAT_BLOCK1, currentFilter, '/forum'),
+        generateFilterButtons(CAT_BLOCK2, currentFilter, '/forum'),
+        generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum'),
+        renderCreateForumButton()
+      ),
+      currentFilter === 'create'
+        ? renderForumForm()
+        : renderForumList(
+          getFilteredForums(currentFilter || 'hot', forums),
+          currentFilter
+        )
+    )
+  );
+
+exports.singleForumView = async (forum, messagesData, currentFilter) =>
+  template(forum.title,
+    section(
+      div({ class: 'tags-header' },
+        h2(i18n.forumTitle),
+        p(i18n.forumDescription)
+      ),
+      div({ class: 'mode-buttons' },
+        generateFilterButtons(BASE_FILTERS, currentFilter, '/forum', {
+          all: i18n.forumFilterAll,
+          mine: i18n.forumFilterMine,
+          recent: i18n.forumFilterRecent,
+          top: i18n.forumFilterTop
+        }),
+        generateFilterButtons(CAT_BLOCK1, currentFilter, '/forum'),
+        generateFilterButtons(CAT_BLOCK2, currentFilter, '/forum'),
+        generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum'),
+        renderCreateForumButton()
+      )
+    ),
+    div({ class: 'forum-thread-container' },
+      div({
+        class: 'forum-card forum-thread-header',
+        style: 'display:flex;align-items:flex-start;'
+      },
+        div({
+          class: 'root-vote-col',
+          style: 'width:60px;text-align:center;'
+        }, renderVotes(
+          forum.key,
+          messagesData.totalScore,
+          forum.key
+        )),
+        div({
+          class: 'forum-main-col',
+          style: 'flex:1;padding-left:10px;'
+        },
+          div({ class: 'forum-header-row' },
+            a({
+              class: 'forum-category',
+              href: `/forum?filter=${encodeURIComponent(forum.category)}`
+            }, `[${forum.category}]`),
+            a({
+              class: 'forum-title',
+              href: `/forum/${encodeURIComponent(forum.key)}`
+            }, forum.title)
+          ),
+          div({ class: 'forum-footer' },
+            span({ class: 'date-link' },
+              `${moment(forum.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
+            a({
+              href: `/author/${encodeURIComponent(forum.author)}`,
+              class: 'user-link',
+              style: 'margin-left:12px;'
+            }, forum.author)
+          ),
+          div(
+            ...(forum.text || '').split('\n')
+              .map(l => l.trim())
+              .filter(l => l)
+              .map(l => p(...renderUrl(l)))
+          ),
+          div({ class: 'forum-meta' },
+            span({ class: 'votes-count' },
+              `▲: ${messagesData.positiveVotes}`),
+            span({
+              class: 'votes-count',
+              style: 'margin-left:12px;'
+            }, `▼: ${messagesData.negativeVotes}`),
+            span({ class: 'forum-participants' },
+              `${i18n.forumParticipants.toUpperCase()}: ${forum.participants?.length || 1}`),
+            span({ class: 'forum-messages' },
+              `${i18n.forumMessages.toUpperCase()}: ${messagesData.total}`)
+          )
+        )
+      ),
+      div({
+        class: 'new-message-wrapper',
+        style: 'margin-top:12px;'
+      },
+        form({
+          method: 'POST',
+          action: `/forum/${forum.key}/message`,
+          class: 'new-message-form'
+        },
+          textarea({
+            name: 'message',
+            rows: 4,
+            required: true,
+            placeholder: i18n.forumMessagePlaceholder,
+            style: 'width:100%;'
+          }), br(),
+          button({
+            type: 'submit',
+            class: 'forum-send-btn',
+            style: 'margin-top:4px;'
+          }, i18n.forumSendButton)
+        )
+      ),
+      ...renderThread(messagesData.messages, 0, forum.key)
+    )
+  );
+

+ 6 - 5
src/views/image_view.js

@@ -1,7 +1,8 @@
-const { form, button, div, h2, p, section, input, label, br, a, img, span } = require("../server/node_modules/hyperaxe");
+const { form, button, div, h2, p, section, input, label, br, a, img, span, textarea } = require("../server/node_modules/hyperaxe");
 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 userId = config.keys.id;
 
@@ -42,7 +43,7 @@ const renderImageList = (filteredImages, filter) => {
           
           imgObj.title ? h2(imgObj.title) : null,
           a({ href: `#img-${encodeURIComponent(imgObj.key)}` }, img({ src: `/blob/${encodeURIComponent(imgObj.url)}` })),
-          imgObj.description ? p(imgObj.description) : null,
+          imgObj.description ? p(...renderUrl(imgObj.description)) : null,
           imgObj.tags?.length
             ? div({ class: "card-tags" }, 
                 imgObj.tags.map(tag =>
@@ -87,7 +88,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(),
-      input({ type: "text", name: "description", placeholder: i18n.imageDescriptionPlaceholder, 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)
@@ -135,7 +136,7 @@ exports.imageView = async (images, filter, imageId) => {
     title,
     section(
       div({ class: "tags-header" },
-        h2(title),
+        h2(i18n.imageCreateSectionTitle),
         p(i18n.imageDescription)
       ),
       div({ class: "filters" },
@@ -194,7 +195,7 @@ exports.singleImageView = async (image, filter) => {
       ) : null,
         h2(image.title),
         image.url ? img({ src: `/blob/${encodeURIComponent(image.url)}` }) : null,
-        p(image.description),
+        p(...renderUrl(image.description)),
         image.tags?.length
             ? div({ class: "card-tags" }, 
               image.tags.map(tag =>

+ 2 - 3
src/views/inhabitants_view.js

@@ -189,7 +189,7 @@ exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }) => {
           p(a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)),
           description ? p(...renderUrl(description)) : null,
           location ? p(`${i18n.locationLabel}: ${location}`) : null,
-          languages.length ? p(`${i18n.languagesLabel}: ${languages.join(', ')}`) : null,
+          languages.length ? p(`${i18n.languagesLabel}: ${languages.join(', ').toUpperCase()}`) : null,
           skills.length ? p(`${i18n.skillsLabel}: ${skills.join(', ')}`) : null,
           status ? p(`${i18n.statusLabel || 'Status'}: ${status}`) : null,
           preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${preferences}`) : null,
@@ -199,10 +199,9 @@ exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }) => {
       feed && feed.length
         ? section({ class: 'profile-feed' },
             h2(i18n.latestInteractions),
-            feed.map(m => div({ class: 'post' }, p(m.value.content.text || '')))
+            feed.map(m => div({ class: 'post' }, p(...renderUrl(m.value.content.text || ''))))
           )
         : null
     )
   );
 };
-

+ 14 - 5
src/views/main_views.js

@@ -8,7 +8,7 @@ const debug = require("../server/node_modules/debug")("oasis");
 const highlightJs = require("../server/node_modules/highlight.js");
 const prettyMs = require("../server/node_modules/pretty-ms");
 const moment = require('../server/node_modules/moment');
-
+const { renderUrl } = require('../backend/renderUrl');
 const ssbClientGUI = require("../client/gui");
 const config = require("../server/ssb_config");
 const cooler = ssbClientGUI({ offline: config.offline });
@@ -316,6 +316,15 @@ const renderPixeliaLink = () => {
     : '';
 };
 
+const renderForumLink = () => {
+  const forumMod = getConfig().modules.forumMod === 'on';
+  return forumMod 
+    ? [
+     navLink({ href: "/forum", emoji: "ꕒ", text: i18n.forumTitle, class: "forum-link enabled" }),
+      ]
+    : '';
+};
+
 const renderAgendaLink = () => {
   const agendaMod = getConfig().modules.agendaMod === 'on';
   return agendaMod 
@@ -439,6 +448,7 @@ const template = (titlePrefix, ...elements) => {
               navLink({ href: "/activity", emoji: "ꔙ", text: i18n.activityTitle }),
               renderTrendingLink(),
               renderOpinionsLink(),
+              renderForumLink(),
               renderFeedLink(),
               renderPixeliaLink(),
               renderMarketLink(),
@@ -797,6 +807,7 @@ exports.editProfileView = ({ name, description }) =>
             {
               autofocus: true,
               name: "description",
+              rows: "6",
             },
             description
           )
@@ -1226,7 +1237,6 @@ exports.privateView = async (input, filter) => {
               if (!content || !author) {
                 return div({ class: 'malformed-message' }, 'Invalid message');
               }
-
               const subject = content.subject || '(no subject)';
               const text = content.text || '';
               const sentAt = new Date(content.sentAt || msg.timestamp).toLocaleString();
@@ -1234,14 +1244,13 @@ exports.privateView = async (input, filter) => {
               const toLinks = (content.to || []).map(addr =>
                 a({ class: 'user-link', href: `/author/${encodeURIComponent(addr)}` }, addr)
               );
-
               return div({ class: 'message-item' },
-                p(subject),
-                div({ class: 'message-text' }, text),
                 p({ class: 'card-footer' },
                 span({ class: 'date-link' }, `${sentAt} ${i18n.performed} `),
                  a({ href: `/author/${encodeURIComponent(from)}`, class: 'user-link' }, `${from}`)
                 ),
+                h2(subject),
+                p({ class: 'message-text' }, ...renderUrl(text)),
                 form({ method: 'POST', action: `/inbox/delete/${encodeURIComponent(msg.key)}`, class: 'delete-message-form' },
                   button({ type: 'submit', class: 'delete-btn' }, i18n.privateDelete)
                 )

+ 19 - 11
src/views/market_view.js

@@ -2,13 +2,14 @@ const { div, h2, p, section, button, form, a, span, textarea, br, input, label,
 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 userId = config.keys.id;
 
 const renderCardField = (labelText, value) =>
   div({ class: 'card-field' },
     span({ class: 'card-label' }, labelText),
-    span({ class: 'card-value' }, value)
+    span({ class: 'card-value' }, ...renderUrl(value))
   );
 
 exports.marketView = async (items, filter, itemToEdit = null) => {
@@ -112,7 +113,7 @@ exports.marketView = async (items, filter, itemToEdit = null) => {
             input({ type: "text", name: "title", id: "title", value: itemToEdit?.title || '', required: true }), br(), br(),
             
             label(i18n.marketItemDescription), br(),
-            textarea({ name: "description", id: "description", placeholder: i18n.marketItemDescriptionPlaceholder, innerHTML: itemToEdit?.description || '', required: true }), br(), br(),
+            textarea({ name: "description", id: "description", placeholder: i18n.marketItemDescriptionPlaceholder, rows:"6", innerHTML: itemToEdit?.description || '', required: true }), br(), br(),
             
             label(i18n.marketCreateFormImageLabel), br(),
             input({ type: "file", name: "image", id: "image", accept: "image/*" }), br(), br(),
@@ -176,7 +177,7 @@ exports.marketView = async (items, filter, itemToEdit = null) => {
 		      ? img({ src: `/blob/${encodeURIComponent(item.image)}` })
 		      : img({ src: '/assets/images/default-market.png', alt: item.title })
 		  ),
-		  p(item.description),
+		  p(...renderUrl(item.description)),
 		  item.tags && item.tags.filter(Boolean).length
 		    ? div({ class: 'card-tags' }, item.tags.filter(Boolean).map(tag =>
 		        a({ class: "tag-link", href: `/search?query=%23${encodeURIComponent(tag)}` },
@@ -192,9 +193,10 @@ exports.marketView = async (items, filter, itemToEdit = null) => {
 		  renderCardField(`${i18n.marketItemCondition}:`, item.item_status),
 		  renderCardField(`${i18n.marketItemIncludesShipping}:`, item.includesShipping ? i18n.YESLabel : i18n.NOLabel),
 		  renderCardField(`${i18n.marketItemSeller}:`),
+		  div({ class: "market-card image" }, 
 		  div({ class: 'card-field' },
 		    a({ class: 'user-link', href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)
-		  ),
+		  )),
 		  item.item_type === 'auction' && item.auctions_poll.length > 0
 		  ? div({ class: "auction-info" },
 		      p({ class: "auction-bid-text" }, i18n.marketAuctionBids),
@@ -280,19 +282,15 @@ exports.singleMarketView = async (item, filter) => {
         h2(item.title),
         renderCardField(`${i18n.marketItemType}:`, `${item.item_type.toUpperCase()}`),
         renderCardField(`${i18n.marketItemStatus}:`, item.status),
-        renderCardField(`${i18n.marketItemStock}:`, item.stock > 0 ? item.stock : i18n.marketOutOfStock),
         renderCardField(`${i18n.marketItemCondition}:`, item.item_status),
-        renderCardField(`${i18n.marketItemPrice}:`, `${item.price} ECO`),
-        renderCardField(`${i18n.marketItemIncludesShipping}:`, `${item.includesShipping ? i18n.YESLabel : i18n.NOLabel}`),
-        item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, `${moment(item.deadline).format('YYYY/MM/DD HH:mm:ss')}`) : null,
         br,
         div({ class: "market-item image" },
           item.image
             ? img({ src: `/blob/${encodeURIComponent(item.image)}` })
             : img({ src: '/assets/images/default-market.png', alt: item.title })
         ),
-        renderCardField(`${i18n.marketItemDescription}:`, item.description),
-        br,
+        renderCardField(`${i18n.marketItemDescription}:`),
+        p(...renderUrl(item.description)),   
         item.tags && item.tags.length
           ? div({ class: 'card-tags' },
               item.tags.map(tag =>
@@ -301,8 +299,18 @@ exports.singleMarketView = async (item, filter) => {
             )
           : null,
           br,
+        renderCardField(`${i18n.marketItemPrice}:`),
+        br,
+        div({ class: 'card-label' },
+          h2(`${item.price} ECO`),
+          ),
+        br,
+        renderCardField(`${i18n.marketItemStock}:`, item.stock > 0 ? item.stock : i18n.marketOutOfStock),
+        renderCardField(`${i18n.marketItemIncludesShipping}:`, `${item.includesShipping ? i18n.YESLabel : i18n.NOLabel}`),
+        item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, `${moment(item.deadline).format('YYYY/MM/DD HH:mm:ss')}`) : null,
+        renderCardField(`${i18n.marketItemSeller}:`),
+        br,
 	div({ class: 'card-field' },
-	  span({ class: 'card-label' }, `${i18n.marketItemSeller}:`),
 	  a({ class: 'user-link', href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)
 	)
       ),

+ 1 - 0
src/views/modules_view.js

@@ -13,6 +13,7 @@ const modulesView = () => {
     { name: 'docs', label: i18n.modulesDocsLabel, description: i18n.modulesDocsDescription },
     { name: 'events', label: i18n.modulesEventsLabel, description: i18n.modulesEventsDescription },
     { name: 'feed', label: i18n.modulesFeedLabel, description: i18n.modulesFeedDescription },
+    { name: 'forum', label: i18n.modulesForumLabel, description: i18n.modulesForumDescription },
     { name: 'governance', label: i18n.modulesGovernanceLabel, description: i18n.modulesGovernanceDescription },
     { name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription },
     { name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },

+ 32 - 26
src/views/opinions_view.js

@@ -1,6 +1,8 @@
 const { div, h2, p, section, button, form, a, img, video: videoHyperaxe, audio: audioHyperaxe, input, table, tr, th, td, br, span } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 const { config } = require('../server/SSB_server.js');
+const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
+const { renderUrl } = require('../backend/renderUrl');
 
 const generateFilterButtons = (filters, currentFilter) => {
   return filters.map(mode =>
@@ -20,17 +22,18 @@ const renderContentHtml = (content, key) => {
         button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
       ),
       br,
-      content.description ? div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ':'),
-        span({ class: 'card-value' }, content.description)
-      ) : "",
       h2(content.url ? div({ class: 'card-field' },
         span({ class: 'card-label' }, p(a({ href: content.url, target: '_blank', class: "bookmark-url" }, content.url)))
       ) : ""),
       content.lastVisit ? div({ class: 'card-field' },
         span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'),
         span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString())
-      ) : ""
+      ) : "",
+      content.description 
+	? [
+	  span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ":"),
+	  p(...renderUrl(content.description))
+        ]: null,
     )
   );
   case 'image':
@@ -41,7 +44,11 @@ const renderContentHtml = (content, key) => {
       ),
       br,
       content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : "",
-      content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : "",
+      content.description 
+	? [
+	  span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"),
+	  p(...renderUrl(content.description))
+        ]: null,
       content.meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : "",
       br,
       div({ class: 'card-field' }, img({ src: `/blob/${encodeURIComponent(content.url)}`, class: 'feed-image' }))
@@ -58,11 +65,11 @@ const renderContentHtml = (content, key) => {
         span({ class: 'card-label' }, i18n.videoTitleLabel + ':'),
         span({ class: 'card-value' }, content.title)
       ) : "",
-      content.description ? div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.videoDescriptionLabel + ':'),
-        span({ class: 'card-value' }, content.description)
-      ) : "",
-      br,
+      content.description 
+	? [
+	  span({ class: 'card-label' }, i18n.videoDescriptionLabel + ":"),
+	  p(...renderUrl(content.description))
+        ]: null,
       div({ class: 'card-field' },
         videoHyperaxe({
           controls: true,
@@ -85,11 +92,11 @@ const renderContentHtml = (content, key) => {
         span({ class: 'card-label' }, i18n.audioTitleLabel + ':'),
         span({ class: 'card-value' }, content.title)
       ) : "",
-      content.description ? div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.audioDescriptionLabel + ':'),
-        span({ class: 'card-value' }, content.description)
-      ) : "",
-      br,
+      content.description 
+	? [
+	  span({ class: 'card-label' }, i18n.audioDescriptionLabel + ":"),
+	  p(...renderUrl(content.description))
+        ]: null,
       div({ class: 'card-field' },
         audioHyperaxe({
           controls: true,
@@ -111,11 +118,11 @@ const renderContentHtml = (content, key) => {
         span({ class: 'card-label' }, i18n.documentTitleLabel + ':'),
         span({ class: 'card-value' }, content.title)
       ) : "",
-      content.description ? div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.documentDescriptionLabel + ':'),
-        span({ class: 'card-value' }, content.description)
-      ) : "",
-      br,
+      content.description 
+	? [
+	  span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"),
+	  p(...renderUrl(content.description))
+        ]: null,
       div({ class: 'card-field' },
         div({ class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(content.url)}` })
       )
@@ -124,7 +131,7 @@ const renderContentHtml = (content, key) => {
   case 'feed':
   return div({ class: 'opinion-feed' },
     div({ class: 'card-section feed' },
-      h2(content.text),
+      div({ class: 'feed-text', innerHTML: renderTextWithStyles(content.text) }),
       h2({ class: 'card-field' },
         span({ class: 'card-label' }, `${i18n.tribeFeedRefeeds}: `),
         span({ class: 'card-value' }, content.refeeds)
@@ -181,7 +188,6 @@ const renderContentHtml = (content, key) => {
         span({ class: 'card-label' }, i18n.amount + ':'),
         span({ class: 'card-value' }, content.amount)
       ),
-      br,
       div({ class: 'card-field' },
         span({ class: 'card-label' }, i18n.from + ':'),
         span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(content.from)}`, target: "_blank" }, content.from))
@@ -190,9 +196,9 @@ const renderContentHtml = (content, key) => {
         span({ class: 'card-label' }, i18n.to + ':'),
         span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(content.to)}`, target: "_blank" }, content.to))
       ),
-      br,
-      div({ class: 'card-field' },
-        h2({ class: 'card-label' }, i18n.transfersConfirmations + ': ' + `${content.confirmedBy.length}/2`)
+      h2({ class: 'card-field' },
+	 span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
+	 span({ class: 'card-value' }, `${content.confirmedBy.length}/2`)
       )
     )
   );

+ 11 - 8
src/views/report_view.js

@@ -2,13 +2,14 @@ const { div, h2, p, section, button, form, a, textarea, br, input, img, span, la
 const { template, i18n } = require('./main_views');
 const { config } = require('../server/SSB_server.js');
 const moment = require('../server/node_modules/moment');
+const { renderUrl } = require('../backend/renderUrl');
 
 const userId = config.keys.id;
 
 const renderCardField = (labelText, value) =>
   div({ class: 'card-field' },
     span({ class: 'card-label' }, labelText),
-    span({ class: 'card-value' }, value)
+    span({ class: 'card-value' }, ...renderUrl(value))
   );
 
 const renderReportCard = (report, userId) => {
@@ -37,15 +38,16 @@ const renderReportCard = (report, userId) => {
     renderCardField(i18n.reportsStatus + ":", report.status),
     renderCardField(i18n.reportsSeverity + ":", report.severity.toUpperCase()),
     renderCardField(i18n.reportsCategory + ":", report.category),
-    renderCardField(i18n.reportsConfirmations + ":", report.confirmations.length),
-    renderCardField(i18n.reportsDescriptionLabel + ":", report.description),
-    br,
+    renderCardField(i18n.reportsDescriptionLabel + ':'),
+    p(...renderUrl(report.description)), 
     div({ class: 'card-field' },
       report.image ? div({ class: 'card-field' },
         img({ src: `/blob/${encodeURIComponent(report.image)}`, class: "report-image" }),
       ) : null
     ),
     br,
+    renderCardField(i18n.reportsConfirmations + ":", report.confirmations.length),
+    br,
     form({ method: "POST", action: `/reports/confirm/${encodeURIComponent(report.id)}` },
       button({ type: "submit" }, i18n.reportsConfirmButton)
     ),
@@ -120,7 +122,7 @@ exports.reportView = async (reports, filter, reportId) => {
               input({ type: "text", name: "title", required: true, value: reportToEdit?.title || '' }), br(), br(),
 
               label(i18n.reportsDescriptionLabel), br(),
-              textarea({ name: "description", required: true }, reportToEdit?.description || ''), br(), br(),
+              textarea({ name: "description", required: true, rows:"4" }, reportToEdit?.description || ''), br(), br(),
 
               label(i18n.reportsCategory), br(),
               select({ name: "category", required: true },
@@ -183,15 +185,16 @@ exports.singleReportView = async (report, filter) => {
         renderCardField(i18n.reportsStatus + ":", report.status),
         renderCardField(i18n.reportsSeverity + ":", report.severity.toUpperCase()),
         renderCardField(i18n.reportsCategory + ":", report.category),
-        renderCardField(i18n.reportsConfirmations + ":", report.confirmations.length),
-        renderCardField(i18n.reportsDescriptionLabel + ":", report.description),
-        br,
+        renderCardField(i18n.reportsDescriptionLabel + ':'),
+        p(...renderUrl(report.description)), 
         div({ class: 'card-field' },
           report.image ? div({ class: 'card-field' },
             img({ src: `/blob/${encodeURIComponent(report.image)}`, class: "report-image" }),
           ) : null
         ),
         br,
+        renderCardField(i18n.reportsConfirmations + ":", report.confirmations.length),
+        br,
         form({ method: "POST", action: `/reports/confirm/${encodeURIComponent(report.id)}` },
           button({ type: "submit" }, i18n.reportsConfirmButton)
         ),

+ 7 - 4
src/views/task_view.js

@@ -2,13 +2,14 @@ const { div, h2, p, section, button, form, input, select, option, a, br, textare
 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 userId = config.keys.id;
 
 const renderStyledField = (labelText, valueElement) =>
   div({ class: 'card-field' },
     span({ class: 'card-label' }, labelText),
-    span({ class: 'card-value' }, valueElement)
+    span({ class: 'card-value' }, ...renderUrl(valueElement))
   );
 
 const renderTaskItem = (task, filter, userId) => {
@@ -36,7 +37,8 @@ const renderTaskItem = (task, filter, userId) => {
     form({ method: 'GET', action: `/tasks/${encodeURIComponent(task.id)}` }, button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails)),
     br,
     renderStyledField(i18n.taskTitleLabel + ':', task.title),
-    renderStyledField(i18n.taskDescriptionLabel + ':', task.description),
+    renderStyledField(i18n.taskDescriptionLabel + ':'),
+    p(...renderUrl(task.description)),
     task.location?.trim() ? renderStyledField(i18n.taskLocationLabel + ':', task.location) : null,
     renderStyledField(i18n.taskStatus + ':', task.status),
     renderStyledField(i18n.taskPriorityLabel + ':', task.priority),
@@ -131,7 +133,7 @@ exports.taskView = async (tasks, filter, taskId) => {
               label(i18n.taskTitleLabel), br(),
               input({ type: 'text', name: 'title', required: true, value: filter === 'edit' ? editTask.title : '' }), br(), br(),
               label(i18n.taskDescriptionLabel), br(),
-              textarea({ name: 'description', required: true, placeholder: i18n.taskDescriptionPlaceholder }, filter === 'edit' ? editTask.description : ''), br(), br(),
+              textarea({ name: 'description', required: true, placeholder: i18n.taskDescriptionPlaceholder, rows:"4"}, filter === 'edit' ? editTask.description : ''), br(), br(),
               label(i18n.taskStartTimeLabel), br(),
               input({ type: 'datetime-local', name: 'startTime', required: true, min: moment().format('YYYY-MM-DDTHH:mm'), value: filter === 'edit' ? moment(editTask.startTime).format('YYYY-MM-DDTHH:mm') : '' }), br(), br(),
               label(i18n.taskEndTimeLabel), br(),
@@ -185,7 +187,8 @@ exports.singleTaskView = async (task, filter) => {
       ),
       div({ class: 'card card-section task' },
         renderStyledField(i18n.taskTitleLabel + ':', task.title),
-        renderStyledField(i18n.taskDescriptionLabel + ':', task.description),
+        renderStyledField(i18n.taskDescriptionLabel + ':'),
+        p(...renderUrl(task.description)),
         renderStyledField(i18n.taskStartTimeLabel + ':', moment(task.startTime).format('YYYY/MM/DD HH:mm:ss')),
         renderStyledField(i18n.taskEndTimeLabel + ':', moment(task.endTime).format('YYYY/MM/DD HH:mm:ss')),
         renderStyledField(i18n.taskPriorityLabel + ':', task.priority),

+ 9 - 13
src/views/transfer_view.js

@@ -42,7 +42,6 @@ const generateTransferCard = (transfer, userId) => {
         span({ class: 'card-label' }, `${i18n.transfersAmount}:`),
         span({ class: 'card-value' }, `${transfer.amount} ECO`)
       ),
-      br,
       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))
@@ -51,7 +50,6 @@ const generateTransferCard = (transfer, userId) => {
         span({ class: 'card-label' }, `${i18n.transfersTo}:`),
         span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(transfer.to)}`, target: "_blank" }, transfer.to))
       ),
-      br,
       h2({ class: 'card-field' },
         span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
         span({ class: 'card-value' }, `${transfer.confirmedBy.length}/2`)
@@ -168,7 +166,7 @@ exports.singleTransferView = async (transfer, filter) => {
           button({ type: 'submit', name: 'filter', value: 'create', class: "create-button" }, i18n.transfersCreateButton)
         )
       ),
-	div({ class: "tags-header" },
+	div({ class: "transfer-item" },
 	  div({ class: 'card-section transfer' },
             div({ class: 'card-field' },
              span({ class: 'card-label' }, `${i18n.transfersConcept}:`),
@@ -186,16 +184,14 @@ exports.singleTransferView = async (transfer, filter) => {
 	      span({ class: 'card-label' }, `${i18n.transfersAmount}:`),
 	      span({ class: 'card-value' }, `${transfer.amount} ECO`)
 	    ),
-	    br,
-	    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))
-	    ),
-            br,
+            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`)

+ 32 - 13
src/views/trending_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 { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { config } = require('../server/SSB_server.js');
+const { renderUrl } = require('../backend/renderUrl');
 
 const userId = config.keys.id
 
@@ -30,9 +31,13 @@ const renderTrendingCard = (item, votes, categories) => {
         button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
       ),
       br,
-      description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ':'), span({ class: 'card-value' }, description)) : "",
       h2(url ? 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,
     )
   );
   } else if (c.type === 'image') {
@@ -44,9 +49,12 @@ const renderTrendingCard = (item, votes, categories) => {
       ),
       br,
       title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
-      description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageDescriptionLabel + ':'), span({ class: 'card-value' }, description)) : "",
+      description
+	? [
+	  span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"),
+	  p(...renderUrl(description))
+        ]: null,
       meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : "",
-      br,
       div({ class: 'card-field' }, img({ src: `/blob/${encodeURIComponent(url)}`, class: 'feed-image' }))
     )
   );
@@ -59,8 +67,11 @@ const renderTrendingCard = (item, votes, categories) => {
       ),
       br,
       title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
-      description?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioDescriptionLabel + ':'), span({ class: 'card-value' }, description)) : "",
-      br,
+      description
+	? [
+	  span({ class: 'card-label' }, i18n.audioDescriptionLabel + ":"),
+	  p(...renderUrl(description))
+        ]: null,
       url
         ? div({ class: 'card-field audio-container' },
             audioHyperaxe({
@@ -81,7 +92,11 @@ const renderTrendingCard = (item, votes, categories) => {
       ),
       br,
       title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
-      description?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoDescriptionLabel + ':'), span({ class: 'card-value' }, description)) : "",
+      description
+	? [
+	  span({ class: 'card-label' }, i18n.videoDescriptionLabel + ":"),
+	  p(...renderUrl(description))
+        ]: null,
       br,
       url
         ? div({ class: 'card-field video-container' },
@@ -106,8 +121,11 @@ const renderTrendingCard = (item, votes, categories) => {
       ),
       br,
       title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.documentTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
-      description?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.documentDescriptionLabel + ':'), span({ class: 'card-value' }, description)) : "",
-      br,
+      description
+	? [
+	  span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"),
+	  p(...renderUrl(description))
+        ]: null,
       div({
         id: `pdf-container-${key || url}`,
         class: 'card-field pdf-viewer-container',
@@ -119,7 +137,7 @@ const renderTrendingCard = (item, votes, categories) => {
     const { text, refeeds } = c;
     contentHtml = div({ class: 'trending-feed' },
     div({ class: 'card-section feed' },
-      h2(text),
+      div({ class: 'feed-text', innerHTML: renderTextWithStyles(text) }),
       h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '), span({ class: 'card-label' }, refeeds))
     )
   );
@@ -154,11 +172,12 @@ const renderTrendingCard = (item, votes, categories) => {
       div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
       div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)),
       div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.amount + ':'), span({ class: 'card-value' }, amount)),
-      br,
       div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.from + ':'), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(from)}`, target: "_blank" }, from))),
       div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.to + ':'), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(to)}`, target: "_blank" }, to))),
-      br,
-      div({ class: 'card-field' }, h2({ class: 'card-label' }, i18n.transfersConfirmations + ': ' + `${confirmedBy.length}/2`))
+      h2({ class: 'card-field' },
+	 span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
+	 span({ class: 'card-value' }, `${confirmedBy.length}/2`)
+      )
     )
   );
   } else {

+ 8 - 6
src/views/tribes_view.js

@@ -75,14 +75,15 @@ const renderFeedTribesView = (tribe, page, query, filter) => {
               ),
               div({ class: 'feed-main' },
                 p(`${new Date(m.date).toLocaleString()} — `, a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)),
-                p(m.message)
+                br,
+                p(...renderUrl(m.message))
               )
             )
           ))
         ),
     tribe.members.includes(userId)
       ? form({ method: 'POST', action: `/tribes/${encodeURIComponent(tribe.id)}/message` },
-          textarea({ name: 'message', rows: 3, cols: 50, maxlength: 280, placeholder: i18n.tribeFeedMessagePlaceholder }),
+          textarea({ name: 'message', rows: 4, cols: 50, maxlength: 280, placeholder: i18n.tribeFeedMessagePlaceholder }),
           button({ type: 'submit' }, i18n.tribeFeedSend)
         )
       : null,
@@ -267,9 +268,9 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}) => {
       p(`${i18n.tribeIsAnonymousLabel}: ${t.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
       p(`${i18n.tribeModeLabel}: ${t.inviteMode.toUpperCase()}`),
       p(`${i18n.tribeLARPLabel}: ${t.isLARP ? i18n.tribeYes : i18n.tribeNo}`),
+      p(`${i18n.tribeLocationLabel}: ${t.location}`),
       img({ src: imageSrc }),
       t.description ? p(...renderUrl(t.description)) : null,
-      p(`${i18n.tribeLocationLabel}: ${t.location}`),
       h2(`${i18n.tribeMembersCount}: ${t.members.length}`),
       t.tags && t.tags.filter(Boolean).length ? div(t.tags.filter(Boolean).map(tag =>
         a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link', style: 'margin-right:0.8em;margin-bottom:0.5em;' }, `#${tag}`)
@@ -357,7 +358,8 @@ const renderFeedTribeView = async (tribe, query = {}, filter) => {
               ),
               div({ class: 'feed-main' },
                 p(`${new Date(m.date).toLocaleString()} — `, a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)),
-                p(m.message)
+                br,
+                p(...renderUrl(m.message))
               )
             )
           ))
@@ -379,9 +381,9 @@ exports.tribeView = async (tribe, userId, query) => {
     p(`${i18n.tribeIsAnonymousLabel}: ${tribe.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
     p(`${i18n.tribeModeLabel}: ${tribe.inviteMode.toUpperCase()}`),
     p(`${i18n.tribeLARPLabel}: ${tribe.isLARP ? i18n.tribeYes : i18n.tribeNo}`),
+    p(`${i18n.tribeLocationLabel}: ${tribe.location}`),
     img({ src: imageSrc, alt: tribe.title }),
     tribe.description ? p(...renderUrl(tribe.description)) : null,
-    p(`${i18n.tribeLocationLabel}: ${tribe.location}`),
     h2(`${i18n.tribeMembersCount}: ${tribe.members.length}`),
     tribe.tags && tribe.tags.filter(Boolean).length ? div(tribe.tags.filter(Boolean).map(tag =>
       a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link', style: 'margin-right:0.8em;margin-bottom:0.5em;' }, `#${tag}`)
@@ -390,7 +392,7 @@ exports.tribeView = async (tribe, userId, query) => {
     p(a({ class: 'user-link', href: `/author/${encodeURIComponent(tribe.author)}` }, tribe.author)),
     div({ class: 'tribe-feed-form' }, tribe.members.includes(config.keys.id)
       ? form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/message` },
-          textarea({ name: 'message', rows: 3, cols: 50, maxlength: 280, placeholder: i18n.tribeFeedMessagePlaceholder }),
+          textarea({ name: 'message', rows: 4, cols: 50, maxlength: 280, placeholder: i18n.tribeFeedMessagePlaceholder }),
           br,
           button({ type: 'submit' }, i18n.tribeFeedSend)
         )

+ 5 - 4
src/views/video_view.js

@@ -1,7 +1,8 @@
-const { form, button, div, h2, p, section, input, label, br, a, video: videoHyperaxe, span } = require("../server/node_modules/hyperaxe");
+const { form, button, div, h2, p, section, input, label, br, a, video: videoHyperaxe, span, textarea } = require("../server/node_modules/hyperaxe");
 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 userId = config.keys.id;
 
@@ -51,7 +52,7 @@ const renderVideoList = (filteredVideos, filter) => {
                 })
               )
             : p(i18n.videoNoFile),        
-          video.description?.trim() ? p(video.description) : null,
+          video.description?.trim() ? p(...renderUrl(video.description)) : null,
           video.tags?.length
             ? div({ class: "card-tags" },
                 video.tags.map(tag =>
@@ -93,7 +94,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(),
-      input({ type: "text", name: "description", placeholder: i18n.videoDescriptionPlaceholder, 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)
     )
   );
@@ -181,7 +182,7 @@ exports.singleVideoView = async (video, filter) => {
               })
             )
           : p(i18n.videoNoFile),
-        p(video.description),
+        p(...renderUrl(video.description)),
         video.tags?.length
             ? div({ class: "card-tags" },
                 video.tags.map(tag =>