diff --git a/src/backend/backend.js b/src/backend/backend.js index 02649d1..6b01222 100644 --- a/src/backend/backend.js +++ b/src/backend/backend.js @@ -3832,7 +3832,7 @@ router try { const oi = await forumModel.getOpenInvite(forumId).catch(() => null); if (oi && forumObj) forumObj.openInviteCode = oi.code; } catch (_) {} const forumMsgs = await forumModel.getMessagesByForumId(forumId); await warmAuthorNames(forumObj, forumMsgs); - ctx.body = await singleForumView(forumObj, forumMsgs, ctx.query.filter, isReply ? ctx.params.forumId : null, { spreads: spreadInfo }); + ctx.body = await singleForumView(forumObj, forumMsgs, ctx.query.filter, ctx.query.topic || ''); }) .get('/welcome', async (ctx) => { const lang = ctx.cookies.get('language') || getConfig().language || 'en'; @@ -4601,7 +4601,7 @@ router .get("/chats/:chatId", async (ctx) => { if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } await chatsModel.ingestKeys().catch(() => {}); - const { filter = 'all', q = '' } = ctx.query; + const { filter = 'all', q = '', replyTo = '' } = ctx.query; const uid = getViewerId(); let chat = await chatsModel.getChatById(ctx.params.chatId); if (!chat) { ctx.redirect('/chats'); return; } @@ -4621,7 +4621,8 @@ router const fav = await mediaFavorites.getFavoriteSet('chats'); const messages = await chatsModel.listMessages(chat.rootId || chat.key); const isTribeMember = !!parentTribe; - ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)), isTribeMember }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']) }); + const chatListForSwitcher = process.env.OASIS_MOBILE === '1' ? await chatsModel.listAll({ viewerId: uid }).catch(() => []) : []; + ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)), isTribeMember }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']), replyTo: typeof replyTo === 'string' ? replyTo : '', chatList: chatListForSwitcher }); }) .get("/pads", async (ctx) => { if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } @@ -6116,8 +6117,9 @@ router .post('/forum/:id/message', koaBody(), async ctx => { const { message, parentId } = ctx.request.body; const cleanedMsg = stripDangerousTags(message); + const subtopic = stripDangerousTags(String(ctx.request.body.subtopic || '').trim()).slice(0, 40); const mentions = await extractMentions(cleanedMsg); - await forumModel.addMessageToForum(ctx.params.id, { text: cleanedMsg, author: getViewerId(), timestamp: new Date().toISOString(), mentions: mentions.length > 0 ? mentions : undefined }, parentId); + await forumModel.addMessageToForum(ctx.params.id, { text: cleanedMsg, author: getViewerId(), timestamp: new Date().toISOString(), mentions: mentions.length > 0 ? mentions : undefined, subtopic: subtopic || undefined }, parentId); ctx.redirect(`/forum/${encodeURIComponent(ctx.params.id)}`); }) .post('/forum/:forumId/vote', koaBody(), async ctx => { @@ -7500,8 +7502,10 @@ router } const text = stripDangerousTags(String(ctx.request.body.text || '').trim()); const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null; + const replyToRaw = String(ctx.request.body.replyTo || '').trim(); + const replyTo = replyToRaw.startsWith('%') ? replyToRaw : null; if (!text && !imageBlob) { ctx.redirect(`/chats/${encodeURIComponent(ctx.params.chatId)}`); return; } - await chatsModel.sendMessage(ctx.params.chatId, text, imageBlob); + await chatsModel.sendMessage(ctx.params.chatId, text, imageBlob, replyTo); ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.chatId)}`, ['/chats'])); }) .post("/pads/create", koaBody(), async (ctx) => { diff --git a/src/client/assets/themes/OasisMobile.css b/src/client/assets/themes/OasisMobile.css index b5216a2..a086704 100644 --- a/src/client/assets/themes/OasisMobile.css +++ b/src/client/assets/themes/OasisMobile.css @@ -25,8 +25,8 @@ body { overflow-x: clip; overflow-y: visible; } padding: 2px 0; text-decoration: none; } -.oasis-bottombar .bb-ico { font-size: 18px; line-height: 1; color: #FFA500; } -.oasis-bottombar .bb-lbl { font-size: 9px; color: #ccc; white-space: nowrap; } +.oasis-bottombar .bb-ico { font-size: 18px; line-height: 1; color: #FFD700; } +.oasis-bottombar .bb-lbl { font-size: 9px; color: #FFD700; white-space: nowrap; } .oasis-bottombar .bb-badge { position: absolute; top: -4px; @@ -34,7 +34,7 @@ body { overflow-x: clip; overflow-y: visible; } min-width: 15px; height: 15px; padding: 0 3px; border-radius: 8px; - background: #FFA500; + background: #FFD700; color: #121212; font-size: 9px; font-weight: bold; display: flex; align-items: center; justify-content: center; @@ -42,33 +42,51 @@ body { overflow-x: clip; overflow-y: visible; } body { padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px)); } +/* topbar FIJA arriba: [logo] [Personal] [Community] [avatar]; el hive scrollea por debajo */ .oasis-mobile-topbar { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; + position: fixed; top: 0; left: 0; right: 0; z-index: 100; + display: flex; align-items: center; gap: 8px; padding: 8px 12px; - background: transparent; - box-shadow: none; border: 0; + background: #121212; + border-bottom: 1px solid #333; } +body { padding-top: 84px; background: #121212; } +.oasis-mobile-topbar .omt-logo, .oasis-mobile-topbar .omt-avatar { flex: 0 0 auto; } .oasis-mobile-topbar .omt-logo img, .oasis-mobile-topbar .omt-avatar img { - width: 32px; height: 32px; + width: 42px; height: 42px; border-radius: 50%; - border: 2px solid #FFA500; + border: 2px solid #FFD700; object-fit: cover; display: block; } -.oasis-mobile-topbar .omt-title { - flex: 1 1 auto; - text-align: center; - font-size: 26px; font-weight: bold; - letter-spacing: 10px; text-indent: 10px; - color: #FFD700; text-decoration: none; - white-space: nowrap; - background: transparent; border: 0; box-shadow: none; padding: 0; +.oasis-mobile-topbar .omt-menus { + flex: 1 1 auto; min-width: 0; + display: flex; justify-content: center; gap: 8px; } +.oasis-mobile-topbar .omt-menus .oasis-menu-btn { + display: flex; align-items: center; justify-content: center; + padding: 7px 14px; + border: 1px solid #FFD700; border-radius: 8px; + background: #161616; color: #FFD700; + font-weight: 600; letter-spacing: .5px; text-decoration: none; + font-size: 13px; white-space: nowrap; cursor: pointer; +} +.oasis-mobile-topbar .omt-menus .oasis-menu-btn.active { + background: #FFD700; color: #121212; border-color: #FFD700; +} +.oasis-mobile-topbar .omt-menus .oasis-menu-btn span { text-decoration: none; } + +/* ANULAR el "card" global de style.css `div{background:#222;border-radius:10px;box-shadow;padding:20px;margin-bottom:20px}` + en nuestro chrome móvil propio: era el 2º color (caja mas clara) detras de los botones, + la caja redondeada de la topbar y el fondo #222 detras de los hexagonos. */ +.header, .hive-nav, .hive-grid, .hive-cats, .hive-row, +.oasis-mobile-topbar, .oasis-mobile-topbar .omt-menus { + background-color: transparent; box-shadow: none; border-radius: 0; +} +.oasis-mobile-topbar { background-color: #121212; } /* fija: fondo opaco (el contenido scrollea por debajo) */ +.oasis-mobile-topbar .omt-menus { padding: 0; margin: 0; } /* sin caja ni relleno detras de los botones */ .header .top-bar-left > a.logo-icon { display: none; } @@ -77,14 +95,15 @@ body { padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px)); } .oasis-mobile-menus .oasis-menu-btn { flex: 1 1 0; display: flex; align-items: center; justify-content: center; - padding: 12px; - border: 1.5px solid #333; border-radius: 10px; - background: #121212; color: #ccc; - font-weight: bold; letter-spacing: 1px; cursor: pointer; + padding: 10px 12px; + border: 1px solid #FFD700; border-radius: 8px; + background: #161616; color: #FFD700; + font-weight: 600; letter-spacing: 1px; text-decoration: none; cursor: pointer; } +.oasis-mobile-menus .oasis-menu-btn span { text-decoration: none; } .oasis-mobile-menus .oasis-menu-btn.active { - background: #FFA500; color: #121212; border-color: #FFA500; + background: #FFD700; color: #121212; border-color: #FFD700; } :root { @@ -98,12 +117,12 @@ body { padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px)); } width: 46px; height: 46px; margin-top: -22px; border-radius: 50%; - background: #FFA500; color: #121212; + background: #FFD700; color: #121212; display: flex; align-items: center; justify-content: center; font-size: 22px; box-shadow: 0 2px 8px rgba(0,0,0,.4); } -.oasis-bottombar .bb-fab .bb-lbl { color: #FFA500; font-weight: bold; } +.oasis-bottombar .bb-fab .bb-lbl { color: #FFD700; font-weight: bold; } .main-column, .main-column p, .main-column li, .main-column a, .main-column span, .main-column td, .main-column h1, .main-column h2, .main-column h3 { @@ -144,13 +163,13 @@ body { padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px)); } .oasis-nav-list li a { display: flex; align-items: center; gap: var(--space-3); min-height: 48px; padding: 0 var(--space-3); - color: #e8e8e8; text-decoration: none; + color: #FFD700; text-decoration: none; border-top: 1px solid #262626; } -.oasis-nav-list li a .emoji { flex: 0 0 auto; width: 22px; text-align: center; color: #FFA500; } +.oasis-nav-list li a .emoji { flex: 0 0 auto; width: 22px; text-align: center; color: #FFD700; } .oasis-nav-list li a .nav-text { font-size: var(--fs-body); } .oasis-nav-list li a:active { background: #262626; } -.oasis-nav-list li a.current { color: #FFA500; font-weight: bold; } +.oasis-nav-list li a.current { color: #FFD700; font-weight: bold; } .main-column h1 { font-size: var(--fs-title); font-weight: 700; line-height: 1.25; margin: 0 0 var(--space-2); } .main-column h2 { font-size: 18px; font-weight: 700; line-height: 1.3; margin: 0 0 var(--space-2); } @@ -198,11 +217,11 @@ body { overscroll-behavior: none; } display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; min-height: 44px; margin: 4px 0 8px; padding: 10px 12px; border: 1.5px solid #333; border-radius: 10px; - background: #121212; color: #ccc; + background: #121212; color: #FFD700; font-weight: bold; letter-spacing: 1px; text-transform: uppercase; cursor: pointer; } .activity-filter-caret { transition: transform 150ms ease; } -#activity-filter-toggle:checked ~ .activity-filter-btn { background: #FFA500; color: #121212; border-color: #FFA500; } +#activity-filter-toggle:checked ~ .activity-filter-btn { background: #FFD700; color: #121212; border-color: #FFD700; } #activity-filter-toggle:checked ~ .activity-filter-btn .activity-filter-caret { transform: rotate(180deg); } .activity-filter-panel { display: none; } #activity-filter-toggle:checked ~ .activity-filter-panel { display: block; } @@ -213,7 +232,7 @@ body { overscroll-behavior: none; } .bb-manage { margin: 8px 0 16px; } .bb-manage h3, .bb-manage-add h3 { font-size: 13px; text-transform: uppercase; letter-spacing: .05em; - color: #FFA500; margin: 14px 0 8px; + color: #FFD700; margin: 14px 0 8px; } .bb-manage-list { display: flex; flex-direction: column; gap: 8px; } .bb-manage-form { margin: 0; width: 100%; } @@ -222,11 +241,11 @@ body { overscroll-behavior: none; } display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 14px; background: #1c1c1c; border: 1px solid #333; border-radius: 10px; - color: #eee; cursor: pointer; text-align: left; + color: #FFD700; cursor: pointer; text-align: left; } -.bb-manage-ico { font-size: 20px; color: #FFA500; width: 26px; text-align: center; } +.bb-manage-ico { font-size: 20px; color: #FFD700; width: 26px; text-align: center; } .bb-manage-lbl { flex: 1 1 auto; font-size: 15px; } -.bb-manage-x { font-size: 16px; color: #ff6b6b; font-weight: bold; } +.bb-manage-x { font-size: 16px; color: #FFD700; font-weight: bold; } .bb-manage-empty, .bb-manage-full { color: #999; font-size: 14px; padding: 8px 2px; } .bb-manage-done { display: block; text-align: center; @@ -244,7 +263,7 @@ body { overscroll-behavior: none; } clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; text-decoration: none; cursor: pointer; box-sizing: border-box; - background: #D9A400; + background: #FFD700; filter: drop-shadow(0 1px 2px rgba(0,0,0,.6)); } .hex-cat::before, .hex-mod::before { @@ -255,10 +274,10 @@ body { overscroll-behavior: none; } } .hex-cat { width: var(--hx); height: calc(var(--hx) * 1.15); } .hex-mod { width: var(--hxm); height: calc(var(--hxm) * 1.15); } -.hex-cat .hx-ico, .hex-mod .hx-ico { color: #FFA500; line-height: 1; position: relative; z-index: 1; } +.hex-cat .hx-ico, .hex-mod .hx-ico { color: #FFD700; line-height: 1; position: relative; z-index: 1; } .hex-cat .hx-ico { font-size: 17px; } .hex-mod .hx-ico { font-size: 16px; } -.hex-cat .hx-lbl, .hex-mod .hx-lbl { color: #d8d8d8; text-align: center; line-height: 1; font-weight: bold; overflow: hidden; position: relative; z-index: 1; } +.hex-cat .hx-lbl, .hex-mod .hx-lbl { color: #FFD700; text-align: center; line-height: 1; font-weight: bold; overflow: hidden; position: relative; z-index: 1; } .hex-cat .hx-lbl { font-size: 8px; max-width: 88%; } .hex-mod .hx-lbl { font-size: 8px; max-width: 92%; } .hex-cat:active::before, .hex-mod:active::before { background: #242424; } @@ -271,7 +290,7 @@ body { overscroll-behavior: none; } #hive-blogs:checked ~ .hive-grid .hex-cat[for="hive-blogs"], #hive-creative:checked ~ .hive-grid .hex-cat[for="hive-creative"], #hive-media:checked ~ .hive-grid .hex-cat[for="hive-media"], -#hive-fediverse:checked ~ .hive-grid .hex-cat[for="hive-fediverse"] { background: #FFA500; } +#hive-fediverse:checked ~ .hive-grid .hex-cat[for="hive-fediverse"] { background: #FFD700; } #hive-personal:checked ~ .hive-grid .hex-cat[for="hive-personal"]::before, #hive-governance:checked ~ .hive-grid .hex-cat[for="hive-governance"]::before, #hive-office:checked ~ .hive-grid .hex-cat[for="hive-office"]::before, @@ -281,7 +300,7 @@ body { overscroll-behavior: none; } #hive-blogs:checked ~ .hive-grid .hex-cat[for="hive-blogs"]::before, #hive-creative:checked ~ .hive-grid .hex-cat[for="hive-creative"]::before, #hive-media:checked ~ .hive-grid .hex-cat[for="hive-media"]::before, -#hive-fediverse:checked ~ .hive-grid .hex-cat[for="hive-fediverse"]::before { background: #FFA500; } +#hive-fediverse:checked ~ .hive-grid .hex-cat[for="hive-fediverse"]::before { background: #FFD700; } #hive-personal:checked ~ .hive-grid .hex-cat[for="hive-personal"] :is(.hx-ico,.hx-lbl), #hive-governance:checked ~ .hive-grid .hex-cat[for="hive-governance"] :is(.hx-ico,.hx-lbl), #hive-office:checked ~ .hive-grid .hex-cat[for="hive-office"] :is(.hx-ico,.hx-lbl), @@ -322,11 +341,11 @@ body { overscroll-behavior: none; } .oasis-bottombar .bb-pair .bb-lbl { font-size: 8px; } .oasis-bottombar a.bb-item.current .bb-ico { - background: #FFA500; color: #121212; + background: #FFD700; color: #121212; border-radius: 999px; padding: 4px 12px; } -.oasis-bottombar a.bb-item.current .bb-lbl { color: #FFA500; font-weight: bold; } -.oasis-bottombar a.bb-item.current .bb-badge { background: #121212; color: #FFA500; } +.oasis-bottombar a.bb-item.current .bb-lbl { color: #FFD700; font-weight: bold; } +.oasis-bottombar a.bb-item.current .bb-badge { background: #121212; color: #FFD700; } .main-column, .main-column p, .main-column li, .main-column a, .main-column span, .main-column td, .main-column th, .main-column label, @@ -402,7 +421,7 @@ button, input[type="submit"], input[type="button"], width: 100%; box-sizing: border-box; border: 0; - border-bottom: 1px solid rgba(255,165,0,0.18); + border-bottom: 1px solid rgba(255,215,0,0.18); border-radius: 0; margin: 0 0 8px 0; padding: 6px 0; @@ -515,7 +534,7 @@ button, input[type="submit"], input[type="button"], .activity-groups .act-count { flex: 0 0 auto; min-width: 20px; text-align: center; font-size: 11px; font-weight: 700; color: #121212; - background: #FFA500; border-radius: 999px; padding: 1px 7px; + background: #FFD700; border-radius: 999px; padding: 1px 7px; margin: 0 8px 0 0; } @@ -526,7 +545,7 @@ button, input[type="submit"], input[type="button"], min-height: 44px; margin: 2px 0; box-sizing: border-box; } .activity-groups .oasis-nav-list .filter-btn.active { - outline: 2px solid #FFA500; outline-offset: -2px; + outline: 2px solid #FFD700; outline-offset: -2px; } .message-list { display: flex; flex-direction: column; gap: 4px; width: 100%; } @@ -550,10 +569,10 @@ button, input[type="submit"], input[type="button"], border-bottom-right-radius: 4px; background: #2a2413; border-color: #5c4c1f; } -.pm-bubble .pm-bubble-sender { font-size: 11px; font-weight: 700; color: #FFA500; margin-bottom: 3px; } -.pm-bubble .pm-bubble-sender a { color: #FFA500; } +.pm-bubble .pm-bubble-sender { font-size: 11px; font-weight: 700; color: #FFD700; margin-bottom: 3px; } +.pm-bubble .pm-bubble-sender a { color: #FFD700; } -.pm-bubble .message-text { font-size: 15px; line-height: 1.42; color: #ececec; margin: 0; } +.pm-bubble .message-text { font-size: 15px; line-height: 1.42; color: #FFD700; margin: 0; } .pm-bubble .message-text a { color: #FFD700; } .pm-bubble .pm-bubble-meta { @@ -737,7 +756,7 @@ button, input[type="submit"], input[type="button"], padding: 40px 16px; gap: 8px; width: 100%; box-sizing: border-box; } .empty-state-icon { font-size: 44px; line-height: 1; opacity: 0.55; } -.empty-state-title { font-size: 16px; font-weight: 700; margin: 4px 0 0 0; color: #eee; } +.empty-state-title { font-size: 16px; font-weight: 700; margin: 4px 0 0 0; color: #FFD700; } .empty-state-text { color: var(--c-muted, #8c8c8c); font-size: 14px; line-height: 1.4; max-width: 40ch; margin: 0; } .empty-state-cta { margin-top: 10px; min-height: 48px; display: inline-flex; align-items: center; } @@ -751,7 +770,7 @@ button, input[type="submit"], input[type="button"], } .activity-back-arrow { font-size: 22px; line-height: 1; font-weight: bold; } .activity-active-filter { - font-weight: 700; color: #FFA500; text-transform: uppercase; + font-weight: 700; color: #FFD700; text-transform: uppercase; font-size: 13px; letter-spacing: .03em; } @@ -766,7 +785,7 @@ button, input[type="submit"], input[type="button"], .score-btn:active, .vote-btn:active, .feed-action-btn:active { filter: brightness(.85); } .oasis-bottombar .bb-add .bb-ico { - background: #FFA500; color: #121212; border-radius: 999px; + background: #FFD700; color: #121212; border-radius: 999px; width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; font-weight: bold; font-size: 20px; line-height: 1; @@ -780,10 +799,10 @@ button, input[type="submit"], input[type="button"], display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; width: 100%; min-height: 76px; padding: 10px 6px; box-sizing: border-box; - background: #1c1c1c; border: 1px solid #333; border-radius: 12px; color: #eee; + background: #1c1c1c; border: 1px solid #333; border-radius: 12px; color: #FFD700; } -.bb-pick-btn.active { border-color: #FFA500; background: #2a2412; } -.bb-pick-ico { font-size: 26px; color: #FFA500; line-height: 1; } +.bb-pick-btn.active { border-color: #FFD700; background: #2a2412; } +.bb-pick-ico { font-size: 26px; color: #FFD700; line-height: 1; } .bb-pick-lbl { font-size: 11px; text-align: center; line-height: 1.2; } @@ -818,10 +837,10 @@ button, input[type="submit"], input[type="button"], } .chat-message-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 3px; font-size: 10px; color: var(--c-muted, #8c8c8c); } .chat-message.chat-message-self .chat-message-meta { justify-content: flex-end; } -.chat-message-sender, .chat-message-sender a { font-size: 11px; font-weight: 700; color: #FFA500; } +.chat-message-sender, .chat-message-sender a { font-size: 11px; font-weight: 700; color: #FFD700; } .chat-message-date { opacity: .8; } .chat-message-text { - display: block; font-size: 15px; line-height: 1.42; color: #ececec; margin: 0; + display: block; font-size: 15px; line-height: 1.42; color: #FFD700; margin: 0; word-break: break-word; overflow-wrap: anywhere; } .chat-message-text a { color: #FFD700; } @@ -842,6 +861,85 @@ button, input[type="submit"], input[type="button"], .chat-message-form .filter-btn::before { content: "\27A4"; font-size: 18px; line-height: 1; color: #121212; } .chat-message-form input[type="file"] { font-size: 12px; } +/* --- Reply a mensaje en chats (server-side ?replyTo=, sin JS) --- */ +.chat-message-meta .chat-reply-btn { margin-left: auto; padding: 0 4px; color: #FFD700; text-decoration: none; font-size: 15px; opacity: .85; } +.chat-message-self .chat-message-meta .chat-reply-btn { margin-left: 8px; } +.chat-quote { + display: block; border-left: 3px solid #FFD700; padding: 3px 8px; margin: 0 0 5px; + background: rgba(255,215,0,.08); border-radius: 6px; font-size: 12px; line-height: 1.3; +} +.chat-quote-author, .chat-quote-author a { color: #FFD700; font-weight: 700; margin-right: 6px; } +.chat-quote-text { color: #FFDD44; } +.chat-replying-to { + display: flex; flex-wrap: wrap; align-items: center; gap: 6px; + border-left: 3px solid #FFD700; background: rgba(255,215,0,.10); + padding: 6px 10px; margin-bottom: 8px; border-radius: 8px; font-size: 12px; +} +.chat-replying-label { color: #FFD700; font-weight: 700; } +.chat-reply-cancel { margin-left: auto; color: #FFD700; text-decoration: none; font-weight: 700; font-size: 14px; } + +/* --- Switcher de chats: tira horizontal para saltar entre conversaciones --- */ +.chat-switcher { + display: flex; gap: 6px; overflow-x: auto; padding: 6px 0; margin: 4px 0 8px; + -webkit-overflow-scrolling: touch; scrollbar-width: none; +} +.chat-switcher::-webkit-scrollbar { display: none; } +.chat-switch-chip { + flex: 0 0 auto; max-width: 140px; padding: 6px 12px; border-radius: 999px; + background: #1c1c1c; border: 1px solid #333; color: #FFD700; text-decoration: none; + font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.chat-switch-chip.active { background: #FFD700; color: #121212; border-color: #FFD700; font-weight: 700; } + +/* --- Subtopics de foro: tira de pestañas (topics con nombre) + input de topic en el compose --- */ +.forum-topics { + display: flex; gap: 6px; overflow-x: auto; padding: 6px 0; margin: 4px 0 8px; + scrollbar-width: none; +} +.forum-topics::-webkit-scrollbar { display: none; } +.forum-topic-chip { + flex: 0 0 auto; max-width: 160px; padding: 6px 14px; border-radius: 999px; + background: #1c1c1c; border: 1px solid #333; color: #FFD700; text-decoration: none; + font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.forum-topic-chip.active { background: #FFD700; color: #121212; border-color: #FFD700; font-weight: 700; } +.new-message-form { flex-wrap: wrap; } +.forum-topic-input { + flex: 1 0 100%; box-sizing: border-box; border-radius: 12px; + padding: 8px 12px; font-size: 13px; margin: 0 0 6px; +} + +/* --- #3: al desplegar una categoría, sus módulos son BOTONES CUADRADOS redondeados (no hexágonos), + en rejilla 2×2 (2 y 2) y grandes: cada cuadrado llena su columna --- */ +.hive-mods { + display: grid; grid-template-columns: repeat(2, 1fr); + justify-items: stretch; align-content: center; + gap: 12px; padding: 8px 4px; +} +.hex-mod { + clip-path: none; -webkit-clip-path: none; + width: 100%; height: auto; aspect-ratio: 1 / 1; min-height: 120px; + border-radius: 18px; padding: 8px; gap: 7px; +} +.hex-mod::before { clip-path: none; -webkit-clip-path: none; inset: 2px; border-radius: 16px; } +.hex-mod .hx-ico { font-size: 36px; } +.hex-mod .hx-lbl { font-size: 12.5px; max-width: 94%; } +/* si el nº de módulos es IMPAR, el último (solo en su fila) se CENTRA en vez de pegarse a la izquierda; + abarca las 2 columnas pero conserva el ancho de una sola (gap 12 → mitad 6) */ +.hex-mod:last-child:nth-child(odd) { + grid-column: 1 / -1; justify-self: center; width: calc(50% - 6px); +} + +/* --- #2: el hexágono "Personal" muestra tu avatar (diferencia del filtro "Personal" del header) --- */ +.hex-cat .hx-avatar { + width: 58%; height: 58%; border-radius: 50%; object-fit: cover; + position: relative; z-index: 1; border: 1px solid rgba(255,215,0,.6); +} + +/* --- #1: chat estilo Signal — compose ABAJO + mensajes arriba (el reply deja de "saltar") --- */ +.tribe-main.chat-full-width { display: flex; flex-direction: column-reverse; } +#chatbox { scroll-margin-top: 8px; scroll-margin-bottom: 8px; } + /* --- Botón spread → pill compacto (vence button{min-height:48px}/ancho completo) --- */ .card-spread-centered, .card-spread-left, .spread-row { margin: 6px 0; } .card-spread-centered .spread-btn, .card-spread-left .spread-btn, @@ -849,10 +947,10 @@ button, input[type="submit"], input[type="button"], display: inline-flex; align-items: center; justify-content: center; width: auto; min-width: 0; min-height: 30px; height: 30px; padding: 2px 14px; font-size: 13px; line-height: 1; - border-radius: 999px; border: 1px solid #555; background: #1a1a1a; color: #ffa500; + border-radius: 999px; border: 1px solid #555; background: #1a1a1a; color: #FFD700; } .card-spread-centered .spread-btn.spread-btn-on, -.card-spread-left .spread-btn.spread-btn-on { background: #ffa500; color: #1a1a1a; border-color: #ff7300; } +.card-spread-left .spread-btn.spread-btn-on { background: #FFD700; color: #1a1a1a; border-color: #FFD700; } /* --- Caja de votos (▲ n ▼): compacta y HORIZONTAL. El problema era una cadena de flex-stretch (comment-vote-col flex → estiraba la caja a 135px). @@ -920,9 +1018,29 @@ button, input[type="submit"], input[type="button"], .forum-comment.level-3 { margin-left: 28px !important; border-left: 2px solid #404040; } .forum-comment.level-4 { margin-left: 34px !important; border-left: 2px solid #454545; } /* comentario destacado: acento sutil a la izquierda en vez del marco naranja enorme */ -.forum-comment.highlighted-reply { border: 1px solid #3a3a3a !important; border-left: 3px solid #FFA500 !important; background: #1b1a16 !important; } +.forum-comment.highlighted-reply { border: 1px solid #3a3a3a !important; border-left: 3px solid #FFD700 !important; background: #1b1a16 !important; } /* --- Reply del foro SIN tocar la vista de epsylon: la cajita se agranda al enfocarla; el botón redondo ↩ está SIEMPRE visible (el usuario pidió icono de reply, no gesto/JS). --- */ .new-reply .comment-textarea { min-height: 42px; } .new-reply:focus-within .comment-textarea { min-height: 64px; } + +/* ========================================================================== + Mejoras UI 0.9.1: fuente única + barra de búsqueda en topbar + hive global + bottombar fija + ========================================================================== */ + +/* --- fuente única en todo el UI móvil (quitar el lío monospace/sans) --- */ +.oasis-mobile-topbar, .oasis-mobile-menus, .hive-nav, .oasis-bottombar, .main-column, +.oasis-mobile-topbar input, .main-column input, .main-column textarea, .main-column select, button { + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +/* (topbar sin barra de búsqueda: vuelve el título "OASIS" — CSS .omt-title más arriba) */ + +/* --- hive ahora global en el header (debajo de los 2 botones) --- */ +.header .hive-nav { margin: 34px auto 10px; } + +/* --- bottombar FIJA de 6 (Inbox·PM·Write·Search·Graphos·Peers): que quepan --- */ +.oasis-bottombar-fixed .bb-item { padding: 2px 0; gap: 2px; } +.oasis-bottombar-fixed .bb-ico { font-size: 17px; } +.oasis-bottombar-fixed .bb-lbl { font-size: 8.5px; } diff --git a/src/models/chats_model.js b/src/models/chats_model.js index 7806bfa..3549e09 100644 --- a/src/models/chats_model.js +++ b/src/models/chats_model.js @@ -293,7 +293,8 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => { text, image: c.image || null, author: c.author || node.author, - createdAt: c.createdAt || new Date(node.ts).toISOString() + createdAt: c.createdAt || new Date(node.ts).toISOString(), + replyTo: c.replyTo || null } } @@ -791,7 +792,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => { } catch (_) { return 0 } }, - async sendMessage(chatId, text, image = null) { + async sendMessage(chatId, text, image = null, replyTo = null) { const ssbClient = await openSsb() const userId = ssbClient.id const chat = await this.getChatById(chatId) @@ -808,7 +809,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => { const c = m.value?.content return c?.type === "chatMessage" && c?.chatId === chat.rootId && m.value?.author === userId && (m.value?.timestamp || 0) >= oneHourAgo }).length - if (recentCount >= 3) throw new Error("Rate limit: max 3 messages per hour") + if (process.env.OASIS_MOBILE !== '1' && recentCount >= 3) throw new Error("Rate limit: max 3 messages per hour") const now = new Date().toISOString() let content = { @@ -818,6 +819,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => { createdAt: now } if (image) content.image = image + if (replyTo && typeof replyTo === "string" && replyTo.startsWith("%")) content.replyTo = replyTo const chatIsEncrypted = !!(chat.tribeId) || !!lookupKey(chat.rootId) if (chatIsEncrypted && tribeCrypto) { diff --git a/src/models/forum_model.js b/src/models/forum_model.js index df27060..b593e3b 100644 --- a/src/models/forum_model.js +++ b/src/models/forum_model.js @@ -503,7 +503,8 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => { text: c.text, author: c.author, timestamp: m.value.timestamp, - parent: c.branch || null + parent: c.branch || null, + subtopic: c.subtopic || null })); for (let r of replies) { const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key); diff --git a/src/views/activity_view.js b/src/views/activity_view.js index 184a97f..8440cd7 100644 --- a/src/views/activity_view.js +++ b/src/views/activity_view.js @@ -1993,7 +1993,6 @@ exports.activityView = (actions, filter, userId, q = '', extras = {}) => { h2(i18n.activityList), p(desc) ), - renderHiveNav(), input({ type: 'checkbox', id: 'activity-filter-toggle', class: 'activity-filter-toggle' }), label({ for: 'activity-filter-toggle', class: 'activity-filter-btn' }, span(i18n.filter || 'Filter'), span({ class: 'activity-filter-caret' }, '▾')), diff --git a/src/views/chats_view.js b/src/views/chats_view.js index a3c2c00..9d373c6 100644 --- a/src/views/chats_view.js +++ b/src/views/chats_view.js @@ -140,7 +140,7 @@ const renderMessageText = (text) => { return span({ class: "chat-message-text" }, ...nodes) } -const renderMessage = (msg, chatAuthor) => { +const renderMessage = (msg, chatAuthor, chatKey, msgById) => { const isAuthor = String(msg.author) === String(chatAuthor) const isSelf = String(msg.author) === String(userId) const dateStr = moment(msg.createdAt).format("YYYY/MM/DD HH:mm") @@ -149,11 +149,26 @@ const renderMessage = (msg, chatAuthor) => { const imageNode = msg.image ? renderMediaBlob(msg.image, null, { class: "chat-message-image" }) : null + // m\u00f3vil: cita del mensaje respondido + bot\u00f3n \u21a9 para responder a ESTE mensaje (server-side ?replyTo=, sin JS) + const mobile = process.env.OASIS_MOBILE === "1" + const quoted = (mobile && msg.replyTo && msgById) ? msgById.get(String(msg.replyTo)) : null + const quoteNode = quoted + ? div({ class: "chat-quote" }, + span({ class: "chat-quote-author" }, quoted.author ? userLink(quoted.author) : span("?")), + span({ class: "chat-quote-text" }, String(quoted.text || (quoted.image ? "\ud83d\uddbc" : "")).slice(0, 80)) + ) + : null + const replyLink = (mobile && chatKey && msg.key) + ? a({ class: "chat-reply-btn", href: `/chats/${encodeURIComponent(chatKey)}?replyTo=${encodeURIComponent(msg.key)}#chatbox`, title: "Reply" }, "\u21a9") + : null + return div({ class: isSelf ? "chat-message chat-message-self" : isAuthor ? "chat-message chat-message-author" : "chat-message" }, div({ class: "chat-message-meta" }, span({ class: "chat-message-sender" }, authorLink), - span({ class: "chat-message-date" }, ` [ ${dateStr} ]`) + span({ class: "chat-message-date" }, ` [ ${dateStr} ]`), + replyLink ), + quoteNode, imageNode ? div({ class: "chat-message-image-wrap" }, imageNode) : null, renderMessageText(msg.text || "") ) @@ -342,15 +357,26 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => { ) const msgList = safeArr(messages) + const msgById = new Map(msgList.map(m => [String(m.key), m])) + const replyingTo = (process.env.OASIS_MOBILE === "1" && params.replyTo) ? msgById.get(String(params.replyTo)) : null const canWrite = (isMember || chat.status === "OPEN") && chat.status !== "CLOSED" const chatMain = isRestrictedInviteOnly ? div({ class: "tribe-main chat-full-width" }, p({ class: "access-denied-msg" }, i18n.chatAccessDenied)) : div({ class: "tribe-main chat-full-width" }, canWrite - ? div({ class: "chat-message-form" }, + ? div({ class: "chat-message-form", id: "chatbox" }, + replyingTo + ? div({ class: "chat-replying-to" }, + span({ class: "chat-replying-label" }, "Replying to: "), + span({ class: "chat-quote-author" }, replyingTo.author ? userLink(replyingTo.author) : span("?")), + span({ class: "chat-quote-text" }, String(replyingTo.text || (replyingTo.image ? "🖼" : "")).slice(0, 80)), + a({ class: "chat-reply-cancel", href: `/chats/${encodeURIComponent(chat.key)}`, title: "Cancel" }, "✕") + ) + : null, form({ method: "POST", action: `/chats/${encodeURIComponent(chat.key)}/message`, enctype: "multipart/form-data" }, input({ type: "hidden", name: "returnTo", value: `/chats/${encodeURIComponent(chat.key)}` }), + replyingTo ? input({ type: "hidden", name: "replyTo", value: String(params.replyTo) }) : null, textarea({ name: "text", rows: 3, placeholder: i18n.chatMessagePlaceholder }), br(), span(i18n.chatImageLabel || "Select an image file (.jpeg, .jpg, .png, .gif)"), br(), input({ type: "file", name: "image", accept: "image/*" }), br(), br(), @@ -362,12 +388,24 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => { (() => { const visible = msgList.filter(msg => (msg.text && String(msg.text).trim()) || msg.image) return visible.length - ? visible.map(msg => renderMessage(msg, chat.author)) + ? visible.map(msg => renderMessage(msg, chat.author, chat.key, msgById)) : p({ class: "chat-no-messages" }, i18n.chatNoMessages) })() ) ) + // móvil: tira para saltar entre chats sin volver a la lista (server-side, enlaces) + const switcherChats = process.env.OASIS_MOBILE === "1" && Array.isArray(params.chatList) ? params.chatList : [] + const chatSwitcher = switcherChats.length + ? div({ class: "chat-switcher" }, + switcherChats.slice(0, 30).map(c => a({ + class: String(c.key) === String(chat.key) ? "chat-switch-chip active" : "chat-switch-chip", + href: `/chats/${encodeURIComponent(c.key)}`, + title: c.title || "" + }, (c.title || "·").slice(0, 18))) + ) + : null + return template( chat.title || i18n.chatUntitled, section( @@ -375,7 +413,8 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => { h2(i18n.chatsTitle), p(i18n.modulesChatsDescription) ), - renderModeButtons(filter || "all") + renderModeButtons(filter || "all"), + chatSwitcher ), section( div({ class: "tribe-details" }, diff --git a/src/views/forum_view.js b/src/views/forum_view.js index 4b201a1..2638b5c 100644 --- a/src/views/forum_view.js +++ b/src/views/forum_view.js @@ -282,8 +282,20 @@ exports.forumView = async (forums, currentFilter, params = {}) => { ); }; -exports.singleForumView = async (forum, messagesData, currentFilter) => { +exports.singleForumView = async (forum, messagesData, currentFilter, topic = '') => { const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{}); + // móvil: subtopics — etiqueta opcional en los mensajes raíz → tira de pestañas + filtro por ?topic= + const forumMobile = process.env.OASIS_MOBILE === '1'; + const rootMsgs = Array.isArray(messagesData.messages) ? messagesData.messages : []; + const forumTopics = forumMobile ? [...new Set(rootMsgs.map(m => String(m.subtopic || '').trim()).filter(Boolean))] : []; + const activeTopic = forumMobile ? String(topic || '').trim() : ''; + const shownMsgs = activeTopic ? rootMsgs.filter(m => String(m.subtopic || '').trim() === activeTopic) : rootMsgs; + const topicStrip = (forumMobile && forumTopics.length) + ? div({ class: 'forum-topics' }, + a({ class: activeTopic ? 'forum-topic-chip' : 'forum-topic-chip active', href: `/forum/${encodeURIComponent(forum.key)}` }, i18n.menuAll || 'All'), + ...forumTopics.map(t => a({ class: t === activeTopic ? 'forum-topic-chip active' : 'forum-topic-chip', href: `/forum/${encodeURIComponent(forum.key)}?topic=${encodeURIComponent(t)}` }, t)) + ) + : null; return template(forum.title, section( div({ class: 'tags-header' }, @@ -305,6 +317,7 @@ exports.singleForumView = async (forum, messagesData, currentFilter) => { ) ), div({ class: 'forum-thread-container' }, + topicStrip, div({ class: 'forum-card forum-thread-header', style: 'display:flex;align-items:flex-start;' @@ -394,6 +407,7 @@ exports.singleForumView = async (forum, messagesData, currentFilter) => { action: `/forum/${encodeURIComponent(forum.key)}/message`, class: 'new-message-form' }, + forumMobile ? input({ type: 'text', name: 'subtopic', value: activeTopic, placeholder: 'Topic (optional)', class: 'forum-topic-input' }) : null, textarea({ name: 'message', rows: 4, @@ -408,7 +422,7 @@ exports.singleForumView = async (forum, messagesData, currentFilter) => { }, i18n.forumSendButton) ) ), - ...renderThread(messagesData.messages, 0, forum.key) + ...renderThread(shownMsgs, 0, forum.key) ) ); }; diff --git a/src/views/main_views.js b/src/views/main_views.js index 48cac74..f60cbea 100644 --- a/src/views/main_views.js +++ b/src/views/main_views.js @@ -1103,12 +1103,19 @@ 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" })), - a({ class: "omt-title", href: "/" }, "OASIS"), - a({ class: "omt-avatar", href: "/profile" }, img({ src: (typeof global !== "undefined" && global.__OASIS_SELF_AVATAR__) || "/assets/images/default-avatar.png", alt: i18n.profile || "Profile" })) -); +// topbar fija arriba: [logo] [Personal] [Community] [avatar] en una línea (sin título OASIS) +const renderMobileTopbar = () => { + const hiveFilter = (typeof global !== "undefined" && global.__OASIS_HIVE_FILTER__) || ""; + return div( + { class: "oasis-mobile-topbar" }, + a({ class: "omt-logo", href: "/" }, img({ src: "/assets/images/snh-oasis.jpg", alt: "Oasis" })), + div({ class: "omt-menus" }, + a({ class: hiveFilter === "personal" ? "oasis-menu-btn active" : "oasis-menu-btn", href: hiveFilter === "personal" ? "/activity" : "/activity?hive=personal" }, span(i18n.menuPersonal || "Personal")), + a({ class: hiveFilter === "community" ? "oasis-menu-btn active" : "oasis-menu-btn", href: hiveFilter === "community" ? "/activity" : "/activity?hive=community" }, span(i18n.menuCommunity || "Community")) + ), + a({ class: "omt-avatar", href: "/profile" }, img({ src: (typeof global !== "undefined" && global.__OASIS_SELF_AVATAR__) || "/assets/images/default-avatar.png", alt: i18n.profile || "Profile" })) + ); +}; const PINNABLE_MODULES = [ { key: 'search', href: '/search', glyph: 'ꔅ', labelKey: 'searchTitle', mod: null }, @@ -1170,26 +1177,18 @@ const renderBottomBar = () => { (badge && badge > 0) ? span({ class: "bb-badge" }, String(badge)) : "", span({ class: "bb-lbl" }, label) ); - const MAX = 4; - const pins = (cfg.bottomBarPins || []) - .map(key => PINNABLE_MODULES.find(m => m.key === key)) - .filter(m => m && modOn(m.mod)) - .slice(0, MAX); - const slots = pins.map(m => bbLink( - m.href, m.glyph, i18n[m.labelKey] || m.key, m.key === 'inbox' ? inboxN : 0 - )); - const editSlot = a( - { class: "bb-item bb-add", href: "/settings/bottombar" }, - span({ class: "bb-ico" }, pins.length < MAX ? "+" : "✎"), - span({ class: "bb-lbl" }, pins.length < MAX ? (i18n.bbAdd || "Add") : (i18n.bbManage || "Edit")) - ); - const pair = [ bbLink("/peers", "⧖", i18n.peers || "Peers") ]; - if (modOn('invitesMod')) pair.push(bbLink("/invites", "ꔮ", i18n.invites || "Invites")); + // barra inferior FIJA (estilo epsylon): Inbox · PM · Write · Search · Graphos · Peers + const items = [ + bbLink("/inbox", "☂", "Inbox", inboxN), + bbLink("/pm", "✉", "PM"), + bbLink("/publish", "✎", "Write"), + bbLink("/search", "ꔅ", "Search"), + bbLink("/graphos", "◈", "Graphos"), + bbLink("/peers", "⧖", "Peers"), + ]; return nav( - { class: "oasis-bottombar", "aria-label": i18n.bbQuickActions || "Quick actions" }, - ...slots, - editSlot, - div({ class: "bb-pair" }, ...pair) + { class: "oasis-bottombar oasis-bottombar-fixed", "aria-label": i18n.bbQuickActions || "Quick actions" }, + ...items ); }; @@ -1292,33 +1291,8 @@ const template = (titlePrefix, ...elements) => { div( { class: uxMode === "ainav" ? "header ainav-only" : "header" }, renderMobileTopbar(), - div( - { class: "oasis-mobile-menus" }, - // enlaces (no checkboxes): funcionan en CUALQUIER página → navegan al home /activity con el filtro. - // toggle: si el filtro ya está activo, el enlace lleva a /activity (se deselecciona → todos) - a({ class: hiveFilter === "personal" ? "oasis-menu-btn active" : "oasis-menu-btn", href: hiveFilter === "personal" ? "/activity" : "/activity?hive=personal" }, span(i18n.menuPersonal || "Personal")), - a({ class: hiveFilter === "community" ? "oasis-menu-btn active" : "oasis-menu-btn", href: hiveFilter === "community" ? "/activity" : "/activity?hive=community" }, span(i18n.menuCommunity || "Community")) - ), - (() => { - const aiNavOn = getConfig().modules.aiNavMod === 'on'; - if (!aiNavOn && uxMode !== 'ainav') return null; - return div( - { class: uxMode === 'ainav' ? "top-bar-center top-bar-center-ainav" : "top-bar-center" }, - form( - { method: 'POST', action: '/ai/ask', class: 'ai-ask-form' }, - input({ - type: 'text', - name: 'q', - class: 'ai-ask-input', - placeholder: i18n.aiNavPlaceholder || 'Where do you want to go?', - autocomplete: 'off', - maxlength: '300', - autofocus: uxMode === 'ainav' ? 'autofocus' : undefined - }), - button({ type: 'submit', class: 'ai-ask-btn' }, '➤') - ) - ); - })() + // hive global: los hexágonos salen en TODAS las páginas y SCROLLEAN bajo la topbar fija (antes solo en /activity) + renderHiveNav() ), (() => { const updateFlagPath = path.join(__dirname, '../server/.update_required');