psy пре 3 дана
родитељ
комит
7ab4a46901

+ 18 - 0
docs/CHANGELOG.md

@@ -13,6 +13,24 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.4.7 - 2025-08-27
+
+### Added
+
+ + Online, discovered, unknown listing (peers plugin).
+ + Federated, unfederated, unreachable networks (invites plugin).
+ 
+### Fixed
+
+ + Fixed mentioning (mentions plugin).
+ 
+### Changed
+
+- Stats.
+- Mentions.
+- Peers.
+- Invites.
+
 ## v0.4.6 - 2025-08-24
  
 ### Fixed

+ 96 - 33
src/backend/backend.js

@@ -365,6 +365,25 @@ const maxSize = 50 * megabyte;
 // koaMiddleware to manage files
 const homeDir = os.homedir();
 const blobsPath = path.join(homeDir, '.ssb', 'blobs', 'tmp');
+const gossipPath = path.join(homeDir, '.ssb', 'gossip.json')
+const unfollowedPath = path.join(homeDir, '.ssb', 'gossip_unfollowed.json')
+
+function readJSON(p) {
+  try { return JSON.parse(fs.readFileSync(p, 'utf8') || '[]') } catch { return [] }
+}
+function writeJSON(p, data) {
+  fs.mkdirSync(path.dirname(p), { recursive: true })
+  fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf8')
+}
+function canonicalKey(key) {
+  let core = String(key).replace(/^@/, '').replace(/\.ed25519$/, '').replace(/-/g, '+').replace(/_/g, '/')
+  if (!core.endsWith('=')) core += '='
+  return `@${core}.ed25519`
+}
+function msAddrFrom(host, port, key) {
+  const core = canonicalKey(key).replace(/^@/, '').replace(/\.ed25519$/, '')
+  return `net:${host}:${Number(port) || 8008}~shs:${core}`
+}
 
 const koaBodyMiddleware = koaBody({
   multipart: true,
@@ -1121,35 +1140,13 @@ router
     });
   })
   .get("/peers", async (ctx) => {
-    const theme = ctx.cookies.get("theme") || config.theme;
-    const getMeta = async () => {
-      const allPeers = await meta.peers();
-      const connected = allPeers.filter(([, data]) => data.state === "connected");
-      const offline = allPeers.filter(([, data]) => data.state !== "connected");
-      const enrich = async (peers) => {
-        return await Promise.all(
-          peers.map(async ([address, data]) => {
-            const feedId = data.key || data.id;
-            const name = await about.name(feedId);
-            return [
-              address,
-              {
-                ...data,
-                key: feedId,
-                name: name || feedId,
-              },
-            ];
-          })
-        );
-      };
-      const connectedPeers = await enrich(connected);
-      const offlinePeers = await enrich(offline);
-      return peersView({
-        connectedPeers,
-        peers: offlinePeers,
-      });
-    };
-    ctx.body = await getMeta();
+    const onlinePeers = await meta.onlinePeers();
+    const { discoveredPeers, unknownPeers } = await meta.discovered();
+    ctx.body = await peersView({
+      onlinePeers,
+      discoveredPeers,
+      unknownPeers
+    });
   })
   .get("/invites", async (ctx) => {
     const theme = ctx.cookies.get("theme") || config.theme;
@@ -2963,12 +2960,78 @@ router
     ctx.redirect("/peers");
   })
   .post("/settings/invite/accept", koaBody(), async (ctx) => {
+   try {
+     const invite = String(ctx.request.body.invite);
+     await meta.acceptInvite(invite);
+   } catch (e) {
+   }
+   ctx.redirect("/invites");
+  })
+  .post('/settings/invite/unfollow', async (ctx) => {
+    const { key } = ctx.request.body || {}
+    if (!key) { ctx.redirect('/invites'); return }
+    const pubs = readJSON(gossipPath);
+    const idx = pubs.findIndex(x => x && canonicalKey(x.key) === canonicalKey(key));
+    let removed = null;
+    if (idx >= 0) {
+      removed = pubs.splice(idx, 1)[0];
+      writeJSON(gossipPath, pubs);
+    }
+    const ssb = await cooler.open();
+    let addr = null;
+    if (removed && removed.host) addr = msAddrFrom(removed.host, removed.port, removed.key);
+    if (addr) {
+      try { await new Promise(res => ssb.conn.disconnect(addr, res)); } catch {}
+      try { ssb.conn.forget(addr); } catch {}
+    }
     try {
-      const invite = String(ctx.request.body.invite);
-      await meta.acceptInvite(invite);
-    } catch (e) {
+      await new Promise((resolve, reject) => {
+        ssb.publish({ type: 'contact', contact: canonicalKey(key), following: false, blocking: true }, (err) => err ? reject(err) : resolve());
+      });
+    } catch {}
+    const unf = readJSON(unfollowedPath);
+    if (removed && !unf.find(x => x && canonicalKey(x.key) === canonicalKey(removed.key))) {
+      unf.push(removed);
+      writeJSON(unfollowedPath, unf);
+    } else if (!removed && !unf.find(x => x && canonicalKey(x.key) === canonicalKey(key))) {
+      unf.push({ key: canonicalKey(key) });
+      writeJSON(unfollowedPath, unf);
+    }
+    ctx.redirect('/invites');
+   })
+  .post('/settings/invite/follow', async (ctx) => {
+    const { key, host, port } = ctx.request.body || {};
+    if (!key || !host) { ctx.redirect('/invites'); return; }
+    const isInErrorState = (host) => {
+      const pubs = readJSON(gossipPath);
+      const pub = pubs.find(p => p.host === host);
+      return pub && pub.error;
+    };
+    if (isInErrorState(host)) {
+      ctx.redirect('/invites');
+      return;
     }
-    ctx.redirect("/invites");
+    const ssb = await cooler.open();
+    const unf = readJSON(unfollowedPath);
+    const kcanon = canonicalKey(key);
+    const saved = unf.find(x => x && canonicalKey(x.key) === kcanon);
+    const rec = saved || { host, port: Number(port) || 8008, key: kcanon };
+    const pubs = readJSON(gossipPath);
+    if (!pubs.find(x => x && canonicalKey(x.key) === kcanon)) {
+      pubs.push({ host: rec.host, port: Number(rec.port) || 8008, key: kcanon });
+      writeJSON(gossipPath, pubs);
+    }
+    const addr = msAddrFrom(rec.host, rec.port, kcanon);
+    try { ssb.conn.remember(addr, { type: 'pub', autoconnect: true, key: kcanon }); } catch {}
+    try { await new Promise(res => ssb.conn.connect(addr, { type: 'pub' }, res)); } catch {}
+    try {
+      await new Promise((resolve, reject) => {
+        ssb.publish({ type: 'contact', contact: kcanon, blocking: false }, (err) => err ? reject(err) : resolve());
+       });
+    } catch {}
+    const nextUnf = unf.filter(x => !(x && canonicalKey(x.key) === kcanon));
+    writeJSON(unfollowedPath, nextUnf);
+    ctx.redirect('/invites');
   })
   .post("/settings/ssb-logstream", koaBody(), async (ctx) => {
     const logLimit = parseInt(ctx.request.body.ssb_log_limit, 10);

+ 4 - 0
src/client/assets/styles/style.css

@@ -2261,3 +2261,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   font-weight: bold;
 }
 
+.pub-item{border:1;border-radius:10px;background:none}
+.error-box{background:#222326;border:none;color:#f5c242;padding:12px;border-radius:8px;margin-top:8px}
+.error-title{margin:0 0 6px 0;font-weight:600}
+.error-pre{margin:0;white-space:pre-wrap;font-family:monospace}

+ 14 - 0
src/client/assets/translations/oasis_en.js

@@ -228,6 +228,9 @@ module.exports = {
     peerConnectionsIntro: "Manage all your connections with other peers.",
     online: "Online",
     offline: "Offline",
+    discovered: 'Discovered',
+    unknown: 'Unknown',
+    pub: 'PUB',
     supported: "Supported",
     recommended: "Recommended", 
     blocked: "Blocked",
@@ -258,6 +261,16 @@ module.exports = {
     invitesAcceptInvite: "Join PUB",
     invitesAcceptedInvites: "Federated Networks",
     invitesNoInvites: "No invitations accepted, yet.",
+    invitesUnfollow: "Unfollow",
+    invitesFollow: "Follow",
+    invitesUnfollowedInvites: "Unfederated Networks",
+    invitesNoFederatedPubs: "No federated networks.",
+    invitesNoUnfollowed: "No unfederated networks.",
+    invitesUnreachablePubs: "Unreachable Networks",
+    invitesNoUnreachablePubs: "No unreachable networks.",
+    currentlyUnreachable: "ERROR!",
+    errorDetails: "Error Details",
+    genericError: "An error occurred.",
     //panic  
     panicMode: "Panic Mode!",
     //cipher
@@ -1475,6 +1488,7 @@ module.exports = {
     statsDocument: "Documents",
     statsTransfer: "Transfers",
     statsAiExchange: "AI",
+    statsPUBs: 'PUBs',
     statsPost: "Posts",
     statsOasisID: "Oasis ID",
     statsSize: "Total (size)",

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

@@ -228,6 +228,9 @@ module.exports = {
     peerConnectionsIntro: "Maneja todas tus conexiones con otros nodos.",
     online: "Online",
     offline: "Offline",
+    discovered: 'Descubiertos',
+    unknown: 'Desconocidos',
+    pub: 'PUB',
     supported: "Soportado",
     recommended: "Recomendado", 
     blocked: "Bloqueado",
@@ -258,6 +261,16 @@ module.exports = {
     invitesAcceptInvite: "Entrar en el PUB",
     invitesAcceptedInvites: "Redes Federadas",
     invitesNoInvites: "No hay invitaciones aceptadas, aún.",
+    invitesUnfollow: 'Dejar de seguir',
+    invitesFollow: 'Seguir',
+    invitesUnfollowedInvites: 'Redes No Federadas',
+    invitesNoFederatedPubs: "No hay redes federadas.",
+    invitesNoUnfollowed: 'No hay redes no federadas.',
+    invitesUnreachablePubs: "Redes Inalcanzables",
+    invitesNoUnreachablePubs: "No hay redes inalcanzables.",
+    currentlyUnreachable: "ERROR!",
+    errorDetails: "Detalles del error",
+    genericError: "A ocurrido un error.",
     //panic  
     panicMode: "Modo Pánico!",
     //cipher
@@ -1487,6 +1500,7 @@ module.exports = {
     statsDocument: "Documentos",
     statsTransfer: "Transferencias",
     statsAiExchange: "IA",
+    statsPUBs: 'PUBs',
     statsPost: "Publicaciones",
     statsOasisID: "ID de Oasis",
     statsSize: "Total (tamaño)",

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

@@ -228,6 +228,9 @@ module.exports = {
     peerConnectionsIntro: "Kudeatu beste parekoekin dituzun konexio guztiak.",
     online: "Linean",
     offline: "Lineaz kanpo",
+    discovered: 'Aurkituak',
+    unknown: 'Ezezagunak',
+    pub: 'PUB',
     supported: "Lagunguta",
     recommended: "Gomendatuta", 
     blocked: "Blokeatuta",
@@ -258,6 +261,16 @@ module.exports = {
     invitesAcceptInvite: "Sartu PUBean",
     invitesAcceptedInvites: "Federatutako Sareak",
     invitesNoInvites: "Ez da gonbidapenik onartu, oraindik.",
+    invitesUnfollow: "Utzi jarraitzea",
+    invitesFollow: "Jarraitu",
+    invitesUnfollowedInvites: "Ez-Federatutako Sareak",
+    invitesNoUnfollowed: "Ez dago ez-federatutako sareak.",
+    invitesNoFederatedPubs: "Ez dago sare federaturik.",
+    invitesUnreachablePubs: "Sare Iristezinak",
+    invitesNoUnreachablePubs: "Ez dago sare iristezinik.",
+    currentlyUnreachable: "ERROR!",
+    errorDetails: "Errore Xehetasunak",
+    genericError: "Errore bat gertatu da.",
     //panic  
     panicMode: "Izu Modua!",
     //cipher
@@ -1488,6 +1501,7 @@ module.exports = {
     statsDocuments: "Dokumentuak",
     statsTransfers: "Transferentziak",
     statsAiExchange: "IA",
+    statsPUBs: 'PUBs',
     statsPosts: "Bidalketak",
     statsOasisID: "Oasis ID-a",
     statsSize: "Guztira (taimaina)",

+ 123 - 16
src/models/main_models.js

@@ -62,7 +62,68 @@ const publicOnlyFilter = pull.filter(isNotPrivate);
 
 const configure = (...customOptions) =>
   Object.assign({}, defaultOptions, ...customOptions);
+ 
+// peers 
+const ebtDir = path.join(os.homedir(), '.ssb', 'ebt');
 
+async function loadPeersFromEbt() {
+  let result = [];
+  try {
+    await fs.access(ebtDir);
+    const files = await fs.readdir(ebtDir);
+    for (const file of files) {
+      if (!file.endsWith('.ed25519')) continue;
+      const base = file.replace(/^@/, '').replace('.ed25519', '');
+      let core = base.replace(/_/g, '/').replace(/-/g, '+');
+      if (!core.endsWith('=')) core += '=';
+      const filePath = path.join(ebtDir, file);
+      try {
+        const data = await fs.readFile(filePath, 'utf8');
+        const users = JSON.parse(data);
+        const userList = Object.keys(users).map(u => ({
+          id: u,
+          link: `/author/${encodeURIComponent(u)}`
+        }));
+        result.push({
+          pub: `@${core}.ed25519`,
+          users: userList
+        });
+      } catch {}
+    }
+  } catch {}
+  return result;
+}
+
+async function loadConnectedUsersFromEbt(pubId) {
+  const filePath = path.join(ebtDir, `@${pubId.replace(/\//g, '_')}.ed25519`);
+  try {
+    const data = await fs.readFile(filePath, 'utf8');
+    const users = JSON.parse(data);
+    return Object.keys(users).map(userId => ({
+      id: userId,
+      link: `/author/${encodeURIComponent(userId)}`
+    }));
+  } catch {
+    return [];
+  }
+}
+
+const canonicalizePubId = (s) => {
+  const core0 = String(s).replace(/^@/, '').replace(/\.ed25519$/, '');
+  let core = core0.replace(/_/g, '/').replace(/-/g, '+');
+  if (!core.endsWith('=')) core += '=';
+  return `@${core}.ed25519`;
+};
+
+const parseRemote = (remote) => {
+  const m = /^net:([^:]+):\d+~shs:([^=]+)=/.exec(remote);
+  if (!m) return { host: null, pubId: null };
+  const host = m[1];
+  const pubId = canonicalizePubId(m[2]);
+  return { host, pubId };
+};
+
+// core modules
 module.exports = ({ cooler, isPublic }) => {
   const models = {};
   const getAbout = async ({ key, feedId }) => {
@@ -162,6 +223,34 @@ module.exports = ({ cooler, isPublic }) => {
     });
   };
   
+  async function enrichEntries(entries) {
+  const ebtList = await loadPeersFromEbt();
+  const ebtMap = new Map(ebtList.map(e => [e.pub, e.users]));
+  const ssb = await cooler.open();
+  return Promise.all(
+    entries.map(async ([remote, data]) => {
+      const { host, pubId } = parseRemote(remote);
+      const name = host || (pubId ? await models.about.name(pubId).catch(() => pubId) : remote);
+      const users = pubId && ebtMap.has(pubId) ? ebtMap.get(pubId) : [];
+      const usersWithNames = await Promise.all(
+        users.map(async (user) => {
+          const userName = await models.about.name(user.id).catch(() => user.id);
+          return { ...user, name: userName };
+        })
+      );
+      return [
+        remote,
+        {
+          ...data,
+          key: pubId || remote,
+          name,
+          users: usersWithNames
+        }
+      ];
+    })
+  );
+};
+  
 //ABOUT MODEL
 models.about = {
   publicWebHosting: async (feedId) => {
@@ -482,9 +571,35 @@ models.meta = {
       const peers = await models.meta.peers();
       return peers.filter(([_, data]) => data.state === "connected");
     },
+    onlinePeers: async () => {
+      const entries = await models.meta.connectedPeers();
+      return enrichEntries(entries);
+    },
+    discovered: async () => {
+      const ssb = await cooler.open();
+      const snapshot = await ssb.conn.dbPeers();
+      const discoveredPeers = await enrichEntries(snapshot);
+      const discoveredIds = new Set(discoveredPeers.map(([, d]) => d.key));
+      const ebtList = await loadPeersFromEbt();
+      const ebtMap = new Map(ebtList.map(e => [e.pub, e.users]));
+      const unknownPeers = [];
+      for (const { pub } of ebtList) {
+        if (!discoveredIds.has(pub)) {
+          const name = await models.about.name(pub).catch(() => pub);
+          unknownPeers.push([
+             pub,
+            {
+              key: pub,
+              name,
+              users: ebtMap.get(pub) || []
+            }
+          ]);
+        }
+      }
+      return { discoveredPeers, unknownPeers };
+    },
     connStop: async () => {
       const ssb = await cooler.open();
-
       try {
         const result = await ssb.conn.stop();
         return result;
@@ -889,22 +1004,14 @@ const post = {
       ssb,
       query,
       filter: (msg) => {
-      const content = msg.value.content;
-      if (content.mentions) {
-        if (Array.isArray(content.mentions)) {
-          if (content.mentions.some(m => {
-            return m.link === myFeedId || m.name === myUsername || m.name === '@' + myUsername;
-          })) {
-            return true; 
+        const content = msg.value.content;
+        if (content.mentions) {
+          if (Array.isArray(content.mentions)) {
+            return content.mentions.some(m => m.link === myFeedId || m.name === myUsername || m.name === '@' + myUsername);
           }
-        }
-        if (typeof content.mentions === 'object' && !Array.isArray(content.mentions)) {
-          const values = Object.values(content.mentions);
-          if (values.some(v => {
-            return v.link === myFeedId || v.name === myUsername || v.name === '@' + myUsername;
-          })) {
-            return true;
-            }
+          if (typeof content.mentions === 'object' && !Array.isArray(content.mentions)) {
+            const values = Object.values(content.mentions);
+            return values.some(v => v.link === myFeedId || v.name === myUsername || v.name === '@' + myUsername);
           }
         }
         const mentionsText = lodash.get(content, "text", "");

+ 13 - 1
src/models/stats_model.js

@@ -19,6 +19,16 @@ function readAddrMap() {
   }
 }
 
+const listPubsFromEbt = () => {
+  try {
+    const ebtDir = path.join(os.homedir(), '.ssb', 'ebt');
+    const files = fs.readdirSync(ebtDir);
+    return files.filter(f => f.endsWith('.ed25519'));
+  } catch {
+    return [];
+  }
+};
+
 module.exports = ({ cooler }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
@@ -266,7 +276,8 @@ module.exports = ({ cooler }) => {
       myAddressCount: myAddress ? 1 : 0,
       totalAddresses: Object.keys(addrMap).length
     };
-
+    const pubsCount = listPubsFromEbt().length;
+    
     const stats = {
       id: userId,
       createdAt,
@@ -279,6 +290,7 @@ module.exports = ({ cooler }) => {
       folderSize: formatSize(folderSize),
       statsBlockchainSize: formatSize(flumeSize),
       statsBlobsSize: formatSize(blobsSize),
+      pubsCount,
       activity: {
         lastMessageAt: lastTs ? new Date(lastTs).toISOString() : null,
         daily7: days7,

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

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

+ 1 - 1
src/server/package.json

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

+ 87 - 22
src/views/invites_view.js

@@ -1,43 +1,103 @@
-const { form, button, div, h2, p, section, ul, li, a, br, hr, input } = require("../server/node_modules/hyperaxe");
+const { form, button, div, h2, p, section, ul, li, a, br, hr, input, span } = require("../server/node_modules/hyperaxe");
 const path = require("path");
 const fs = require('fs');
 const { template, i18n } = require('./main_views');
 
 const homedir = require('os').homedir();
-const gossipPath = path.join(homedir, ".ssb/gossip.json");
+const gossipPath = path.join(homedir, ".ssb", "gossip.json");
+const unfollowedPath = path.join(homedir, ".ssb", "gossip_unfollowed.json");
+
+const encodePubLink = (key) => {
+  let core = String(key).replace(/^@/, '').replace(/\.ed25519$/, '').replace(/-/g, '+').replace(/_/g, '/');
+  if (!core.endsWith('=')) core += '=';
+  return `/author/${encodeURIComponent('@' + core)}.ed25519`;
+};
 
 const invitesView = ({ invitesEnabled }) => {
   let pubs = [];
   let pubsValue = "false";
+  let unfollowed = [];
 
   try {
     pubs = fs.readFileSync(gossipPath, "utf8");
   } catch {
-    pubs = undefined;
+    pubs = '[]';
   }
 
-  if (pubs) {
-    try {
-      pubs = JSON.parse(pubs);
-      if (Array.isArray(pubs) && pubs.length > 0) pubsValue = "true";
-      else pubsValue = "false";
-    } catch {
-      pubsValue = "false";
-    }
+  try {
+    pubs = JSON.parse(pubs);
+    pubsValue = Array.isArray(pubs) && pubs.length > 0 ? "true" : "false";
+  } catch {
+    pubsValue = "false";
+    pubs = [];
   }
 
-  const pubItems = pubsValue === "true"
-    ? pubs.map(pubItem =>
+  try {
+    unfollowed = JSON.parse(fs.readFileSync(unfollowedPath, "utf8") || "[]");
+  } catch {
+    unfollowed = [];
+  }
+
+  const filteredPubs = pubsValue === "true"
+    ? pubs.filter(pubItem => !unfollowed.find(u => u.key === pubItem.key))
+    : [];
+
+  const hasError = (pubItem) => pubItem && (pubItem.error || (typeof pubItem.failure === 'number' && pubItem.failure > 0));
+
+  const unreachableLabel = i18n.currentlyUnreachable || i18n.currentlyUnrecheable || 'ERROR!';
+
+  const pubItems = filteredPubs.filter(pubItem => !hasError(pubItem)).map(pubItem =>
+    li(
+      div(
+        { class: 'pub-item' },
+        h2('PUB: ', pubItem.host),
+        h2(`${i18n.inhabitants}: ${pubItem.announcers || 0}`),
+        a({ href: encodePubLink(pubItem.key), class: 'user-link' }, pubItem.key),
+        form(
+          { action: '/settings/invite/unfollow', method: 'post' },
+          input({ type: 'hidden', name: 'key', value: pubItem.key }),
+          button({ type: 'submit' }, i18n.invitesUnfollow)
+        ),
+      )
+    )
+  );
+
+  const unfollowedItems = unfollowed.length
+    ? unfollowed.map(pubItem =>
         li(
-          p(`PUB: ${pubItem.host}`),
-          p(`${i18n.inhabitants}: ${pubItem.announcers}`),
-          a({ href: `/author/${encodeURIComponent(pubItem.key)}` }, pubItem.key),
-          br,
-          br
+          div(
+            { class: 'pub-item' },
+            h2('PUB: ', pubItem.host),
+            h2(`${i18n.inhabitants}: ${pubItem.announcers || 0}`),
+            a({ href: encodePubLink(pubItem.key), class: 'user-link' }, pubItem.key),
+            form(
+              { action: '/settings/invite/follow', method: 'post' },
+              input({ type: 'hidden', name: 'key', value: pubItem.key }),
+              input({ type: 'hidden', name: 'host', value: pubItem.host || '' }),
+              input({ type: 'hidden', name: 'port', value: String(pubItem.port || 8008) }),
+              button({ type: 'submit', disabled: hasError(pubItem) }, i18n.invitesFollow)
+            ),
+          )
         )
       )
     : [];
 
+  const unreachableItems = pubs.filter(hasError).map(pubItem =>
+    li(
+      div(
+        { class: 'pub-item' },
+        h2('PUB: ', pubItem.host),
+        h2(`${i18n.inhabitants}: ${pubItem.announcers || 0}`),
+        a({ href: encodePubLink(pubItem.key), class: 'user-link' }, pubItem.key),
+        div(
+          { class: 'error-box' },
+          p({ class: 'error-title' }, i18n.errorDetails),
+          p({ class: 'error-pre' }, String(pubItem.error || i18n.genericError))
+        ),
+      )
+    )
+  );
+
   const title = i18n.invites;
   const description = i18n.invitesDescription;
 
@@ -69,15 +129,20 @@ const invitesView = ({ invitesEnabled }) => {
           br(),
           button({ type: 'submit' }, i18n.invitesAcceptInvite)
         ),
-        br(),
+        br,
         hr(),
         h2(`${i18n.invitesAcceptedInvites} (${pubItems.length})`),
-        pubItems.length
-          ? ul(pubItems)
-          : p({ class: 'empty' }, i18n.invitesNoInvites)
+        pubItems.length ? ul(pubItems) : p(i18n.invitesNoFederatedPubs),
+        hr(),
+        h2(`${i18n.invitesUnfollowedInvites} (${unfollowedItems.length})`),
+        unfollowedItems.length ? ul(unfollowedItems) : p(i18n.invitesNoUnfollowed),
+        hr(),
+        h2(`${i18n.invitesUnreachablePubs} (${unreachableItems.length})`),
+        unreachableItems.length ? ul(unreachableItems) : p(i18n.invitesNoUnreachablePubs)
       )
     )
   );
 };
 
 exports.invitesView = invitesView;
+

+ 1 - 1
src/views/main_views.js

@@ -1204,7 +1204,7 @@ exports.mentionsView = ({ messages, myFeedId }) => {
   }
   const filteredMessages = messages.filter(msg => {
     const mentions = lodash.get(msg, "value.content.mentions", {});
-    return Object.keys(mentions).length > 0;
+    return Object.keys(mentions).some(key => mentions[key].link === myFeedId);
   });
   if (filteredMessages.length === 0) {
     return template(

+ 37 - 13
src/views/peers_view.js

@@ -1,19 +1,39 @@
-const peersView = async ({ peers, connectedPeers }) => {
-  const { form, button, div, h2, p, section, ul, li, a, br } = require("../server/node_modules/hyperaxe");
+const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
+  const { form, button, div, h2, p, section, ul, li, a, hr } = require("../server/node_modules/hyperaxe");
   const { template, i18n } = require('./main_views');
-
   const startButton = form({ action: "/settings/conn/start", method: "post" }, button({ type: "submit" }, i18n.startNetworking));
   const restartButton = form({ action: "/settings/conn/restart", method: "post" }, button({ type: "submit" }, i18n.restartNetworking));
   const stopButton = form({ action: "/settings/conn/stop", method: "post" }, button({ type: "submit" }, i18n.stopNetworking));
   const syncButton = form({ action: "/settings/conn/sync", method: "post" }, button({ type: "submit" }, i18n.sync));
   const connButtons = [startButton, restartButton, stopButton, syncButton];
-  const renderPeerList = (list) =>
-    list.map(([, data]) =>
-      li(
-        data.name, br,
-        a({ href: `/author/${encodeURIComponent(data.key)}` }, data.key), br, br
-      )
+  const renderInhabitants = (users, pubID) => {
+    const filteredUsers = users.filter(user => user.id !== pubID);
+    if (filteredUsers.length === 0) {
+      return li(i18n.noDiscovered);
+    }
+    return filteredUsers.map((user) => {
+      const userUrl = `/author/${encodeURIComponent(user.id)}`;
+      return li(
+        a({ href: userUrl, class:"user-link" }, `${user.id}`)
+      );
+    });
+  };
+  const encodePubKey = (pubId) => {
+    let core = pubId.replace(/^@/, '').replace(/\.ed25519$/, '').replace(/_/g, '/');
+    if (!core.endsWith('=')) core += '=';
+    return `/author/${encodeURIComponent('@' + core)}.ed25519`;
+  };
+  const renderPeer = (peerData) => {
+  const peer = peerData[1];
+  const { name, users, key } = peer;
+  const pubUrl = encodePubKey(key);
+  const inhabitants = renderInhabitants(users, peerData[0]);
+    return li(
+      `${i18n.pub}: ${name} `,
+      a({ href: pubUrl, class:"user-link" }, `${key}`),
+      inhabitants.length > 0 ? ul(inhabitants) : p(i18n.noDiscovered)
     );
+  };
   return template(
     i18n.peers,
     section(
@@ -23,10 +43,14 @@ const peersView = async ({ peers, connectedPeers }) => {
       ),
       div({ class: "conn-actions" }, ...connButtons),
       div({ class: "peers-list" },
-        h2(`${i18n.online} (${connectedPeers.length})`),
-        connectedPeers.length > 0 ? ul(renderPeerList(connectedPeers)) : p(i18n.noConnections),
-        h2(`${i18n.offline} (${peers.length})`),
-        peers.length > 0 ? ul(renderPeerList(peers)) : p(i18n.noDiscovered),
+        h2(i18n.online),
+        onlinePeers.length > 0 ? ul(onlinePeers.map(renderPeer)) : p(i18n.noConnections),
+        hr,
+        h2(i18n.discovered),
+        discoveredPeers.length > 0 ? ul(discoveredPeers.map(renderPeer)) : p(i18n.noDiscovered),
+        hr,
+        h2(i18n.unknown),
+        unknownPeers.length > 0 ? ul(unknownPeers.map(renderPeer)) : p(i18n.noDiscovered),
         p(i18n.connectionActionIntro)
       )
     )

+ 5 - 1
src/views/stats_view.js

@@ -88,7 +88,11 @@ exports.statsView = (stats, filter) => {
               span({ style: 'color:#888;' }, String(C(stats, 'aiExchange') || 0))
             )
           )
-        ),     
+        ),  
+        
+        div({ style: headerStyle },
+	  h3(`${i18n.statsPUBs}: ${String(stats.pubsCount || 0)}`)
+	),   
 
         filter === 'ALL'
           ? div({ class: 'stats-container' }, [