Pārlūkot izejas kodu

Oasis release 0.3.3

psy 1 mēnesi atpakaļ
vecāks
revīzija
bc466ca3e2

+ 0 - 5
.depcheckrc

@@ -1,5 +0,0 @@
-ignores: [
-  "@types/*",
-  "husky",
-  "stylelint-config-recommended"
-]

+ 0 - 3
.github/ISSUE_TEMPLATE.md

@@ -1,3 +0,0 @@
-## What's the problem you want solved?
-
-## Is there a solution you'd like to recommend?

+ 0 - 3
.github/PULL_REQUEST_TEMPLATE.md

@@ -1,3 +0,0 @@
-## What's the problem you solved?
-
-## What solution are you recommending?

+ 0 - 12
.github/dependabot.yml

@@ -1,12 +0,0 @@
-# To get started with Dependabot version updates, you'll need to specify which
-# package ecosystems to update and where the package manifests are located.
-# Please see the documentation for all configuration options:
-# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
-
-version: 2
-updates:
-  - package-ecosystem: "npm" # See documentation for possible values
-    directory: "/" # Location of package manifests
-    schedule:
-      interval: "daily"
-    open-pull-requests-limit: 16

+ 0 - 28
.github/workflows/pr.yml

@@ -1,28 +0,0 @@
-name: Node.js CI
-
-on:
-  push:
-    branches:
-      - master
-  pull_request:
-    branches:
-      - master
-
-jobs:
-  build:
-    runs-on: ${{ matrix.os }}
-
-    strategy:
-      matrix:
-        node-version: [10.x, 12.x, 14.x]
-        os: [macos-latest, windows-latest, ubuntu-latest]
-
-    steps:
-      - run: git config --global core.autocrlf false
-      - uses: actions/checkout@v2
-      - name: Use Node.js ${{ matrix.node-version }}
-        uses: actions/setup-node@v1
-        with:
-          node-version: ${{ matrix.node-version }}
-      - run: npm ci
-      - run: npm test

+ 0 - 15
.gitignore

@@ -1,15 +0,0 @@
-*.log*
-.DS_Store
-.nyc_output/
-coverage/
-dist/
-node_modules/
-tmp/
-yarn.lock
-
-# Visual Studio Code
-launch.json
-*.code-workspace
-
-# Vim
-.*.sw[a-z]

+ 0 - 5
.huskyrc

@@ -1,5 +0,0 @@
-{
-  "hooks": {
-    "pre-commit": "npm test"
-  }
-}

+ 0 - 1
.prettierignore

@@ -1 +0,0 @@
-.nyc_output

+ 0 - 3
.stylelintrc

@@ -1,3 +0,0 @@
-{
-  "extends": "stylelint-config-recommended"
-}

+ 83 - 17
README.md

@@ -1,22 +1,77 @@
-# SNH-Oasis
+# Oasis
 
