Quellcode durchsuchen

Oasis release 0.5.1

psy vor 1 Woche
Ursprung
Commit
15dd733745

+ 15 - 0
docs/CHANGELOG.md

@@ -13,6 +13,21 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.5.1 - 2025-09-26
+
+### Added
+
+ + Activity level measurement (Inhabitants plugin).
+ + Home page settings (Settings plugin).
+
+### Fixed
+
+ + ECOIn wallet addresses (Banking plugin).
+ + Tribes view (Tribes plugin).
+ + Inhabitants view (Inhabitants plugin).
+ + Avatar view (Main module).
+ + Forum posts (Forums plugin).
+ + Tribes info display (Search plugin).
 
 ## v0.5.0 - 2025-09-20
 

+ 76 - 7
src/backend/backend.js

@@ -534,7 +534,9 @@ router
   
   //GET backend routes
   .get("/", async (ctx) => {
-    ctx.redirect("/activity"); // default view when starting Oasis
+    const currentConfig = getConfig();
+    const homePage = currentConfig.homePage || "activity";
+    ctx.redirect(`/${homePage}`);
   })
   .get("/robots.txt", (ctx) => {
     ctx.body = "User-agent: *\nDisallow: /";
@@ -726,7 +728,31 @@ router
     const avatarUrl = getAvatarUrl(image);
     const ecoAddress = await bankingModel.getUserAddress(feedId);
     const { ecoValue, karmaScore } = await bankingModel.getBankingData(feedId);
-    ctx.body = authorView({
+    const normTs = (t) => {
+      const n = Number(t || 0);
+      if (!isFinite(n) || n <= 0) return 0;
+      return n < 1e12 ? n * 1000 : n;
+    };
+    const pull = require('../server/node_modules/pull-stream');
+    const ssbClientGUI = require('../client/gui');
+    const coolerInstance = ssbClientGUI({ offline: require('../server/ssb_config').offline });
+    const ssb = await coolerInstance.open();
+    const latestFromStream = await new Promise((resolve) => {
+      pull(
+        ssb.createUserStream({ id: feedId, reverse: true }),
+        pull.filter(m => m && m.value && m.value.content && m.value.content.type !== 'tombstone'),
+        pull.take(1),
+        pull.collect((err, arr) => {
+          if (err || !arr || !arr.length) return resolve(0);
+          const m = arr[0];
+          const ts = normTs((m.value && m.value.timestamp) || m.timestamp);
+          resolve(ts || null);
+        })
+      );
+    });
+    const days = latestFromStream ? (Date.now() - latestFromStream) / 86400000 : Infinity;
+    const lastActivityBucket = days < 14 ? 'green' : days < 182.5 ? 'orange' : 'red';
+    ctx.body = await authorView({
       feedId,
       messages,
       firstPost,
@@ -736,7 +762,8 @@ router
       avatarUrl,
       relationship,
       ecoAddress,
-      karmaScore
+      karmaScore,
+      lastActivityBucket
     });
   })
   .get("/search", async (ctx) => {
@@ -947,7 +974,7 @@ router
     ctx.body = await inhabitantsProfileView({ about, cv, feed }, currentUserId);
   })
   .get('/tribes', async ctx => {
-    const filter = ctx.query.filter || 'recent';
+    const filter = ctx.query.filter || 'all';
     const search = ctx.query.search || ''; 
     const tribes = await tribesModel.listAll();
     let filteredTribes = tribes;
@@ -1006,8 +1033,42 @@ router
     const lastPost = await post.latestBy(myFeedId)
     const avatarUrl = getAvatarUrl(image)
     const ecoAddress = await bankingModel.getUserAddress(myFeedId)
-    const { karmaScore, ecoValue } = await bankingModel.getBankingData(myFeedId);
-    
+    const { karmaScore } = await bankingModel.getBankingData(myFeedId)
+    const normTs = (t) => {
+    const n = Number(t || 0)
+      if (!isFinite(n) || n <= 0) return 0
+      return n < 1e12 ? n * 1000 : n
+    }
+    const pickTs = (obj) => {
+      if (!obj) return 0
+      const v = obj.value || obj
+      return normTs(v.timestamp || v.ts || v.time || (v.meta && v.meta.timestamp) || 0)
+    }
+    const msgTs = Array.isArray(messages) && messages.length ? Math.max(...messages.map(pickTs)) : 0
+    const tsLastPost = pickTs(lastPost)
+    const tsFirstPost = pickTs(firstPost)
+    let lastActivityTs = Math.max(msgTs, tsLastPost, tsFirstPost)
+
+    if (!lastActivityTs) {
+      const pull = require("../server/node_modules/pull-stream")
+      const ssbClientGUI = require("../client/gui")
+      const coolerInstance = ssbClientGUI({ offline: require("../server/ssb_config").offline })
+      const ssb = await coolerInstance.open()
+      lastActivityTs = await new Promise((resolve) => {
+        pull(
+          ssb.createUserStream({ id: myFeedId, reverse: true }),
+          pull.filter(m => m && m.value && m.value.content && m.value.content.type !== "tombstone"),
+          pull.take(1),
+          pull.collect((err, arr) => {
+            if (err || !arr || !arr.length) return resolve(0)
+            const m = arr[0]
+            resolve(normTs((m.value && m.value.timestamp) || m.timestamp))
+          })
+        )
+      })
+    }
+    const days = lastActivityTs ? (Date.now() - lastActivityTs) / 86400000 : Infinity
+    const lastActivityBucket = days < 14 ? "green" : days < 182.5 ? "orange" : "red"
     ctx.body = await authorView({
       feedId: myFeedId,
       messages,
@@ -1018,7 +1079,8 @@ router
       avatarUrl,
       relationship: { me: true },
       ecoAddress,
-      karmaScore
+      karmaScore,
+      lastActivityBucket
     })
   })
   .get("/profile/edit", async (ctx) => {
@@ -3069,6 +3131,13 @@ router
     }
     ctx.redirect("/settings");
   })
+  .post("/settings/home-page", koaBody(), async (ctx) => {
+    const homePage = String(ctx.request.body.homePage || "").trim();
+    const currentConfig = getConfig();
+    currentConfig.homePage = homePage || "activity";
+    saveConfig(currentConfig);
+    ctx.redirect("/settings");
+  })
   .post("/settings/rebuild", async (ctx) => {
     meta.rebuild();
     ctx.redirect("/settings");

+ 92 - 47
src/client/assets/styles/style.css

@@ -486,6 +486,20 @@ thread-container {
   margin-bottom: 10px;
 }
 
+.profile-metrics {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.profile-metrics .inhabitant-last-activity {
+  align-self: center;
+}
+
+.inhabitant-last-activity .label {
+  margin-right: 6px;
+}
+
 .avatar-container {
   display: flex;
   flex-direction: column;
@@ -493,7 +507,7 @@ thread-container {
   padding: 20px;
   border-radius: 16px;
   box-shadow: 0 8px 20px rgba(255, 165, 0, 0.25);
-  max-width: 180px;
+  max-width: 300px;
   margin: 0 auto 20px auto;
   transition: transform 0.3s ease;
 }
@@ -764,73 +778,77 @@ button.create-button:hover {
 }
 
 /* Inhabitants */
-.inhabitants-header {
-  margin-bottom: 1rem;
-}
-
-.filters {
-  margin-top: 0.5rem;
-  margin-bottom: 1rem;
-}
-
-.inhabitants-container {
+.inhabitant-card {
   display: flex;
-  flex-wrap: wrap;
+  flex-wrap: nowrap;
+  align-items: flex-start;
   gap: 1rem;
 }
 
-.inhabitant-card {
+.inhabitant-left {
   display: flex;
-  align-items: flex-start;
-  background-color: #1e1e1e;
-  border-radius: 12px;
-  padding: 1rem;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+  width: 300px;
+  flex: 0 0 300px;
+}
+
+.inhabitant-left a {
+  display: block;
   width: 100%;
-  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
-  transition: transform 0.2s ease;
+}
+
+.inhabitant-left h2 {
+  margin: 4px 0 0;
+  line-height: 1.1;
 }
 
 .inhabitant-photo {
-  width: 64px;
-  height: 64px !important;;
+  width: 100% !important;
+  height: 100% !important;
+  border-radius: 50%;
+  border: 3px solid #ffa500;
+  display: block;
+  margin: 0 auto;
+}
+
+.inhabitant-photo-details {
+  width: 100% !important;
+  height: 100% !important;
+  max-width: none !important;
   object-fit: cover;
   border-radius: 50%;
-  margin-right: 1rem;
   border: 2px solid #ffa500;
+  display: block;
+  margin: 0 auto;
 }
 
-.inhabitant-info {
+.inhabitant-details {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
   flex: 1;
+  min-width: 0;
 }
 
-.inhabitant-id {
-  font-weight: bold;
-  color: #ffa500;
-  text-decoration: none;
-  font-size: 1.1rem;
-}
-
-.inhabitant-bio {
-  margin-top: 0.25rem;
-  color: #ccc;
-  font-size: 0.95rem;
-}
-
-.inhabitant-status {
-  margin-top: 0.5rem;
+.inhabitant-last-activity {
+  margin-top: 4px;
   display: flex;
+  border: 0;
   align-items: center;
+  gap: 6px;
 }
 
-.status-online {
-  color: #4caf50;
-  font-weight: bold;
+.activity-dot {
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
 }
 
-.status-offline {
-  color: #999;
-  font-weight: normal;
-}
+.activity-dot.green { background-color: #2ecc71; }
+.activity-dot.orange { background-color: #f39c12; }
+.activity-dot.red { background-color: #e74c3c; }
 
 /* Documents */
 .pdf-viewer-container {
@@ -1045,11 +1063,32 @@ display:flex; gap:8px; margin-top:16px;
 }
 
 .tribes-container{ 
-display:grid; grid-template-columns:repeat(auto-fill,minmax(400px,1fr)); gap:20px; margin-top:16px;
+  display:grid; grid-template-columns:repeat(auto-fill,minmax(400px,1fr)); gap:20px; margin-top:16px;
  }
  
 .tribe-card { 
-padding:16px; background:#2c2f33; border-radius:8px; border:1px solid #444; 
+  padding:16px; background:#2c2f33; border-radius:8px; border:1px solid #444; 
+}
+
+.tribe-details {
+  display: flex;
+  background: #2c2f33;
+  border-radius: 12px;
+  padding: 24px;
+  grid-template-columns: 1fr 2fr;
+  gap: 24px;
+}
+
+.tribe-main {
+  flex-direction: column;
+  gap: 16px;
+}
+
+.tribe-side {
+  background: #2c2f33;
+  width: 60%;
+  flex-direction: column;
+  gap: 16px;
 }
 
 .filter-btn,.create-button,.edit-btn,.delete-btn,.join-btn,.leave-btn,.buy-btn                                  
@@ -1616,6 +1655,11 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   gap: 1.1em;
   background: none;
 }
+.visit-forum-form {
+  display: flex;
+  justify-content: center;
+  margin-top: 10px; 
+}
 .forum-score-col {
   min-width: 78px;
   display: flex;
@@ -1713,6 +1757,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 }
 .forum-meta {
   font-size: 1em;
+  align-items: center;
   color: #ffd740;
   margin: 0.1em 0 0 0;
   display: flex;

+ 32 - 26
src/client/assets/translations/oasis_en.js

@@ -202,7 +202,7 @@ module.exports = {
     legacyImportButton: "Import",
     ssbLogStream: "Blokchain",
     ssbLogStreamDescription: "Configure the message limit for Blockchain streams.",
-    saveSettings: "Save Settings",
+    saveSettings: "Save settings",
     exportTitle: "Export data",
     exportDescription: "Set password (min 32 characters long) to encrypt your key",
     exportDataTitle: "Backup",
@@ -210,7 +210,7 @@ module.exports = {
     exportDataButton: "Download database",
     pubWallet: "PUB Wallet",
     pubWalletDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
-    pubWalletConfiguration: "Save Configuration",
+    pubWalletConfiguration: "Save configuration",
     importTitle: "Import data",
     importDescription: "Import your encrypted secret (private key) to enable your avatar",
     importAttach: "Attach encrypted file (.enc)",    
@@ -251,6 +251,9 @@ module.exports = {
     indexes: "Indexes",
     indexesDescription:
       "Rebuilding your indexes is safe, and may fix some types of bugs.",
+    homePageTitle: "Home",
+    homePageDescription: "Select which module you want as your home page.",
+    saveHomePage: "Set home",
     //invites
     invites: "Invites",
     invitesTitle: "Invites",
@@ -584,39 +587,41 @@ module.exports = {
     audioNotSupported: "Your browser does not support the audio element.",
     noAudios: "No audios available.",
     // inhabitants
-    yourContacts:       "Your Contacts",
-    allInhabitants:     "Inhabitants",
-    allCVs:             "All CVs",
-    discoverPeople:     "Discover inhabitants in your network.",
+    yourContacts: "Your Contacts",
+    allInhabitants: "Inhabitants",
+    allCVs: "All CVs",
+    discoverPeople: "Discover inhabitants in your network.",
     allInhabitantsButton: "ALL",
-    contactsButton:     "SUPPORTS",
-    CVsButton:          "CVs",
-    matchSkills:        "Match Skills",
-    matchSkillsButton:  "MATCH SKILLS",
-    suggestedButton:    "SUGGESTED",
-    searchInhabitantsPlaceholder:  "FILTER inhabitants BY NAME …",
-    filterLocation:     "FILTER inhabitants BY LOCATION …",
-    filterLanguage:     "FILTER inhabitants BY LANGUAGE …",
-    filterSkills:       "FILTER inhabitants BY SKILLS …",
-    applyFilters:       "Apply Filters",
-    locationLabel:      "Location",
-    languagesLabel:     "Languages",
-    skillsLabel:        "Skills",
-    commonSkills:       "Common Skills",
-    mutualFollowers:    "Mutual Followers",
+    contactsButton: "SUPPORTS",
+    CVsButton: "CVs",
+    matchSkills: "Match Skills",
+    matchSkillsButton: "MATCH SKILLS",
+    suggestedButton: "SUGGESTED",
+    searchInhabitantsPlaceholder: "FILTER inhabitants BY NAME …",
+    filterLocation: "FILTER inhabitants BY LOCATION …",
+    filterLanguage: "FILTER inhabitants BY LANGUAGE …",
+    filterSkills: "FILTER inhabitants BY SKILLS …",
+    applyFilters: "Apply Filters",
+    locationLabel: "Location",
+    languagesLabel: "Languages",
+    skillsLabel: "Skills",
+    commonSkills: "Common Skills",
+    mutualFollowers: "Mutual Followers",
     latestInteractions: "Latest Interactions",
-    viewAvatar:        "View Avatar",
-    viewCV:            "View CV",
+    viewAvatar: "View Avatar",
+    viewCV: "View CV",
     suggestedSectionTitle: "Suggested",
     topkarmaSectionTitle: "Top Karma",
+    topactivitySectionTitle: "Top Activity",
     blockedSectionTitle: "Blocked",
     gallerySectionTitle: "GALLERY",
-    blockedButton:       "BLOCKED",
-    blockedLabel:        "Blocked User",
+    blockedButton: "BLOCKED",
+    blockedLabel: "Blocked User",
     inhabitantviewDetails: "View Details",
     viewDetails: "View Details",
     oasisId: "ID",
-    noInhabitantsFound:    "No inhabitants found, yet.",
+    noInhabitantsFound: "No inhabitants found, yet.",
+    inhabitantActivityLevel: "Activity Level",
     //trending
     trendingTitle: "Trending",
     exploreTrending: "Explore the most popular content in your network.",
@@ -950,6 +955,7 @@ module.exports = {
     forumSendButton: "Send",
     forumVisitForum: "Visit Forum",
     noForums: "No forums found.",
+    forumVisitButton: "Visit forum",
     //images
     imageTitle: "Images",
     imagePluginTitle: "Title",

+ 6 - 0
src/client/assets/translations/oasis_es.js

@@ -248,6 +248,9 @@ module.exports = {
     indexes: "Índices",
     indexesDescription:
       "Reconstruir índices es seguro, y puede arreglar algunos errores en tu blockchain.",
+    homePageTitle: "Página de inicio",
+    homePageDescription: "Selecciona qué módulo quieres como tu página de inicio.",
+    saveHomePage: "Establecer página de inicio",
     //invites
     invites: "Invitaciones",
     invitesTitle: "Invitaciones",
@@ -605,6 +608,7 @@ module.exports = {
     viewCV:            "Ver CV",
     suggestedSectionTitle: "Sugeridos",
     topkarmaSectionTitle: "Top Karma",
+    topactivitySectionTitle: "Top Actividad",
     blockedSectionTitle: "Bloqueados",
     gallerySectionTitle: "GALERÍA",
     blockedButton:       "BLOQUEADOS",
@@ -613,6 +617,7 @@ module.exports = {
     viewDetails: "Ver Detalles",
     oasisId: "ID",
     noInhabitantsFound:    "No se encontraron habitantes, aún.",
+    inhabitantActivityLevel: "Nivel Actividad",
     //trending
     trendingTitle: "Tendencias",
     exploreTrending: "Explora el contenido más popular en tu red.",
@@ -946,6 +951,7 @@ module.exports = {
     forumSendButton: "Enviar",
     forumVisitForum: "Visitar Foro",
     noForums: "No hay foros disponibles, aún.",
+    forumVisitButton: "Visitar foro", 
     // images
     imageTitle: "Imágenes",
     imagePluginTitle: "Título",

+ 6 - 0
src/client/assets/translations/oasis_eu.js

@@ -248,6 +248,9 @@ module.exports = {
     indexes: "Indizeak",
     indexesDescription:
       "Indizeak birsortzea segurua da eta hutsegite mota batzuk konpon ditzake.",
+    homePageTitle: "Hasierako orria",
+    homePageDescription: "Hautatu nahi duzun moduluaren orria hasiera gisa.",
+    saveHomePage: "Gorde hasierako orria",
     //invites
     invites: "Gonbidapenak",
     invitesTitle: "Gonbidapenak",
@@ -606,6 +609,7 @@ module.exports = {
     viewCV:            "Ikusi CV-a",
     suggestedSectionTitle: "Gomendatuta",
     topkarmaSectionTitle: "Top Karma",
+    topactivitySectionTitle: "Top Jarduera",
     blockedSectionTitle: "Blokeatuta",
     gallerySectionTitle: "GALERIA",
     blockedButton:       "BLOKETATUTA",
@@ -614,6 +618,7 @@ module.exports = {
     viewDetails: "Ikusi Xehetasunak",
     oasisId: "ID-a",
     noInhabitantsFound:  "Bizilagunik ez, oraindik.",
+    inhabitantActivityLevel: "Jarduera Maila",
     //trending
     trendingTitle: "Pil-pilean",
     exploreTrending: "Aurkitu pil-pileko edukia zure sarean.",
@@ -947,6 +952,7 @@ module.exports = {
     forumSendButton: "Bidali",
     forumVisitForum: "Bisitatu Foroaren",
     noForums: "Ez da fororik aurkitu.",
+    forumVisitButton: "Foroa bisitatu", 
     // images
     imageTitle: "Irudiak",
     imagePluginTitle: "Izenburua",

+ 6 - 0
src/client/assets/translations/oasis_fr.js

@@ -248,6 +248,9 @@ module.exports = {
     indexes: "Index",
     indexesDescription:
       "Reconstruire les index est sans risque et peut corriger certains problèmes dans votre blockchain.",
+    homePageTitle: "Page d'accueil",
+    homePageDescription: "Sélectionnez le module que vous souhaitez comme page d'accueil.",
+    saveHomePage: "Enregistrer la page d'accueil",
     //invites
     invites: "Invitations",
     invitesTitle: "Invitations",
@@ -605,6 +608,7 @@ module.exports = {
     viewCV:            "Voir le CV",
     suggestedSectionTitle: "Suggérés",
     topkarmaSectionTitle: "Top Karma",
+    topactivitySectionTitle: "Top activité",
     blockedSectionTitle: "Bloqués",
     gallerySectionTitle: "GALERIE",
     blockedButton:       "BLOQUÉS",
@@ -613,6 +617,7 @@ module.exports = {
     viewDetails: "Voir les détails",
     oasisId: "ID",
     noInhabitantsFound:    "Aucun habitant trouvé pour l’instant.",
+    inhabitantActivityLevel: "Niveau Activité",
     //trending
     trendingTitle: "Tendances",
     exploreTrending: "Explorez le contenu le plus populaire dans votre réseau.",
@@ -946,6 +951,7 @@ module.exports = {
     forumSendButton: "Envoyer",
     forumVisitForum: "Visiter le forum",
     noForums: "Aucun forum disponible pour l’instant.",
+    forumVisitButton: "Visiter le forum",
     // images
     imageTitle: "Images",
     imagePluginTitle: "Titre",

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

@@ -47,7 +47,7 @@ if (!fs.existsSync(configFilePath)) {
       "url": "http://localhost:7474",
       "user": "",
       "pass": "",
-      "fee": "1"
+      "fee": "5"
     },
     "walletPub": {
       "url": "",
@@ -59,7 +59,8 @@ if (!fs.existsSync(configFilePath)) {
     },
     "ssbLogStream": {
       "limit": 2000
-    }
+    },
+    "homePage": "activity"
   };
   fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
 }

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

@@ -41,7 +41,7 @@
     "url": "http://localhost:7474",
     "user": "",
     "pass": "",
-    "fee": ""
+    "fee": "5"
   },
   "walletPub": {
     "url": "",
@@ -53,5 +53,6 @@
   },
   "ssbLogStream": {
     "limit": 2000
-  }
+  },
+  "homePage": "activity"
 }

+ 1 - 1
src/configs/wallet-addresses.json

@@ -1 +1 @@
-{}
+{}

+ 41 - 9
src/models/inhabitants_model.js

@@ -37,6 +37,34 @@ module.exports = ({ cooler }) => {
     });
   }
 
+  async function getLastActivityTimestamp(feedId) {
+    const ssbClient = await openSsb();
+    const norm = (t) => (t && t < 1e12 ? t * 1000 : t || 0);
+    return new Promise((resolve) => {
+      pull(
+        ssbClient.createUserStream({ id: feedId, reverse: true }),
+        pull.filter(m => m && m.value && m.value.content && m.value.content.type !== 'tombstone'),
+        pull.take(1),
+        pull.collect((err, arr) => {
+          if (err || !arr || !arr.length) return resolve(null);
+          const m = arr[0];
+          const ts = norm((m.value && m.value.timestamp) || m.timestamp);
+          resolve(ts || null);
+        })
+      );
+    });
+  }
+
+  function bucketLastActivity(ts) {
+    if (!ts) return { bucket: 'red', range: '≥6m' };
+    const now = Date.now();
+    const delta = Math.max(0, now - ts);
+    const days = delta / 86400000;
+    if (days < 14) return { bucket: 'green', range: '<2w' };
+    if (days < 182.5) return { bucket: 'orange', range: '2w–6m' };
+    return { bucket: 'red', range: '≥6m' };
+  }
+
   return {
     async listInhabitants(options = {}) {
       const { filter = 'all', search = '', location = '', language = '', skills = '' } = options;
@@ -46,8 +74,8 @@ module.exports = ({ cooler }) => {
       const fetchUserImage = (feedId) => {
         return Promise.race([
           about.image(feedId),
-          timeoutPromise(5000) 
-        ]).catch(() => '/assets/images/default-avatar.png'); 
+          timeoutPromise(5000)
+        ]).catch(() => '/assets/images/default-avatar.png');
       };
       if (filter === 'GALLERY') {
         const feedIds = await new Promise((res, rej) => {
@@ -72,7 +100,7 @@ module.exports = ({ cooler }) => {
           uniqueFeedIds.map(async (feedId) => {
             const name = await about.name(feedId);
             const description = await about.description(feedId);
-            const image = await fetchUserImage(feedId); 
+            const image = await fetchUserImage(feedId);
             const photo =
               typeof image === 'string'
                 ? `/image/256/${encodeURIComponent(image)}`
@@ -82,7 +110,7 @@ module.exports = ({ cooler }) => {
         );
         return users;
       }
-      if (filter === 'all' || filter === 'TOP KARMA') {
+      if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY') {
         const feedIds = await new Promise((res, rej) => {
           pull(
             ssbClient.createLogStream({ limit: logLimit, reverse: true }),
@@ -109,7 +137,9 @@ module.exports = ({ cooler }) => {
               typeof image === 'string'
                 ? `/image/256/${encodeURIComponent(image)}`
                 : '/assets/images/default-avatar.png';
-            return { id: feedId, name, description, photo };
+            const lastActivityTs = await getLastActivityTimestamp(feedId);
+            const { bucket, range } = bucketLastActivity(lastActivityTs);
+            return { id: feedId, name, description, photo, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
           })
         );
         users = Array.from(new Map(users.filter(u => u && u.id).map(u => [u.id, u])).values());
@@ -121,14 +151,17 @@ module.exports = ({ cooler }) => {
             u.id?.toLowerCase().includes(q)
           );
         }
-        const withKarma = await Promise.all(users.map(async u => {
+        const withMetrics = await Promise.all(users.map(async u => {
           const karmaScore = await getLastKarmaScore(u.id);
           return { ...u, karmaScore };
         }));
         if (filter === 'TOP KARMA') {
-          return withKarma.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
+          return withMetrics.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
+        }
+        if (filter === 'TOP ACTIVITY') {
+          return withMetrics.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
         }
-        return withKarma;
+        return withMetrics;
       }
       if (filter === 'contacts') {
         const all = await this.listInhabitants({ filter: 'all' });
@@ -302,4 +335,3 @@ module.exports = ({ cooler }) => {
     }
   };
 };
-

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

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

+ 1 - 1
src/server/package.json

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

+ 10 - 7
src/views/activity_view.js

@@ -124,22 +124,25 @@ function renderActionCards(actions, userId) {
     }
 
     if (type === 'tribe') {
-      const { title, image, description, tags, isLARP, inviteMode, isAnonymous, members } = content;
+      const { title, image, description, location, tags, isLARP, inviteMode, isAnonymous, members } = content;
       const validTags = Array.isArray(tags) ? tags : [];
       cardBody.push(
         div({ class: 'card-section tribe' },
           h2({ class: 'tribe-title' },
             a({ href: `/tribe/${encodeURIComponent(action.id)}`, class: "user-link" }, title)
           ),
-          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
+           div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' },
+            location ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.tribeLocationLabel.toUpperCase()) + ':'), span({ class: 'card-value' }, ...renderUrl(location))) : "",
+            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) ? h2(`${i18n.tribeMembersCount}: ${members.length}`) : "",
+          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}`)))

+ 10 - 10
src/views/banking_views.js

@@ -251,16 +251,16 @@ const renderAddresses = (data, userId) => {
                     td(a({ href: `/author/${encodeURIComponent(r.id)}`, class: "user-link" }, r.id)),
                     td(r.address),
                     td(r.source === "local" ? i18n.bankLocal : i18n.bankFromOasis),
-                    td(
-                      div({ class: "row-actions" },
-                        r.source === "local"
-                          ? form({ method: "POST", action: "/banking/addresses/delete", class: "addr-del" },
-                              input({ type: "hidden", name: "userId", value: r.id }),
-                              button({ type: "submit", class: "delete-btn", onclick: `return confirm(${JSON.stringify(i18n.bankAddressDeleteConfirm)})` }, i18n.bankAddressDelete)
-                            )
-                          : null
-                      )
-                    )
+			td(
+			  r.source === "local"
+			    ? div({ class: "row-actions" },
+				form({ method: "POST", action: "/banking/addresses/delete", class: "addr-del" },
+				  input({ type: "hidden", name: "userId", value: r.id }),
+				  button({ type: "submit", class: "delete-btn", onclick: `return confirm(${JSON.stringify(i18n.bankAddressDeleteConfirm)})` }, i18n.bankAddressDelete)
+				)
+			      )
+			    : null
+			)
                   )
                 )
               )

+ 6 - 3
src/views/forum_view.js

@@ -140,7 +140,7 @@ const renderThread = (nodes, level = 0, forumId) => {
         div({ class: 'new-reply' },
           form({
             method: 'POST',
-            action: `/forum/${forumId}/message`,
+            action: `/forum/${encodeURIComponent(forumId)}/message`,
             class: 'comment-form'
           },
             input({ type: 'hidden', name: 'parentId', value: m.key }),
@@ -188,7 +188,10 @@ const renderForumList = (forums, currentFilter) =>
               span({ class: 'forum-participants' },
                 `${i18n.forumParticipants.toUpperCase()}: ${f.participants?.length || 1}`),
               span({ class: 'forum-messages' },
-                `${i18n.forumMessages.toUpperCase()}: ${f.messagesCount - 1}`)
+                `${i18n.forumMessages.toUpperCase()}: ${f.messagesCount - 1}`),
+              form({ method: 'GET', action: `/forum/${encodeURIComponent(f.key)}`, style: 'visit-forum-form' },
+                button({ type: 'submit', class: 'filter-btn' }, i18n.forumVisitButton)
+              )
             ),
             div({ class: 'forum-footer' },
               span({ class: 'date-link' },
@@ -330,7 +333,7 @@ exports.singleForumView = async (forum, messagesData, currentFilter) =>
       },
         form({
           method: 'POST',
-          action: `/forum/${forum.key}/message`,
+          action: `/forum/${encodeURIComponent(forum.key)}/message`,
           class: 'new-message-form'
         },
           textarea({

+ 48 - 7
src/views/inhabitants_view.js

@@ -21,10 +21,38 @@ const generateFilterButtons = (filters, currentFilter) =>
     )
   );
 
+function formatRange(bucket, i18n) {
+  const ws = i18n.weeksShort || 'w';
+  const ms = i18n.monthsShort || 'm';
+  if (bucket === 'green') return `<2 ${ws}`;
+  if (bucket === 'orange') return `2 ${ws}–6 ${ms}`;
+  return `≥6 ${ms}`;
+}
+
+function lastActivityBadge(user) {
+  const label = i18n.inhabitantActivityLevel;
+  const bucket = user.lastActivityBucket || 'red';
+  const dotClass = bucket === 'green' ? 'green' : bucket === 'orange' ? 'orange' : 'red';
+  return div(
+    { class: 'inhabitant-last-activity' },
+    span({ class: 'label' }, `${label}: `),
+    span({ class: `activity-dot ${dotClass}` }, '')
+  );
+}
+
 const renderInhabitantCard = (user, filter, currentUserId) => {
   const isMe = user.id === currentUserId;
   return div({ class: 'inhabitant-card' },
-    img({ class: 'inhabitant-photo', src: resolvePhoto(user.photo) }),
+    div({ class: 'inhabitant-left' },
+      a(
+         { href: `/author/${encodeURIComponent(user.id)}` },
+         img({ class: 'inhabitant-photo-details', src: resolvePhoto(user.photo), alt: user.name })
+      ),
+      br(),
+      span(`${i18n.bankingUserEngagementScore}: `),
+     h2(strong(typeof user.karmaScore === 'number' ? user.karmaScore : 0)),
+     lastActivityBadge(user)
+    ),
     div({ class: 'inhabitant-details' },
       h2(user.name),
       user.description ? p(...renderUrl(user.description)) : null,
@@ -46,10 +74,6 @@ const renderInhabitantCard = (user, filter, currentUserId) => {
         : div({ class: "eco-wallet" },
             p(i18n.ecoWalletNotConfigured || "ECOin Wallet not configured")
           ),
-      p(
-        `${i18n.bankingUserEngagementScore}: `,
-        strong(typeof user.karmaScore === 'number' ? user.karmaScore : 0)
-      ),
       div(
         { class: 'cv-actions', style: 'display:flex; flex-direction:column; gap:8px; margin-top:12px;' },
         isMe
@@ -101,10 +125,11 @@ exports.inhabitantsView = (inhabitants, filter, query, currentUserId) => {
                : filter === 'blocked'     ? i18n.blockedSectionTitle
                : filter === 'GALLERY'     ? i18n.gallerySectionTitle
                : filter === 'TOP KARMA'    ? i18n.topkarmaSectionTitle
+               : filter === 'TOP ACTIVITY' ? (i18n.topactivitySectionTitle)
                                           : i18n.allInhabitants;
 
   const showCVFilters = filter === 'CVs' || filter === 'MATCHSKILLS';
-  const filters = ['all', 'TOP KARMA', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
+  const filters = ['all', 'TOP ACTIVITY', 'TOP KARMA', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
 
   return template(
     title,
@@ -170,6 +195,16 @@ exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }, currentUse
   const isMe = id === currentUserId;
   const title = i18n.inhabitantProfileTitle || i18n.inhabitantviewDetails;
 
+  const lastFromFeed = Array.isArray(feed) && feed.length ? feed.reduce((mx, m) => Math.max(mx, m.value?.timestamp || 0), 0) : null;
+  const now = Date.now();
+  const delta = lastFromFeed ? Math.max(0, now - lastFromFeed) : Number.POSITIVE_INFINITY;
+  const days = delta / 86400000;
+  const bucket = days < 14 ? 'green' : days < 182.5 ? 'orange' : 'red';
+  const ws = i18n.weeksShort || 'w';
+  const ms = i18n.monthsShort || 'm';
+  const range = bucket === 'green' ? `<2 ${ws}` : bucket === 'orange' ? `2 ${ws}–6 ${ms}` : `≥6 ${ms}`;
+  const dotClass = bucket === 'green' ? 'green' : bucket === 'orange' ? 'orange' : 'red';
+
   return template(
     name,
     section(
@@ -181,14 +216,20 @@ exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }, currentUse
         ...generateFilterButtons(['all', 'TOP KARMA', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
       ),
       div({ class: 'inhabitant-card', style: 'margin-top:32px;' },
-        img({ class: 'inhabitant-photo', src: image, alt: name }),
         div({ class: 'inhabitant-details' },
+          img({ class: 'inhabitant-photo-details', src: image, alt: name }),
           h2(name),
           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(', ').toUpperCase()}`) : null,
           skills.length ? p(`${i18n.skillsLabel}: ${skills.join(', ')}`) : null,
+          div(
+            { class: 'inhabitant-last-activity' },
+            span({ class: 'label' }, `${i18n.inhabitantActivityLevel}:`),
+            span({ class: `activity-dot ${dotClass}` }, ''),
+            span({ class: 'range' }, range)
+          ),
           status ? p(`${i18n.statusLabel || 'Status'}: ${status}`) : null,
           preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${preferences}`) : null,
           createdAt ? p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`) : null,

+ 46 - 99
src/views/main_views.js

@@ -864,143 +864,90 @@ exports.authorView = ({
   name,
   relationship,
   ecoAddress,
-  karmaScore = 0
+  karmaScore = 0,
+  lastActivityBucket
 }) => {
   const mention = `[@${name}](${feedId})`;
   const markdownMention = highlightJs.highlight(mention, { language: "markdown", ignoreIllegals: true }).value;
 
   const contactForms = [];
-
   const addForm = ({ action }) =>
     contactForms.push(
       form(
-        {
-          action: `/${action}/${encodeURIComponent(feedId)}`,
-          method: "post",
-        },
-        button(
-          {
-            type: "submit",
-          },
-          i18n[action]
-        )
+        { action: `/${action}/${encodeURIComponent(feedId)}`, method: "post" },
+        button({ type: "submit" }, i18n[action])
       )
     );
 
   if (relationship.me === false) {
-    if (relationship.following) {
-      addForm({ action: "unfollow" });
-    } else if (relationship.blocking) {
-      addForm({ action: "unblock" });
-    } else {
-      addForm({ action: "follow" });
-      addForm({ action: "block" });
-    }
+    if (relationship.following) addForm({ action: "unfollow" });
+    else if (relationship.blocking) addForm({ action: "unblock" });
+    else { addForm({ action: "follow" }); addForm({ action: "block" }) }
   }
 
   const relationshipMessage = (() => {
     if (relationship.me) return i18n.relationshipYou;
     const following = relationship.following === true;
     const followsMe = relationship.followsMe === true;
-    if (following && followsMe) {
-      return i18n.relationshipMutuals;
-    }
-    const messages = [];
-    messages.push(
-      following
-        ? i18n.relationshipFollowing
-        : i18n.relationshipNone
-    );
-    messages.push(
-      followsMe
-        ? i18n.relationshipTheyFollow
-        : i18n.relationshipNotFollowing
-    );
-    return messages.join(". ") + ".";
+    if (following && followsMe) return i18n.relationshipMutuals;
+    const messagesArr = [];
+    messagesArr.push(following ? i18n.relationshipFollowing : i18n.relationshipNone);
+    messagesArr.push(followsMe ? i18n.relationshipTheyFollow : i18n.relationshipNotFollowing);
+    return messagesArr.join(". ") + ".";
   })();
 
+  const bucket = lastActivityBucket || 'red';
+  const dotClass = bucket === "green" ? "green" : bucket === "orange" ? "orange" : "red";
+
   const prefix = section(
     { class: "message" },
     div(
       { class: "profile" },
       div({ class: "avatar-container" },
-        img({ class: "avatar", src: avatarUrl }),
+        img({ class: "inhabitant-photo-details", src: avatarUrl }),
         h1({ class: "name" }, name),
       ),
-      pre({
-        class: "md-mention",
-        innerHTML: markdownMention,
-      })
+      pre({ class: "md-mention", innerHTML: markdownMention }),
+      p(a({ class: "user-link", href: `/author/${encodeURIComponent(feedId)}` }, feedId)),
+      div({ class: "profile-metrics" },
+        p(`${i18n.bankingUserEngagementScore}: `, strong(karmaScore !== undefined ? karmaScore : 0)),
+        div({ class: "inhabitant-last-activity" },
+          span({ class: "label" }, `${i18n.inhabitantActivityLevel}:`),
+          span({ class: `activity-dot ${dotClass}` }, "")
+        ),
+        ecoAddress
+          ? div({ class: "eco-wallet" }, p(`${i18n.bankWalletConnected}: `, strong(ecoAddress)))
+          : div({ class: "eco-wallet" }, p(i18n.ecoWalletNotConfigured || "ECOin Wallet not configured"))
+      )
     ),
     description !== "" ? article({ innerHTML: markdown(description) }) : null,
     footer(
       div(
         { class: "profile" },
         ...contactForms.map(form => span({ style: "font-weight: bold;" }, form)),
-        relationship.me ? (
-          span({ class: "status you" }, i18n.relationshipYou)
-        ) : (
-          div({ class: "relationship-status" },
-            relationship.blocking && relationship.blockedBy
-              ? span({ class: "status blocked" }, i18n.relationshipMutualBlock)
-            : [
-                relationship.blocking
-                  ? span({ class: "status blocked" }, i18n.relationshipBlocking)
-                  : null,
-                relationship.blockedBy
-                  ? span({ class: "status blocked-by" }, i18n.relationshipBlockedBy)
-                  : null,
-                relationship.following && relationship.followsMe
-                  ? span({ class: "status mutual" }, i18n.relationshipMutuals)
-                  : [
-                      span(
-                        { class: "status supporting" },
-                        relationship.following
-                          ? i18n.relationshipFollowing
-                          : i18n.relationshipNone
-                      ),
-                      span(
-                        { class: "status supported-by" },
-                        relationship.followsMe
-                          ? i18n.relationshipTheyFollow
-                          : i18n.relationshipNotFollowing
-                      )
-                   ]
-                ]
-          )
-        ),
-	p(
-	  `${i18n.bankingUserEngagementScore}: `,
-	  strong(karmaScore !== undefined ? karmaScore : 0)
-	),
-	ecoAddress
-	  ? div({ class: "eco-wallet" },
-              p(`${i18n.bankWalletConnected}: `, strong(ecoAddress))
-	    )
-	  : div({ class: "eco-wallet" },
-	      p(i18n.ecoWalletNotConfigured || "ECOin Wallet not configured")
-	    ),
         relationship.me
-          ? a({ href: `/profile/edit`, class: "btn" }, nbsp, i18n.editProfile)
-          : null,
+          ? span({ class: "status you" }, i18n.relationshipYou)
+          : div({ class: "relationship-status" },
+              relationship.blocking && relationship.blockedBy
+                ? span({ class: "status blocked" }, i18n.relationshipMutualBlock)
+                : [
+                    relationship.blocking ? span({ class: "status blocked" }, i18n.relationshipBlocking) : null,
+                    relationship.blockedBy ? span({ class: "status blocked-by" }, i18n.relationshipBlockedBy) : null,
+                    relationship.following && relationship.followsMe
+                      ? span({ class: "status mutual" }, i18n.relationshipMutuals)
+                      : [
+                          span({ class: "status supporting" }, relationship.following ? i18n.relationshipFollowing : i18n.relationshipNone),
+                          span({ class: "status supported-by" }, relationship.followsMe ? i18n.relationshipTheyFollow : i18n.relationshipNotFollowing)
+                        ]
+                  ]
+            ),
+        relationship.me ? a({ href: `/profile/edit`, class: "btn" }, nbsp, i18n.editProfile) : null,
         a({ href: `/likes/${encodeURIComponent(feedId)}`, class: "btn" }, i18n.viewLikes),
-        !relationship.me
-          ? a(
-              {
-                href: `/pm?recipients=${encodeURIComponent(feedId)}`,
-                class: "btn"
-              },
-              i18n.pmCreateButton
-            )
-          : null
+        !relationship.me ? a({ href: `/pm?recipients=${encodeURIComponent(feedId)}`, class: "btn" }, i18n.pmCreateButton) : null
       )
     )
   );
 
-  const linkUrl = relationship.me
-    ? "/profile/"
-    : `/author/${encodeURIComponent(feedId)}/`;
-
   let items = messages.map((msg) => post({ msg }));
   if (items.length === 0) {
     if (lastPost === undefined) {
@@ -1060,7 +1007,7 @@ exports.authorView = ({
   }
 
   return template(i18n.profile, prefix, items);
-};
+}
 
 exports.previewCommentView = async ({
   previewData,

+ 25 - 16
src/views/search_view.js

@@ -2,6 +2,7 @@ const { form, button, div, h2, p, section, input, select, option, img, audio: au
 const { template, i18n } = require('./main_views');
 const moment = require("../server/node_modules/moment");
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
+const { renderUrl } = require('../backend/renderUrl');
 
 const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = [], hashtag = null, results = {}, resultCount = "10" }) => {
   const searchInput = input({
@@ -141,24 +142,32 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
             )
           ) : null
         );
-      case 'tribe':
-        return div({ class: 'search-tribe' },
-          content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
-          content.isAnonymous !== undefined ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeIsAnonymousLabel + ':'), span({ class: 'card-value' }, content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)) : null,
-          content.inviteMode ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeModeLabel + ':'), span({ class: 'card-value' }, content.inviteMode.toUpperCase())) : null,
-          content.isLARP !== undefined ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLARPLabel + ':'), span({ class: 'card-value' }, content.isLARP ? i18n.tribeYes : i18n.tribeNo)) : null,
-          br,
-          content.image ? img({ src: `/blob/${encodeURIComponent(content.image)}`, class: 'feed-image' }) : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }),
-          br,
-          content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
-          content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.location + ':'), span({ class: 'card-value' }, content.location)) : null,
-          Array.isArray(content.members) ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeMembersCount + ':'), span({ class: 'card-value' }, content.members.length)) : null,
-          content.tags && content.tags.length
-            ? div({ class: 'card-tags' }, content.tags.map(tag =>
+    case 'tribe':
+      return div({ class: 'search-tribe' },
+        content.title ? h2(content.title) : null,
+        content.image ? img({ src: `/blob/${encodeURIComponent(content.image)}`, class: 'feed-image' }) : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }),
+        br,
+        content.description ? content.description : null,
+        br,br,
+        div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' },
+          content.location ? p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLocationLabel.toUpperCase()}: `, ...renderUrl(content.location)) : null,
+          p({ style: 'color:#9aa3b2;' }, `${i18n.tribeIsAnonymousLabel}: ${content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
+          content.inviteMode ? p({ style: 'color:#9aa3b2;' }, `${i18n.tribeModeLabel}: ${content.inviteMode.toUpperCase()}`) : null,
+          p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLARPLabel}: ${content.isLARP ? i18n.tribeYes : i18n.tribeNo}`)
+        ),
+        Array.isArray(content.members)
+          ? div({},
+              div({ class: 'card-field' },
+               h2(`${i18n.tribeMembersCount}: ${content.members.length}`),
+              )  
+            )
+          : null,
+        content.tags && content.tags.length
+          ? div({ class: 'card-tags' }, content.tags.map(tag =>
               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
             ))
-            : null
-        );
+          : null
+      );
       case 'audio':
         return content.url ? div({ class: 'search-audio' },
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,

+ 42 - 18
src/views/settings_view.js

@@ -96,6 +96,48 @@ const settingsView = ({ version, aiPrompt }) => {
         )
       )
     ),
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.homePageTitle),
+        p(i18n.homePageDescription),
+        form(
+          { action: "/settings/home-page", method: "POST" },
+          select({ name: "homePage" },
+            option({ value: "activity", selected: currentConfig.homePage === "activity" ? true : undefined }, i18n.activityTitle),
+            option({ value: "ai", selected: currentConfig.homePage === "ai" ? true : undefined }, i18n.aiTitle),
+            option({ value: "trending", selected: currentConfig.homePage === "trending" ? true : undefined }, i18n.trendingTitle),
+            option({ value: "opinions", selected: currentConfig.homePage === "opinions" ? true : undefined }, i18n.opinionsTitle),
+            option({ value: "forum", selected: currentConfig.homePage === "forum" ? true : undefined }, i18n.forumTitle),
+            option({ value: "feed", selected: currentConfig.homePage === "feed" ? true : undefined }, i18n.feedTitle),
+            option({ value: "mentions", selected: currentConfig.homePage === "mentions" ? true : undefined }, i18n.mentions),
+            option({ value: "inbox", selected: currentConfig.homePage === "inbox" ? true : undefined }, i18n.inbox),
+            option({ value: "agenda", selected: currentConfig.homePage === "agenda" ? true : undefined }, i18n.agendaTitle),
+            option({ value: "stats", selected: currentConfig.homePage === "stats" ? true : undefined }, i18n.statsTitle),
+            option({ value: "blockexplorer", selected: currentConfig.homePage === "blockexplorer" ? true : undefined }, i18n.blockchain)
+          ),
+          br(), br(),
+          button({ type: "submit" }, i18n.saveHomePage)
+        )
+      )
+    ),
+    section(
+      div({ class: "tags-header" },
+      h2(i18n.ssbLogStream),
+      p(i18n.ssbLogStreamDescription),
+      form(
+        { action: "/settings/ssb-logstream", method: "POST" },
+        input({
+          type: "number",
+          id: "ssb_log_limit",
+          name: "ssb_log_limit",
+          min: 1,
+          max: 100000,
+          value: currentConfig.ssbLogStream?.limit || 1000
+        }), br(),br(),
+        button({ type: "submit" }, i18n.saveSettings)
+      )
+     )
+    ),
     section(
       div({ class: "tags-header" },
         h2(i18n.wallet),
@@ -168,24 +210,6 @@ const settingsView = ({ version, aiPrompt }) => {
         )
       )
     ),
-    section(
-      div({ class: "tags-header" },
-      h2(i18n.ssbLogStream),
-      p(i18n.ssbLogStreamDescription),
-      form(
-        { action: "/settings/ssb-logstream", method: "POST" },
-        input({
-          type: "number",
-          id: "ssb_log_limit",
-          name: "ssb_log_limit",
-          min: 1,
-          max: 100000,
-          value: currentConfig.ssbLogStream?.limit || 1000
-        }), br(),br(),
-        button({ type: "submit" }, i18n.saveSettings)
-      )
-     )
-    ),
     section(
       div({ class: "tags-header" },
         h2(i18n.indexes),

+ 99 - 74
src/views/tribes_view.js

@@ -129,20 +129,20 @@ exports.renderInvitePage = (inviteCode) => {
 
 exports.tribesView = async (tribes, filter, tribeId, query = {}) => {
   const now = Date.now();
-  const search = (query.search || '').toLowerCase(); 
+  const search = (query.search || '').toLowerCase();
 
   const filtered = tribes.filter(t => {
-  return (
-    filter === 'all' ? t.isAnonymous === false : 
-    filter === 'mine' ? t.author === userId :
-    filter === 'membership' ? t.members.includes(userId) :
-    filter === 'recent' ? t.isAnonymous === false && ((typeof t.createdAt === 'string' ? Date.parse(t.createdAt) : t.createdAt) >= now - 86400000 ) :
-    filter === 'top' ? t.isAnonymous === false :
-    filter === 'gallery' ? t.isAnonymous === false :
-    filter === 'larp' ? t.isAnonymous === false && t.isLARP === true : 
-    filter === 'create' ? true : 
-    filter === 'edit' ? true : 
-    false 
+    return (
+      filter === 'all' ? t.isAnonymous === false :
+      filter === 'mine' ? t.author === userId :
+      filter === 'membership' ? t.members.includes(userId) :
+      filter === 'recent' ? t.isAnonymous === false && ((typeof t.createdAt === 'string' ? Date.parse(t.createdAt) : t.createdAt) >= now - 86400000 ) :
+      filter === 'top' ? t.isAnonymous === false :
+      filter === 'gallery' ? t.isAnonymous === false :
+      filter === 'larp' ? t.isAnonymous === false && t.isLARP === true :
+      filter === 'create' ? true :
+      filter === 'edit' ? true :
+      false
     );
   });
 
@@ -180,7 +180,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}) => {
   );
 
   const modeButtons = div({ class: 'mode-buttons', style: 'display:flex; gap:8px; margin-top:16px;' },
-    ['recent','all','mine','membership','larp','top','gallery'].map(f =>
+    ['all','recent','mine','membership','larp','top','gallery'].map(f =>
     form({ method: 'GET', action: '/tribes' },
       input({ type: 'hidden', name: 'filter', value: f }),
       button({ type: 'submit', class: filter === f ? 'filter-btn active' : 'filter-btn' },
@@ -237,13 +237,13 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}) => {
     ),
     br(), br(),   
     
-   // label({ for: 'isLARP' }, i18n.tribeIsLARPLabel),
-   // br,
-   // select({ name: 'isLARP', id: 'isLARP' },
-   //   option({ value: 'true', selected: tribeToEdit.isLARP === true ? 'selected' : undefined }, i18n.tribeYes),
-   //   option({ value: 'false', selected: tribeToEdit.isLARP === false ? 'selected' : undefined }, i18n.tribeNo)
-   // ),
-   // br(), br(),
+    // label({ for: 'isLARP' }, i18n.tribeIsLARPLabel),
+    // br,
+    // select({ name: 'isLARP', id: 'isLARP' },
+    //   option({ value: 'true', selected: tribeToEdit.isLARP === true ? 'selected' : undefined }, i18n.tribeYes),
+    //   option({ value: 'false', selected: tribeToEdit.isLARP === false ? 'selected' : undefined }, i18n.tribeNo)
+    // ),
+    // br(), br(),
     
     button({ type: 'submit' }, isEdit ? i18n.tribeUpdateButton : i18n.tribeCreateButton)
     )
@@ -254,41 +254,45 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}) => {
       ? `/blob/${encodeURIComponent(t.image)}`
       : '/assets/images/default-tribe.png';
 
-    const infoCol = div({ class: 'tribe-card', style: 'width:50%' },
-      filter === 'mine' ? div({ class: 'tribe-actions' },
-        form({ method: 'GET', action: `/tribes/edit/${encodeURIComponent(t.id)}` }, button({ type: 'submit' }, i18n.tribeUpdateButton)),
-        form({ method: 'POST', action: `/tribes/delete/${encodeURIComponent(t.id)}` }, button({ type: 'submit' }, i18n.tribeDeleteButton))
-      ) : null,
-      div({ style: 'display: flex; justify-content: space-between;' },
-        form({ method: 'GET', action: `/tribe/${encodeURIComponent(t.id)}` },
-          button({ type: 'submit', class: 'filter-btn' }, i18n.tribeviewTribeButton)
-        ),
-        h2(t.title)
+    const infoCol = div({ class: 'tribe-card', style: 'width:50%; background:#2c2f33; border:1px solid #444; border-radius:12px; padding:16px; display:flex; flex-direction:column; gap:10px;' },
+      div({ style: 'position:relative; border-radius:12px; overflow:hidden; box-shadow:0 10px 24px rgba(0,0,0,.35);' },
+        img({ src: imageSrc, style: 'display:block; width:100%; height:260px; object-fit:cover; transform:translateZ(0); filter:saturate(1.05) contrast(1.02);' }),
+      ),
+      h2(t.title),
+      t.description ? p({ style: 'font-size:16px; line-height:1.55; color:#cfd3e1; margin:4px 0 6px 0;' }, ...renderUrl(t.description)) : null,
+      div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' },
+        t.location ? p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLocationLabel.toUpperCase()}: `, ...renderUrl(t.location)) : null,
+        p({ style: 'color:#9aa3b2;' }, `${i18n.tribeIsAnonymousLabel}: ${t.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
+        p({ style: 'color:#9aa3b2;' }, `${i18n.tribeModeLabel}: ${t.inviteMode.toUpperCase()}`),
+        p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLARPLabel}: ${t.isLARP ? i18n.tribeYes : i18n.tribeNo}`)
       ),
-      p(`${i18n.tribeIsAnonymousLabel}: ${t.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
-      p(`${i18n.tribeModeLabel}: ${t.inviteMode.toUpperCase()}`),
-      p(`${i18n.tribeLARPLabel}: ${t.isLARP ? i18n.tribeYes : i18n.tribeNo}`),
-      t.location ? p(`${i18n.tribeLocationLabel}: `, ...renderUrl(t.location)) : null,
-      img({ src: imageSrc }),
-      t.description ? p(...renderUrl(t.description)) : null,
       h2(`${i18n.tribeMembersCount}: ${t.members.length}`),
-      t.tags && t.tags.filter(Boolean).length ? div(t.tags.filter(Boolean).map(tag =>
+      t.tags && t.tags.filter(Boolean).length ? div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' }, 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}`)
-      )) : null,    
+      )) : null,
       p(`${i18n.tribeCreatedAt}: ${new Date(t.createdAt).toLocaleString()}`),
       p(a({ class: 'user-link', href: `/author/${encodeURIComponent(t.author)}` }, t.author)),
-      t.members.includes(userId) ? div(
-      form({ method: 'POST', action: '/tribes/generate-invite' }, 
-        input({ type: 'hidden', name: 'tribeId', value: t.id }),
-        button({ type: 'submit' }, i18n.tribeGenerateInvite)
-      ),
-      form({ method: 'POST', action: `/tribes/leave/${encodeURIComponent(t.id)}` }, button({ type: 'submit' }, i18n.tribeLeaveButton))
+      filter === 'mine' ? div({ class: 'tribe-actions', style: 'display:flex; gap:8px; justify-content:flex-end;' },
+        form({ method: 'GET', action: `/tribes/edit/${encodeURIComponent(t.id)}` }, button({ type: 'submit' }, i18n.tribeUpdateButton)),
+        form({ method: 'POST', action: `/tribes/delete/${encodeURIComponent(t.id)}` }, button({ type: 'submit' }, i18n.tribeDeleteButton))
+      ) : null,
+      t.members.includes(userId) ? div({ class: 'member-actions', style: 'display:flex; gap:8px; flex-wrap:wrap; margin-top:4px;' },
+        form({ method: 'GET', action: `/tribe/${encodeURIComponent(t.id)}`, style: 'position:absolute; bottom:12px; right:12px;' },
+          button({ type: 'submit', class: 'filter-btn' }, i18n.tribeviewTribeButton.toUpperCase())
+        ),
+        form({ method: 'POST', action: '/tribes/generate-invite' },
+          input({ type: 'hidden', name: 'tribeId', value: t.id }),
+          button({ type: 'submit' }, i18n.tribeGenerateInvite)
+        ),
+        form({ method: 'POST', action: `/tribes/leave/${encodeURIComponent(t.id)}` },
+          button({ type: 'submit' }, i18n.tribeLeaveButton)
+        )
       ) : null
     );
 
     const feedCol = renderFeedTribesView(t, query.page || 1, query, filter);
 
-    return div({ class: 'tribe-row', style: 'display:flex; gap:24px; margin-bottom:32px;' }, infoCol, feedCol);
+    return div({ class: 'tribe-row', style: 'display:flex; gap:24px; margin-bottom:32px; align-items:flex-start;' }, infoCol, feedCol);
   });
 
   return template(
@@ -297,18 +301,19 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}) => {
     section(filters),
     section(modeButtons),
     section(
-    (filter === 'create' || filter === 'edit')
-      ? createForm 
-      : filter === 'gallery'
-        ? renderGallery(sorted.filter(t => t.isAnonymous === false)) 
-        : div({ class: 'tribe-grid', style: 'display:grid; grid-template-columns: repeat(3, 1fr); gap:16px;' },
-            tribeCards.length > 0 ? tribeCards : p(i18n.noTribes)
-        )
+      (filter === 'create' || filter === 'edit')
+        ? createForm
+        : filter === 'gallery'
+          ? renderGallery(sorted.filter(t => t.isAnonymous === false))
+          : div({ class: 'tribe-grid', style: 'display:grid; grid-template-columns: repeat(3, 1fr); gap:16px;' },
+              tribeCards.length > 0 ? tribeCards : p(i18n.noTribes)
+            )
      ),
-  ...renderLightbox(sorted.filter(t => t.isAnonymous === false))
-  ); 
+    ...renderLightbox(sorted.filter(t => t.isAnonymous === false))
+  );
 };
 
+
 const renderFeedTribeView = async (tribe, query = {}, filter) => {
   const feed = Array.isArray(tribe.feed) ? tribe.feed : [];
   const feedFilter = (query.feedFilter || 'RECENT').toUpperCase();
@@ -371,35 +376,55 @@ exports.tribeView = async (tribe, userId, query) => {
   if (!tribe) {
     return div({ class: 'error' }, 'Tribe not found!');
   }
+
   const feedFilter = (query.feedFilter || 'TOP').toUpperCase();
   const imageSrc = tribe.image
     ? `/blob/${encodeURIComponent(tribe.image)}`
     : '/assets/images/default-tribe.png';
   const pageTitle = tribe.title;
+
   const tribeDetails = div({ class: 'tribe-details' },
-    h2(tribe.title),
-    p(`${i18n.tribeIsAnonymousLabel}: ${tribe.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
-    p(`${i18n.tribeModeLabel}: ${tribe.inviteMode.toUpperCase()}`),
-    p(`${i18n.tribeLARPLabel}: ${tribe.isLARP ? i18n.tribeYes : i18n.tribeNo}`),
-    tribe.location ? p(`${i18n.tribeLocationLabel}: `, ...renderUrl(tribe.location)) : null,
-    img({ src: imageSrc, alt: tribe.title }),
-    tribe.description ? p(...renderUrl(tribe.description)) : null,
-    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}`)
-    )) : null,  
-    p(`${i18n.tribeCreatedAt}: ${new Date(tribe.createdAt).toLocaleString()}`),
-    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: 4, cols: 50, maxlength: 280, placeholder: i18n.tribeFeedMessagePlaceholder }),
-          br,
-          button({ type: 'submit' }, i18n.tribeFeedSend)
+    div({ class: 'tribe-side' },
+      h2(tribe.title),
+      img({ src: imageSrc, alt: tribe.title }),
+      h2(`${i18n.tribeMembersCount}: ${tribe.members.length}`),
+      div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' },
+        tribe.location ? p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLocationLabel.toUpperCase()}: `, ...renderUrl(tribe.location)) : null,
+        p({ style: 'color:#9aa3b2;' }, `${i18n.tribeIsAnonymousLabel}: ${tribe.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
+        p({ style: 'color:#9aa3b2;' }, `${i18n.tribeModeLabel}: ${tribe.inviteMode.toUpperCase()}`),
+        p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLARPLabel}: ${tribe.isLARP ? i18n.tribeYes : i18n.tribeNo}`)
+      ),
+      tribe.description ? p(...renderUrl(tribe.description)) : null,
+      div({ class: 'tribe-meta' },
+        tribe.tags && tribe.tags.filter(Boolean).length ? div(tribe.tags.filter(Boolean).map(tag =>
+          a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+        )) : null,
+        p(`${i18n.tribeCreatedAt}: ${new Date(tribe.createdAt).toLocaleString()}`),
+        p(a({ class: 'user-link', href: `/author/${encodeURIComponent(tribe.author)}` }, tribe.author)),
+      ),
+      div({ class: 'tribe-meta' },
+        form({ method: 'POST', action: '/tribes/generate-invite' },
+          input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
+          button({ type: 'submit' }, i18n.tribeGenerateInvite)
+        ),
+        form({ method: 'POST', action: `/tribes/leave/${encodeURIComponent(tribe.id)}` },
+          button({ type: 'submit' }, i18n.tribeLeaveButton)
         )
-      : null
       ),
-    div({ class: 'tribe-feed-full' }, await renderFeedTribeView(tribe, query, query.filter)),
+    ),
+    div({ class: 'tribe-main' },
+      div({ class: 'tribe-feed-form' }, tribe.members.includes(config.keys.id)
+        ? form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/message` },
+            textarea({ name: 'message', rows: 4, cols: 50, maxlength: 280, placeholder: i18n.tribeFeedMessagePlaceholder }),
+            br,
+            button({ type: 'submit' }, i18n.tribeFeedSend)
+          )
+        : null
+      ),
+      await renderFeedTribeView(tribe, query, query.filter),
+    )
   );
+
   return template(
     pageTitle,
     tribeDetails