upstream: integrar Oasis 1.3.1

Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
This commit is contained in:
oasis-bot 2026-10-18 02:52:17 +02:00
parent 8d838dd8b1
commit c332afd6ea
265 changed files with 80970 additions and 29167 deletions

View file

@ -1,12 +1,13 @@
const { form, button, div, h2, h3, p, section, ul, li, a, br, hr, input, label, span, table, tr, td, textarea } = require("../server/node_modules/hyperaxe");
const { form, button, div, h2, h3, p, section, a, br, input, label, table, tr, td, textarea } = require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment");
const path = require("path");
const fs = require('fs');
const { renderUrl } = require("../backend/renderUrl");
const { renderStyledText } = require("../backend/renderStyledText");
const { template, i18n } = require('./main_views');
const homedir = require('os').homedir();
const gossipPath = path.join(homedir, ".ssb", "gossip.json");
const unfollowedPath = path.join(homedir, ".ssb", "gossip_unfollowed.json");
const gossipPath = path.join(require("../server/ssb_config").path, "gossip.json");
const unfollowedPath = require("../configs/state-manager").statePath("gossip_unfollowed.json");
const peerHealth = require("../models/peer_health");
const encodePubLink = (key) => {
let core = String(key).replace(/^@/, '').replace(/\.ed25519$/, '').replace(/-/g, '+').replace(/_/g, '/');
@ -14,12 +15,21 @@ const encodePubLink = (key) => {
return `/author/${encodeURIComponent('@' + core)}.ed25519`;
};
const snhInvitePath = path.join(__dirname, '..', 'configs', 'snh-invite-code.json');
let snhInvite = null;
try {
snhInvite = JSON.parse(fs.readFileSync(snhInvitePath, 'utf8'));
} catch {}
const INVITE_KIND_LABEL = {
tribes: 'invitesTribesTitle',
houses: 'invitesHousesTitle',
chats: 'invitesChatsTitle',
pads: 'invitesPadsTitle',
rooms: 'invitesRoomsTitle',
calendars: 'invitesCalendarsTitle',
events: 'invitesEventsTitle',
forum: 'invitesForumsTitle',
school: 'invitesSchoolTitle',
maps: 'invitesMapsTitle',
shops: 'invitesShopsTitle',
inhabitants: 'invitesInhabitantsTitle',
pubs: 'invitesPubsTitle'
};
const deduplicateByHost = (list) => {
const seen = new Set();
@ -31,7 +41,25 @@ const deduplicateByHost = (list) => {
});
};
const invitesView = ({ invitesEnabled, flash }) => {
const renderSuggestedPub = (entry, canJoin) => div({ class: 'snh-invite-box' },
h3({ class: 'snh-invite-name' }, entry.name),
p({ class: 'snh-invite-name' }, entry.description),
p({ class: 'snh-invite-name' }, renderStyledText(entry.url)),
entry.createdAt ? p({ class: 'snh-invite-date' }, `${i18n.statsCreatedAt || 'Created'}: ${moment(entry.createdAt).format("YYYY/MM/DD")}`) : null,
p({ class: 'snh-invite-name snh-invite-code' }, entry.code),
entry.invites ? p({ class: 'snh-invite-date' }, `${i18n.invitesPubUses}: ${entry.invites.used}/${entry.invites.total}`) : null,
entry.exhausted
? p({ class: 'snh-invite-name' }, i18n.invitesPubExhausted)
: canJoin
? form({ action: '/settings/invite/accept', method: 'post' },
input({ type: 'hidden', name: 'invite', value: entry.code }),
button({ type: 'submit' }, i18n.invitesAcceptInvite)
)
: null
);
const invitesView = (opts = {}) => {
const canJoin = opts.canJoin !== false;
let pubs = [];
let pubsValue = "false";
let unfollowed = [];
@ -60,15 +88,8 @@ const invitesView = ({ invitesEnabled, flash }) => {
? deduplicateByHost(pubs.filter(pubItem => !unfollowed.find(u => u.key === pubItem.key)))
: [];
const hasError = (pubItem) => pubItem && (pubItem.error || (typeof pubItem.failure === 'number' && pubItem.failure > 0));
const sanitizeError = (err) => {
if (!err) return i18n.genericError || 'Unknown error';
const firstLine = String(err).split('\n')[0].replace(/^Error:\s*/, '').trim();
return firstLine || (i18n.genericError || 'Unknown error');
};
const unreachableLabel = i18n.currentlyUnreachable || i18n.currentlyUnrecheable || 'ERROR!';
const deadKeys = opts.deadKeys instanceof Set ? opts.deadKeys : new Set();
const hasError = (pubItem) => !!(pubItem && deadKeys.has(peerHealth.canonicalKey(pubItem.key)));
const pubTableHeader = () => tr(
td({ class: 'card-label' }, 'PUB'),
@ -78,7 +99,9 @@ const invitesView = ({ invitesEnabled, flash }) => {
);
const activePubs = filteredPubs.filter(pubItem => !hasError(pubItem));
const unreachablePubs = pubs.filter(hasError);
const filter = opts.filter === 'others' ? 'others' : 'pubs';
const suggested = Array.isArray(opts.inviteCodes) ? opts.inviteCodes : [];
const used = (Array.isArray(opts.invitesUsed) ? opts.invitesUsed : []).filter((e) => e && typeof e.href === 'string' && e.href.startsWith('/'));
const renderPubTable = (items, actionFn) => table({ class: 'block-info-table invites-pubs-table' },
pubTableHeader(),
@ -96,56 +119,44 @@ const invitesView = ({ invitesEnabled, flash }) => {
return template(
title,
section(
div({ class: 'tags-header' },
div({ class: 'tags-header module-header-line' },
h2(title),
p(description)
),
div({ class: 'mode-buttons-row' },
a({ class: filter === 'pubs' ? 'filter-btn active' : 'filter-btn', href: '/invites?filter=pubs' }, String(i18n.invitesPubsTitle).toUpperCase()),
a({ class: filter === 'others' ? 'filter-btn active' : 'filter-btn', href: '/invites?filter=others' }, String(i18n.invitesFilterOthers).toUpperCase())
)
),
flash === 'alreadyFederated'
? section(div({ class: 'message-banner' }, p(i18n.invitesAlreadyFederated || 'You are already federated with this pub.')))
: null,
section(
filter === 'pubs' && suggested.length ? section(
div({ class: 'pubs-section' },
h2(`${i18n.invitesSuggestedPubs} (${suggested.length})`),
...suggested.map((entry) => renderSuggestedPub(entry, canJoin))
)
) : null,
filter === 'pubs' ? section(
div({ class: 'pubs-section' },
h2(i18n.invitesPubsTitle),
p(i18n.invitesPubsHint),
form(
{ action: '/settings/invite/accept', method: 'post' },
input({ name: 'invite', type: 'text', placeholder: i18n.invitesPubInviteCodePlaceholder, autofocus: true, required: true }),
br(),
button({ type: 'submit' }, i18n.invitesAcceptInvite)
),
br(),
snhInvite ? div({ class: 'snh-invite-box' },
h3({ class: 'snh-invite-name' }, snhInvite.name),
p({ class: 'snh-invite-name' }, snhInvite.description),
p({ class: 'snh-invite-name' }, renderUrl(snhInvite.url)),
snhInvite.createdAt ? p({ class: 'snh-invite-date' }, `${i18n.statsCreatedAt || 'Created'}: ${new Date(snhInvite.createdAt).toLocaleDateString()}`) : null,
form({ action: '/settings/invite/accept', method: 'post' },
input({ type: 'hidden', name: 'invite', value: snhInvite.code }),
button({ type: 'submit', class: 'filter-btn' }, snhInvite.code)
)
) : null
canJoin
? form(
{ action: '/settings/invite/accept', method: 'post' },
input({ name: 'invite', type: 'text', placeholder: i18n.invitesPubInviteCodePlaceholder, autofocus: true, required: true }),
br(),
button({ type: 'submit' }, i18n.invitesAcceptInvite)
)
: p({ class: 'invites-join-note' }, opts.publicMode === true ? i18n.invitesJoinPubMode : i18n.invitesJoinLocalOnly)
)
),
section(
) : null,
filter === 'pubs' ? section(
div({ class: 'federations-section' },
h2(i18n.invitesFederationsTitle || 'Federations'),
p(i18n.invitesFederationsHint),
div({ class: 'conn-actions invites-pubs-actions' },
form({ action: '/invites/refresh-pubs', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsRefresh || 'Refresh')),
form({ action: '/invites/clear-unreachable', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsClearUnreachable || 'Remove unreachable')),
form({ action: '/invites/clear-unreachable', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsClearUnreachable || 'Clean')),
form({ action: '/invites/export-pubs', method: 'get' }, button({ type: 'submit' }, i18n.invitesPubsExport || 'Export'))
),
form(
{ action: '/invites/import-pubs', method: 'post', enctype: 'multipart/form-data', class: 'peers-import-form' },
label({ class: 'peers-import-label' }, i18n.invitesPubsImportTitle || 'Import pubs'),
br(),
textarea({ name: 'peerList', rows: '4', placeholder: i18n.invitesPubsImportPlaceholder || 'Paste one multiserver address or invite code per line…' }),
br(),
input({ type: 'file', name: 'peerFile', accept: '.txt,text/plain' }),
br(),
button({ type: 'submit', class: 'filter-btn' }, i18n.invitesPubsImport || 'Import')
),
h2(`${i18n.invitesAcceptedInvites} (${activePubs.length})`),
activePubs.length
? renderPubTable(activePubs, pubItem =>
@ -155,7 +166,6 @@ const invitesView = ({ invitesEnabled, flash }) => {
)
)
: p(i18n.invitesNoFederatedPubs),
hr(),
h2(`${i18n.invitesUnfollowedInvites} (${unfollowed.length})`),
unfollowed.length
? renderPubTable(unfollowed, pubItem =>
@ -167,145 +177,51 @@ const invitesView = ({ invitesEnabled, flash }) => {
)
)
: p(i18n.invitesNoUnfollowed),
hr(),
h2(`${i18n.invitesUnreachablePubs} (${unreachablePubs.length})`),
unreachablePubs.length
? renderPubTable(unreachablePubs, pubItem =>
div({ class: 'error-box' },
p({ class: 'error-title' }, i18n.errorDetails),
p({ class: 'error-pre' }, sanitizeError(pubItem.error))
)
form(
{ action: '/invites/import-pubs', method: 'post', enctype: 'multipart/form-data', class: 'peers-import-form' },
label({ class: 'peers-import-label' }, i18n.invitesPubsImportTitle || 'Import pubs'),
br(),
textarea({ name: 'peerList', rows: '4', placeholder: i18n.invitesPubsImportPlaceholder || 'Paste one multiserver address or invite code per line…' }),
br(),
input({ type: 'file', name: 'peerFile', accept: '.txt,text/plain' }),
br(),
button({ type: 'submit' }, i18n.invitesPubsImport || 'Import')
)
)
) : null,
filter === 'others' ? section(
div(
h2(i18n.invitesJoinTitle),
p(i18n.invitesJoinHint),
form(
{ action: '/invites/join', method: 'post' },
input({ name: 'code', type: 'text', placeholder: i18n.invitesJoinPlaceholder, required: true }),
br(),
button({ type: 'submit' }, i18n.invitesJoinButton)
),
h2(`${i18n.invitesUsedTitle} (${used.length})`),
used.length
? table({ class: 'block-info-table invites-used-table' },
tr(
td({ class: 'card-label' }, i18n.invitesUsedType),
td({ class: 'card-label' }, i18n.invitesUsedName),
td({ class: 'card-label' }, i18n.invitesUsedDate)
),
used.map((entry) => tr(
td(INVITE_KIND_LABEL[entry.kind] ? i18n[INVITE_KIND_LABEL[entry.kind]] : entry.kind),
td(a({ href: entry.href }, entry.title || entry.href)),
td(moment(entry.at).format('YYYY/MM/DD HH:mm'))
))
)
: p(i18n.invitesNoUnreachablePubs)
: p(i18n.invitesUsedEmpty)
)
),
section(
div({ class: 'invites-houses', id: 'invites-houses' },
h2(i18n.invitesHousesTitle || 'Houses'),
p(i18n.invitesHousesHint),
form(
{ action: '/larp/invite/redeem', method: 'post' },
input({ type: 'hidden', name: 'returnTo', value: '/invites' }),
input({ name: 'code', type: 'text', placeholder: i18n.invitesHouseInviteCodePlaceholder || 'House invitation code', required: true, maxlength: 32 }),
br(),
button({ type: 'submit' }, i18n.invitesHouseJoinButton || 'Join House')
)
)
),
section(
div({ class: 'invites-tribes', id: 'invites-tribes' },
h2(i18n.invitesTribesTitle),
p(i18n.invitesTribesHint),
form(
{ action: '/tribes/join-code', method: 'post' },
input({ name: 'inviteCode', type: 'text', placeholder: i18n.invitesTribeInviteCodePlaceholder, required: true }),
br(),
button({ type: 'submit' }, i18n.invitesTribeJoinButton)
)
)
),
section(
div({ class: 'invites-chats', id: 'invites-chats' },
h2(i18n.invitesChatsTitle || 'Chats'),
p(i18n.invitesChatsHint),
form(
{ action: '/chats/join-code', method: 'post' },
input({ name: 'code', type: 'text', placeholder: i18n.invitesChatInviteCodePlaceholder || 'Enter chat invite code', required: true }),
br(),
button({ type: 'submit' }, i18n.invitesChatJoinButton || 'Join Chat')
)
)
),
section(
div({ class: 'invites-pads', id: 'invites-pads' },
h2(i18n.invitesPadsTitle || 'Pads'),
p(i18n.invitesPadsHint),
form(
{ action: '/pads/join-code', method: 'post' },
input({ name: 'code', type: 'text', placeholder: i18n.invitesPadInviteCodePlaceholder || 'Enter pad invite code', required: true }),
br(),
button({ type: 'submit' }, i18n.invitesPadJoinButton || 'Join Pad')
)
)
),
section(
div({ class: 'invites-calendars', id: 'invites-calendars' },
h2(i18n.invitesCalendarsTitle || 'Calendars'),
p(i18n.invitesCalendarsHint),
form(
{ action: '/calendars/join-code', method: 'post' },
input({ name: 'code', type: 'text', placeholder: i18n.invitesCalendarInviteCodePlaceholder || 'Enter calendar invite code', required: true }),
br(),
button({ type: 'submit' }, i18n.invitesCalendarJoinButton || 'Join Calendar')
)
)
),
section(
div({ class: 'invites-events', id: 'invites-events' },
h2(i18n.invitesEventsTitle || 'Events'),
p(i18n.invitesEventsHint),
form(
{ action: '/events/join-code', method: 'post' },
input({ name: 'code', type: 'text', placeholder: i18n.invitesEventInviteCodePlaceholder || 'Enter event invite code', required: true }),
br(),
button({ type: 'submit' }, i18n.invitesEventJoinButton || 'Join Event')
)
)
),
section(
div({ class: 'invites-forums', id: 'invites-forums' },
h2(i18n.invitesForumsTitle || 'Forums'),
p(i18n.invitesForumsHint),
form(
{ action: '/forum/join-code', method: 'post' },
input({ name: 'code', type: 'text', placeholder: i18n.invitesForumInviteCodePlaceholder || 'Enter forum invite code', required: true }),
br(),
button({ type: 'submit' }, i18n.invitesForumJoinButton || 'Join Forum')
)
)
),
section(
div({ class: 'invites-maps', id: 'invites-maps' },
h2(i18n.invitesMapsTitle || 'Maps'),
p(i18n.invitesMapsHint),
form(
{ action: '/maps/join-code', method: 'post' },
input({ name: 'code', type: 'text', placeholder: i18n.invitesMapInviteCodePlaceholder || 'Enter map invite code', required: true }),
br(),
button({ type: 'submit' }, i18n.invitesMapJoinButton || 'Join Map')
)
)
),
section(
div({ class: 'invites-shops', id: 'invites-shops' },
h2(i18n.invitesShopsTitle || 'Shops'),
p(i18n.invitesShopsHint),
form(
{ action: '/shops/join-code', method: 'post' },
input({ name: 'code', type: 'text', placeholder: i18n.invitesShopInviteCodePlaceholder || 'Enter shop invite code', required: true }),
br(),
button({ type: 'submit' }, i18n.invitesShopJoinButton || 'Join Shop')
)
)
),
section(
div({ class: 'invites-inhabitants', id: 'invites-inhabitants' },
h2(i18n.invitesInhabitantsTitle || 'Inhabitants'),
p(i18n.invitesInhabitantsHint),
form(
{ action: '/invites/inhabitant/follow', method: 'post' },
input({ name: 'feedId', id: 'inh_oasis_id', type: 'text', placeholder: '@...=.ed25519', pattern: '@[A-Za-z0-9+/_\\-]{43}=\\.ed25519', required: true, maxlength: 56 }),
br(),
button({ type: 'submit' }, i18n.invitesInhabitantsFollow || 'Give Support')
)
)
),
section(
div({ class: 'invites-peers', id: 'invites-peers' },
) : null,
filter === 'others' ? section(
div(
h2(i18n.peers || 'Peers'),
p(i18n.directConnectDescription),
form({ action: '/peers/connect', method: 'post' },
input({ type: 'text', id: 'peer_host', name: 'host', required: true, placeholder: '192.168.1.100', pattern: '(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?)*)', title: i18n.peerHostValidation || 'Valid IPv4 (e.g. 192.168.1.100) or hostname (e.g. pub.example.com)', maxlength: 253 }),
input({ type: 'text', id: 'peer_host', name: 'host', required: true, placeholder: '192.168.1.100', pattern: '(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?)*)', title: i18n.peerHostValidation || 'Valid IPv4 (e.g. 192.168.1.100) or hostname (e.g. pub.solarnethub.com)', maxlength: 253 }),
br(),
label({ for: 'peer_port' }, i18n.peerPort),
br(),
@ -318,9 +234,10 @@ const invitesView = ({ invitesEnabled, flash }) => {
button({ type: 'submit' }, i18n.connectAndFollow)
)
)
)
) : null
);
};
exports.invitesView = invitesView;
exports.renderSuggestedPub = renderSuggestedPub;