-  ![SNH](https://solarnethub.com/lib/tpl/dokuwiki/images/logo.png "SolarNET.HuB")
+Oasis is a **free, open-source, encrypted, peer-to-peer, distributed & federated**... project networking application 
+that helps you follow interesting content and discover new ones.
 
-## Description:
+Check ['Overview`](https://wiki.solarnethub.com/socialnet/overview) for more info.
 
-SNH-Oasis is a **free, open-source, encrypted, peer-to-peer, distributed & federated**... project networking application 
-that helps you follow interesting content and discover new ones.
+  ![SNH](https://solarnethub.com/git/snh-oasis-logo.jpg "SolarNET.HuB")
+
+Oasis redefines what it means to be connected in the modern world, giving people 
+the ability to control their online presence and interactions without the need for centralized institutions. 
+
+## Architecture:
+
+Oasis uses a gossip protocol or epidemic protocol which is a procedure or process of computer peer-to peer communication 
+that is based on the way epidemics spread.
+
+  ![SNH](https://solarnethub.com/git/snh-meshnet.png "SolarNET.HuB")
+
+This means that information is able to distribute across multiple machines, without requiring direct connections between them. 
+
+  ![SNH](https://solarnethub.com/git/gossip-graph1.png "SolarNET.HuB")
+
+Even though Alice and Dan lack a direct connection, they can still exchange feeds: 
+
+  ![SNH](https://solarnethub.com/git/gossip-graph2.png "SolarNET.HuB")
+ 
+This is because gossip creates “transitive” connections between computers. Dan's messages travel through Carla and the PUB 
+to reach Alice, and visa-versa. 
+
+## Backend:
+
+Oasis is based on a mesh network and self-hosted social media ecosystem called Secure Scuttlebutt (SSB). 
+
+SSB uses a blockchain like append-only data structure and a fully decentralized P2P network. There are no servers or authorities 
+of any kind. Like a crypto transaction, SSB posts are censorship-resistant and are replicated to the entire network.
+
+  ![SNH](https://solarnethub.com/git/ssb-participants-perspective.png "SolarNET.HuB")
+
+In SSB each user hosts their own content and the content of the peers they follow, which provides fault tolerance and 
+eventual consistency. 
 
-  ![SNH](https://solarnethub.com/_media/socialnet/snh-oasis_profile-2.png "SolarNET.HuB")
+## Frontend:
 
- +  No browser JavaScript!. Just pure HTML+CSS.
+Main features of the Oasis interface are:
+
+ +  No browser JavaScript. Just pure HTML+CSS. A really secure frontend!.
  +  Use your favorite web browser to read and write messages to the people you care about.
  +  Strong cryptography in every single point of the network.
  +  You are the center of your own distributed network. Online or offline, it works anywhere that you are.
  +  Initial identities are randomnly generated (no username or password required).
  +  No personal profile generated (no questions about gender, age, location, etc …).
- +  No email or associated mobile phone required.
  +  Automatic exif stripping (such as GPS coordinates) on images for better privacy.
+ +  No email or associated mobile phone required.
+ +  Support for multiple languages.
+ +  Automatic updates with new functionalities.
+ 
+   ![SNH](https://solarnethub.com/git/snh-oasis-settings.png "SolarNET.HuB")
+      
+But it has others features that are also really interesting, for example:
+
+ +  Modularity to set your own environment.
+ 
+   ![SNH](https://solarnethub.com/git/snh-oasis-modules.png "SolarNET.HuB")
+     
+ +  A wallet to manage your ECOIn assets directly on the network.
+ 
+   ![SNH](https://solarnethub.com/git/snh-oasis-ecoin.png "SolarNET.HuB")
+    
+ +  A client side robust encryption (aes-256-cbc) to encrypt/decrypt your messages, even on the semantic layer.
+
+   ![SNH](https://solarnethub.com/git/snh-oasis-cipher.png "SolarNET.HuB")
+   
+And much more, that we invite you to discover by yourself.
 
 ----------
 
@@ -36,11 +91,11 @@ Visit ['Settings'](https://wiki.solarnethub.com/socialnet/snh#settings_minimal)
 
 Join ['PUB: "La Plaza"'](https://wiki.solarnethub.com/socialnet/snh-pub) to start to be connected with other interesting projects in the Multiverse.
 
-  ![SNH](https://solarnethub.com/_media/socialnet/snh-oasis_federation-2.png "SolarNET.HuB")
+  ![SNH](https://solarnethub.com/git/snh-oasis_federation-2.png "SolarNET.HuB")
   
 This allows you to communicate and access content from outside the [project network](https://wiki.solarnethub.com/socialnet/overview). 
 
-  ![SNH](https://solarnethub.com/_media/socialnet/snh-multiverse.png "SolarNET.HuB")
+  ![SNH](https://solarnethub.com/git/snh-multiverse.png "SolarNET.HuB")
 
 ----------
 
@@ -48,23 +103,31 @@ This allows you to communicate and access content from outside the [project netw
 
 The public content of the ['PUB: "La Plaza"'](https://wiki.solarnethub.com/socialnet/snh-pub) can be visited from outside the [project network](https://wiki.solarnethub.com/socialnet/overview), through the [World Wide Web](https://en.wikipedia.org/wiki/World_Wide_Web) (aka [Clearnet](https://en.wikipedia.org/wiki/Clearnet_(networking))).
 
-  ![SNH](https://solarnethub.com/_media/socialnet/snh-pub-feed.png "SolarNET.HuB") 
+  ![SNH](https://solarnethub.com/git/snh-pub-feed.png "SolarNET.HuB") 
   
 Just visit: https://pub.solarnethub.com/
 
-  ![SNH](https://solarnethub.com/_media/socialnet/snh-pub-laplaza.png "SolarNET.HuB")
+  ![SNH](https://solarnethub.com/git/snh-pub-laplaza.png "SolarNET.HuB")
+  
+And also you can visit periodically the public statistic of the SNH-PUB:
+
+  ![SNH](https://solarnethub.com/git/snh-pub-stats.png "SolarNET.HuB")
+  
+See stats: https://laplaza.solarnethub.com/
 
 ----------
 
-## Roadmap:
+## Development:
 
-Review ['Roadmap'](https://wiki.solarnethub.com/project/roadmap#the_project_network) to know about some required functionalities that can be implemented.
+Oasis is completely coded in: node.js (v22.13.1), HTML5 + CSS.
+
+Check ['Call 4 Hackers'](https://wiki.solarnethub.com/community/hackers) for contributing with developments.
 
 ----------
 
-## Development:
+## Roadmap:
 
-Check ['Call 4 Hackers'](https://wiki.solarnethub.com/community/hackers) for contributing with developments.
+Review ['Roadmap'](https://wiki.solarnethub.com/project/roadmap#the_project_network) to know about some required functionalities that can be implemented.
 
 ----------
 
@@ -72,10 +135,13 @@ 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
- + Role-playing: https://wiki.solarnethub.com/socialnet/roleplaying
+ + 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

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 467 - 867
package-lock.json


+ 6 - 4
package.json

@@ -1,7 +1,7 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.3.2",
-  "description": "SNH-Oasis Project Network GUI",
+  "version": "0.3.3",
+  "description": "SNH-Oasis Project Networking GUI",
   "repository": {
     "type": "git",
     "url": "git+ssh://git@code.03c8.net/krakenlabs/oasis.git"
@@ -25,14 +25,15 @@
     "version": "mv docs/CHANGELOG.md ./ && changelog-version && mv CHANGELOG.md docs/ && git add docs/CHANGELOG.md"
   },
   "dependencies": {
-    "@fraction/base16-css": "^1.1.0",
     "@koa/router": "^13.1.0",
     "@open-rpc/client-js": "^1.8.1",
     "abstract-level": "^2.0.1",
+    "archiver": "^7.0.1",
     "await-exec": "^0.1.2",
     "axios": "^1.7.9",
     "broadcast-stream": "^0.2.1",
     "caller-path": "^4.0.0",
+    "crypto": "^1.0.1",
     "debug": "^4.3.1",
     "env-paths": "^2.2.1",
     "epidemic-broadcast-trees": "^9.0.4",
@@ -45,6 +46,7 @@
     "is-valid-domain": "^0.1.6",
     "koa": "^2.7.0",
     "koa-body": "^6.0.1",
+    "koa-bodyparser": "^4.4.1",
     "koa-mount": "^4.0.0",
     "koa-static": "^5.0.0",
     "lodash": "^4.17.21",
@@ -77,7 +79,6 @@
     "qrcode": "^1.5.4",
     "remark-html": "^16.0.1",
     "require-style": "^1.1.0",
-    "scuttle-poll": "^1.0.3",
     "secret-stack": "^6.3.1",
     "ssb-about": "^2.0.1",
     "ssb-backlinks": "^2.1.1",
@@ -131,6 +132,7 @@
     "ssb-tunnel": "^2.0.0",
     "ssb-unix-socket": "^1.0.0",
     "ssb-ws": "^6.2.3",
+    "unzipper": "^0.12.3",
     "yargs": "^17.7.2"
   },
   "overrides": {

+ 5 - 1
src/assets/highlight.css

@@ -49,7 +49,6 @@
 .hljs {
   display: block;
   overflow-x: auto;
-  background: white;
   color: var(--base05);
   padding: 0.5em;
 }
@@ -61,3 +60,8 @@
 .hljs-strong {
   font-weight: bold;
 }
+
+.hljs-string, .hljs-link {
+  background: #FFA500;
+  user-select: text;
+}

BIN
src/assets/snh-oasis.jpg


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 414 - 710
src/assets/style.css


+ 11 - 3
src/modules-config.js

@@ -1,12 +1,20 @@
 const fs = require('fs');
 const path = require('path');
 
-const configFilePath = path.join(__dirname, 'modules.json');
+const configFilePath = path.join(__dirname, 'config.json');
 
 if (!fs.existsSync(configFilePath)) {
   const defaultConfig = {
-    invitesMod: 'on',
-    walletMod: 'on',
+    modules: {
+      invitesMod: 'on',
+      walletMod: 'on',
+    },
+    wallet: {
+      url: 'http://localhost:7474',
+      user: 'ecoinrpc',
+      pass: 'ecoinrpc',
+      fee: 0.01,
+    }
   };
   fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
 }

+ 21 - 0
src/config.json

@@ -0,0 +1,21 @@
+{
+  "modules": {
+    "popularMod": "on",
+    "topicsMod": "on",
+    "summariesMod": "on",
+    "latestMod": "on",
+    "threadsMod": "on",
+    "multiverseMod": "on",
+    "inboxMod": "on",
+    "invitesMod": "on",
+    "walletMod": "on",
+    "legacyMod": "on",
+    "cipherMod": "on"
+  },
+  "wallet": {
+    "url": "http://localhost:7474",
+    "user": "ecoinrpc",
+    "pass": "ecoinrpc",
+    "fee": "0.01"
+  }
+}

+ 163 - 137
src/index.js

@@ -7,7 +7,7 @@ const path = require("path");
 const envPaths = require("env-paths");
 const {cli} = require("./cli");
 const fs = require("fs");
-
+const os = require('os');
 const promisesFs = require("fs").promises;
 
 const supports = require("./supports.js").supporting;
@@ -82,7 +82,7 @@ debug("Current configuration: %O", config);
 debug(`You can save the above to ${defaultConfigFile} to make \
 these settings the default. See the readme for details.`);
 
-const { saveConfig, getConfig } = require('./modules-config');
+const { saveConfig, getConfig } = require('./config');
 
 const oasisCheckPath = "/.well-known/oasis";
 
@@ -139,46 +139,26 @@ const pull = require("pull-stream");
 const koaRouter = require("@koa/router");
 const ssbMentions = require("ssb-mentions");
 const isSvg = require('is-svg');
-const { themeNames } = require("@fraction/base16-css");
 const { isFeed, isMsg, isBlob } = require("ssb-ref");
 
 const ssb = require("./ssb");
 
 const router = new koaRouter();
-
-// Create "cooler"-style interface from SSB connection.
-// This handle is passed to the models for their convenience.
 const cooler = ssb({ offline: config.offline });
 
 const models = require("./models");
 
-const { about, blob, friend, meta, post, vote, wallet } = models({
+const { about, blob, friend, meta, post, vote, wallet, legacy, cipher } = models({
   cooler,
   isPublic: config.public,
 });
 
 const nameWarmup = about._startNameWarmup();
-
-// enhance the users' input text by expanding @name to [@name](@feedPub.key)
-// and slurps up blob uploads and appends a markdown link for it to the text (see handleBlobUpload)
 const preparePreview = async function (ctx) {
   let text = String(ctx.request.body.text);
-
-  // find all the @mentions that are not inside a link already
-  // stores name:[matches...]
-  // TODO: sort by relationship
   const mentions = {};
-
-  // This matches for @string followed by a space or other punctuations like ! , or .
-  // The idea here is to match a plain @name but not [@name](...)
-  // also: re.exec has state => regex is consumed and thus needs to be re-instantiated for each call
-  //
-  // Change this link when the regex changes: https://regex101.com/r/j5rzSv/2
+  // TODO: sort by relationship
   const rex = /(^|\s)(?!\[)@([a-zA-Z0-9-]+)([\s.,!?)~]{1}|$)/g;
-  //                                        ^ sentence ^
-  //                                         delimiters
-
-  // find @mentions using rex and use about.named() to get the info for them
   let m;
   while ((m = rex.exec(text)) !== null) {
     const name = m[2];
@@ -189,7 +169,6 @@ const preparePreview = async function (ctx) {
       mentions[name] = found;
     }
   }
-
   // filter the matches depending on the follow relation
   Object.keys(mentions).forEach((name) => {
     let matches = mentions[name];
@@ -249,11 +228,11 @@ const handleBlobUpload = async function (ctx) {
     return "";
   }
 
-  // 5 MiB check
-  const mebibyte = Math.pow(2, 20);
-  const maxSize = 5 * mebibyte;
+  // 25 MiB check
+  const megabyte = Math.pow(2, 20);
+  const maxSize = 25 * megabyte;
   if (data.length > maxSize) {
-    throw new Error("Blob file is too big, maximum size is 5 mebibytes");
+    throw new Error("File is too big, maximum size is 25 megabytes");
   }
 
   try {
@@ -384,10 +363,11 @@ const {
   walletSendFormView,
   walletSendConfirmView,
   walletSendResultView,
+  legacyView,
+  cipherView,
 } = require("./views/index.js");
 
 const ssbRef = require("ssb-ref");
-
 const markdownView = require("./views/markdown.js");
 
 let sharp;
@@ -563,18 +543,14 @@ router
     if (isBlob(query)) {
       return ctx.redirect(`/blob/${encodeURIComponent(query)}`);
     }
-
     if (typeof query === "string") {
-      // https://github.com/ssbc/ssb-search/issues/7
       query = query.toLowerCase();
       if (query.length > 1 && query.startsWith("#")) {
         const hashtag = query.slice(1);
         return ctx.redirect(`/hashtag/${encodeURIComponent(hashtag)}`);
       }
     }
-
     const messages = await post.search({ query });
-
     ctx.body = await searchView({ messages, query });
   })
   .get("/imageSearch", async (ctx) => {
@@ -601,50 +577,7 @@ router
     const { hashtag } = ctx.params;
     const messages = await post.fromHashtag(hashtag);
     ctx.body = await hashtagView({ hashtag, messages });
-  })
-  .get("/theme.css", async (ctx) => {
-    const theme = ctx.cookies.get("theme") || config.theme;
-  
-    const packageName = "@fraction/base16-css";
-    const filePath = path.resolve(
-      "node_modules",
-      packageName,
-      "src",
-      `base16-${theme}.css`
-    );
-  
-    try {
-      
-      // await the css content
-      const cssContent = await promisesFs.readFile(filePath, { encoding: "utf8" });
-  
-      ctx.type = "text/css"; // Set the Content-Type header
-      ctx.body = cssContent; // Serve the CSS content
-
-    } catch (err) {
-      console.error("Error reading CSS file:", err.message);
-  
-      ctx.status = 404; // Return a 404 status if the file is not found
-      ctx.body = "Theme not found.";
-    }
-  })
-  
-  .get("/custom-style.css", async (ctx) => {
-    ctx.type = "text/css";
-    try {
-
-      // Read the CSS file
-      const cssContent = await fs.readFileSync(customStyleFile, "utf8");
-
-      ctx.type = "text/css"; // Set the Content-Type header
-      ctx.body = cssContent; // Serve the CSS content
-    } catch (err) {
-      console.error("Error reading custom style file:", err.message);
-
-      ctx.status = 404; // Return a 404 status if the file is not found
-      ctx.body = "Custom style not found.";
-    }
-  })
+   })
   .get("/profile", async (ctx) => {
     const myFeedId = await meta.myFeedId();
     const gt = Number(ctx.request.query["gt"] || -1);
@@ -808,7 +741,7 @@ router
     ctx.body = await image({ blobId, imageSize: Number(imageSize) });
   })
   .get("/modules", async (ctx) => {
-    const configMods = getConfig();
+    const configMods = getConfig().modules;
     const popularMod = ctx.cookies.get('popularMod', { signed: false }) || configMods.popularMod;
     const topicsMod = ctx.cookies.get('topicsMod', { signed: false }) || configMods.topicsMod;
     const summariesMod = ctx.cookies.get('summariesMod', { signed: false }) || configMods.summariesMod;
@@ -818,22 +751,16 @@ router
     const inboxMod = ctx.cookies.get('inboxMod', { signed: false }) || configMods.inboxMod;
     const invitesMod = ctx.cookies.get('invitesMod', { signed: false }) || configMods.invitesMod;
     const walletMod = ctx.cookies.get('walletMod', { signed: false }) || configMods.walletMod;
-    ctx.body = modulesView({ popularMod, topicsMod, summariesMod, latestMod, threadsMod, multiverseMod, inboxMod, invitesMod, walletMod });
+    const legacyMod = ctx.cookies.get('legacyMod', { signed: false }) || configMods.legacyMod;
+    const cipherMod = ctx.cookies.get('cipherMod', { signed: false }) || configMods.cipherMod;
+    ctx.body = modulesView({ popularMod, topicsMod, summariesMod, latestMod, threadsMod, multiverseMod, inboxMod, invitesMod, walletMod, legacyMod, cipherMod });
   })
   .get("/settings", async (ctx) => {
     const theme = ctx.cookies.get("theme") || config.theme;
-    const walletUrl = ctx.cookies.get("wallet_url") || config.walletUrl;
-    const walletUser = ctx.cookies.get("wallet_user") || config.walletUser;
-    const walletFee = ctx.cookies.get("wallet_fee") || config.walletFee;
-
-   const getMeta = async ({ theme }) => {
+    const getMeta = async ({ theme }) => {
       return settingsView({
         theme,
-        themeNames,
         version: version.toString(),
-        walletUrl,
-        walletUser,
-        walletFee
       });
     };
     ctx.body = await getMeta({ theme });
@@ -895,6 +822,30 @@ router
     };
     ctx.body = await mentions();
   })
+  .get('/legacy', async (ctx) => {
+    const legacyMod = ctx.cookies.get("legacyMod") || 'on';
+    if (legacyMod !== 'on') {
+      ctx.redirect('/modules');
+      return;
+    }
+    try {
+      ctx.body = await legacyView();
+    } catch (error) {
+      ctx.body = { error: error.message };
+    }
+  }) 
+  .get('/cipher', async (ctx) => {
+    const cipherMod = ctx.cookies.get("cipherMod") || 'on';
+    if (cipherMod !== 'on') {
+      ctx.redirect('/modules');
+      return;
+    }
+    try {
+      ctx.body = await cipherView();
+    } catch (error) {
+      ctx.body = { error: error.message };
+    }
+  })  
   .get("/thread/:message", async (ctx) => {
     const { message } = ctx.params;
     const thread = async (message) => {
@@ -921,14 +872,12 @@ router
     ctx.body = await commentView({ messages, myFeedId, parentMessage });
   })
   .get("/wallet", async (ctx) => {
+    const { url, user, pass } = getConfig().wallet;
     const walletMod = ctx.cookies.get("walletMod") || 'on';
     if (walletMod !== 'on') {
       ctx.redirect('/modules');
       return;
     }
-    const url = ctx.cookies.get("wallet_url") || config.walletUrl;
-    const user = ctx.cookies.get("wallet_user") || config.walletUser;
-    const pass = ctx.cookies.get("wallet_pass") || config.walletPass;
     try {
       const balance = await wallet.getBalance(url, user, pass);
       ctx.body = await walletView(balance);
@@ -937,9 +886,7 @@ router
     }
   })
   .get("/wallet/history", async (ctx) => {
-    const url = ctx.cookies.get("wallet_url") || config.walletUrl;
-    const user = ctx.cookies.get("wallet_user") || config.walletUser;
-    const pass = ctx.cookies.get("wallet_pass") || config.walletPass;
+    const { url, user, pass } = getConfig().wallet;
     try {
       const balance = await wallet.getBalance(url, user, pass);
       const transactions = await wallet.listTransactions(url, user, pass);
@@ -949,22 +896,17 @@ router
     }
   })
   .get("/wallet/receive", async (ctx) => {
-    const url = ctx.cookies.get("wallet_url") || config.walletUrl;
-    const user = ctx.cookies.get("wallet_user") || config.walletUser;
-    const pass = ctx.cookies.get("wallet_pass") || config.walletPass;
+    const { url, user, pass } = getConfig().wallet;
     try {
-    const balance = await wallet.getBalance(url, user, pass);
-    const address = await wallet.getAddress(url, user, pass);
-    ctx.body = await walletReceiveView(balance, address);
+      const balance = await wallet.getBalance(url, user, pass);
+      const address = await wallet.getAddress(url, user, pass);
+      ctx.body = await walletReceiveView(balance, address);
     } catch (error) {
       ctx.body = await walletErrorView(error);
     }
   })
   .get("/wallet/send", async (ctx) => {
-    const url = ctx.cookies.get("wallet_url") || config.walletUrl;
-    const user = ctx.cookies.get("wallet_user") || config.walletUser;
-    const pass = ctx.cookies.get("wallet_pass") || config.walletPass;
-    const fee = ctx.cookies.get("wallet_fee") || config.walletFee;
+    const { url, user, pass, fee } = getConfig().wallet;
     try {
       const balance = await wallet.getBalance(url, user, pass);
       ctx.body = await walletSendFormView(balance, null, null, fee, null);
@@ -972,8 +914,7 @@ router
       ctx.body = await walletErrorView(error);
     }
   })
-  .post(
-    "/subtopic/preview/:message",
+  .post("/subtopic/preview/:message",
     koaBody({ multipart: true }),
     async (ctx) => {
       const { message } = ctx.params;
@@ -1017,8 +958,7 @@ router
     ctx.body = await publishSubtopic({ message, text });
     ctx.redirect(`/thread/${encodeURIComponent(message)}`);
   })
-  .post(
-    "/comment/preview/:message",
+  .post("/comment/preview/:message",
     koaBody({ multipart: true }),
     async (ctx) => {
       const { messages, contentWarning, myFeedId, parentMessage } =
@@ -1058,8 +998,6 @@ router
   })
   .post("/publish/preview", koaBody({ multipart: true }), async (ctx) => {
     const rawContentWarning = String(ctx.request.body.contentWarning).trim();
-
-    // Only submit content warning if it's a string with non-zero length.
     const contentWarning =
       rawContentWarning.length > 0 ? rawContentWarning : undefined;
 
@@ -1069,8 +1007,6 @@ router
   .post("/publish", koaBody(), async (ctx) => {
     const text = String(ctx.request.body.text);
     const rawContentWarning = String(ctx.request.body.contentWarning);
-
-    // Only submit content warning if it's a string with non-zero length.
     const contentWarning =
       rawContentWarning.length > 0 ? rawContentWarning : undefined;
 
@@ -1156,7 +1092,95 @@ router
     ctx.body = await like({ messageKey, voteValue });
     ctx.redirect(referer.href);
   })
-
+  .post('/legacy/export', koaBody(), async (ctx) => {
+    const password = ctx.request.body.password;
+    if (!password || password.length < 32) {
+      ctx.redirect('/legacy'); 
+      return;
+    }
+    try {
+      const encryptedFilePath = await legacy.exportData({ password });
+      ctx.body = {
+        message: 'Data exported successfully!',
+        file: encryptedFilePath
+      };
+      ctx.redirect('/legacy');
+    } catch (error) {
+      ctx.status = 500;
+      ctx.body = { error: `Error: ${error.message}` };
+      ctx.redirect('/legacy');
+    }
+  })
+  .post('/legacy/import', koaBody({ 
+    multipart: true, 
+    formidable: { 
+      keepExtensions: true, 
+      uploadDir: '/tmp', 
+      } 
+    }), async (ctx) => {
+    const uploadedFile = ctx.request.files?.uploadedFile;
+    const password = ctx.request.body.importPassword;
+    if (!uploadedFile) {
+      ctx.body = { error: 'No file uploaded' };
+      ctx.redirect('/legacy');
+      return;
+    }
+    if (!password || password.length < 32) {
+      ctx.body = { error: 'Password is too short or missing.' };
+      ctx.redirect('/legacy');
+      return;
+    }
+    try {
+      await legacy.importData({ filePath: uploadedFile.filepath, password });
+      ctx.body = { message: 'Data imported successfully!' };
+      ctx.redirect('/legacy');
+    } catch (error) {
+      ctx.body = { error: error.message };
+      ctx.redirect('/legacy');
+    }
+  })
+  .post('/cipher/encrypt', koaBody(), async (ctx) => {
+    const { text, password } = ctx.request.body;
+    if (!text || !password) {
+      ctx.body = { error: 'Text or password not provided.' };
+      ctx.redirect('/cipher');
+      return;
+    }
+    if (password.length < 32) {
+      ctx.body = { error: 'Password is too short or missing.' };
+      ctx.redirect('/cipher');
+      return;
+    }
+    try {
+      const { encryptedText, iv } = cipher.encryptData(text, password);
+      const view = await cipherView(encryptedText, "", iv, password); 
+      ctx.body = view;
+    } catch (error) {
+      ctx.body = { error: error.message };
+      ctx.redirect('/cipher');
+    }
+  })
+  .post('/cipher/decrypt', koaBody(), async (ctx) => {
+    const { encryptedText, password, iv } = ctx.request.body;
+    if (!encryptedText || !password || !iv) {
+      ctx.body = { error: 'Text, password, or iv not provided.' };
+      ctx.redirect('/cipher');
+      return;
+    }
+    if (password.length < 32) {
+      ctx.body = { error: 'Password is too short or missing.' };
+      ctx.redirect('/cipher');
+      return;
+    }
+    try {
+      const decryptedText = cipher.decryptData(encryptedText, password, iv);
+      const view = await cipherView("", decryptedText, iv, password);
+      ctx.body = view;
+    } catch (error) {
+      ctx.body = { error: error.message };
+      ctx.redirect('/cipher');
+    }
+  })
   .post("/update", koaBody(), async (ctx) => {
     const util = require("node:util");
     const exec = util.promisify(require("node:child_process").exec);
@@ -1171,10 +1195,10 @@ router
     ctx.redirect(referer.href);
   })
   .post("/theme.css", koaBody(), async (ctx) => {
-    const theme = String(ctx.request.body.theme);
-    ctx.cookies.set("theme", theme);
+    const theme = "SNH-Oasis"; 
+    ctx.cookies.set("theme", theme);  
     const referer = new URL(ctx.request.header.referer);
-    ctx.redirect(referer.href);
+    ctx.redirect(referer.href);  
   })
   .post("/language", koaBody(), async (ctx) => {
     const language = String(ctx.request.body.language);
@@ -1222,6 +1246,8 @@ router
     const inboxMod = ctx.request.body.inboxForm === 'on' ? 'on' : 'off';
     const invitesMod = ctx.request.body.invitesForm === 'on' ? 'on' : 'off';
     const walletMod = ctx.request.body.walletForm === 'on' ? 'on' : 'off';
+    const legacyMod = ctx.request.body.legacyForm === 'on' ? 'on' : 'off';
+    const cipherMod = ctx.request.body.cipherForm === 'on' ? 'on' : 'off';
     ctx.cookies.set("popularMod", popularMod, { httpOnly: true, maxAge: 86400000, path: '/' });
     ctx.cookies.set("topicsMod", topicsMod, { httpOnly: true, maxAge: 86400000, path: '/' });
     ctx.cookies.set("summariesMod", summariesMod, { httpOnly: true, maxAge: 86400000, path: '/' });
@@ -1231,16 +1257,20 @@ router
     ctx.cookies.set("inboxMod", inboxMod, { httpOnly: true, maxAge: 86400000, path: '/' });
     ctx.cookies.set("invitesMod", invitesMod, { httpOnly: true, maxAge: 86400000, path: '/' });
     ctx.cookies.set("walletMod", walletMod, { httpOnly: true, maxAge: 86400000, path: '/' });
+    ctx.cookies.set("legacyMod", legacyMod, { httpOnly: true, maxAge: 86400000, path: '/' });
+    ctx.cookies.set("cipherMod", cipherMod, { httpOnly: true, maxAge: 86400000, path: '/' });
     const currentConfig = getConfig();
-    currentConfig.popularMod = popularMod;
-    currentConfig.topicsMod = topicsMod;
-    currentConfig.summariesMod = summariesMod;
-    currentConfig.latestMod = latestMod;
-    currentConfig.threadsMod = threadsMod;
-    currentConfig.multiverseMod = multiverseMod;
-    currentConfig.inboxMod = inboxMod;
-    currentConfig.invitesMod = invitesMod;
-    currentConfig.walletMod = walletMod;
+    currentConfig.modules.popularMod = popularMod;
+    currentConfig.modules.topicsMod = topicsMod;
+    currentConfig.modules.summariesMod = summariesMod;
+    currentConfig.modules.latestMod = latestMod;
+    currentConfig.modules.threadsMod = threadsMod;
+    currentConfig.modules.multiverseMod = multiverseMod;
+    currentConfig.modules.inboxMod = inboxMod;
+    currentConfig.modules.invitesMod = invitesMod;
+    currentConfig.modules.walletMod = walletMod;
+    currentConfig.modules.legacyMod = legacyMod;
+    currentConfig.modules.cipherMod = cipherMod;
     saveConfig(currentConfig);
     ctx.redirect(`/modules`);
   })
@@ -1249,10 +1279,12 @@ router
     const user = String(ctx.request.body.wallet_user);
     const pass = String(ctx.request.body.wallet_pass);
     const fee = String(ctx.request.body.wallet_fee);
-    url && url.trim() !== "" && ctx.cookies.set("wallet_url", url);
-    user && user.trim() !== "" && ctx.cookies.set("wallet_user", user);
-    pass && pass.trim() !== "" && ctx.cookies.set("wallet_pass", pass);
-    fee && fee > 0 && ctx.cookies.set("wallet_fee", fee);
+    const currentConfig = getConfig();
+    if (url) currentConfig.wallet.url = url;
+    if (user) currentConfig.wallet.user = user;
+    if (pass) currentConfig.wallet.pass = pass;
+    if (fee) currentConfig.wallet.fee = fee;
+    saveConfig(currentConfig);
     const referer = new URL(ctx.request.header.referer);
     ctx.redirect(referer.href);
   })
@@ -1261,9 +1293,7 @@ router
     const destination = String(ctx.request.body.destination);
     const amount = Number(ctx.request.body.amount);
     const fee = Number(ctx.request.body.fee);
-    const url = ctx.cookies.get("wallet_url") || config.walletUrl;
-    const user = ctx.cookies.get("wallet_user") || config.walletUser;
-    const pass = ctx.cookies.get("wallet_pass") || config.walletPass;
+    const { url, user, pass } = getConfig().wallet;
     let balance = null
 
     try {
@@ -1334,7 +1364,6 @@ const middleware = [
 
     const left = totalTarget - totalCurrent;
 
-    // Weird trick to get percentage with 1 decimal place (e.g. 78.9)
     const percent = Math.floor((totalCurrent / totalTarget) * 1000) / 10;
     const mebibyte = 1024 * 1024;
 
@@ -1350,9 +1379,6 @@ const middleware = [
 const { allowHost } = config;
 const app = http({ host, port, middleware, allowHost });
 
-// HACK: This lets us close the database once tests finish.
-// If we close the database after each test it throws lots of really fun "parent
-// stream closing" errors everywhere and breaks the tests. :/
 app._close = () => {
   nameWarmup.close();
   cooler.close();

+ 141 - 33
src/models.js

@@ -9,6 +9,7 @@ const pullParallelMap = require("pull-paramap");
 const pull = require("pull-stream");
 const pullSort = require("pull-sort");
 const ssbRef = require("ssb-ref");
+
 const {
   RequestManager,
   HTTPTransport,
@@ -63,21 +64,6 @@ const configure = (...customOptions) =>
 
 module.exports = ({ cooler, isPublic }) => {
   const models = {};
-
-  /**
-   * The SSB-About plugin is a thin wrapper around the SSB-Social-Index plugin.
-   * Unfortunately, this plugin has two problems that make it incompatible with
-   * our needs:
-   *
-   * - We want to get the latest value from an author, like what someone calls
-   *   themselves, **not what other people call them**.
-   * - The plugin has a bug where `false` isn't handled correctly, which is very
-   *   important since we use `publicWebHosting`, a boolean field.
-   *
-   * It feels very silly to have to maintain an alternative implementation of
-   * SSB-About, but this is much smaller code and doesn't have either of the
-   * above problems. Maybe this should be moved somewhere else in the future?
-   */
   const getAbout = async ({ key, feedId }) => {
     const ssb = await cooler.open();
     const source = ssb.backlinks.read({
@@ -114,17 +100,7 @@ module.exports = ({ cooler, isPublic }) => {
       )
     );
   };
-
-  // build a @mentions lookup cache
-  // ==============================
-  // one gotcha with ssb-query is: if we add `name: "my name"` to that query below,
-  // it can trigger a full-scan of the database instead of better query planing
-  // also doing multiple of those can be very slow (5 to 30s on my machine).
-  // gotcha two is: there is no way to express (where msg.author == msg.value.content.about) so we need to do it as a pull.filter()
-  // one drawback: is, it gives us all the about messages from forever, not just the latest
   // TODO: an alternative would be using ssb.names if available and just loading this as a fallback
-
-  // Two lookup tables to remove old and duplicate names
   const feeds_to_name = {};
   let all_the_names = {};
 
@@ -1699,11 +1675,11 @@ module.exports = ({ cooler, isPublic }) => {
     publishProfileEdit: async ({ name, description, image }) => {
       const ssb = await cooler.open();
       if (image.length > 0) {
-        // 5 MiB check
-        const mebibyte = Math.pow(2, 20);
-        const maxSize = 5 * mebibyte;
+        // 25 MiB check
+        const megabyte = Math.pow(2, 20);
+        const maxSize = 25 * megabyte;
         if (image.length > maxSize) {
-          throw new Error("Image file is too big, maximum size is 5 mebibytes");
+          throw new Error("File is too big, maximum size is 25 megabytes");
         }
 
         return new Promise((resolve, reject) => {
@@ -1901,8 +1877,7 @@ module.exports = ({ cooler, isPublic }) => {
   };
   models.post = post;
 
-  models.vote = {
-    /** @param {{messageKey: string, value: {}, recps: []}} input */
+models.vote = {
     publish: async ({ messageKey, value, recps }) => {
       const ssb = await cooler.open();
       const branch = await ssb.tangle.branch(messageKey);
@@ -1919,7 +1894,7 @@ module.exports = ({ cooler, isPublic }) => {
     },
   };
 
-  models.wallet = {
+models.wallet = {
     client: async (url, user, pass) => {
       const transport = new HTTPTransport(url, {
         headers: {
@@ -1966,5 +1941,138 @@ module.exports = ({ cooler, isPublic }) => {
     }
   }
 
-  return models;
+//legacy: export/import .ssb secret (private key)
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const os = require('os');
+
+function encryptFile(filePath, password) {
+  if (typeof password === 'object' && password.password) {
+    password = password.password;
+  }
+  const key = Buffer.from(password, 'utf-8');
+  const iv = crypto.randomBytes(16);
+  const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
+  const homeDir = os.homedir();
+  const encryptedFilePath = path.join(homeDir, 'oasis.enc');
+  const output = fs.createWriteStream(encryptedFilePath);
+  const input = fs.createReadStream(filePath);
+  input.pipe(cipher).pipe(output);
+  return new Promise((resolve, reject) => {
+    output.on('finish', () => {
+      resolve(encryptedFilePath);
+    });
+    output.on('error', (err) => {
+      reject(err);
+    });
+  });
+}
+
+function decryptFile(filePath, password) {
+  if (typeof password === 'object' && password.password) {
+    password = password.password;
+  } 
+  const key = Buffer.from(password, 'utf-8');
+  const iv = crypto.randomBytes(16);
+  const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); 
+  const homeDir = os.homedir();
+  const decryptedFilePath = path.join(homeDir, 'secret');
+  const output = fs.createWriteStream(decryptedFilePath);
+  const input = fs.createReadStream(filePath);
+  input.pipe(decipher).pipe(output);
+  return new Promise((resolve, reject) => {
+    output.on('finish', () => {
+      resolve(decryptedFilePath);
+    });
+    output.on('error', (err) => {
+      console.error('Error deciphering data:', err);
+      reject(err);
+    });
+  });
+}
+
+models.legacy = {
+  exportData: async (password) => {
+    try {
+      const homeDir = os.homedir();
+      const secretFilePath = path.join(homeDir, '.ssb', 'secret');
+      
+      if (!fs.existsSync(secretFilePath)) {
+        throw new Error(".ssb/secret file doesn't exist");
+      }
+      const encryptedFilePath = await encryptFile(secretFilePath, password);   
+      fs.unlinkSync(secretFilePath);
+      return encryptedFilePath;
+    } catch (error) {
+      throw new Error("Error exporting data: " + error.message);
+    }
+  },
+  importData: async ({ filePath, password }) => {
+    try {
+      if (!fs.existsSync(filePath)) {
+        throw new Error('Encrypted file not found.');
+      }
+      const decryptedFilePath = await decryptFile(filePath, password);
+
+      if (!fs.existsSync(decryptedFilePath)) {
+        throw new Error("Decryption failed.");
+      }
+
+      fs.unlinkSync(filePath);
+      return decryptedFilePath;
+
+    } catch (error) {
+      throw new Error("Error importing data: " + error.message);
+    }
+  }
+};
+
+//cipher: encrypt/decrypt text at client side
+function encryptText(text, password) {
+  if (typeof password === 'object' && password.password) {
+    password = password.password;
+  }
+  const key = Buffer.from(password, 'utf-8');
+  const iv = crypto.randomBytes(16);
+  const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
+  let encryptedText = cipher.update(text, 'utf-8', 'hex');
+  encryptedText += cipher.final('hex');
+  const ivHex = iv.toString('hex');
+  return { encryptedText, iv: ivHex }; 
+}
+
+function decryptText(encryptedText, password, ivHex) {
+  if (typeof password === 'object' && password.password) {
+    password = password.password;
+  }
+  const key = Buffer.from(password, 'utf-8');
+  const iv = Buffer.from(ivHex, 'hex');
+  const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
+  let decryptedText = decipher.update(encryptedText, 'hex', 'utf-8');
+  decryptedText += decipher.final('utf-8');
+  return decryptedText;
+}
+
+models.cipher = {
+  encryptData: (text, password) => {
+    try {
+      const { encryptedText, iv } = encryptText(text, password);
+      return { encryptedText, iv }; 
+    } catch (error) {
+      throw new Error("Error encrypting data: " + error.message);
+    }
+  },
+  decryptData: (encryptedText, password, iv) => {
+    try {
+      const decryptedText = decryptText(encryptedText, password, iv);
+      return decryptedText;
+    } catch (error) {
+      throw new Error("Error decrypting data: " + error.message);
+    }
+  }
+};
+
+//return models
+return models;
 };

+ 0 - 11
src/modules.json

@@ -1,11 +0,0 @@
-{
-  "popularMod": "on",
-  "topicsMod": "on",
-  "summariesMod": "on",
-  "latestMod": "on",
-  "threadsMod": "on",
-  "multiverseMod": "on",
-  "inboxMod": "on",
-  "invitesMod": "on",
-  "walletMod": "on"
-}

+ 1 - 2
src/server.js

@@ -52,7 +52,6 @@ var conn_query = require('ssb-conn-query')
 var conn_hub = require('ssb-conn-hub')
 var conn_staging = require('ssb-conn-staging')
 var device_address = require('ssb-device-address')
-var poll = require('scuttle-poll')
 var gossip = require('ssb-gossip')
 var master = require('ssb-master')
 var logging = require('ssb-logging')
@@ -66,7 +65,7 @@ var links = require('ssb-links')
 
 // create ssb server
 function createSsbServer () {
-  return SecretStack({ caps }).use(SSB, gossip, tribes, conn, db2, master, ebt, box, threads, invite, conn_db, search2, friend_pub, invite_client, tunnel, config, conn_query, conn_hub, conn_staging, device_address, poll, friends, logging, replication_scheduler, partial_replication, about, onion, unix, auth, backlinks, links)
+  return SecretStack({ caps }).use(SSB, gossip, tribes, conn, db2, master, ebt, box, threads, invite, conn_db, search2, friend_pub, invite_client, tunnel, config, conn_query, conn_hub, conn_staging, device_address, friends, logging, replication_scheduler, partial_replication, about, onion, unix, auth, backlinks, links)
 }
 
 // add other required plugins (+flotilla) by SNH-Oasis (client) (plugin order is required!)

+ 528 - 163
src/views/i18n.js

@@ -73,6 +73,8 @@ const i18n = {
     inboxLabel: "☂ Inbox",
     invitesLabel: "ꔹ Invites",
     walletLabel: "❄ Wallet",
+    legacyLabel: "ꖸ Legacy",
+    cipherLabel: "ꗄ Crypter",
     saveSettings: "Save configuration",
     // post actions
     comment: "Comment",
@@ -87,17 +89,17 @@ const i18n = {
     olderPosts: "Older posts",
     feedRangeEmpty: "The given range is empty for this feed. Try viewing the ",
     seeFullFeed: "full feed",
-    feedEmpty: "The local client has never seen posts from this account.",
+    feedEmpty: "The Oasis client has never seen posts from this account.",
     beginningOfFeed: "This is the beginning of the feed",
     noNewerPosts: "No newer posts have been received yet.",
-    relationshipNotFollowing: "",
-    relationshipTheyFollow: "",
-    relationshipMutuals: "",
+    relationshipNotFollowing: "You are not supported",
+    relationshipTheyFollow: "They support",
+    relationshipMutuals: "Mutual support",
     relationshipFollowing: "You are supporting",
     relationshipYou: "You",
     relationshipBlocking: "You are blocking",
-    relationshipNone: "",
-    relationshipConflict: "",
+    relationshipNone: "You are not supporting",
+    relationshipConflict: "Conflict",
     relationshipBlockingPost: "Blocked post",
     // spreads view
     viewLikes: "View spreads",
@@ -153,7 +155,24 @@ const i18n = {
     settingsIntro: ({ version }) => [
       `Oasis: [${version}]`,
     ],
+    timeAgo: "ago",
+    sendTime: "about ",
     theme: "Theme",
+    legacy: "Legacy",
+    legacyTitle: "Legacy",
+    legacyDescription: "Manage your secret (private key) quickly and safely.",   
+    legacyExportButton: "Export",
+    legacyImportButton: "Import",
+    exportTitle: "Export data",
+    exportDescription: "Set password (min 32 characters long) to encrypt your key",
+    importTitle: "Import data",
+    importDescription: "Import your encrypted secret (private key) to enable your avatar",
+    importAttach: "Attach encrypted file (.enc)",    
+    passwordLengthInfo: "Password must be at least 32 characters long.",
+    passwordImport: "Write your password to decrypt data that will be saved at your system home (name: secret)",
+    randomPassword: "Random password",
+    exportPasswordPlaceholder: "Use lowercase, uppercase, numbers & symbols",
+    fileInfo: "Your encrypted secret key will be saved at your system home (name: oasis.enc)",
     themeIntro:
       "Choose a theme.",
     setTheme: "Set theme",
@@ -163,6 +182,7 @@ const i18n = {
     setLanguage: "Set language",
     status: "Status",
     peerConnections: "Peers",
+    peerConnectionsIntro: "Manage all your connections with other peers.",
     online: "Online",
     supported: "Supported",
     recommended: "Recommended", 
@@ -176,7 +196,7 @@ const i18n = {
     startNetworking: "Start networking",
     stopNetworking: "Stop networking",
     restartNetworking: "Restart networking",
-    sync: "Sync",
+    sync: "Sync network",
     indexes: "Indexes",
     indexesDescription:
       "Rebuilding your indexes is safe, and may fix some types of bugs.",
@@ -205,6 +225,7 @@ const i18n = {
     // misc
     oasisDescription: "SNH Project Network",
     submit: "Submit",
+    subjectLabel: "Subject",
     editProfile: "Edit Avatar",
     editProfileDescription:
       "",
@@ -218,9 +239,13 @@ const i18n = {
     walletAddress: "Address",
     walletAmount: "Amount",
     walletAddressLine: ({ address }) => `Address: ${address}`,
-    walletAmountLine: ({ amount }) => `Amount: ECO ${amount}`,
+    walletAmountLine: ({ amount }) => `Amount: ${amount} ECO`,
     walletBack: "Back",
-    walletBalanceLine: ({ balance }) => `ECO ${balance}`,
+    walletBalanceTitle: "Balance",
+    walletWalletSendTitle: "Send",
+    walletReceiveTitle: "Receive",
+    walletHistoryTitle: "History",
+    walletBalanceLine: ({ balance }) => `${balance} ECO`,
     walletCnfrs: "Cnfrs",
     walletConfirm: "Confirm",
     walletDescription: "Manage your digital assets, including sending and receiving ECOin, viewing your balance, and accessing your transaction history.",
@@ -251,263 +276,603 @@ const i18n = {
     walletUser: "Username",
     walletPass: "Password",
     walletConfiguration: "Set wallet",
-  },
+    //cipher
+    cipher: "Crypter",
+    cipherTitle: "Cipher",
+    cipherDescription: "Encrypt and decrypt your text symmetrically (using a shared password).",
+    randomPassword: "Random Password",
+    cipherEncryptTitle: "Encrypt Text",
+    cipherEncryptDescription: "Set password (min 32 characters long) to encrypt your text",
+    cipherTextLabel: "Text to Encrypt",
+    cipherTextPlaceholder: "Enter text to encrypt...",
+    cipherPasswordLabel: "Password",
+    cipherPasswordPlaceholder: "Enter a password...",
+    cipherEncryptButton: "Encrypt",
+    cipherDecryptTitle: "Decrypt Text",
+    cipherDecryptDescription: "Enter the encrypted text and password to decrypt.",
+    cipherEncryptedTextLabel: "Encrypted Text",
+    cipherEncryptedTextPlaceholder: "Enter the encrypted text...",
+    cipherIvLabel: "IV",
+    cipherIvPlaceholder: "Enter the initialization vector...",
+    cipherDecryptButton: "Decrypt",
+    password: "Password",
+    text: "Text",
+    encryptedText: "Encrypted Text",
+    iv: "Initialization Vector (IV)",
+    encryptTitle: "Encrypt your text",
+    encryptDescription: "Enter the text you want to encrypt and provide a password.",
+    encryptButton: "Encrypt",
+    decryptTitle: "Decrypt your text",
+    decryptDescription: "Enter the encrypted text and provide the same password used for encryption.",
+    decryptButton: "Decrypt",
+    passwordLengthError: "Password must be at least 32 characters long.",
+    missingFieldsError: "Text, password or IV not provided.",
+    encryptionError: "Error encrypting text.",
+    decryptionError: "Error decrypting text."
+    },
   /* spell-checker: disable */
-  es: {
-    latest: "Novedades",
-    profile: "Avatar",
-    inhabitants: "Habitantes",
-    search: "Buscar",
-    imageSearch: "Buscar Imágenes",
-    settings: "Configuración",
-    continueReading: "continuar leyendo",
-    moreComments: "comentario",
-    readThread: "leer el resto del hilo",
+    es: {
     // navbar items
     extended: "Multiverso",
     extendedDescription: [
-      "Cuando apoyes a alguien, podrás descargar publicaciones de habitantes que apoye, y esas publicaciones aparecerán aquí, ordenadas por las más recientes.",
+    "Cuando apoyas a alguien, puedes descargar sus publicaciones, y esas publicaciones aparecen aquí, ordenadas por las más recientes.",
+    ],
+    popular: "Destacados",
+    popularDescription: [
+    "Publicaciones de habitantes en tu red, ",
+    strong("ordenadas por expansiones"),
+    ". Selecciona el período de tiempo para obtener una lista.",
     ],
-    popular: "Destacadas",
     day: "Día",
     week: "Semana",
     month: "Mes",
     year: "Año",
-    popularDescription: [
-      "Posts de habitantes de tu red, ",
-      strong("ordenados por difusiones"),
-      ". Selecciona el periodo de tiempo, para obtener una lista.",
-    ],
+    latest: "Últimos",
     latestDescription: [
-      strong("Posts"), 
-      " tuyos y de habitantes que apoyas, ordenados por los más recientes.",
+    strong("Publicaciones"),
+    " tuyas y de lass habitantes que apoyas, ordenadas por la más reciente.",
     ],
     topics: "Temas",
     topicsDescription: [
-      strong("Temas"),
-      " tuyas y de habitantes que apoyas, ordenadas por las más recientes. Selecciona la hora de publicación para leer el hilo completo.",
+    strong("Temas"),
+    " tuyos y de las habitantes que apoyas, ordenados por los más recientes. Selecciona la marca de tiempo de cualquier publicación para ver el resto del hilo.",
     ],
     summaries: "Resúmenes",
     summariesDescription: [
-      strong("Temas y algunos comentarios"),
-      " tuyos y de habitantes que apoyas, ordenado por lo más reciente. Selecciona la hora de publicación para leer el hilo completo.",
+    strong("Temas y algunos comentarios"),
+    " tuyos y de las habitantes que apoyas, ordenados por los más recientes. Selecciona la marca de tiempo de cualquier publicación para ver el resto del hilo.",
     ],
     threads: "Hilos",
     threadsDescription: [
-      strong("Posts que tienen comentarios"),
-      " de habitantes que apoyas y de tu multiverso, ordenados por los más recientes. Selecciona la hora de publicación para leer el hilo completo.",
+    strong("Publicaciones que tienen comentarios"),
+    " de las habitantes que apoyas y tu multiverso, ordenadas por las más recientes. Selecciona la marca de tiempo de cualquier publicación para ver el resto del hilo.",
     ],
-    manualMode: "Modo manual",
+    profile: "Avatar",
+    inhabitants: "Habitantes",
+    manualMode: "Modo Manual",
     mentions: "Menciones",
     mentionsDescription: [
-      strong("Posts que te @mencionan"),
-      ", ordenados por los más recientes.",
+    strong("Publicaciones que te @mencionan"),
+    ", ordenadas por las más recientes.",
     ],
     private: "Buzón",
-    peers: "Enlaces",
+    peers: "Nodos",
     privateDescription: [
-      "Los comentarios más recientes de ",
-      strong("hilos privados que te incluyen"),
-      ". Las publicaciones privadas están cifradas para ti, y contienen un máximo de 7 destinatarios. No se podrán añadir nuevos destinarios después de que empieze el hilo. Selecciona la hora de publicación para leer el hilo completo.",
+    "El último comentario de ",
+    strong("hilos privados que te incluyen"),
+    ", ordenados por los más recientes. Las publicaciones privadas están cifradas para tu clave pública, y tienen un máximo de 7 destinatarios. Los destinatarios no pueden ser añadidos después de que el hilo haya comenzado. Selecciona la marca de tiempo para ver el hilo completo.",
     ],
-    // post actions
-    comment: "Comentar",
-    reply: "Responder",
-    subtopic: "Subhilo",
-    json: "JSON", 
+    search: "Buscar",
+    imageSearch: "Búsqueda de imágenes",
+    settings: "Configuración",
+    continueReading: "seguir leyendo",
+    moreComments: "comentarios má",
+    readThread: "leer el resto del hilo",
     // modules
     modules: "Módulos",
     modulesViewTitle: "Módulos",
-    modulesViewDescription: "Configura tu entorno activando y desactivando módulos.",
+    modulesViewDescription: "Configura tu entorno habilitando o deshabilitando módulos.",
     inbox: "Buzón",
     multiverse: "Multiverso",
-    popularLabel: "⌘ Destacadas",
+    popularLabel: "⌘ Destacados",
     topicsLabel: "ϟ Temas",
+    latestLabel: "☄ Últimos",
     summariesLabel: "※ Resúmenes",
     threadsLabel: "♺ Hilos",
     multiverseLabel: "∞ Multiverso",
-    latestLabel: "☄ Novedades",
     inboxLabel: "☂ Buzón",
     invitesLabel: "ꔹ Invitaciones",
-    walletLabel: "❄ Cartera",
-    saveSettings: "Salvar configuración",
+    walletLabel: "❄ Billetera",
+    legacyLabel: "ꖸ Herencia",
+    cipherLabel: "ꗄ Cripta",
+    saveSettings: "Guardar configuración",
+    // post actions
+    comment: "Comentar",
+    subtopic: "Subtema",
+    json: "JSON",
     // relationships
-    relationshipNotFollowing: "",
-    relationshipTheyFollow: "",
-    relationshipMutuals: "",
-    relationshipFollowing: "Apoyando",
-    relationshipYou: "Tú",
-    relationshipBlocking: "Bloqueado",
-    relationshipNone: "",
-    relationshipConflict: "",
-    relationshipBlockingPost: "Post bloqueado",
     unfollow: "Dejar de apoyar",
     follow: "Apoyar",
     block: "Bloquear",
     unblock: "Desbloquear",
-    newerPosts: "Nuevos posts",
-    olderPosts: "Anteriores posts",
-    feedRangeEmpty: "El rango requerido está vacío para éste hilo. Prueba a ver el ",
-    seeFullFeed: "hilo completo",
-    feedEmpty: "No tienes posts de ésta cuenta.",
-    beginningOfFeed: "Éste es el comienzo del hilo",
-    noNewerPosts: "No se han recibido nuevos posts aún.",
+    newerPosts: "Publicaciones más recientes",
+    olderPosts: "Publicaciones más antiguas",
+    feedRangeEmpty: "El rango dado está vacío para este feed. Intenta ver el ",
+    seeFullFeed: "feed completo",
+    feedEmpty: "El cliente local de Oasis no ha accedido a publicaciones aún.",
+    beginningOfFeed: "Este es el comienzo del feed",
+    noNewerPosts: "No se han recibido publicaciones más recientes.",
+    relationshipNotFollowing: "No te da apoyo",
+    relationshipTheyFollow: "Ellos apoyan",
+    relationshipMutuals: "Apoyo mutuo",
+    relationshipFollowing: "Das apoyo",
+    relationshipYou: "Tú",
+    relationshipBlocking: "Estás bloqueando",
+    relationshipNone: "No estás apoyando",
+    relationshipConflict: "En conflicto",
+    relationshipBlockingPost: "Publicación bloqueada",
     // spreads view
-    viewLikes: "Ver difusiones",
-    spreadedDescription: "Listado de posts difundidos del habitante.",
-    likedBy: " -> Difusiones",
+    viewLikes: "Ver Apoyos",
+    spreadedDescription: "Lista de publicaciones apoyadas por la habitante.",
+    likedBy: " -> Apoyos",
     // composer
-    attachFiles: "Agregar archivos",
+    attachFiles: "Adjuntar archivos",
     mentionsMatching: "Menciones coincidentes",
     preview: "Vista previa",
     publish: "Escribir",
-    contentWarningPlaceholder: "Añade un asunto al post (opcional)",
-    privateWarningPlaceholder: "Añade habitantes para enviar un post privado (ej: @bob @alice) (opcional)",
+    contentWarningPlaceholder: "Agrega un tema a la publicación (opcional)",
+    privateWarningPlaceholder: "Agrega habitantes para enviar una publicación privada (ej: @bob @alice) (opcional)",
     publishWarningPlaceholder: "...",
     publishCustomDescription: [
-      "RECUERDA: Debido a la tecnología blockchain, una vez publicado un post, no podrá ser editado o borrado.",
+    "RECUERDA: Debido a la tecnología blockchain, una vez que se publica no se puede editar ni eliminar.",
     ],
     commentWarning: [
-      " RECUERDA: Debido a la tecnología blockchain, una vez publicado un post, no podrá ser editado o borrado.",
+    "RECUERDA: Debido a la tecnología blockchain, una vez que se publica no se puede editar ni eliminar.",
     ],
     commentPublic: "público",
     commentPrivate: "privado",
     commentLabel: ({ publicOrPrivate, markdownUrl }) => [
     ],
     publishLabel: ({ markdownUrl, linkTarget }) => [
-      "RECUERDA: Debido a la tecnología blockchain, una vez publicado un post, no podrá ser editado o borrado.",
+    "RECUERDA: Debido a la tecnología blockchain, una vez que se publica no se puede editar ni eliminar.",
+    ],
+    replyLabel: ({ markdownUrl }) => [
+    "RECUERDA: Debido a la tecnología blockchain, una vez que se publica no se puede editar ni eliminar.",
     ],
     publishCustomInfo: ({ href }) => [
-      "Si tienes experiencia, también puedes ",
-      a({ href }, "escribir un post avanzado"),
-      ".",
+    "Si tienes experiencia, también puedes ",
+    a({ href }, "escribir una publicación avanzada"),
+    ".",
     ],
     publishBasicInfo: ({ href }) => [
-      "Si no tienes experiencia, lo mejor es ",
-      a({ href }, "escribir un post normal"),
+      "Si no tienes experiencia, deberías ",
+      a({ href }, "escribir una publicación"),
       ".",
     ],
-    publishCustom: "Escribir post avanzado",
-    replyLabel: ({ markdownUrl }) => [
-      "RECUERDA: Debido a la tecnología blockchain, una vez publicados los posts, no podrán ser editados o borrados.",
+    publishCustom: "Escribir publicación avanzada",
+    subtopicLabel: ({ markdownUrl }) => [
+      "Crear un ",
+      strong("subtema público"),
+      " de esta publicación con ",
+      a({ href: markdownUrl }, "Markdown"),
+      ". Las publicaciones no pueden ser editadas ni eliminadas. Para responder a un hilo completo, selecciona ",
+      strong("comentar"),
+    " en su lugar. La vista previa muestra los medios adjuntos.",
     ],
-    // settings-es
+    // settings
     updateit: "Obtener actualizaciones",
-    info: "Info",
+    info: "Información",
     settingsIntro: ({ version }) => [
       `Oasis: [${version}]`,
     ],
+    timeAgo: "",
+    sendTime: "aproximadamente hace ",
     theme: "Tema",
-    themeIntro:
-      "Elige un tema.",
-    setTheme: "Seleccionar tema",
+    legacy: "Herencia",
+    legacyTitle: "Herencia",
+    legacyDescription: "Gestiona tu secreto (clave privada) de forma rápida y segura.",
+    legacyExportButton: "Exportar",
+    legacyImportButton: "Importar",
+    exportTitle: "Exportar datos",
+    exportDescription: "Establece una contraseña (mínimo 32 caracteres) para cifrar tu clave",
+    importTitle: "Importar datos",
+    importDescription: "Importa tu secreto cifrado (clave privada) para habilitar tu avatar",
+    importAttach: "Adjuntar archivo cifrado (.enc)",
+    passwordLengthInfo: "La contraseña debe tener al menos 32 caracteres.",
+    passwordImport: "Escribe tu contraseña para descifrar los datos que se guardarán en el directorio de tu sistema (nombre: secreto)",
+    exportPasswordPlaceholder: "Usa minúsculas, mayúsculas, números y símbolos",
+    fileInfo: "Tu clave secreta cifrada se guardará en el directorio de tu sistema (nombre: oasis.enc)",
+    themeIntro: "Elige un tema.",
+    setTheme: "Establecer tema",
     language: "Idioma",
-    languageDescription:
-      "Si quieres usar otro idioma, seleccionalo aquí.",
-    setLanguage: "Seleccionar idioma",
+    languageDescription: "Si deseas usar otro idioma, selecciona aquí.",
+    setLanguage: "Establecer idioma",
     status: "Estado",
-    peerConnections: "Enlaces",
-    online: "Online",
-    supported: "Soportados",
-    recommended: "Recomendados",
-    blocked: "Bloqueados",
-    noConnections: "Sin enlaces conectados.",
-    noSupportedConnections: "Sin enlaces soportados.",
-    noBlockedConnections: "Sin enlaces bloqueados.",
-    noRecommendedConnections: "Sin enlaces recomendados.",
-    connectionActionIntro:
-      "",
-    startNetworking: "Iniciar red",
-    stopNetworking: "Detener red",
-    restartNetworking: "Reiniciar red",
-    sync: "Sincronizar",
+    peerConnections: "Nodos",
+    peerConnectionsIntro: "Maneja todas tus conexiones que otros nodos.",
+    online: "En línea",
+    supported: "Apoyado",
+    recommended: "Recomendado",
+    blocked: "Bloqueado",
+    noConnections: "No hay habitantes conectados.",
+    noSupportedConnections: "No hay habitantes apoyados.",
+    noBlockedConnections: "No hay habitantes bloqueados.",
+    noRecommendedConnections: "No hay habitantes recomendados.",
+    connectionActionIntro: "",
+    startNetworking: "Comenzar a conectar",
+    stopNetworking: "Dejar de conectar",
+    restartNetworking: "Reiniciar la conexión",
+    sync: "Sincronizar red",
     indexes: "Índices",
-    indexesDescription:
-      "Reconstruir la caché de forma segura, puede solucionar algunos errores si se presentan.",
+    indexesDescription: "Reconstruir tus índices es seguro y puede solucionar algunos tipos de errores.",
     invites: "Invitaciones",
-    invitesDescription:
-      "Utiliza los códigos de invitación de los PUBs aquí.",
+    invitesDescription: "Usa los códigos de invitación del PUB aquí.",
     acceptInvite: "Aceptar invitación",
-    acceptedInvites: "Redes Federadas",
-    noInvites: "Sin invitaciones aceptadas.",
+    acceptedInvites: "Redes federadas",
+    noInvites: "No se han aceptado invitaciones",
     // search page
-    searchLabel:
-      "Busca habitantes y palabras clave, entre los posts que tienes descargados.",
-    // posts and comments
-    commentDescription: ({ parentUrl }) => [
-      " comentó en el",
-      a({ href: parentUrl }, " hilo"),
-    ],
-    replyDescription: ({ parentUrl }) => [
-      " respondido al ",
-      a({ href: parentUrl }, "post "),
-    ],
+    searchLabel: "Buscar habitantes y palabras clave entre las publicaciones que has descargado.",
     // image search page
-    imageSearchLabel:
-      "Busca entre los títulos de las imágenes que tienes descargadas.",
+    imageSearchLabel: "Introduce palabras para buscar imágenes etiquetadas con ellas.",
     // posts and comments
-    commentTitle: ({ authorName }) => [
-      `Comentó en el post de @${authorName}`,
+    commentDescription: ({ parentUrl }) => [
+    " comentó en ",
+    a({ href: parentUrl }, " el hilo"),
     ],
+    commentTitle: ({ authorName }) => [`Comentario en la publicación de @${authorName}`],
     subtopicDescription: ({ parentUrl }) => [
-      " creó un nuevo hilo para ",
-      a({ href: parentUrl }, "este post"),
-    ],
-    subtopicTitle: ({ authorName }) => [
-      `Nuevo hilo en el post de @${authorName}`,
+    " creó un subtema de ",
+    a({ href: parentUrl }, " una publicación"),
     ],
-    mysteryDescription: "publicó un post misterioso",
+    subtopicTitle: ({ authorName }) => [`Subtema en la publicación de @${authorName}`],
+    mysteryDescription: "envió una publicación misteriosa",
     // misc
-    oasisDescription:
-      "Red de Proyectos de SNH",
-    submit: "Aceptar",
-    editProfile: "Editar avatar",
-    editProfileDescription:
-      "",
-    profileName: "Nombre del avatar (texto)",
+    oasisDescription: "Red del Proyecto SNH",
+    submit: "Enviar",
+    subjectLabel: "Asunto",
+    editProfile: "Editar Avatar",
+    editProfileDescription: "",
+    profileName: "Nombre del avatar (texto plano)",
     profileImage: "Imagen del avatar",
     profileDescription: "Descripción del avatar (Markdown)",
-    hashtagDescription:
-      "Posts de habitantes en tu red que referencian a ésta #etiqueta, ordenados por los más recientes.",
+    hashtagDescription: "Publicaciones de habitantes en tu red que mencionan este #hashtag, ordenadas por las más recientes.",
     rebuildName: "Reconstruir base de datos",
-    wallet: "Cartera",
+    wallet: "Billetera",
     walletAddress: "Dirección",
     walletAmount: "Cantidad",
     walletAddressLine: ({ address }) => `Dirección: ${address}`,
     walletAmountLine: ({ amount }) => `Cantidad: ${amount} ECO`,
-    walletBack: "Atrás",
+    walletBack: "Volver",
+    walletBalanceTitle: "Balance",
+    walletReceiveTitle: "Recibir",
+    walletHistoryTitle: "Historial",
+    walletWalletSendTitle: "Enviar",
     walletBalanceLine: ({ balance }) => `${balance} ECO`,
-    walletConfirm: "Confirmar",
     walletCnfrs: "Cnfrs",
-    walletDescription: "Administra tus activos digitales, incluyendo el envío y recepción de ECOin, consulta de saldo e historial de transacciones.",
+    walletConfirm: "Confirmar",
+    walletDescription: "Gestiona tus activos digitales, incluyendo el envío y recepción de ECOin, ver tu saldo e historial de transacciones.",
     walletDate: "Fecha",
-    walletFee: "Tarifa (A mayor tarifa, más rápido se procesa tu transacción)",
-    walletFeeLine: ({ fee }) => `Tarifa: ${fee} ECO`,
+    walletFee: "Comisión (Cuanto mayor es la comisión, más rápido se procesará tu transacción)",
+    walletFeeLine: ({ fee }) => `Comisión: ECO ${fee}`,
     walletHistory: "Historial",
     walletReceive: "Recibir",
     walletReset: "Restablecer",
     walletSend: "Enviar",
     walletStatus: "Estado",
-    walletDisconnected: "Cartera ECOin desconectada. Revisa la configuración.",
-    walletSentToLine: ({ destination, amount }) => `Enviados ${amount} ECO a ${destination}.`,
-    walletSettingsTitle: "Cartera",
-    walletSettingsDescription: "Integra Oasis con tu cartera ECOin.",
+    walletDisconnected: "Billetera ECOin desconectada. Verifica la configuración de tu billetera o el estado de la conexión.",
+    walletSentToLine: ({ destination, amount }) => `Enviado ECO ${amount} a ${destination}`,
+    walletSettingsTitle: "Billetera",
+    walletSettingsDescription: "Integra Oasis con tu billetera ECOin.",
     walletStatusMessages: {
-      invalid_amount: "Cantidad inválida",
-      invalid_dest: "Dirección de destino inválida",
-      invalid_fee: "Tarifa inválida",
-      validation_errors: "Errores de validación",
-      send_tx_success: "Transacción exitosa",
+    invalid_amount: "Cantidad inválida",
+    invalid_dest: "Dirección de destino inválida",
+    invalid_fee: "Comisión inválida",
+    validation_errors: "Errores de validación",
+    send_tx_success: "Transacción exitosa",
     },
-    walletTitle: "Cartera",
-    walletTotalCostLine: ({ totalCost }) => `Coste total: ${totalCost} ECO`,
+    walletTitle: "Billetera",
+    walletTotalCostLine: ({ totalCost }) => `Costo total: ECO ${totalCost}`,
     walletTransactionId: "ID de transacción",
-    walletTxId: "Tx ID",
+    walletTxId: "ID Tx",
     walletType: "Tipo",
     walletUser: "Nombre de usuario",
     walletPass: "Contraseña",
-    walletConfiguration: "Configurar cartera",
+    walletConfiguration: "Configurar billetera",
+    //cipher
+    cipher: "Cripta",
+    randomPassword: "Contraseña aleatoria",
+    password: "Contraseña",
+    text: "Texto",
+    encryptedText: "Texto cifrado",
+    iv: "Vector de Inicialización (IV)",
+    encryptTitle: "Cifra tu texto",
+    encryptDescription: "Introduce el texto que deseas cifrar y proporciona una contraseña.",
+    encryptButton: "Cifrar",
+    decryptTitle: "Descifra tu texto",
+    decryptDescription: "Introduce el texto cifrado y proporciona la misma contraseña utilizada para cifrar.",
+    decryptButton: "Descifrar",
+    passwordLengthError: "La contraseña debe tener al menos 32 caracteres.",
+    missingFieldsError: "Texto, contraseña o IV no proporcionados.",
+    encryptionError: "Error cifrando el texto.",
+    decryptionError: "Error descifrando el texto.",
+    //cipher
+    cipherTitle: "Cifrado",
+    cipherDescription: "Cifra y descifra contenido simétricamente (usando una contraseña compartida).",
+    randomPassword: "Contraseña Aleatoria",
+    cipherEncryptTitle: "Encriptar Texto",
+    cipherEncryptDescription: "Establece una contraseña (mínimo 32 caracteres) para cifrar tu texto",
+    cipherTextLabel: "Texto a Encriptar",
+    cipherTextPlaceholder: "Introduce el texto para encriptar...",
+    cipherPasswordLabel: "Contraseña",
+    cipherPasswordPlaceholder: "Introduce una contraseña...",
+    cipherEncryptButton: "Encriptar",
+    cipherDecryptTitle: "Desencriptar Texto",
+    cipherDecryptDescription: "Introduce el texto encriptado, la contraseña y el IV para desencriptar.",
+    cipherEncryptedTextLabel: "Texto Encriptado",
+    cipherEncryptedTextPlaceholder: "Introduce el texto encriptado...",
+    cipherIvLabel: "IV",
+    cipherIvPlaceholder: "Introduce el vector de inicialización...",
+    cipherDecryptButton: "Desencriptar"
   },
+  /* spell-checker: disable */
+fr: {
+    // navbar items
+    extended: "Multivers",
+    extendedDescription: [
+    "Lorsque vous soutenez quelqu'un, vous pouvez télécharger ses publications, et ces publications apparaissent ici, triées par les plus récentes.",
+    ],
+    popular: "En vedette",
+    popularDescription: [
+    "Publications des habitants de votre réseau, ",
+    strong("classées par expansions"),
+    ". Sélectionnez la période pour obtenir une liste.",
+    ],
+    day: "Jour",
+    week: "Semaine",
+    month: "Mois",
+    year: "Année",
+    latest: "Derniers",
+    latestDescription: [
+    strong("Publications"),
+    " de vous et des habitants que vous soutenez, triées par les plus récentes.",
+    ],
+    topics: "Sujets",
+    topicsDescription: [
+    strong("Sujets"),
+    " de vous et des habitants que vous soutenez, triés par les plus récents. Sélectionnez l'horodatage d'une publication pour voir le reste du fil.",
+    ],
+    summaries: "Résumés",
+    summariesDescription: [
+    strong("Sujets et quelques commentaires"),
+    " de vous et des habitants que vous soutenez, triés par les plus récents. Sélectionnez l'horodatage d'une publication pour voir le reste du fil.",
+    ],
+    threads: "Fils",
+    threadsDescription: [
+    strong("Publications avec commentaires"),
+    " des habitants que vous soutenez et de votre multivers, triées par les plus récentes. Sélectionnez l'horodatage d'une publication pour voir le reste du fil.",
+    ],
+    profile: "Avatar",
+    inhabitants: "Habitants",
+    manualMode: "Mode Manuel",
+    mentions: "Mentions",
+    mentionsDescription: [
+    strong("Publications qui vous @mentionnent"),
+    ", triées par les plus récentes.",
+    ],
+    private: "Boîte de réception",
+    peers: "Nœuds",
+    privateDescription: [
+    "Le dernier commentaire de ",
+    strong("fils privés qui vous incluent"),
+    ", triés par les plus récents. Les publications privées sont chiffrées pour votre clé publique et ont un maximum de 7 destinataires. Les destinataires ne peuvent pas être ajoutés après le début du fil. Sélectionnez l'horodatage pour voir le fil complet.",
+    ],
+    search: "Rechercher",
+    imageSearch: "Recherche d'images",
+    settings: "Paramètres",
+    continueReading: "continuer la lecture",
+    moreComments: "plus de commentaires",
+    readThread: "lire le reste du fil",
+    // modules
+    modules: "Modules",
+    modulesViewTitle: "Modules",
+    modulesViewDescription: "Configurez votre environnement en activant ou désactivant des modules.",
+    inbox: "Boîte de réception",
+    multiverse: "Multivers",
+    popularLabel: "⌘ En vedette",
+    topicsLabel: "ϟ Sujets",
+    latestLabel: "☄ Derniers",
+    summariesLabel: "※ Résumés",
+    threadsLabel: "♺ Fils",
+    multiverseLabel: "∞ Multivers",
+    inboxLabel: "☂ Boîte de réception",
+    invitesLabel: "ꔹ Invitations",
+    walletLabel: "❄ Portefeuille",
+    legacyLabel: "ꖸ Héritage",
+    cipherLabel: "ꗄ Crypte",
+    saveSettings: "Enregistrer les paramètres",
+    // post actions
+    comment: "Commenter",
+    subtopic: "Sous-sujet",
+    json: "JSON",
+    // relationships
+    unfollow: "Ne plus soutenir",
+    follow: "Soutenir",
+    block: "Bloquer",
+    unblock: "Débloquer",
+    newerPosts: "Publications plus récentes",
+    olderPosts: "Publications plus anciennes",
+    feedRangeEmpty: "La plage donnée est vide pour ce flux. Essayez de voir le ",
+    seeFullFeed: "flux complet",
+    feedEmpty: "Le client local d'Oasis n'a pas encore accédé aux publications.",
+    beginningOfFeed: "Ceci est le début du flux",
+    noNewerPosts: "Aucune publication plus récente reçue.",
+    relationshipNotFollowing: "Ne vous soutient pas",
+    relationshipTheyFollow: "Ils soutiennent",
+    relationshipMutuals: "Soutien mutuel",
+    relationshipFollowing: "Vous soutenez",
+    relationshipYou: "Vous",
+    relationshipBlocking: "Vous bloquez",
+    relationshipNone: "Vous ne soutenez pas",
+    relationshipConflict: "En conflit",
+    relationshipBlockingPost: "Publication bloquée",
+    // spreads view
+    viewLikes: "Voir les soutiens",
+    spreadedDescription: "Liste des publications soutenues par l'habitant.",
+    likedBy: " -> Soutiens",
+    // composer
+    attachFiles: "Joindre des fichiers",
+    mentionsMatching: "Mentions correspondantes",
+    preview: "Aperçu",
+    publish: "Publier",
+    contentWarningPlaceholder: "Ajoutez un sujet à la publication (optionnel)",
+    privateWarningPlaceholder: "Ajoutez des habitants pour envoyer une publication privée (ex: @bob @alice) (optionnel)",
+    publishWarningPlaceholder: "...",
+    publishCustomDescription: [
+    "RAPPEL : En raison de la technologie blockchain, une fois publié, il ne peut être ni modifié ni supprimé.",
+    ],
+    commentWarning: [
+    "RAPPEL : En raison de la technologie blockchain, une fois publié, il ne peut être ni modifié ni supprimé.",
+    ],
+    commentPublic: "public",
+    commentPrivate: "privé",
+    // settings
+    updateit: "Obtenir les mises à jour",
+    info: "Informations",
+    settingsIntro: ({ version }) => [
+      `Oasis: [${version}]`,
+    ],
+    timeAgo: "",
+    sendTime: "environ ",
+    theme: "Thème",
+    legacy: "Héritage",
+    legacyTitle: "Héritage",
+    legacyDescription: "Gérez votre secret (clé privée) rapidement et en toute sécurité.",
+    legacyExportButton: "Exporter",
+    legacyImportButton: "Importer",
+    exportTitle: "Exporter les données",
+    exportDescription: "Définissez un mot de passe (minimum 32 caractères) pour chiffrer votre clé",
+    importTitle: "Importer des données",
+    importDescription: "Importez votre secret chiffré (clé privée) pour activer votre avatar",
+    importAttach: "Joindre un fichier chiffré (.enc)",
+    passwordLengthInfo: "Le mot de passe doit contenir au moins 32 caractères.",
+    passwordImport: "Saisissez votre mot de passe pour déchiffrer les données qui seront enregistrées dans le répertoire de votre système (nom : secret)",
+    exportPasswordPlaceholder: "Utilisez des minuscules, majuscules, chiffres et symboles",
+    fileInfo: "Votre clé secrète chiffrée sera enregistrée dans le répertoire de votre système (nom : oasis.enc)",
+    themeIntro: "Choisissez un thème.",
+    setTheme: "Définir le thème",
+    language: "Langue",
+    languageDescription: "Si vous souhaitez utiliser une autre langue, sélectionnez-la ici.",
+    setLanguage: "Définir la langue",
+    status: "Statut",
+    peerConnections: "Nœuds",
+    peerConnectionsIntro: "Gérez toutes vos connexions avec d'autres nœuds.",
+    online: "En ligne",
+    supported: "Soutenu",
+    recommended: "Recommandé",
+    blocked: "Bloqué",
+    noConnections: "Aucun habitant connecté.",
+    noSupportedConnections: "Aucun habitant soutenu.",
+    noBlockedConnections: "Aucun habitant bloqué.",
+    noRecommendedConnections: "Aucun habitant recommandé.",
+    startNetworking: "Commencer à se connecter",
+    stopNetworking: "Arrêter de se connecter",
+    restartNetworking: "Redémarrer la connexion",
+    sync: "Synchroniser le réseau",
+    indexes: "Index",
+    indexesDescription: "Reconstruire vos index est sûr et peut résoudre certains types d'erreurs.",
+    invites: "Invitations",
+    invitesDescription: "Utilisez les codes d'invitation du PUB ici.",
+    acceptInvite: "Accepter l'invitation",
+    acceptedInvites: "Réseaux fédérés",
+    noInvites: "Aucune invitation acceptée",
+    // misc
+    oasisDescription: "Réseau du Projet SNH",
+    submit: "Envoyer",
+    subjectLabel: "Sujet",
+    editProfile: "Modifier l'avatar",
+    editProfileDescription: "",
+    profileName: "Nom de l'avatar (texte brut)",
+    profileImage: "Image de l'avatar",
+    profileDescription: "Description de l'avatar (Markdown)",
+    hashtagDescription: "Publications des habitants de votre réseau mentionnant ce #hashtag, triées par les plus récentes.",
+    rebuildName: "Reconstruire la base de données",
+    wallet: "Portefeuille",
+    walletAddress: "Adresse",
+    walletAmount: "Montant",
+    walletAddressLine: ({ address }) => `Adresse : ${address}`,
+    walletAmountLine: ({ amount }) => `Montant : ${amount} ECO`,
+    walletBack: "Retour",
+    walletBalanceTitle: "Solde",
+    walletReceiveTitle: "Recevoir",
+    walletHistoryTitle: "Historique",
+    walletWalletSendTitle: "Envoyer",
+    walletBalanceLine: ({ balance }) => `${balance} ECO`,
+    walletCnfrs: "Cnfrs",
+    walletConfirm: "Confirmer",
+    walletDescription: "Gérez vos actifs numériques, y compris l'envoi et la réception d'ECOin, consultez votre solde et votre historique de transactions.",
+    walletDate: "Date",
+    walletFee: "Frais (Plus les frais sont élevés, plus votre transaction sera traitée rapidement)",
+    walletFeeLine: ({ fee }) => `Frais : ECO ${fee}`,
+    walletHistory: "Historique",
+    walletReceive: "Recevoir",
+    walletReset: "Réinitialiser",
+    walletSend: "Envoyer",
+    walletStatus: "Statut",
+    walletDisconnected: "Portefeuille ECOin déconnecté. Vérifiez la configuration de votre portefeuille ou l'état de la connexion.",
+    walletSentToLine: ({ destination, amount }) => `Envoyé ECO ${amount} à ${destination}`,
+    walletSettingsTitle: "Portefeuille",
+    walletSettingsDescription: "Intégrez Oasis à votre portefeuille ECOin.",
+    walletStatusMessages: {
+        invalid_amount: "Montant invalide",
+        invalid_dest: "Adresse de destination invalide",
+        invalid_fee: "Frais invalides",
+        validation_errors: "Erreurs de validation",
+        send_tx_success: "Transaction réussie",
+    },
+    walletTitle: "Portefeuille",
+    walletTotalCostLine: ({ totalCost }) => `Coût total : ECO ${totalCost}`,
+    walletTransactionId: "ID de transaction",
+    walletTxId: "ID Tx",
+    walletType: "Type",
+    walletUser: "Nom d'utilisateur",
+    walletPass: "Mot de passe",
+    walletConfiguration: "Configurer le portefeuille",
+    //cipher
+    cipher: "Chiffrement",
+    randomPassword: "Mot de passe aléatoire",
+    password: "Mot de passe",
+    text: "Texte",
+    encryptedText: "Texte chiffré",
+    iv: "Vecteur d'initialisation (IV)",
+    encryptTitle: "Chiffrez votre texte",
+    encryptDescription: "Entrez le texte que vous souhaitez chiffrer et fournissez un mot de passe.",
+    encryptButton: "Chiffrer",
+    decryptTitle: "Déchiffrez votre texte",
+    decryptDescription: "Entrez le texte chiffré et fournissez le même mot de passe utilisé pour le chiffrer.",
+    decryptButton: "Déchiffrer",
+    passwordLengthError: "Le mot de passe doit contenir au moins 32 caractères.",
+    missingFieldsError: "Texte, mot de passe ou IV non fournis.",
+    encryptionError: "Erreur lors du chiffrement du texte.",
+    decryptionError: "Erreur lors du déchiffrement du texte.",
+    //cipher
+    cipherTitle: "Chiffrement",
+    cipherDescription: "Chiffrez et déchiffrez du contenu de manière symétrique (en utilisant un mot de passe partagé).",
+    randomPassword: "Mot de passe aléatoire",
+    cipherEncryptTitle: "Chiffrer un texte",
+    cipherEncryptDescription: "Définissez un mot de passe (minimum 32 caractères) pour chiffrer votre texte",
+    cipherTextLabel: "Texte à chiffrer",
+    cipherTextPlaceholder: "Entrez le texte à chiffrer...",
+    cipherPasswordLabel: "Mot de passe",
+    cipherPasswordPlaceholder: "Entrez un mot de passe...",
+    cipherEncryptButton: "Chiffrer",
+    cipherDecryptTitle: "Déchiffrer un texte",
+    cipherDecryptDescription: "Entrez le texte chiffré, le mot de passe et l'IV pour le déchiffrer.",
+    cipherEncryptedTextLabel: "Texte chiffré",
+    cipherEncryptedTextPlaceholder: "Entrez le texte chiffré...",
+    cipherIvLabel: "IV",
+    cipherIvPlaceholder: "Entrez le vecteur d'initialisation...",
+    cipherDecryptButton: "Déchiffrer"
+    }
 };
 
 module.exports = i18n;

+ 380 - 221
src/views/index.js

@@ -25,6 +25,12 @@ async function checkForUpdate() {
 }
 checkForUpdate();
 
+const crypto = require('crypto');
+
+function generateRandomPassword(length = 32) {
+  return crypto.randomBytes(length).toString('hex').slice(0, length); 
+}
+
 const {
   a,
   article,
@@ -97,8 +103,8 @@ const toAttributes = (obj) =>
 // non-breaking space
 const nbsp = "\xa0";
 
-const { saveConfig, getConfig } = require('../modules-config');
-const configMods = getConfig();
+const { saveConfig, getConfig } = require('../config');
+const configMods = getConfig().modules;
 const navLink = ({ href, emoji, text, current }) =>
   li(
     a(
@@ -124,59 +130,80 @@ const customCSS = (filename) => {
 };
 
 const renderPopularLink = () => {
-  const popularMod = getConfig().popularMod === 'on';
+  const popularMod = getConfig().modules.popularMod === 'on';
   return popularMod 
     ? navLink({ href: "/public/popular/day", emoji: "⌘", text: i18n.popular, class: "popular-link enabled" }) 
     : ''; 
 };
 const renderTopicsLink = () => {
-  const topicsMod = getConfig().topicsMod === 'on';
+  const topicsMod = getConfig().modules.topicsMod === 'on';
   return topicsMod 
     ? navLink({ href: "/public/latest/topics", emoji: "ϟ", text: i18n.topics, class: "topics-link enabled" }) 
     : ''; 
 };
 const renderSummariesLink = () => {
-  const summariesMod = getConfig().summariesMod === 'on';
+  const summariesMod = getConfig().modules.summariesMod === 'on';
   return summariesMod 
     ? navLink({ href: "/public/latest/summaries", emoji: "※", text: i18n.summaries, class: "summaries-link enabled" }) 
     : ''; 
 };
 const renderLatestLink = () => {
-  const latestMod = getConfig().latestMod === 'on';
+  const latestMod = getConfig().modules.latestMod === 'on';
   return latestMod 
     ? navLink({ href: "/public/latest", emoji: "☄", text: i18n.latest, class: "latest-link enabled" }) 
     : ''; 
 };
 const renderThreadsLink = () => {
-  const threadsMod = getConfig().threadsMod === 'on';
+  const threadsMod = getConfig().modules.threadsMod === 'on';
   return threadsMod 
     ? navLink({ href: "/public/latest/threads", emoji: "♺", text: i18n.threads, class: "threads-link enabled" }) 
     : ''; 
 };
-const renderMultiverseLink = () => {
-  const multiverseMod = getConfig().multiverseMod === 'on';
-  return multiverseMod 
-    ? navLink({ href: "/public/latest/extended", emoji: "∞", text: i18n.multiverse, class: "multiverse-link enabled" }) 
-    : ''; 
-};
+
 const renderInboxLink = () => {
-  const inboxMod = getConfig().inboxMod === 'on';
+  const inboxMod = getConfig().modules.inboxMod === 'on';
   return inboxMod 
     ? navLink({ href: "/inbox", emoji: "☂", text: i18n.inbox, class: "inbox-link enabled" }) 
     : ''; 
 };
 const renderInvitesLink = () => {
-  const invitesMod = getConfig().invitesMod === 'on';
+  const invitesMod = getConfig().modules.invitesMod === 'on';
   return invitesMod 
     ? navLink({ href: "/invites", emoji: "ꔹ", text: i18n.invites, class: "invites-link enabled" }) 
     : ''; 
 };
+const renderMultiverseLink = () => {
+  const multiverseMod = getConfig().modules.multiverseMod === 'on';
+  return multiverseMod 
+    ? [
+        hr(),
+        navLink({ href: "/public/latest/extended", emoji: "∞", text: i18n.multiverse, class: "multiverse-link enabled" }) 
+      ]
+    : '';
+};
 const renderWalletLink = () => {
-  const walletMod = getConfig().walletMod === 'on';
+  const walletMod = getConfig().modules.walletMod === 'on';
   if (walletMod) {
     return [
       navLink({ href: "/wallet", emoji: "❄", text: i18n.wallet, class: "wallet-link enabled" }),
-      hr()
+    ];
+  }
+  return ''; 
+};
+const renderLegacyLink = () => {
+  const legacyMod = getConfig().modules.legacyMod === 'on';
+  if (legacyMod) {
+    return [
+      navLink({ href: "/legacy", emoji: "ꖸ", text: i18n.legacy, class: "legacy-link enabled" }),
+    ];
+  }
+  return ''; 
+};
+const renderCipherLink = () => {
+  const cipherMod = getConfig().modules.cipherMod === 'on';
+  if (cipherMod) {
+    return [
+      navLink({ href: "/cipher", emoji: "ꗄ", text: i18n.cipher, class: "cipher-link enabled" }),
     ];
   }
   return ''; 
@@ -187,54 +214,70 @@ const template = (titlePrefix, ...elements) => {
     { lang: "en" },
     head(
       title(titlePrefix, " | Oasis"),
-      link({ rel: "stylesheet", href: "/theme.css" }),
       link({ rel: "stylesheet", href: "/assets/style.css" }),
-      link({ rel: "stylesheet", href: "/assets/highlight.css" }),
-      customCSS("/custom-style.css"),
-      link({ rel: "icon", type: "image/svg+xml", href: "/assets/favicon.svg" }),
+      link({ rel: "icon", href: "/assets/favicon.svg" }),
       meta({ charset: "utf-8" }),
       meta({ name: "description", content: i18n.oasisDescription }),
       meta({ name: "viewport", content: toAttributes({ width: "device-width", "initial-scale": 1 }) })
     ),
     body(
-      nav(
-        ul(
-          navLink({ href: "/mentions", emoji: "✺", text: i18n.mentions }),
-          renderPopularLink(),
-          hr,
-          renderTopicsLink(),
-          renderSummariesLink(),
-          renderLatestLink(),
-          renderThreadsLink(),
-          hr,
-          renderMultiverseLink()
+      div(
+        { class: "header" },      
+          a(
+            {
+             class: "logo-icon",
+             href: "https://solarnethub.com",
+            },
+             img({ class: "logo-icon", src: "/assets/snh-oasis.jpg" })
+           ),
+        nav(
+          ul(
+            navLink({ href: "/profile", emoji: "⚉", text: i18n.profile }),
+            renderInboxLink(),
+            renderCipherLink(),
+            navLink({ href: "/peers", emoji: "⧖", text: i18n.peers }),
+            renderInvitesLink(),
+            navLink({ href: "/modules", emoji: "ꗣ", text: i18n.modules }),
+            renderLegacyLink(),
+            navLink({ href: "/settings", emoji: "⚙", text: i18n.settings }),
+            renderWalletLink()
+          )
         )
       ),
-      main({ id: "content" }, elements),
-      nav(
-        ul(
-          navLink({ href: "/publish", emoji: "❂", text: i18n.publish }),
-          renderInboxLink(),
-          navLink({ href: "/search", emoji: "✦", text: i18n.search }),
-          hr,
-          renderWalletLink(),
-          navLink({ href: "/profile", emoji: "⚉", text: i18n.profile }),
-          navLink({ href: "/peers", emoji: "⧖", text: i18n.peers }),
-          hr,
-          navLink({ href: "/settings", emoji: "⚙", text: i18n.settings }),
-          navLink({ href: "/modules", emoji: "ꗣ", text: i18n.modules }),
-          renderInvitesLink(),
+      div(
+        { class: "main-content" },
+        div(
+          { class: "sidebar-left" },
+          nav(
+            ul(
+              navLink({ href: "/mentions", emoji: "✺", text: i18n.mentions }),
+              hr,
+              renderPopularLink(),
+              renderTopicsLink(),
+              renderSummariesLink(),
+              renderLatestLink(),
+              renderThreadsLink(),
+              renderMultiverseLink()
+            )
+          )
+        ),
+        main({ id: "content", class: "main-column" }, elements),
+        div(
+          { class: "sidebar-right" },
+          nav(
+            ul(
+              navLink({ href: "/publish", emoji: "❂", text: i18n.publish }),
+              navLink({ href: "/search", emoji: "ꔅ", text: i18n.search }),
+            )
+          )
         )
-      ),
+      )
     )
   );
   return doctypeString + nodes.outerHTML;
 };
 
 const thread = (messages) => {
-  // this first loop is preprocessing to enable auto-expansion of forks when a
-  // message in the fork is linked to
-
   let lookingForTarget = true;
   let shallowest = Infinity;
 
@@ -266,8 +309,6 @@ const thread = (messages) => {
     const nextMsg = messages[j];
 
     const depth = (msg) => {
-      // will be undefined when checking depth(nextMsg) when currentMsg is the
-      // last message in the thread
       if (msg === undefined) return 0;
       return lodash.get(msg, "value.meta.thread.depth", 0);
     };
@@ -279,7 +320,7 @@ const thread = (messages) => {
         lodash.get(currentMsg, "value.meta.thread.ancestorOfTarget", false)
       );
       const isBlocked = Boolean(nextMsg.value.meta.blocking);
-      msgList.push(`<div class="indent"><details ${isAncestor ? "open" : ""}>`);
+      msgList.push(`<details ${isAncestor ? "open" : ""}>`);
 
       const nextAuthor = lodash.get(nextMsg, "value.meta.author.name");
       const nextSnippet = postSnippet(
@@ -319,8 +360,6 @@ const postSnippet = (text) => {
   const max = 40;
 
   text = text.trim().split("\n", 3).join("\n");
-  // this is taken directly from patchwork. i'm not entirely sure what this
-  // regex is doing
   text = text.replace(/_|`|\*|#|^\[@.*?]|\[|]|\(\S*?\)/g, "").trim();
   text = text.replace(/:$/, "");
   text = text.trim().split("\n", 1)[0].trim();
@@ -395,7 +434,7 @@ const postAside = ({ key, value }) => {
     fragments.push(section(continueThreadComponent(thread, isComment)));
   }
 
-  return div({ class: "indent" }, fragments);
+  return fragments;
 };
 
 const post = ({ msg, aside = false }) => {
@@ -429,8 +468,8 @@ const post = ({ msg, aside = false }) => {
   const { name } = msg.value.meta.author;
 
   const ts_received = msg.value.meta.timestamp.received;
-  const timeAgo = ts_received.since.replace("~", "");
-  const timeAbsolute = ts_received.iso8601.split(".")[0].replace("T", " ");
+  const timeAgo = ts_received.since.replace("~");
+  const timeAbsolute = ts_received.iso8601.split(".")[0].replace("T");
 
   const markdownContent = markdown(
     msg.value.content.text,
@@ -524,39 +563,25 @@ const post = ({ msg, aside = false }) => {
     },
     header(
       div(
+        { class: "header-content" },
         span(
           { class: "author" },
-          a(
-            { href: url.author },
-            img({ class: "avatar", src: url.avatar, alt: "" }),
+          a({ href: url.author },
+            img({ class: "avatar-profile", src: url.avatar, alt: "" }),
             name
-          )
-        ),
+          ),
         span({ class: "author-action" }, postOptions[msg.value.meta.postType]),
-        span(
-          {
-            class: "time",
-            title: timeAbsolute,
-          },
+        label(i18n.sendTime),
+          { class: "time", title: timeAbsolute },
           isPrivate ? "🔒" : null,
           isPrivate ? recps : null,
-          a({ href: url.link }, nbsp, timeAgo)
-        )
+          a({ href: url.link }, timeAgo)
+        ),
+        label(i18n.timeAgo)
       )
     ),
     articleContent,
-
-    // HACK: centered-footer
-    //
-    // Here we create an empty div with an anchor tag that can be linked to.
-    // In our CSS we ensure that this gets centered on the screen when we
-    // link to this anchor tag.
-    //
-    // This is used for redirecting users after they like a post, when we
-    // want the like button that they just clicked to remain close-ish to
-    // where it was before they clicked the button.
-    div({ id: `centered-footer-${encoded.key}`, class: "centered-footer" }),
-
+    br,
     footer(
       div(
         form(
@@ -576,13 +601,12 @@ const post = ({ msg, aside = false }) => {
         isPrivate || isRoot || isFork
           ? null
           : a({ href: url.subtopic }, nbsp, i18n.subtopic),
-        a({ href: url.json }, nbsp, i18n.json)
       ),
       br()
     )
   );
 
-  const threadSeparator = [div({ class: "text-browser" }, hr(), br())];
+  const threadSeparator = [br()];
 
   if (aside) {
     return [fragment, postAside(msg), isRoot ? threadSeparator : null];
@@ -701,30 +725,32 @@ exports.authorView = ({
     }
   })();
 
-  const prefix = section(
-    { class: "message" },
-    div(
-      { class: "profile" },
+const prefix = section(
+  { class: "message" },
+  div(
+    { class: "profile" },
+    div({ class: "avatar-container" },
       img({ class: "avatar", src: avatarUrl }),
-      h1(name)
+      h1({ class: "name" }, name)
     ),
     pre({
       class: "md-mention",
       innerHTML: markdownMention,
-    }),
-    description !== "" ? article({ innerHTML: markdown(description) }) : null,
-    footer(
-      div(
-        a({ href: `/likes/${encodeURIComponent(feedId)}` }, i18n.viewLikes),
-        span(nbsp, relationshipText),
-        ...contactForms,
-        relationship.me
-          ? a({ href: `/profile/edit` }, nbsp, i18n.editProfile)
-          : null
-      ),
-      br()
-    )
-  );
+    })
+  ),
+  description !== "" ? article({ innerHTML: markdown(description) }) : null,
+  footer(
+  div(
+      { class: "profile" },
+    ...contactForms.map(form => span({ style: "font-weight: bold;" }, form)),
+    span(nbsp, relationshipText),
+    relationship.me
+      ? a({ href: `/profile/edit`, class: "btn" }, nbsp, i18n.editProfile)
+      : span(i18n.relationshipNotFollowing),
+  a({ href: `/likes/${encodeURIComponent(feedId)}`, class: "btn" }, i18n.viewLikes)
+  )
+  )
+);
 
   const linkUrl = relationship.me
     ? "/profile/"
@@ -744,31 +770,31 @@ exports.authorView = ({
         )
       );
     }
-  } else {
-    const highestSeqNum = messages[0].value.sequence;
-    const lowestSeqNum = messages[messages.length - 1].value.sequence;
-    let newerPostsLink;
-    if (lastPost !== undefined && highestSeqNum < lastPost.value.sequence)
-      newerPostsLink = a(
-        { href: `${linkUrl}?gt=${highestSeqNum}` },
-        i18n.newerPosts
-      );
-    else newerPostsLink = span(i18n.newerPosts, { title: i18n.noNewerPosts });
-    let olderPostsLink;
-    if (lowestSeqNum > firstPost.value.sequence)
-      olderPostsLink = a(
-        { href: `${linkUrl}?lt=${lowestSeqNum}` },
-        i18n.olderPosts
-      );
-    else
-      olderPostsLink = span(i18n.olderPosts, { title: i18n.beginningOfFeed });
-    const pagination = section(
-      { class: "message" },
-      footer(div(newerPostsLink, olderPostsLink), br())
-    );
-    items.unshift(pagination);
-    items.push(pagination);
-  }
+  } //else {
+    //const highestSeqNum = messages[0].value.sequence;
+    //const lowestSeqNum = messages[messages.length - 1].value.sequence;
+    //let newerPostsLink;
+    //if (lastPost !== undefined && highestSeqNum < lastPost.value.sequence)
+    //  newerPostsLink = a(
+    //    { href: `${linkUrl}?gt=${highestSeqNum}` },
+    //    i18n.newerPosts
+    //  );
+    //else newerPostsLink = span(i18n.newerPosts, { title: i18n.noNewerPosts });
+   // let olderPostsLink;
+   // if (lowestSeqNum > firstPost.value.sequence)
+   //   olderPostsLink = a(
+   //     { href: `${linkUrl}?lt=${lowestSeqNum}` },
+   //     i18n.olderPosts
+    //  );
+   // else
+  //    olderPostsLink = span(i18n.olderPosts, { title: i18n.beginningOfFeed });
+  //  const pagination = section(
+  //    { class: "message" },
+  //    footer(div(newerPostsLink, olderPostsLink), br())
+  //  );
+  //  items.unshift(pagination);
+  //  items.push(pagination);
+  //}
 
   return template(i18n.profile, prefix, items);
 };
@@ -932,7 +958,7 @@ exports.publishView = (preview, text, contentWarning) => {
           method: "post",
           enctype: "multipart/form-data",
         },
-        label(
+        p(
           i18n.publishLabel({ markdownUrl, linkTarget: "_blank" }),
         label(
           i18n.contentWarningLabel,
@@ -946,9 +972,10 @@ exports.publishView = (preview, text, contentWarning) => {
         ),
           textarea({ required: true, name: "text", placeholder: i18n.publishWarningPlaceholder }, text ? text : "")
         ),
+        input({ type: "file", id: "blob", name: "blob" }),
+        br,
+        br,
         button({ type: "submit" }, i18n.preview),
-        label({ class: "file-button", for: "blob" }, i18n.attachFiles),
-        input({ type: "file", id: "blob", name: "blob" })
       )
     ),
     preview ? preview : "",
@@ -1051,8 +1078,6 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
     section(
       { class: "post-preview" },
       post({ msg }),
-
-      // doesn't need blobs, preview adds them to the text
       form(
         { action, method: "post" },
         input({
@@ -1084,7 +1109,7 @@ exports.previewView = ({ previewData, contentWarning }) => {
 
 exports.peersView = async ({ peers, supports, blocks, recommends }) => {
 
- const startButton = form(
+  const startButton = form(
     { action: "/settings/conn/start", method: "post" },
     button({ type: "submit" }, i18n.startNetworking)
   );
@@ -1115,35 +1140,36 @@ exports.peersView = async ({ peers, supports, blocks, recommends }) => {
     .filter(([, data]) => data.state === "connected")
     .map(([, data]) => {
       return li(
-          data.name, br,
+        data.name, br,
         a(
           { href: `/author/${encodeURIComponent(data.key)}` },
           data.key, br, br
         )
       );
-   });
+    });
 
- return template(
-  i18n.peers,
+  return template(
+    i18n.peers,
     section(
-      { class: "message" },
+      { class: "viewInfo" },
       h1(i18n.peerConnections),
-      connButtons,
+      p(i18n.peerConnectionsIntro),
+      div({ class: "conn-buttons" }, connButtons),
       h1(i18n.online, " (", peerList.length, ")"),
-      peerList.length > 0 ? ul(peerList) : i18n.noConnections,
-      p(i18n.connectionActionIntro),
-      h1(i18n.supported, " (", supports.length/2, ")"),
-      supports.length > 0 ? ul(supports): i18n.noSupportedConnections,
+      p(peerList.length > 0 ? ul(peerList) : i18n.noConnections),
       p(i18n.connectionActionIntro),
-      h1(i18n.recommended, " (", recommends.length/2, ")"),
-      recommends.length > 0 ? ul(recommends): i18n.noRecommendedConnections,
+      h1(i18n.supported, " (", supports.length / 2, ")"),
+      p(supports.length > 0 ? ul(supports) : i18n.noSupportedConnections),
       p(i18n.connectionActionIntro),
-      h1(i18n.blocked, " (", blocks.length/2, ")"),
-      blocks.length > 0 ? ul(blocks): i18n.noBlockedConnections,
+      h1(i18n.recommended, " (", recommends.length / 2, ")"),
+      p(recommends.length > 0 ? ul(recommends) : i18n.noRecommendedConnections),
       p(i18n.connectionActionIntro),
-      )
-    );
-};
+      h1(i18n.blocked, " (", blocks.length / 2, ")"),
+      p(blocks.length > 0 ? ul(blocks) : i18n.noBlockedConnections),
+      p(i18n.connectionActionIntro)
+    )
+  );
+}; 
 
 exports.invitesView = ({ invitesEnabled }) => {
   let pubs = [];
@@ -1172,10 +1198,8 @@ exports.invitesView = ({ invitesEnabled }) => {
   if (pubsValue === "true") {
     const arr2 = pubs.map(pubItem => {
       return li(
-        `PUB: ${pubItem.host}`,
-        br,
-        `${i18n.inhabitants}: ${pubItem.announcers}`,
-        br,
+        p(`PUB: ${pubItem.host}`),
+        p(`${i18n.inhabitants}: ${pubItem.announcers}`),
         a(
           { href: `/author/${encodeURIComponent(pubItem.key)}` },
           pubItem.key
@@ -1190,13 +1214,14 @@ exports.invitesView = ({ invitesEnabled }) => {
   return template(
     i18n.invites,
     section(
-      { class: "message" },
+      { class: "viewInfo" },
       h1(i18n.invites),
       p(i18n.invitesDescription),
       form(
         { action: "/settings/invite/accept", method: "post" },
         input({ name: "invite", type: "text", autofocus: true, required: true }),
         button({ type: "submit" }, i18n.acceptInvite),
+        hr,
         h1(i18n.acceptedInvites, " (", pub.length, ")"),
         pub.length > 0 ? ul(pub) : i18n.noInvites
       )
@@ -1205,7 +1230,7 @@ exports.invitesView = ({ invitesEnabled }) => {
 };
  
 exports.modulesView = () => {
-  const config = getConfig();
+  const config = getConfig().modules;
   const popularMod = config.popularMod === 'on' ? 'on' : 'off';
   const topicsMod = config.topicsMod === 'on' ? 'on' : 'off';
   const summariesMod = config.summariesMod === 'on' ? 'on' : 'off';
@@ -1215,9 +1240,11 @@ exports.modulesView = () => {
   const inboxMod = config.inboxMod === 'on' ? 'on' : 'off';
   const invitesMod = config.invitesMod === 'on' ? 'on' : 'off';
   const walletMod = config.walletMod === 'on' ? 'on' : 'off';
+  const legacyMod = config.legacyMod === 'on' ? 'on' : 'off';
+  const cipherMod = config.cipherMod === 'on' ? 'on' : 'off';
   
   return template(
-    i18n.modulesView,
+    i18n.modules,
     section(
       { class: "modules-view" },
       h1(i18n.modulesViewTitle),
@@ -1323,7 +1350,29 @@ exports.modulesView = () => {
                 checked: invitesMod === 'on' ? true : undefined
               })
             )
+          ),
+         tr(
+          td(i18n.legacyLabel),
+          td(
+           input({
+            type: "checkbox",
+            id: "legacyMod",
+            name: "legacyForm",
+            class: "input-checkbox",
+            checked: legacyMod === 'on' ? true : undefined
+           })
+          ),
+          td(i18n.cipherLabel),
+          td(
+           input({
+            type: "checkbox",
+            id: "cipherMod",
+            name: "cipherForm",
+            class: "input-checkbox",
+            checked: cipherMod === 'on' ? true : undefined
+           })
           )
+         )       
         ),
         div(
           { class: "save-button-container" },
@@ -1334,42 +1383,17 @@ exports.modulesView = () => {
   );
 };
 
-exports.settingsView = ({ theme, themeNames, version, walletUrl, walletUser, walletFee }) => {
- const themeElements = themeNames.map((cur) => {
-    const isCurrentTheme = cur === theme;
-    if (isCurrentTheme) {
-      return option({ value: cur, selected: true }, cur);
-    } else {
-      return option({ value: cur }, cur);
-    }
-  });
+exports.settingsView = ({ theme, version }) => {
+    const currentConfig = getConfig();
+    const walletUrl = currentConfig.wallet.url
+    const walletUser = currentConfig.wallet.user
+    const walletFee = currentConfig.wallet.fee
 
-  const base16 = [
-    // '00', removed because this is the background
-    "01",
-    "02",
-    "03",
-    "04",
-    "05",
-    "06",
-    "07",
-    "08",
-    "09",
-    "0A",
-    "0B",
-    "0C",
-    "0D",
-    "0E",
-    "0F",
+  const themeElements = [
+    option({ value: "SNH-Oasis", selected: true }, "SNH-Oasis"),
   ];
 
-  const base16Elements = base16.map((base) =>
-    div({
-      class: `theme-preview theme-preview-${base}`,
-    })
-  );
-
-  const languageOption = (longName, shortName) =>
+  const languageOption = (longName, shortName, selectedLanguage) =>
     shortName === selectedLanguage
       ? option({ value: shortName, selected: true }, longName)
       : option({ value: shortName }, longName);
@@ -1382,30 +1406,31 @@ exports.settingsView = ({ theme, themeNames, version, walletUrl, walletUser, wal
   return template(
     i18n.settings,
     section(
-      { class: "message" },
+      { class: "viewInfo" },
       h1(i18n.settings),
-      p(a({ href:snhUrl, target: "_blank" }, i18n.settingsIntro({ version }))),
-      p(global.updaterequired),
+      p(a({ href: snhUrl, target: "_blank" }, i18n.settingsIntro({ version }))),
       hr,
       h2(i18n.theme),
       p(i18n.themeIntro),
       form(
-         { action: "/theme.css", method: "post" },
-         select({ name: "theme" }, ...themeElements),
-         button({ type: "submit" }, i18n.setTheme)
-       ),
+        { action: "/theme.css", method: "post" },
+        select({ name: "theme" }, ...themeElements),
+        br,
+        br,
+        button({ type: "submit" }, i18n.setTheme)
+      ),
       hr,
       h2(i18n.language),
       p(i18n.languageDescription),
       form(
         { action: "/language", method: "post" },
         select({ name: "language" }, [
-          // Languages are sorted alphabetically by their 'long name'.
-          /* spell-checker:disable */
-          languageOption("English", "en"),
-          languageOption("Español", "es"),
-          /* spell-checker:enable */
+          languageOption("English", "en", selectedLanguage),
+          languageOption("Español", "es", selectedLanguage),
+          languageOption("Français", "fr", selectedLanguage),
         ]),
+        br,
+        br,
         button({ type: "submit" }, i18n.setLanguage)
       ),
       hr,
@@ -1429,7 +1454,7 @@ exports.settingsView = ({ theme, themeNames, version, walletUrl, walletUser, wal
       hr,
       h2(i18n.indexes),
       p(i18n.indexesDescription),
-      rebuildButton,
+      rebuildButton
     )
   );
 };
@@ -1626,12 +1651,6 @@ exports.searchView = ({ messages, query }) => {
     type: "search",
     value: query,
   });
-
-  // - Minimum length of 3 because otherwise SSB-Search hangs forever. :)
-  //   https://github.com/ssbc/ssb-search/issues/8
-  // - Using `setAttribute()` because HyperScript (the HyperAxe dependency has
-  //   a bug where the `minlength` property is being ignored. No idea why.
-  //   https://github.com/hyperhype/hyperscript/issues/91
   searchInput.setAttribute("minlength", 3);
 
   return template(
@@ -1640,7 +1659,9 @@ exports.searchView = ({ messages, query }) => {
       h1(i18n.search),
       form(
         { action: "/search", method: "get" },
-        label(i18n.searchLabel, searchInput),
+        p(i18n.searchLabel, searchInput),
+        br,
+        br,
         button(
           {
             type: "submit",
@@ -1689,12 +1710,6 @@ exports.imageSearchView = ({ blobs, query }) => {
     type: "search",
     value: query,
   });
-
-  // - Minimum length of 3 because otherwise SSB-Search hangs forever. :)
-  //   https://github.com/ssbc/ssb-search/issues/8
-  // - Using `setAttribute()` because HyperScript (the HyperAxe dependency has
-  //   a bug where the `minlength` property is being ignored. No idea why.
-  //   https://github.com/hyperhype/hyperscript/issues/91
   searchInput.setAttribute("minlength", 3);
 
   return template(
@@ -1775,17 +1790,27 @@ const walletViewRender = (balance, ...elements) => {
       p(i18n.walletDescription),
     ),
     section(
+    h1(i18n.walletBalanceTitle),
       div(
         {class: "div-center"},
         span(
           {class: "wallet-balance"},
           i18n.walletBalanceLine({ balance })
         ),
+        ),
+        div(
+        {class: "div-center"},
         span(
-          { class: "form-button-group-center" },
-          a({ href: "/wallet/send", class: "button-like-link" }, i18n.walletSend),
-          a({ href: "/wallet/receive", class: "button-like-link" }, i18n.walletReceive),
-          a({ href: "/wallet/history", class: "button-like-link" }, i18n.walletHistory),
+          { class: "wallet-form-button-group-center" },
+           form({ action: "/wallet/send", method: "get" },
+            button({ type: 'submit' }, i18n.walletSend)
+           ),
+           form({ action: "/wallet/receive", method: "get" },
+            button({ type: 'submit' }, i18n.walletReceive)
+            ),
+           form({ action: "/wallet/history", method: "get" },
+            button({ type: 'submit' }, i18n.walletHistory)
+           )
         )
       ),
     ),
@@ -1800,6 +1825,7 @@ exports.walletView = async (balance) => {
 exports.walletHistoryView = async (balance, transactions) => {
   return walletViewRender(
     balance,
+    h1(i18n.walletHistoryTitle),
     table(
       { class: "wallet-history" },
       thead(
@@ -1844,6 +1870,7 @@ exports.walletReceiveView = async (balance, address) => {
 
   return walletViewRender(
     balance,
+    h1(i18n.walletReceiveTitle),
     div(
       {class: 'div-center qr-code', innerHTML: qrContainer},
     ),
@@ -1869,6 +1896,7 @@ exports.walletSendFormView = async (balance, destination, amount, fee, statusMes
 
   return walletViewRender(
     balance,
+    h1(i18n.walletWalletSendTitle),
     div(
       {class: "div-center"},
       messages?.length > 0 ? statusBlock : null,
@@ -1945,3 +1973,134 @@ exports.walletSendResultView = async (balance, destination, amount, txId) => {
     ),
   )
 }
+
+exports.legacyView = async () => {
+  const randomPassword = generateRandomPassword();
+  
+  return template(
+    `${i18n.legacyTitle}`,
+    section(
+      h1(i18n.legacyTitle),
+      p(i18n.legacyDescription)
+    ),
+    p({ class: "generated-password", id: "randomPassword" }, `${i18n.randomPassword}: ${randomPassword}`),
+    section(
+      div(
+        { class: "div-center" },
+        label(i18n.exportTitle),
+        p(i18n.exportDescription),
+        form(
+          { 
+            action: "/legacy/export", 
+            method: "POST", 
+            id: "exportForm" 
+          },
+          label(i18n.exportPasswordLabel),
+          input({ 
+            type: "password", 
+            name: "password", 
+            id: "password", 
+            required: true, 
+            placeholder: i18n.exportPasswordPlaceholder, 
+            minlength: 32
+          }),
+          p({ class: "file-info" }, i18n.fileInfo),
+          button({ type: "submit" }, i18n.legacyExportButton)
+        ),
+        br,
+        label(i18n.importTitle),
+        p(i18n.importDescription),
+        form(
+          { action: "/legacy/import", method: "POST", enctype: "multipart/form-data" },
+          input({ type: "file", name: "uploadedFile", required: true }),
+          br,
+          p(i18n.passwordImport),
+          input({ 
+            type: "password", 
+            name: "importPassword", 
+            required: true, 
+            placeholder: i18n.importPasswordPlaceholder,
+            minlength: 32
+          }),
+          button({ type: "submit" }, i18n.legacyImportButton)
+        )
+      )
+    )
+  );
+};
+
+exports.cipherView = async (encryptedText = "", decryptedText = "", iv = "", password = "") => {
+  const randomPassword = generateRandomPassword();
+
+  const view = template(
+    `${i18n.cipherTitle}`,
+    section(
+      h1(i18n.cipherTitle),
+      p(i18n.cipherDescription)
+    ),
+    p({ class: "generated-password", id: "randomPassword" }, `${i18n.randomPassword}: ${randomPassword}`),
+    section(
+      div(
+        { class: "div-center" },
+        label(i18n.cipherEncryptTitle),
+        p(i18n.cipherEncryptDescription),
+        form(
+          { 
+            action: "/cipher/encrypt", 
+            method: "POST", 
+            id: "encryptForm" 
+          },
+          label(i18n.cipherTextLabel),
+          textarea({
+            name: "text",
+            id: "text",
+            required: true,
+            placeholder: i18n.cipherTextPlaceholder,
+            rows: 4
+          }),
+          label(i18n.cipherPasswordLabel),
+          input({
+            type: "password",
+            name: "password",
+            id: "password",
+            required: true,
+            placeholder: i18n.cipherPasswordPlaceholder,
+            minlength: 32
+          }),
+          button({ type: "submit" }, i18n.cipherEncryptButton)
+        ),
+        encryptedText ? div({ class: "cipher-result visible encrypted-result" }, `${encryptedText}`) : div({ class: "cipher-result" }),
+        encryptedText ? div({ class: "cipher-result visible encrypted-result" }, `${password}`) : div({ class: "cipher-result" }),
+        encryptedText ? input({ type: "hidden", name: "iv", value: iv }) : "",
+        label(i18n.cipherDecryptTitle),
+        p(i18n.cipherDecryptDescription),
+        form(
+          { action: "/cipher/decrypt", method: "POST", id: "decryptForm" },
+          label(i18n.cipherEncryptedTextLabel),
+          textarea({
+            name: "encryptedText",
+            id: "encryptedText",
+            required: true,
+            placeholder: i18n.cipherEncryptedTextPlaceholder,
+            rows: 4,
+            value: encryptedText
+          }),
+          label(i18n.cipherPasswordLabel),
+          input({
+            type: "password",
+            name: "password",
+            id: "password",
+            required: true,
+            placeholder: i18n.cipherPasswordPlaceholder,
+            minlength: 32
+          }),
+          input({ type: "hidden", name: "iv", value: iv }),
+          button({ type: "submit" }, i18n.cipherDecryptButton)
+        ),
+        decryptedText ? div({ class: "cipher-result visible decrypted-result" }, `${decryptedText}`) : div({ class: "cipher-result" }),
+        decryptedText ? div({ class: "cipher-result visible decrypted-result" }, `${password}`) : div({ class: "cipher-result" }),
+      )
+    )
+  );
+  return view;
+};