Upgrade mobile UX to Oasis 0.9.0

Rebase onto upstream 0.9.0. Mobile topbar (logo->home, avatar->profile),
hexagon category nav (hive) and bottom bar; remove the six top-bar
buttons (inbox/pm/publish/search/graphos/peers) that cluttered the header.
Theme OasisMobileUX carries topbar/hexagon/bottombar styles.
This commit is contained in:
s1to 2026-08-01 13:41:09 +02:00
parent de36b38851
commit 8d21216362
69 changed files with 8974 additions and 820 deletions

View file

@ -23,7 +23,7 @@ const getUserId = async () => {
return userId;
};
const { a, article, br, body, button, details, div, em, footer, form, h1, h2, h3, head, header, hr, html, img, input, label, li, link, main, meta, nav, option, p, pre, section, select, span, summary, table, td, textarea, title, tr, ul, strong, script, video: videoHyperaxe, audio: audioHyperaxe } = require("../server/node_modules/hyperaxe");
const { a, article, br, body, button, details, div, em, footer, form, h1, h2, h3, head, header, hr, html, img, input, label, li, link, main, meta, nav, option, p, pre, section, select, span, summary, table, td, textarea, title, tr, ul, strong, video: videoHyperaxe, audio: audioHyperaxe } = require("../server/node_modules/hyperaxe");
const lodash = require("../server/node_modules/lodash");
const markdown = require("./markdown");
@ -112,67 +112,6 @@ const renderContentActions = (msgId, viewHref) => {
};
exports.renderContentActions = renderContentActions;
const emptyState = ({ icon, title, text, ctaHref, ctaLabel }) => div(
{ class: "empty-state" },
icon ? span({ class: "empty-state-icon" }, icon) : "",
title ? p({ class: "empty-state-title" }, title) : "",
text ? p({ class: "empty-state-text" }, text) : "",
(ctaHref && ctaLabel) ? a({ class: "empty-state-cta filter-btn", href: ctaHref }, ctaLabel) : ""
);
exports.emptyState = emptyState;
exports.bottomBarPickerView = () => {
const cfg = getConfig();
const modOn = (m) => !m || cfg.modules[m] === 'on' || cfg.modules[m] === undefined;
const MAX = 4;
const pinnedKeys = cfg.bottomBarPins || [];
const pinned = pinnedKeys
.map(key => exports.PINNABLE_MODULES.find(m => m.key === key))
.filter(Boolean);
const full = pinned.length >= MAX;
const actionForm = (action, key, cls, node) => form({ method: 'POST', action: '/settings/bottombar', class: cls },
input({ type: 'hidden', name: 'action', value: action }),
input({ type: 'hidden', name: 'key', value: key }),
node
);
const pinnedRow = (m) => actionForm('remove', m.key, 'bb-manage-form',
button({ type: 'submit', class: 'bb-manage-btn', 'aria-label': i18n.bbRemove || 'Remove' },
span({ class: 'bb-manage-ico' }, m.glyph),
span({ class: 'bb-manage-lbl' }, i18n[m.labelKey] || m.key),
span({ class: 'bb-manage-x' }, '✕')
)
);
const addBtn = (m) => actionForm('add', m.key, 'bb-pick-form',
button({ type: 'submit', class: 'bb-pick-btn' },
span({ class: 'bb-pick-ico' }, m.glyph),
span({ class: 'bb-pick-lbl' }, i18n[m.labelKey] || m.key)
)
);
const addable = exports.PINNABLE_MODULES.filter(m => modOn(m.mod) && !pinnedKeys.includes(m.key));
return template(
i18n.bbManage || 'Edit bar',
section(
div({ class: 'tags-header' },
h2(i18n.bbPickTitle || 'Customize the bar'),
p(i18n.bbPickDesc || 'Choose up to 4 shortcuts for the bottom bar.')
),
div({ class: 'bb-manage' },
h3(`${i18n.bbYourBar || 'Your bar'} (${pinned.length}/${MAX})`),
pinned.length
? div({ class: 'bb-manage-list' }, ...pinned.map(pinnedRow))
: p({ class: 'bb-manage-empty' }, i18n.bbPickNone || 'None')
),
div({ class: 'bb-manage-add' },
h3(i18n.bbAdd || 'Add'),
full
? p({ class: 'bb-manage-full' }, i18n.bbFull || 'Maximum reached')
: div({ class: 'bb-pick-grid' }, ...addable.map(addBtn))
),
a({ class: 'filter-btn bb-manage-done', href: '/activity' }, i18n.bbDone || 'Done')
)
);
};
exports.renderStateChip = renderStateChip;
exports.renderOpenClosedChip = renderOpenClosedChip;
exports.renderVisibilityChip = renderVisibilityChip;
@ -575,6 +514,21 @@ const renderWalletLink = () => {
return "";
};
const renderDevLink = () => {
const devMod = getConfig().modules.devMod === "on";
if (devMod) {
return [
navLink({
href: "/dev",
emoji: "⌘",
text: i18n.developer,
class: "dev-link enabled"
})
];
}
return "";
};
const renderLegacyLink = () => {
const legacyMod = getConfig().modules.legacyMod === "on";
if (legacyMod) {
@ -826,6 +780,19 @@ const renderProjectsLink = () => {
: "";
};
const renderIndustryLink = () => {
const industryMod = getConfig().modules.industryMod === "on";
return industryMod
? [
navLink({
href: "/industry",
emoji: "ꖴ",
text: i18n.industryTitle
})
]
: "";
};
const renderBankingLink = () => {
const bankingMod = getConfig().modules.bankingMod === "on";
return bankingMod
@ -1134,6 +1101,8 @@ const renderTasksLink = () => {
: "";
};
// === Oasis UX (portado de 0.8.7_UX): topbar + bottombar + hive ===
const renderMobileTopbar = () => div(
{ class: "oasis-mobile-topbar" },
a({ class: "omt-logo", href: "/" }, img({ src: "/assets/images/snh-oasis.jpg", alt: "Oasis" })),
@ -1223,16 +1192,6 @@ const renderBottomBar = () => {
);
};
const RADIAL_ITEMS = [
{ key: 'search', href: '/search', glyph: 'ꔅ', labelKey: 'searchTitle', mod: null },
{ key: 'chats', href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' },
{ key: 'tribes', href: '/tribes', glyph: 'ꖥ', labelKey: 'tribesTitle', mod: 'tribesMod' },
{ key: 'market', href: '/market', glyph: 'ꕻ', labelKey: 'marketTitle', mod: 'marketMod' },
{ key: 'events', href: '/events', glyph: 'ꕆ', labelKey: 'eventsLabel', mod: 'eventsMod' },
{ key: 'forum', href: '/forum', glyph: 'ꕒ', labelKey: 'forumTitle', mod: 'forumMod' },
{ key: 'agenda', href: '/agenda', glyph: 'ꗤ', labelKey: 'agendaTitle', mod: 'agendaMod' },
];
const renderHiveNav = () => {
if (process.env.OASIS_MOBILE !== '1') return "";
const cfg = getConfig();
@ -1299,14 +1258,9 @@ const template = (titlePrefix, ...elements) => {
const currentConfig = getConfig();
const theme = currentConfig.themes.current || "Dark-SNH";
const uxMode = currentConfig.ux?.current === "ainav" ? "ainav" : "blocks";
const baseTheme = theme === "OasisMobileUX" ? "Dark-SNH" : theme;
const mobileUXActive = (process.env.OASIS_MOBILE === '1') || (theme === "OasisMobileUX");
const mobileUXLink = mobileUXActive
? link({ rel: "stylesheet", href: "/assets/themes/OasisMobileUX.css", ...(process.env.OASIS_MOBILE === '1' ? {} : { media: "(max-width: 768px)" }) })
: "";
const themeLink = link({
rel: "stylesheet",
href: `/assets/themes/${baseTheme}.css`
href: `/assets/themes/${theme}.css`
});
const nodes = html(
{ lang: "en" },
@ -1315,7 +1269,6 @@ const template = (titlePrefix, ...elements) => {
link({ rel: "stylesheet", href: "/assets/styles/style.css" }),
themeLink,
link({ rel: "stylesheet", href: "/assets/styles/mobile.css", ...(process.env.OASIS_MOBILE === '1' ? {} : { media: "(max-width: 768px)" }) }),
mobileUXLink,
link({ rel: "icon", href: "/assets/images/favicon.svg" }),
meta({ charset: "utf-8" }),
meta({ name: "description", content: i18n.oasisDescription }),
@ -1323,8 +1276,7 @@ const template = (titlePrefix, ...elements) => {
name: "viewport",
content: toAttributes({
width: "device-width",
"initial-scale": 1,
"viewport-fit": "cover"
"initial-scale": 1
})
})
),
@ -1334,50 +1286,6 @@ const template = (titlePrefix, ...elements) => {
div(
{ class: uxMode === "ainav" ? "header ainav-only" : "header" },
renderMobileTopbar(),
div(
{ class: "top-bar-left" },
a(
{ class: "logo-icon", href: "/" },
img({
class: "logo-icon",
src: "/assets/images/snh-oasis.jpg",
alt: "Oasis Logo"
})
),
uxMode === "ainav" ? nav(
ul(
(() => {
const inboxCount = sharedState.getInboxCount();
const badge = inboxCount > 0 ? span({ class: 'inbox-badge' }, String(inboxCount)) : '';
return li(
a({ href: "/inbox" },
span({ class: "emoji" }, "☂"), nbsp, i18n.inbox, badge
)
);
})(),
navLink({ href: "/settings", emoji: "⚙", text: i18n.settings }),
navLink({ href: "/invites", emoji: "ꔮ", text: i18n.invites })
)
) : nav(
ul(
(() => {
const inboxCount = sharedState.getInboxCount();
const badge = inboxCount > 0 ? span({ class: 'inbox-badge' }, String(inboxCount)) : '';
return li(
a({ href: "/inbox" },
span({ class: "emoji" }, "☂"), nbsp, i18n.inbox, badge
)
);
})(),
navLink({
href: "/pm",
emoji: "ꕕ",
text: i18n.privateMessage
}),
navLink({ href: "/publish", emoji: "❂", text: i18n.publish })
)
)
),
div(
{ class: "oasis-mobile-menus" },
label({ for: "nav-left-toggle", class: "oasis-menu-btn" },
@ -1404,26 +1312,7 @@ const template = (titlePrefix, ...elements) => {
button({ type: 'submit', class: 'ai-ask-btn' }, '➤')
)
);
})(),
uxMode === "ainav" ? div(
{ class: "top-bar-right" },
nav(
ul(
navLink({ href: "/activity", emoji: "ꔙ", text: i18n.activityTitle }),
navLink({ href: "/graphos", emoji: "ꕢ", text: i18n.graphos }),
navLink({ href: "/peers", emoji: "⧖", text: i18n.peers })
)
)
) : div(
{ class: "top-bar-right" },
nav(
ul(
navLink({ href: "/search", emoji: "ꔅ", text: i18n.searchTitle }),
renderGraphosLink(),
navLink({ href: "/peers", emoji: "⧖", text: i18n.peers })
)
)
)
})()
),
(() => {
const updateFlagPath = path.join(__dirname, '../server/.update_required');
@ -1437,6 +1326,24 @@ const template = (titlePrefix, ...elements) => {
}
return null;
})(),
(() => {
try {
const onboarding = require('../models/onboarding_model');
if (!onboarding.bannerVisible(config && config.path)) return null;
return div(
{ class: "update-banner welcome-banner" },
span({ class: "update-banner-icon" }, "🌴"),
span({ class: "update-banner-text" }, i18n.welcomeBannerText),
a({ href: "/welcome", class: "update-banner-link" }, i18n.welcomeBannerAction),
form(
{ method: "POST", action: "/welcome/dismiss", class: "welcome-banner-close" },
button({ type: "submit", class: "welcome-banner-close-btn" }, "✕")
)
);
} catch (_) {
return null;
}
})(),
div(
{ class: uxMode === "ainav" ? "main-content ainav-only" : "main-content" },
uxMode === "ainav" ? null : div(
@ -1511,6 +1418,7 @@ const template = (titlePrefix, ...elements) => {
renderBankingLink(),
renderMarketLink(),
renderProjectsLink(),
renderIndustryLink(),
renderJobsLink(),
renderShopsLink(),
renderTransfersLink()
@ -1527,6 +1435,7 @@ const template = (titlePrefix, ...elements) => {
emoji: "ꖸ",
text: i18n.blockchain
}),
renderDevLink(),
renderCipherLink(),
renderInvitesLink(),
renderLegacyLink(),
@ -1618,7 +1527,6 @@ const template = (titlePrefix, ...elements) => {
)
),
renderBottomBar(),
script({ src: "/js/oasis-mobile-nav.js", defer: "defer" }),
renderFooter()
)
);
@ -1628,23 +1536,69 @@ const template = (titlePrefix, ...elements) => {
exports.template = template;
exports.bottomBarPickerView = () => {
const cfg = getConfig();
const modOn = (m) => !m || cfg.modules[m] === 'on' || cfg.modules[m] === undefined;
const MAX = 4;
const pinnedKeys = cfg.bottomBarPins || [];
const pinned = pinnedKeys
.map(key => exports.PINNABLE_MODULES.find(m => m.key === key))
.filter(Boolean);
const full = pinned.length >= MAX;
const actionForm = (action, key, cls, node) => form({ method: 'POST', action: '/settings/bottombar', class: cls },
input({ type: 'hidden', name: 'action', value: action }),
input({ type: 'hidden', name: 'key', value: key }),
node
);
const pinnedRow = (m) => actionForm('remove', m.key, 'bb-manage-form',
button({ type: 'submit', class: 'bb-manage-btn', 'aria-label': i18n.bbRemove || 'Remove' },
span({ class: 'bb-manage-ico' }, m.glyph),
span({ class: 'bb-manage-lbl' }, i18n[m.labelKey] || m.key),
span({ class: 'bb-manage-x' }, '✕')
)
);
const addBtn = (m) => actionForm('add', m.key, 'bb-pick-form',
button({ type: 'submit', class: 'bb-pick-btn' },
span({ class: 'bb-pick-ico' }, m.glyph),
span({ class: 'bb-pick-lbl' }, i18n[m.labelKey] || m.key)
)
);
const addable = exports.PINNABLE_MODULES.filter(m => modOn(m.mod) && !pinnedKeys.includes(m.key));
return template(
i18n.bbManage || 'Edit bar',
section(
div({ class: 'tags-header' },
h2(i18n.bbPickTitle || 'Customize the bar'),
p(i18n.bbPickDesc || 'Choose up to 4 shortcuts for the bottom bar.')
),
div({ class: 'bb-manage' },
h3(`${i18n.bbYourBar || 'Your bar'} (${pinned.length}/${MAX})`),
pinned.length
? div({ class: 'bb-manage-list' }, ...pinned.map(pinnedRow))
: p({ class: 'bb-manage-empty' }, i18n.bbPickNone || 'None')
),
div({ class: 'bb-manage-add' },
h3(i18n.bbAdd || 'Add'),
full
? p({ class: 'bb-manage-full' }, i18n.bbFull || 'Maximum reached')
: div({ class: 'bb-pick-grid' }, ...addable.map(addBtn))
),
a({ class: 'filter-btn bb-manage-done', href: '/activity' }, i18n.bbDone || 'Done')
)
);
};
exports.ainavHomeView = ({ recentTags = [] } = {}) => {
const currentConfig = getConfig();
const theme = currentConfig.themes.current || "Dark-SNH";
const baseTheme = theme === "OasisMobileUX" ? "Dark-SNH" : theme;
const mobileUXActive = (process.env.OASIS_MOBILE === '1') || (theme === "OasisMobileUX");
const mobileUXLink = mobileUXActive
? link({ rel: "stylesheet", href: "/assets/themes/OasisMobileUX.css", ...(process.env.OASIS_MOBILE === '1' ? {} : { media: "(max-width: 768px)" }) })
: "";
const placeholder = i18n.aiNavPlaceholder || 'Where do you want to go?';
const nodes = html(
{ lang: "en" },
head(
title(placeholder, " | Oasis"),
link({ rel: "stylesheet", href: "/assets/styles/style.css" }),
link({ rel: "stylesheet", href: `/assets/themes/${baseTheme}.css` }),
link({ rel: "stylesheet", href: `/assets/themes/${theme}.css` }),
link({ rel: "stylesheet", href: "/assets/styles/mobile.css", ...(process.env.OASIS_MOBILE === '1' ? {} : { media: "(max-width: 768px)" }) }),
mobileUXLink,
link({ rel: "icon", href: "/assets/images/favicon.svg" }),
meta({ charset: "utf-8" }),
meta({ name: "description", content: i18n.oasisDescription }),
@ -3156,7 +3110,12 @@ exports.mentionsView = ({ messages, myFeedId }) => {
);
};
exports.privateView = async (messagesInput, filter, decrypted = null) => {
exports.privateView = async (messagesInput, filter, decrypted = null, notice = '') => {
const noticeText = notice === 'unavailable'
? (i18n.fileShareUnavailable || 'This file is not available right now. Try again later.')
: notice === 'badkey'
? (i18n.pmCrypterBadKey || 'Your shared key is incorrect!')
: ''
const messagesRaw = Array.isArray(messagesInput) ? messagesInput : messagesInput.messages
const messages = (messagesRaw || []).filter(m => m && m.key && m.value && m.value.content && m.value.content.type === 'post' && m.value.content.private === true)
const userId = await getUserId()
@ -3169,7 +3128,8 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
const hrefFor = {
job: (id) => `/jobs/${encodeURIComponent(id)}`,
project: (id) => `/projects/${encodeURIComponent(id)}`,
market: (id) => `/market/${encodeURIComponent(id)}`
market: (id) => `/market/${encodeURIComponent(id)}`,
shopProduct: (id) => `/shops/product/${encodeURIComponent(id)}`
}
const clickableCardProps = (href, extraClass = '') => {
@ -3184,6 +3144,15 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
const chip = (txt) => span({ class: 'chip' }, txt)
const fmtBytes = (n) => {
const b = Number(n) || 0
if (b < 1024) return `${b} B`
const units = ['KB', 'MB', 'GB', 'TB']
let v = b / 1024, i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`
}
const e2eChip = () => span({ class: 'pm-exposition-chip pm-exposition-encrypted' },
span({ class: 'pm-exposition-icon' }, '🔒'),
span({ class: 'pm-exposition-text' }, i18n.encryptedChipLabel || 'E2E')
@ -3229,17 +3198,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
try { return Buffer.byteLength(JSON.stringify(m && m.value), 'utf8'); } catch (_) { return 0; }
}
function bubbleMeta({ sentAt, subjectRaw, crypter, msgSize, msgKey }) {
const ecoChip = msgSize ? renderEcoTax(msgSize, msgKey) : null
return div({ class: 'pm-bubble-meta' },
subjectRaw ? span({ class: 'pm-bubble-subject' }, subjectRaw) : null,
span({ class: 'pm-bubble-time' }, moment(sentAt).format('DD/MM HH:mm')),
span({ class: 'pm-bubble-lock', title: crypter ? '2xE2E' : 'E2E' }, crypter ? '🔒🔒' : '🔒'),
ecoChip ? span({ class: 'pm-bubble-eco' }, ecoChip) : null
)
}
function actions({ key, replyId, subjectRaw, text }) {
function actions({ key, replyId, subjectRaw, text, extra = null }) {
const stop = { onclick: 'event.stopPropagation()' }
const subjectReply = /^(\s*RE:\s*)/i.test(subjectRaw || '') ? (subjectRaw || '') : `RE: ${subjectRaw || ''}`
const isSelf = replyId === userId
@ -3250,6 +3209,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
input({ type: 'hidden', name: 'quote', value: text || '' }),
button({ type: 'submit', class: 'pm-btn reply-btn' }, i18n.pmReply.toUpperCase())
),
extra || null,
form({ method: 'POST', action: `/inbox/delete/${encodeURIComponent(key)}`, class: 'pm-action-form', ...stop },
button({ type: 'submit', class: 'pm-btn delete-btn danger-btn' }, i18n.privateDelete.toUpperCase())
)
@ -3293,6 +3253,10 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
const m = str.match(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/)
return m ? m[1] : ''
}
if (kind === 'shopProduct') {
const m = str.match(/\/shops\/product\/([%A-Za-z0-9/+._=-]+\.sha256)/)
return m ? m[1] : ''
}
return ''
}
@ -3315,15 +3279,27 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
}
}
flushQuote()
return parts.join('<br>')
const mdLinks = []
const masked = parts.join('<br>')
.replace(/\[([^\]\n]{1,120})\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)/g, (match, label, href) => {
const idx = mdLinks.length
const safeHref = String(href).replace(/"/g, '%22')
const ext = /^https?:/.test(href) ? ' target="_blank" rel="noopener noreferrer"' : ''
mdLinks.push(`<a class="oasis-md-link" href="${safeHref}"${ext}>${label}</a>`)
return `\u0000MD${idx}\u0000`
})
return masked
.replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g, (match, id) => `<a class="user-link" href="/author/${encodeURIComponent(id)}">${userLinkLabel(id)}</a>`)
.replace(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="job-link" href="${hrefFor.job(id)}">${match}</a>`)
.replace(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="project-link" href="${hrefFor.project(id)}">${match}</a>`)
.replace(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="market-link" href="${hrefFor.market(id)}">${match}</a>`)
.replace(/\/calendars\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="calendar-link" href="/calendars/${encodeURIComponent(id)}">${match}</a>`)
.replace(/\/ai\/ask\?[^\s<"]+/g, (match) => `<a class="ai-ask-link" href="${match}">${match}</a>`)
.replace(/(?<![A-Za-z0-9_])\/(profile|inbox|invites|peers|tribes|inhabitants|publish|activity|settings|modules|banking|larp|melody|audios|videos|images|documents|bookmarks|torrents|forum|feed|fediverse|events|tasks|votes|reports|market|jobs|projects|shops|pixelia|opinions|trending|agenda|cv|favorites|stats|blockexplorer|wallet|chats|pads|maps|calendars|ai|games)(?![A-Za-z0-9_\/])/g, (match) => `<a class="oasis-path-link" href="${match}">${match}</a>`)
.replace(/\/tribe\/([%A-Za-z0-9/+._=-]+\.sha256)(\?section=[a-zA-Z]+)?/g, (match) => `<a class="tribe-link" href="${match}">${match}</a>`)
.replace(/\/larp\/([a-zA-Z]+)/g, (match) => `<a class="larp-link" href="${match}">${match}</a>`)
.replace(/(?<![A-Za-z0-9_])\/(profile|inbox|invites|peers|tribes|inhabitants|publish|activity|settings|modules|banking|larp|parliament|courts|melody|audios|videos|images|documents|bookmarks|torrents|forum|feed|fediverse|events|tasks|votes|reports|market|jobs|projects|industry|shops|pixelia|opinions|trending|agenda|cv|favorites|stats|blockexplorer|wallet|chats|pads|maps|calendars|ai|games|search)(?![A-Za-z0-9_\/])/g, (match) => `<a class="oasis-path-link" href="${match}">${match}</a>`)
.replace(/(https?:\/\/[^\s<"]+)/g, (match) => `<a href="${match}" target="_blank" rel="noopener noreferrer">${match}</a>`)
.replace(/\u0000MD(\d+)\u0000/g, (m, i) => mdLinks[Number(i)] !== undefined ? mdLinks[Number(i)] : m)
}
const threads = {}
@ -3374,7 +3350,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
const href = jobId ? hrefFor.job(jobId) : null
return div(
clickableCardProps(href, `job-notification thread-level-0`),
headerLine({ sentAt, from, toLinks, subject: type, msgKey: key, msgSize }),
headerLine({ sentAt, from, toLinks, subject: titleH, msgKey: key, msgSize }),
h2({ class: 'pm-title' }, `${icon} ${i18n.pmBotJobs} · ${titleH}`),
p(
i18n.pmInhabitantWithId, ' ',
@ -3398,7 +3374,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
const href = projectId ? hrefFor.project(projectId) : null
return div(
clickableCardProps(href, `project-${isFollow ? 'follow' : 'unfollow'}-notification thread-level-0`),
headerLine({ sentAt, from, toLinks, subject: type, msgKey: key, msgSize }),
headerLine({ sentAt, from, toLinks, subject: titleH, msgKey: key, msgSize }),
h2({ class: 'pm-title' }, `${icon} ${i18n.pmBotProjects} · ${titleH}`),
p(
i18n.pmInhabitantWithId, ' ',
@ -3412,23 +3388,28 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
)
}
function MarketSoldCard({ sentAt, from, toLinks, subject, text, key, msgSize }) {
function MarketSoldCard({ sentAt, from, toLinks, subject, text, key, msgSize, variant = 'market' }) {
const isShop = variant === 'shop'
const itemTitle = quoted(subject) || quoted(text) || 'item'
const buyerId = (text.match(/OASIS ID:\s*([\w=/+.-]+)/) || [])[1] || from
const price = (text.match(/for:\s*\$([\d.]+)/) || [])[1] || ''
const price = (text.match(/for:\s*\$?([\d.]+)/) || [])[1] || ''
const marketId = pickLink(text, 'market')
const href = marketId ? hrefFor.market(marketId) : null
const shopProductId = pickLink(text, 'shopProduct')
const href = isShop ? (shopProductId ? hrefFor.shopProduct(shopProductId) : null) : (marketId ? hrefFor.market(marketId) : null)
const icon = isShop ? '🛍️' : '💰'
const botLabel = isShop ? i18n.pmBotShops : i18n.pmBotMarket
const soldTitle = isShop ? (i18n.inboxShopSoldTitle || 'Product Sold') : i18n.inboxMarketItemSoldTitle
return div(
clickableCardProps(href, 'market-sold-notification thread-level-0'),
headerLine({ sentAt, from, toLinks, subject, msgKey: key, msgSize }),
h2({ class: 'pm-title' }, `💰 ${i18n.pmBotMarket} · ${i18n.inboxMarketItemSoldTitle}`),
clickableCardProps(href, (isShop ? 'shop-sold-notification' : 'market-sold-notification') + ' thread-level-0'),
headerLine({ sentAt, from, toLinks, subject: soldTitle, msgKey: key, msgSize }),
h2({ class: 'pm-title' }, `${icon} ${botLabel} · ${soldTitle}`),
p(
i18n.pmYourItem, ' ',
href ? a({ class: 'market-link', href }, `"${itemTitle}"`) : `"${itemTitle}"`,
href ? a({ class: isShop ? 'shop-link' : 'market-link', href }, `"${itemTitle}"`) : `"${itemTitle}"`,
' ',
i18n.pmHasBeenSoldTo, ' ',
linkAuthor(buyerId),
price ? ` ${i18n.pmFor} $${price}.` : '.'
price ? ` ${i18n.pmFor} ${price} ECO.` : '.'
),
actions({ key, replyId: buyerId, subjectRaw: itemTitle, text })
)
@ -3441,7 +3422,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
const href = projectId ? hrefFor.project(projectId) : null
return div(
clickableCardProps(href, 'project-pledge-notification thread-level-0'),
headerLine({ sentAt, from, toLinks, subject: 'PROJECT_PLEDGE', msgKey: key, msgSize }),
headerLine({ sentAt, from, toLinks, subject: i18n.inboxProjectPledgedTitle, msgKey: key, msgSize }),
h2({ class: 'pm-title' }, `💚 ${i18n.pmBotProjects} · ${i18n.inboxProjectPledgedTitle}`),
p(
i18n.pmInhabitantWithId, ' ',
@ -3455,6 +3436,41 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
)
}
function PoliticalBotCard({ subjectU, sentAt, from, toLinks, text, key, msgSize }) {
const titleMap = {
LARP_RULING: i18n.politicalBotLarpTitle || 'The ruling house of the L.A.R.P has changed.',
PARLIAMENT_GOV: i18n.politicalBotParliamentTitle || 'A new government cycle has begun in the Parliament.',
TRIBE_GOV: i18n.politicalBotTribeTitle || 'A new government cycle has begun in one of your Tribes.'
}
const title = titleMap[subjectU] || (i18n.pmBotPolitical || 'PoliticalBot')
return div(
{ class: 'pm-card political-bot-notification thread-level-0' },
headerLine({ sentAt, from, toLinks, subject: title, msgKey: key, msgSize }),
h2({ class: 'pm-title' }, `🏛️ ${i18n.pmBotPolitical} · ${title}`),
div({ class: 'message-text', innerHTML: sanitizeHtml(clickableLinks(text || '')) }),
actions({ key, replyId: from, subjectRaw: title, text })
)
}
function IndustryBotCard({ subjectU, sentAt, from, toLinks, text, key, msgSize }) {
const titleMap = {
INDUSTRY_ADMITTED: i18n.industryBotAdmittedTitle || 'You have been admitted to a facility.',
INDUSTRY_APPLICATION: i18n.industryBotApplicationTitle || 'New membership application in your facility.',
INDUSTRY_INVITED: i18n.industryBotInvitedTitle || 'You have been invited to a facility.',
INDUSTRY_DISSOLVED: i18n.industryBotDissolvedTitle || 'A facility you belong to has been dissolved.',
INDUSTRY_BUILD_APPROVED: i18n.industryBotBuildApprovedTitle || 'A build has been approved.',
INDUSTRY_DISTRIBUTED: i18n.industryBotDistributedTitle || 'A build output has been distributed.'
}
const title = titleMap[subjectU] || (i18n.pmBotIndustry || 'IndustryBot')
return div(
{ class: 'pm-card industry-bot-notification thread-level-0' },
headerLine({ sentAt, from, toLinks, subject: title, msgKey: key, msgSize }),
h2({ class: 'pm-title' }, `🏭 ${i18n.pmBotIndustry || 'IndustryBot'} · ${title}`),
div({ class: 'message-text', innerHTML: sanitizeHtml(clickableLinks(text || '')) }),
actions({ key, replyId: from, subjectRaw: title, text })
)
}
const { renderEncryptedChip } = require('./clearnet_view');
return template(
i18n.private,
@ -3463,6 +3479,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
div({ class: 'title-with-chip' }, h2(i18n.private), renderEncryptedChip(i18n)),
p(i18n.privateDescription)
),
noticeText ? div({ class: 'pm-form-error-msg' }, p('✗ ' + noticeText)) : null,
(() => {
const pmVis = getConfig().pmVisibility === 'mutuals' ? 'mutuals' : 'whole'
const pmVisLabel = pmVis === 'mutuals' ? i18n.settingsPmVisibilityMutuals : i18n.settingsPmVisibilityWhole
@ -3526,7 +3543,35 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
const toLinks = Array.isArray(content.to) ? content.to.map(addr => linkAuthor(addr)) : []
const level = threadLevel(subjectRaw)
const msgSize = msgSizeBytes(msg)
const isMob = process.env.OASIS_MOBILE === '1'
if (content.fileShare && typeof content.fileShare === 'object') {
const fsp = content.fileShare
const dblKey = (decrypted && decrypted.key === msg.key) ? decrypted : null
return div(
{ class: 'pm-card normal-pm pm-fileshare-card' },
headerLine({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, msgKey: msg.key, msgSize, crypter: !!fsp.crypter }),
div({ class: 'pm-fileshare-info' },
span({ class: 'pm-fileshare-icon' }, '📎'),
span({ class: 'pm-fileshare-name' }, String(fsp.filename || 'file')),
span({ class: 'pm-fileshare-meta' }, `${fmtBytes(fsp.size)} · ${String(fsp.mime || 'application/octet-stream')}`)
),
fsp.crypter
? form({ method: 'GET', action: `/inbox/file/${encodeURIComponent(msg.key)}`, class: 'pm-fileshare-download-form' },
dblKey && dblKey.error ? div({ class: 'pm-form-error-msg' }, p('✗ ' + (i18n.pmCrypterBadKey || 'Your shared key is incorrect!'))) : null,
input({ type: 'text', name: 'key', placeholder: 'd66d7d32f4d30f34812aee3d01347154', minlength: 32, maxlength: 32, size: 34, required: true, class: 'pm-crypter-key-input' }),
button({ type: 'submit', class: 'pm-btn pm-fileshare-download' }, (i18n.fileShareDownload || 'Download').toUpperCase())
)
: null,
actions({
key: msg.key, replyId: fromResolved, subjectRaw, text: '',
extra: fsp.crypter
? null
: form({ method: 'GET', action: `/inbox/file/${encodeURIComponent(msg.key)}`, class: 'pm-action-form', onclick: 'event.stopPropagation()' },
button({ type: 'submit', class: 'pm-btn pm-fileshare-download' }, (i18n.fileShareDownload || 'Download').toUpperCase())
)
})
)
}
if (subjectU === 'JOB_SUBSCRIBED' || subjectU === 'JOB_UNSUBSCRIBED') {
return JobCard({ type: subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key, msgSize })
@ -3535,7 +3580,16 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
return ProjectFollowCard({ type: subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key, msgSize })
}
if (subjectU === 'MARKET_SOLD') {
return MarketSoldCard({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, text, key: msg.key, msgSize })
return MarketSoldCard({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, text, key: msg.key, msgSize, variant: 'market' })
}
if (subjectU === 'SHOP_SOLD') {
return MarketSoldCard({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, text, key: msg.key, msgSize, variant: 'shop' })
}
if (subjectU === 'LARP_RULING' || subjectU === 'PARLIAMENT_GOV' || subjectU === 'TRIBE_GOV') {
return PoliticalBotCard({ subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key, msgSize })
}
if (subjectU === 'INDUSTRY_ADMITTED' || subjectU === 'INDUSTRY_APPLICATION' || subjectU === 'INDUSTRY_INVITED' || subjectU === 'INDUSTRY_DISSOLVED' || subjectU === 'INDUSTRY_BUILD_APPROVED' || subjectU === 'INDUSTRY_DISTRIBUTED') {
return IndustryBotCard({ subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key, msgSize })
}
if (subjectU === 'PROJECT_PLEDGE' || content.meta?.type === 'project-pledge') {
return ProjectPledgeCard({ sentAt, from: fromResolved, toLinks, content, text, key: msg.key, msgSize })
@ -3544,48 +3598,23 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
if (content.crypter === true) {
const dec = (decrypted && decrypted.key === msg.key) ? decrypted : null
const shownText = dec && typeof dec.text === 'string' ? dec.text : ''
const cbody = dec && typeof dec.text === 'string'
? div({ class: 'message-text', innerHTML: sanitizeHtml(clickableLinks(dec.text)) })
: dec && dec.error
? div({ class: 'pm-form-error-msg' }, p('✗ ' + i18n.pmCrypterBadKey))
: null
const cform = dec && typeof dec.text === 'string'
? null
: form({ method: 'POST', action: '/inbox/decrypt', class: 'pm-crypter-decrypt-form' },
input({ type: 'hidden', name: 'id', value: msg.key }),
input({ type: 'hidden', name: 'returnFilter', value: filter || 'all' }),
input({ type: 'text', name: 'key', placeholder: 'd66d7d32f4d30f34812aee3d01347154', minlength: 32, maxlength: 32, size: 34, required: true, class: 'pm-crypter-key-input' }),
button({ type: 'submit', class: 'pm-btn pm-crypter-decrypt-btn' }, (i18n.pmCrypterDecryptButton || 'Decrypt').toUpperCase())
)
const cacts = actions({ key: msg.key, replyId: fromResolved, subjectRaw, text: shownText })
if (isMob) {
const mine = isSent(msg)
return div(
{ class: `pm-card normal-pm pm-crypter-card pm-bubble ${mine ? 'pm-sent' : 'pm-recv'} thread-level-${level}` },
mine ? null : div({ class: 'pm-bubble-sender' }, linkAuthor(fromResolved)),
cbody,
cform,
bubbleMeta({ sentAt, subjectRaw, crypter: true, msgSize, msgKey: msg.key }),
cacts
)
}
return div(
{ class: 'pm-card normal-pm pm-crypter-card' },
headerLine({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, msgKey: msg.key, msgSize, crypter: true }),
cbody,
cform,
cacts
)
}
if (isMob) {
const mine = isSent(msg)
return div(
{ class: `pm-card normal-pm pm-bubble ${mine ? 'pm-sent' : 'pm-recv'} thread-level-${level}` },
mine ? null : div({ class: 'pm-bubble-sender' }, linkAuthor(fromResolved)),
div({ class: 'message-text', innerHTML: sanitizeHtml(clickableLinks(text)) }),
bubbleMeta({ sentAt, subjectRaw, crypter: false, msgSize, msgKey: msg.key }),
actions({ key: msg.key, replyId: fromResolved, subjectRaw, text })
dec && typeof dec.text === 'string'
? div({ class: 'message-text', innerHTML: sanitizeHtml(clickableLinks(dec.text)) })
: dec && dec.error
? div({ class: 'pm-form-error-msg' }, p('✗ ' + i18n.pmCrypterBadKey))
: null,
dec && typeof dec.text === 'string'
? null
: form({ method: 'POST', action: '/inbox/decrypt', class: 'pm-crypter-decrypt-form' },
input({ type: 'hidden', name: 'id', value: msg.key }),
input({ type: 'hidden', name: 'returnFilter', value: filter || 'all' }),
input({ type: 'text', name: 'key', placeholder: 'd66d7d32f4d30f34812aee3d01347154', minlength: 32, maxlength: 32, size: 34, required: true, class: 'pm-crypter-key-input' }),
button({ type: 'submit', class: 'pm-btn pm-crypter-decrypt-btn' }, (i18n.pmCrypterDecryptButton || 'Decrypt').toUpperCase())
),
actions({ key: msg.key, replyId: fromResolved, subjectRaw, text: shownText })
)
}
@ -3608,7 +3637,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
threadGroups[tid].push(msg)
}
if (!threadOrder.length) return emptyState({ icon: "✉", title: i18n.noPrivateMessages, text: i18n.emptyConnectHint, ctaHref: "/invites", ctaLabel: i18n.emptyConnectCta })
if (!threadOrder.length) return p({ class: 'empty' }, i18n.noPrivateMessages)
return threadOrder.map(tid => {
const msgs = threadGroups[tid]
@ -3741,7 +3770,7 @@ exports.publishView = (preview, text, contentWarning) => {
cols: "50",
placeholder: i18n.publishWarningPlaceholder,
class: "publish-textarea",
maxlength: "8096"
maxlength: "7000"
},
text || ""
),