diff --git a/webui-src/app/boards/board_kanban.js b/webui-src/app/boards/board_kanban.js new file mode 100644 index 0000000..9752bed --- /dev/null +++ b/webui-src/app/boards/board_kanban.js @@ -0,0 +1,695 @@ +const m = require('mithril'); +const util = require('boards/boards_util'); + +const PAGE_SIZE = 25; + +function numberValue(value) { + if (value && typeof value === 'object' && value.xint64 !== undefined) value = value.xint64; + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +/** + * Fallback SVG Thumbnail when no image is available + */ +const FallbackImage = { + view: () => m('.board-card__placeholder-content', { + style: { + display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', + gap: '.3rem', color: '#64748b', fontSize: '.72rem', fontWeight: '600', textAlign: 'center', + }, + }, [ + m('i.fas.fa-image[aria-hidden=true]', { style: { fontSize: '1.35rem' } }), + m('span', 'No image'), + ]), +}; + +/** + * Check if notes string contains non-whitespace text + */ +function hasNotesText(notes) { + if (notes === null || notes === undefined) return false; + if (typeof notes !== 'string') notes = String(notes); + return notes.trim().length > 0; +} + +/** + * Robust image extraction helper for RetroShare post items + */ +function extractImageSrc(item) { + if (!item) return ''; + const p = item.post || item; + + if (item.thumbnail && typeof item.thumbnail === 'string' && item.thumbnail.trim() !== '') { + return item.thumbnail.startsWith('data:') ? item.thumbnail : `data:image/png;base64,${item.thumbnail}`; + } + if (item.image && typeof item.image === 'string' && item.image.trim() !== '') { + return item.image.startsWith('data:') ? item.image : `data:image/png;base64,${item.image}`; + } + if (p.mImage) { + if (p.mImage.mData && p.mImage.mData.base64 && p.mImage.mData.base64.trim() !== '') { + return `data:image/png;base64,${p.mImage.mData.base64}`; + } + if (typeof p.mImage.base64 === 'string' && p.mImage.base64.trim() !== '') { + return `data:image/png;base64,${p.mImage.base64}`; + } + if (typeof p.mImage === 'string' && p.mImage.trim() !== '') { + return p.mImage.startsWith('data:') ? p.mImage : `data:image/png;base64,${p.mImage}`; + } + } + if (p.mThumbnail) { + if (p.mThumbnail.mData && p.mThumbnail.mData.base64 && p.mThumbnail.mData.base64.trim() !== '') { + return `data:image/png;base64,${p.mThumbnail.mData.base64}`; + } + if (typeof p.mThumbnail.base64 === 'string' && p.mThumbnail.base64.trim() !== '') { + return `data:image/png;base64,${p.mThumbnail.base64}`; + } + if (typeof p.mThumbnail === 'string' && p.mThumbnail.trim() !== '') { + return p.mThumbnail.startsWith('data:') ? p.mThumbnail : `data:image/png;base64,${p.mThumbnail}`; + } + } + + // Only extract embedded images: remote URLs can expose the reader's IP to peers. + const text = p.mNotes || p.mBody || item.notes || item.body || ''; + if (typeof text === 'string') { + const dataMatch = text.match(/data:image\/[a-zA-Z]+;base64,[^"\s)]+/); + if (dataMatch) return dataMatch[0]; + } + + return ''; +} + +/** + * Dedicated fullscreen photo overlay appended directly to document.body. + * Bypasses #modal-container entirely so z-index is guaranteed. + */ +let _photoOverlayEl = null; + +function getPhotoOverlay() { + if (!_photoOverlayEl) { + _photoOverlayEl = document.createElement('div'); + _photoOverlayEl.id = 'photo-view-overlay'; + document.body.appendChild(_photoOverlayEl); + } + return _photoOverlayEl; +} + +function closePhotoOverlay() { + if (_photoOverlayEl) { + _photoOverlayEl.style.display = 'none'; + m.render(_photoOverlayEl, null); + } +} + +/** + * PhotoView Lightbox — Qt GUI style: nav arrows outside the image in a 3-col flex row + */ +function PhotoViewModal() { + let currentIndex = 0; + + function navigate(photoList, newIndex) { + currentIndex = newIndex; + m.render(getPhotoOverlay(), m(PhotoViewModal, { + photoList, + photoIndex: currentIndex, + })); + } + + return { + oninit: (vnode) => { + currentIndex = vnode.attrs.photoIndex || 0; + }, + view: (vnode) => { + const { photoList = [] } = vnode.attrs; + if (!photoList || photoList.length === 0) return null; + + if (currentIndex < 0) currentIndex = 0; + if (currentIndex >= photoList.length) currentIndex = photoList.length - 1; + + const currentItem = photoList[currentIndex]; + if (!currentItem) return null; + + const p = currentItem.post || currentItem; + const meta = (p && p.mMeta) ? p.mMeta : (currentItem.mMeta || {}); + const title = currentItem.title || meta.mMsgName || 'Photo View'; + const imgSrc = extractImageSrc(currentItem); + const author = meta.mAuthorId ? meta.mAuthorId.substring(0, 10) : 'Unknown'; + const publishTs = meta.mPublishTs || currentItem.created; + const dateStr = publishTs + ? (typeof publishTs === 'object' && publishTs.xint64 + ? new Date(publishTs.xint64 * 1000).toLocaleString() + : new Date(publishTs * 1000).toLocaleString()) + : ''; + + const hasPrev = currentIndex > 0; + const hasNext = currentIndex < photoList.length - 1; + + return m('.photo-view-dialog', [ + // Header: italic title + X close (Qt style) + m('.photo-view-header', [ + m('h3.photo-view-title', title), + m('button.photo-view-close-btn', { + type: 'button', + onclick: closePhotoOverlay, + title: 'Close', + }, '\u00d7'), + ]), + + // Body: 3-column flex [left-nav] [image] [right-nav] + // Arrows are outside the image, matching Qt GUI + m('.photo-view-body', [ + m('.photo-view-nav-col', [ + hasPrev + ? m('button.photo-view-nav-btn', { + type: 'button', + title: 'Previous', + onclick: (e) => { e.stopPropagation(); navigate(photoList, currentIndex - 1); }, + }, m('i.fas.fa-chevron-left')) + : null, + ]), + m('.photo-view-img-wrap', [ + imgSrc + ? m('img.photo-view-img', { src: imgSrc, alt: title }) + : m('.photo-view-no-img', 'No image available'), + ]), + m('.photo-view-nav-col', [ + hasNext + ? m('button.photo-view-nav-btn', { + type: 'button', + title: 'Next', + onclick: (e) => { e.stopPropagation(); navigate(photoList, currentIndex + 1); }, + }, m('i.fas.fa-chevron-right')) + : null, + ]), + ]), + + // Footer: author + date only + m('.photo-view-footer', [ + m('.photo-view-meta', [ + m('span', 'Posted by '), + m('b', author), + dateStr ? m('span', ` \u2022 ${dateStr}`) : null, + ]), + ]), + ]); + }, + }; +} + +/** + * Open PhotoView — renders into a dedicated body-appended overlay. + * No #modal-container dependency, guaranteed z-index 999999. + */ +function openPhotoModal(photoList, photoIndex) { + const overlay = getPhotoOverlay(); + Object.assign(overlay.style, { + position: 'fixed', + inset: '0', + zIndex: '999999', + backgroundColor: 'rgba(0,0,0,0.85)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }); + m.render(overlay, m(PhotoViewModal, { + photoList, + photoIndex, + })); +} + +/** + * BoardCard Component Factory + */ +function BoardCard() { + return { + view: (vnode) => { + const { item, viewMode, onOpenComments, onOpenPhoto, forumId, voterId } = vnode.attrs; + if (!item) return null; + + // Extract item properties with fallback defaults + const title = item.title || item.mMsgName || (item.post && item.post.mMeta && item.post.mMeta.mMsgName) || 'Untitled Post'; + const notes = util.plainText(item.notes || item.mNotes || item.mBody || (item.post && (item.post.mNotes || item.post.mBody)) || ''); + const hasNotes = hasNotesText(notes); + + // Author & Date details + const meta = (item.post && item.post.mMeta) ? item.post.mMeta : (item.mMeta || {}); + const author = meta.mAuthorId ? meta.mAuthorId.substring(0, 10) : (item.author || 'cluster'); + const publishTs = meta.mPublishTs ? meta.mPublishTs : item.created; + const dateString = publishTs + ? (typeof publishTs === 'object' && publishTs.xint64 ? new Date(publishTs.xint64 * 1000).toLocaleString() : new Date(publishTs * 1000).toLocaleString()) + : ''; + + // RsPostedPost keeps calculated vote totals on the post, not mMeta. + const post = item.post || item; + const upVotes = numberValue(post.mUpVotes !== undefined ? post.mUpVotes : meta.mUpVotes); + const downVotes = numberValue(post.mDownVotes !== undefined ? post.mDownVotes : meta.mDownVotes); + const score = upVotes - downVotes; + + // Thumbnail resolution via extractImageSrc + const thumbnailSrc = extractImageSrc(item); + + // Comment count + const commentCount = item.commentCount !== undefined + ? item.commentCount + : item.mCommentCount !== undefined + ? item.mCommentCount + : item.mComments !== undefined + ? item.mComments + : (meta.mComments !== undefined + ? meta.mComments + : (meta.mChildCount !== undefined ? meta.mChildCount : 0)); + + const msgId = item.msgId || item.mMsgId || (item.key ? item.key : null); + + return m( + '.board-card', + { + class: `board-card board-card--${viewMode}`, + tabindex: 0, + role: 'article', + 'aria-label': title, + }, + [ + // Image / Thumbnail Section (Clicking opens PhotoView modal!) + m( + '.board-card__image-container', + { + title: thumbnailSrc ? 'Click to view photo' : 'View photo', + style: 'cursor: pointer', + onclick: (e) => { + e.stopPropagation(); + if (onOpenPhoto) { + onOpenPhoto(item); + } + }, + }, + [ + thumbnailSrc + ? m('img.board-card__image', { + src: thumbnailSrc, + alt: title, + loading: 'lazy', + onerror: (e) => { + e.target.style.display = 'none'; + if (e.target.nextSibling) { + e.target.nextSibling.style.display = 'flex'; + } + }, + }) + : null, + m( + '.board-card__placeholder-wrapper', + { style: { display: thumbnailSrc ? 'none' : 'flex' } }, + m(FallbackImage) + ), + ] + ), + + // Card Content Body + m('.board-card__content', [ + // Title (blue link matching Qt GUI) + m( + 'h4.board-card__title', + m('button.board-card__title-button[type=button]', { + title, + onclick: (e) => { + e.stopPropagation(); + if (onOpenComments) { + onOpenComments(item, msgId, forumId); + } + }, + }, title) + ), + + // Metadata Line (Posted by ) + m('.board-card__meta', [ + m('span', 'Posted by '), + m('b', author), + dateString ? m('span', ` ${dateString}`) : null, + ]), + + // Card Actions Line. Notes stay out of the card preview and open in a dedicated dialog. + m('.board-card__footer', [ + hasNotes ? m( + 'button.board-card__notes-btn[type=button]', + { + title: 'View notes', + onclick: (e) => { + e.stopPropagation(); + util.popupmessage(m('.board-notes-dialog', [ + m('h3', title), + m('p.board-notes-dialog__label', 'Notes'), + m('p.board-notes-dialog__content', notes), + ])); + }, + }, + [m('i.fas.fa-sticky-note'), m('span', 'View notes')] + ) : null, + m( + 'button.board-card__comments-btn', + { + type: 'button', + 'aria-label': `View ${commentCount} comments for ${title}`, + title: `Comments (${commentCount})`, + onclick: (e) => { + e.stopPropagation(); + if (onOpenComments) { + onOpenComments(item, msgId, forumId); + } else if (msgId && forumId) { + m.route.set('/boards/:tab/:mGroupId/:mMsgId', { + tab: m.route.param().tab || 'Subscribed', + mGroupId: forumId, + mMsgId: msgId, + }); + } + }, + }, + [ + m('i.fas.fa-comment-alt.board-card__comments-icon'), + m('span.board-card__comments-label', commentCount > 0 ? `${commentCount} comment${commentCount === 1 ? '' : 's'}` : 'Comment'), + ] + ), + m('.board-card__vote-pill', [ + m( + 'button.board-card__vote-btn.board-card__vote-btn--up[type=button][title=Upvote]', + { + disabled: !voterId, + title: voterId ? 'Upvote' : 'Select a voter identity first', + onclick: async (e) => { + e.stopPropagation(); + if (forumId && msgId) { + const voted = await util.voteForPost(forumId, msgId, util.GXS_VOTE_UP, voterId); + if (voted) { + post.mUpVotes = numberValue(post.mUpVotes) + 1; + m.redraw(); + } + } + }, + }, + [m('i.fas.fa-arrow-up')] + ), + m('span.board-card__vote-score', score), + m( + 'button.board-card__vote-btn.board-card__vote-btn--down[type=button][title=Downvote]', + { + disabled: !voterId, + title: voterId ? 'Downvote' : 'Select a voter identity first', + onclick: async (e) => { + e.stopPropagation(); + if (forumId && msgId) { + const voted = await util.voteForPost(forumId, msgId, util.GXS_VOTE_DOWN, voterId); + if (voted) { + post.mDownVotes = numberValue(post.mDownVotes) + 1; + m.redraw(); + } + } + }, + }, + [m('i.fas.fa-arrow-down')] + ), + ]), + ]), + ]), + ] + ); + }, + }; +} + +/** + * Toolbar Component Factory + */ +function Toolbar() { + return { + view: (vnode) => { + const { + viewMode, + onViewModeChange, + itemCount, + searchString, + onSearchInput, + currentPage, + totalPages, + onPageChange, + startItem, + endItem, + voterIdentities = [], + voterId, + voterIdentitiesLoading, + onVoterIdChange, + } = vnode.attrs; + + return m('.board-toolbar', { role: 'toolbar', 'aria-label': 'Board View Controls' }, [ + // Left section: Search Filter + m('.board-toolbar__left', [ + vnode.attrs.onCreatePost && m('button.board-toolbar__create-post[type=button][title=Create Post][aria-label=Create Post]', { + onclick: vnode.attrs.onCreatePost, + }, m('i.fas.fa-plus')), + onSearchInput + ? m('.board-toolbar__search', [ + m('i.fas.fa-search.board-toolbar__search-icon'), + m('input.board-toolbar__search-input[type=text][placeholder=Search...]', { + value: searchString || '', + oninput: (e) => onSearchInput(e.target.value), + }), + ]) + : null, + ]), + + // Right section: View Switcher AND Pagination inline + m('.board-toolbar__right', [ + // View Mode Switcher + m('.board-toolbar__view-toggle', { role: 'radiogroup', 'aria-label': 'Display Mode' }, [ + m( + 'button.board-toolbar__toggle-btn', + { + type: 'button', + class: viewMode === 'compact' ? 'board-toolbar__toggle-btn--active' : '', + role: 'radio', + 'aria-checked': viewMode === 'compact', + title: 'Switch to Compact View', + onclick: () => onViewModeChange('compact'), + }, + [ + m('i.fas.fa-bars'), + m('span', 'Compact View'), + ] + ), + m( + 'button.board-toolbar__toggle-btn', + { + type: 'button', + class: viewMode === 'card' ? 'board-toolbar__toggle-btn--active' : '', + role: 'radio', + 'aria-checked': viewMode === 'card', + title: 'Switch to Card View', + onclick: () => onViewModeChange('card'), + }, + [ + m('i.fas.fa-th-large'), + m('span', 'Card View'), + ] + ), + ]), + + // Pagination Controls (< 1 - 25 >) + itemCount > 0 + ? m('.board-pagination', { 'aria-label': 'Pagination Controls' }, [ + m( + 'button.board-pagination__btn.board-pagination__btn--prev', + { + type: 'button', + title: 'Previous Page', + disabled: currentPage <= 1, + onclick: () => onPageChange(currentPage - 1), + }, + m('i.fas.fa-chevron-left') + ), + m( + 'span.board-pagination__label', + `${startItem} - ${endItem}` + ), + m( + 'button.board-pagination__btn.board-pagination__btn--next', + { + type: 'button', + title: 'Next Page', + disabled: currentPage >= totalPages, + onclick: () => onPageChange(currentPage + 1), + }, + m('i.fas.fa-chevron-right') + ), + ]) + : null, + m('.board-toolbar__voter', [ + m('select#board-post-voter', { + value: voterId || '', + disabled: voterIdentitiesLoading || voterIdentities.length === 0, + onchange: (e) => onVoterIdChange && onVoterIdChange(e.target.value), + title: 'Identity used to vote on posts', + 'aria-label': 'Identity used to vote on posts', + }, voterIdentities.length > 0 + ? voterIdentities.map((identity) => m('option', { value: identity.id }, identity.label)) + : m('option', { value: '' }, voterIdentitiesLoading ? 'Loading identities...' : 'No identity available')), + ]), + ]), + ]); + }, + }; +} + +/** + * CommentsViewer Modal Trigger — navigates to the boards post detail route + */ +function openCommentsModal(item, msgId, forumId) { + const tab = m.route.param().tab || 'Subscribed'; + m.route.set('/boards/:tab/:mGroupId/:mMsgId', { + tab, + mGroupId: forumId, + mMsgId: msgId, + }); +} + +/** + * Main BoardView Component Factory + * Manages view mode (default: compact), search filtering, 25-item page pagination + */ +function BoardView() { + let viewMode = 'compact'; + let filterText = ''; + let currentPage = 1; + + return { + view: (vnode) => { + const { + items = [], forumId, onOpenComments, voterIdentities = [], voterId, + voterIdentitiesLoading, onVoterIdChange, + } = vnode.attrs; + + // Filter items + const filteredItems = items.filter((item) => { + if (!filterText.trim()) return true; + const query = filterText.toLowerCase(); + const title = (item.title || item.mMsgName || (item.post && item.post.mMeta && item.post.mMeta.mMsgName) || '').toLowerCase(); + const notes = (item.notes || item.mNotes || item.mBody || (item.post && (item.post.mNotes || item.post.mBody)) || '').toLowerCase(); + return title.includes(query) || notes.includes(query); + }); + + // Automatically sort posts by publish timestamp descending (newest posts on top) + filteredItems.sort((a, b) => { + const getTs = (item) => { + const p = item.post || item; + const meta = p.mMeta || item.mMeta || {}; + const ts = meta.mPublishTs || p.mPublishTs || item.created || 0; + if (ts && typeof ts === 'object' && ts.xint64 !== undefined) return Number(ts.xint64); + if (typeof ts === 'number') return ts; + if (typeof ts === 'string') { const n = Number(ts); return isNaN(n) ? 0 : n; } + return 0; + }; + return getTs(b) - getTs(a); + }); + + // Pagination math (25 posts max per page) + const totalFiltered = filteredItems.length; + const totalPages = Math.max(1, Math.ceil(totalFiltered / PAGE_SIZE)); + if (currentPage > totalPages) { + currentPage = totalPages; + } + if (currentPage < 1) { + currentPage = 1; + } + + const startIndex = (currentPage - 1) * PAGE_SIZE; + const endIndex = Math.min(startIndex + PAGE_SIZE, totalFiltered); + const pagedItems = filteredItems.slice(startIndex, endIndex); + + const startItemNum = totalFiltered > 0 ? startIndex + 1 : 0; + const endItemNum = endIndex; + + // Items with photos for PhotoView modal + const photoItems = pagedItems.filter((item) => { + return extractImageSrc(item) !== ''; + }); + + const modalPhotos = photoItems.length > 0 ? photoItems : pagedItems; + + return m('.board-view-container', [ + // Top Toolbar with Pagination + m(Toolbar, { + key: 'toolbar-node', + onCreatePost: vnode.attrs.onCreatePost, + viewMode, + onViewModeChange: (newMode) => { + viewMode = newMode; + m.redraw(); + }, + itemCount: totalFiltered, + searchString: filterText, + onSearchInput: (text) => { + filterText = text; + currentPage = 1; + }, + currentPage, + totalPages, + onPageChange: (newPage) => { + currentPage = newPage; + m.redraw(); + }, + startItem: startItemNum, + endItem: endItemNum, + voterIdentities, + voterId, + voterIdentitiesLoading, + onVoterIdChange, + }), + + // Board Grid (rendering paged slice of 25 items max) + pagedItems.length > 0 + ? m( + '.board-grid', + { + key: 'grid-node', + class: `board-grid board-grid--${viewMode}`, + role: 'region', + 'aria-label': 'Board items', + }, + pagedItems.map((item, index) => { + const itemKey = item.key || item.msgId || item.mMsgId || index; + return m(BoardCard, { + key: `card-${itemKey}`, + item, + viewMode, + forumId, + voterId, + onOpenComments: onOpenComments || ((itemObj, mId, fId) => openCommentsModal(itemObj, mId, fId)), + onOpenPhoto: (clickedItem) => { + const photoIdx = modalPhotos.findIndex((pi) => { + const k1 = pi.key || pi.msgId || pi.mMsgId || (pi.post && pi.post.mMeta && pi.post.mMeta.mMsgId); + const k2 = clickedItem.key || clickedItem.msgId || clickedItem.mMsgId || (clickedItem.post && clickedItem.post.mMeta && clickedItem.post.mMeta.mMsgId); + return (k1 && k2 && k1 === k2) || pi === clickedItem; + }); + openPhotoModal(modalPhotos, photoIdx >= 0 ? photoIdx : 0); + }, + }); + }) + ) + : m('.board-grid__empty', { key: 'empty-node' }, [ + m('i.fas.fa-inbox.board-grid__empty-icon'), + m('p.board-grid__empty-title', 'No items found'), + m('p.board-grid__empty-desc', filterText ? 'Try adjusting your search criteria.' : 'This board currently has no posts.'), + ]), + ]); + }, + }; +} + +module.exports = { + BoardView, + BoardCard, + Toolbar, + PhotoViewModal, + openPhotoModal, + openCommentsModal, + extractImageSrc, + hasNotesText, +}; diff --git a/webui-src/app/boards/board_view.js b/webui-src/app/boards/board_view.js index fe60498..b9ac239 100644 --- a/webui-src/app/boards/board_view.js +++ b/webui-src/app/boards/board_view.js @@ -1,151 +1,137 @@ const m = require('mithril'); -const rs = require('rswebui'); const util = require('boards/boards_util'); +const boardKanban = require('boards/board_kanban'); +const rs = require('rswebui'); +const peopleUtil = require('people/people_util'); +const { CommentsSection } = require('comments'); const Data = util.Data; -const messageGroups = ['Public', 'Restricted Circle', 'Restricted Node Group']; -const messageGroupsCode = [util.PUBLIC, util.EXTERNAL, util.NODES_GROUP]; // rsgxscirles.h:50 - function createboard() { let title; let body; let identity; let thumbnail; - let selectedGroup = messageGroups[0]; - let selectedGroupCode = messageGroupsCode[0]; + let thumbnailPreview = ''; + let thumbnailFileName = ''; + let circle = util.PUBLIC; + let circles = []; let selectedCircle; - let circles; return { oninit: async (vnode) => { if (vnode.attrs.authorId) { identity = vnode.attrs.authorId[0]; } - const res = await rs.rsJsonApiRequest('/rsgxscircles/getCirclesSummaries'); if (res.body.retval) { - circles = res.body.circles; - selectedCircle = circles[0].mGroupName; + circles = res.body.circles || []; + selectedCircle = circles[0]; } }, view: (vnode) => - m('.widget', [ - m('h3', 'Create Board'), - m('hr'), - m('input[type=text][placeholder=Title]', { - style: { float: 'left' }, + m('.widget.create-board-form', [ + m('.create-board-form__heading', [ + m('h3', 'Create Board'), + m('p', 'Set up the board appearance and publishing options.'), + ]), + m('input.create-board-form__title[type=text][placeholder=Board title]', { oninput: (e) => (title = e.target.value), }), - m('div', { style: { float: 'right', marginTop: '10px', marginBottom: '10px' } }, [ - m('label[for=thumbnail]', 'Thumbnail: '), - m('input[type=file][name=files][id=thumbnail][accept=image/*]', { - onchange: async (e) => { + m('.create-board-form__visual', [ + m('.board-thumbnail-preview', [ + thumbnailPreview + ? m('img', { src: thumbnailPreview, alt: 'Board thumbnail preview' }) + : m('.board-thumbnail-preview__placeholder', [ + m('i.fas.fa-image'), + m('span', 'Board logo'), + m('small', 'No image selected'), + ]), + ]), + m('span.create-board-form__visual-label', 'Thumbnail'), + m('input.create-board-form__file-input[type=file][id=board-thumbnail][accept=image/*]', { + onchange: (e) => { + const file = e.target.files[0]; + if (!file) { + thumbnail = undefined; + thumbnailPreview = ''; + thumbnailFileName = ''; + return; + } + thumbnailFileName = file.name; const reader = new FileReader(); - reader.onloadend = function () { - thumbnail = reader.result.substring(reader.result.indexOf(',') + 1); + reader.onloadend = () => { + thumbnailPreview = reader.result; + thumbnail = thumbnailPreview.substring(thumbnailPreview.indexOf(',') + 1); + m.redraw(); }; - reader.readAsDataURL(e.target.files[0]); + reader.readAsDataURL(file); }, }), + m('label.create-board-form__file-button[for=board-thumbnail]', { + title: thumbnailFileName || 'Choose a board thumbnail', + }, [m('i.fas.fa-upload'), thumbnailPreview ? ' Change image' : ' Choose image']), + m('small', 'Square images work best.'), ]), - - m('div', { style: { float: 'right', marginTop: '10px', marginBottom: '10px' } }, [ - m('label[for=idtags]', 'Select identity: '), - m( - 'select[id=idtags]', - { - value: identity, - onchange: (e) => { - identity = vnode.attrs.authorId[e.target.selectedIndex]; - }, - }, - [ - vnode.attrs.authorId && - vnode.attrs.authorId.map((o) => - m( - 'option', - { value: o }, - rs.userList.userMap[o] - ? rs.userList.userMap[o].toLocaleString() - : 'No Signature' - ) - ), - ] - ), + m('.create-board-form__field.create-board-form__identity', [ + m('label[for=idtags]', 'Publishing identity'), + m('select.config-style-select[id=idtags]', { + value: identity, + onchange: (e) => (identity = vnode.attrs.authorId[e.target.selectedIndex]), + }, vnode.attrs.authorId && vnode.attrs.authorId.map((o) => m( + 'option', + { value: o }, + Number(o) === 0 ? 'No Signature' : `${rs.userList.username(o)} (${o.slice(0, 8)}...)` + ))), ]), - m('div', { style: { float: 'left', marginTop: '10px', marginBottom: '10px' } }, [ - m('label[for=mtags]', 'Message Distribution: '), - m( - 'select[id=mtags]', - { - value: selectedGroup, - onchange: (e) => { - selectedGroup = messageGroups[e.target.selectedIndex]; - selectedGroupCode = messageGroupsCode[e.target.selectedIndex]; - util.popupmessage(m(createboard, { authorId: vnode.attrs.authorId })); - }, - }, - [messageGroups.map((group) => m('option', { value: group }, group))] - ), + m('.create-board-form__field.create-board-form__distribution', [ + m('label[for=circletags]', 'Message distribution'), + m('select.config-style-select[id=circletags]', { + value: circle, + onchange: (e) => (circle = e.target.value), + }, [ + m('option', { value: util.PUBLIC }, '🌐 Public'), + m('option', { value: util.EXTERNAL }, '◉ Restricted to External Circle'), + ]), ]), - circles && - m( - 'div', - { - style: { - float: 'left', - marginTop: '10px', - marginBottom: '10px', - display: selectedGroupCode === util.EXTERNAL ? 'block' : 'none', - }, + Number(circle) === util.EXTERNAL && m('.create-board-form__field.create-board-form__circle', [ + m('label[for=board-circle]', 'Circle'), + m('select.config-style-select[id=board-circle]', { + value: selectedCircle && selectedCircle.mGroupId, + onchange: (e) => { + selectedCircle = circles.find((item) => item.mGroupId === e.target.value); }, - [ - m('label[for=circlestag]', 'Circles: '), - m( - 'select[id=circlestag]', - { - value: selectedCircle, - onchange: (e) => { - selectedCircle = circles[e.target.selectedIndex]; - console.log(selectedCircle); - // selectedGroupCode = messageGroupsCode[e.target.selectedIndex]; - }, - }, - [ - circles.map((circle) => - m('option', { value: circle.mGroupName }, circle.mGroupName) - ), - ] - ), - ] - ), - m('textarea[rows=5][placeholder=Description]', { - style: { width: '100%', display: 'block' }, + }, circles.length + ? circles.map((item) => m('option', { value: item.mGroupId }, item.mGroupName)) + : m('option[disabled]', 'No circles available')), + ]), + m('textarea.create-board-form__description[rows=5][placeholder=Describe your board]', { oninput: (e) => (body = e.target.value), value: body, }), m( - 'button', + 'button.create-board-form__submit', { onclick: async () => { const res = await rs.rsJsonApiRequest('/rsposted/createBoardV2', { - name: title, - description: body, - thumbnail: { mData: { base64: thumbnail } }, + board_name: title, + board_description: body, + board_image: { mData: { base64: thumbnail } }, ...(Number(identity) !== 0 && { authorId: identity }), - circleType: selectedGroupCode, - ...(selectedGroupCode === util.EXTERNAL && - selectedCircle && { circleId: selectedCircle.mGroupId }), + circleType: Number(circle), + ...(Number(circle) === util.EXTERNAL && selectedCircle && { + circleId: selectedCircle.mGroupId, + }), }); - if (res.body.retval) { - util.updatedisplayboards(res.body.boardId); - m.redraw(); - } - res.body.retval === false - ? util.popupmessage([m('h3', 'Error'), m('hr'), m('p', res.body.errorMessage)]) - : util.popupmessage([ + if (res.body.retval && vnode.attrs.onCreated) await vnode.attrs.onCreated(); + res.body.retval + ? util.popupmessage([ m('h3', 'Success'), m('hr'), m('p', 'Board created successfully'), + ]) + : util.popupmessage([ + m('h3', 'Error'), + m('hr'), + m('p', res.body.errorMessage || 'Error in creating Board'), ]); }, }, @@ -155,144 +141,592 @@ function createboard() { }; } -const BoardView = () => { - let bname = ''; - let bimage = ''; - let bauthor = ''; - let bsubscribed = {}; - let bposts = 0; - let plist = {}; - let createDate = {}; - let lastActivity = {}; +function CreatePost() { + let mode = 'post'; + let title = ''; + let notes = ''; + let link = ''; + let authorId; + let identities = []; + let imageBase64; + let imagePreview = ''; + let imageFileName = ''; + let imageError = ''; + let submitting = false; + + const readDataUrl = (file) => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + const loadImage = (source) => new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = reject; + image.src = source; + }); + + async function preparePostImage(file) { + const original = await readDataUrl(file); + const isAnimatedFormat = file.type === 'image/gif' || file.type === 'image/webp'; + if (isAnimatedFormat && file.size <= 194000) return original; + + const sourceImage = await loadImage(original); + const scale = Math.min(1, 640 / sourceImage.naturalWidth, 480 / sourceImage.naturalHeight); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(sourceImage.naturalWidth * scale)); + canvas.height = Math.max(1, Math.round(sourceImage.naturalHeight * scale)); + const context = canvas.getContext('2d'); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, canvas.width, canvas.height); + context.drawImage(sourceImage, 0, 0, canvas.width, canvas.height); + + let result; + for (let quality = 0.88; quality >= 0.35; quality -= 0.08) { + result = canvas.toDataURL('image/jpeg', quality); + const bytes = Math.ceil((result.length - result.indexOf(',') - 1) * 3 / 4); + if (bytes <= 190000) return result; + } + throw new Error('The image is too large to fit in a Board post.'); + } + + return { + oninit: async () => { + identities = (await peopleUtil.ownIds()) || []; + identities = identities.filter((id) => Number(id) !== 0); + authorId = identities[0]; + m.redraw(); + }, + view: (vnode) => m('.widget.create-board-post', [ + m('.create-board-post__heading', [ + m('h3', 'Create a Post'), + m('p', 'Share an interesting post with a clear, descriptive title.'), + ]), + m('.create-board-post__modes', [ + ['post', 'fa-comment-alt', 'Post'], + ['image', 'fa-image', 'Image'], + ['link', 'fa-link', 'Link'], + ].map(([value, icon, label]) => m('button[type=button]', { + class: mode === value ? 'active' : '', + onclick: () => (mode = value), + }, [m(`i.fas.${icon}`), ` ${label}`]))), + m('input.create-board-post__title[type=text][placeholder=Post title]', { + value: title, + oninput: (e) => (title = e.target.value), + }), + mode === 'link' && m('input.create-board-post__link[type=url][placeholder=https://example.com]', { + value: link, + oninput: (e) => (link = e.target.value), + }), + mode === 'image' && m('.create-board-post__image', [ + m('.create-board-post__preview', [ + imagePreview + ? m('img', { src: imagePreview, alt: 'Post image preview' }) + : m('.create-board-post__placeholder', [m('i.fas.fa-image'), m('span', 'Post image')]), + ]), + m('input.create-board-post__file[type=file][id=board-post-image][accept=image/*]', { + onchange: async (e) => { + const file = e.target.files[0]; + if (!file) return; + imageFileName = file.name; + imageError = ''; + try { + imagePreview = await preparePostImage(file); + imageBase64 = imagePreview.substring(imagePreview.indexOf(',') + 1); + } catch (error) { + imagePreview = ''; + imageBase64 = undefined; + imageError = error.message || 'The selected image could not be prepared.'; + } + m.redraw(); + }, + }), + m('label.create-board-post__file-button[for=board-post-image]', { + title: imageFileName || 'Choose a post image', + }, [m('i.fas.fa-upload'), imagePreview ? ' Change image' : ' Choose image']), + imageError && m('.create-board-post__image-error', imageError), + ]), + mode === 'post' && m('textarea.create-board-post__notes[rows=8][placeholder=Text (optional)]', { + value: notes, + oninput: (e) => (notes = e.target.value), + }), + m('.create-board-post__author', [ + m('label[for=board-post-author]', 'Post as'), + m('select.config-style-select.network-style-select[id=board-post-author]', { + value: authorId, + onchange: (e) => (authorId = e.target.value), + disabled: identities.length === 0, + }, identities.length + ? identities.map((id) => m('option', { value: id }, `${rs.userList.username(id)} (${id.slice(0, 8)}...)`)) + : m('option', 'No signed identity available')), + ]), + m('button.create-board-post__submit[type=button]', { + disabled: submitting || !title.trim() || !authorId || + (mode === 'link' && !link.trim()) || (mode === 'image' && !imageBase64), + onclick: async () => { + submitting = true; + m.redraw(); + try { + const res = await rs.rsJsonApiRequest('/rsposted/createPostV2', { + boardId: vnode.attrs.boardId, + title: title.trim(), + link: { urlString: mode === 'link' ? link.trim() : '' }, + notes: mode === 'link' ? '' : notes, + authorId, + image: { mData: { base64: mode === 'image' ? imageBase64 : undefined } }, + }); + if (res.body.retval) { + Data.Posts[vnode.attrs.boardId] = {}; + await util.updateDisplayBoards(vnode.attrs.boardId); + util.popupmessage([m('h3', 'Success'), m('hr'), m('p', 'Post created successfully')]); + } else { + util.popupmessage([m('h3', 'Error'), m('hr'), m('p', + res.body.error_message || res.body.errorMessage || 'The post could not be created')]); + } + } finally { + submitting = false; + m.redraw(); + } + }, + }, submitting ? 'Posting…' : 'Post'), + ]), + }; +} + +function BoardView() { + let lastLoadedBoardId = null; + let voterIdentities = []; + let voterId = null; + let voterIdentitiesLoading = true; + return { oninit: (v) => { - if (Data.DisplayBoards[v.attrs.id]) { - bname = Data.DisplayBoards[v.attrs.id].name; - bimage = Data.DisplayBoards[v.attrs.id].image; - if (rs.userList.userMap[Data.DisplayBoards[v.attrs.id].author]) { - bauthor = rs.userList.userMap[Data.DisplayBoards[v.attrs.id].author]; - } else if (Number(Data.DisplayBoards[v.attrs.id].author) === 0) { - bauthor = 'No Contact Author'; - } else { - bauthor = 'Unknown'; - } - bsubscribed = Data.DisplayBoards[v.attrs.id].isSubscribed; - bposts = Data.DisplayBoards[v.attrs.id].posts; - createDate = Data.DisplayBoards[v.attrs.id].created; - lastActivity = Data.DisplayBoards[v.attrs.id].activity; - } - if (Data.Posts[v.attrs.id]) { - plist = Data.Posts[v.attrs.id]; + lastLoadedBoardId = v.attrs.id; + util.updateDisplayBoards(v.attrs.id); + peopleUtil.ownIds((ids) => { + voterIdentities = (ids || []) + .filter((id) => Number(id) !== 0) + .map((id) => ({ + id, + label: rs.userList.username(id) || rs.userList.userMap[id] || `${String(id).slice(0, 10)}...`, + })); + voterId = voterIdentities[0] ? voterIdentities[0].id : null; + voterIdentitiesLoading = false; + m.redraw(); + }); + }, + onupdate: (v) => { + if (v.attrs.id && v.attrs.id !== lastLoadedBoardId) { + lastLoadedBoardId = v.attrs.id; + util.updateDisplayBoards(v.attrs.id); } }, - view: (v) => [ - m( - 'a[title=Back]', - { - onclick: () => - m.route.set('/boards/:tab', { - tab: m.route.param().tab, - }), - }, - m('i.fas.fa-arrow-left') - ), - m('.widget__heading', [ - m('h3', bname), + view: (v) => { + const boardInfo = Data.DisplayBoards[v.attrs.id] || {}; + const bname = boardInfo.name || ''; + const bimage = boardInfo.image || { mData: { base64: '' } }; + // userMap holds {name, isContact} objects: username() is what turns an + // id into a string fit for the view. + let bauthor = 'Unknown'; + if (boardInfo.author) { + bauthor = Number(boardInfo.author) === 0 + ? 'No Contact Author' + : rs.userList.username(boardInfo.author); + } + const bsubscribed = boardInfo.isSubscribed; + const toggleSubscription = async () => { + const res = await rs.rsJsonApiRequest('/rsposted/subscribeToBoard', { + boardId: v.attrs.id, + subscribe: !bsubscribed, + }); + if (res.body.retval) { + boardInfo.isSubscribed = !bsubscribed; + if (v.attrs.onSubscriptionChange) v.attrs.onSubscriptionChange(); + m.redraw(); + } + }; + const subscribeFlags = Number(boardInfo.subscribeFlags || 0); + const canPublish = (subscribeFlags & (util.GROUP_SUBSCRIBE_ADMIN | util.GROUP_SUBSCRIBE_PUBLISH)) !== 0; + const bposts = boardInfo.posts || 0; + const createDate = boardInfo.created; + const lastActivity = boardInfo.activity; + const plist = Data.Posts[v.attrs.id] || {}; + + const items = Object.keys(plist) + .filter((key) => plist[key] && (plist[key].isSearched === undefined || plist[key].isSearched)) + .map((key) => { + const itemObj = plist[key] || {}; + const p = itemObj.post || itemObj; + const meta = p.mMeta || {}; + + let thumb = ''; + if (p.mImage && p.mImage.mData && p.mImage.mData.base64) { + thumb = p.mImage.mData.base64; + } else if (p.mImage && typeof p.mImage.base64 === 'string') { + thumb = p.mImage.base64; + } else if (typeof p.mImage === 'string') { + thumb = p.mImage; + } else if (p.mThumbnail && p.mThumbnail.mData && p.mThumbnail.mData.base64) { + thumb = p.mThumbnail.mData.base64; + } else if (typeof p.thumbnail === 'string') { + thumb = p.thumbnail; + } + + const notesText = util.plainText(p.mNotes || p.mBody || meta.mNotes || p.notes || p.body || ''); + const titleText = meta.mMsgName || p.mMsgName || p.title || 'Untitled Post'; + // RsPosted exposes the calculated count as mComments on the post. + const commentCount = p.mComments !== undefined + ? p.mComments + : (meta.mChildCount !== undefined + ? meta.mChildCount + : (p.mCommentCount !== undefined ? p.mCommentCount : (p.commentCount !== undefined ? p.commentCount : 0))); + + return { + key, + msgId: key, + title: titleText, + thumbnail: thumb, + notes: notesText, + commentCount, + post: p, + }; + }); + + // Automatically sort posts by publish timestamp descending (newest on top) + items.sort((a, b) => { + const getTs = (item) => { + const p = item.post || item; + const meta = p.mMeta || item.mMeta || {}; + const ts = meta.mPublishTs || p.mPublishTs || item.created || 0; + if (ts && typeof ts === 'object' && ts.xint64 !== undefined) return Number(ts.xint64); + if (typeof ts === 'number') return ts; + if (typeof ts === 'string') { const n = Number(ts); return isNaN(n) ? 0 : n; } + return 0; + }; + return getTs(b) - getTs(a); + }); + + return [ + m('.board-detail-navigation', [ m( - 'button', + 'a.board-back[title=Back][aria-label=Back]', { - onclick: async () => { - const res = await rs.rsJsonApiRequest('/rsposted/subscribeToBoard', { - boardId: v.attrs.id, - subscribe: !bsubscribed, - }); - if (res.body.retval) { - bsubscribed = !bsubscribed; - Data.DisplayBoards[v.attrs.id].isSubscribed = bsubscribed; + onclick: () => + m.route.set('/boards/:tab', { + tab: m.route.param().tab || 'Subscribed', + }), + }, + m('i.fas.fa-arrow-left') + ), + m('details.board-mobile-actions', { + onkeydown: (event) => { + if (event.key === 'Escape') { + event.currentTarget.open = false; + event.currentTarget.querySelector('summary').focus(); } }, - }, - bsubscribed ? 'Subscribed' : 'Subscribe' - ), - ]), - m('.widget__body', [ - m('.media-item', [ - m('.media-item__details', [ - m('img', { - src: - bimage.mData.base64 === '' - ? 'data/streaming.png' - : `data:image/png;base64,${bimage.mData.base64}`, - }), - m('.media-item__details-info', [ - m('div', [m('b', 'Posts: '), m('span', bposts)]), - m('div', [ - m('b', 'Date created: '), - m( - 'span', - typeof createDate === 'object' - ? new Date(createDate.xint64 * 1000).toLocaleString() - : 'Unknown' - ), - ]), - m('div', [m('b', 'Admin: '), m('span', bauthor)]), - m('div', [ - m('b', 'Last activity: '), - m( - 'span', - typeof lastActivity === 'object' - ? new Date(lastActivity.xint64 * 1000).toLocaleString() - : 'Unknown' - ), - ]), - ]), - ]), - m('.media-item__desc', [ - m('b', 'Description: '), - m('span', Data.DisplayBoards[v.attrs.id].description || 'No Description'), + onfocusout: (event) => { + if (!event.currentTarget.contains(event.relatedTarget)) event.currentTarget.open = false; + }, + }, [ + m('summary[aria-label=Board actions][title=Board actions]', m('i.fas.fa-ellipsis-v')), + m('.board-mobile-actions__items', m('button[type=button]', { + onclick: (event) => { + const menu = event.currentTarget.closest('details'); + menu.open = false; + menu.querySelector('summary').focus(); + return toggleSubscription(); + }, + }, bsubscribed ? 'Unsubscribe' : 'Subscribe')), ]), ]), - m( - '.posts', - { - style: 'display:' + (bsubscribed ? 'flex' : 'none'), - }, - m('.posts__heading', m('h3', 'Posts')), + m('.widget__heading', [ + m('h3', bname), m( - '.posts-container', - Object.keys(plist).map((key, index) => [ - m( - '.posts-container-card', - { - style: 'display: ' + (plist[key].isSearched ? 'flex' : 'none'), - onclick: () => { - m.route.set('/boards/:tab/:mGroupId/:mMsgId', { - tab: m.route.param().tab, - mGroupId: v.attrs.id, - mMsgId: key, - }); - }, - }, - [ - m('img', { - src: - plist[key].post.mThumbnail.mData.base64 === '' - ? 'data/streaming.png' - : 'data:image/png;base64,' + plist[key].post.mThumbnail.mData.base64, - alt: 'No Thumbnail', - }), - m('p', plist[key].post.mMeta.mMsgName), - ] - ), - ]) - ) - ), - ]), - ], + 'button.board-subscription-button', + { + class: bsubscribed ? 'board-subscription-button--subscribed' : '', + onclick: toggleSubscription, + }, + bsubscribed ? 'Subscribed' : 'Subscribe' + ), + ]), + m('.widget__body', [ + m('.media-item', [ + m('.media-item__details', [ + bimage && bimage.mData && bimage.mData.base64 + ? m('img', { + src: `data:image/png;base64,${bimage.mData.base64}`, + alt: `${bname} board thumbnail`, + }) + : m('.board-detail-default-thumbnail[role=img][aria-label=Default board thumbnail]', + m('i.fas.fa-globe') + ), + m('.media-item__details-info', [ + m('div', [m('b', 'Posts: '), m('span', bposts)]), + m('div', [ + m('b', 'Date created: '), + m( + 'span', + typeof createDate === 'object' && createDate !== null + ? new Date(createDate.xint64 * 1000).toLocaleString() + : 'Unknown' + ), + ]), + m('div', [m('b', 'Admin: '), m('span', bauthor)]), + m('div', [ + m('b', 'Last activity: '), + m( + 'span', + typeof createDate === 'object' && lastActivity !== null && typeof lastActivity === 'object' + ? new Date(lastActivity.xint64 * 1000).toLocaleString() + : 'Unknown' + ), + ]), + ]), + ]), + m('.media-item__desc', [ + m('b', 'Description: '), + m('span', boardInfo.description || 'No Description'), + ]), + ]), + m( + '.posts', + { + style: 'display:' + (bsubscribed ? 'block' : 'none'), + }, + m('.posts__heading.board-posts-heading', [ + m('h3', 'Posts'), + canPublish && m('button.board-posts-heading__create[type=button][title=Create Post][aria-label=Create Post]', { + onclick: () => util.popupmessage( + m(CreatePost, { boardId: v.attrs.id }), + 'create-board-post-modal' + ), + }, [m('i.fas.fa-plus'), m('span', 'Create Post')]), + ]), + m(boardKanban.BoardView, { + forumId: v.attrs.id, + onCreatePost: canPublish ? () => util.popupmessage( + m(CreatePost, { boardId: v.attrs.id }), + 'create-board-post-modal' + ) : null, + items, + voterIdentities, + voterId, + voterIdentitiesLoading, + onVoterIdChange: (id) => { + voterId = id || null; + }, + }) + ), + ]), + ]; + }, }; -}; +} +/** + * PostView: Board post detail page (shown at /boards/:tab/:mGroupId/:mMsgId) + * Reads from Data.Posts[forumId][msgId]. The Posted API returns comments together + * with board content, so comments for this post are filtered by their thread id. + */ +function PostView() { + let comments = []; + let loadingComments = true; + let identities = []; + let voteIdentity = null; + let postVoteSubmitting = false; + let notesExpanded = false; + + const nameOf = (id) => (!id || Number(id) === 0 ? 'Anonymous' : (rs.userList.username(id) || rs.userList.userMap[id] || `${String(id).slice(0, 10)}…`)); + + async function loadComments(forumId, msgId) { + loadingComments = true; + comments = []; + try { + const res = await rs.rsJsonApiRequest('/rsPosted/getBoardAllContent', { boardId: forumId }); + if (res && res.body && res.body.retval) { + comments = (res.body.comments || res.body.commentList || []).filter((comment) => { + const meta = (comment && comment.mMeta) || {}; + return meta.mThreadId === msgId || (!meta.mThreadId && meta.mParentId === msgId); + }); + // The endpoints reachable over the JSON API never fill a comment's + // mUpVotes (only the unexposed getRelatedComments tallies), so the + // counts rendered from them were 0 forever. The votes travel in + // their own array here, one message per vote with mParentId naming + // its target: count them ourselves, the way the channels page does. + const counts = {}; + (res.body.votes || res.body.voteList || []).forEach((vote) => { + const parentId = vote && vote.mMeta && vote.mMeta.mParentId; + if (!parentId) return; + if (!counts[parentId]) counts[parentId] = { up: 0, down: 0 }; + if (vote.mVoteType === util.GXS_VOTE_UP) counts[parentId].up += 1; + else if (vote.mVoteType === util.GXS_VOTE_DOWN) counts[parentId].down += 1; + }); + comments.forEach((comment) => { + const tally = counts[(comment.mMeta && comment.mMeta.mMsgId) || '']; + comment.mUpVotes = tally ? tally.up : 0; + comment.mDownVotes = tally ? tally.down : 0; + }); + } + } catch (e) { + console.warn('PostView: failed to load comments', e); + } + loadingComments = false; + m.redraw(); + } + + return { + oninit: (v) => { + // Ensure board data is loaded + if (!Data.Posts[v.attrs.forumId] || !Data.Posts[v.attrs.forumId][v.attrs.msgId]) { + util.updateDisplayBoards(v.attrs.forumId); + } + loadComments(v.attrs.forumId, v.attrs.msgId); + + // A board comment must be signed by one of the user's identities. + peopleUtil.ownIds((ids) => { + identities = (ids || []).filter((id) => Number(id) !== 0); + voteIdentity = identities[0] || null; + m.redraw(); + }); + }, + view: (v) => { + const { forumId, msgId } = v.attrs; + const plist = Data.Posts[forumId] || {}; + const itemObj = plist[msgId] || {}; + const p = itemObj.post || itemObj; + const meta = (p && p.mMeta) ? p.mMeta : {}; + + const title = meta.mMsgName || p.mMsgName || p.title || 'Post'; + const notes = util.plainText(p.mNotes || p.mBody || p.notes || p.body || ''); + const hasLongNotes = notes.length > 280; + const author = meta.mAuthorId ? meta.mAuthorId.substring(0, 10) : 'Unknown'; + const publishTs = meta.mPublishTs || p.mPublishTs || null; + const dateStr = publishTs + ? (typeof publishTs === 'object' && publishTs.xint64 + ? new Date(publishTs.xint64 * 1000).toLocaleString() + : new Date(publishTs * 1000).toLocaleString()) + : ''; + const numberValue = (value) => { + if (value && typeof value === 'object') return Number(value.xint64 || value.xint32 || 0); + return Number(value || 0); + }; + const postUpVotes = numberValue(p.mUpVotes !== undefined ? p.mUpVotes : meta.mUpVotes); + const postDownVotes = numberValue(p.mDownVotes !== undefined ? p.mDownVotes : meta.mDownVotes); + + let imgSrc = ''; + if (p.mImage && p.mImage.mData && p.mImage.mData.base64 && p.mImage.mData.base64.trim()) { + imgSrc = `data:image/png;base64,${p.mImage.mData.base64}`; + } else if (p.mThumbnail && p.mThumbnail.mData && p.mThumbnail.mData.base64 && p.mThumbnail.mData.base64.trim()) { + imgSrc = `data:image/png;base64,${p.mThumbnail.mData.base64}`; + } + + return [ + m( + 'a.board-back[title=Back][aria-label=Back]', + { + onclick: () => + m.route.set('/boards/:tab/:mGroupId', { + tab: m.route.param().tab || 'Subscribed', + mGroupId: forumId, + }), + }, + m('i.fas.fa-arrow-left') + ), + m('.widget__heading', m('h3', title)), + m('.widget__body', [ + imgSrc + ? m('img', { + src: imgSrc, + alt: title, + style: { maxWidth: '100%', maxHeight: '400px', display: 'block', marginBottom: '1rem', borderRadius: '8px' }, + }) + : null, + m('.board-post-meta', [ + m('span', 'Posted by '), + m('b', author), + dateStr ? m('span', ` • ${dateStr}`) : null, + ]), + m('.board-post-voting', [ + m('.board-post-voting__identity', [ + m('label[for=board-post-voter]', 'Vote as'), + m('select#board-post-voter', { + value: voteIdentity || '', + disabled: identities.length === 0 || postVoteSubmitting, + onchange: (e) => { voteIdentity = e.target.value; }, + }, identities.length + ? identities.map((id) => m('option', { value: id }, nameOf(id))) + : m('option', { value: '' }, 'Loading identities…')), + ]), + m('.board-post-voting__buttons', [ + m('button[type=button][title=Upvote post]', { + disabled: !voteIdentity || postVoteSubmitting, + onclick: async () => { + postVoteSubmitting = true; + m.redraw(); + // The bump is what the reader sees: the core only retallies + // the stored count on its ~15 s background pass. + const voted = await util.voteForPost(forumId, msgId, util.GXS_VOTE_UP, voteIdentity); + if (voted) p.mUpVotes = numberValue(p.mUpVotes !== undefined ? p.mUpVotes : meta.mUpVotes) + 1; + postVoteSubmitting = false; + m.redraw(); + }, + }, [m('i.fas.fa-arrow-up'), ` ${postUpVotes}`]), + m('span.board-post-voting__score', postUpVotes - postDownVotes), + m('button[type=button][title=Downvote post]', { + disabled: !voteIdentity || postVoteSubmitting, + onclick: async () => { + postVoteSubmitting = true; + m.redraw(); + const voted = await util.voteForPost(forumId, msgId, util.GXS_VOTE_DOWN, voteIdentity); + if (voted) p.mDownVotes = numberValue(p.mDownVotes !== undefined ? p.mDownVotes : meta.mDownVotes) + 1; + postVoteSubmitting = false; + m.redraw(); + }, + }, [m('i.fas.fa-arrow-down'), ` ${postDownVotes}`]), + ]), + ]), + notes ? m('.post-description.board-post-description', [ + m('.post-description__text', { class: notesExpanded ? '' : 'post-description__text--collapsed', style: { whiteSpace: 'pre-wrap', maxHeight: notesExpanded ? 'none' : '4.5em', overflow: 'hidden', lineHeight: '1.5' } }, notes), + hasLongNotes ? m('button.post-description__toggle[type=button]', { onclick: () => { notesExpanded = !notesExpanded; } }, notesExpanded ? 'Show less' : '…more') : null, + ]) : null, + m('hr'), + m(CommentsSection, { + comments, + loading: loadingComments, + rootThreadId: msgId, + identities, + voteIdentity, + onVoteIdentity: (id) => { voteIdentity = id; }, + onSubmitComment: async ({ text, authorId, parentId }) => { + const res = await rs.rsJsonApiRequest('/rsPosted/createCommentV2', { + boardId: forumId, + postId: msgId, + comment: text, + authorId, + parentId: parentId || msgId, + }); + if (!res || !res.body || res.body.retval === false) { + throw new Error((res && res.body && res.body.errorMessage) || 'Your comment could not be posted.'); + } + await loadComments(forumId, msgId); + await util.updateDisplayBoards(forumId); + }, + onVoteComment: async ({ commentId, voteType, voteIdentity: voterId }) => { + await util.voteForComment(forumId, msgId, commentId, voteType, voterId); + await loadComments(forumId, msgId); + }, + }), + ]), + ]; + }, + }; +} module.exports = { BoardView, + PostView, createboard, }; diff --git a/webui-src/app/boards/boards.js b/webui-src/app/boards/boards.js index e31c11a..e678c28 100644 --- a/webui-src/app/boards/boards.js +++ b/webui-src/app/boards/boards.js @@ -7,41 +7,56 @@ const peopleUtil = require('people/people_util'); const getBoards = { All: [], - PopularBoards: [], - SubscribedBoards: [], + Popular: [], + Subscribed: [], MyBoards: [], - OtherBoards: [], + Other: [], async load() { - const res = await rs.rsJsonApiRequest('/rsPosted/getBoardsSummaries'); - const data = res.body; - getBoards.All = data.groupInfo; - getBoards.PopularBoards = getBoards.All; - getBoards.PopularBoards.sort((a, b) => b.mPop - a.mPop); - getBoards.OtherBoards = getBoards.PopularBoards.slice(5); - getBoards.PopularBoards = getBoards.PopularBoards.slice(0, 5); - getBoards.SubscribedBoards = getBoards.All.filter( - (board) => board.mSubscribeFlags === util.GROUP_SUBSCRIBE_SUBSCRIBED - ); - getBoards.MyBoards = getBoards.All.filter( - (board) => board.mSubscribeFlags === util.GROUP_MY_BOARD - ); + try { + const res = await rs.rsJsonApiRequest('/rsPosted/getBoardsSummaries'); + const boards = res && res.body && Array.isArray(res.body.groupInfo) ? res.body.groupInfo : null; + if (!boards) { + console.warn('Boards summaries response did not include groupInfo', res && res.body); + return; + } + getBoards.All = boards; + const popular = [...boards].sort((a, b) => (b.mPop || 0) - (a.mPop || 0)); + getBoards.Other = popular.slice(5); + getBoards.Popular = popular.slice(0, 5); + getBoards.Subscribed = boards.filter( + (board) => board.mSubscribeFlags === util.GROUP_SUBSCRIBE_SUBSCRIBED + ); + getBoards.MyBoards = boards.filter( + (board) => board.mSubscribeFlags === util.GROUP_MY_BOARD + ); + m.redraw(); + } catch (error) { + console.warn('Failed to load board summaries', error); + } }, }; +// Group lists change on the scale of a conversation, not of a frame. +const BOARD_LIST_REFRESH_MS = 30000; + const sections = { MyBoards: require('boards/my_boards'), - SubscribedBoards: require('boards/subscribed_boards'), - PopularBoards: require('boards/popular_boards'), - OtherBoards: require('boards/other_boards'), + Subscribed: require('boards/subscribed_boards'), + Popular: require('boards/popular_boards'), + Other: require('boards/other_boards'), }; const Layout = () => { let ownId; + const createBoard = () => ownId && util.popupmessage( + m(viewUtil.createboard, { authorId: ownId, onCreated: getBoards.load }), + 'create-board-modal' + ); return { oninit: () => { - rs.setBackgroundTask(getBoards.load, 5000, () => { - // return m.route.get() === '/files/files'; + rs.setBackgroundTask(getBoards.load, BOARD_LIST_REFRESH_MS, () => { + return m.route.get().startsWith('/boards'); }); peopleUtil.ownIds((data) => { ownId = data; @@ -54,18 +69,19 @@ const Layout = () => { }); }, view: (vnode) => - m('.widget', [ - m('.top-heading', [ + m('.widget', { + class: vnode.attrs.pathInfo.mGroupId && !vnode.attrs.pathInfo.mMsgId ? 'boards-detail-widget' : '', + }, [ + m('.top-heading', { + class: ['Subscribed', 'MyBoards', 'Popular', 'Other'].includes(vnode.attrs.pathInfo.tab) && !vnode.attrs.pathInfo.mGroupId + ? 'boards-subscribed-list-toolbar' : '', + }, [ m( - 'button', + 'button.boards-create-button', { - onclick: () => - ownId && - util.popupmessage( - m(viewUtil.createboard, { - authorId: ownId, - }) - ), + class: ['Subscribed', 'MyBoards', 'Other', 'Popular'].includes(vnode.attrs.pathInfo.tab) || vnode.attrs.pathInfo.mGroupId + ? 'boards-create-button--mobile-hidden' : '', + onclick: createBoard, }, 'Create Board' ), @@ -81,9 +97,11 @@ const Layout = () => { : Object.prototype.hasOwnProperty.call(vnode.attrs.pathInfo, 'mGroupId') ? m(viewUtil.BoardView, { id: vnode.attrs.pathInfo.mGroupId, + onSubscriptionChange: getBoards.load, }) : m(sections[vnode.attrs.pathInfo.tab], { list: getBoards[vnode.attrs.pathInfo.tab], + onCreateBoard: createBoard, }), ]), }; @@ -95,6 +113,7 @@ module.exports = { m(widget.Sidebar, { tabs: Object.keys(sections), baseRoute: '/boards/', + mobileDrawer: true, }), m('.node-panel', m(Layout, { pathInfo: vnode.attrs })), ]; diff --git a/webui-src/app/boards/boards_util.js b/webui-src/app/boards/boards_util.js index 1519b81..834bd6e 100644 --- a/webui-src/app/boards/boards_util.js +++ b/webui-src/app/boards/boards_util.js @@ -1,5 +1,7 @@ const m = require('mithril'); const rs = require('rswebui'); +const peopleUtil = require('people/people_util'); +const widget = require('widgets'); const GROUP_SUBSCRIBE_ADMIN = 0x01; // means: you have the admin key for this group const GROUP_SUBSCRIBE_PUBLISH = 0x02; // means: you have the publish key for thiss group. Typical use: publish key in channels are shared with specific friends. @@ -20,137 +22,365 @@ const Data = { Comments: {}, // threadID, msgID -> {Comment, showReplies} }; -async function updateDisplayBoards(keyid, details) { - const res1 = await rs.rsJsonApiRequest('/rsPosted/getBoardsInfo', { - boardsIds: [keyid], - }); - details = res1.body.boardsInfo[0]; - Data.DisplayBoards[keyid] = { - name: details.mMeta.mGroupName, - isSearched: true, - description: details.mDescription, - image: details.mGroupImage, - author: details.mMeta.mAuthorId, - isSubscribed: - details.mMeta.mSubscribeFlags === GROUP_SUBSCRIBE_SUBSCRIBED || - details.mMeta.mSubscribeFlags === GROUP_MY_BOARD, - posts: details.mMeta.mVisibleMsgCount, - activity: details.mMeta.mLastPost, - created: details.mMeta.mPublishTs, - all: details, - }; - - if (Data.Posts[keyid] === undefined) { - Data.Posts[keyid] = {}; - } - - /* const res2 = await rs.rsJsonApiRequest('/rsPosted/getContentSummaries', { - boardId: keyid, - }); - - if (res2.body.retval) { - res2.body.summaries.map((content) => { - updateContent(content, keyid); - }); - }*/ +// Older Qt clients store board notes as rich HTML. Render them as readable, +// inert text in the web UI instead of exposing the markup and embedded CSS. +function plainText(value) { + if (value === null || value === undefined) return ''; + const text = String(value); + if (!/<\/?[a-z][^>]*>/i.test(text)) return text.trim(); + return text + .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, '') + .replace(/<(br|\/p|\/div|\/li|\/h[1-6])\b[^>]*>/gi, '\n') + .replace(/<[^>]*>/g, '') + .replace(/&(nbsp|#160);/gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, '\'') + .replace(/\n\s*\n+/g, '\n') + .trim(); } -const DisplayBoardsFromList = () => { +const BOARD_POST_BATCH_SIZE = 25; + +async function updateContent(content, boardid) { + const requested = Array.isArray(content) ? content : [content]; + const msgIds = requested + .map((item) => item.mMsgId || item.msgId || item) + .filter(Boolean); + if (msgIds.length === 0) return false; + try { + const res = await rs.rsJsonApiRequest('/rsPosted/getBoardContent', { + boardId: boardid, + contentsIds: msgIds, + }); + if (res && res.body && res.body.retval) { + const posts = res.body.posts || res.body.postList || []; + const comments = res.body.comments || res.body.commentList || []; + const votes = res.body.votes || res.body.voteList || []; + + if (posts.length > 0) { + if (!Data.Posts[boardid]) Data.Posts[boardid] = {}; + posts.forEach((post) => { + const msgId = post.mMeta && post.mMeta.mMsgId; + if (msgId) Data.Posts[boardid][msgId] = { post, isSearched: true }; + }); + m.redraw(); + } else if (comments.length > 0 && requested.length === 1) { + const threadId = requested[0].mThreadId || comments[0].mMeta.mThreadId; + if (Data.Comments[threadId] === undefined) { + Data.Comments[threadId] = {}; + } + Data.Comments[threadId][msgIds[0]] = comments[0]; + m.redraw(); + } else if (votes.length > 0) { + const vote = votes[0]; + if ( + Data.Comments[vote.mMeta.mThreadId] && + Data.Comments[vote.mMeta.mThreadId][vote.mMeta.mParentId] + ) { + if (vote.mVoteType === GXS_VOTE_UP) { + Data.Comments[vote.mMeta.mThreadId][vote.mMeta.mParentId].mUpVotes += 1; + } + if (vote.mVoteType === GXS_VOTE_DOWN) { + Data.Comments[vote.mMeta.mThreadId][vote.mMeta.mParentId].mDownVotes += 1; + } + m.redraw(); + } + } + return true; + } + } catch (err) { + console.warn('updateContent error:', err); + } + return false; +} + +// Same as the channel loader: a post can carry base64 media, and the JSON API +// cuts a response it cannot flush in time. Retry a failed batch as two smaller +// ones until the offending post is isolated, instead of dropping the 25 of them +// on a single console.warn. Do not split when the core stopped answering, or +// one batch would turn into 2N-1 doomed requests. +async function updateContentBatch(contentIds, boardid) { + const loaded = await updateContent(contentIds, boardid); + if (loaded || contentIds.length <= 1 || !rs.connectionState.status) { + if (!loaded) { + console.warn('Unable to load board content item', contentIds[0]); + } + return; + } + + const middle = Math.ceil(contentIds.length / 2); + await updateContentBatch(contentIds.slice(0, middle), boardid); + await updateContentBatch(contentIds.slice(middle), boardid); +} + +const inFlightBoards = {}; + +async function updateDisplayBoards(keyid, details) { + if (!keyid) return Promise.resolve(); + + // 1. Fast path: if posts for this board are already loaded in memory, render instantly and do not re-fetch + if (Data.DisplayBoards[keyid] && Data.Posts[keyid] && Object.keys(Data.Posts[keyid]).length > 0) { + m.redraw(); + return Promise.resolve(); + } + + // 2. Prevent duplicate concurrent HTTP requests for the same board ID + if (inFlightBoards[keyid]) { + return inFlightBoards[keyid]; + } + + inFlightBoards[keyid] = (async () => { + try { + // Fetch board info metadata if missing + if (!Data.DisplayBoards[keyid]) { + const res1 = await rs.rsJsonApiRequest('/rsPosted/getBoardsInfo', { + boardsIds: [keyid], + }); + if (res1 && res1.body && res1.body.boardsInfo && res1.body.boardsInfo.length > 0) { + details = res1.body.boardsInfo[0]; + Data.DisplayBoards[keyid] = { + name: details.mMeta.mGroupName, + isSearched: true, + description: details.mDescription, + image: details.mGroupImage, + author: details.mMeta.mAuthorId, + subscribeFlags: details.mMeta.mSubscribeFlags, + isSubscribed: + details.mMeta.mSubscribeFlags === GROUP_SUBSCRIBE_SUBSCRIBED || + details.mMeta.mSubscribeFlags === GROUP_MY_BOARD, + posts: details.mMeta.mVisibleMsgCount, + activity: details.mMeta.mLastPost, + created: details.mMeta.mPublishTs, + all: details, + }; + m.redraw(); + } + } + + if (!Data.Posts[keyid]) { + Data.Posts[keyid] = {}; + } + + // Load lightweight post metadata first, then fetch complete posts newest + // first in page-sized batches. The first page can render without waiting + // for every image and older post in a large board. + const summariesRes = await rs.rsJsonApiRequest('/rsPosted/getBoardPostSummaries', { + boardId: keyid, + }); + const summaries = summariesRes && summariesRes.body && summariesRes.body.retval + && Array.isArray(summariesRes.body.summaries) + ? summariesRes.body.summaries + : null; + + if (summaries) { + summaries.sort((a, b) => Number((b.mPublishTs && b.mPublishTs.xint64) || b.mPublishTs || 0) + - Number((a.mPublishTs && a.mPublishTs.xint64) || a.mPublishTs || 0)); + for (let i = 0; i < summaries.length; i += BOARD_POST_BATCH_SIZE) { + await updateContentBatch(summaries.slice(i, i + BOARD_POST_BATCH_SIZE), keyid); + } + } else { + // Compatibility fallback for RetroShare cores which do not yet expose + // getBoardPostSummaries. + const resAll = await rs.rsJsonApiRequest('/rsPosted/getBoardAllContent', { + boardId: keyid, + }); + const posts = resAll && resAll.body && resAll.body.retval + ? (resAll.body.posts || resAll.body.postList || []) + : []; + posts.forEach((post) => { + const msgId = (post.mMeta && post.mMeta.mMsgId) || post.mMsgId; + if (msgId) Data.Posts[keyid][msgId] = { post, isSearched: true }; + }); + m.redraw(); + } + } catch (err) { + console.warn('updateDisplayBoards network error for board:', keyid, err); + } finally { + delete inFlightBoards[keyid]; + } + })(); + + return inFlightBoards[keyid]; +} + +const BoardSummary = () => { return { - oninit: (v) => {}, - view: (v) => - m( + view: (vnode) => { + const details = vnode.attrs.details; + const bname = details.mGroupName || details.name || ''; + + return m( 'tr', { - key: v.attrs.id, - class: - Data.DisplayBoards[v.attrs.id] && Data.DisplayBoards[v.attrs.id].isSearched - ? '' - : 'hidden', + key: details.mGroupId, onclick: () => { m.route.set('/boards/:tab/:mGroupId', { - tab: v.attrs.category, - mGroupId: v.attrs.id, + tab: vnode.attrs.category, + mGroupId: details.mGroupId, }); }, }, - [m('td', Data.DisplayBoards[v.attrs.id] ? Data.DisplayBoards[v.attrs.id].name : '')] - ), - }; -}; - -const BoardSummary = () => { - let keyid = {}; - return { - oninit: (v) => { - keyid = v.attrs.details.mGroupId; - updateDisplayBoards(keyid); + [ + m('td', bname), + ] + ); }, - - view: (v) => {}, }; }; const BoardTable = () => { return { - oninit: (v) => {}, - view: (v) => m('table.boards', [m('tr', [m('th', 'Board Name')]), v.children]), + view: (vnode) => + m('table.board-table', [ + m('thead', [ + m('tr', [ + m('th', 'Board Name'), + ]), + ]), + vnode.children, + ]), }; }; -function popupmessage(message) { - const container = document.getElementById('modal-container'); - container.style.display = 'block'; - m.render( - container, - m('.modal-content[id=composepopup]', [ - m( - 'button.red', - { - onclick: () => (container.style.display = 'none'), - }, - m('i.fas.fa-times') - ), - message, - ]) - ); -} - const SearchBar = () => { let searchString = ''; return { - view: (v) => - m('input[type=text][id=searchboard][placeholder=Search Subject].searchbar', { - value: searchString, - oninput: (e) => { - searchString = e.target.value.toLowerCase(); - for (const hash in Data.DisplayBoards) { - if (Data.DisplayBoards[hash].name.toLowerCase().indexOf(searchString) > -1) { - Data.DisplayBoards[hash].isSearched = true; - } else { - Data.DisplayBoards[hash].isSearched = false; + view: (vnode) => + m('.search-bar', [ + m('input[type=text][placeholder=Search Boards...]', { + value: searchString, + oninput: (e) => { + searchString = e.target.value; + const query = searchString.toLowerCase(); + if (vnode.attrs.list) { + vnode.attrs.list.forEach((board) => { + const name = (board.mGroupName || board.name || '').toLowerCase(); + board.isSearched = name.includes(query); + }); } - } - }, - }), + }, + }), + ]), }; }; +function popupmessage(message, modalClass = '') { + widget.popupMessage(message, modalClass); +} + +// Replace the cached post only once the core's background tally has moved +// past the pre-vote counts: a blind fixed-delay refetch could land BEFORE +// the retally (one board per tick, asynchronous write), revert the +// optimistic bump on screen, and stick -- updateDisplayBoards never +// refreshes cached content. Bounded retries; on give-up the bump stays. +function refetchPostAfterRetally(postGrpId, postMsgId, baseline, attempt = 0) { + setTimeout(async () => { + try { + const res = await rs.rsJsonApiRequest('/rsPosted/getBoardContent', { + boardId: postGrpId, + contentsIds: [postMsgId], + }); + const post = res && res.body && res.body.retval + && ((res.body.posts || res.body.postList || [])[0]); + if (!post) return; + const up = Number(post.mUpVotes || 0); + const down = Number(post.mDownVotes || 0); + if (up === baseline.up && down === baseline.down) { + if (attempt < 3) refetchPostAfterRetally(postGrpId, postMsgId, baseline, attempt + 1); + return; + } + if (!Data.Posts[postGrpId]) Data.Posts[postGrpId] = {}; + Data.Posts[postGrpId][postMsgId] = { post, isSearched: true }; + m.redraw(); + } catch (e) { /* a failed refetch leaves the bump */ } + }, 30000); +} + +async function voteForPost(postGrpId, postMsgId, voteType, voterId = null) { + try { + let authorId = voterId; + if (!authorId) { + // Goes through people_util so the endpoints and their caching stay in + // one place: /rsIdentity/getOwnIds is deprecated and answers 404. + const ownIds = await peopleUtil.ownIds(); + if (ownIds.length === 0) { + alert('No identity found to vote.'); + return false; + } + authorId = ownIds[0]; + } + + const res = await rs.rsJsonApiRequest('/rsPosted/voteForPost', { + postGrpId, + postMsgId, + authorId, + vote: voteType, + }); + + if (res && res.body && res.body.retval) { + // No immediate refetch: a post's count lives in mMeta.mServiceString, + // retallied by the core's background pass -- one board per ~15 s tick, + // landing asynchronously -- so an immediate getBoardContent returns + // the OLD count and would replace the cached post object out from + // under the callers' optimistic +1. Callers bump the number they + // render; the refetch below waits for the retally to actually show. + const entry = Data.Posts[postGrpId] && Data.Posts[postGrpId][postMsgId]; + const baseline = entry && entry.post + ? { up: Number(entry.post.mUpVotes || 0), down: Number(entry.post.mDownVotes || 0) } + : null; + if (baseline) refetchPostAfterRetally(postGrpId, postMsgId, baseline); + m.redraw(); + return true; + } + } catch (e) { + console.error('voteForPost error:', e); + } + return false; +} + +async function voteForComment(boardId, postId, commentId, voteType, authorId) { + if (!authorId) return false; + try { + const res = await rs.rsJsonApiRequest('/rsPosted/voteForComment', { + boardId, + postId, + commentId, + authorId, + vote: voteType, + }); + if (res && res.body && res.body.retval) { + updateDisplayBoards(boardId); + m.redraw(); + return true; + } + console.warn('voteForComment failed:', res && res.body && res.body.errorMessage); + } catch (error) { + console.error('voteForComment error:', error); + } + return false; +} + module.exports = { Data, + updateDisplayBoards, + updateContent, + BoardSummary, + BoardTable, SearchBar, popupmessage, - BoardSummary, - DisplayBoardsFromList, - updateDisplayBoards, - BoardTable, + voteForPost, + voteForComment, + plainText, + GXS_VOTE_UP, + GXS_VOTE_DOWN, GROUP_SUBSCRIBE_ADMIN, - GROUP_SUBSCRIBE_NOT_SUBSCRIBED, GROUP_SUBSCRIBE_PUBLISH, GROUP_SUBSCRIBE_SUBSCRIBED, + GROUP_SUBSCRIBE_NOT_SUBSCRIBED, GROUP_MY_BOARD, - GXS_VOTE_DOWN, - GXS_VOTE_UP, PUBLIC, EXTERNAL, NODES_GROUP, diff --git a/webui-src/app/boards/my_boards.js b/webui-src/app/boards/my_boards.js index dd345e7..b712ba5 100644 --- a/webui-src/app/boards/my_boards.js +++ b/webui-src/app/boards/my_boards.js @@ -4,23 +4,24 @@ const util = require('boards/boards_util'); const Layout = () => { return { view: (v) => [ - m('.widget__heading', m('h3', 'My Boards')), + m('.widget__heading', [ + m('h3', 'My Boards'), + m('button.my-boards-create[type=button][title=Create Board][aria-label=Create Board]', { + onclick: v.attrs.onCreateBoard, + }, m('i.fas.fa-plus')), + ]), m('.widget__body', [ m( util.BoardTable, m('tbody', [ - v.attrs.list.map((board) => - m(util.BoardSummary, { - details: board, - category: 'MyBoards', - }) - ), - v.attrs.list.map((board) => - m(util.DisplayBoardsFromList, { - id: board.mGroupId, - category: 'MyBoards', - }) - ), + v.attrs.list && + v.attrs.list.map((board) => + m(util.BoardSummary, { + key: board.mGroupId, + details: board, + category: 'MyBoards', + }) + ), ]) ), ]), @@ -28,4 +29,4 @@ const Layout = () => { }; }; -module.exports = Layout(); +module.exports = Layout; diff --git a/webui-src/app/boards/other_boards.js b/webui-src/app/boards/other_boards.js index 685e67b..7b245be 100644 --- a/webui-src/app/boards/other_boards.js +++ b/webui-src/app/boards/other_boards.js @@ -4,23 +4,24 @@ const util = require('boards/boards_util'); const Layout = () => { return { view: (v) => [ - m('.widget__heading', m('h3', 'Other Boards')), + m('.widget__heading', [ + m('h3', 'Other Boards'), + m('button.other-boards-create[type=button][title=Create Board][aria-label=Create Board]', { + onclick: v.attrs.onCreateBoard, + }, m('i.fas.fa-plus')), + ]), m('.widget__body', [ m( util.BoardTable, m('tbody', [ - v.attrs.list.map((board) => - m(util.BoardSummary, { - details: board, - category: 'OtherBoards', - }) - ), - v.attrs.list.map((board) => - m(util.DisplayBoardsFromList, { - id: board.mGroupId, - category: 'OtherBoards', - }) - ), + v.attrs.list && + v.attrs.list.map((board) => + m(util.BoardSummary, { + key: board.mGroupId, + details: board, + category: 'Other', + }) + ), ]) ), ]), @@ -28,4 +29,4 @@ const Layout = () => { }; }; -module.exports = Layout(); +module.exports = Layout; diff --git a/webui-src/app/boards/popular_boards.js b/webui-src/app/boards/popular_boards.js index 752b24e..cb801bb 100644 --- a/webui-src/app/boards/popular_boards.js +++ b/webui-src/app/boards/popular_boards.js @@ -4,23 +4,24 @@ const util = require('boards/boards_util'); const Layout = () => { return { view: (v) => [ - m('.widget__heading', m('h3', 'Popular Boards')), + m('.widget__heading', [ + m('h3', 'Popular Boards'), + m('button.popular-boards-create[type=button][title=Create Board][aria-label=Create Board]', { + onclick: v.attrs.onCreateBoard, + }, m('i.fas.fa-plus')), + ]), m('.widget__body', [ m( util.BoardTable, m('tbody', [ - v.attrs.list.map((board) => - m(util.BoardSummary, { - details: board, - category: 'PopularBoards', - }) - ), - v.attrs.list.map((board) => - m(util.DisplayBoardsFromList, { - id: board.mGroupId, - category: 'PopularBoards', - }) - ), + v.attrs.list && + v.attrs.list.map((board) => + m(util.BoardSummary, { + key: board.mGroupId, + details: board, + category: 'Popular', + }) + ), ]) ), ]), diff --git a/webui-src/app/boards/subscribed_boards.js b/webui-src/app/boards/subscribed_boards.js index 812933e..d77ae1e 100644 --- a/webui-src/app/boards/subscribed_boards.js +++ b/webui-src/app/boards/subscribed_boards.js @@ -4,23 +4,26 @@ const util = require('boards/boards_util'); const Layout = () => { return { view: (v) => [ - m('.widget__heading', m('h3', 'Subscribed Boards')), + m('.widget__heading', [ + m('h3', 'Subscribed Boards'), + // The heading button is the only create entry point on phones, where + // the toolbar Create is hidden; this tab was the one without it. + m('button.my-boards-create[type=button][title=Create Board][aria-label=Create Board]', { + onclick: v.attrs.onCreateBoard, + }, m('i.fas.fa-plus')), + ]), m('.widget__body', [ m( util.BoardTable, m('tbody', [ - v.attrs.list.map((board) => - m(util.BoardSummary, { - details: board, - category: 'SubscribedBoards', - }) - ), - v.attrs.list.map((board) => - m(util.DisplayBoardsFromList, { - id: board.mGroupId, - category: 'SubscribedBoards', - }) - ), + v.attrs.list && + v.attrs.list.map((board) => + m(util.BoardSummary, { + key: board.mGroupId, + details: board, + category: 'Subscribed', + }) + ), ]) ), ]), diff --git a/webui-src/app/channels/channel_view.js b/webui-src/app/channels/channel_view.js index 31101cc..72e651e 100644 --- a/webui-src/app/channels/channel_view.js +++ b/webui-src/app/channels/channel_view.js @@ -7,6 +7,7 @@ const peopleUtil = require('people/people_util'); const sha1 = require('channels/sha1'); const fileUtil = require('files/files_util'); const fileDown = require('files/files_downloads'); +const { CommentsSection } = require('comments'); const filesUploadHashes = { // figure out a better way later. @@ -14,6 +15,38 @@ const filesUploadHashes = { Thumbnail: [], }; +function channelThumbnailSrc(post) { + const thumbnail = post && (post.mThumbnail || post.thumbnail || post.mImage); + const base64 = thumbnail && thumbnail.mData && thumbnail.mData.base64 + ? thumbnail.mData.base64 + : typeof thumbnail === 'string' + ? thumbnail + : thumbnail && thumbnail.base64; + if (!base64 || !String(base64).trim()) return ''; + return String(base64).startsWith('data:') ? base64 : `data:image/png;base64,${base64}`; +} + +function channelPostCommentCount(postId, post) { + const loadedComments = Data.Comments[postId]; + if (loadedComments) return Object.keys(loadedComments).length; + + const meta = (post && post.mMeta) || {}; + const count = post && (post.mComments ?? post.mCommentCount ?? post.commentCount); + return Number(count ?? meta.mComments ?? meta.mCommentCount ?? 0) || 0; +} + +const ChannelFallbackThumbnail = () => ({ + view: (vnode) => m('.channel-post__placeholder', { style: { + display: vnode.attrs.hidden ? 'none' : 'flex', flex: '1 1 auto', minHeight: '0', + flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '.35rem', + color: '#64748b', background: 'linear-gradient(135deg, #f8fafc, #dbe5f1)', + } }, [ + m('i.fas.fa-image[aria-hidden=true]', { style: { fontSize: '1.35rem', color: '#64748b' } }), + m('span', { style: { fontSize: '2rem', fontWeight: '700', color: '#2563eb' } }, (vnode.attrs.title || 'Post').trim().slice(0, 1).toUpperCase()), + m('small', { style: { fontSize: '.72rem', fontWeight: '600' } }, 'No image'), + ]), +}); + async function parsefile(file, type) { const fileSize = file.size; const chunkSize = 1024 * 1024; // bytes @@ -74,6 +107,7 @@ async function parsefile(file, type) { return ansList; } const messageGroups = ['Public', 'Restricted Circle', 'Restricted Node Group']; +const messageGroupLabels = ['🌐 Public', '◉ Restricted Circle', '⬢ Restricted Node Group']; const messageGroupsCode = [util.PUBLIC, util.EXTERNAL, util.NODES_GROUP]; // rsgxscirles.h:50 function createchannel() { @@ -81,6 +115,8 @@ function createchannel() { let body; let identity; let thumbnail; + let thumbnailPreview = ''; + let thumbnailFileName = ''; let selectedGroup = messageGroups[0]; let selectedGroupCode = messageGroupsCode[0]; let selectedCircle; @@ -94,34 +130,58 @@ function createchannel() { const res = await rs.rsJsonApiRequest('/rsgxscircles/getCirclesSummaries'); if (res.body.retval) { circles = res.body.circles; - selectedCircle = circles[0].mGroupName; + selectedCircle = circles[0]; } }, view: (vnode) => - m('.widget', [ - m('h3', 'Create Channel'), - m('hr'), - m('input[type=text][placeholder=Title]', { - style: { float: 'left' }, + m('.widget.create-channel-form', [ + m('.create-channel-form__heading', [ + m('h3', 'Create Channel'), + m('p', 'Set up the channel appearance and publishing options.'), + ]), + m('input.create-channel-form__title[type=text][placeholder=Channel title]', { oninput: (e) => (title = e.target.value), }), - m('div', { style: { float: 'right', marginTop: '10px', marginBottom: '10px' } }, [ - m('label[for=thumbnail]', 'Thumbnail: '), - m('input[type=file][name=files][id=thumbnail][accept=image/*]', { + m('.create-channel-form__thumbnail', [ + m('.channel-thumbnail-preview', [ + thumbnailPreview + ? m('img', { src: thumbnailPreview, alt: 'Channel thumbnail preview' }) + : m('.channel-thumbnail-preview__placeholder', [ + m('i.fas.fa-image'), + m('span', 'Channel logo'), + m('small', 'No image selected'), + ]), + ]), + m('span.create-channel-form__thumbnail-label', 'Thumbnail'), + m('input.create-channel-form__file-input[type=file][name=files][id=thumbnail][accept=image/*]', { onchange: async (e) => { + const file = e.target.files[0]; + if (!file) { + thumbnail = undefined; + thumbnailPreview = ''; + thumbnailFileName = ''; + return; + } + thumbnailFileName = file.name; const reader = new FileReader(); reader.onloadend = function () { - thumbnail = reader.result.substring(reader.result.indexOf(',') + 1); + thumbnailPreview = reader.result; + thumbnail = thumbnailPreview.substring(thumbnailPreview.indexOf(',') + 1); + m.redraw(); }; - reader.readAsDataURL(e.target.files[0]); + reader.readAsDataURL(file); }, }), + m('label.create-channel-form__file-button[for=thumbnail]', { + title: thumbnailFileName || 'Choose a channel thumbnail', + }, [m('i.fas.fa-upload'), thumbnailPreview ? ' Change image' : ' Choose image']), + m('small', 'Square images work best.'), ]), - m('div', { style: { float: 'right', marginTop: '10px', marginBottom: '10px' } }, [ - m('label[for=idtags]', 'Select identity: '), + m('.create-channel-form__field.create-channel-form__identity', [ + m('label[for=idtags]', 'Publishing identity'), m( - 'select[id=idtags]', + 'select.config-style-select[id=idtags]', { value: identity, onchange: (e) => { @@ -134,49 +194,43 @@ function createchannel() { m( 'option', { value: o }, - rs.userList.userMap[o] - ? rs.userList.userMap[o].toLocaleString() + ' (' + o.slice(0, 8) + '...)' - : 'No Signature' + Number(o) === 0 + ? 'No Signature' + : `${rs.userList.username(o)} (${o.slice(0, 8)}...)` ) ), ] ), ]), - m('div', { style: { float: 'left', marginTop: '10px', marginBottom: '10px' } }, [ - m('label[for=mtags]', 'Message Distribution: '), + m('.create-channel-form__field.create-channel-form__distribution', [ + m('label[for=mtags]', 'Message distribution'), m( - 'select[id=mtags]', + 'select.config-style-select[id=mtags]', { value: selectedGroup, onchange: (e) => { selectedGroup = messageGroups[e.target.selectedIndex]; selectedGroupCode = messageGroupsCode[e.target.selectedIndex]; - widget.popupMessage(m(createchannel, { authorId: vnode.attrs.authorId })); }, }, - [messageGroups.map((group) => m('option', { value: group }, group))] + [messageGroups.map((group, index) => m( + 'option', + { value: group }, + messageGroupLabels[index] + ))] ), ]), - circles && + circles && selectedGroupCode === util.EXTERNAL && m( - 'div', - { - style: { - float: 'left', - marginTop: '10px', - marginBottom: '10px', - display: selectedGroupCode === util.EXTERNAL ? 'block' : 'none', - }, - }, + '.create-channel-form__field.create-channel-form__circle', [ - m('label[for=circlestag]', 'Circles: '), + m('label[for=circlestag]', 'Circle'), m( - 'select[id=circlestag]', + 'select.config-style-select[id=circlestag]', { - value: selectedCircle, + value: selectedCircle && selectedCircle.mGroupName, onchange: (e) => { selectedCircle = circles[e.target.selectedIndex]; - // selectedGroupCode = messageGroupsCode[e.target.selectedIndex]; }, }, [ @@ -187,13 +241,12 @@ function createchannel() { ), ] ), - m('textarea[rows=5][placeholder=Description]', { - style: { width: '100%', display: 'block' }, + m('textarea.create-channel-form__description[rows=5][placeholder=Describe your channel]', { oninput: (e) => (body = e.target.value), value: body, }), m( - 'button', + 'button.create-channel-form__submit', { onclick: async () => { const res = await rs.rsJsonApiRequest('/rsgxschannels/createChannelV2', { @@ -206,7 +259,8 @@ function createchannel() { selectedCircle && { circleId: selectedCircle.mGroupId }), // checks if the selectedGroup code is EXTERNAL }); if (res.body.retval) { - util.updatedisplaychannels(res.body.channelId); + await util.updatedisplaychannels(res.body.channelId, undefined, false); + if (vnode.attrs.onCreated) await vnode.attrs.onCreated(); m.redraw(); } res.body.retval === false @@ -227,65 +281,138 @@ function createchannel() { const AddPost = () => { let content = ''; let ptitle = ''; - let pthumbnail = []; - let pfiles = []; + let pthumbnail; + let thumbnailPreview = ''; + let thumbnailFileName = ''; + let attachmentLabel = 'Choose files'; + const attachmentItems = []; + const pfiles = []; let uploadFiles = true; return { view: (vnode) => - m('.widget', [ - m('h3', 'Add Post'), - m('hr'), - m('label[for=thumbnail]', 'Thumbnail: '), - m('input[type=file][name=files][id=thumbnail][accept=image/*]', { - onchange: async (e) => { + m('.widget.create-channel-post-form', [ + m('.create-channel-post-form__heading', [ + m('h3', 'Create Channel Post'), + m('p', 'Add a title, thumbnail, message, and optional attachments.'), + ]), + m('input.create-channel-post-form__title[type=text][placeholder=Post title]', { + value: ptitle, + oninput: (e) => (ptitle = e.target.value), + }), + m('.create-channel-post-form__thumbnail', [ + m('.channel-post-thumbnail-preview', [ + thumbnailPreview + ? m('img', { src: thumbnailPreview, alt: 'Post thumbnail preview' }) + : m('.channel-post-thumbnail-preview__placeholder', [ + m('i.fas.fa-image'), + m('span', 'Post thumbnail'), + m('small', 'No image selected'), + ]), + ]), + m('span.create-channel-post-form__thumbnail-label', 'Thumbnail'), + m('input.create-channel-post-form__file-input[type=file][name=files][id=channel-post-thumbnail][accept=image/*]', { + onchange: (e) => { + const file = e.target.files[0]; + if (!file) return; + thumbnailFileName = file.name; const reader = new FileReader(); reader.onloadend = function () { - pthumbnail = reader.result.substring(reader.result.indexOf(',') + 1); + thumbnailPreview = reader.result; + pthumbnail = thumbnailPreview.substring(thumbnailPreview.indexOf(',') + 1); + m.redraw(); }; - reader.readAsDataURL(e.target.files[0]); // converts into base64 string + reader.readAsDataURL(file); }, - }), - m('label[for=browse]', 'Attachments: '), - m('input[type=file][name=files][id=browse][multiple=multiple]', { + }), + m('label.create-channel-post-form__file-button[for=channel-post-thumbnail]', { + title: thumbnailFileName || 'Choose a post thumbnail', + }, [m('i.fas.fa-upload'), thumbnailPreview ? ' Change image' : ' Choose image']), + m('small', 'Square images work best.'), + ]), + m('.create-channel-post-form__attachments', [ + m('label', 'Attachments'), + m('input.create-channel-post-form__file-input[type=file][name=files][id=channel-post-files][multiple=multiple]', { + disabled: !uploadFiles, // attachments option wrong hash, not working onchange: async (e) => { + const input = e.target; + const existingKeys = new Set(attachmentItems.map((file) => file.key)); + const newFiles = Array.from(input.files).filter((file) => { + const key = `${file.name}:${file.size}:${file.lastModified}`; + return !existingKeys.has(key); + }); + input.value = ''; + if (newFiles.length === 0) return; + + attachmentItems.push(...newFiles.map((file) => ({ + key: `${file.name}:${file.size}:${file.lastModified}`, + name: file.name, + size: file.size, + hash: '', + }))); + attachmentLabel = `${attachmentItems.length} file${attachmentItems.length === 1 ? '' : 's'} selected`; uploadFiles = false; filesUploadHashes.PostFiles = []; - pfiles = []; - for (let i = 0; i < e.target.files.length; i++) { - await parsefile(e.target.files[i], 'multiple'); + m.redraw(); + for (let i = 0; i < newFiles.length; i++) { + await parsefile(newFiles[i], 'multiple'); } // console.log(filesUploadHashes.PostFiles, filesUploadHashes.PostFiles.length); - if (filesUploadHashes.PostFiles.length === e.target.files.length) { - for (let i = 0; i < e.target.files.length; i++) { + if (filesUploadHashes.PostFiles.length === newFiles.length) { + for (let i = 0; i < newFiles.length; i++) { pfiles.push({ - name: e.target.files[i].name, - size: e.target.files[i].size, + name: newFiles[i].name, + size: newFiles[i].size, hash: filesUploadHashes.PostFiles[i], }); } uploadFiles = true; + attachmentItems.forEach((item, index) => { + item.hash = pfiles[index] && pfiles[index].hash; + }); + m.redraw(); } }, - }), - m('input[type=text][placeholder=Title]', { - oninput: (e) => (ptitle = e.target.value), - }), - m('textarea[rows=5]', { - style: { width: '90%', display: 'block' }, + }), + m('label.create-channel-post-form__attachment-button[for=channel-post-files]', [ + m('i.fas.fa-paperclip'), ` ${attachmentLabel}`, + ]), + !uploadFiles && m('small', 'Preparing attachments...'), + attachmentItems.length > 0 && m('.create-channel-post-form__attachment-list', + attachmentItems.map((file, index) => m('.create-channel-post-form__attachment-item', [ + m('i.fas.fa-file'), + m('.create-channel-post-form__attachment-info', [ + m('span', { title: file.name }, file.name), + m('small', rs.formatBytes(file.size)), + ]), + m('button.create-channel-post-form__attachment-remove[type=button][title=Remove attachment]', { + disabled: !uploadFiles, + onclick: () => { + attachmentItems.splice(index, 1); + pfiles.splice(index, 1); + attachmentLabel = attachmentItems.length + ? `${attachmentItems.length} file${attachmentItems.length === 1 ? '' : 's'} selected` + : 'Choose files'; + }, + }, m('i.fas.fa-times')), + ])) + ), + ]), + m('textarea.create-channel-post-form__description[rows=7][placeholder=Write your post]', { oninput: (e) => (content = e.target.value), value: content, }), m( - 'button', + 'button.create-channel-post-form__submit', { + disabled: !uploadFiles || !ptitle.trim(), onclick: async () => { if (uploadFiles) { // console.log(vnode.attrs.chanId, ptitle, content, pfiles, pthumbnail); const res = await rs.rsJsonApiRequest('/rsgxschannels/createPostV2', { channelId: vnode.attrs.chanId, - title: ptitle, + title: ptitle.trim(), mBody: content, files: pfiles, // does not work for now thumbnail: { mData: { base64: pthumbnail } }, @@ -302,12 +429,18 @@ const AddPost = () => { } }, }, - 'Add' + uploadFiles ? 'Create Post' : 'Preparing…' ), ]), }; }; +// When each channel last had its content pulled, so that stepping in and out +// of a channel does not redownload it every time. Module level: the component +// is rebuilt at every visit, a field of it would forget instantly. +const contentLoadedAt = {}; +const CONTENT_CACHE_MS = 60000; + const ChannelView = () => { let cname = ''; let cimage = ''; @@ -318,15 +451,28 @@ const ChannelView = () => { let plist = {}; let createDate = {}; let lastActivity = {}; + const toggleSubscription = async (attrs) => { + const res = await rs.rsJsonApiRequest('/rsgxschannels/subscribeToChannel', { + channelId: attrs.id, subscribe: !csubscribed, + }); + if (res.body.retval) { + csubscribed = !csubscribed; + Data.DisplayChannels[attrs.id].isSubscribed = csubscribed; + if (attrs.onSubscriptionChange) attrs.onSubscriptionChange(); + m.redraw(); + } + }; return { oninit: (v) => { if (Data.DisplayChannels[v.attrs.id]) { cname = Data.DisplayChannels[v.attrs.id].name; cimage = Data.DisplayChannels[v.attrs.id].image; - if (rs.userList.userMap[Data.DisplayChannels[v.attrs.id].author]) { - cauthor = rs.userList.userMap[Data.DisplayChannels[v.attrs.id].author]; - } else if (Number(Data.DisplayChannels[v.attrs.id].author) === 0) { + // Same as forum_view: userMap stores objects, username() is the only + // accessor that yields a string. + if (Number(Data.DisplayChannels[v.attrs.id].author) === 0) { cauthor = 'No Contact Author'; + } else if (Data.DisplayChannels[v.attrs.id].author) { + cauthor = rs.userList.username(Data.DisplayChannels[v.attrs.id].author); } else { cauthor = 'Unknown'; } @@ -339,10 +485,25 @@ const ChannelView = () => { if (Data.Posts[v.attrs.id]) { plist = Data.Posts[v.attrs.id]; } + // Channel lists load metadata only, so the content is fetched here, on + // opening. oninit runs again on every visit though, and a 2000 item + // channel would redownload its whole content, images included, each time + // the user steps in and out. Skip it while the copy in memory is fresh, + // and let it age so posts published meanwhile still show up. The callers + // that publish or delete call updatedisplaychannels directly and are not + // affected by this guard. + const lastLoad = contentLoadedAt[v.attrs.id] || 0; + if (Object.keys(plist).length > 0 && Date.now() - lastLoad < CONTENT_CACHE_MS) return; + contentLoadedAt[v.attrs.id] = Date.now(); + util.updatedisplaychannels(v.attrs.id).then(() => { + plist = Data.Posts[v.attrs.id] || {}; + m.redraw(); + }); }, view: (v) => [ + m('.channel-detail-navigation', [ m( - 'a[title=Back]', + 'a.channel-back[title=Back][aria-label=Back]', { onclick: () => m.route.set('/channels/:tab', { @@ -351,21 +512,42 @@ const ChannelView = () => { }, m('i.fas.fa-arrow-left') ), + m('.channel-mobile-search', [ + m(util.SearchBar, { category: 'posts', channelId: v.attrs.id }), + ]), + m('details.channel-mobile-actions', { + onkeydown: (event) => { + if (event.key === 'Escape') { + event.currentTarget.open = false; + event.currentTarget.querySelector('summary').focus(); + } + }, + onfocusout: (event) => { + if (!event.currentTarget.contains(event.relatedTarget)) event.currentTarget.open = false; + }, + }, [ + m('summary[aria-label=Channel actions][title=Channel actions]', m('i.fas.fa-ellipsis-v')), + m('.channel-mobile-actions__items', m('button[type=button]', { + onclick: (event) => { + const menu = event.currentTarget.closest('details'); + menu.open = false; + menu.querySelector('summary').focus(); + return toggleSubscription(v.attrs); + }, + }, csubscribed ? 'Unsubscribe' : 'Subscribe')), + ]), + ]), m('.widget__heading', [ m('h3', cname), + mychannel && csubscribed && m('button.channel-mobile-create[type=button][title=Add Post][aria-label=Add Post]', { + onclick: () => widget.popupMessage(m(AddPost, { chanId: v.attrs.id }), 'create-channel-post-modal'), + }, m('i.fas.fa-plus')), + m( 'button', { - onclick: async () => { - const res = await rs.rsJsonApiRequest('/rsgxschannels/subscribeToChannel', { - channelId: v.attrs.id, - subscribe: !csubscribed, - }); - if (res.body.retval) { - csubscribed = !csubscribed; - Data.DisplayChannels[v.attrs.id].isSubscribed = csubscribed; - } - }, + class: csubscribed ? 'channel-subscription--subscribed' : '', + onclick: () => toggleSubscription(v.attrs), }, csubscribed ? 'Subscribed' : 'Subscribe' ), @@ -373,12 +555,14 @@ const ChannelView = () => { m('.widget__body', [ m('.media-item', [ m('.media-item__details', [ - m('img', { - src: - cimage.mData.base64 === '' - ? 'data/streaming.png' - : `data:image/png;base64,${cimage.mData.base64}`, - }), + cimage && cimage.mData && cimage.mData.base64 + ? m('img', { + src: `data:image/png;base64,${cimage.mData.base64}`, + alt: `${cname} channel thumbnail`, + }) + : m('.channel-detail-default-thumbnail[role=img][aria-label=Default channel thumbnail]', + m('i.fas.fa-tv') + ), m('.media-item__details-info', [ m('div', [m('b', 'Posts: '), m('span', cposts)]), m('div', [ @@ -413,22 +597,29 @@ const ChannelView = () => { style: 'display: ' + (csubscribed ? 'flex' : 'none'), }, [ - m('.posts__heading', [ + m('.posts__heading.channel-posts-heading', [ m('h3', 'Posts'), mychannel && m( - 'button', - { onclick: () => widget.popupMessage(m(AddPost, { chanId: v.attrs.id })) }, - ['Add Post', m('i.fas.fa-edit')] + 'button.channel-posts-heading__create[type=button][title=Add Post][aria-label=Add Post]', + { onclick: () => widget.popupMessage( + m(AddPost, { chanId: v.attrs.id }), + 'create-channel-post-modal' + ) }, + [m('i.fas.fa-edit'), m('span', 'Add Post')] ), ]), m( '.posts-container', - Object.keys(plist).map((key, index) => [ + Object.keys(plist).map((key) => { + const commentCount = channelPostCommentCount(key, plist[key].post); + return [ m( '.posts-container-card', { - style: 'display: ' + (plist[key].isSearched ? 'flex' : 'none'), // for search + style: { + display: plist[key].isSearched ? 'flex' : 'none', // for search + }, onclick: () => { m.route.set('/channels/:tab/:mGroupId/:mMsgId', { tab: m.route.param().tab, @@ -438,17 +629,31 @@ const ChannelView = () => { }, }, [ - m('img', { - src: - plist[key].post.mThumbnail.mData.base64 === '' - ? 'data/streaming.png' - : 'data:image/png;base64,' + plist[key].post.mThumbnail.mData.base64, - alt: 'No Thumbnail', - }), + commentCount > 0 && m('.channel-post-comment-badge', { + title: `${commentCount} comment${commentCount === 1 ? '' : 's'}`, + 'aria-label': `${commentCount} comment${commentCount === 1 ? '' : 's'}`, + }, [ + m('i.fas.fa-comment'), + m('span', commentCount), + ]), + channelThumbnailSrc(plist[key].post) + ? [ + m('img', { + src: channelThumbnailSrc(plist[key].post), + alt: plist[key].post.mMeta.mMsgName || 'Post thumbnail', + onerror: (e) => { + e.target.style.display = 'none'; + if (e.target.nextSibling) e.target.nextSibling.style.display = 'flex'; + }, + }), + m(ChannelFallbackThumbnail, { title: plist[key].post.mMeta.mMsgName, hidden: true }), + ] + : m(ChannelFallbackThumbnail, { title: plist[key].post.mMeta.mMsgName }), m('p', plist[key].post.mMeta.mMsgName), ] ), - ]) + ]; + }) ), ] ), @@ -471,214 +676,18 @@ async function addvote(voteType, vchannelId, vpostId, vauthorId, vcommentId) { } } -const AddComment = () => { - let inputComment = ''; - let identity; - return { - oninit: (vnode) => { - if (vnode.attrs.authorId) { - identity = vnode.attrs.authorId[0]; - } - }, - view: (vnode) => - m('.widget', [ - m('h3', 'Add Comment'), - m('label[for=tags]', 'Select identity'), - m( - 'select[id=idtags]', - { - value: identity, - onchange: (e) => { - identity = vnode.attrs.authorId[e.target.selectedIndex]; - }, - }, - [ - vnode.attrs.authorId && - vnode.attrs.authorId.map((o) => - m( - 'option', - { value: o }, - rs.userList.userMap[o].toLocaleString() + ' (' + o.slice(0, 8) + '...)' - ) - ), - ] - ), - m('hr'), - (vnode.attrs.parent_comment !== '') > 0 - ? [m('h5', 'Reply to comment: '), m('p', vnode.attrs.parent_comment)] // if it is add reply option - : '', - m('textarea[rows=5]', { - style: { width: '90%', display: 'block' }, - oninput: (e) => (inputComment = e.target.value), - value: inputComment, - }), - m( - 'button', - { - onclick: async () => { - const res = await rs.rsJsonApiRequest('/rsgxschannels/createCommentV2', { - channelId: vnode.attrs.channelId, - threadId: vnode.attrs.threadId, - comment: inputComment, - authorId: identity, - parentId: vnode.attrs.parentId, - }); - - res.body.retval === false - ? widget.popupMessage([m('h3', 'Error'), m('hr'), m('p', res.body.errorMessage)]) - : widget.popupMessage([ - m('h3', 'Success'), - m('hr'), - m('p', 'Comment added successfully'), - ]); - util.updatedisplaychannels(vnode.attrs.channelId); - m.redraw(); - }, - }, - 'Add' - ), - ]), - }; -}; -function displaycomment() { - // recursive function to display comments - return { - oninit: (v) => {}, - view: ({ attrs: { commentStruct, identity, replyDepth, voteIdentity } }) => { - const comment = commentStruct.comment; - let cUpVotes = 0; - let cDownVotes = 0; - let parMap = {}; - if (Data.ParentCommentMap[comment.mMeta.mMsgId]) { - parMap = Data.ParentCommentMap[comment.mMeta.mMsgId]; - } - if ( - Data.Votes[comment.mMeta.mThreadId] && - Data.Votes[comment.mMeta.mThreadId][comment.mMeta.mMsgId] - ) { - cUpVotes = Data.Votes[comment.mMeta.mThreadId][comment.mMeta.mMsgId].upvotes; - cDownVotes = Data.Votes[comment.mMeta.mThreadId][comment.mMeta.mMsgId].downvotes; - } - return [ - m('tr', [ - Object.keys(parMap).length // if it has replies - ? m( - 'td', - m('i.fas.fa-angle-right', { - class: 'fa-rotate-' + (commentStruct.showReplies ? '90' : '0'), - style: 'cursor:pointer', - onclick: () => { - commentStruct.showReplies = !commentStruct.showReplies; - }, - }) - ) - : m('td', ''), - - m( - 'td', - { - style: { - position: 'relative', - '--replyDepth': replyDepth, - left: 'calc(30px*var(--replyDepth))', // shifts the reply by 30px - }, - }, - [ - comment.mComment, - m('options', { style: 'display:block' }, [ - m( - 'button', - { - style: 'font-size:15px', - onclick: () => - widget.popupMessage( - m(AddComment, { - parent_comment: comment.mComment, - channelId: comment.mMeta.mGroupId, - authorId: identity, - threadId: comment.mMeta.mThreadId, - parentId: comment.mMeta.mMsgId, - }) - ), - }, - 'Reply' - ), - voteIdentity && - m( - 'button', - { - style: 'font-size:15px', - onclick: () => - addvote( - util.GXS_VOTE_UP, - comment.mMeta.mGroupId, - comment.mMeta.mThreadId, - voteIdentity, - comment.mMeta.mMsgId - ), - }, - m('i.fas.fa-thumbs-up') - ), - voteIdentity && - m( - 'button', - { - style: 'font-size:15px', - onclick: () => - addvote( - util.GXS_VOTE_DOWN, - comment.mMeta.mGroupId, - comment.mMeta.mThreadId, - voteIdentity, - comment.mMeta.mMsgId - ), - }, - m('i.fas.fa-thumbs-down') - ), - ]), - ] - ), - - m('td', rs.userList.userMap[comment.mMeta.mAuthorId]), - m( - 'td', - typeof comment.mMeta.mPublishTs === 'object' - ? new Date(comment.mMeta.mPublishTs.xint64 * 1000).toLocaleString() - : 'undefined' - ), - m('td', comment.mScore), - m('td', cUpVotes), - m('td', cDownVotes), - ]), - commentStruct.showReplies && // recursive calls for the replies - // parMap.map((value) => - Object.keys(parMap).map((key, index) => - m(displaycomment, { - commentStruct: Data.Comments[parMap[key].mMeta.mThreadId][parMap[key].mMeta.mMsgId], - voteIdentity, - identity, - replyDepth: replyDepth + 1, // for the css - }) - ), - ]; - }, - }; -} - const PostView = () => { let post = {}; - let topComments = {}; const filesInfo = {}; let voteIdentity; let ownId; + let identitiesLoading = true; + let messageExpanded = false; return { oninit: async (v) => { if (Data.Posts[v.attrs.channelId] && Data.Posts[v.attrs.channelId][v.attrs.msgId]) { post = Data.Posts[v.attrs.channelId][v.attrs.msgId].post; } - if (Data.TopComments[v.attrs.msgId]) { - topComments = Data.TopComments[v.attrs.msgId]; // get all the top level parent comments - } if (post) { post.mFiles.map(async (file) => { const res = await rs.rsJsonApiRequest('/rsfiles/alreadyHaveFile', { @@ -696,12 +705,18 @@ const PostView = () => { } } voteIdentity = ownId[0]; + identitiesLoading = false; }); fileDown.Downloads.loadStatus(); // for retrieving downloading files. }, - view: (v) => [ + view: (v) => { + const message = post.mMsg || ''; + const messageText = String(message).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + const hasEmbeddedImage = / 280 || hasEmbeddedImage; + return [ m( - 'a[title=Back]', + 'a.channel-back[title=Back][aria-label=Back]', { onclick: () => m.route.set('/channels/:tab/:mGroupId', { @@ -713,7 +728,19 @@ const PostView = () => { ), m('.widget__heading', m('h3', post.mMeta.mMsgName)), m('.widget__body', [ - m('p', { style: { whiteSpace: 'normal' } }, m.trust(post.mMsg)), + message ? m('.post-description', [ + m('.post-description__text', { + style: { + maxHeight: messageExpanded ? 'none' : '4.5em', + overflow: 'hidden', + lineHeight: '1.5', + }, + }, m.trust(message)), + hasLongMessage ? m('button.post-description__toggle[type=button]', { + style: { marginTop: '.35rem', padding: '0', border: '0', boxShadow: 'none', background: 'transparent', color: '#0f172a', fontSize: '.85rem', fontWeight: '700' }, + onclick: () => { messageExpanded = !messageExpanded; }, + }, messageExpanded ? 'Show less' : '…more') : null, + ]) : null, m('.file-section', [ m('h3', 'Files(' + post.mAttachmentCount + ')'), m( @@ -722,132 +749,94 @@ const PostView = () => { 'tbody', post.mFiles.map((file) => m('tr', [ - m('td', file.mName), - m('td', rs.formatBytes(file.mSize.xint64)), - m( - 'button', - { - style: { fontSize: '0.9em' }, - onclick: async () => - widget.popupMessage([ - m('p', 'Start Download?'), - m( - 'button', - { - onclick: async () => { - if (filesInfo[file.mHash] && !filesInfo[file.mHash].retval) { - const res = await rs.rsJsonApiRequest('/rsFiles/FileRequest', { - fileName: file.mName, - hash: file.mHash, - flags: util.RS_FILE_REQ_ANONYMOUS_ROUTING, - size: { - xstr64: file.mSize.xstr64, - }, - }); - res.body.retval === false - ? widget.popupMessage([ - m('h3', 'Error'), - m('hr'), - m('p', res.body.errorMessage), - ]) - : widget.popupMessage([ - m('h3', 'Success'), - m('hr'), - m('p', 'Download Started'), - ]); - m.redraw(); - } + m('td.channel-file__name[data-label=File name]', file.mName), + m('td.channel-file__size[data-label=Size]', rs.formatBytes(file.mSize.xint64)), + m('td.channel-file__action[data-label=Download]', [ + m( + 'button', + { + style: { fontSize: '0.9em' }, + onclick: async () => + widget.popupMessage([ + m('p', 'Start Download?'), + m( + 'button', + { + onclick: async () => { + if (filesInfo[file.mHash] && !filesInfo[file.mHash].retval) { + const res = await rs.rsJsonApiRequest('/rsFiles/FileRequest', { + fileName: file.mName, + hash: file.mHash, + flags: util.RS_FILE_REQ_ANONYMOUS_ROUTING, + size: { + xstr64: file.mSize.xstr64, + }, + }); + res.body.retval === false + ? widget.popupMessage([ + m('h3', 'Error'), + m('hr'), + m('p', res.body.errorMessage), + ]) + : widget.popupMessage([ + m('h3', 'Success'), + m('hr'), + m('p', 'Download Started'), + ]); + m.redraw(); + } + }, }, - }, - 'Start Download' - ), - ]), - }, - filesInfo[file.mHash] - ? filesInfo[file.mHash].retval - ? 'Open File' - : ['Download', m('i.fas.fa-download')] - : 'Please Wait...' - ), - fileDown.list[file.mHash] && // using the file from files_util to display download. - m(fileUtil.File, { + 'Start Download' + ), + ]), + }, + filesInfo[file.mHash] + ? filesInfo[file.mHash].retval + ? 'Open File' + : ['Download ', m('i.fas.fa-download')] + : 'Please Wait...' + ), + fileDown.list[file.mHash] && m(fileUtil.File, { info: fileDown.list[file.mHash], direction: 'down', transferred: fileDown.list[file.mHash].transfered.xint64, parts: [], }), + ]), ]) ) ) ), ]), - m('.comments-section', [ - m('h3', 'Comments'), - m('.comments-section__menu', [ - m( - 'button', - { - onclick: () => { - widget.popupMessage( - m(AddComment, { - parent_comment: '', - channelId: v.attrs.channelId, - authorId: ownId, - threadId: v.attrs.msgId, - parentId: v.attrs.msgId, - }) - ); - }, - }, - 'Add Comment' - ), - m('.comments-section__menu-id', [ - m('label[for=idtags', 'Voter ID: '), - m( - 'select[id=idtags]', - { - value: voteIdentity, - onchange: (e) => { - voteIdentity = ownId[e.target.selectedIndex]; - }, - }, - [ - ownId && - ownId.map((o) => - m( - 'option', - { value: o }, - `${rs.userList.userMap[o].toLocaleString()} (${o.slice(0, 8)}...)` - ) - ), - ] - ), - ]), - ]), - ]), - m( - util.CommentsTable, - m( - 'tbody', - Object.keys(topComments).map((key, index) => - Data.Comments[topComments[key].mMeta.mThreadId] && - Data.Comments[topComments[key].mMeta.mThreadId][topComments[key].mMeta.mMsgId] - ? m(displaycomment, { - // calls the recursive function for all the parents. - identity: ownId, - voteIdentity, - commentStruct: - Data.Comments[topComments[key].mMeta.mThreadId][ - topComments[key].mMeta.mMsgId - ], - replyDepth: 0, - }) - : '' - ) - ) - ), + m(CommentsSection, { + comments: Data.Comments[v.attrs.msgId] || {}, + rootThreadId: v.attrs.msgId, + identities: ownId, + voteIdentity, + identitiesLoading, + onVoteIdentity: (id) => { voteIdentity = id; }, + onSubmitComment: async ({ text, authorId, parentId }) => { + const res = await rs.rsJsonApiRequest('/rsgxschannels/createCommentV2', { + channelId: v.attrs.channelId, + threadId: v.attrs.msgId, + comment: text, + authorId, + parentId: parentId || v.attrs.msgId, + }); + if (!res || !res.body || res.body.retval === false) { + throw new Error((res && res.body && res.body.errorMessage) || 'Your comment could not be posted.'); + } + await util.updatedisplaychannels(v.attrs.channelId); + }, + onVoteComment: async ({ commentId, voteType, voteIdentity: voterId }) => { + await addvote(voteType, v.attrs.channelId, v.attrs.msgId, voterId, commentId); + }, + getCommentVotes: (id) => (Data.Votes[v.attrs.msgId] && Data.Votes[v.attrs.msgId][id]) || { upvotes: 0, downvotes: 0 }, + }), ]), - ], + ]; + }, }; }; diff --git a/webui-src/app/channels/channels.js b/webui-src/app/channels/channels.js index d9dde39..b7cbd3f 100644 --- a/webui-src/app/channels/channels.js +++ b/webui-src/app/channels/channels.js @@ -7,48 +7,66 @@ const peopleUtil = require('people/people_util'); const getChannels = { All: [], - PopularChannels: [], - SubscribedChannels: [], + Popular: [], + Subscribed: [], MyChannels: [], - OtherChannels: [], + Other: [], async load() { - const res = await rs.rsJsonApiRequest('/rsgxschannels/getChannelsSummaries'); - const data = res.body; - getChannels.All = data.channels; - getChannels.SubscribedChannels = getChannels.All.filter( + try { + const res = await rs.rsJsonApiRequest('/rsgxschannels/getChannelsSummaries'); + const channels = res && res.body && Array.isArray(res.body.channels) ? res.body.channels : null; + if (!channels) { + console.warn('Channels summaries response did not include channels', res && res.body); + return; + } + getChannels.All = channels; + getChannels.Subscribed = channels.filter( (channel) => channel.mSubscribeFlags === util.GROUP_SUBSCRIBE_SUBSCRIBED || channel.mSubscribeFlags === util.GROUP_MY_CHANNEL // my channel is subscribed - ); - // getChannels.PopularChannels = getChannels.All; - getChannels.PopularChannels = getChannels.All.filter( - (a) => !getChannels.SubscribedChannels.includes(a) - ); - getChannels.PopularChannels.sort((a, b) => b.mPop - a.mPop); - getChannels.OtherChannels = getChannels.PopularChannels.slice(5); - getChannels.PopularChannels = getChannels.PopularChannels.slice(0, 5); + ); + const popular = channels.filter((channel) => !getChannels.Subscribed.includes(channel)); + popular.sort((a, b) => (b.mPop || 0) - (a.mPop || 0)); + getChannels.Other = popular.slice(5); + getChannels.Popular = popular.slice(0, 5); - getChannels.MyChannels = getChannels.All.filter( - (channel) => channel.mSubscribeFlags === util.GROUP_MY_CHANNEL - ); + getChannels.MyChannels = channels.filter( + (channel) => channel.mSubscribeFlags === util.GROUP_MY_CHANNEL + ); + m.redraw(); + } catch (error) { + console.warn('Failed to load channel summaries', error); + } }, }; +// Group lists change on the scale of a conversation, not of a frame. +const CHANNEL_LIST_REFRESH_MS = 30000; + const sections = { MyChannels: require('channels/my_channels'), - SubscribedChannels: require('channels/subscribed_channels'), - PopularChannels: require('channels/popular_channels'), - OtherChannels: require('channels/other_channels'), + Subscribed: require('channels/subscribed_channels'), + Popular: require('channels/popular_channels'), + Other: require('channels/other_channels'), }; const Layout = () => { let ownId; + const createChannel = () => ownId && widget.popupMessage( + m(viewUtil.createchannel, { authorId: ownId, onCreated: getChannels.load }), + 'create-channel-modal' + ); return { oninit: () => { - rs.setBackgroundTask(getChannels.load, 5000, () => { - // return m.route.get() === '/files/files'; - }); + // The scope predicate used to be commented out, so it returned undefined + // and setBackgroundTask stopped after the first interval: the channel list + // was loaded once and never refreshed while the page stayed open. Same + // period as the boards list, which asks the same kind of question -- a + // five second poll of a whole summaries list is a lot to pay on a phone. + rs.setBackgroundTask(getChannels.load, CHANNEL_LIST_REFRESH_MS, () => + m.route.get().startsWith('/channels') + ); peopleUtil.ownIds((data) => { ownId = data; for (let i = 0; i < ownId.length; i++) { @@ -61,18 +79,14 @@ const Layout = () => { }, // onupdate: getChannels.load, view: (vnode) => - m('.widget', [ + m('.widget', { + class: vnode.attrs.pathInfo.mGroupId && !vnode.attrs.pathInfo.mMsgId ? 'channels-detail-widget' : '', + }, [ m('.top-heading', [ m( - 'button', + 'button.channels-create-button', { - onclick: () => - ownId && - widget.popupMessage( - m(viewUtil.createchannel, { - authorId: ownId, - }) - ), + onclick: createChannel, }, 'Create Channel' ), @@ -95,10 +109,12 @@ const Layout = () => { : Object.prototype.hasOwnProperty.call(vnode.attrs.pathInfo, 'mGroupId') // channels view ? m(viewUtil.ChannelView, { id: vnode.attrs.pathInfo.mGroupId, + onSubscriptionChange: getChannels.load, }) : m(sections[vnode.attrs.pathInfo.tab], { // subscribed, all, popular, other list: getChannels[vnode.attrs.pathInfo.tab], + onCreateChannel: createChannel, }), ]), }; @@ -110,6 +126,7 @@ module.exports = { m(widget.Sidebar, { tabs: Object.keys(sections), baseRoute: '/channels/', + mobileDrawer: true, }), m('.node-panel', m(Layout, { pathInfo: vnode.attrs })), ]; diff --git a/webui-src/app/channels/channels_util.js b/webui-src/app/channels/channels_util.js index 9e272d8..e484352 100644 --- a/webui-src/app/channels/channels_util.js +++ b/webui-src/app/channels/channels_util.js @@ -31,58 +31,111 @@ const Data = { Votes: {}, }; -async function updatecontent(content, channelid) { - const res = await rs.rsJsonApiRequest('/rsgxschannels/getChannelContent', { - channelId: channelid, - contentsIds: [content.mMsgId], - }); - if (res.body.retval && res.body.posts.length > 0) { - Data.Posts[channelid][content.mMsgId] = { post: res.body.posts[0], isSearched: true }; - } else if (res.body.retval && res.body.comments.length > 0) { - if (Data.Comments[content.mThreadId] === undefined) { - Data.Comments[content.mThreadId] = {}; - } - Data.Comments[content.mThreadId][content.mMsgId] = { - comment: res.body.comments[0], - showReplies: false, - }; // Comments[post][comment] - const comm = res.body.comments[0]; - if (Data.TopComments[comm.mMeta.mThreadId] === undefined) { - Data.TopComments[comm.mMeta.mThreadId] = {}; - } - if (comm.mMeta.mThreadId === comm.mMeta.mParentId) { - // this is a check for the top level comments - Data.TopComments[comm.mMeta.mThreadId][comm.mMeta.mMsgId] = comm; - // pushing top comments respective to post - } else { - if (Data.ParentCommentMap[comm.mMeta.mParentId] === undefined) { - Data.ParentCommentMap[comm.mMeta.mParentId] = {}; - } - Data.ParentCommentMap[comm.mMeta.mParentId][comm.mMeta.mMsgId] = comm; - } - } else if (res.body.retval && res.body.votes.length > 0) { - const vote = res.body.votes[0]; +// getChannelContent takes a set of ids, so a whole channel is fetched in a few +// requests instead of one per item. Chunked rather than sent as a single call so +// that no request grows unbounded and so the UI can paint as batches land. +const CONTENT_BATCH_SIZE = 25; - if (Data.Votes[vote.mMeta.mThreadId] === undefined) { - Data.Votes[vote.mMeta.mThreadId] = {}; - } - if (Data.Votes[vote.mMeta.mThreadId][vote.mMeta.mParentId] === undefined) { - Data.Votes[vote.mMeta.mThreadId][vote.mMeta.mParentId] = { upvotes: 0, downvotes: 0 }; - } - if (vote.mVoteType === GXS_VOTE_UP) { - Data.Votes[vote.mMeta.mThreadId][vote.mMeta.mParentId].upvotes += 1; - } +function storePost(post, channelid) { + const msgId = post.mMeta && post.mMeta.mMsgId; + if (!msgId) { + return; + } + Data.Posts[channelid][msgId] = { post, isSearched: true }; +} - if (vote.mVoteType === GXS_VOTE_DOWN) { - Data.Votes[vote.mMeta.mThreadId][vote.mMeta.mParentId].downvotes += 1; +function storeComment(comm) { + const meta = comm.mMeta; + if (!meta) { + return; + } + if (Data.Comments[meta.mThreadId] === undefined) { + Data.Comments[meta.mThreadId] = {}; + } + Data.Comments[meta.mThreadId][meta.mMsgId] = { comment: comm, showReplies: false }; // Comments[post][comment] + if (Data.TopComments[meta.mThreadId] === undefined) { + Data.TopComments[meta.mThreadId] = {}; + } + if (meta.mThreadId === meta.mParentId) { + // this is a check for the top level comments + Data.TopComments[meta.mThreadId][meta.mMsgId] = comm; + // pushing top comments respective to post + } else { + if (Data.ParentCommentMap[meta.mParentId] === undefined) { + Data.ParentCommentMap[meta.mParentId] = {}; } + Data.ParentCommentMap[meta.mParentId][meta.mMsgId] = comm; } } -async function updatedisplaychannels(keyid, details) { +function storeVote(vote) { + const meta = vote.mMeta; + if (!meta) { + return; + } + if (Data.Votes[meta.mThreadId] === undefined) { + Data.Votes[meta.mThreadId] = {}; + } + if (Data.Votes[meta.mThreadId][meta.mParentId] === undefined) { + Data.Votes[meta.mThreadId][meta.mParentId] = { upvotes: 0, downvotes: 0 }; + } + if (vote.mVoteType === GXS_VOTE_UP) { + Data.Votes[meta.mThreadId][meta.mParentId].upvotes += 1; + } + + if (vote.mVoteType === GXS_VOTE_DOWN) { + Data.Votes[meta.mThreadId][meta.mParentId].downvotes += 1; + } +} + +async function updatecontent(contentIds, channelid) { + const ids = Array.isArray(contentIds) ? contentIds : [contentIds]; + if (ids.length === 0) { + return true; + } + const res = await rs.rsJsonApiRequest('/rsgxschannels/getChannelContent', { + channelId: channelid, + contentsIds: ids, + }); + // rsJsonApiRequest resolves to undefined when the request never made it out + if (!res || !res.body || !res.body.retval) { + return false; + } + // A batch mixes the three kinds, so all three lists have to be walked. The + // metadata of each item is used rather than the summary it was asked from. + (res.body.posts || []).forEach((post) => storePost(post, channelid)); + (res.body.comments || []).forEach(storeComment); + (res.body.votes || []).forEach(storeVote); + return true; +} + +// Large posts can contain base64 media. If RetroShare truncates a response, +// retry it as two smaller requests until the problematic batch is isolated. +async function updateContentBatch(contentIds, channelid) { + const loaded = await updatecontent(contentIds, channelid); + // Splitting only makes sense against a core that answers: when it is gone, + // every half fails too and one batch of 25 turns into 49 doomed requests. + // connectionState stays true when a 200 arrived but its body was cut short, + // which is exactly the case worth retrying smaller. + if (loaded || contentIds.length <= 1 || !rs.connectionState.status) { + if (!loaded) { + console.warn('Unable to load channel content item', contentIds[0]); + } + return; + } + + const middle = Math.ceil(contentIds.length / 2); + await updateContentBatch(contentIds.slice(0, middle), channelid); + await updateContentBatch(contentIds.slice(middle), channelid); +} + +async function updatedisplaychannels(keyid, details, loadContent = true) { const res1 = await rs.rsJsonApiRequest('/rsgxschannels/getChannelsInfo', { chanIds: [keyid], }); + if (!res1 || !res1.body || !Array.isArray(res1.body.channelsInfo) || !res1.body.channelsInfo[0]) { + return; + } details = res1.body.channelsInfo[0]; Data.DisplayChannels[keyid] = { // struct for a channel @@ -103,14 +156,25 @@ async function updatedisplaychannels(keyid, details) { if (Data.Posts[keyid] === undefined) { Data.Posts[keyid] = {}; } + // Channel lists only need metadata. Fetching every post, comment, vote and + // embedded image for every listed channel made large lists extremely slow. + if (!loadContent) { + return; + } const res2 = await rs.rsJsonApiRequest('/rsgxschannels/getContentSummaries', { channelId: keyid, }); - if (res2.body.retval) { - res2.body.summaries.map(async (content) => { - await updatecontent(content, keyid); - }); + if (!res2 || !res2.body || !res2.body.retval || !Array.isArray(res2.body.summaries)) { + return; + } + + const ids = res2.body.summaries.map((content) => content.mMsgId).filter(Boolean); + // Sequential on purpose: this runs once per channel of the list, so firing the + // batches concurrently would put the browser back where it started. + for (let i = 0; i < ids.length; i += CONTENT_BATCH_SIZE) { + await updateContentBatch(ids.slice(i, i + CONTENT_BATCH_SIZE), keyid); + m.redraw(); } } const DisplayChannelsFromList = () => { @@ -142,7 +206,7 @@ const ChannelSummary = () => { return { oninit: (v) => { keyid = v.attrs.details.mGroupId; - updatedisplaychannels(keyid); + updatedisplaychannels(keyid, undefined, false); }, view: (v) => {}, @@ -154,7 +218,9 @@ const CommentsTable = () => { oninit: (v) => {}, view: (v) => m('table.comments', [ - m('tr', [ + // See table.mails: the header row is tagged so the small screen + // stylesheet can card-ify the data rows only. + m('tr.comments-head', [ m('th', ''), m('th', 'Comment'), m('th', 'Author'), @@ -173,8 +239,8 @@ const FilesTable = () => { return { oninit: (v) => {}, view: (v) => - m('table.files', [ - m('tr', [m('th', 'File Name'), m('th', 'Size'), m('th', m('i.fas.fa-download'))]), + m('table.files.channel-files', [ + m('thead', m('tr', [m('th', 'File Name'), m('th', 'Size'), m('th', m('i.fas.fa-download'))])), v.children, ]), }; diff --git a/webui-src/app/channels/my_channels.js b/webui-src/app/channels/my_channels.js index 9978bab..9282b1a 100644 --- a/webui-src/app/channels/my_channels.js +++ b/webui-src/app/channels/my_channels.js @@ -4,7 +4,12 @@ const util = require('channels/channels_util'); const Layout = () => { return { view: (v) => [ - m('.widget__heading', m('h3', 'My Channels')), + m('.widget__heading', [ + m('h3', 'My Channels'), + m('button.channels-heading-create[type=button][title=Create Channel][aria-label=Create Channel]', { + onclick: v.attrs.onCreateChannel, + }, m('i.fas.fa-plus')), + ]), m('.widget__body', [ m( util.ChannelTable, diff --git a/webui-src/app/channels/other_channels.js b/webui-src/app/channels/other_channels.js index cc8c5bf..491d33e 100644 --- a/webui-src/app/channels/other_channels.js +++ b/webui-src/app/channels/other_channels.js @@ -4,7 +4,12 @@ const util = require('channels/channels_util'); const Layout = () => { return { view: (v) => [ - m('.widget__heading', m('h3', 'Other Channels')), + m('.widget__heading', [ + m('h3', 'Other Channels'), + m('button.channels-heading-create[type=button][title=Create Channel][aria-label=Create Channel]', { + onclick: v.attrs.onCreateChannel, + }, m('i.fas.fa-plus')), + ]), m('.widget__body', [ m( util.ChannelTable, @@ -12,13 +17,13 @@ const Layout = () => { v.attrs.list.map((channel) => m(util.ChannelSummary, { details: channel, - category: 'OtherChannels', + category: 'Other', }) ), v.attrs.list.map((channel) => m(util.DisplayChannelsFromList, { id: channel.mGroupId, - category: 'OtherChannels', + category: 'Other', }) ), ]) diff --git a/webui-src/app/channels/popular_channels.js b/webui-src/app/channels/popular_channels.js index db58754..ceb649e 100644 --- a/webui-src/app/channels/popular_channels.js +++ b/webui-src/app/channels/popular_channels.js @@ -4,7 +4,12 @@ const util = require('channels/channels_util'); const Layout = () => { return { view: (v) => [ - m('.widget__heading', m('h3', 'Popular Channels')), + m('.widget__heading', [ + m('h3', 'Popular Channels'), + m('button.channels-heading-create[type=button][title=Create Channel][aria-label=Create Channel]', { + onclick: v.attrs.onCreateChannel, + }, m('i.fas.fa-plus')), + ]), m('.widget__body', [ m( util.ChannelTable, @@ -12,13 +17,13 @@ const Layout = () => { v.attrs.list.map((channel) => m(util.ChannelSummary, { details: channel, - category: 'PopularChannels', + category: 'Popular', }) ), v.attrs.list.map((channel) => m(util.DisplayChannelsFromList, { id: channel.mGroupId, - category: 'PopularChannels', + category: 'Popular', }) ), ]) diff --git a/webui-src/app/channels/subscribed_channels.js b/webui-src/app/channels/subscribed_channels.js index 0a28f01..25f5b16 100644 --- a/webui-src/app/channels/subscribed_channels.js +++ b/webui-src/app/channels/subscribed_channels.js @@ -4,7 +4,13 @@ const util = require('channels/channels_util'); const Layout = () => { return { view: (v) => [ - m('.widget__heading', m('h3', 'Subscribed Channels')), + m('.widget__heading', [ + m('h3', 'Subscribed Channels'), + // Same phone-only create entry point as the sibling tabs. + m('button.channels-heading-create[type=button][title=Create Channel][aria-label=Create Channel]', { + onclick: v.attrs.onCreateChannel, + }, m('i.fas.fa-plus')), + ]), m('.widget__body', [ m( util.ChannelTable, @@ -12,13 +18,13 @@ const Layout = () => { v.attrs.list.map((channel) => m(util.ChannelSummary, { details: channel, - category: 'SubscribedChannels', + category: 'Subscribed', }) ), v.attrs.list.map((channel) => m(util.DisplayChannelsFromList, { id: channel.mGroupId, - category: 'SubscribedChannels', + category: 'Subscribed', }) ), ]) diff --git a/webui-src/app/chat/chat.js b/webui-src/app/chat/chat.js index ff8e215..178c56b 100644 --- a/webui-src/app/chat/chat.js +++ b/webui-src/app/chat/chat.js @@ -14,6 +14,8 @@ const { ChatRoomsModel, ChatLobbyModel, ChatHubState, + autoResizeTextarea, + openChatImageViewer, } = chatState; chatEmoji.setDependencies({ ChatHubState }); @@ -52,14 +54,14 @@ function formatChatImage(file, callback) { } if (dataUrl.length <= 32000) { - callback(``); + callback(``, dataUrl); } else { alert('Image file is too large to send over RetroShare chat packet size limit.'); - callback(null); + callback(null, null); } }; img.onerror = () => { - callback(null); + callback(null, null); }; img.src = evt.target.result; }; @@ -110,8 +112,109 @@ function scrollChatToBottom() { }, 50); } -function pollHashStatus(localpath) { +// Whether the conversation is still pinned to its last message. It starts +// pinned, follows the user's own scrolling, and decides whether a redraw is +// allowed to jump back down -- without it, reading anything older is +// impossible: the pane redraws on every event and every poll answer, and each +// one dragged the reader back to the bottom a few tens of milliseconds later. +let chatStickToBottom = true; + +// A little slack, because a reader who stops one line short of the end still +// means "keep following", and because scrollTop is fractional on zoomed or +// high-density displays. +const CHAT_STICK_SLACK_PX = 80; + +function updateChatStickToBottom(element) { + if (!element) return; + chatStickToBottom = + element.scrollHeight - element.scrollTop - element.clientHeight <= CHAT_STICK_SLACK_PX; +} + +// Far enough from the top to have the next slice ready before the reader gets +// there, close enough not to fire on the first flick of a long conversation. +const CHAT_LOAD_OLDER_AT_PX = 120; + +function loadOlderWhenAtTop(element) { + if (!element || element.scrollTop > CHAT_LOAD_OLDER_AT_PX) return; + + // Older messages are inserted above the ones on screen, which pushes + // everything down by exactly the height they add. Put that height back into + // scrollTop and the reader does not move at all -- without it the pane jumps + // to a different part of the conversation each time a slice lands. + const previousHeight = element.scrollHeight; + const previousTop = element.scrollTop; + + ChatLobbyModel.loadOlderHistory(() => { + requestAnimationFrame(() => { + const pane = document.querySelector('.chat-hub-messages'); + if (!pane) return; + pane.scrollTop = previousTop + (pane.scrollHeight - previousHeight); + }); + }); +} + +function renderUserTooltip(gxsId, name) { + const details = ChatHubState.gxsDetails[gxsId]; + if (!details) return null; + + const avatar = getSafeAvatar(details); + const firstLetter = (name || '?').slice(0, 1).toUpperCase(); + const votes = details.mReputation + ? (details.mReputation.mFriendsPositiveVotes - details.mReputation.mFriendsNegativeVotes) + : 0; + + const rect = ChatHubState.hoveredUser ? ChatHubState.hoveredUser.rect : null; + const tooltipWidth = 280; + const tooltipGap = 10; + let left = rect ? rect.left - tooltipWidth - tooltipGap : window.innerWidth - tooltipWidth - tooltipGap; + if (left < tooltipGap && rect) left = rect.right + tooltipGap; + let top = rect ? rect.top : 100; + if (top + 160 > window.innerHeight) top = window.innerHeight - 170; + if (top < 10) top = 10; + + return m('.user-tooltip', { + style: { + position: 'fixed', + top: `${top}px`, + left: `${left}px`, + zIndex: 10000, + } + }, [ + m('.tooltip-avatar', m(peopleUtil.UserAvatar, { avatar, firstLetter, identityId: gxsId, size: 56, isSquare: true })), + m('.tooltip-details', [ + m('.tooltip-row', [m('span.tooltip-label', 'Identity name: '), m('span.tooltip-value', name)]), + m('.tooltip-row', [m('span.tooltip-label', 'Identity Id: '), m('span.tooltip-value.tooltip-id', gxsId)]), + details.mPgpId && details.mPgpId !== '0000000000000000' && m('.tooltip-row', [ + m('span.tooltip-label', 'Node: '), + m('span.tooltip-value', `${rs.userList.username(details.mPgpId) || name} [${details.mPgpId}]`) + ]), + m('.tooltip-row', [ + m('span.tooltip-label', 'Votes: '), + m('span.tooltip-value', { + style: { + color: votes >= 0 ? '#008000' : '#cc0000', + fontWeight: 'bold' + } + }, (votes >= 0 ? '+' : '') + votes) + ]) + ]) + ]); +} + +// Hashing a large file takes minutes, so there is no deadline to enforce here. +// What the poll must not do is keep asking at full speed: it backs off from one +// second towards ten, which is still prompt for a small file and costs a +// request every ten seconds for a big one -- each of them a fresh connection, +// the JSON API answers `Connection: close`. +const HASH_POLL_START_MS = 1000; +const HASH_POLL_MAX_MS = 10000; + +function pollHashStatus(localpath, delay = HASH_POLL_START_MS) { rs.rsJsonApiRequest('/rsFiles/ExtraFileStatus', { localpath }, (data) => { + // Give up quietly if the user cancelled or left in the meantime: this + // chain lives in a setTimeout, not in the component. + if (!ChatHubState.isHashing || ChatHubState.attachPath !== localpath) return; + if (data && data.retval && data.info && data.info.hash && data.info.hash !== '0000000000000000000000000000000000000000') { const info = data.info; const sizeNum = info.size.xint64 || parseInt(info.size.xstr64) || info.size; @@ -121,6 +224,7 @@ function pollHashStatus(localpath) { if (textarea) { const val = textarea.value; textarea.value = val ? val + '\n' + fileLink : fileLink; + autoResizeTextarea(textarea); } ChatHubState.showAttachModal = false; @@ -128,15 +232,29 @@ function pollHashStatus(localpath) { ChatHubState.attachPath = ''; m.redraw(); } else { - if (ChatHubState.isHashing) { - setTimeout(() => pollHashStatus(localpath), 1000); - } + setTimeout( + () => pollHashStatus(localpath, Math.min(delay * 2, HASH_POLL_MAX_MS)), + delay + ); } }); } +// The core has no way of telling us that hashing failed -- ftExtraList drops +// the file silently -- so a file that exists but cannot be read leaves this +// poll running for ever. Hence: stopping must always be possible from the +// dialog, and this is the single place that does it. +function stopHashing() { + ChatHubState.isHashing = false; + ChatHubState.attachPath = ''; + ChatHubState.attachBrowseHint = false; + ChatHubState.hashingError = ''; +} + // ************************* views **************************** +// ************************* Chat Hub Sub-Components **************************** + const ChatRoomHeader = () => { return { view: (vnode) => { @@ -200,6 +318,22 @@ const ChatRoomHeader = () => { ) ] : [ + // Below 900px the participants column is not laid out; this + // opens it as a sheet. Desktop hides the button (see + // pages/_chat.scss), the column being always visible there. + m( + 'button.participants-toggle', + { + title: 'Participants', + style: 'margin-right: 0.75rem;', + onclick: () => { + ChatHubState.showParticipants = !ChatHubState.showParticipants; + ChatHubState.activeMenu = null; + ChatHubState.hoveredUser = null; + } + }, + [m('i.fas.fa-users'), ' ' + ChatLobbyModel.users.length] + ), m( 'button', { @@ -246,11 +380,17 @@ const ChatRoomHeader = () => { }; const ChatConversationView = () => { + let showAttachmentMenu = false; + function onDocClick(e) { if (ChatHubState.showEmojiPicker && !e.target.closest('.emoji-picker-wrapper')) { ChatHubState.showEmojiPicker = false; m.redraw(); } + if (showAttachmentMenu && !e.target.closest('.mobile-chat-attachment')) { + showAttachmentMenu = false; + m.redraw(); + } } return { oninit: () => { @@ -261,27 +401,68 @@ const ChatConversationView = () => { }, onremove: () => { document.removeEventListener('click', onDocClick, true); + // The poll writes its file link into the textarea of this very view, so + // once the view is gone the answer has nowhere to land: leaving it + // running would only keep asking for a result nobody can use. + if (ChatHubState.isHashing) { + ChatHubState.showAttachModal = false; + stopHashing(); + } }, view: () => { const chatType = ChatLobbyModel.currentLobby && ChatLobbyModel.currentLobby.chatType; const isRoom = chatType === 3; const isDistant = chatType === 2; const canTalk = !isDistant || (ChatLobbyModel.distantChatStatus && ChatLobbyModel.distantChatStatus.status === 2); - return m('.chat-hub-conversation-layout', [ + return m('.chat-hub-conversation-layout' + (ChatHubState.showParticipants ? '.show-participants' : ''), [ m('.chat-hub-conversation-main', [ m( '.chat-hub-messages' + (isRoom ? '.compact-container' : ''), { - oncreate: () => scrollChatToBottom(), - onupdate: () => scrollChatToBottom(), + oncreate: () => { + chatStickToBottom = true; + scrollChatToBottom(); + }, + onupdate: () => { + if (chatStickToBottom) scrollChatToBottom(); + }, + onscroll: (event) => { + // A scroll must not trigger a redraw: mithril redraws after + // every handler by default, and this one fires continuously + // while the reader drags the pane. + event.redraw = false; + updateChatStickToBottom(event.target); + loadOlderWhenAtTop(event.target); + }, }, ChatLobbyModel.messages ), + ChatHubState.attachedImage && m('.chat-attachment-preview', [ + m('.chat-attachment-preview__item', [ + m('img.chat-attachment-preview__thumb', { + src: ChatHubState.attachedImage.dataUrl, + alt: 'Preview', + title: 'Click to view full image', + onclick: () => openChatImageViewer(ChatHubState.attachedImage.dataUrl), + }), + m('button.chat-attachment-preview__remove', { + type: 'button', + title: 'Remove image', + onclick: () => { + ChatHubState.attachedImage = null; + } + }, m('i.fas.fa-times')), + ]), + m('.chat-attachment-preview__info', [ + m('span.chat-attachment-preview__name', ChatHubState.attachedImage.name || 'Image attached'), + m('span.chat-attachment-preview__hint', 'Will be sent with your message'), + ]), + ]), m( '.chat-hub-input-area', [ m( - 'button.chat-hub-action-btn', + 'button.chat-hub-action-btn.desktop-chat-attachment', { disabled: !canTalk, style: !canTalk ? 'opacity: 0.5; cursor: not-allowed;' : '', @@ -293,6 +474,46 @@ const ChatConversationView = () => { }, m('i.fas.fa-paperclip') ), + m('.mobile-chat-attachment', [ + m('button.chat-hub-action-btn', { + disabled: !canTalk, + style: !canTalk ? 'opacity: 0.5; cursor: not-allowed;' : '', + title: 'Add attachment', + onclick: (e) => { + e.stopPropagation(); + showAttachmentMenu = !showAttachmentMenu; + ChatHubState.showEmojiPicker = false; + }, + }, m('i.fas.fa-paperclip')), + showAttachmentMenu && m('.mobile-chat-attachment__menu', [ + m('button.mobile-chat-attachment__option', { + type: 'button', + onclick: () => { + showAttachmentMenu = false; + ChatHubState.showAttachModal = true; + }, + }, [m('i.fas.fa-file'), ' File']), + m('label.mobile-chat-attachment__option', [ + m('i.fas.fa-image'), + ' Picture', + m('input[type=file][accept=image/*]', { + style: 'display: none;', + onchange: (e) => { + if (!e.target.files || !e.target.files[0]) return; + const file = e.target.files[0]; + formatChatImage(file, (imgTag, dataUrl) => { + if (imgTag && dataUrl) { + ChatHubState.attachedImage = { imgTag, dataUrl, name: file.name || 'Image' }; + m.redraw(); + } + }); + showAttachmentMenu = false; + e.target.value = ''; + }, + }), + ]), + ]), + ]), m('.emoji-picker-wrapper', [ m( 'button.chat-hub-action-btn', @@ -309,7 +530,7 @@ const ChatConversationView = () => { ), ChatHubState.showEmojiPicker && m(chatEmoji.EmojiPicker), ]), - m('label.chat-hub-action-btn', { + m('label.chat-hub-action-btn.desktop-chat-attachment', { title: 'Send image', style: `cursor: ${canTalk ? 'pointer' : 'not-allowed'}; opacity: ${canTalk ? 1 : 0.5};`, }, [ @@ -320,13 +541,9 @@ const ChatConversationView = () => { onchange: (e) => { if (!e.target.files || !e.target.files[0]) return; const file = e.target.files[0]; - const textarea = e.target.closest('.chat-hub-input-area').querySelector('textarea'); - formatChatImage(file, (imgTag) => { - if (imgTag && textarea) { - const start = textarea.selectionStart || 0; - const end = textarea.selectionEnd || 0; - const val = textarea.value; - textarea.value = val.substring(0, start) + imgTag + val.substring(end); + formatChatImage(file, (imgTag, dataUrl) => { + if (imgTag && dataUrl) { + ChatHubState.attachedImage = { imgTag, dataUrl, name: file.name || 'Image' }; m.redraw(); } }); @@ -335,9 +552,13 @@ const ChatConversationView = () => { }) ]), m('textarea.chat-hub-textarea', { - placeholder: canTalk ? 'Type a message... Press Enter to send (or paste image)' : 'Waiting for tunnel to be secured...', + placeholder: ChatHubState.attachedImage ? 'Add a caption... (optional)' : 'Type a message...', disabled: !canTalk, enterkeyhint: 'send', + rows: 1, + oncreate: (vnode) => autoResizeTextarea(vnode.dom), + onupdate: (vnode) => autoResizeTextarea(vnode.dom), + oninput: (e) => autoResizeTextarea(e.target), onpaste: (e) => { if (!canTalk) return; const items = (e.clipboardData || (e.originalEvent && e.originalEvent.clipboardData))?.items; @@ -346,13 +567,9 @@ const ChatConversationView = () => { if (items[i].type.indexOf('image') !== -1) { e.preventDefault(); const blob = items[i].getAsFile(); - const textarea = e.target; - formatChatImage(blob, (imgTag) => { - if (imgTag && textarea) { - const start = textarea.selectionStart || 0; - const end = textarea.selectionEnd || 0; - const val = textarea.value; - textarea.value = val.substring(0, start) + imgTag + val.substring(end); + formatChatImage(blob, (imgTag, dataUrl) => { + if (imgTag && dataUrl) { + ChatHubState.attachedImage = { imgTag, dataUrl, name: 'Pasted image' }; m.redraw(); } }); @@ -361,16 +578,40 @@ const ChatConversationView = () => { } }, onkeydown: (e) => { - if ((e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey) { - if (!canTalk) return false; - const msg = e.target.value; - if (msg.trim() === '') return false; - e.target.value = ' sending ... '; - ChatLobbyModel.sendMessage(msg, () => { - e.target.value = ''; - scrollChatToBottom(); - }); - return false; + if (e.key === 'Enter' || e.keyCode === 13) { + if (!e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault(); + if (!canTalk) return false; + const textarea = e.target; + const msg = (textarea.value || '').trim(); + const attached = ChatHubState.attachedImage; + if (!msg && !attached) return false; + + const fullMsg = attached + ? (msg ? `${msg}\n${attached.imgTag}` : attached.imgTag) + : msg; + + textarea.value = ' sending ... '; + ChatHubState.attachedImage = null; + ChatLobbyModel.sendMessage(fullMsg, () => { + textarea.value = ''; + autoResizeTextarea(textarea); + scrollChatToBottom(); + m.redraw(); + }); + return false; + } + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + if (!document.execCommand || !document.execCommand('insertText', false, '\n')) { + const start = e.target.selectionStart || 0; + const end = e.target.selectionEnd || 0; + const val = e.target.value; + e.target.value = val.substring(0, start) + '\n' + val.substring(end); + e.target.selectionStart = e.target.selectionEnd = start + 1; + } + autoResizeTextarea(e.target); + } } }, }), @@ -382,12 +623,23 @@ const ChatConversationView = () => { onclick: (e) => { if (!canTalk) return; const textarea = e.target.closest('.chat-hub-input-area').querySelector('textarea'); - const msg = textarea.value; - if (msg.trim() === '') return; - textarea.value = ' sending ... '; - ChatLobbyModel.sendMessage(msg, () => { - textarea.value = ''; + const msg = (textarea ? textarea.value : '').trim(); + const attached = ChatHubState.attachedImage; + if (!msg && !attached) return; + + const fullMsg = attached + ? (msg ? `${msg}\n${attached.imgTag}` : attached.imgTag) + : msg; + + if (textarea) textarea.value = ' sending ... '; + ChatHubState.attachedImage = null; + ChatLobbyModel.sendMessage(fullMsg, () => { + if (textarea) { + textarea.value = ''; + autoResizeTextarea(textarea); + } scrollChatToBottom(); + m.redraw(); }); }, }, @@ -397,11 +649,9 @@ const ChatConversationView = () => { ), ChatHubState.showAttachModal && m('.attach-modal-overlay', { onclick: (e) => { - if (e.target === e.currentTarget && !ChatHubState.isHashing) { + if (e.target === e.currentTarget) { ChatHubState.showAttachModal = false; - ChatHubState.attachPath = ''; - ChatHubState.attachBrowseHint = false; - ChatHubState.hashingError = ''; + stopHashing(); } } }, [ @@ -475,6 +725,9 @@ const ChatConversationView = () => { disabled: ChatHubState.isHashing || !ChatHubState.attachPath.trim() || ChatHubState.attachBrowseHint, onclick: () => { const path = ChatHubState.attachPath.trim(); + // Normalised, because the poll below identifies its own run + // by comparing this against the path it was started with. + ChatHubState.attachPath = path; ChatHubState.isHashing = true; ChatHubState.hashingError = ''; m.redraw(); @@ -495,21 +748,29 @@ const ChatConversationView = () => { } }, [m('i.fas.fa-link'), m('span', ' Attach')]), m('button.btn.red', { - disabled: ChatHubState.isHashing, onclick: () => { ChatHubState.showAttachModal = false; - ChatHubState.attachPath = ''; - ChatHubState.attachBrowseHint = false; - ChatHubState.hashingError = ''; + stopHashing(); } - }, 'Cancel') + }, ChatHubState.isHashing ? 'Stop' : 'Cancel') ]) ]) ]), m(HistoryBrowserModal, { isRoom: true }), ]), m('.chat-hub-rightbar', [ - m('.rightbar-title', 'Participants'), + m('.rightbar-title', [ + 'Participants', + m('button.rightbar-close', { + type: 'button', + title: 'Close', + 'aria-label': 'Close participants', + onclick: () => { + ChatHubState.showParticipants = false; + ChatHubState.activeMenu = null; + }, + }, m('i.fas.fa-times')), + ]), m('.rightbar-users-list', (() => { const sortedUsers = [...ChatLobbyModel.users]; if (ChatHubState.userSortMethod === 'activity') { @@ -561,30 +822,7 @@ const ChatConversationView = () => { statusTooltip = 'Away'; } - return m('.user', { - onmouseenter: (e) => { - if (ChatHubState.activeMenu) return; - const rect = e.currentTarget.getBoundingClientRect(); - const rightbar = document.querySelector('.chat-hub-rightbar'); - if (rightbar) { - const parentRect = rightbar.getBoundingClientRect(); - const top = rect.top - parentRect.top + rect.height / 2; - ChatHubState.hoveredUser = { gxsId, name, top }; - } - }, - onmouseleave: () => { - ChatHubState.hoveredUser = null; - }, - onclick: (e) => { - e.preventDefault(); - e.stopPropagation(); - ChatHubState.hoveredUser = null; - ChatHubState.activeMenu = null; - m.redraw(); - }, - oncontextmenu: (e) => { - e.preventDefault(); - e.stopPropagation(); + const openUserMenu = (e) => { ChatHubState.hoveredUser = null; const rect = e.currentTarget.getBoundingClientRect(); @@ -601,7 +839,36 @@ const ChatConversationView = () => { ChatHubState.activeMenu = { gxsId, name, top }; m.redraw(); } - } + }; + + return m('.user', { + onmouseenter: (e) => { + if (ChatHubState.activeMenu) return; + const rect = e.currentTarget.getBoundingClientRect(); + ChatHubState.hoveredUser = { gxsId, name, rect }; + }, + onmouseleave: () => { + ChatHubState.hoveredUser = null; + }, + onclick: (e) => { + e.preventDefault(); + e.stopPropagation(); + ChatHubState.hoveredUser = null; + // A phone has no right click: a tap on a participant is the + // only way to reach "Start private chat" and the rest of + // the menu. Same media query as the sheet in _chat.scss. + if (window.matchMedia('(max-width: 899px), (hover: none)').matches) { + openUserMenu(e); + return; + } + ChatHubState.activeMenu = null; + m.redraw(); + }, + oncontextmenu: (e) => { + e.preventDefault(); + e.stopPropagation(); + openUserMenu(e); + }, }, [ m(peopleUtil.UserAvatar, { avatar, firstLetter, identityId: gxsId, size: 32 }), m('span.user-name', name), @@ -641,59 +908,44 @@ const ChatConversationView = () => { }); } return null; - })() + })(), ]); }); })()), - ChatHubState.hoveredUser && (() => { - const hUser = ChatHubState.hoveredUser; - const details = ChatHubState.gxsDetails[hUser.gxsId]; - if (!details) return null; - - const avatar = getSafeAvatar(details); - const firstLetter = (hUser.name || '?').slice(0, 1).toUpperCase(); - const votes = details.mReputation - ? (details.mReputation.mFriendsPositiveVotes - details.mReputation.mFriendsNegativeVotes) - : 0; - - return m('.user-tooltip', { - style: { - top: `${hUser.top}px`, - } - }, [ - m('.tooltip-avatar', m(peopleUtil.UserAvatar, { avatar, firstLetter, identityId: hUser.gxsId, size: 64 })), - m('.tooltip-details', [ - m('.tooltip-row', [m('span.tooltip-label', 'Identity name: '), m('span.tooltip-value', hUser.name)]), - m('.tooltip-row', [m('span.tooltip-label', 'Identity Id: '), m('span.tooltip-value.tooltip-id', hUser.gxsId)]), - details.mPgpId && details.mPgpId !== '0000000000000000' && m('.tooltip-row', [ - m('span.tooltip-label', 'Node: '), - m('span.tooltip-value', `${rs.userList.username(details.mPgpId) || hUser.name} [${details.mPgpId}]`) - ]), - m('.tooltip-row', [ - m('span.tooltip-label', 'Votes: '), - m('span.tooltip-value', { - style: { - color: votes >= 0 ? '#22c55e' : '#ef4444', - fontWeight: 'bold' - } - }, (votes >= 0 ? '+' : '') + votes) - ]) - ]) - ]); - })(), + ChatHubState.hoveredUser && renderUserTooltip(ChatHubState.hoveredUser.gxsId, ChatHubState.hoveredUser.name), ChatHubState.activeMenu && (() => { const menu = ChatHubState.activeMenu; const isOwn = menu.gxsId === rs.idToHex(ChatLobbyModel.currentLobby.gxs_id || ''); const isMuted = ChatHubState.mutedUsers && ChatHubState.mutedUsers.has(menu.gxsId); - return m('.rightbar-context-menu', { - style: { - top: `${menu.top}px`, - }, - onclick: (e) => { - e.stopPropagation(); - } - }, [ + return [ + m('.menu-backdrop', { + style: { + position: 'fixed', + inset: 0, + zIndex: 9998, + }, + onclick: (e) => { + e.preventDefault(); + e.stopPropagation(); + ChatHubState.activeMenu = null; + m.redraw(); + }, + oncontextmenu: (e) => { + e.preventDefault(); + e.stopPropagation(); + ChatHubState.activeMenu = null; + m.redraw(); + }, + }), + m('.rightbar-context-menu', { + style: { + top: `${menu.top}px`, + }, + onclick: (e) => { + e.stopPropagation(); + } + }, [ m('.menu-item', { onclick: () => { ChatHubState.userSortMethod = 'activity'; @@ -736,6 +988,7 @@ const ChatConversationView = () => { !isOwn && m('.menu-item', { onclick: () => { ChatHubState.activeMenu = null; + ChatHubState.showParticipants = false; people.setSelectedId(menu.gxsId, 'chat'); } }, [ @@ -745,6 +998,7 @@ const ChatConversationView = () => { !isOwn && m('.menu-item', { onclick: () => { ChatHubState.activeMenu = null; + ChatHubState.showParticipants = false; people.setSelectedId(menu.gxsId, 'details', true); } }, [ @@ -850,7 +1104,7 @@ const ChatConversationView = () => { m('i.fas.fa-user', { style: 'color: #8b5cf6; margin-right: 0.5rem; width: 18px; text-align: center;' }), 'Show author in people tab' ]) - ]); + ])]; })() ]) ]); @@ -901,6 +1155,8 @@ function getLobbyPrivacyInfo(room) { } const ChatRoomDetailView = () => { + let activeParticipantId = null; + return { view: () => { const room = ChatHubState.selectedRoom; @@ -934,8 +1190,7 @@ const ChatRoomDetailView = () => { } const participantCount = participants.length; - const participantNames = participants.map((p) => p.name); - participantNames.sort((a, b) => a.localeCompare(b)); + const sortedParticipants = participants.sort((a, b) => a.name.localeCompare(b.name)); const lobbyHexId = rs.idToHex(room.lobby_id); const privacy = getLobbyPrivacyInfo(room); @@ -964,12 +1219,76 @@ const ChatRoomDetailView = () => { m('.detail-section', [ m('h3', 'Participants (' + participantCount + ')'), - participantNames.length > 0 + sortedParticipants.length > 0 ? m( '.participants-grid', - participantNames.map((name) => - m('.participant-card', m('.participant-name', name)) - ) + sortedParticipants.map((participant) => { + if (participant.key && ChatHubState.gxsDetails[participant.key] === undefined) { + ChatHubState.gxsDetails[participant.key] = null; + rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id: participant.key }, (data) => { + if (data && data.details) { + ChatHubState.gxsDetails[participant.key] = data.details; + m.redraw(); + } + }); + } + + const details = ChatHubState.gxsDetails[participant.key]; + const avatar = getSafeAvatar(details); + const firstLetter = (participant.name || '?').slice(0, 1).toUpperCase(); + const isOwn = participant.key === rs.idToHex(room.gxs_id || ''); + const actionsOpen = activeParticipantId === participant.key; + + return m('.participant-card' + (!isOwn ? '.has-actions' : '') + (actionsOpen ? '.actions-open' : ''), { + role: !isOwn ? 'button' : undefined, + tabindex: !isOwn ? 0 : undefined, + 'aria-expanded': !isOwn ? String(actionsOpen) : undefined, + onclick: !isOwn ? () => { + activeParticipantId = actionsOpen ? null : participant.key; + } : undefined, + onkeydown: !isOwn ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + activeParticipantId = actionsOpen ? null : participant.key; + } + } : undefined, + }, [ + m(peopleUtil.UserAvatar, { + avatar, + firstLetter, + identityId: participant.key, + size: 32, + }), + m('.participant-name', participant.name), + !isOwn && m('i.fas.fa-chevron-down.participant-more', { 'aria-hidden': 'true' }), + !isOwn && actionsOpen && m('.participant-actions', [ + m('button.participant-action', { + type: 'button', + title: `Start a private chat with ${participant.name}`, + onclick: (e) => { + e.stopPropagation(); + people.setSelectedId(participant.key, 'chat'); + }, + }, [m('i.fas.fa-comments'), m('span', 'Chat')]), + m('button.participant-action', { + type: 'button', + title: `Send mail to ${participant.name}`, + onclick: (e) => { + e.stopPropagation(); + people.setSelectedId(participant.key, 'details', true); + }, + }, [m('i.fas.fa-envelope'), m('span', 'Mail')]), + m('button.participant-action', { + type: 'button', + title: `View details for ${participant.name}`, + onclick: (e) => { + e.stopPropagation(); + people.setSelectedId(participant.key, 'details'); + }, + }, [m('i.fas.fa-user'), m('span', 'Details')]), + ]), + ]); + }) ) : m('p.no-participants', 'No participant information available'), ]), @@ -980,13 +1299,24 @@ const ChatRoomDetailView = () => { const ChatRoomJoinView = () => { let ownIds = []; + let stopWatching; return { - oninit: () => peopleUtil.ownIds((data) => (ownIds = data)), + oninit: () => { + stopWatching = peopleUtil.watchOwnIds((data) => { + ownIds = data; + m.redraw(); + }); + }, + onremove: () => stopWatching && stopWatching(), view: () => { const room = ChatHubState.selectedRoom; if (!room) return null; const lobbyHexId = rs.idToHex(room.lobby_id); + const isInvitation = ChatRoomsModel.invitationIds.has(lobbyHexId); + // ChatLobbyInvite has no total_number_of_peers -- the field only exists + // on the records the nearby-lobby list returns -- so an invited room + // would always claim it has nobody in it. const participantCount = room.total_number_of_peers || 0; const privacy = getLobbyPrivacyInfo(room); @@ -1003,27 +1333,53 @@ const ChatRoomJoinView = () => { m('.info-label', 'Security'), m('.info-value', privacy.security), m('.info-label', 'Participants'), - m('.info-value', participantCount + ' users'), + m('.info-value', isInvitation ? 'Unknown until you join' : participantCount + ' users'), ]), ]), m('.detail-section', [ - m('h3', 'Join Room'), + m('h3', isInvitation ? 'Invitation' : 'Join Room'), m('p.join-description', 'Select an identity to join this chat room:'), + ChatRoomsModel.joiningLobbyId === lobbyHexId && + m('p.join-description', [m('i.fas.fa-spinner.fa-spin'), ' Joining…']), + ChatRoomsModel.joinError && m('p.error', ChatRoomsModel.joinError), m( '.identities-grid', ownIds.map((nick) => m( '.identity-card', - { onclick: () => ChatLobbyModel.enterPublicLobby(lobbyHexId, nick) }, + { + class: ChatRoomsModel.joiningLobbyId === lobbyHexId ? 'disabled' : '', + onclick: () => ChatRoomsModel.invitationIds.has(lobbyHexId) + ? ChatRoomsModel.acceptInvitation(lobbyHexId, nick) + : ChatLobbyModel.enterPublicLobby(lobbyHexId, nick), + }, [ - m('.identity-name', rs.userList.username(nick) || nick), + m('.identity-card__identity', [ + m(peopleUtil.IdentityAvatar, { + identityId: nick, + name: rs.userList.username(nick) || nick, + size: 36, + }), + m('.identity-name', rs.userList.username(nick) || nick), + ]), m('i.fas.fa-sign-in-alt'), ] ) ) ), + // Without this an invitation can only be accepted: it stays in the + // room list and keeps the Chat badge lit, since invitationCount() + // feeds it and nothing else ever clears the entry. + isInvitation && m( + 'button.chat-invite-decline', + { + disabled: ChatRoomsModel.joiningLobbyId === lobbyHexId, + onclick: () => ChatRoomsModel.declineInvitation(lobbyHexId), + }, + [m('i.fas.fa-times'), ' Decline invitation'] + ), ]), ]); }, @@ -1046,6 +1402,7 @@ const Layout = { oninit: () => { ChatHubState.activeTab = 'chat'; const lobbyId = m.route.param('lobby'); + ChatHubState.mobilePane = lobbyId ? 'detail' : 'list'; if (lobbyId) { ChatHubState.selectedRoomId = lobbyId; ChatLobbyModel.loadLobby(lobbyId); @@ -1073,12 +1430,20 @@ const Layout = { onupdate: () => { const lobbyId = m.route.param('lobby'); if (lobbyId && ChatHubState.selectedRoomId !== lobbyId) { + ChatHubState.mobilePane = 'detail'; ChatHubState.selectedRoomId = lobbyId; + // Another room, another conversation: it opens on its last message, + // whatever the reader had scrolled to in the previous one. The pane + // itself is reused rather than recreated, so its oncreate does not run. + chatStickToBottom = true; ChatLobbyModel.loadLobby(lobbyId); + } else if (!lobbyId) { + ChatHubState.mobilePane = 'list'; } }, onremove: () => { ChatLobbyModel.stopStatusPolling(); + ChatLobbyModel.stopParticipantPolling(); window.removeEventListener('click', Layout.dismissMenu); }, view: () => { @@ -1092,6 +1457,13 @@ const Layout = { .filter((info) => !ChatRoomsModel.subscribed(info)) .filter((info) => (info.lobby_name || '').toLowerCase().includes(search)); + const invitedRooms = publicRooms.filter((info) => + ChatRoomsModel.invitationIds.has(rs.idToHex(info.lobby_id)) + ); + const discoverableRooms = publicRooms.filter((info) => + !ChatRoomsModel.invitationIds.has(rs.idToHex(info.lobby_id)) + ); + const isSelected = (info, type) => ChatHubState.selectedRoomId === rs.idToHex(info.lobby_id); @@ -1128,7 +1500,7 @@ const Layout = { ChatHubState.selectedRoomType = null; } - return m('.chat-hub-container', [ + return m('.chat-hub-container' + (ChatHubState.mobilePane === 'detail' ? '.mobile-detail-open' : ''), [ m('.chat-hub-left-pane', [ m('.chat-own-profile-card', [ m('.profile-header', [ @@ -1137,13 +1509,15 @@ const Layout = { m('.profile-name', 'Chat rooms'), ]), ]), - m('button.chat-create-lobby-btn', { + m('button.chat-create-room-btn', { + title: 'Create room', + 'aria-label': 'Create room', onclick: () => { ChatHubState.showCreateRoomModal = true; } }, [ m('i.fas.fa-plus'), - ' Create' + m('span.btn-text', 'Create') ]) ]), @@ -1166,26 +1540,13 @@ const Layout = { ]), subscribedRooms.map((info) => { const hexId = rs.idToHex(info.lobby_id); - let count = 0; - let hasOwn = false; - if (info.gxs_ids) { - if (Array.isArray(info.gxs_ids)) { - count = info.gxs_ids.length; - hasOwn = info.gxs_ids.some((u) => u.key === info.gxs_id); - } else if (typeof info.gxs_ids === 'object') { - count = Object.keys(info.gxs_ids).length; - hasOwn = info.gxs_ids[info.gxs_id] !== undefined; - } - } - if (!hasOwn && info.gxs_id && info.gxs_id !== '00000000000000000000000000000000') { - count++; - } return m( '.chat-room-list-item' + (isSelected(info, 'subscribed') ? '.selected' : ''), { key: hexId, onclick: () => { + ChatHubState.mobilePane = 'detail'; m.route.set('/chat/:lobby', { lobby: hexId }); }, }, @@ -1195,26 +1556,57 @@ const Layout = { m('.room-name', info.lobby_name || ''), m('.room-topic', info.lobby_topic || 'No topic'), ]), - count > 0 && m('.room-badge', count), + (ChatRoomsModel.unreadCount[hexId] || 0) > 0 + && m('.room-badge', ChatRoomsModel.unreadCount[hexId]), ] ); }), ], - publicRooms.length > 0 && [ + invitedRooms.length > 0 && [ + m('.rooms-section-title.invited-rooms-title', [ + m('i.fas.fa-envelope'), + m('span', 'Invitations (' + invitedRooms.length + ')'), + ]), + invitedRooms.map((info) => { + const hexId = rs.idToHex(info.lobby_id); + return m( + '.chat-room-list-item.public-room.invited-room' + + (isSelected(info, 'public') ? '.selected' : ''), + { + key: hexId, + onclick: () => { + ChatHubState.mobilePane = 'detail'; + m.route.set('/chat/:lobby', { lobby: hexId }); + }, + }, + [ + m('.room-icon', m('i.fas.fa-envelope-open-text')), + m('.room-meta', [ + m('.room-name', info.lobby_name || ''), + m('.room-topic', info.lobby_topic || 'You were invited to join'), + ]), + m('.room-badge', { title: 'Chat room invitation' }, '!'), + ] + ); + }), + ], + + discoverableRooms.length > 0 && [ m('.rooms-section-title', [ m('i.fas.fa-globe'), - m('span', 'Public (' + publicRooms.length + ')'), + m('span', 'Public (' + discoverableRooms.length + ')'), ]), - publicRooms.map((info) => { + discoverableRooms.map((info) => { const hexId = rs.idToHex(info.lobby_id); - const count = info.total_number_of_peers || 0; + const participantCount = info.total_number_of_peers || 0; return m( '.chat-room-list-item.public-room' + (isSelected(info, 'public') ? '.selected' : ''), { key: hexId, onclick: () => { + ChatHubState.mobilePane = 'detail'; m.route.set('/chat/:lobby', { lobby: hexId }); }, }, @@ -1224,196 +1616,209 @@ const Layout = { m('.room-name', info.lobby_name || ''), m('.room-topic', info.lobby_topic || 'No topic'), ]), - count > 0 && m('.room-badge', count), + participantCount > 0 && m('.room-badge', { + title: `${participantCount} participant${participantCount === 1 ? '' : 's'}`, + }, participantCount), ] ); }), ], subscribedRooms.length === 0 && - publicRooms.length === 0 && + invitedRooms.length === 0 && + discoverableRooms.length === 0 && m('p.no-rooms', 'No chat rooms found'), ]), ]), - ChatHubState.showCreateRoomModal && m('.attach-modal-overlay', [ - m('.attach-modal', [ - m('h4', 'Create New Chat Room'), + ]), + ChatHubState.showCreateRoomModal && m('.attach-modal-overlay', [ + m('.attach-modal', [ + m('h4', 'Create New Chat Room'), - m('.form-field', { style: 'display: flex; flex-direction: column; gap: 0.25rem;' }, [ - m('label', { style: 'font-weight: bold; font-size: 0.9rem; color: #475569;' }, 'Room Name:'), - m('input[type=text]', { - value: ChatHubState.newRoomName, - oninput: (e) => { ChatHubState.newRoomName = e.target.value; }, - placeholder: 'Enter room name', - style: 'padding: 0.5rem; border: 1px solid #cbd5e1; border-radius: 0.25rem; font-size: 0.9rem;' + m('.form-field', { style: 'display: flex; flex-direction: column; gap: 0.25rem;' }, [ + m('label', { style: 'font-weight: bold; font-size: 0.9rem; color: #475569;' }, 'Room Name:'), + m('input[type=text]', { + value: ChatHubState.newRoomName, + oninput: (e) => { ChatHubState.newRoomName = e.target.value; }, + placeholder: 'Enter room name', + style: 'padding: 0.5rem; border: 1px solid #cbd5e1; border-radius: 0.25rem; font-size: 0.9rem;' + }) + ]), + + m('.form-field', { style: 'display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.5rem;' }, [ + m('label', { style: 'font-weight: bold; font-size: 0.9rem; color: #475569;' }, 'Topic:'), + m('input[type=text]', { + value: ChatHubState.newRoomTopic, + oninput: (e) => { ChatHubState.newRoomTopic = e.target.value; }, + placeholder: 'Enter room topic', + style: 'padding: 0.5rem; border: 1px solid #cbd5e1; border-radius: 0.25rem; font-size: 0.9rem;' + }) + ]), + + m('.form-field', { style: 'display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.5rem;' }, [ + m('label', { style: 'font-weight: bold; font-size: 0.9rem; color: #475569;' }, 'Admin Identity:'), + m('select', { + value: ChatHubState.newRoomIdentity, + onchange: (e) => { ChatHubState.newRoomIdentity = e.target.value; }, + style: 'padding: 0.5rem; border: 1px solid #cbd5e1; border-radius: 0.25rem; font-size: 0.9rem; background-color: #ffffff;' + }, [ + ChatHubState.ownGxsIdentities && ChatHubState.ownGxsIdentities.map((id) => { + const details = ChatHubState.gxsDetails[id]; + const name = details ? (details.mNickname || details.mGroupName) : id; + return m('option', { value: id }, name); }) - ]), - - m('.form-field', { style: 'display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.5rem;' }, [ - m('label', { style: 'font-weight: bold; font-size: 0.9rem; color: #475569;' }, 'Topic:'), - m('input[type=text]', { - value: ChatHubState.newRoomTopic, - oninput: (e) => { ChatHubState.newRoomTopic = e.target.value; }, - placeholder: 'Enter room topic', - style: 'padding: 0.5rem; border: 1px solid #cbd5e1; border-radius: 0.25rem; font-size: 0.9rem;' - }) - ]), - - m('.form-field', { style: 'display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.5rem;' }, [ - m('label', { style: 'font-weight: bold; font-size: 0.9rem; color: #475569;' }, 'Admin Identity:'), - m('select', { - value: ChatHubState.newRoomIdentity, - onchange: (e) => { ChatHubState.newRoomIdentity = e.target.value; }, - style: 'padding: 0.5rem; border: 1px solid #cbd5e1; border-radius: 0.25rem; font-size: 0.9rem; background-color: #ffffff;' - }, [ - ChatHubState.ownGxsIdentities && ChatHubState.ownGxsIdentities.map((id) => { - const details = ChatHubState.gxsDetails[id]; - const name = details ? (details.mNickname || details.mGroupName) : id; - return m('option', { value: id }, name); - }) - ]) - ]), - - m('.form-field', { style: 'display: flex; gap: 0.5rem; align-items: center; margin-top: 0.75rem;' }, [ - m('label', { style: 'display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; color: #475569; cursor: pointer; user-select: none;' }, [ - m('input[type=checkbox]', { - checked: ChatHubState.newRoomPublic, - onclick: (e) => { ChatHubState.newRoomPublic = e.target.checked; } - }), - 'Public Room' - ]) - ]), - - m('.form-field', { style: 'display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem;' }, [ - m('label', { style: 'display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; color: #475569; cursor: pointer; user-select: none;' }, [ - m('input[type=checkbox]', { - checked: ChatHubState.newRoomSigned, - onclick: (e) => { ChatHubState.newRoomSigned = e.target.checked; } - }), - 'PGP signed identities' - ]) - ]), - - ChatHubState.createRoomError && m('p.error-text', { style: 'color: #ef4444; font-size: 0.85rem; margin: 0.5rem 0 0 0;' }, ChatHubState.createRoomError), - - m('.modal-buttons', { style: 'display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1rem;' }, [ - m('button', { - disabled: !ChatHubState.newRoomName.trim() || !ChatHubState.newRoomIdentity, - onclick: () => { - const name = ChatHubState.newRoomName.trim(); - const topic = ChatHubState.newRoomTopic.trim(); - const identity = ChatHubState.newRoomIdentity; - const isPublic = ChatHubState.newRoomPublic; - const isSigned = ChatHubState.newRoomSigned; - let flags = 0; - if (isPublic) flags |= 4; - if (isSigned) flags |= 8; - - rs.rsJsonApiRequest('/rsChats/createChatLobby', { - lobby_name: name, - lobby_identity: identity, - lobby_topic: topic, - invited_friends: [], - lobby_privacy_type: flags - }, (data, success) => { - if (success) { - ChatHubState.showCreateRoomModal = false; - ChatHubState.newRoomName = ''; - ChatHubState.newRoomTopic = ''; - ChatHubState.newRoomSigned = false; - ChatHubState.createRoomError = ''; - ChatRoomsModel.loadSubscribedRooms(); - m.redraw(); - } else { - ChatHubState.createRoomError = 'Failed to create room. Check parameters.'; - m.redraw(); - } - }); - } - }, 'Create'), - m('button.red', { - onclick: () => { - ChatHubState.showCreateRoomModal = false; - ChatHubState.newRoomName = ''; - ChatHubState.newRoomTopic = ''; - ChatHubState.newRoomSigned = false; - ChatHubState.createRoomError = ''; - } - }, 'Cancel') ]) + ]), + + m('.form-field', { style: 'display: flex; gap: 0.5rem; align-items: center; margin-top: 0.75rem;' }, [ + m('label', { style: 'display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; color: #475569; cursor: pointer; user-select: none;' }, [ + m('input[type=checkbox]', { + checked: ChatHubState.newRoomPublic, + onclick: (e) => { ChatHubState.newRoomPublic = e.target.checked; } + }), + 'Public Room' + ]) + ]), + + m('.form-field', { style: 'display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem;' }, [ + m('label', { style: 'display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; color: #475569; cursor: pointer; user-select: none;' }, [ + m('input[type=checkbox]', { + checked: ChatHubState.newRoomSigned, + onclick: (e) => { ChatHubState.newRoomSigned = e.target.checked; } + }), + 'PGP signed identities' + ]) + ]), + + ChatHubState.createRoomError && m('p.error-text', { style: 'color: #ef4444; font-size: 0.85rem; margin: 0.5rem 0 0 0;' }, ChatHubState.createRoomError), + + m('.modal-buttons', { style: 'display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1rem;' }, [ + m('button', { + disabled: !ChatHubState.newRoomName.trim() || !ChatHubState.newRoomIdentity, + onclick: () => { + const name = ChatHubState.newRoomName.trim(); + const topic = ChatHubState.newRoomTopic.trim(); + const identity = ChatHubState.newRoomIdentity; + const isPublic = ChatHubState.newRoomPublic; + const isSigned = ChatHubState.newRoomSigned; + let flags = 0; + if (isPublic) flags |= 4; + if (isSigned) flags |= 8; + + rs.rsJsonApiRequest('/rsChats/createChatLobby', { + lobby_name: name, + lobby_identity: identity, + lobby_topic: topic, + invited_friends: [], + lobby_privacy_type: flags + }, (data, success) => { + if (success) { + ChatHubState.showCreateRoomModal = false; + ChatHubState.newRoomName = ''; + ChatHubState.newRoomTopic = ''; + ChatHubState.newRoomSigned = false; + ChatHubState.createRoomError = ''; + ChatRoomsModel.loadSubscribedRooms(); + m.redraw(); + } else { + ChatHubState.createRoomError = 'Failed to create room. Check parameters.'; + m.redraw(); + } + }); + } + }, 'Create'), + m('button.red', { + onclick: () => { + ChatHubState.showCreateRoomModal = false; + ChatHubState.newRoomName = ''; + ChatHubState.newRoomTopic = ''; + ChatHubState.newRoomSigned = false; + ChatHubState.createRoomError = ''; + } + }, 'Cancel') ]) - ]), - ChatHubState.showInviteModal && m('.attach-modal-overlay', [ - m('.attach-modal', { style: 'max-width: 450px;' }, [ - m('h4', 'Invite Friends to ' + (ChatHubState.selectedRoom ? ChatHubState.selectedRoom.lobby_name : '')), - m('.friends-invite-list', { style: 'max-height: 250px; overflow-y: auto; margin-top: 1rem; border: 1px solid #e2e8f0; border-radius: 0.375rem; padding: 0.5rem;' }, [ - ChatHubState.friendsList.length === 0 - ? m('p', { style: 'text-align: center; color: #64748b; font-style: italic; margin: 1rem 0;' }, 'No friends available') - : ChatHubState.friendsList.map((friend) => { - const isChecked = ChatHubState.selectedFriendsToInvite.has(friend.id); - return m('.friend-invite-item', { - style: 'display: flex; align-items: center; justify-content: space-between; padding: 0.5rem; border-bottom: 1px solid #f1f5f9; cursor: pointer;', - onclick: () => { - if (isChecked) { - ChatHubState.selectedFriendsToInvite.delete(friend.id); - } else { + ]) + ]), + ChatHubState.showInviteModal && m('.attach-modal-overlay', [ + m('.attach-modal', { style: 'max-width: 450px;' }, [ + m('h4', 'Invite Friends to ' + (ChatHubState.selectedRoom ? ChatHubState.selectedRoom.lobby_name : '')), + m('.friends-invite-list', { style: 'max-height: 250px; overflow-y: auto; margin-top: 1rem; border: 1px solid #e2e8f0; border-radius: 0.375rem; padding: 0.5rem;' }, [ + ChatHubState.friendsList.length === 0 + ? m('p', { style: 'text-align: center; color: #64748b; font-style: italic; margin: 1rem 0;' }, 'No friends available') + : ChatHubState.friendsList.map((friend) => { + const isChecked = ChatHubState.selectedFriendsToInvite.has(friend.id); + return m('.friend-invite-item', { + style: 'display: flex; align-items: center; justify-content: space-between; padding: 0.5rem; border-bottom: 1px solid #f1f5f9; cursor: pointer;', + onclick: () => { + if (isChecked) { + ChatHubState.selectedFriendsToInvite.delete(friend.id); + } else { + ChatHubState.selectedFriendsToInvite.add(friend.id); + } + } + }, [ + m('div', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [ + m('.status-bullet', { style: { backgroundColor: friend.online ? '#22c55e' : '#94a3b8', width: '8px', height: '8px', borderRadius: '50%', display: 'inline-block' } }), + m('span', { style: 'font-weight: 500;' }, friend.name) + ]), + m('input[type=checkbox]', { + checked: isChecked, + onclick: (e) => { + e.stopPropagation(); + if (e.target.checked) { ChatHubState.selectedFriendsToInvite.add(friend.id); + } else { + ChatHubState.selectedFriendsToInvite.delete(friend.id); } } - }, [ - m('div', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [ - m('.status-bullet', { style: { backgroundColor: friend.online ? '#22c55e' : '#94a3b8', width: '8px', height: '8px', borderRadius: '50%', display: 'inline-block' } }), - m('span', { style: 'font-weight: 500;' }, friend.name) - ]), - m('input[type=checkbox]', { - checked: isChecked, - onclick: (e) => { - e.stopPropagation(); - if (e.target.checked) { - ChatHubState.selectedFriendsToInvite.add(friend.id); - } else { - ChatHubState.selectedFriendsToInvite.delete(friend.id); - } - } - }) - ]); - }) - ]), - m('.modal-buttons', { style: 'display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.5rem;' }, [ - m('button', { - disabled: ChatHubState.selectedFriendsToInvite.size === 0, - onclick: () => { - const lobbyHexId = rs.idToHex(ChatHubState.selectedRoom.lobby_id); - const invitePromises = []; - ChatHubState.selectedFriendsToInvite.forEach((friendId) => { - invitePromises.push( - new Promise((resolve) => { - rs.rsJsonApiRequest('/rsChats/invitePeerToLobby', { - lobby_id: lobbyHexId, - peer_id: friendId - }, () => resolve()); - }) - ); - }); - Promise.all(invitePromises).then(() => { - ChatHubState.showInviteModal = false; - ChatHubState.selectedFriendsToInvite.clear(); - m.redraw(); - }); - } - }, 'Invite'), - m('button.red', { - onclick: () => { + }) + ]); + }) + ]), + m('.modal-buttons', { style: 'display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1rem;' }, [ + m('button.blue', { + disabled: ChatHubState.selectedFriendsToInvite.size === 0, + onclick: () => { + const lobbyHexId = rs.idToHex(ChatHubState.selectedRoom.lobby_id); + const invitePromises = []; + ChatHubState.selectedFriendsToInvite.forEach((friendId) => { + invitePromises.push( + new Promise((resolve) => { + rs.rsJsonApiRequest('/rsChats/invitePeerToLobby', { + lobby_id: lobbyHexId, + peer_id: friendId + }, () => resolve()); + }) + ); + }); + Promise.all(invitePromises).then(() => { ChatHubState.showInviteModal = false; ChatHubState.selectedFriendsToInvite.clear(); - } - }, 'Cancel') - ]) + m.redraw(); + }); + } + }, 'Invite'), + m('button.red', { + onclick: () => { + ChatHubState.showInviteModal = false; + ChatHubState.selectedFriendsToInvite.clear(); + } + }, 'Cancel') ]) - ]), + ]) ]), m('.chat-hub-right-pane', [ + m('.mobile-pane-header', [ + m('button.mobile-back-button', { + type: 'button', + onclick: () => { + ChatHubState.mobilePane = 'list'; + m.route.set('/chat'); + }, + }, [m('i.fas.fa-chevron-left'), ' Chats']), + m('strong', ChatHubState.selectedRoom ? (ChatHubState.selectedRoom.lobby_name || 'Conversation') : 'Conversation'), + ]), ChatHubState.selectedRoom ? [ ChatHubState.selectedRoomType === 'subscribed' @@ -1444,14 +1849,14 @@ const Layout = { ), ]), ]), - m('.chat-hub-tab-content', { style: { padding: ChatHubState.activeTab === 'chat' ? '0' : '1.5rem' } }, [ + m('.chat-hub-tab-content' + (ChatHubState.activeTab === 'details' ? '.details-content' : ''), { style: { padding: ChatHubState.activeTab === 'chat' ? '0' : '1.5rem' } }, [ ChatHubState.activeTab === 'chat' ? m(ChatConversationView) : m(ChatRoomDetailView), ]), ] : [ - m('.chat-hub-tab-content', m(ChatRoomJoinView)), + m('.chat-hub-tab-content.details-content', m(ChatRoomJoinView)), ], ] : m('.chat-pane-placeholder', [ @@ -1463,7 +1868,10 @@ const Layout = { ]), ]), ChatHubState.messageContextMenu.show && m('.chat-msg-context-menu', { - style: `position: fixed; top: ${ChatHubState.messageContextMenu.y}px; left: ${ChatHubState.messageContextMenu.x}px; background: #ffffff; border: 1px solid #cbd5e1; border-radius: 0.5rem; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05); padding: 0.35rem 0; z-index: 3000; min-width: 160px;`, + style: { + top: `${Math.max(8, Math.min(ChatHubState.messageContextMenu.y, window.innerHeight - 132))}px`, + left: `${Math.max(8, Math.min(ChatHubState.messageContextMenu.x, window.innerWidth - 228))}px`, + }, onclick: (e) => e.stopPropagation(), }, [ m('.context-menu-item', { @@ -1533,8 +1941,15 @@ const Layout = { */ const LayoutCreateDistant = () => { let ownIds = []; + let stopWatching; return { - oninit: () => peopleUtil.ownIds((data) => (ownIds = data)), + oninit: () => { + stopWatching = peopleUtil.watchOwnIds((data) => { + ownIds = data; + m.redraw(); + }); + }, + onremove: () => stopWatching && stopWatching(), view: (vnode) => m('.node-panel.chat-panel.chat-room', [ m('.createDistantChat', [ diff --git a/webui-src/app/chat/chat_emoji.js b/webui-src/app/chat/chat_emoji.js index bfff2bb..28a3a61 100644 --- a/webui-src/app/chat/chat_emoji.js +++ b/webui-src/app/chat/chat_emoji.js @@ -92,6 +92,7 @@ function insertEmojiIntoTextarea(emoji, onSelect) { textarea.selectionStart = newPos; textarea.selectionEnd = newPos; textarea.focus(); + textarea.dispatchEvent(new Event('input', { bubbles: true })); } const EmojiPicker = () => ({ diff --git a/webui-src/app/chat/chat_preview.js b/webui-src/app/chat/chat_preview.js new file mode 100644 index 0000000..4613063 --- /dev/null +++ b/webui-src/app/chat/chat_preview.js @@ -0,0 +1,33 @@ +function chatPreviewText(rawText) { + if (!rawText) return ''; + + const source = String(rawText); + if (!/[<&]/.test(source)) return source.trim(); + + const hasImage = / { + const decoder = document.createElement('textarea'); + decoder.innerHTML = text; + return decoder.value; + }; + const stripMarkup = (text) => text + .replace(/<(style|script|head)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ') + .replace(//gi, ' ') + .replace(/<\/p\s*>|<\/div\s*>/gi, ' ') + .replace(/<[^>]+>/g, ' '); + + // Some peers send literal HTML while others send the same payload with its + // tags entity-encoded. Decode and strip a second time for the latter form. + let text = decodeEntities(stripMarkup(source)); + if (/<[^>]+>/.test(text)) text = decodeEntities(stripMarkup(text)); + text = text + .replace(/\u00a0/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + if (text) return text; + if (hasImage) return 'Photo'; + return 'Message'; +} + +module.exports = chatPreviewText; diff --git a/webui-src/app/chat/chat_state.js b/webui-src/app/chat/chat_state.js index 2760a63..ffce59d 100644 --- a/webui-src/app/chat/chat_state.js +++ b/webui-src/app/chat/chat_state.js @@ -89,9 +89,165 @@ function getStatusTooltip(status) { } } +// Chat messages travel as HTML. Stripping the tags is not enough: the entities +// they leave behind are still raw text and end up displayed verbatim, the most +// visible one being the   that Qt emits for leading and repeated spaces. +// A textarea decodes them without ever parsing markup, since its content model +// is plain text and nothing in the string can become an element. +function decodeHtmlEntities(text) { + const el = document.createElement('textarea'); + el.innerHTML = text; + return el.value; +} + +// Turn the HTML payload of a chat message into the text we display. +function htmlToText(text) { + return decodeHtmlEntities( + text + .replaceAll('
', '\n') + .replaceAll('
', '\n') + .replace(new RegExp('|<[^>]*>', 'gm'), '') + ); +} + +// data: covers what the web UI itself sends (a compressed JPEG data URI) and +// what any other client embeds the same way. Everything else -- http, https, +// file, anything exotic -- is a fetch to a third party. +function isEmbeddedImageSrc(src) { + return /^data:image\//i.test(String(src).trim()); +} + +// Keep chat pictures inside the current page. A blank window opened here can +// strand embedded browsers such as Android WebView without a usable Back entry. +let chatImageViewer = null; +let chatImageViewerPreviousOverflow = ''; +let chatImageViewerOpener = null; +let chatImageViewerKeyHandler = null; +const CHAT_IMAGE_VIEWER_HISTORY_KEY = 'chatImageViewer'; + +function removeChatImageViewer() { + if (!chatImageViewer) return; + if (chatImageViewerKeyHandler) { + document.removeEventListener('keydown', chatImageViewerKeyHandler, true); + chatImageViewerKeyHandler = null; + } + chatImageViewer.remove(); + chatImageViewer = null; + document.body.style.overflow = chatImageViewerPreviousOverflow; + // Put the focus back where it was taken from, so closing the preview does + // not leave the caret on with the message list scrolled away. + if (chatImageViewerOpener && document.contains(chatImageViewerOpener)) { + chatImageViewerOpener.focus(); + } + chatImageViewerOpener = null; +} + +function closeChatImageViewer() { + if (history.state && history.state[CHAT_IMAGE_VIEWER_HISTORY_KEY]) { + history.back(); + } else { + removeChatImageViewer(); + } +} + +window.addEventListener('popstate', () => removeChatImageViewer()); + +function openChatImageViewer(src) { + removeChatImageViewer(); + + const overlay = document.createElement('div'); + overlay.className = 'chat-image-viewer'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Image preview'); + + const image = document.createElement('img'); + image.className = 'chat-image-viewer__image'; + image.src = src; + image.alt = 'Chat image'; + + const closeButton = document.createElement('button'); + closeButton.className = 'chat-image-viewer__close'; + closeButton.type = 'button'; + closeButton.setAttribute('aria-label', 'Close image preview'); + closeButton.innerHTML = '×'; + closeButton.onclick = (event) => { + event.stopPropagation(); + closeChatImageViewer(); + }; + + overlay.append(image, closeButton); + overlay.onclick = (event) => { + if (event.target === overlay) closeChatImageViewer(); + }; + + // The overlay says role=dialog and aria-modal=true, so it has to behave like + // one. Listening on the overlay only caught what bubbled through it: tapping + // the picture moves the focus to and Escape went dead from then on. + // Listening on the document, in the capture phase, means Escape closes the + // preview wherever the focus has drifted, and Tab cannot walk out of it into + // the page underneath -- the close button is the only thing to land on. + chatImageViewerKeyHandler = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + closeChatImageViewer(); + } else if (event.key === 'Tab') { + event.preventDefault(); + closeButton.focus(); + } + }; + document.addEventListener('keydown', chatImageViewerKeyHandler, true); + + chatImageViewerPreviousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + // Captured before the overlay steals the focus, and restored on close. + chatImageViewerOpener = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + document.body.appendChild(overlay); + chatImageViewer = overlay; + history.pushState({ ...(history.state || {}), [CHAT_IMAGE_VIEWER_HISTORY_KEY]: true }, ''); + closeButton.focus(); +} + function renderChatMessage(rawText) { if (!rawText) return ''; + // Preserve file attachments before HTML-to-text conversion drops their href. + // Parse inside an inert template and rebuild the link, never render peer HTML. + const anchorRegex = /]*>[\s\S]*?<\/a\s*>/gi; + const fileParts = []; + let fileEnd = 0; + let anchor; + while ((anchor = anchorRegex.exec(rawText)) !== null) { + const template = document.createElement('template'); + template.innerHTML = anchor[0]; + const link = template.content.querySelector('a'); + const href = link && link.getAttribute('href'); + if (!href || !/^retroshare:\/\/file\?/i.test(href)) continue; + let url; + try { url = new URL(href); } catch (_) { continue; } + const name = url.searchParams.get('name'); + const size = url.searchParams.get('size'); + const hash = url.searchParams.get('hash'); + if (!name || !/^\d+$/.test(size || '') || !/^[a-f0-9]{40}$/i.test(hash || '')) continue; + const safeHref = `retroshare://file?name=${encodeURIComponent(name)}&size=${size}&hash=${hash}`; + if (anchor.index > fileEnd) fileParts.push(renderChatMessage(rawText.slice(fileEnd, anchor.index))); + fileParts.push(m('a.chat-file-link', { + href: safeHref, + title: `Download ${name}`, + onclick: (event) => { + event.preventDefault(); + require('files/files_downloads').addFile(safeHref); + }, + }, link.textContent || name)); + fileEnd = anchorRegex.lastIndex; + } + if (fileParts.length) { + if (fileEnd < rawText.length) fileParts.push(renderChatMessage(rawText.slice(fileEnd))); + return fileParts; + } + // 1. Check for HTML tags const imgRegex = /]*src=["']([^"']+)["'][^>]*>/gi; if (imgRegex.test(rawText)) { @@ -103,17 +259,21 @@ function renderChatMessage(rawText) { while ((match = imgRegex.exec(rawText)) !== null) { if (match.index > lastIndex) { const precedingText = rawText.substring(lastIndex, match.index); - const cleanText = precedingText - .replaceAll('
', '\n') - .replaceAll('
', '\n') - .replace(new RegExp('|<[^>]*>', 'gm'), ''); + const cleanText = htmlToText(precedingText); if (cleanText) { parts.push(renderTextWithEmoji(cleanText)); } } const src = match[1]; - if (src) { + // A message is written by whoever is at the other end of the tunnel, and + // an pointing at a host of their choosing makes this browser fetch + // it: the reader's address handed over, and a read receipt with it, on a + // conversation whose whole point is that neither is knowable. Embedded + // pictures travel as data: URIs; anything else is shown as the text it is. + if (src && !isEmbeddedImageSrc(src)) { + parts.push(renderTextWithEmoji(`[remote image not loaded: ${src}]`)); + } else if (src) { parts.push( m('img.chat-embedded-image', { src, @@ -127,12 +287,7 @@ function renderChatMessage(rawText) { cursor: 'pointer', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', }, - onclick: () => { - const w = window.open(''); - if (w) { - w.document.write(``); - } - } + onclick: () => openChatImageViewer(src), }) ); } @@ -142,10 +297,7 @@ function renderChatMessage(rawText) { if (lastIndex < rawText.length) { const trailingText = rawText.substring(lastIndex); - const cleanText = trailingText - .replaceAll('
', '\n') - .replaceAll('
', '\n') - .replace(new RegExp('|<[^>]*>', 'gm'), ''); + const cleanText = htmlToText(trailingText); if (cleanText) { parts.push(renderFormattedMessageText(cleanText)); } @@ -155,7 +307,7 @@ function renderChatMessage(rawText) { } // 2. Check for raw data:image/... base64 URLs - if (rawText.trim().startsWith('data:image/')) { + if (isEmbeddedImageSrc(rawText)) { const src = rawText.trim(); return m('img.chat-embedded-image', { src, @@ -169,22 +321,16 @@ function renderChatMessage(rawText) { cursor: 'pointer', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', }, - onclick: () => { - const w = window.open(''); - if (w) { - w.document.write(``); - } - } + onclick: () => openChatImageViewer(src), }); } // 3. Normal text message - const cleanText = rawText - .replace(/]*>/gi, '\n> ') - .replace(/<\/blockquote>/gi, '\n') - .replaceAll('
', '\n') - .replaceAll('
', '\n') - .replace(new RegExp('|<[^>]*>', 'gm'), ''); + const cleanText = htmlToText( + rawText + .replace(/]*>/gi, '\n> ') + .replace(/<\/blockquote>/gi, '\n') + ); return renderFormattedMessageText(cleanText); } @@ -292,6 +438,106 @@ const ChatRoomsModel = { allRooms: [], knownSubscrIds: [], subscribedRooms: {}, + unreadCount: {}, + invitationIds: new Set(), + joiningLobbyId: null, + joinError: '', + invitationCount() { + return this.invitationIds.size; + }, + loadPendingInvitations() { + rs.rsJsonApiRequest('/rsChats/getPendingChatLobbyInvites', {}, (data) => { + const invites = data && Array.isArray(data.invites) ? data.invites : []; + const previousInvitationIds = this.invitationIds; + this.invitationIds = new Set(invites.map((invite) => rs.idToHex(invite.lobby_id))); + const inviteIds = this.invitationIds; + const rooms = this.allRooms.filter((room) => { + const id = rs.idToHex(room.lobby_id); + return !previousInvitationIds.has(id) && !inviteIds.has(id); + }); + this.allRooms = sortLobbies([...rooms, ...invites]); + m.redraw(); + }); + }, + receiveAdministrativeEvent(event) { + // RsChatLobbyEventCode::CHAT_LOBBY_INVITE_RECEIVED + if (event && Number(event.mEventCode) === 4) this.loadPendingInvitations(); + }, + // An invitation that is neither accepted nor refused keeps the Chat badge + // lit for good: it is counted by invitationCount() and nothing else clears + // it. denyLobbyInvite() is what the core offers for that. + declineInvitation(lobbyId) { + return rs.rsJsonApiRequest( + '/rsChats/denyLobbyInvite', + { id: { xstr64: lobbyId } }, + (data, success) => { + if (!success) { + // No answer at all: the core is unreachable or the endpoint is not + // in this build. Nothing was decided, so nothing is dropped here. + this.joinError = 'No answer from RetroShare, the invitation was left alone.'; + m.redraw(); + return; + } + if (!data || !data.retval) { + // denyLobbyInvite() only returns false for one reason: the id is not + // in the core's invite queue (DistributedChatService, "lobby invite + // not in cache"). The queue lives in memory only, so a core restart + // empties it while this list still shows what it held before. + // + // Either way the invitation is gone as far as the core is concerned, + // and keeping it here would leave the Chat badge lit over something + // that can never be accepted nor refused. Drop it and re-read the + // queue, so the list ends up saying what the core says. + this.invitationIds.delete(lobbyId); + this.allRooms = this.allRooms.filter( + (room) => rs.idToHex(room.lobby_id) !== lobbyId + ); + if (ChatHubState.selectedRoomId === lobbyId) { + ChatHubState.selectedRoomId = null; + ChatHubState.mobilePane = 'list'; + } + this.joinError = 'RetroShare no longer had this invitation; it has been removed from the list.'; + this.loadPendingInvitations(); + m.redraw(); + return; + } + this.invitationIds.delete(lobbyId); + // The room came from the invitation, not from the nearby list, so it + // has to go with it -- otherwise it stays as a room with no + // participants that cannot be joined. + this.allRooms = this.allRooms.filter( + (room) => rs.idToHex(room.lobby_id) !== lobbyId + ); + if (ChatHubState.selectedRoomId === lobbyId) { + ChatHubState.selectedRoomId = null; + ChatHubState.mobilePane = 'list'; + } + this.joinError = ''; + m.redraw(); + } + ); + }, + acceptInvitation(lobbyId, identity) { + this.joiningLobbyId = lobbyId; + this.joinError = ''; + return rs.rsJsonApiRequest( + '/rsChats/acceptLobbyInvite', + { id: { xstr64: lobbyId }, identity }, + (data, success) => { + this.joiningLobbyId = null; + if (!success || !data || !data.retval) { + this.joinError = 'RetroShare rejected this identity. This room may require a signed identity.'; + m.redraw(); + return; + } + this.invitationIds.delete(lobbyId); + this.loadSubscribedRooms(); + ChatHubState.selectedRoomType = 'subscribed'; + ChatLobbyModel.loadLobby(lobbyId); + m.redraw(); + } + ); + }, loadPublicRooms() { rs.rsJsonApiRequest( '/rsChats/getListOfNearbyChatLobbies', @@ -305,14 +551,24 @@ const ChatRoomsModel = { seen.add(id); return true; }); - ChatRoomsModel.allRooms = sortLobbies(uniqueLobbies); + const inviteIds = ChatRoomsModel.invitationIds; + const pendingInvites = ChatRoomsModel.allRooms.filter((room) => + inviteIds.has(rs.idToHex(room.lobby_id)) + ); + ChatRoomsModel.allRooms = sortLobbies([ + ...uniqueLobbies.filter((room) => !inviteIds.has(rs.idToHex(room.lobby_id))), + ...pendingInvites, + ]); } else { - ChatRoomsModel.allRooms = []; + ChatRoomsModel.allRooms = ChatRoomsModel.allRooms.filter((room) => + ChatRoomsModel.invitationIds.has(rs.idToHex(room.lobby_id)) + ); } } ); }, loadSubscribedRooms(after = null) { + ChatRoomsModel.loadPendingInvitations(); rs.rsJsonApiRequest( '/rsChats/getChatLobbyList', {}, @@ -320,6 +576,7 @@ const ChatRoomsModel = { if (data && data.cl_list) { const ids = [...new Set(data.cl_list.map((lid) => rs.idToHex(lid)))]; ChatRoomsModel.knownSubscrIds = ids; + ids.forEach((id) => ChatRoomsModel.invitationIds.delete(id)); Object.keys(ChatRoomsModel.subscribedRooms).forEach((id) => { if (!ids.includes(id)) { @@ -334,6 +591,15 @@ const ChatRoomsModel = { return; } + // One getChatLobbyInfo per subscribed room, and each one is a whole + // round trip: the JSON API answers `Connection: close`, so nothing is + // pipelined and the browser only keeps six sockets open. Waiting for + // the last answer before painting anything means the list appears + // after N round trips -- invisible over loopback, seconds on a phone. + // Paint each room as it lands instead, and ask for the public ones + // right away rather than queueing them behind the whole batch. + ChatRoomsModel.loadPublicRooms(); + let count = 0; ids.forEach((id) => loadLobbyDetails(id, (info) => { @@ -341,12 +607,9 @@ const ChatRoomsModel = { ChatRoomsModel.subscribedRooms[id] = info; } count++; - if (count === ids.length) { - ChatRoomsModel.loadPublicRooms(); - if (after != null) { - after(); - } - m.redraw(); + m.redraw(); + if (count === ids.length && after != null) { + after(); } }) ); @@ -483,6 +746,76 @@ const ChatLobbyModel = { lastLobbyId: null, distantChatStatus: null, statusPollInterval: null, + participantPollInterval: null, + + updateParticipants(detail) { + if (!detail) return; + const byId = new Map(); + if (detail.gxs_ids) { + if (Array.isArray(detail.gxs_ids)) { + detail.gxs_ids.forEach((entry) => { + const key = entry && entry.key; + if (key) byId.set(key, { + key, + name: rs.userList.username(key) || key, + lastAct: get64Num(entry.value), + }); + }); + } else if (typeof detail.gxs_ids === 'object') { + Object.keys(detail.gxs_ids).forEach((key) => byId.set(key, { + key, + name: rs.userList.username(key) || key, + lastAct: get64Num(detail.gxs_ids[key]), + })); + } + } + + const ownId = detail.gxs_id; + if (ownId && ownId !== '00000000000000000000000000000000' && !byId.has(ownId)) { + byId.set(ownId, { + key: ownId, + name: rs.userList.username(ownId) || ownId, + lastAct: Math.floor(Date.now() / 1000), + }); + } + this.users = Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name)); + }, + + rememberLiveParticipant(chatMessage) { + const cid = chatMessage && chatMessage.chat_id; + if (!cid || cid.type !== 3 || rs.idToHex(cid.lobby_id) !== this.lastLobbyId) return; + const key = rs.idToHex(chatMessage.lobby_peer_gxs_id || chatMessage.peerId); + if (!key || /^0+$/.test(key)) return; + const existing = this.users.find((user) => user.key === key); + if (existing) { + existing.lastAct = chatMessage.sendTime || Math.floor(Date.now() / 1000); + } else { + this.users.push({ + key, + name: rs.userList.username(key) || chatMessage.peerName || key, + lastAct: chatMessage.sendTime || Math.floor(Date.now() / 1000), + }); + } + }, + + startParticipantPolling(lobbyId) { + this.stopParticipantPolling(); + const refresh = () => loadLobbyDetails(lobbyId, (detail) => { + if (!detail || this.lastLobbyId !== lobbyId) return; + this.currentLobby = { ...this.currentLobby, ...detail, chatType: 3 }; + this.updateParticipants(detail); + m.redraw(); + }); + refresh(); + this.participantPollInterval = setInterval(refresh, 5000); + }, + + stopParticipantPolling() { + if (this.participantPollInterval) { + clearInterval(this.participantPollInterval); + this.participantPollInterval = null; + } + }, pollDistantChatStatus() { if (!this.currentLobby || this.currentLobby.chatType !== 2) return; @@ -576,7 +909,12 @@ const ChatLobbyModel = { } }, - loadHistory(id, type) { + // How much of a conversation is on screen when it opens. Small on purpose: + // every room opening pays for it, and on a phone each request is a fresh + // connection on a core that answers one at a time. + HISTORY_PAGE: 20, + + historyChatPeerId(id, type) { const chatPeerId = { broadcast_status_peer_id: '00000000000000000000000000000000', type, @@ -588,20 +926,75 @@ const ChatLobbyModel = { if (type === 3) chatPeerId.lobby_id.xstr64 = id; else if (type === 2) chatPeerId.distant_chat_id = id; else if (type === 1) chatPeerId.peer_id = id; + return chatPeerId; + }, + + loadHistory(id, type) { + const requestToken = {}; + this.historyRequestToken = requestToken; + this.historyLoaded = this.HISTORY_PAGE; + this.historyExhausted = false; + this.historyLoading = false; rs.rsJsonApiRequest( '/rsHistory/getMessages', { - chatPeerId, - loadCount: 20, + chatPeerId: this.historyChatPeerId(id, type), + loadCount: this.HISTORY_PAGE, }, (data, success) => { + // A room switch or newer history load makes this response obsolete. + if (this.lastLobbyId !== id || this.currentLobby?.chatType !== type + || this.historyRequestToken !== requestToken) return; if (success && data.msgs) { + if (data.msgs.length < this.HISTORY_PAGE) this.historyExhausted = true; this.addMessages(data.msgs); } } ); }, + + // Reading further back. p3HistoryMgr::getMessages takes a count and nothing + // else -- no cursor, no "before this message" -- and always answers with the + // newest ones, so the only way to see older text is to ask for a bigger slice + // and let addMessages() drop what is already here. It re-sends what we hold, + // which is the price of that API; a page is small and the core keeps ten days + // at most anyway (mMaxStorageDurationSeconds). + loadOlderHistory(done) { + const detail = this.currentLobby; + if (!detail || this.historyLoading || this.historyExhausted) return false; + + const id = this.lastLobbyId; + if (!id) return false; + const type = detail.chatType; + const requestToken = this.historyRequestToken; + + this.historyLoading = true; + const wanted = (this.historyLoaded || this.HISTORY_PAGE) + this.HISTORY_PAGE * 2; + + rs.rsJsonApiRequest( + '/rsHistory/getMessages', + { + chatPeerId: this.historyChatPeerId(id, type), + loadCount: wanted, + }, + (data, success) => { + if (this.lastLobbyId !== id || this.currentLobby?.chatType !== type + || this.historyRequestToken !== requestToken) return; + this.historyLoading = false; + if (!success || !data.msgs) { + if (done) done(); + return; + } + // Fewer than asked for means the core has nothing older left. + if (data.msgs.length < wanted) this.historyExhausted = true; + this.historyLoaded = wanted; + this.addMessages(data.msgs); + if (done) done(); + } + ); + return true; + }, loadAllHistoryForRoom(lobbyId, callback) { ChatHubState.isHistoryLoading = true; ChatHubState.fullHistoryMessages = []; @@ -647,19 +1040,50 @@ const ChatLobbyModel = { ); }, enterPublicLobby(lobbyId, nick) { + ChatRoomsModel.joiningLobbyId = lobbyId; + ChatRoomsModel.joinError = ''; rs.rsJsonApiRequest( '/rsChats/joinVisibleChatLobby', { lobby_id: { xstr64: lobbyId }, own_id: nick, }, - () => { - loadLobbyDetails(lobbyId, (info) => { - ChatRoomsModel.subscribedRooms[lobbyId] = info; - ChatRoomsModel.loadSubscribedRooms(() => { - m.route.set('/chat/:lobby', { lobby: rs.idToHex(info.lobby_id) }); - }); - }); + (data, success) => { + ChatRoomsModel.joiningLobbyId = null; + if (!success || !data || !data.retval) { + const room = ChatHubState.selectedRoom || {}; + const flags = Number(room.lobby_flags || 0); + if ((flags & 0x10) !== 0) { + ChatRoomsModel.joinError = 'This room requires a signed identity. Select a PGP-linked identity.'; + } else if (!ChatRoomsModel.invitationIds.has(lobbyId) + && Number(room.total_number_of_peers || 0) === 0) { + ChatRoomsModel.joinError = 'This room is no longer being advertised by an online participant. Try again when someone in the room is online.'; + ChatRoomsModel.loadPublicRooms(); + } else { + ChatRoomsModel.joinError = 'RetroShare could not join this room. It may no longer be available; refresh the room list and try again.'; + } + m.redraw(); + return; + } + + // Keep the subscription in the RetroShare profile so the core joins + // this room again after a restart. Recent cores also enable this from + // joinVisibleChatLobby, but doing it explicitly preserves the expected + // behaviour with cores where joining only lasts for the current run. + rs.rsJsonApiRequest( + '/rsChats/setLobbyAutoSubscribe', + { + lobby_id: { xstr64: lobbyId }, + autoSubscribe: true, + }, + () => { }, + true + ); + + ChatRoomsModel.loadSubscribedRooms(); + ChatHubState.selectedRoomType = 'subscribed'; + ChatLobbyModel.loadLobby(lobbyId); + m.redraw(); }, true ); @@ -695,7 +1119,11 @@ const ChatLobbyModel = { }, loadLobby(currentlobbyid) { this.stopStatusPolling(); + this.stopParticipantPolling(); this.lastLobbyId = currentlobbyid; + ChatRoomsModel.unreadCount[currentlobbyid] = 0; + ChatHubState.showParticipants = false; + ChatHubState.attachedImage = null; const finishLoad = (detail) => { this.setupAction = this.setIdentity; @@ -713,76 +1141,50 @@ const ChatLobbyModel = { this.addMessages(l); }); - rs.events[15].notify = (chatMessage) => { - const msgCid = chatMessage.chat_id; - let msgId; - - if (msgCid.type === 3) { - msgId = rs.idToHex(msgCid.lobby_id); - } else if (msgCid.type === 2) { - msgId = rs.idToHex(msgCid.distant_chat_id); - } else if (msgCid.type === 1) { - msgId = rs.idToHex(msgCid.peer_id); - } else { - msgId = rs.idToHex(msgCid); - } - - if (msgId === currentlobbyid) { - this.addMessages([chatMessage]); - } - }; - - let list = []; - if (detail.gxs_ids) { - if (Array.isArray(detail.gxs_ids)) { - list = detail.gxs_ids.map((u) => { - const key = u.key; - return { key, name: rs.userList.username(key) || key, lastAct: get64Num(u.value) }; - }); - } else if (typeof detail.gxs_ids === 'object') { - list = Object.keys(detail.gxs_ids).map((key) => { - return { key, name: rs.userList.username(key) || key, lastAct: get64Num(detail.gxs_ids[key]) }; - }); - } - } - - const ownId = detail.gxs_id; - if (ownId && ownId !== '00000000000000000000000000000000') { - const hasOwn = list.some((u) => u.key === ownId); - if (!hasOwn) { - list.push({ - key: ownId, - name: rs.userList.username(ownId) || ownId, - lastAct: Math.floor(Date.now() / 1000) - }); - } - } - - if (list.length === 0) { - list = [{ key: ownId || '', name: rs.userList.username(ownId) || detail.lobby_name || '???', lastAct: Math.floor(Date.now() / 1000) }]; - } - - list.sort((a, b) => a.name.localeCompare(b.name)); - this.users = list; + this.updateParticipants(detail); if (detail.chatType === 2) { this.startStatusPolling(); + } else if (detail.chatType === 3) { + this.startParticipantPolling(currentlobbyid); } m.redraw(); }; - loadLobbyDetails(currentlobbyid, (detail) => { + const isDistantChatId = /^[0-9a-f]{32}$/i.test(String(currentlobbyid)); + const loadDetails = (attempt = 0) => loadLobbyDetails(currentlobbyid, (detail) => { if (detail) { finishLoad(detail); - } else { + return; + } + + // Public lobby IDs are uint64 decimal strings. Passing one to the + // distant-chat fallback makes the core construct a 128-bit tunnel ID + // from (for example) a 20-character decimal value and can terminate the + // JSON API listener. Only a real 32-hex-character tunnel ID may use it. + if (isDistantChatId) { loadDistantChatDetails(currentlobbyid, (dDetail) => { - if (dDetail) { - finishLoad(dDetail); - } + if (dDetail) finishLoad(dDetail); }); + return; + } + + // A newly joined room may not be immediately visible through + // getChatLobbyInfo. Prefer the lobby data already loaded by the room + // lists, then retry briefly while the core completes the subscription. + const cached = ChatRoomsModel.subscribedRooms[currentlobbyid] + || (ChatRoomsModel.allRooms || []).find( + (room) => rs.idToHex(room.lobby_id) === currentlobbyid + ); + if (cached) { + finishLoad({ ...cached, chatType: 3 }); + } else if (attempt < 3) { + setTimeout(() => loadDetails(attempt + 1), 250 * (attempt + 1)); } }); + + loadDetails(); }, loadPublicLobby(currentlobbyid) { this.setupAction = this.enterPublicLobby; @@ -798,6 +1200,11 @@ const ChatLobbyModel = { }, sendMessage(msg, onsuccess) { const cid = this.chatId(); + // Captured now: the answer can land after a room switch, and the echo, + // its sender identity and the target list must be the room the message + // was typed in -- addMessages() writes into the room on screen. + const askedLobbyId = this.lastLobbyId; + const senderGxsId = this.currentLobby ? this.currentLobby.gxs_id : ''; rs.rsJsonApiRequest( '/rsChats/sendChat', @@ -807,11 +1214,18 @@ const ChatLobbyModel = { }, (data, success) => { if (success) { + if (this.lastLobbyId !== askedLobbyId) { + // Another room is open: its message array is not this echo's + // home. The message itself was sent; the sender's own line will + // come back through the history on the next visit. + if (onsuccess) onsuccess(); + return; + } const echoMsg = { chat_id: cid, msg, sendTime: Math.floor(Date.now() / 1000), - lobby_peer_gxs_id: this.currentLobby.gxs_id, + lobby_peer_gxs_id: senderGxsId, }; this.addMessages([echoMsg], true); if (onsuccess) onsuccess(); @@ -845,6 +1259,7 @@ const ChatLobbyModel = { // ************************* Chat Hub State **************************** const ChatHubState = { + mobilePane: 'list', selectedRoomId: null, selectedRoom: null, selectedRoomType: null, @@ -854,11 +1269,14 @@ const ChatHubState = { hoveredUser: null, mutedUsers: new Set(), activeMenu: null, + // Phone only: the participants column is shown as a sheet over the messages. + showParticipants: false, showAttachModal: false, attachPath: '', attachBrowseHint: false, isHashing: false, hashingError: '', + attachedImage: null, showEmojiPicker: false, emojiSearch: '', emojiCategory: 'Smileys', @@ -888,6 +1306,22 @@ const ChatHubState = { }, }; +function receiveLobbyChatMessage(chatMessage) { + const cid = chatMessage && chatMessage.chat_id; + if (!cid || cid.type !== 3) return; + const lobbyId = rs.idToHex(cid.lobby_id); + if (!lobbyId) return; + ChatLobbyModel.rememberLiveParticipant(chatMessage); + const isOpen = m.route.get().split('/')[1] === 'chat' + && ChatLobbyModel.lastLobbyId === lobbyId + && (window.innerWidth > 700 || ChatHubState.mobilePane === 'detail'); + if (isOpen) ChatLobbyModel.addMessages([chatMessage]); + else if (chatMessage.incoming === true) { + ChatRoomsModel.unreadCount[lobbyId] = (ChatRoomsModel.unreadCount[lobbyId] || 0) + 1; + m.redraw(); + } +} + module.exports = { get64Num, loadLobbyDetails, @@ -904,4 +1338,17 @@ module.exports = { Message, ChatLobbyModel, ChatHubState, + receiveLobbyChatMessage, + autoResizeTextarea, + openChatImageViewer, }; + +function autoResizeTextarea(el) { + if (!el) return; + el.style.height = 'auto'; + const maxHeight = 160; + const scrollHeight = el.scrollHeight; + const newHeight = Math.min(Math.max(scrollHeight, 40), maxHeight); + el.style.height = newHeight + 'px'; + el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'; +} diff --git a/webui-src/app/comments.js b/webui-src/app/comments.js new file mode 100644 index 0000000..bb36a59 --- /dev/null +++ b/webui-src/app/comments.js @@ -0,0 +1,285 @@ +const m = require('mithril'); +const rs = require('rswebui'); +const peopleUtil = require('people/people_util'); +const chatEmoji = require('chat/chat_emoji'); + +// The core's contract (rsgxscommon.h RsGxsVoteType, same as the legacy +// GXS_VOTE_* constants used by boards_util/channels_util): DOWN = 1, UP = 2. +// These were inverted at first, and a GXS vote is a published message that +// cannot be retracted: every thumbs-up was recorded as a downvote. +const VOTE_UP = 2; +const VOTE_DOWN = 1; + +const CommentsSection = () => { + let replyTo = null; + let composerText = ''; + let authorId = null; + let submitting = false; + let submitError = ''; + let showEmojiPicker = false; + const expandedReplies = {}; + + const metaOf = (comment) => (comment && comment.mMeta) || {}; + const idOf = (comment) => metaOf(comment).mMsgId || comment.msgId || comment.id; + const parentOf = (comment) => metaOf(comment).mParentId || comment.parentId || ''; + const textOf = (comment) => comment.mComment || comment.comment || comment.mBody || ''; + const nameOf = (id) => (!id || Number(id) === 0 ? 'Anonymous' : (rs.userList.username(id) || rs.userList.userMap[id] || `${String(id).slice(0, 10)}…`)); + const dateOf = (value) => { + const seconds = value && typeof value === 'object' ? value.xint64 : value; + const date = Number(seconds) ? new Date(Number(seconds) * 1000) : null; + return date && !Number.isNaN(date.getTime()) ? date.toLocaleString() : ''; + }; + + function buildTree(rawComments, rootThreadId) { + const nodes = {}; + const roots = []; + const list = Array.isArray(rawComments) + ? rawComments + : Object.values(rawComments || {}).map((entry) => entry.comment || entry); + + list.forEach((comment) => { + const id = idOf(comment); + if (id) nodes[id] = { comment, children: [] }; + }); + + Object.keys(nodes).forEach((id) => { + const node = nodes[id]; + const parent = parentOf(node.comment); + if (parent && parent !== id && parent !== rootThreadId && nodes[parent]) { + nodes[parent].children.push(node); + } else { + roots.push(node); + } + }); + + const chronological = (a, b) => { + const ta = Number(metaOf(a.comment).mPublishTs && (metaOf(a.comment).mPublishTs.xint64 || metaOf(a.comment).mPublishTs)) || 0; + const tb = Number(metaOf(b.comment).mPublishTs && (metaOf(b.comment).mPublishTs.xint64 || metaOf(b.comment).mPublishTs)) || 0; + return ta - tb; + }; + roots.sort(chronological); + Object.keys(nodes).forEach((id) => nodes[id].children.sort(chronological)); + return { roots, totalCount: list.length }; + } + + async function handleSubmit(vnode) { + const comment = composerText.trim(); + if (!comment || !authorId || submitting) return; + submitting = true; + submitError = ''; + try { + if (typeof vnode.attrs.onSubmitComment === 'function') { + await vnode.attrs.onSubmitComment({ + text: comment, + authorId, + parentId: replyTo ? idOf(replyTo) : null, + replyTo, + }); + } + composerText = ''; + replyTo = null; + } catch (err) { + console.warn('CommentsSection: submission failed', err); + submitError = (err && err.message) || 'Your comment could not be posted. Please try again.'; + } finally { + submitting = false; + m.redraw(); + } + } + + function renderCommentNode(node, depth, vnode) { + const comment = node.comment; + const id = idOf(comment); + const meta = metaOf(comment); + const name = nameOf(meta.mAuthorId); + const repliesCount = node.children.length; + const repliesExpanded = expandedReplies[id] === true; + + const votes = typeof vnode.attrs.getCommentVotes === 'function' + ? vnode.attrs.getCommentVotes(id, comment) + : { + upvotes: Number(comment.mUpVotes || 0), + downvotes: Number(comment.mDownVotes || 0), + }; + + const voteIdentity = vnode.attrs.voteIdentity; + + return m('.comment', { key: id, class: depth ? 'comment--reply' : '' }, [ + m('.comment-avatar', m(peopleUtil.IdentityAvatar, { + identityId: meta.mAuthorId, + name, + size: '100%', + })), + m('.comment__content', [ + m('.comment__header', [ + m('.comment__meta', [ + m('b', name), + dateOf(meta.mPublishTs) ? m('span', dateOf(meta.mPublishTs)) : null, + ]), + ]), + m('p.comment__text', textOf(comment)), + m('.comment__actions', [ + m('button[type=button]', { + disabled: !voteIdentity, + onclick: () => { + if (typeof vnode.attrs.onVoteComment === 'function') { + vnode.attrs.onVoteComment({ + commentId: id, + voteType: VOTE_UP, + voteIdentity, + comment, + }); + } + }, + }, [m('i.fas.fa-thumbs-up'), ` ${votes.upvotes || 0}`]), + m('button[type=button]', { + disabled: !voteIdentity, + onclick: () => { + if (typeof vnode.attrs.onVoteComment === 'function') { + vnode.attrs.onVoteComment({ + commentId: id, + voteType: VOTE_DOWN, + voteIdentity, + comment, + }); + } + }, + }, m('i.fas.fa-thumbs-down')), + m('button[type=button]', { + onclick: () => { + replyTo = comment; + composerText = ''; + submitError = ''; + }, + }, 'Reply'), + ]), + repliesCount ? m('button.comment__replies-toggle[type=button]', { + 'aria-expanded': repliesExpanded, + onclick: () => { expandedReplies[id] = !repliesExpanded; }, + }, [ + `${repliesCount} ${repliesCount === 1 ? 'reply' : 'replies'} `, + m('i.fas', { class: repliesExpanded ? 'fa-chevron-up' : 'fa-chevron-down' }), + ]) : null, + repliesCount && repliesExpanded + ? m('.comment__replies', node.children.map((child) => renderCommentNode(child, depth + 1, vnode))) + : null, + ]), + ]); + } + + return { + view: (vnode) => { + const identities = (vnode.attrs.identities || []).filter((id) => Number(id) !== 0); + if (!authorId && identities.length) authorId = identities[0]; + if (authorId && identities.length && !identities.includes(authorId)) { + authorId = identities[0]; + } + + const { roots, totalCount } = buildTree(vnode.attrs.comments, vnode.attrs.rootThreadId); + const showVoter = typeof vnode.attrs.onVoteIdentity === 'function'; + + return m('.comments', [ + m('.comments__heading', [ + m('h3', `${totalCount} Comment${totalCount === 1 ? '' : 's'}`), + m('span', [m('i.fas.fa-sort-amount-down'), ' Oldest first']), + showVoter ? m('.comments__voter', [ + m('label[for=comment-voter-select]', 'Voter identity'), + m('select#comment-voter-select', { + value: vnode.attrs.voteIdentity || '', + disabled: identities.length === 0, + onchange: (e) => vnode.attrs.onVoteIdentity(e.target.value), + }, identities.length + ? identities.map((id) => m('option', { value: id }, nameOf(id))) + : m('option', { value: '' }, vnode.attrs.identitiesLoading ? 'Loading identities…' : 'No identity available')), + ]) : null, + ]), + m('.comment-composer', [ + m('.comment-avatar', m(peopleUtil.IdentityAvatar, { + identityId: authorId, + name: nameOf(authorId), + size: '100%', + })), + m('.comment-composer__body', [ + replyTo ? m('.comment-composer__replying', [ + 'Replying to ', + m('b', nameOf(metaOf(replyTo).mAuthorId)), + m('button[type=button][aria-label=Cancel reply]', { + onclick: () => { replyTo = null; composerText = ''; }, + }, m('i.fas.fa-times')), + ]) : null, + identities.length ? m('select.comment-composer__identity', { + value: authorId || '', + onchange: (e) => { authorId = e.target.value; }, + }, identities.map((id) => m('option', { value: id }, nameOf(id)))) : null, + m('textarea.comment-composer__input[rows=1][placeholder=Add a comment…]', { + value: composerText, + disabled: !authorId || submitting, + oninput: (e) => { composerText = e.target.value; }, + onkeydown: (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + handleSubmit(vnode); + } + }, + }), + !authorId ? m('p.comment-composer__hint', vnode.attrs.identitiesLoading ? 'Loading identities…' : 'Create or select an identity to post a comment.') : null, + submitError ? m('p.comment-composer__error', submitError) : null, + m('.comment-composer__actions', [ + m('.comment-composer__emoji', [ + m('button[type=button][title=Insert emoji][aria-label=Insert emoji]', { + style: { + width: '32px', + height: '32px', + padding: '0', + borderRadius: '50%', + border: '0', + boxShadow: 'none', + background: showEmojiPicker ? '#e0f2fe' : 'transparent', + color: '#475569', + fontSize: '1.15rem', + }, + onclick: () => { showEmojiPicker = !showEmojiPicker; }, + }, m('i.fas.fa-smile')), + showEmojiPicker && chatEmoji && chatEmoji.EMOJI_DATA && chatEmoji.EMOJI_DATA.Smileys ? m('.comment-emoji-popover', chatEmoji.EMOJI_DATA.Smileys.slice(0, 48).map((emoji) => m('button[type=button]', { + style: { + width: '28px', + height: '28px', + padding: '0', + border: '0', + boxShadow: 'none', + background: 'transparent', + fontSize: '1.1rem', + }, + onclick: () => { + composerText += emoji; + showEmojiPicker = false; + }, + }, emoji))) : null, + ]), + composerText || replyTo ? m('button.comment-composer__cancel[type=button]', { + onclick: () => { + composerText = ''; + replyTo = null; + submitError = ''; + }, + }, 'Cancel') : null, + m('button.comment-composer__submit[type=button]', { + disabled: !composerText.trim() || !authorId || submitting, + onclick: () => handleSubmit(vnode), + }, submitting ? 'Posting…' : 'Comment'), + ]), + ]), + ]), + vnode.attrs.loading ? m('.comments__status', [m('i.fas.fa-spinner.fa-spin'), ' Loading comments…']) + : roots.length ? m('.comments__list', roots.map((node) => renderCommentNode(node, 0, vnode))) + : m('.comments__empty', [m('i.fas.fa-comment'), m('p', 'No comments yet. Start the conversation.')]), + ]); + }, + }; +}; + +module.exports = { + CommentsSection, + ThreadedComments: CommentsSection, + VOTE_UP, + VOTE_DOWN, +}; diff --git a/webui-src/app/config/config_resolver.js b/webui-src/app/config/config_resolver.js index dd9f82e..99386fd 100644 --- a/webui-src/app/config/config_resolver.js +++ b/webui-src/app/config/config_resolver.js @@ -16,6 +16,7 @@ const Layout = { m(widget.Sidebar, { tabs: Object.keys(sections), baseRoute: '/config/', + mobileDrawer: true, }), m('.node-panel', vnode.children), ], diff --git a/webui-src/app/debug/debug.js b/webui-src/app/debug/debug.js new file mode 100644 index 0000000..ee6889e --- /dev/null +++ b/webui-src/app/debug/debug.js @@ -0,0 +1,252 @@ +const m = require('mithril'); +const rs = require('rswebui'); + +// A page for what is otherwise invisible from a phone: which build this is, +// what the core answers, and what the API is doing from this browser -- +// requests in flight, the round trip of the last chat message, the slowest +// calls, the health of the event stream. Numbers, not a console. + +const Debug = () => { + let timer = null; + let coreVersion = null; + let coreVersionAt = 0; + + const ago = (t) => (t ? Math.round((Date.now() - t) / 1000) + ' s ago' : 'never'); + const short = (p) => String(p || '').replace(/^\/rs/, ''); + + const loadCoreVersion = () => { + const startedAt = performance.now(); + rs.rsJsonApiRequest('/rsJsonApi/version', {}, (data, success) => { + coreVersionAt = Math.round(performance.now() - startedAt); + coreVersion = success && data ? data : null; + m.redraw(); + }); + }; + + const latencyClass = (ms) => { + if (ms < 50) return 'debug-latency--fast'; + if (ms < 200) return 'debug-latency--moderate'; + return 'debug-latency--slow'; + }; + + return { + oninit: () => { + loadCoreVersion(); + // The counters move on their own; redraw once a second while here. + timer = setInterval(() => m.redraw(), 1000); + }, + onremove: () => { + if (timer) clearInterval(timer); + }, + view: (vnode) => { + const s = rs.apiStats; + const version = vnode.attrs.version || ''; + const isConnected = rs.connectionState.status; + const core = coreVersion + ? `${coreVersion.major}.${coreVersion.minor}.${coreVersion.mini}${coreVersion.extra || ''}` + : 'Unknown'; + const coreHuman = coreVersion && coreVersion.human ? coreVersion.human : ''; + + return m('.debug-page', [ + // Page Header + m('.debug-header', [ + m('.debug-header__title', [ + m('.debug-header__icon', m('i.fas.fa-bug')), + m('.debug-header__text', [ + m('h1', 'Debug & Diagnostics'), + m('p', 'Real-time build information, API performance metrics, and connection health.'), + ]), + ]), + m('.debug-header__actions', [ + m('button.debug-btn[type=button]', { + onclick: () => window.location.reload(true), + title: 'Force reload the Web UI bundle', + }, [m('i.fas.fa-sync-alt'), m('span', 'Reload Web UI')]), + m('button.debug-btn[type=button]', { + onclick: loadCoreVersion, + title: 'Ping the RetroShare core for version & latency', + }, [m('i.fas.fa-stopwatch'), m('span', 'Ping Core')]), + m('button.debug-btn.debug-btn--danger[type=button]', { + onclick: () => rs.resetApiStats(), + title: 'Reset API counters and latency tracking', + }, [m('i.fas.fa-eraser'), m('span', 'Reset Stats')]), + ]), + ]), + + // KPI Summary Cards + m('.debug-kpi-grid', [ + m('.debug-kpi-card', [ + m('.debug-kpi-card__icon.debug-kpi-card__icon--blue', m('i.fas.fa-server')), + m('.debug-kpi-card__body', [ + m('.debug-kpi-card__label', 'Core Latency'), + m('.debug-kpi-card__value', coreVersion ? `${coreVersionAt} ms` : '-'), + m('.debug-kpi-card__subtext', [ + m(`span.debug-status-dot.${isConnected ? 'online' : 'offline'}`), + isConnected ? 'Connected' : 'Offline', + ]), + ]), + ]), + m('.debug-kpi-card', [ + m('.debug-kpi-card__icon.debug-kpi-card__icon--purple', m('i.fas.fa-network-wired')), + m('.debug-kpi-card__body', [ + m('.debug-kpi-card__label', 'Active Requests'), + m('.debug-kpi-card__value', s.pending), + m('.debug-kpi-card__subtext', `${s.total.toLocaleString()} total calls`), + ]), + ]), + m('.debug-kpi-card', [ + m('.debug-kpi-card__icon.debug-kpi-card__icon--green', m('i.fas.fa-comment-dots')), + m('.debug-kpi-card__body', [ + m('.debug-kpi-card__label', 'Last sendChat'), + m('.debug-kpi-card__value', s.lastSend ? `${s.lastSend.ms} ms` : 'None yet'), + m('.debug-kpi-card__subtext', s.lastSend ? ago(s.lastSend.at) : 'No chat sent'), + ]), + ]), + m('.debug-kpi-card', [ + m('.debug-kpi-card__icon.debug-kpi-card__icon--amber', m('i.fas.fa-satellite-dish')), + m('.debug-kpi-card__body', [ + m('.debug-kpi-card__label', 'Event Stream'), + m('.debug-kpi-card__value', rs.formatBytes(s.eventsBytes)), + m('.debug-kpi-card__subtext', `${s.eventsRestarts} reconnects • ${ago(s.lastEventAt)}`), + ]), + ]), + ]), + + // Detail Sections Grid (Build info + Event stream info) + m('.debug-grid-2col', [ + m('.debug-section', [ + m('.debug-section__header', [ + m('i.fas.fa-cube'), + m('h3', 'Build & Environment'), + ]), + m('.debug-info-list', [ + m('.debug-info-row', [ + m('span.debug-info-label', 'Web UI Version'), + m('span.debug-info-value', m('span.debug-badge.debug-badge--blue', version || 'dev')), + ]), + m('.debug-info-row', [ + m('span.debug-info-label', 'Core Version'), + m('span.debug-info-value', [ + m('span.debug-badge.debug-badge--slate', core), + coreHuman && m('small.debug-sublabel', coreHuman), + ]), + ]), + m('.debug-info-row', [ + m('span.debug-info-label', 'Core Round Trip'), + m('span.debug-info-value', coreVersion ? `${coreVersionAt} ms` : '-'), + ]), + m('.debug-info-row', [ + m('span.debug-info-label', 'Page Loaded'), + m('span.debug-info-value', ago(s.startedAt)), + ]), + m('.debug-info-row', [ + m('span.debug-info-label', 'Browser Viewport'), + m('span.debug-info-value', `${window.innerWidth} × ${window.innerHeight} px`), + ]), + m('.debug-info-row', [ + m('span.debug-info-label', 'Core Connection'), + m('span.debug-info-value', [ + m(`span.debug-status-dot.${isConnected ? 'online' : 'offline'}`), + isConnected ? 'Online' : 'Disconnected', + ]), + ]), + ]), + ]), + + m('.debug-section', [ + m('.debug-section__header', [ + m('i.fas.fa-stream'), + m('h3', 'Event Stream & Connection'), + ]), + m('.debug-info-list', [ + m('.debug-info-row', [ + m('span.debug-info-label', 'Data Received'), + m('span.debug-info-value', rs.formatBytes(s.eventsBytes)), + ]), + m('.debug-info-row', [ + m('span.debug-info-label', 'Last Event Time'), + m('span.debug-info-value', ago(s.lastEventAt)), + ]), + m('.debug-info-row', [ + m('span.debug-info-label', 'Reconnections'), + m('span.debug-info-value', String(s.eventsRestarts)), + ]), + m('.debug-info-row', [ + m('span.debug-info-label', 'Connection Mode'), + m('span.debug-info-value', m('span.debug-badge.debug-badge--green', 'Active Long-Poll / SSE')), + ]), + ]), + m('.debug-callout', [ + m('i.fas.fa-info-circle'), + m('p', 'The event stream carries every real-time event from the core over a single persistent HTTP channel. Browsers typically keep up to 6 simultaneous connections per host, allowing the remaining 5 to handle concurrent API requests.'), + ]), + ]), + ]), + + // API Performance Section + m('.debug-section', [ + m('.debug-section__header', [ + m('i.fas.fa-tachometer-alt'), + m('h3', 'API Performance & Request History'), + ]), + + m('.debug-tables-grid', [ + m('.debug-table-panel', [ + m('.debug-table-panel__header', [ + m('h4', 'Slowest Requests'), + m('span.debug-count-badge', `${s.slowest.length} recorded`), + ]), + s.slowest.length === 0 + ? m('.debug-empty', [ + m('i.fas.fa-check-circle'), + m('p', 'No slow requests recorded yet.'), + ]) + : m('.debug-table-wrap', [ + m('table.debug-table', [ + m('thead', m('tr', [ + m('th', 'Endpoint'), + m('th', 'Latency'), + m('th', 'When'), + ])), + m('tbody', s.slowest.map((e) => m('tr', [ + m('td.debug-table__endpoint', m('code', short(e.path))), + m('td.debug-table__latency', m(`span.debug-latency-badge.${latencyClass(e.ms)}`, `${e.ms} ms`)), + m('td.debug-table__when', ago(e.at)), + ]))), + ]), + ]), + ]), + + m('.debug-table-panel', [ + m('.debug-table-panel__header', [ + m('h4', 'Recent Requests'), + m('span.debug-count-badge', `${s.recent.length} recent`), + ]), + s.recent.length === 0 + ? m('.debug-empty', [ + m('i.fas.fa-inbox'), + m('p', 'No requests recorded yet.'), + ]) + : m('.debug-table-wrap', [ + m('table.debug-table', [ + m('thead', m('tr', [ + m('th', 'Endpoint'), + m('th', 'Latency'), + m('th', 'When'), + ])), + m('tbody', s.recent.map((e) => m('tr', [ + m('td.debug-table__endpoint', m('code', short(e.path))), + m('td.debug-table__latency', m(`span.debug-latency-badge.${latencyClass(e.ms)}`, `${e.ms} ms`)), + m('td.debug-table__when', ago(e.at)), + ]))), + ]), + ]), + ]), + ]), + ]), + ]); + }, + }; +}; + +module.exports = Debug; diff --git a/webui-src/app/dialog.js b/webui-src/app/dialog.js new file mode 100644 index 0000000..00cb9cb --- /dev/null +++ b/webui-src/app/dialog.js @@ -0,0 +1,79 @@ +const m = require('mithril'); + +// Mount only while open. Native modal dialogs make the rest of the page inert +// and provide dialog semantics; callers own the open state and sheet content. +const Dialog = () => { + let opener; + let onclose = () => { }; + let desktopQuery; + let onLayoutChange; + return { + oncreate: ({ dom }) => { + opener = document.activeElement; + dom.showModal(); + // The sheets are phone chrome: above 700px their CSS hides the dialog, + // but showModal() keeps the whole page inert regardless -- rotating to + // landscape with a sheet open left the UI untappable with no visible + // way out. Crossing into the desktop layout closes the sheet instead. + // The SAME condition the stylesheet uses to show the sheet + // (max-width: 700px), so JS and CSS agree at every width -- a + // min-width: 701px mirror leaves a fractional crack (700 < w < 701, + // common at desktop zoom levels) where the sheet is hidden but still + // modal. + desktopQuery = window.matchMedia('(max-width: 700px)'); + onLayoutChange = (event) => { + if (!event.matches) { + onclose(); + m.redraw(); + } + }; + if (desktopQuery.addEventListener) desktopQuery.addEventListener('change', onLayoutChange); + else desktopQuery.addListener(onLayoutChange); + }, + onremove: ({ dom }) => { + if (desktopQuery && onLayoutChange) { + if (desktopQuery.removeEventListener) desktopQuery.removeEventListener('change', onLayoutChange); + else desktopQuery.removeListener(onLayoutChange); + } + dom.close(); + if (opener && opener.isConnected) opener.focus(); + }, + view: ({ attrs, children }) => { + onclose = attrs.onclose; + return m('dialog.accessible-dialog', { + class: attrs.overlayClass, + 'aria-label': attrs.label, + 'aria-modal': 'true', + oncancel: (event) => { + event.preventDefault(); + attrs.onclose(); + }, + onclick: (event) => { + if (event.target === event.currentTarget) attrs.onclose(); + }, + onkeydown: (event) => { + if (event.key !== 'Tab') return; + const dialog = event.currentTarget; + const controls = Array.from(dialog.querySelectorAll( + 'a[href], button, input, select, textarea, [tabindex], [contenteditable="true"]' + )).filter((element) => element.tabIndex >= 0 && + !element.matches(':disabled') && !element.closest('[inert]') && + element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden'); + const first = controls[0]; + const last = controls[controls.length - 1]; + if (!first) { + event.preventDefault(); + } else if (event.shiftKey && (document.activeElement === first || !controls.includes(document.activeElement))) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (document.activeElement === last || !controls.includes(document.activeElement))) { + event.preventDefault(); + first.focus(); + } + }, + }, m('div', { class: attrs.sheetClass }, children)); + }, + }; +}; + +module.exports = Dialog; diff --git a/webui-src/app/files/files_downloads.js b/webui-src/app/files/files_downloads.js index 192c0a1..dff154b 100644 --- a/webui-src/app/files/files_downloads.js +++ b/webui-src/app/files/files_downloads.js @@ -125,16 +125,29 @@ function addFile(url) { const NewFileDialog = () => { let url = ''; return { - view: () => [ - m('i.fas.fa-file-medical'), - m('h3', 'Add new file'), - m('hr'), - m('p', 'Enter the file link:'), - m('input[type=text][name=fileurl]', { - onchange: (e) => (url = e.target.value), - }), - m('button', { onclick: () => addFile(url) }, 'Add'), - ], + view: () => + m( + 'form.add-file-dialog', + { + onsubmit: (event) => { + event.preventDefault(); + addFile(url); + }, + }, + [ + m('.add-file-dialog__heading', [ + m('i.fas.fa-file-medical'), + m('h3', 'Add new file'), + ]), + m('hr'), + m('label[for=new-file-url]', 'Enter the file link:'), + m('input#new-file-url[type=text][name=fileurl]', { + value: url, + oninput: (e) => (url = e.target.value), + }), + m('button[type=submit]', 'Add'), + ] + ), }; }; @@ -151,7 +164,11 @@ const Component = () => { view: () => [ m('.widget__body-heading', { style: { display: 'flex', flexDirection: 'column', alignItems: 'flex-start' } }, [ m('.action', { style: { marginBottom: '10px' } }, [ - m('button', { onclick: () => widget.popupMessage(m(NewFileDialog)) }, 'Add new file'), + m( + 'button', + { onclick: () => widget.popupMessage(m(NewFileDialog), 'add-file-modal') }, + 'Add new file' + ), m('button', { onclick: clearFileCompleted }, 'Clear completed'), ]), m('h3', `Downloads (${Downloads.hashes ? Downloads.hashes.length : 0} files)`), @@ -173,6 +190,7 @@ const Component = () => { }; module.exports = { + addFile, Component, Downloads, list: Downloads.statusMap, diff --git a/webui-src/app/files/files_manager.js b/webui-src/app/files/files_manager.js index 332d1bf..a82e29d 100644 --- a/webui-src/app/files/files_manager.js +++ b/webui-src/app/files/files_manager.js @@ -154,8 +154,8 @@ const ShareDirTable = () => { ), m( 'tbody.share-manager__table_body', - sharedDirArr.length && - sharedDirArr.map((sharedDirItem, index) => { + sharedDirArr.length + ? sharedDirArr.map((sharedDirItem, index) => { const { filename, virtualname, @@ -233,7 +233,8 @@ const ShareDirTable = () => { : parentGroups.map((groupFlag) => futil.RsNodeGroupId[groupFlag]).join(', ') ), ]); - }) + }) + : m('tr.share-manager__empty', m('td[colspan=4]', 'No shared folders yet.')) ), ]); }, diff --git a/webui-src/app/files/files_resolver.js b/webui-src/app/files/files_resolver.js index 2c985be..a5486ce 100644 --- a/webui-src/app/files/files_resolver.js +++ b/webui-src/app/files/files_resolver.js @@ -35,6 +35,7 @@ const Layout = { m(widget.Sidebar, { tabs: Object.keys(sections), baseRoute: '/files/', + mobileDrawer: true, }), m('.node-panel', m('.widget', vnode.children)), ], diff --git a/webui-src/app/files/files_search.js b/webui-src/app/files/files_search.js index 9e62247..5cb020a 100644 --- a/webui-src/app/files/files_search.js +++ b/webui-src/app/files/files_search.js @@ -21,8 +21,13 @@ function handleSubmit() { const SearchBar = () => { return { view: () => - m('form.search-form', { onsubmit: handleSubmit }, [ - m('input[type=text][placeholder=search keyword]', { + m('form.search-form', { + onsubmit: (event) => { + event.preventDefault(); + handleSubmit(); + }, + }, [ + m('input[type=text][placeholder=Search files]', { value: matchString, oninput: (e) => (matchString = e.target.value), }), @@ -144,9 +149,16 @@ const Layout = () => { fproxy.fileProxyObj[currentItem.slice(1)] ? fproxy.fileProxyObj[currentItem.slice(1)].map((item) => m('div.results-row.file-item', [ - m('.results-cell.name-col', [m(getFileIcon(item.fName)), m('span', item.fName)]), - m('.results-cell.size-col', rs.formatBytes((item.fSize && (item.fSize.xint64 || item.fSize.xstr64)) || 0)), - m('.results-cell.hash-col', item.fHash), + m('.results-cell.name-col', { 'data-label': 'Name' }, [ + m(getFileIcon(item.fName)), + m('span', item.fName), + ]), + m( + '.results-cell.size-col', + { 'data-label': 'Size' }, + rs.formatBytes((item.fSize && (item.fSize.xint64 || item.fSize.xstr64)) || 0) + ), + m('.results-cell.hash-col', { 'data-label': 'Hash' }, item.fHash), m( '.results-cell.action-col', m( diff --git a/webui-src/app/files/friends_files.js b/webui-src/app/files/friends_files.js index 3eb9af2..c617b69 100644 --- a/webui-src/app/files/friends_files.js +++ b/webui-src/app/files/friends_files.js @@ -25,7 +25,7 @@ function displayfiles() { haveFile = res.body.retval; } } - if (v.attrs.replyDepth === 1 && parStruct) { + if (v.attrs.replyDepth === 0 && parStruct) { isId = true; const res = await rs.rsJsonApiRequest('/rsPeers/getPeerDetails', { sslId: parStruct.details.name, @@ -37,25 +37,29 @@ function displayfiles() { }, view: (v) => [ m('tr', [ - parStruct && Object.keys(parStruct.details.children).length + parStruct && parStruct.details.children && Object.keys(parStruct.details.children).length ? m( 'td', m('i.fas.fa-angle-right', { class: 'fa-rotate-' + (parStruct.showChild ? '90' : '0'), style: 'margin-top:12px', - onclick: () => { + onclick: async () => { if (!loaded) { - // if it is not already retrieved. - parStruct.details.children.map(async (child) => { - const res = await rs.rsJsonApiRequest('/rsfiles/requestDirDetails', { - handle: child.handle.xint64, - flags: util.RS_FILE_HINTS_REMOTE, - }); - childrenList.push(res.body.details); - loaded = true; - }); + // Retrieve the directory entries before displaying the nested rows. + const entries = await Promise.all( + parStruct.details.children.map(async (child) => { + const res = await rs.rsJsonApiRequest('/rsfiles/requestDirDetails', { + handle: child.handle.xint64, + flags: util.RS_FILE_HINTS_REMOTE, + }); + return res.body.details; + }) + ); + childrenList.push(...entries); + loaded = true; } parStruct.showChild = !parStruct.showChild; + m.redraw(); }, }) ) @@ -69,9 +73,25 @@ function displayfiles() { left: `calc(30px*${v.attrs.replyDepth})`, }, }, - isId - ? nameOfId + ' (' + parStruct.details.name.slice(0, 8) + '...)' - : parStruct.details.name + [ + m('i.fas', { + class: isId + ? 'fa-user-friends friends-files__friend-icon' + : !isFile + ? parStruct.showChild + ? 'fa-folder-open friends-files__folder-icon' + : 'fa-folder friends-files__folder-icon' + : 'fa-file friends-files__file-icon', + title: isId ? 'Friend' : isFile ? 'File' : 'Folder', + style: 'margin-right:0.45rem', + }), + isId + ? (nameOfId || parStruct.details.name) + + ' (' + + parStruct.details.name.slice(0, 8) + + '...)' + : parStruct.details.name, + ] ), m('td', rs.formatBytes(parStruct.details.size.xint64)), isFile && @@ -142,13 +162,31 @@ function displayfiles() { } const Layout = () => { - // let root_handle; - let parent; + let directories = []; return { - oninit: () => { - rs.rsJsonApiRequest('/rsfiles/requestDirDetails', { + oninit: async () => { + const res = await rs.rsJsonApiRequest('/rsfiles/requestDirDetails', { flags: util.RS_FILE_HINTS_REMOTE, - }).then((res) => (parent = res)); + }); + const root = res.body.details; + + // The remote API returns a synthetic "root" directory. It is not a + // friend and only adds an unnecessary level to this view, so begin at + // its children instead. + if (root && root.name === 'root' && root.children) { + directories = await Promise.all( + root.children.map(async (child) => { + const childRes = await rs.rsJsonApiRequest('/rsfiles/requestDirDetails', { + handle: child.handle.xint64, + flags: util.RS_FILE_HINTS_REMOTE, + }); + return childRes.body.details; + }) + ); + } else if (root) { + directories = [root]; + } + m.redraw(); }, view: () => [ m('.widget__heading', [m('h3', 'Friends Files')]), @@ -157,11 +195,12 @@ const Layout = () => { util.FriendsFilesTable, m( 'tbody', - parent && // root + directories.map((directory) => m(displayfiles, { - par_directory: { details: parent.body.details, showChild: false }, + par_directory: { details: directory, showChild: false }, replyDepth: 0, }) + ) ) ), ]), diff --git a/webui-src/app/files/my_files.js b/webui-src/app/files/my_files.js index 2cc332b..ce139d8 100644 --- a/webui-src/app/files/my_files.js +++ b/webui-src/app/files/my_files.js @@ -61,7 +61,16 @@ const DisplayFiles = () => { left: `calc(1.5rem*${v.attrs.replyDepth})`, }, }, - translateName(parStruct.details.name || '') + [ + parStruct.details.children !== undefined + ? m('i.fas', { + class: parStruct.showChild ? 'fa-folder-open' : 'fa-folder', + title: 'Folder', + style: 'margin-right: 0.45rem; color: #d69e2e;', + }) + : null, + translateName(parStruct.details.name || ''), + ] ), m('td', rs.formatBytes((parStruct.details.size && parStruct.details.size.xint64) || 0)), ]), @@ -108,7 +117,15 @@ const Layout = () => { view: () => [ m('.widget__heading', [ m('h3', 'My Files'), - m('button', { onclick: () => (showShareManager = true) }, 'Configure shared directories'), + m( + 'button.my-files__configure-shares', + { + onclick: () => (showShareManager = true), + title: 'Configure shared directories', + 'aria-label': 'Configure shared directories', + }, + [m('i.fas.fa-folder-plus'), m('span', 'Configure shared directories')] + ), ]), m('.widget__body', [ m( diff --git a/webui-src/app/forums/forum_view.js b/webui-src/app/forums/forum_view.js index 0c3845a..481bac9 100644 --- a/webui-src/app/forums/forum_view.js +++ b/webui-src/app/forums/forum_view.js @@ -2,63 +2,149 @@ const m = require('mithril'); const rs = require('rswebui'); const util = require('forums/forums_util'); const peopleUtil = require('people/people_util'); +const chatEmoji = require('chat/chat_emoji'); const { loadPostContent, getTimestampValue, formatTimestamp } = require('./forums_util'); +const CIRCLE_PUBLIC = 1; +const CIRCLE_EXTERNAL = 2; function createforum() { let title; let body; let identity; + let circle = CIRCLE_PUBLIC; + let circles = []; + let selectedCircle; + let enableModerators = false; + let moderatorFilter = 'all'; + let moderatorSearch = ''; + const moderators = new Set(); return { - oninit: (vnode) => { + oninit: async (vnode) => { if (vnode.attrs.authorId) { identity = vnode.attrs.authorId[0]; } + const res = await rs.rsJsonApiRequest('/rsgxscircles/getCirclesSummaries'); + if (res.body.retval) { + circles = res.body.circles || []; + selectedCircle = circles[0]; + } }, - view: (vnode) => - m('.widget', [ - m('h3', 'Create Forum'), - m('hr'), - m('input[type=text][placeholder=Title]', { + view: (vnode) => { + const query = moderatorSearch.trim().toLowerCase(); + const identities = (rs.userList.users || []) + .filter((item) => item && item.mGroupId) + .filter((item) => moderatorFilter !== 'contacts' || + (rs.userList.userMap[item.mGroupId] && rs.userList.userMap[item.mGroupId].isContact)) + .filter((item) => !query || `${item.mGroupName} ${item.mGroupId}`.toLowerCase().includes(query)) + .sort((a, b) => (a.mGroupName || '').localeCompare(b.mGroupName || '')); + return m('.widget.create-forum-form', [ + m('.create-forum-form__heading', [ + m('h3', 'Create Forum'), + m('p', 'Set up the forum and choose its publishing permissions.'), + ]), + m('input.create-forum-form__title[type=text][placeholder=Forum title]', { oninput: (e) => (title = e.target.value), }), - m('label[for=tags]', 'Select identity'), - m( - 'select[id=idtags]', - { + m('.create-forum-form__field', [ + m('label[for=forum-idtags]', 'Owner identity'), + m('select.config-style-select[id=forum-idtags]', { value: identity, - onchange: (e) => { - identity = vnode.attrs.authorId[e.target.selectedIndex]; - }, - }, - [ - vnode.attrs.authorId && - vnode.attrs.authorId.map((o) => - m( - 'option', - { value: o }, - rs.userList.username(o) - ? rs.userList.username(o) + ' (' + o.slice(0, 8) + '...)' - : 'No Signature' - ) - ), - ] - ), - m('textarea[rows=5][placeholder=Description]', { - style: { width: '90%', display: 'block' }, + onchange: (e) => (identity = vnode.attrs.authorId[e.target.selectedIndex]), + }, vnode.attrs.authorId && vnode.attrs.authorId.map((o) => m('option', { value: o }, + Number(o) === 0 ? 'No Signature' : `${rs.userList.username(o)} (${o.slice(0, 8)}...)`))), + ]), + m('.create-forum-form__field', [ + m('label[for=forum-distribution]', 'Message distribution'), + m('select.config-style-select[id=forum-distribution]', { + value: circle, + onchange: (e) => (circle = e.target.value), + }, [ + m('option', { value: CIRCLE_PUBLIC }, '\u{1F310} Public'), + m('option', { value: CIRCLE_EXTERNAL }, '\u25C9 Restricted to External Circle'), + ]), + ]), + Number(circle) === CIRCLE_EXTERNAL && m('.create-forum-form__field', [ + m('label[for=forum-circle]', 'Circle'), + m('select.config-style-select[id=forum-circle]', { + value: selectedCircle && selectedCircle.mGroupId, + onchange: (e) => (selectedCircle = circles.find((item) => item.mGroupId === e.target.value)), + }, circles.length + ? circles.map((item) => m('option', { value: item.mGroupId }, item.mGroupName)) + : m('option[disabled]', 'No circles available')), + ]), + m('.create-forum-form__moderators', [ + m('.create-forum-form__moderators-heading', [ + m('label.create-forum-form__moderators-toggle', [ + m('input[type=checkbox]', { + checked: enableModerators, + onchange: (e) => { + enableModerators = e.target.checked; + if (!enableModerators) { + moderators.clear(); + } + }, + }), + m('span', 'Add moderators'), + ]), + enableModerators && m('span', `${moderators.size} selected`), + ]), + enableModerators && m('.create-forum-form__moderator-controls', [ + m('select.config-style-select[id=forum-moderator-filter]', { + value: moderatorFilter, + onchange: (e) => { + moderatorFilter = e.target.value; + }, + }, [ + m('option[value=all]', 'All identities'), + m('option[value=contacts]', 'My contacts'), + ]), + m('.create-forum-form__search', [ + m('i.fas.fa-search'), + m('input[id=forum-moderator-search][type=search][placeholder=Search identities]', { + value: moderatorSearch, + oninput: (e) => (moderatorSearch = e.target.value), + }), + ]), + m('.create-forum-form__moderator-list', identities.length + ? identities.map((item) => m('label.create-forum-form__moderator', [ + m('input[type=checkbox]', { + checked: moderators.has(item.mGroupId), + onchange: (e) => e.target.checked + ? moderators.add(item.mGroupId) + : moderators.delete(item.mGroupId), + }), + m(peopleUtil.UserAvatar, { + firstLetter: (item.mGroupName || '?').slice(0, 1).toUpperCase(), + identityId: item.mGroupId, + size: 30, + isSquare: true, + }), + m('span', [ + m('b', item.mGroupName || 'Unnamed identity'), + m('small', item.mGroupId), + ]), + ])) + : m('.create-forum-form__empty', query ? 'No matching identities' : 'No identities available')), + ]), + ]), + m('textarea.create-forum-form__description[rows=5][placeholder=Describe your forum]', { oninput: (e) => (body = e.target.value), value: body, }), - m( - 'button', + m('button.create-forum-form__submit', { onclick: async () => { const res = await rs.rsJsonApiRequest('/rsgxsforums/createForumV2', { name: title, description: body, - ...(Number(identity) !== 0 && { authorId: identity }), // if id == '0', authorId is left empty + ...(Number(identity) !== 0 && { authorId: identity }), + moderatorsIds: enableModerators ? Array.from(moderators) : [], + circleType: Number(circle), + ...(Number(circle) === CIRCLE_EXTERNAL && selectedCircle && { circleId: selectedCircle.mGroupId }), }); if (res.body.retval) { - util.updatedisplayforums(res.body.forumId); + await util.updatedisplayforums(res.body.forumId); + if (vnode.attrs.onCreated) await vnode.attrs.onCreated(); m.redraw(); } res.body.retval === false @@ -72,70 +158,335 @@ function createforum() { }, 'Create' ), - ]), + ]); + }, }; } const AddThread = () => { + const MAX_GXS_MESSAGE_SIZE = 199000; let title = ''; let body = ''; let identity; + let showEmojiPicker = false; + let emojiCategory = 'Smileys'; + let showFilePanel = false; + let isFullscreen = false; + let filePath = ''; + let filePathNeedsPrefix = false; + let fileHashing = false; + let fileError = ''; + let closed = false; + const attachments = []; + const inlineImages = []; + + const escapeHtml = (value) => String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + const formatSize = (bytes) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + const pollFileHash = (localpath, attempt = 0) => { + if (closed) return; + rs.rsJsonApiRequest('/rsFiles/ExtraFileStatus', { localpath }, (data) => { + if (closed) return; + const info = data && data.retval && data.info; + if (info && info.hash && info.hash !== '0000000000000000000000000000000000000000') { + const size = Number(info.size && (info.size.xint64 || info.size.xstr64 || info.size)) || 0; + if (!attachments.some((file) => file.hash === info.hash)) { + attachments.push({ name: info.name, size, hash: info.hash }); + } + fileHashing = false; + filePath = ''; + showFilePanel = false; + fileError = ''; + m.redraw(); + } else if (fileHashing && attempt < 120) { + setTimeout(() => pollFileHash(localpath, attempt + 1), 500); + } else { + fileHashing = false; + fileError = 'RetroShare could not hash this file. Check the full local path.'; + m.redraw(); + } + }); + }; + + const attachFile = () => { + const localpath = filePath.trim(); + if (!localpath || filePathNeedsPrefix || fileHashing) return; + fileHashing = true; + fileError = ''; + rs.rsJsonApiRequest('/rsFiles/ExtraFileHash', { + localpath, + period: 86400 * 7, + flags: 0, + }, (data, success) => { + if (success && data && data.retval) { + pollFileHash(localpath); + } else { + fileHashing = false; + fileError = 'Failed to start file hashing. Check the full local path.'; + m.redraw(); + } + }); + }; + + const addInlineImages = (files) => { + Array.from(files || []).forEach((file) => { + const image = new Image(); + const objectUrl = URL.createObjectURL(file); + image.onload = () => { + let width = image.naturalWidth; + let height = image.naturalHeight; + const scale = Math.min(1, 640 / width, 480 / height); + width = Math.max(1, Math.round(width * scale)); + height = Math.max(1, Math.round(height * scale)); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, width, height); + context.drawImage(image, 0, 0, width, height); + + let quality = .84; + let dataUrl = canvas.toDataURL('image/jpeg', quality); + while (dataUrl.length > 175000 && (quality > .35 || width > 160 || height > 120)) { + if (quality > .35) { + quality = Math.max(.35, quality - .08); + } else { + width = Math.max(160, Math.round(width * .82)); + height = Math.max(120, Math.round(height * .82)); + canvas.width = width; + canvas.height = height; + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, width, height); + context.drawImage(image, 0, 0, width, height); + } + dataUrl = canvas.toDataURL('image/jpeg', quality); + } + inlineImages.push({ name: file.name, dataUrl }); + URL.revokeObjectURL(objectUrl); + m.redraw(); + }; + image.onerror = () => URL.revokeObjectURL(objectUrl); + image.src = objectUrl; + }); + }; + + // The GXS limit is expressed in bytes, not in JS characters: an accent is two + // bytes and an emoji four, while both count as one or two units of .length. + // With an emoji picker one click away, counting characters lets the composer + // accept a message the core then rejects. + const byteLength = (value) => new TextEncoder().encode(value).length; + + const postBody = () => { + const message = escapeHtml(body).replace(/\r?\n/g, '
'); + const images = inlineImages.map((file) => + `

${escapeHtml(file.name)}

` + ).join(''); + const embedded = attachments.map((file) => + `

📎 ${escapeHtml(file.name)} (${formatSize(file.size)})

` + ).join(''); + return `${message}${images}${embedded}`; + }; + + const insertEmoji = (emoji) => { + body += emoji; + showEmojiPicker = false; + }; + return { oninit: (vnode) => { if (vnode.attrs.authorId) { identity = vnode.attrs.authorId[0]; } }, - view: (vnode) => - m('.widget', [ - m('h3', 'Add Thread'), - m('hr'), + onremove: () => { + // pollFileHash re-arms itself every 500 ms for up to a minute. Closing + // the composer has to stop it, or it keeps hashing and redrawing against + // a component that is no longer on screen. + closed = true; + }, + view: (vnode) => { + // Built once per pass: postBody() re-escapes the message and re-joins + // every base64 image, and it was called five times per render, on every + // global redraw, while the user types. + const mBody = postBody(); + const bodySize = byteLength(mBody); + + return m('.widget.forum-thread-composer', [ + m('.forum-thread-composer__heading', [ + m('.forum-thread-composer__heading-copy', [ + m('h3', (vnode.attrs.parent_thread !== '') > 0 ? 'Add Reply' : 'Create New Thread'), + m('p', (vnode.attrs.parent_thread !== '') > 0 + ? 'Write a reply and optionally include images or files.' + : 'Start a discussion and optionally include images or files.'), + ]), + m('button.forum-thread-composer__fullscreen[type=button]', { + title: isFullscreen ? 'Restore default size' : 'Fullscreen', + 'aria-label': isFullscreen ? 'Restore default size' : 'Fullscreen', + onclick: (e) => { + isFullscreen = !isFullscreen; + const modal = e.currentTarget.closest('.modal-content'); + if (modal) modal.classList.toggle('is-fullscreen', isFullscreen); + }, + }, m(`i.fas.${isFullscreen ? 'fa-compress' : 'fa-expand'}`)), + ]), (vnode.attrs.parent_thread !== '') > 0 - ? [m('h5', 'Reply to thread: '), m('p', vnode.attrs.parent_thread)] + ? m('.forum-thread-composer__reply', [m('b', 'Replying to: '), vnode.attrs.parent_thread]) : '', - m('input[type=text][placeholder=Title]', { + m('input.forum-thread-composer__title[type=text][placeholder=Thread title]', { + value: title, oninput: (e) => (title = e.target.value), }), - m('label[for=tags]', 'Select identity'), - m( - 'select[id=idtags]', - { + m('.forum-thread-composer__field', [ + m('label[for=forum-thread-identity]', 'Publishing identity'), + m('select.config-style-select[id=forum-thread-identity]', { value: identity, onchange: (e) => { identity = vnode.attrs.authorId[e.target.selectedIndex]; }, - }, - [ - vnode.attrs.authorId && - vnode.attrs.authorId.map((o) => - m( - 'option', - { value: o }, - rs.userList.username(o) + ' (' + o.slice(0, 8) + '...)' - ) - ), - ] - ), - m('textarea[rows=5]', { - style: { width: '90%', display: 'block' }, + }, vnode.attrs.authorId && vnode.attrs.authorId.map((o) => m( + 'option', + { value: o }, + Number(o) === 0 ? 'No Signature' : `${rs.userList.username(o)} (${o.slice(0, 8)}...)` + ))), + ]), + m('.forum-thread-composer__editor', [ + m('textarea[rows=8][placeholder=Write your message...]', { oninput: (e) => (body = e.target.value), value: body, - }), - m( - 'button', + }), + m('.forum-thread-composer__toolbar', [ + m('input[type=file][id=forum-thread-files]', { + onchange: (e) => { + const file = e.target.files && e.target.files[0]; + if (file) { + const fullPath = file.path; + const hasFullPath = fullPath && (fullPath.includes('/') || fullPath.includes('\\')) && fullPath !== file.name; + filePath = hasFullPath ? fullPath : file.name; + filePathNeedsPrefix = !hasFullPath; + showFilePanel = true; + fileError = ''; + } + e.target.value = ''; + }, + }), + m('input[type=file][id=forum-thread-images][accept=image/*][multiple]', { + onchange: (e) => { + addInlineImages(e.target.files); + e.target.value = ''; + }, + }), + m('button.forum-thread-composer__tool[type=button][title=Attach file][aria-label=Attach file]', { + class: showFilePanel ? 'active' : '', + onclick: () => (showFilePanel = !showFilePanel), + }, m('i.fas.fa-paperclip')), + m('button.forum-thread-composer__tool[type=button][title=Insert emoji][aria-label=Insert emoji]', { + class: showEmojiPicker ? 'active' : '', + onclick: () => (showEmojiPicker = !showEmojiPicker), + }, m('i.fas.fa-smile')), + m('label.forum-thread-composer__tool[for=forum-thread-images][title=Attach images][aria-label=Attach images]', + m('i.fas.fa-image') + ), + showEmojiPicker && m('.forum-thread-composer__emoji-picker', [ + m('.forum-thread-composer__emoji-categories', chatEmoji.EMOJI_CATEGORIES.map((category) => + m('button[type=button]', { + class: category === emojiCategory ? 'active' : '', + title: category, + onclick: () => (emojiCategory = category), + }, chatEmoji.EMOJI_ICONS[category]) + )), + m('.forum-thread-composer__emoji-grid', + (chatEmoji.EMOJI_DATA[emojiCategory] || []).map((emoji) => + m('button[type=button]', { onclick: () => insertEmoji(emoji) }, emoji) + ) + ), + ]), + ]), + showFilePanel && m('.forum-thread-composer__file-panel', [ + m('div', [ + m('input[type=text][placeholder=Full local path to file]', { + value: filePath, + disabled: fileHashing, + oninput: (e) => { + filePath = e.target.value; + filePathNeedsPrefix = false; + fileError = ''; + }, + }), + m('label[for=forum-thread-files][title=Browse for file]', m('i.fas.fa-folder-open')), + m('button[type=button]', { + disabled: fileHashing || !filePath.trim() || filePathNeedsPrefix, + onclick: attachFile, + }, fileHashing ? [m('i.fas.fa-spinner.fa-spin'), ' Hashing...'] : 'Attach'), + ]), + filePathNeedsPrefix && m('small', [ + 'The browser only returned the filename. Add its complete folder path before attaching.', + ]), + fileError && m('small.error-text', fileError), + ]), + inlineImages.length > 0 && m('.forum-thread-composer__inline-images', + inlineImages.map((file, index) => m('.forum-thread-composer__inline-image', [ + m('img', { src: file.dataUrl, alt: file.name }), + m('button[type=button][title=Remove inline image][aria-label=Remove inline image]', { + onclick: () => inlineImages.splice(index, 1), + }, m('i.fas.fa-times')), + ])) + ), + ]), + attachments.length > 0 && m('.forum-thread-composer__attachments', [ + m('.forum-thread-composer__attachments-heading', [ + m('i.fas.fa-paperclip'), + m('span', `${attachments.length} attachment${attachments.length === 1 ? '' : 's'}`), + ]), + m('.forum-thread-composer__attachment-list', attachments.map((file, index) => + m('.forum-thread-composer__attachment', [ + m('i.fas.fa-file-alt'), + m('span', [m('b', file.name), m('small', formatSize(file.size))]), + m('button[type=button][title=Remove attachment][aria-label=Remove attachment]', { + onclick: () => attachments.splice(index, 1), + }, m('i.fas.fa-times')), + ]) + )), + ]), + m('.forum-thread-composer__capacity', { + class: bodySize > MAX_GXS_MESSAGE_SIZE ? 'is-over-limit' : '', + }, bodySize > MAX_GXS_MESSAGE_SIZE + ? `Message is ${bodySize - MAX_GXS_MESSAGE_SIZE} bytes too large.` + : `${MAX_GXS_MESSAGE_SIZE - bodySize} bytes remaining after HTML conversion.` + ), + m('.forum-thread-composer__actions', m( + 'button[type=button]', { + disabled: fileHashing || bodySize > MAX_GXS_MESSAGE_SIZE, onclick: async () => { + if (!title.trim() || (!body.trim() && attachments.length === 0 && inlineImages.length === 0)) return; + // Rebuilt here rather than reused from the render: what is sent + // must be what the fields hold at the click, not what they held + // when the button was last drawn. + const mBody = postBody(); + if (byteLength(mBody) > MAX_GXS_MESSAGE_SIZE) return; const res = (vnode.attrs.parent_thread !== '') > 0 // is it a reply or a new thread ? await rs.rsJsonApiRequest('/rsgxsforums/createPost', { forumId: vnode.attrs.forumId, - mBody: body, + mBody, title, authorId: identity, parentId: vnode.attrs.parentId, }) : await rs.rsJsonApiRequest('/rsgxsforums/createPost', { forumId: vnode.attrs.forumId, - mBody: body, + mBody, title, authorId: identity, }); @@ -151,9 +502,10 @@ const AddThread = () => { m.redraw(); }, }, - 'Add' - ), - ]), + (vnode.attrs.parent_thread !== '') > 0 ? 'Add Reply' : 'Create Thread' + )), + ]); + }, }; }; @@ -180,9 +532,9 @@ const ThreadView = () => { const threadStruct = (util.Data.Threads[forumId] && util.Data.Threads[forumId][msgId]) ? util.Data.Threads[forumId][msgId] : null; if (!threadStruct) { - return m('.widget', [ + return m('.forum-thread-view', [ m( - 'a[title=Back]', + 'a.forum-back[title=Back][aria-label=Back]', { onclick: () => m.route.set('/forums/:tab/:mGroupId', { tab: m.route.param().tab, @@ -198,9 +550,9 @@ const ThreadView = () => { const meta = threadStruct.thread.mMeta; const unread = meta.mMsgStatus === util.THREAD_UNREAD; - return m('.widget', { key: msgId }, [ + return m('.forum-thread-view', { key: msgId }, [ m( - 'a[title=Back]', + 'a.forum-back[title=Back][aria-label=Back]', { onclick: () => m.route.set('/forums/:tab/:mGroupId', { tab: m.route.param().tab, @@ -223,7 +575,7 @@ const ThreadView = () => { forumId, authorId: ownId, parentId: msgId, - })) + }), 'create-forum-thread-modal') }, 'Reply'), m('button', { onclick: async () => { @@ -238,7 +590,7 @@ const ThreadView = () => { } }, unread ? 'Mark Read' : 'Mark Unread'), ]), - m('div.content', { + m('div.forum-post-content', { style: { width: '100%', backgroundColor: '#f9f9f9', @@ -259,6 +611,7 @@ const ThreadView = () => { const ForumView = () => { let ownId = ''; + let threadSearch = ''; return { oninit: (v) => { util.updatedisplayforums(v.attrs.id); @@ -287,66 +640,80 @@ const ForumView = () => { const fsubscribed = forumDetails.isSubscribed; const createDate = forumDetails.created; const lastActivity = forumDetails.activity; + // userMap holds {name, isContact} objects, so it must not be read + // directly into the view: username() is what turns an id into a string. let fauthor = 'Unknown'; - if (rs.userList.userMap[forumDetails.author]) { - fauthor = rs.userList.userMap[forumDetails.author]; - } else if (Number(forumDetails.author) === 0) { + if (Number(forumDetails.author) === 0) { fauthor = 'No Contact Author'; + } else if (forumDetails.author) { + fauthor = rs.userList.username(forumDetails.author); } - return [ - m( - 'a[title=Back]', - { - onclick: () => - m.route.set('/forums/:tab', { - tab: m.route.param().tab, - }), - }, - m('i.fas.fa-arrow-left') - ), + const toggleSubscription = async () => { + const res = await rs.rsJsonApiRequest('/rsgxsforums/subscribeToForum', { + forumId: v.attrs.id, + subscribe: !fsubscribed, + }); + if (res.body.retval) { + util.Data.DisplayForums[v.attrs.id].isSubscribed = !fsubscribed; + if (v.attrs.onSubscriptionChange) await v.attrs.onSubscriptionChange(); + m.redraw(); + } + }; - m('h3', fname), - m( - 'button', - { - onclick: async () => { - const res = await rs.rsJsonApiRequest('/rsgxsforums/subscribeToForum', { - forumId: v.attrs.id, - subscribe: !fsubscribed, - }); - if (res.body.retval) { - util.Data.DisplayForums[v.attrs.id].isSubscribed = !fsubscribed; + const query = threadSearch.trim().toLowerCase(); + const filteredPosts = query + ? allPosts.filter((thread) => (thread.mMsgName || '').toLowerCase().includes(query)) + : allPosts; + + return [ + m('.forum-detail-navigation', [ + m( + 'a.forum-back[title=Back][aria-label=Back]', + { + onclick: () => + m.route.set('/forums/:tab', { + tab: m.route.param().tab || 'Subscribed', + }), + }, + m('i.fas.fa-arrow-left') + ), + m('.forum-mobile-search', [ + m('input[type=search][placeholder=Search threads...]', { + value: threadSearch, + oninput: (e) => { + threadSearch = e.target.value; + }, + }), + ]), + m('details.forum-mobile-actions', { + onkeydown: (event) => { + if (event.key === 'Escape') { + event.currentTarget.open = false; + event.currentTarget.querySelector('summary').focus(); } }, - }, - fsubscribed ? 'Subscribed' : 'Subscribe' - ), - m('[id=forumdetails]', [ - m( - 'p', - m('b', 'Date created: '), - formatTimestamp(createDate) - ), - m('p', m('b', 'Admin: '), fauthor), - m( - 'p', - m('b', 'Last activity: '), - formatTimestamp(lastActivity) - ), + onfocusout: (event) => { + if (!event.currentTarget.contains(event.relatedTarget)) event.currentTarget.open = false; + }, + }, [ + m('summary[aria-label=Forum actions][title=Forum actions]', m('i.fas.fa-ellipsis-v')), + m('.forum-mobile-actions__items', m('button[type=button]', { + onclick: (event) => { + const menu = event.currentTarget.closest('details'); + menu.open = false; + menu.querySelector('summary').focus(); + return toggleSubscription(); + }, + }, fsubscribed ? 'Unsubscribe' : 'Subscribe')), + ]), ]), - m('hr'), - m('forumdesc', m('b', 'Description: '), forumDetails.description), - m('hr'), - m( - 'threaddetails', - { - style: 'display:' + (fsubscribed ? 'block' : 'none'), - }, - m('h3', 'Threads'), - m( - 'button', + + m('.widget__heading.forum-detail-heading', [ + m('h3', fname), + fsubscribed && m( + 'button.forum-mobile-create[type=button][title=New Thread][aria-label=New Thread]', { onclick: () => { util.popupmessage( @@ -355,32 +722,81 @@ const ForumView = () => { forumId: v.attrs.id, authorId: ownId, parentId: '', - }) + }), + 'create-forum-thread-modal' ); }, }, - ['New Thread', m('i.fas.fa-pencil-alt')] + m('i.fas.fa-pencil-alt') ), - m('hr'), + m( + 'button.forum-subscription-button', + { + class: fsubscribed ? 'forum-subscription--subscribed' : '', + onclick: toggleSubscription, + }, + fsubscribed ? 'Subscribed' : 'Subscribe' + ), + ]), + m('.media-item', [ + m('.media-item__details', [ + m( + '.forum-detail-default-thumbnail[role=img][aria-label=Default forum thumbnail]', + m('i.fas.fa-bullhorn') + ), + m('.media-item__details-info', [ + m('div', [m('b', 'Threads: '), m('span', allPosts.length)]), + m('div', [m('b', 'Date created: '), m('span', formatTimestamp(createDate))]), + m('div', [m('b', 'Admin: '), m('span', fauthor)]), + m('div', [m('b', 'Last activity: '), m('span', formatTimestamp(lastActivity))]), + ]), + ]), + m('.media-item__desc', [ + m('b', 'Description: '), + m('span', forumDetails.description || 'No Description'), + ]), + ]), + m( + 'threaddetails.forum-threads', + { + style: 'display:' + (fsubscribed ? 'block' : 'none'), + }, + m('.forum-threads__heading', [ + m('h3', 'Threads'), + m( + 'button.forum-threads__create[type=button][title=New Thread][aria-label=New Thread]', + { + onclick: () => { + util.popupmessage( + m(AddThread, { + parent_thread: '', + forumId: v.attrs.id, + authorId: ownId, + parentId: '', + }), + 'create-forum-thread-modal' + ); + }, + }, + [m('i.fas.fa-pencil-alt'), m('span', 'New Thread')] + ), + ]), m( util.ThreadsTable, m( 'tbody', - allPosts - .sort((a, b) => getTimestampValue(b.mPublishTs) - getTimestampValue(a.mPublishTs)) - .map((thread) => - m( - 'tr', - { - style: - thread.mMsgStatus === util.THREAD_UNREAD ? { fontWeight: 'bold' } : '', - }, - m('td', { style: { padding: '10px 0' } }, [ - m('div.date', { style: { fontSize: '0.8em', color: '#888' } }, - formatTimestamp(thread.mPublishTs) - ), - m('div.title', { - style: { fontWeight: 'bold', fontSize: '1.2em', cursor: 'pointer', margin: '5px 0' }, + filteredPosts.length === 0 + ? m('tr', m('td.forum-threads__empty', { + style: { textAlign: 'center', padding: '1.25rem', color: '#64748b', fontSize: '.9rem' }, + }, query ? 'No threads matching search.' : 'No threads in this forum yet.')) + : filteredPosts + .sort((a, b) => getTimestampValue(b.mPublishTs) - getTimestampValue(a.mPublishTs)) + .map((thread) => + m( + 'tr.forum-thread-row', + { + class: + thread.mMsgStatus === util.THREAD_UNREAD ? 'forum-thread-row--unread' : '', onclick: () => { m.route.set('/forums/:tab/:mGroupId/:mMsgId', { tab: m.route.param().tab, @@ -388,11 +804,17 @@ const ForumView = () => { mMsgId: thread.mOrigMsgId, }); }, - }, thread.mMsgName), - m('div.author', { style: { fontSize: '0.9em', fontStyle: 'italic' } }, rs.userList.username(thread.mAuthorId)), - ]) + }, + m('td.forum-thread-row__cell', [ + m('.forum-thread-row__title', thread.mMsgName), + m('.forum-thread-row__meta', [ + m('span.forum-thread-row__author', rs.userList.username(thread.mAuthorId)), + m('span.forum-thread-row__bullet', '•'), + m('span.forum-thread-row__date', formatTimestamp(thread.mPublishTs)), + ]), + ]) + ) ) - ) ) ) ), diff --git a/webui-src/app/forums/forums.js b/webui-src/app/forums/forums.js index 587692f..aabee8b 100644 --- a/webui-src/app/forums/forums.js +++ b/webui-src/app/forums/forums.js @@ -7,15 +7,15 @@ const peopleUtil = require('people/people_util'); const getForums = { All: [], - PopularForums: [], - SubscribedForums: [], + Popular: [], + Subscribed: [], MyForums: [], async load() { const res = await rs.rsJsonApiRequest('/rsgxsforums/getForumsSummaries'); if (res && res.body && res.body.forums) { getForums.All = res.body.forums; - getForums.PopularForums = getForums.All; - getForums.SubscribedForums = getForums.All.filter( + getForums.Popular = getForums.All; + getForums.Subscribed = getForums.All.filter( (forum) => forum.mSubscribeFlags === util.GROUP_SUBSCRIBE_SUBSCRIBED || forum.mSubscribeFlags === util.GROUP_MY_FORUM @@ -26,19 +26,34 @@ const getForums = { } }, }; +// Group lists change on the scale of a conversation, not of a frame. +const FORUM_LIST_REFRESH_MS = 30000; + const sections = { MyForums: require('forums/my_forums'), - SubscribedForums: require('forums/subscribed_forums'), - PopularForums: require('forums/popular_forums'), - OtherForums: require('forums/other_forums'), + Subscribed: require('forums/subscribed_forums'), + Popular: require('forums/popular_forums'), + Other: require('forums/other_forums'), }; const Layout = () => { let ownId; + const createForum = () => + ownId && + util.popupmessage( + m(viewUtil.createforum, { + authorId: ownId, + onCreated: getForums.load, + }), + 'create-forum-modal' + ); return { oninit: () => { - rs.setBackgroundTask(getForums.load, 5000, () => { + // Was every 5 s. getForumsSummaries returns the whole list every time, + // and on a phone each poll is a fresh TCP handshake on a server that + // answers one request at a time; the boards list already settled on 30 s. + rs.setBackgroundTask(getForums.load, FORUM_LIST_REFRESH_MS, () => { return m.route.get().includes('/forums'); }); peopleUtil.ownIds((data) => { @@ -51,20 +66,18 @@ const Layout = () => { ownId.unshift(0); }); }, - view: (vnode) => - m('.widget', [ + view: (vnode) => { + const isForumDetail = vnode.attrs.pathInfo.mGroupId && !vnode.attrs.pathInfo.mMsgId; + const isThreadDetail = vnode.attrs.pathInfo.mGroupId && vnode.attrs.pathInfo.mMsgId; + return m('.widget', { + class: isForumDetail ? 'forums-detail-widget' : isThreadDetail ? 'forums-thread-widget' : '', + }, [ m('.top-heading', [ vnode.attrs.pathInfo.tab === 'MyForums' && m( - 'button', + 'button.forums-create-button', { - onclick: () => - ownId && - util.popupmessage( - m(viewUtil.createforum, { - authorId: ownId, - }) - ), + onclick: createForum, }, 'Create Forum' ), @@ -80,11 +93,14 @@ const Layout = () => { : Object.prototype.hasOwnProperty.call(vnode.attrs.pathInfo, 'mGroupId') // Forum's view ? m(viewUtil.ForumView, { id: vnode.attrs.pathInfo.mGroupId, + onSubscriptionChange: getForums.load, }) : m(sections[vnode.attrs.pathInfo.tab], { list: getForums[vnode.attrs.pathInfo.tab], + onCreateForum: createForum, }), - ]), + ]); + }, }; }; @@ -94,6 +110,7 @@ module.exports = { m(widget.Sidebar, { tabs: Object.keys(sections), baseRoute: '/forums/', + mobileDrawer: true, }), m('.node-panel', m(Layout, { pathInfo: vnode.attrs })), ]; diff --git a/webui-src/app/forums/forums_util.js b/webui-src/app/forums/forums_util.js index e7a2773..69d62de 100644 --- a/webui-src/app/forums/forums_util.js +++ b/webui-src/app/forums/forums_util.js @@ -1,5 +1,6 @@ const m = require('mithril'); const rs = require('rswebui'); +const widget = require('widgets'); const GROUP_SUBSCRIBE_ADMIN = 0x01; // means: you have the admin key for this group const GROUP_SUBSCRIBE_PUBLISH = 0x02; // means: you have the publish key for thiss group. Typical use: publish key in forums are shared with specific friends. @@ -17,6 +18,12 @@ const Data = { loading: new Set(), }; +// 'forumId/msgId' of the post bodies currently being fetched, see +// loadPostContent(). Module level rather than in Data: it is plumbing, not +// forum content. +const bodyRequestsInFlight = new Set(); +const FAILED_BODY_RETRY_MS = 5 * 60 * 1000; + function getTimestampValue(ts) { if (!ts) return 0; if (typeof ts === 'object') { @@ -154,6 +161,15 @@ async function loadPostContent(forumId, msgId) { return Data.Threads[forumId][msgId].thread.mMsg; } + // This is called straight from the view (forum_view.js, the 'Loading + // content...' branch), and the body stays null for the whole round trip, so + // without this guard every redraw fires another getForumContent for the same + // post -- and a redraw happens on each of the other posts' answers. An open + // thread would multiply one request per post into one per post per redraw. + const inFlightKey = forumId + '/' + msgId; + if (bodyRequestsInFlight.has(inFlightKey)) return null; + bodyRequestsInFlight.add(inFlightKey); + try { const res = await rs.rsJsonApiRequest('/rsgxsforums/getForumContent', { forumId, @@ -165,12 +181,22 @@ async function loadPostContent(forumId, msgId) { if (Data.Threads[forumId] && Data.Threads[forumId][msgId]) { Data.Threads[forumId][msgId].thread.mMsg = body; } + // The cached body is what stops the view from asking again, so the + // key is released only once it is in place. + bodyRequestsInFlight.delete(inFlightKey); m.redraw(); return body; } } catch (e) { console.error('[RS] Error loading post content:', forumId, msgId, e); } + // Failure. The view fires this again on EVERY redraw while the body stays + // null, and each completed request triggers a redraw of its own -- so + // releasing the key here (a finally) makes an unfetchable post a + // self-sustaining request loop at redraw rate. Keep the key, release it + // after a while: one request per post per five minutes is storm-proof, + // and a body the core could not return still gets another chance. + setTimeout(() => bodyRequestsInFlight.delete(inFlightKey), FAILED_BODY_RETRY_MS); return null; } @@ -251,22 +277,8 @@ const SearchBar = () => { }), }; }; -function popupmessage(message) { - const container = document.getElementById('modal-container'); - container.style.display = 'block'; - m.render( - container, - m('.modal-content', [ - m( - 'button.red', - { - onclick: () => (container.style.display = 'none'), - }, - m('i.fas.fa-times') - ), - message, - ]) - ); +function popupmessage(message, modalClass = '') { + widget.popupMessage(message, modalClass); } module.exports = { diff --git a/webui-src/app/forums/my_forums.js b/webui-src/app/forums/my_forums.js index 344a576..556f108 100644 --- a/webui-src/app/forums/my_forums.js +++ b/webui-src/app/forums/my_forums.js @@ -4,7 +4,12 @@ const util = require('forums/forums_util'); const Layout = () => { return { view: (v) => [ - m('.widget__heading', m('h3', 'My Forums')), + m('.widget__heading', [ + m('h3', 'My Forums'), + m('button.forums-heading-create[type=button][title=Create Forum][aria-label=Create Forum]', { + onclick: v.attrs.onCreateForum, + }, m('i.fas.fa-plus')), + ]), m('.widget__body', [ m( util.ForumTable, diff --git a/webui-src/app/forums/popular_forums.js b/webui-src/app/forums/popular_forums.js index f3cc591..4ce0eb4 100644 --- a/webui-src/app/forums/popular_forums.js +++ b/webui-src/app/forums/popular_forums.js @@ -12,13 +12,13 @@ const Layout = () => { v.attrs.list.map((forum) => m(util.ForumSummary, { details: forum, - category: 'PopularForums', + category: 'Popular', }) ), v.attrs.list.map((forum) => m(util.DisplayForumsFromList, { id: forum.mGroupId, - category: 'PopularForums', + category: 'Popular', }) ), ]) diff --git a/webui-src/app/forums/subscribed_forums.js b/webui-src/app/forums/subscribed_forums.js index 9411924..c3660c3 100644 --- a/webui-src/app/forums/subscribed_forums.js +++ b/webui-src/app/forums/subscribed_forums.js @@ -12,13 +12,13 @@ const Layout = () => { v.attrs.list.map((forum) => m(util.ForumSummary, { details: forum, - category: 'SubscribedForums', + category: 'Subscribed', }) ), v.attrs.list.map((forum) => m(util.DisplayForumsFromList, { id: forum.mGroupId, - category: 'SubscribedForums', + category: 'Subscribed', }) ), ]) diff --git a/webui-src/app/home.js b/webui-src/app/home.js index 461aaff..a608978 100644 --- a/webui-src/app/home.js +++ b/webui-src/app/home.js @@ -1,6 +1,7 @@ const m = require('mithril'); const rs = require('rswebui'); const widget = require('widgets'); +const NetworkData = require('network/network_data'); const logo = () => { return { @@ -21,22 +22,25 @@ const logo = () => { const webhelpConfirm = () => { return { - view: () => [ + view: () => m('.web-help-confirmation', [ m('h3', 'Confirmation'), m('hr'), m('p', 'Do you want this link to be handled by your system?'), - m('p', 'https://retrosharedocs.readthedocs.io/en/latest/'), + m('.web-help-confirmation__url', 'https://retrosharedocs.readthedocs.io/en/latest/'), m('p', 'Make sure this link has not been forged to drag you to a malicious website.'), m( 'button', { onclick: () => { window.open('https://retrosharedocs.readthedocs.io/en/latest/'); + // The documentation opens in another tab; leaving this one behind + // it means coming back to a dialog that has nothing left to ask. + widget.closePopupMessage(); }, }, 'Ok' ), - ], + ]), }; }; @@ -47,7 +51,7 @@ const webhelp = () => { '.webhelp', { onclick: () => { - widget.popupMessage(m(webhelpConfirm)); + widget.popupMessage(m(webhelpConfirm), 'web-help-modal'); }, }, [m('i.fas.fa-globe-europe'), m('p', 'Open Web Help')] @@ -66,7 +70,7 @@ const ConfirmCopied = () => { 'p[style="margin: 4px 0 12px"]', 'Now, you can paste and send it to your friend via email or some other way.' ), - m('button', {}, 'Ok'), + m('button', { onclick: widget.closePopupMessage }, 'Ok'), ], }; }; @@ -77,6 +81,59 @@ const retroshareId = () => { el.style.height = 'auto'; el.style.height = el.scrollHeight + 'px'; } + + function copyIdFallback() { + const field = document.getElementById('retroId'); + if (!field) return false; + field.select(); + // Deprecated, but the Clipboard API is only exposed in a secure context + // and the web UI is normally served over plain http on the LAN. + return document.execCommand('copy'); + } + + async function copyId(value) { + let copied; + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(value); + copied = true; + } catch (_) { + // Denied permission or an unfocused document: fall back rather than + // leaving the promise rejected and no feedback at all. + copied = copyIdFallback(); + } + } else { + copied = copyIdFallback(); + } + widget.popupMessage( + copied + ? m(ConfirmCopied) + : [ + m('h3', 'Copy failed'), + m('hr'), + m('p', 'Your browser refused the copy. Select the ID above and copy it by hand.'), + ], + 'copy-confirmation-modal' + ); + } + + async function shareId(value) { + if (navigator.share) { + try { + await navigator.share({ + title: 'My RetroShare ID', + text: value, + }); + return; + } catch (error) { + // Closing the native share sheet is intentional and needs no fallback. + if (error && error.name === 'AbortError') return; + } + } + + await copyId(value); + } + return { view(v) { return m('.retroshareID', [ @@ -94,41 +151,68 @@ const retroshareId = () => { v.attrs.ownCert ), m('i.fas.fa-copy', { - onclick: () => { - document.getElementById('retroId').select(); - document.execCommand('copy'); - widget.popupMessage(m(ConfirmCopied)); + role: 'button', + tabindex: 0, + title: 'Copy RetroShare ID', + 'aria-label': 'Copy RetroShare ID', + onclick: () => copyId(v.attrs.ownCert), + onkeydown: (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + copyId(v.attrs.ownCert); + } + }, + }), + m('i.fas.fa-share-alt', { + role: 'button', + tabindex: 0, + title: 'Share RetroShare ID', + 'aria-label': 'Share RetroShare ID', + onclick: () => shareId(v.attrs.ownCert), + onkeydown: (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + shareId(v.attrs.ownCert); + } }, }), - m('i.fas.fa-share-alt'), ]); }, }; }; function invalidCertPrompt() { - widget.popupMessage([m('h3', 'Error'), m('hr'), m('p', 'Not a valid Retroshare certificate.')]); + widget.popupMessage([m('h3', 'Invalid RetroShare ID'), m('hr'), m('p', 'Check the ID and try again.')]); +} + +async function refreshFriendLists(expectedGpgId) { + const expected = String(expectedGpgId || '').toLowerCase(); + const retryDelays = [0, 300, 1000]; + + for (const delay of retryDelays) { + if (delay) await new Promise((resolve) => setTimeout(resolve, delay)); + try { + await NetworkData.refreshGpgDetails({ force: true }); + if (!expected || NetworkData.gpgDetails[expected]) break; + } catch (_) { + // RetroShare may still be storing the imported certificate/location. + } + } + + rs.userList.loadUsers(); + m.redraw(); } function confirmAddPrompt(details, cert, long) { - widget.popupMessage([ - m('i.fas.fa-user-plus'), - m('h3', 'Make friend'), - m('p', 'Details about your friend'), - m('hr'), - m('ul', [ - m('li', 'Name: ' + details.name), - m('li', 'Location: ' + details.location + '(' + details.id + ')'), - m('li', details.isHiddenNode ? details.hiddenNodeAddress : details.extAddr), - ]), - - long - ? m( + const finishButton = long + ? m( 'button', { onclick: async () => { const res = await rs.rsJsonApiRequest('/rsPeers/loadCertificateFromString', { cert }); if (res.body.retval) { + NetworkData.rememberPendingFriend(details); + await refreshFriendLists(details.gpg_id || details.pgpId); widget.popupMessage([ m('h3', 'Successful'), m('hr'), @@ -145,15 +229,18 @@ function confirmAddPrompt(details, cert, long) { }, 'Finish' ) - : m( + : m( 'button', { onclick: async () => { const res = await rs.rsJsonApiRequest('/rsPeers/addSslOnlyFriend', { sslId: details.id, pgpId: details.gpg_id, + details, }); if (res.body.retval) { + NetworkData.rememberPendingFriend(details); + await refreshFriendLists(details.gpg_id || details.pgpId); widget.popupMessage([ m('h3', 'Successful'), m('hr'), @@ -169,16 +256,47 @@ function confirmAddPrompt(details, cert, long) { }, }, 'Finish' - ), - ]); + ); + + widget.popupMessage( + m('.friend-confirmation', [ + m('.friend-confirmation__heading', [ + m('i.fas.fa-user-plus'), + m('div', [m('h3', 'Make friend'), m('p', 'Confirm this is the person you want to add.')]), + ]), + m('.friend-confirmation__details', [ + m('.friend-confirmation__row', [ + m('span.friend-confirmation__label', 'Name'), + m('strong', details.name || 'Unknown'), + ]), + m('.friend-confirmation__row', [ + m('span.friend-confirmation__label', 'Location'), + m('span', details.location || 'Unknown'), + ]), + m('.friend-confirmation__row', [ + m('span.friend-confirmation__label', 'Peer ID'), + m('code', details.id || 'Unknown'), + ]), + m('.friend-confirmation__row', [ + m('span.friend-confirmation__label', details.isHiddenNode ? 'Hidden address' : 'Address'), + m('span', (details.isHiddenNode ? details.hiddenNodeAddress : details.extAddr) || 'Unknown'), + ]), + ]), + m('.friend-confirmation__actions', finishButton), + ]), + 'friend-confirmation-modal' + ); } async function addFriendFromCert(cert) { - const res = await rs.rsJsonApiRequest('/rsPeers/parseShortInvite', { invite: cert }); + const retroshareId = rs.cleanRetroshareId(cert); + if (!retroshareId) return; + + const res = await rs.rsJsonApiRequest('/rsPeers/parseShortInvite', { invite: retroshareId }); if (res.body.retval) { // console.log(res.body); - confirmAddPrompt(res.body.details, cert, false); + confirmAddPrompt(res.body.details, retroshareId, false); } else { rs.rsJsonApiRequest('/rsPeers/loadDetailsFromStringCert', { cert }, (data) => { if (!data.retval) { @@ -192,16 +310,16 @@ async function addFriendFromCert(cert) { const AddFriend = () => { let certificate = ''; + let fileName = ''; function loadFileContents(fileListObj) { - const file = fileListObj[0]; - if (file.type.indexOf('text') !== 0 || file.size === 0) { - // TODO handle incorrect file - return null; - } + const file = fileListObj && fileListObj[0]; + if (!file || file.size === 0) return; + const reader = new FileReader(); reader.onload = (e) => { certificate = e.target.result; + fileName = file.name; m.redraw(); }; reader.readAsText(file); @@ -209,19 +327,23 @@ const AddFriend = () => { return { view: (vnode) => - m('.widget', [ - m('h3', 'Add friend'), - m('h5', 'Did you recieve a certificate from a friend?'), - m('hr'), + m('.widget.add-friend-wizard', [ + m('.add-friend-wizard__heading', [ + m('i.fas.fa-user-plus'), + m('div', [ + m('h3', 'Add friend'), + m('p', 'Paste your friend\'s RetroShare ID to connect.'), + ]), + ]), m( '.cert-drop-zone', { isDragged: false, ondragenter: () => (vnode.state.isDragged = true), - ondragexit: () => (vnode.state.isDragged = false), + ondragleave: () => (vnode.state.isDragged = false), // Styling element when file is dragged - style: { border: vnode.state.isDragged && '5px solid #3ba4d7' }, + class: vnode.state.isDragged ? 'cert-drop-zone--active' : '', ondragover: (e) => e.preventDefault(), ondrop: (e) => { @@ -232,31 +354,35 @@ const AddFriend = () => { }, [ + m('label[for=friend-retroshare-id]', 'Friend\'s RetroShare ID'), m( - 'p[style="margin: 16px 0 4px"]', - 'You can directly upload or Drag and drop the file below' - ), - m('input[type=file][name=certificate]', { - onchange: (e) => { - // Note: this one is for the 'browse' button - loadFileContents(e.target.files || e.dataTransfer.files); - }, - }), - m('p[style="width: 100%; text-align: center; margin: 5px 0;"]', 'OR'), - m( - 'textarea[rows=5][placeholder="Paste the certificate here"][style="width: 100%; display: block; resize: vertical;"]', + 'textarea#friend-retroshare-id[rows=6][placeholder="Paste the RetroShare ID here"]', { - oninput: (e) => (certificate = e.target.value), + oninput: (e) => { + certificate = e.target.value; + fileName = ''; + }, value: certificate, } ), - m( - 'button[style="margin-top: 10px;"]', - { - onclick: () => addFriendFromCert(certificate), - }, - 'Add' - ), + m('.add-friend-wizard__divider', [m('span', 'or')]), + m('.add-friend-wizard__file', [ + m('label.button[for=friend-id-file]', [m('i.fas.fa-folder-open'), ' Choose ID file']), + m('input#friend-id-file[type=file][name=certificate][accept="text/*,.rsc,.txt"]', { + onchange: (e) => loadFileContents(e.target.files), + }), + m('span', fileName || 'You can also drop a text file here.'), + ]), + m('.add-friend-wizard__actions', [ + m( + 'button', + { + disabled: !certificate.trim(), + onclick: () => addFriendFromCert(certificate), + }, + [m('i.fas.fa-user-plus'), ' Add friend'] + ), + ]), ] ), ]), @@ -300,7 +426,7 @@ const Certificate = () => { 'button', { onclick: () => { - widget.popupMessage(m(AddFriend)); + widget.popupMessage(m(AddFriend), 'add-friend-modal'); }, }, 'Add Friend' diff --git a/webui-src/app/login.js b/webui-src/app/login.js index 3b967b2..140fad6 100644 --- a/webui-src/app/login.js +++ b/webui-src/app/login.js @@ -29,13 +29,18 @@ function loginComponent() { const urlParams = new URLSearchParams(window.location.search); let uname = urlParams.get('Username') || 'webui'; let passwd = urlParams.get('Password') || ''; + // Parenthesised on purpose: === binds tighter than ||, so without them the + // test read `(Url || protocol === 'file:') ? default : origin`, and any + // ?Url= given was thrown away in favour of the hardcoded default -- the one + // case the parameter exists for. let url = - urlParams.get('Url') || window.location.protocol === 'file:' + urlParams.get('Url') || + (window.location.protocol === 'file:' ? 'http://127.0.0.1:9092' : window.location.protocol + '//' + window.location.host + - window.location.pathname.replace('/index.html', ''); + window.location.pathname.replace('/index.html', '')); let withOptions = false; const logo = () => diff --git a/webui-src/app/mail/mail_compose.js b/webui-src/app/mail/mail_compose.js index d8cd0eb..f652af9 100644 --- a/webui-src/app/mail/mail_compose.js +++ b/webui-src/app/mail/mail_compose.js @@ -3,8 +3,10 @@ const rs = require('rswebui'); const widget = require('widgets'); const peopleUtil = require('people/people_util'); const chatEmoji = require('chat/chat_emoji'); +const renderIdentityTooltip = require('mail/mail_identity_tooltip'); const UserAvatarsCache = {}; +const RecipientDetailsCache = {}; const MAX_RECIPIENTS = 20; function formatFileSize(bytes) { @@ -23,6 +25,39 @@ const Layout = () => { let showEmojiPicker = false; let emojiSearch = ''; let emojiCategory = 'Smileys'; + let hoveredRecipient = null; + + function showRecipientTooltip(item, element) { + hoveredRecipient = { + id: item.mGroupId, + name: item.mGroupName, + rect: element.getBoundingClientRect(), + }; + + if (!RecipientDetailsCache[item.mGroupId]) { + rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id: item.mGroupId }, (data) => { + if (data && data.details) { + RecipientDetailsCache[item.mGroupId] = data.details; + UserAvatarsCache[item.mGroupId] = data.details.mAvatar; + m.redraw(); + } + }); + } + } + + function renderRecipientTooltip() { + if (!hoveredRecipient) return null; + const details = RecipientDetailsCache[hoveredRecipient.id]; + if (!details) return null; + + return renderIdentityTooltip({ + details, + gxsId: hoveredRecipient.id, + name: hoveredRecipient.name, + rect: hoveredRecipient.rect, + overlapAnchor: true, + }); + } const Data = { allUsers: [], @@ -488,13 +523,21 @@ const Layout = () => { m('input[type=text].recipients__input-field', { value: Data.recipients.to.inputVal, oninput: (e) => handleInput(e, 'to'), - placeholder: totalRecipients() >= MAX_RECIPIENTS ? 'Max recipients reached' : '', + placeholder: totalRecipients() >= MAX_RECIPIENTS + ? 'Max recipients reached' + : Data.recipients.to.sendList.length === 0 + ? 'Recipients' + : '', disabled: totalRecipients() >= MAX_RECIPIENTS, }), m('ul.recipients__input-list[autocomplete=off]', [ Data.recipients.to.inputList.length > 0 ? Data.recipients.to.inputList.map((item) => - m('li', { onclick: () => handleClick(item, 'to') }, item.mGroupName) + m('li', { + onclick: () => handleClick(item, 'to'), + onmouseenter: (event) => showRecipientTooltip(item, event.currentTarget), + onmouseleave: () => (hoveredRecipient = null), + }, item.mGroupName) ) : m('li', 'No Item'), ]), @@ -552,7 +595,11 @@ const Layout = () => { ? Data.recipients[recipientType].inputList.map((item) => m( 'li', - { onclick: () => handleClick(item, recipientType) }, + { + onclick: () => handleClick(item, recipientType), + onmouseenter: (event) => showRecipientTooltip(item, event.currentTarget), + onmouseleave: () => (hoveredRecipient = null), + }, item.mGroupName ) ) @@ -565,6 +612,7 @@ const Layout = () => { totalRecipients() >= MAX_RECIPIENTS && m('.compose-mail__recipient-limit', { style: { color: '#e67e22', fontSize: '0.85rem', padding: '0.25rem 0' } }, `Maximum of ${MAX_RECIPIENTS} recipients reached. Remove a recipient to add more.`), + renderRecipientTooltip(), ]), m('input.compose-mail__subject[type=text][placeholder=Subject]', { value: Data.subject, @@ -641,83 +689,65 @@ const Layout = () => { }), // Modern Mail Composer Bottom Toolbar - m('.mail-compose-toolbar', { - style: 'display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0.75rem; background: #ffffff; border: 1px solid #cbd5e1; border-top: 1px solid #e2e8f0; border-radius: 0 0 0.375rem 0.375rem; position: relative;' - }, [ - m('.toolbar-left', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [ - m('button.mail-compose-send-btn', { - style: 'display: flex; align-items: center; gap: 0.5rem; padding: 0.45rem 1.25rem; background: #019DFF; color: #ffffff; border: none; border-radius: 1.5rem; font-weight: 600; font-size: 0.9rem; cursor: pointer; transition: background 0.15s ease; box-shadow: 0 2px 4px rgba(1,157,255,0.25);', + m('.mail-compose-toolbar', [ + m('.toolbar-left', [ + m('button.mail-compose-send-btn[type=button]', { onclick: sendMail, }, [ m('span', 'Send'), - m('i.fas.fa-paper-plane', { style: 'font-size: 0.85rem;' }), + m('i.fas.fa-paper-plane'), ]), - m('.toolbar-divider', { style: 'width: 1px; height: 22px; background: #cbd5e1; margin: 0 0.25rem;' }), - m('button.mail-tool-btn', { - type: 'button', + m('.toolbar-divider'), + m('button.mail-tool-btn[type=button]', { title: 'Attach files', - style: 'width: 34px; height: 34px; border-radius: 50%; border: none; background: transparent; color: #475569; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s ease;', - onmouseenter: (e) => (e.currentTarget.style.background = '#f1f5f9'), - onmouseleave: (e) => (e.currentTarget.style.background = 'transparent'), onclick: () => { const input = document.getElementById('mail-file-attach'); if (input) input.click(); }, - }, m('i.fas.fa-paperclip', { style: 'font-size: 1.05rem;' })), - m('button.mail-tool-btn', { - type: 'button', + }, m('i.fas.fa-paperclip')), + m('button.mail-tool-btn[type=button]', { title: 'Insert image', - style: 'width: 34px; height: 34px; border-radius: 50%; border: none; background: transparent; color: #475569; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s ease;', - onmouseenter: (e) => (e.currentTarget.style.background = '#f1f5f9'), - onmouseleave: (e) => (e.currentTarget.style.background = 'transparent'), onclick: () => { const input = document.getElementById('mail-image-attach'); if (input) input.click(); }, - }, m('i.fas.fa-image', { style: 'font-size: 1.05rem;' })), - m('button.mail-tool-btn', { - type: 'button', + }, m('i.fas.fa-image')), + m('button.mail-tool-btn[type=button]', { title: 'Insert emoji', - style: `width: 34px; height: 34px; border-radius: 50%; border: none; background: ${showEmojiPicker ? '#e0f2fe' : 'transparent'}; color: ${showEmojiPicker ? '#0284c7' : '#475569'}; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s ease;`, + class: showEmojiPicker ? 'active' : '', onclick: () => (showEmojiPicker = !showEmojiPicker), - }, m('i.fas.fa-smile', { style: 'font-size: 1.05rem;' })), + }, m('i.fas.fa-smile')), ]), // Floating Emoji Picker Popover - showEmojiPicker && m('.mail-emoji-picker-popover', { - style: 'position: absolute; bottom: 50px; left: 130px; background: #ffffff; border: 1px solid #cbd5e1; border-radius: 0.5rem; box-shadow: 0 10px 25px -5px rgba(0,0,0,0.15), 0 8px 10px -6px rgba(0,0,0,0.1); width: 320px; max-height: 340px; z-index: 2000; display: flex; flex-direction: column; overflow: hidden;', + showEmojiPicker && m('.emoji-picker', { + style: 'position: absolute; bottom: 50px; left: 130px; z-index: 2000;', onclick: (e) => e.stopPropagation(), }, [ - m('.emoji-search-bar', { style: 'padding: 0.5rem; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; gap: 0.5rem;' }, [ - m('i.fas.fa-search', { style: 'color: #94a3b8; font-size: 0.85rem;' }), - m('input[type=text][placeholder=Search emoji...]', { - style: 'border: none; outline: none; width: 100%; font-size: 0.85rem;', + m('.emoji-search-row', [ + m('i.fas.fa-search.emoji-search-icon'), + m('input.emoji-search-input[type=text][placeholder=Search emoji...]', { value: emojiSearch, oninput: (e) => (emojiSearch = e.target.value), }), - emojiSearch && m('i.fas.fa-times', { - style: 'cursor: pointer; color: #94a3b8; font-size: 0.85rem;', + emojiSearch && m('button.emoji-search-clear[type=button]', { onclick: () => (emojiSearch = ''), - }), + }, m('i.fas.fa-times')), ]), - !emojiSearch && m('.emoji-cat-bar', { style: 'display: flex; background: #f8fafc; border-bottom: 1px solid #e2e8f0; padding: 0.25rem; overflow-x: auto;' }, + !emojiSearch && m('.emoji-categories', chatEmoji.EMOJI_CATEGORIES.map((c) => - m('button', { - style: `border: none; background: ${c === emojiCategory ? '#ffffff' : 'transparent'}; border-radius: 0.25rem; padding: 0.3rem 0.4rem; cursor: pointer; font-size: 1rem; box-shadow: ${c === emojiCategory ? '0 1px 2px rgba(0,0,0,0.1)' : 'none'};`, + m('button.emoji-cat-btn[type=button]' + (c === emojiCategory ? '.active' : ''), { title: c, onclick: () => (emojiCategory = c), }, chatEmoji.EMOJI_ICONS[c]) ) ), - m('.emoji-grid-body', { style: 'padding: 0.5rem; display: grid; grid-template-columns: repeat(7, 1fr); gap: 0.25rem; max-height: 230px; overflow-y: auto;' }, + m('.emoji-grid', (emojiSearch ? Object.values(chatEmoji.EMOJI_DATA).flat().filter((e) => e.includes(emojiSearch)) : (chatEmoji.EMOJI_DATA[emojiCategory] || []) ).map((e) => - m('button', { - style: 'border: none; background: transparent; font-size: 1.25rem; cursor: pointer; padding: 0.25rem; border-radius: 0.25rem; transition: background 0.15s ease;', - onmouseenter: (ev) => (ev.currentTarget.style.background = '#f1f5f9'), - onmouseleave: (ev) => (ev.currentTarget.style.background = 'transparent'), + m('button.emoji-btn[type=button]', { onclick: () => { insertEmoji(e); showEmojiPicker = false; @@ -725,7 +755,7 @@ const Layout = () => { }, e) ) ), - ]) + ]), ]), ]), ]), diff --git a/webui-src/app/mail/mail_identity_tooltip.js b/webui-src/app/mail/mail_identity_tooltip.js new file mode 100644 index 0000000..15614b5 --- /dev/null +++ b/webui-src/app/mail/mail_identity_tooltip.js @@ -0,0 +1,47 @@ +const m = require('mithril'); +const rs = require('rswebui'); +const peopleUtil = require('people/people_util'); + +function renderIdentityTooltip({ details, gxsId, name, rect, overlapAnchor = false }) { + if (!details || !rect) return null; + + const avatar = details.mAvatar && details.mAvatar.base64 ? details.mAvatar.base64 : details.mAvatar; + const votes = details.mReputation + ? (details.mReputation.mFriendsPositiveVotes || 0) - + (details.mReputation.mFriendsNegativeVotes || 0) + : 0; + const tooltipWidth = 280; + const gap = 10; + let left = overlapAnchor ? rect.left + 90 : rect.right + gap; + if (left + tooltipWidth > window.innerWidth - gap) left = rect.left - tooltipWidth - gap; + if (left < gap) left = gap; + let top = overlapAnchor ? rect.top - 10 : rect.top; + if (top + 160 > window.innerHeight) top = window.innerHeight - 170; + if (top < gap) top = gap; + + return m('.user-tooltip', { style: { top: `${top}px`, left: `${left}px` } }, [ + m('.tooltip-avatar', m(peopleUtil.UserAvatar, { + avatar, + firstLetter: (name || '?').slice(0, 1).toUpperCase(), + identityId: gxsId, + size: 56, + isSquare: true, + })), + m('.tooltip-details', [ + m('.tooltip-row', [m('span.tooltip-label', 'Identity name: '), m('span.tooltip-value', name)]), + m('.tooltip-row', [m('span.tooltip-label', 'Identity Id: '), m('span.tooltip-value.tooltip-id', gxsId)]), + details.mPgpId && details.mPgpId !== '0000000000000000' && m('.tooltip-row', [ + m('span.tooltip-label', 'Node: '), + m('span.tooltip-value', `${rs.userList.username(details.mPgpId) || name} [${details.mPgpId}]`), + ]), + m('.tooltip-row', [ + m('span.tooltip-label', 'Votes: '), + m('span.tooltip-value', { + style: { color: votes >= 0 ? '#008000' : '#cc0000', fontWeight: 'bold' }, + }, `${votes >= 0 ? '+' : ''}${votes}`), + ]), + ]), + ]); +} + +module.exports = renderIdentityTooltip; diff --git a/webui-src/app/mail/mail_resolver.js b/webui-src/app/mail/mail_resolver.js index c06b5fc..c5778e3 100644 --- a/webui-src/app/mail/mail_resolver.js +++ b/webui-src/app/mail/mail_resolver.js @@ -19,6 +19,52 @@ const Messages = { personal: [], todo: [], later: [], + refreshTimer: null, + unread: 0, + + recountUnread() { + Messages.unread = (Messages.inbox || []).filter((msg) => { + const status = msg.msgflags & 0xf0; + return ( + (status === util.RS_MSG_NEW || status === util.RS_MSG_UNREAD_BY_USER) && + !(msg.msgflags & util.RS_MSG_TRASH) && + !(msg.msgflags & util.RS_MSG_SPAM) + ); + }).length; + return Messages.unread; + }, + unreadCount() { + return Messages.unread; + }, + refreshSoon() { + if (Messages.refreshTimer) return; + Messages.refreshTimer = setTimeout(() => { + Messages.refreshTimer = null; + Messages.load(); + }, 250); + }, + markReadLocally(msgId) { + if (!msgId) return; + let changed = false; + Messages.all.forEach((msg) => { + if (msg.msgId === msgId) { + if (msg.msgflags & (util.RS_MSG_NEW | util.RS_MSG_UNREAD_BY_USER)) { + msg.msgflags &= ~(util.RS_MSG_NEW | util.RS_MSG_UNREAD_BY_USER); + changed = true; + } + } + }); + if (util.MessageCache && util.MessageCache[msgId] && util.MessageCache[msgId].msgflags !== undefined) { + if (util.MessageCache[msgId].msgflags & (util.RS_MSG_NEW | util.RS_MSG_UNREAD_BY_USER)) { + util.MessageCache[msgId].msgflags &= ~(util.RS_MSG_NEW | util.RS_MSG_UNREAD_BY_USER); + changed = true; + } + } + if (changed) { + Messages.recountUnread(); + util.triggerMessageUpdated(msgId, util.RS_MSG_NEW, false); + } + }, load() { rs.rsJsonApiRequest('/rsMail/getMessageSummaries', { box: util.BOX_ALL }, (data) => { if (data && data.msgList) { @@ -33,13 +79,16 @@ const Messages = { (msg) => (msg.msgflags & util.RS_MSG_BOXMASK) === util.RS_MSG_OUTBOX ); Messages.drafts = Messages.all.filter( - (msg) => (msg.msgflags & util.RS_MSG_BOXMASK) === util.RS_MSG_DRAFTBOX + (msg) => + (msg.msgflags & util.RS_MSG_BOXMASK) === util.RS_MSG_DRAFTBOX || + (msg.msgflags & 0x05) === 0x05 || + (msg.msgflags & 0x04) !== 0 || + (msg.msgflags & 0x08) !== 0 ); Messages.trash = Messages.all.filter((msg) => msg.msgflags & util.RS_MSG_TRASH); Messages.starred = Messages.all.filter((msg) => msg.msgflags & util.RS_MSG_STAR); Messages.system = Messages.all.filter((msg) => msg.msgflags & util.RS_MSG_SYSTEM); Messages.spam = Messages.all.filter((msg) => msg.msgflags & util.RS_MSG_SPAM); - Messages.attachment = Messages.all.filter((msg) => msg.count); Messages.important = Messages.all.filter( @@ -57,188 +106,490 @@ const Messages = { Messages.later = Messages.all.filter( (msg) => msg.msgtags && msg.msgtags.includes(util.RS_MSGTAGTYPE_LATER) ); + Messages.recountUnread(); + m.redraw(); } }); }, }; -const sections = { - inbox: require('mail/mail_inbox'), - outbox: require('mail/mail_outbox'), - drafts: require('mail/mail_draftbox'), - sent: require('mail/mail_sentbox'), - trash: require('mail/mail_trashbox'), -}; -const sectionsquickview = { - starred: require('mail/mail_starred'), - system: require('mail/mail_system'), - spam: require('mail/mail_spam'), - attachment: require('mail/mail_attachment'), - important: require('mail/mail_important'), - work: require('mail/mail_work'), - todo: require('mail/mail_todo'), - later: require('mail/mail_later'), - personal: require('mail/mail_personal'), -}; -const tagselect = { - showval: 'Tags', - opts: ['Tags', 'Important', 'Work', 'Personal'], -}; -const Layout = () => { +util.onMessageUpdated((msgId, flag, isSet) => { + Messages.all.forEach((msg) => { + if (msg.msgId === msgId) { + if (isSet) { + msg.msgflags |= flag; + } else { + msg.msgflags &= ~flag; + if (flag === util.RS_MSG_NEW || flag === util.RS_MSG_UNREAD_BY_USER) { + msg.msgflags &= ~(util.RS_MSG_NEW | util.RS_MSG_UNREAD_BY_USER); + } + } + } + }); + if (util.MessageCache && util.MessageCache[msgId] && util.MessageCache[msgId].msgflags !== undefined) { + if (isSet) { + util.MessageCache[msgId].msgflags |= flag; + } else { + util.MessageCache[msgId].msgflags &= ~flag; + if (flag === util.RS_MSG_NEW || flag === util.RS_MSG_UNREAD_BY_USER) { + util.MessageCache[msgId].msgflags &= ~(util.RS_MSG_NEW | util.RS_MSG_UNREAD_BY_USER); + } + } + } + Messages.spam = Messages.all.filter((msg) => msg.msgflags & util.RS_MSG_SPAM); + Messages.starred = Messages.all.filter((msg) => msg.msgflags & util.RS_MSG_STAR); + Messages.recountUnread(); + Messages.refreshSoon(); + m.redraw(); +}); + +const folderConfigs = [ + { id: 'inbox', title: 'Inbox', icon: 'fa-inbox' }, + { id: 'sent', title: 'Sent', icon: 'fa-envelope-open' }, + { id: 'drafts', title: 'Drafts', icon: 'fa-edit' }, + { id: 'outbox', title: 'Outbox', icon: 'fa-envelope-open-text' }, + { id: 'starred', title: 'Starred', icon: 'fa-star' }, + { id: 'trash', title: 'Trash', icon: 'fa-trash-alt' }, + { id: 'spam', title: 'Spam', icon: 'fa-fire' }, + { id: 'attachment', title: 'Attachments', icon: 'fa-paperclip' }, + { id: 'system', title: 'System', icon: 'fa-bell' }, +]; + +const categoryConfigs = [ + { id: 'important', title: 'Important', color: '#ef4444', tagId: 1 }, + { id: 'work', title: 'Work', color: '#f97316', tagId: 2 }, + { id: 'personal', title: 'Personal', color: '#22c55e', tagId: 3 }, + { id: 'todo', title: 'Todo', color: '#3b82f6', tagId: 4 }, + { id: 'later', title: 'Later', color: '#a855f7', tagId: 5 }, +]; + +const tagFilterOptions = [ + { label: '🏷️ Filter by Tag...', val: '' }, + { label: '🔴 Important', val: '1' }, + { label: '🟠 Work', val: '2' }, + { label: '🟢 Personal', val: '3' }, + { label: '🔵 Todo', val: '4' }, + { label: '🟣 Later', val: '5' }, +]; + +const MailComponent = () => { let showCompose = false; - // setFunction like react to show/hide popup + let mobileNavOpen = false; + // Which message the auto-mark-read already ran for. Running it on EVERY + // redraw re-cleared the local unread bits the instant "Mark as unread" + // set them, and the change notification scheduled a summaries reload + // whose redraw re-triggered it: a full getMessageSummaries fetch every + // ~250 ms for as long as the message stayed open. + let lastAutoReadMsgId = null; + const autoMarkRead = (msgId) => { + if (!msgId) { + lastAutoReadMsgId = null; + return; + } + if (msgId === lastAutoReadMsgId) return; + lastAutoReadMsgId = msgId; + Messages.markReadLocally(msgId); + }; + let searchQuery = ''; + let filterUnreadOnly = false; + let selectedTagFilter = ''; + let viewMode = localStorage.getItem('rs_mail_view_mode') || 'cards'; + // Cards fetch a body each (for the snippet): unpaginated, a large folder + // fired one getMessage per mail in one burst. Same page size as the table. + const CARD_PAGE_SIZE = 50; + let cardPage = 0; + let cardPageTab = null; + function setShowCompose(bool) { showCompose = bool; } - return { - oninit: () => Messages.load(), - view: (vnode) => { - const sectionsSize = { - inbox: (Messages.inbox || []).length, - outbox: (Messages.outbox || []).length, - drafts: (Messages.drafts || []).length, - sent: (Messages.sent || []).length, - trash: (Messages.trash || []).length, - }; - const sectionsQuickviewSize = { - starred: (Messages.starred || []).length, - system: (Messages.system || []).length, - spam: (Messages.spam || []).length, - attachment: (Messages.attachment || []).length, - important: (Messages.important || []).length, - work: (Messages.work || []).length, - todo: (Messages.todo || []).length, - later: (Messages.later || []).length, - personal: (Messages.personal || []).length, - }; - return [ - m('.side-bar', [ - m( - 'button.mail-compose-btn', - { - style: 'display: flex; align-items: center; justify-content: center; gap: 0.5rem;', - onclick: () => setShowCompose(true), - }, - [m('i.fas.fa-pen'), 'Compose'] - ), - m(util.Sidebar, { - tabs: Object.keys(sections), - size: sectionsSize, - baseRoute: '/mail/', - }), - m(util.SidebarQuickView, { - tabs: Object.keys(sectionsquickview), - size: sectionsQuickviewSize, - baseRoute: '/mail/', - }), - ]), - m( - '.node-panel', - m('.widget', [ - m.route.get().split('/').length < 4 && - m('.top-heading', [ - m( - 'select.mail-tag', - { - value: tagselect.showval, - onchange: (e) => (tagselect.showval = tagselect.opts[e.target.selectedIndex]), - }, - [tagselect.opts.map((opt) => m('option', { value: opt }, opt.toLocaleString()))] - ), - m(util.SearchBar, { list: {} }), - ]), - vnode.children, - ]) - ), - showCompose && m( - '.composePopupOverlay#mailComposerPopup', - m( - '.composePopup', - m(compose, { msgType: 'compose', setShowCompose }), - m('button.red.close-btn', { onclick: () => setShowCompose(false) }, m('i.fas.fa-times')) - ) - ), - ]; + return { + oninit: (vnode) => { + Messages.load(); + autoMarkRead(vnode.attrs.msgId); + }, + onupdate: (vnode) => { + autoMarkRead(vnode.attrs.msgId); }, - }; -}; - -const tabConfig = { - inbox: { title: 'Inbox', category: 'inbox' }, - outbox: { title: 'Outbox', category: 'outbox' }, - drafts: { title: 'Draft', category: 'drafts' }, - sent: { title: 'Sent', category: 'sent' }, - trash: { title: 'Trash', category: 'trash' }, - starred: { title: 'Starred', category: 'starred' }, - system: { title: 'System', category: 'system' }, - spam: { title: 'Spam', category: 'spam' }, - important: { title: 'Important', category: 'important' }, - work: { title: 'Work', category: 'work' }, - todo: { title: 'Todo', category: 'todo' }, - later: { title: 'Later', category: 'later' }, - personal: { title: 'Personal', category: 'personal' }, -}; - -const GenericMailList = () => { - return { view: (vnode) => { - const { title, category, list } = vnode.attrs; - return [ - m('.widget__heading', m('h3', title)), - m('.widget__body', [ - m( - util.Table, + const activeTab = vnode.attrs.tab || 'inbox'; + const activeMsgId = vnode.attrs.msgId || null; + + const currentFolder = + folderConfigs.find((f) => f.id === activeTab) || + categoryConfigs.find((c) => c.id === activeTab) || + { title: activeTab.charAt(0).toUpperCase() + activeTab.slice(1), icon: 'fa-envelope' }; + + let list = Messages[activeTab] || []; + + // Filter by Unread + if (filterUnreadOnly) { + list = list.filter((msg) => { + const status = msg.msgflags & 0xf0; + return ( + (status === util.RS_MSG_NEW || status === util.RS_MSG_UNREAD_BY_USER) && + !(msg.msgflags & util.RS_MSG_TRASH) && + !(msg.msgflags & util.RS_MSG_SPAM) + ); + }); + } + + // Filter by tag + if (selectedTagFilter) { + const tId = parseInt(selectedTagFilter, 10); + list = list.filter((msg) => msg.msgtags && msg.msgtags.includes(tId)); + } + + // Filter by search query + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase().trim(); + list = list.filter((msg) => { + const title = (msg.title || '').toLowerCase(); + return title.includes(q); + }); + } + + // Sort + const sortedList = util.sortList(list); + + function selectMessage(id) { + Messages.markReadLocally(id); + // No markMessageRead here: MessageView.loadMail() sends it when the + // message opens -- both did, two server calls and two summaries + // reloads per click. + m.route.set('/mail/:tab/:msgId', { tab: activeTab, msgId: id }); + } + + function deselectMessage() { + m.route.set('/mail/:tab', { tab: activeTab }); + } + + return m('.mail-outlook-container', [ + // Backdrop overlay for mobile drawer + mobileNavOpen && + m('.mail-drawer-backdrop', { + onclick: () => { + mobileNavOpen = false; + }, + }), + + // 1. LEFT PANE: Folders & Categories Navigation + m('.mail-folders-pane', { class: mobileNavOpen ? 'mail-folders-pane--open' : '' }, [ + m('.mail-folders-header', [ m( - 'tbody', - list.map((msg) => - m(util.MessageSummary, { - key: msg.msgId, - details: msg, - category, - }) - ) + 'button.mail-compose-btn[type=button]', + { + onclick: () => { + mobileNavOpen = false; + setShowCompose(true); + }, + }, + [m('i.fas.fa-edit'), m('span', 'New email')] + ), + ]), + + m('.mail-nav-scroll', [ + m('.mail-nav-section-title', 'Folders'), + m( + '.mail-nav-list', + folderConfigs.map((folder) => { + const isActive = activeTab === folder.id; + const count = (Messages[folder.id] || []).length; + const unread = folder.id === 'inbox' ? Messages.unreadCount() : 0; + return m( + m.route.Link, + { + key: folder.id, + href: `/mail/${folder.id}`, + class: `mail-nav-item ${isActive ? 'active' : ''}`, + onclick: () => { + mobileNavOpen = false; + }, + }, + [ + m('i.fas', { + class: folder.icon, + }), + m('span.mail-nav-label', folder.title), + unread > 0 + ? m('span.mail-nav-badge.mail-nav-badge--unread', unread) + : count > 0 + ? m('span.mail-nav-badge', count) + : null, + ] + ); + }) + ), + + m('.mail-nav-section-title', 'Categories'), + m( + '.mail-nav-list', + categoryConfigs.map((cat) => { + const isActive = activeTab === cat.id; + const count = (Messages[cat.id] || []).length; + return m( + m.route.Link, + { + key: cat.id, + href: `/mail/${cat.id}`, + class: `mail-nav-item ${isActive ? 'active' : ''}`, + onclick: () => { + mobileNavOpen = false; + }, + }, + [ + m('span.mail-category-dot', { style: `background-color: ${cat.color};` }), + m('span.mail-nav-label', cat.title), + count > 0 && m('span.mail-nav-badge', count), + ] + ); + }) + ), + ]), + ]), + + // 2. MIDDLE PANE: Message List + m( + '.mail-list-pane', + { + class: [ + activeMsgId ? 'mail-list-pane--mobile-hidden' : '', + viewMode === 'table' ? 'mail-list-pane--table-view' : '', + viewMode === 'table' && activeMsgId ? 'mail-list-pane--table-selected-hidden' : '', + ] + .filter(Boolean) + .join(' '), + }, + [ + m('.mail-list-header', [ + m('.mail-list-header-top', [ + m( + 'button.mail-mobile-nav-toggle[type=button][aria-label=Open navigation]', + { + onclick: () => { + mobileNavOpen = !mobileNavOpen; + }, + }, + m('i.fas.fa-bars') + ), + m('.mail-folder-title-row', [ + m('i.fas', { + class: currentFolder.icon || 'fa-envelope', + }), + m('h2.mail-folder-heading', currentFolder.title), + m('span.mail-folder-count', `(${sortedList.length})`), + ]), + m('.mail-view-toggle', [ + m( + 'button.mail-toggle-btn[type=button]', + { + class: viewMode === 'cards' ? 'active' : '', + title: 'Card view', + onclick: () => { + viewMode = 'cards'; + localStorage.setItem('rs_mail_view_mode', 'cards'); + deselectMessage(); + }, + }, + m('i.fas.fa-th-large') + ), + m( + 'button.mail-toggle-btn[type=button]', + { + class: viewMode === 'table' ? 'active' : '', + title: 'Table view', + onclick: () => { + viewMode = 'table'; + localStorage.setItem('rs_mail_view_mode', 'table'); + deselectMessage(); + }, + }, + m('i.fas.fa-bars') + ), + ]), + ]), + + // Search bar + m('.mail-search-wrapper', [ + m('i.fas.fa-search.mail-search-icon'), + m('input.mail-search-input[type=text][placeholder=Search subject...]', { + value: searchQuery, + oninput: (e) => { + searchQuery = e.target.value; + }, + }), + searchQuery && + m( + 'button.mail-search-clear[type=button][title=Clear search]', + { + onclick: () => { + searchQuery = ''; + }, + }, + m('i.fas.fa-times') + ), + ]), + + // Filter subheader + m('.mail-filter-row', [ + m('.mail-filter-tabs', [ + m( + 'button.mail-filter-pill[type=button]', + { + class: !filterUnreadOnly ? 'active' : '', + onclick: () => { + filterUnreadOnly = false; + }, + }, + 'All' + ), + m( + 'button.mail-filter-pill[type=button]', + { + class: filterUnreadOnly ? 'active' : '', + onclick: () => { + filterUnreadOnly = true; + }, + }, + [ + 'Unread', + activeTab === 'inbox' && Messages.unreadCount() > 0 && + m('span.mail-unread-pill-count', Messages.unreadCount()), + ] + ), + ]), + m( + 'select.mail-tag-select', + { + value: selectedTagFilter, + onchange: (e) => { + selectedTagFilter = e.target.value; + }, + }, + tagFilterOptions.map((opt) => m('option', { value: opt.val }, opt.label)) + ), + ]), + ]), + + m('.mail-list-body', [ + sortedList.length === 0 + ? m('.mail-empty-state', [ + m('i.fas.fa-inbox.mail-empty-icon'), + m('h4', 'No messages'), + m('p', searchQuery || filterUnreadOnly || selectedTagFilter ? 'No emails match your filter criteria.' : 'This folder is currently empty.'), + ]) + : viewMode === 'cards' + ? (() => { + if (cardPageTab !== activeTab) { + cardPageTab = activeTab; + cardPage = 0; + } + const totalCardPages = Math.ceil(sortedList.length / CARD_PAGE_SIZE) || 1; + if (cardPage >= totalCardPages) cardPage = totalCardPages - 1; + const pageStart = cardPage * CARD_PAGE_SIZE; + const pagedCards = sortedList.slice(pageStart, pageStart + CARD_PAGE_SIZE); + return [ + m( + '.mail-cards-container', + pagedCards.map((msg) => + m(util.MessageCard, { + key: msg.msgId, + msg, + isSelected: msg.msgId === activeMsgId, + category: activeTab, + onSelect: (id) => selectMessage(id), + }) + ) + ), + sortedList.length > CARD_PAGE_SIZE && m('.mail-cards-pagination', [ + m('button[type=button]', { + disabled: cardPage === 0, + onclick: () => { cardPage -= 1; }, + }, m('i.fas.fa-chevron-left')), + m('span', `${cardPage + 1} / ${totalCardPages}`), + m('button[type=button]', { + disabled: cardPage >= totalCardPages - 1, + onclick: () => { cardPage += 1; }, + }, m('i.fas.fa-chevron-right')), + ]), + ]; + })() + : m( + util.Table, + m( + 'tbody', + sortedList.map((msg) => + m(util.MessageSummary, { + key: msg.msgId, + details: msg, + category: activeTab, + isSelected: msg.msgId === activeMsgId, + onSelect: (id) => selectMessage(id), + }) + ) + ) + ), + ]), + ] + ), + + // 3. RIGHT PANE: Reading Pane + (viewMode === 'cards' || activeMsgId) && + m( + '.mail-reading-pane', + { + class: [ + !activeMsgId ? 'mail-reading-pane--mobile-hidden' : '', + viewMode === 'table' ? 'mail-reading-pane--table-view' : '', + ] + .filter(Boolean) + .join(' '), + }, + [ + activeMsgId + ? m(util.MessageView, { + key: activeMsgId, + msgId: activeMsgId, + onBack: deselectMessage, + onDeleted: () => { + deselectMessage(); + Messages.load(); + }, + onRefresh: () => { + Messages.load(); + }, + }) + : m(util.ReadingPanePlaceholder), + ] + ), + + // Mobile Compose FAB + m( + 'button.mobile-fab-compose', + { + title: 'Compose Mail', + onclick: () => setShowCompose(true), + }, + m('i.fas.fa-pen') + ), + + // Compose Modal Overlay + showCompose && + m( + '.composePopupOverlay#mailComposerPopup', + m( + '.composePopup', + m(compose, { msgType: 'compose', setShowCompose }), + m('button.red.close-btn', { onclick: () => setShowCompose(false) }, m('i.fas.fa-times')) ) ), - ]), - ]; + ]); }, }; }; module.exports = { - view: ({ attrs, attrs: { tab, msgId } }) => { - // TODO: utilize multiple routing params - if (Object.prototype.hasOwnProperty.call(attrs, 'msgId')) { - return m(Layout, m(util.MessageView, { msgId })); - } - - if (tab === 'attachment') { - return m( - Layout, - m(sectionsquickview.attachment, { - list: util.sortList(Messages[tab]), - }) - ); - } - - const config = tabConfig[tab]; - if (config) { - return m( - Layout, - m(GenericMailList, { - title: config.title, - category: config.category, - list: util.sortList(Messages[tab]), - }) - ); - } - - return m( - Layout, - m(sections[tab] || sectionsquickview[tab], { - list: util.sortList(Messages[tab]), - }) - ); - }, + Messages, + view: ({ attrs }) => m(MailComponent, attrs), }; diff --git a/webui-src/app/mail/mail_util.js b/webui-src/app/mail/mail_util.js index f4acb68..fab6eec 100644 --- a/webui-src/app/mail/mail_util.js +++ b/webui-src/app/mail/mail_util.js @@ -4,6 +4,7 @@ const util = require('files/files_util'); const widget = require('widgets'); const peopleUtil = require('people/people_util'); const compose = require('mail/mail_compose'); +const renderIdentityTooltip = require('mail/mail_identity_tooltip'); // rsmail.h const RS_MSG_BOXMASK = 0x000f; @@ -41,49 +42,51 @@ const MailHoverState = { hoveredUser: null, }; +const messageUpdateListeners = []; +function onMessageUpdated(callback) { + if (typeof callback === 'function') messageUpdateListeners.push(callback); +} +function triggerMessageUpdated(msgId, flag, isSet) { + messageUpdateListeners.forEach((cb) => { + try { + cb(msgId, flag, isSet); + } catch (e) { + /* ignore */ + } + }); +} + +function markMessageRead(msgId, onDone) { + if (!msgId) return; + if (MessageCache[msgId] && MessageCache[msgId].msgflags !== undefined) { + MessageCache[msgId].msgflags &= ~(RS_MSG_NEW | RS_MSG_UNREAD_BY_USER); + } + triggerMessageUpdated(msgId, RS_MSG_NEW, false); + rs.rsJsonApiRequest( + '/rsMail/MessageRead', + { msgId, unreadByUser: false }, + (data, success) => { + if (MessageCache[msgId] && MessageCache[msgId].msgflags !== undefined) { + MessageCache[msgId].msgflags &= ~(RS_MSG_NEW | RS_MSG_UNREAD_BY_USER); + } + triggerMessageUpdated(msgId, RS_MSG_NEW, false); + if (onDone) onDone(Boolean(success && (!data || data.retval !== false))); + } + ); +} + function renderMailUserTooltip() { if (!MailHoverState.hoveredUser) return null; const hUser = MailHoverState.hoveredUser; const details = MailGxsDetailsCache[hUser.gxsId]; if (!details) return null; - const avatar = details.mAvatar && details.mAvatar.base64 ? details.mAvatar.base64 : null; - const firstLetter = (hUser.name || '?').slice(0, 1).toUpperCase(); - const votes = details.mReputation - ? ((details.mReputation.mFriendsPositiveVotes || 0) - (details.mReputation.mFriendsNegativeVotes || 0)) - : 0; - - const top = hUser.rect.top - 10; - const left = Math.min(Math.max(hUser.rect.left, 140), window.innerWidth - 280); - - return m('.user-tooltip', { - style: { - position: 'fixed', - top: `${top}px`, - left: `${left}px`, - transform: 'translateY(-100%)', - zIndex: 10000, - } - }, [ - m('.tooltip-avatar', m(peopleUtil.UserAvatar, { avatar, firstLetter, identityId: hUser.gxsId, size: 64 })), - m('.tooltip-details', [ - m('.tooltip-row', [m('span.tooltip-label', 'Identity name: '), m('span.tooltip-value', hUser.name)]), - m('.tooltip-row', [m('span.tooltip-label', 'Identity Id: '), m('span.tooltip-value.tooltip-id', hUser.gxsId)]), - details.mPgpId && details.mPgpId !== '0000000000000000' && m('.tooltip-row', [ - m('span.tooltip-label', 'Node: '), - m('span.tooltip-value', `${rs.userList.username(details.mPgpId) || hUser.name} [${details.mPgpId}]`) - ]), - m('.tooltip-row', [ - m('span.tooltip-label', 'Votes: '), - m('span.tooltip-value', { - style: { - color: votes >= 0 ? '#22c55e' : '#ef4444', - fontWeight: 'bold' - } - }, (votes >= 0 ? '+' : '') + votes) - ]) - ]) - ]); + return renderIdentityTooltip({ + details, + gxsId: hUser.gxsId, + name: hUser.name, + rect: hUser.rect, + }); } const tagTypesCache = {}; @@ -113,6 +116,22 @@ function loadTagTypes() { } loadTagTypes(); +function formatMailDate(ts) { + if (!ts) return ''; + const date = new Date(ts * 1000); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + if (isToday) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + const isThisYear = date.getFullYear() === now.getFullYear(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + if (isThisYear) { + return `${date.getDate()} ${months[date.getMonth()]}`; + } + return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear().toString().slice(2)}`; +} + // Utility functions const humanReadableSize = (fileSize) => { return fileSize / 1024 > 1024 @@ -122,16 +141,37 @@ const humanReadableSize = (fileSize) => { : (fileSize / 1024).toFixed(2) + ' KB'; }; +const stripHtmlForSnippet = (html) => { + if (!html) return ''; + return html + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/head>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\.[A-Za-z0-9_-]+\s*\{[^}]*\}/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, '\'') + .replace(/&[a-z0-9#]+;/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +}; + // Layouts const MessageSummary = () => { let details = {}; let files; let isStarred = false; - let msgStatus = ''; + let isSpam = false; let fromUserInfo; function starMessage(e) { isStarred = !isStarred; rs.rsJsonApiRequest('/rsMail/MessageStar', { msgId: details.msgId, mark: isStarred }); + triggerMessageUpdated(details.msgId, RS_MSG_STAR, isStarred); // Stop event bubbling, both functions for supporting IE & FF e.stopImmediatePropagation(); e.preventDefault(); @@ -147,8 +187,12 @@ const MessageSummary = () => { details.msgtags = v.attrs.details.msgtags; files = details.files; isStarred = (details.msgflags & 0xf00) === RS_MSG_STAR; - const flag = details.msgflags & 0xf0; - msgStatus = flag === RS_MSG_NEW || flag === RS_MSG_UNREAD_BY_USER ? 'unread' : 'read'; + isSpam = Boolean(details.msgflags & RS_MSG_SPAM); + if (v.attrs.details && v.attrs.details.msgflags !== undefined) { + details.msgflags = v.attrs.details.msgflags; + isStarred = (details.msgflags & 0xf00) === RS_MSG_STAR; + isSpam = Boolean(details.msgflags & RS_MSG_SPAM); + } MessageCache[v.attrs.details.msgId] = details; } }) @@ -168,18 +212,71 @@ const MessageSummary = () => { } }); }, - view: (v) => - m( + // The reading pane's star/spam toggles refresh the summaries; the row's + // closure flags must follow the refreshed attrs or the icon stays stale + // until a remount. + onupdate: (v) => { + if (v.attrs.details && v.attrs.details.msgflags !== undefined) { + isStarred = (v.attrs.details.msgflags & 0xf00) === RS_MSG_STAR; + isSpam = Boolean(v.attrs.details.msgflags & RS_MSG_SPAM); + } + }, + view: (v) => { + const spamActive = isSpam || Boolean((details.msgflags || v.attrs.details.msgflags) & RS_MSG_SPAM); + function spamMessage(e) { + isSpam = !spamActive; + const targetId = details.msgId || (v.attrs.details && v.attrs.details.msgId); + if (details.msgflags !== undefined) { + if (isSpam) details.msgflags |= RS_MSG_SPAM; + else details.msgflags &= ~RS_MSG_SPAM; + } + if (v.attrs.details && v.attrs.details.msgflags !== undefined) { + if (isSpam) v.attrs.details.msgflags |= RS_MSG_SPAM; + else v.attrs.details.msgflags &= ~RS_MSG_SPAM; + } + if (MessageCache[targetId]) { + if (isSpam) MessageCache[targetId].msgflags |= RS_MSG_SPAM; + else MessageCache[targetId].msgflags &= ~RS_MSG_SPAM; + } + rs.rsJsonApiRequest('/rsMail/MessageJunk', { msgId: targetId, mark: isSpam }); + triggerMessageUpdated(targetId, RS_MSG_SPAM, isSpam); + e.stopImmediatePropagation(); + e.preventDefault(); + m.redraw(); + } + + const summaryMsg = v.attrs.details; + const currentDetails = MessageCache[summaryMsg.msgId] || details || summaryMsg; + const currentFlags = summaryMsg.msgflags !== undefined ? summaryMsg.msgflags : (currentDetails.msgflags || 0); + if (MessageCache[summaryMsg.msgId] && summaryMsg.msgflags !== undefined) { + MessageCache[summaryMsg.msgId].msgflags = summaryMsg.msgflags; + } + const flag = currentFlags & 0xf0; + const isUnread = (flag === RS_MSG_NEW || flag === RS_MSG_UNREAD_BY_USER) + && !(currentFlags & RS_MSG_TRASH) + && !(currentFlags & RS_MSG_SPAM); + const currentStatus = isUnread ? 'unread' : 'read'; + + return m( 'tr.msgbody', { key: v.attrs.details.msgId, - class: msgStatus, - onclick: () => - m.route.set('/mail/:tab/:msgId', { tab: v.attrs.category, msgId: v.attrs.details.msgId }), + class: [ + currentStatus, + v.attrs.isSelected ? 'selected' : '', + ].filter(Boolean).join(' '), + onclick: () => { + if (v.attrs.onOpen) v.attrs.onOpen(); + if (v.attrs.onSelect) { + v.attrs.onSelect(v.attrs.details.msgId); + } else { + m.route.set('/mail/:tab/:msgId', { tab: v.attrs.category, msgId: v.attrs.details.msgId }); + } + }, }, [ m( - 'td', + 'td.cell-star', m(`input.star-check[type=checkbox][id=msg-${v.attrs.details.msgId}]`, { checked: isStarred }), // Use label with [for] to manipulate hidden checkbox m( @@ -191,8 +288,8 @@ const MessageSummary = () => { m('i.fas.fa-star') ) ), - files && m('td', files.length), - m('td', { style: 'border-bottom: inherit;' }, [ + m('td.cell-attachment', files && files.length > 0 ? m('i.fas.fa-paperclip', { title: `${files.length} attachment(s)` }) : null), + m('td.cell-subject', [ m('div', { style: { display: 'flex', @@ -200,6 +297,7 @@ const MessageSummary = () => { gap: '0.5rem', } }, [ + files && files.length > 0 && m('i.fas.fa-paperclip.mobile-subject-clip', { title: `${files.length} attachment(s)` }), m('span', details.title), details.msgtags && details.msgtags.length > 0 && m('.mail-tags-container', { style: 'display: inline-flex; gap: 0.25rem;' }, details.msgtags.map((tagId) => { @@ -213,7 +311,7 @@ const MessageSummary = () => { ]) ]), m( - 'td', + 'td.cell-from', m( 'div', { @@ -257,15 +355,225 @@ const MessageSummary = () => { ] ) ), - m('td', new Date(details.ts * 1000).toLocaleString()), + m( + 'td.cell-spam', + m( + 'button.spam-btn[type=button]', + { + onclick: spamMessage, + class: spamActive ? 'spammed' : '', + title: spamActive ? 'Mark as not spam' : 'Mark as spam', + }, + m('i.fas.fa-fire') + ) + ), + m('td.cell-date', { title: new Date(details.ts * 1000).toLocaleString() }, formatMailDate(details.ts)), + m('td.cell-spacer'), ] - ), + ); + }, + }; +}; + +// Bodies already being fetched for a card: a remount during the round trip +// (filter toggle, page change) must not fire the same getMessage again. +const CardFetchesInFlight = new Set(); + +const MessageCard = () => { + return { + oninit: (v) => { + const msgId = v.attrs.msg.msgId; + if (!MessageCache[msgId] && !CardFetchesInFlight.has(msgId)) { + CardFetchesInFlight.add(msgId); + rs.rsJsonApiRequest('/rsMail/getMessage', { msgId }).then((res) => { + CardFetchesInFlight.delete(msgId); + if (res && res.body && res.body.retval) { + MessageCache[msgId] = res.body.msg; + MessageCache[msgId].msgtags = v.attrs.msg.msgtags; + if (v.attrs.msg && v.attrs.msg.msgflags !== undefined) { + MessageCache[msgId].msgflags = v.attrs.msg.msgflags; + } + const senderAddr = res.body.msg.from?._addr_string; + if (senderAddr && !MailGxsDetailsCache[senderAddr]) { + rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id: senderAddr }, (d) => { + if (d && d.details) { + MailGxsDetailsCache[senderAddr] = d.details; + UserNicknamesCache[senderAddr] = d.details.mNickname || ''; + m.redraw(); + } + }); + } + m.redraw(); + } + }); + } else { + const senderAddr = MessageCache[msgId]?.from?._addr_string || v.attrs.msg.from?._addr_string; + if (senderAddr && !MailGxsDetailsCache[senderAddr]) { + rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id: senderAddr }, (d) => { + if (d && d.details) { + MailGxsDetailsCache[senderAddr] = d.details; + UserNicknamesCache[senderAddr] = d.details.mNickname || ''; + m.redraw(); + } + }); + } + } + }, + view: (v) => { + const msg = v.attrs.msg; + const details = MessageCache[msg.msgId] || msg; + if (MessageCache[msg.msgId] && msg.msgflags !== undefined) { + MessageCache[msg.msgId].msgflags = msg.msgflags; + } + const senderAddr = details.from?._addr_string || msg.from?._addr_string; + const senderName = UserNicknamesCache[senderAddr] || rs.userList.username(senderAddr) || '[Unknown]'; + const senderInfo = MailGxsDetailsCache[senderAddr]; + const currentFlags = msg.msgflags !== undefined ? msg.msgflags : (details.msgflags || 0); + const flag = currentFlags & 0xf0; + const isUnread = (flag === RS_MSG_NEW || flag === RS_MSG_UNREAD_BY_USER) + && !(currentFlags & RS_MSG_TRASH) + && !(currentFlags & RS_MSG_SPAM); + const isStarred = (currentFlags & 0xf00) === RS_MSG_STAR; + const isSpam = Boolean(currentFlags & RS_MSG_SPAM); + const filesCount = (details.files && details.files.length) || msg.count || 0; + const tags = details.msgtags || msg.msgtags || []; + const isSelected = Boolean(v.attrs.isSelected); + + const rawMsg = details.msg || ''; + const snippet = stripHtmlForSnippet(rawMsg).slice(0, 110); + + return m( + '.mail-card-item', + { + key: msg.msgId, + class: [ + isSelected ? 'selected' : '', + isUnread ? 'unread' : 'read', + ].filter(Boolean).join(' '), + onclick: () => { + if (v.attrs.onSelect) v.attrs.onSelect(msg.msgId); + }, + }, + [ + isUnread && m('.mail-card-unread-dot'), + m('.mail-card-avatar-col', [ + m(peopleUtil.UserAvatar, { + avatar: senderInfo?.mAvatar, + firstLetter: senderName.slice(0, 1).toUpperCase(), + identityId: senderAddr, + size: 38, + }), + ]), + m('.mail-card-content-col', [ + m('.mail-card-row-top', [ + m( + '.mail-card-sender', + { + title: senderName, + onmouseenter: (e) => { + if (!senderAddr) return; + const rect = e.currentTarget.getBoundingClientRect(); + MailHoverState.hoveredUser = { gxsId: senderAddr, name: senderName, rect }; + m.redraw(); + }, + onmouseleave: () => { + MailHoverState.hoveredUser = null; + m.redraw(); + }, + }, + senderName + ), + m('.mail-card-date', { title: new Date((msg.ts?.xint64 || msg.ts || details.ts) * 1000).toLocaleString() }, formatMailDate(msg.ts?.xint64 || msg.ts || details.ts)), + ]), + m('.mail-card-row-subject', [ + m('.mail-card-subject', { title: details.title || msg.title }, details.title || msg.title || '(No Subject)'), + m('.mail-card-indicators', [ + filesCount > 0 && m('i.fas.fa-paperclip.mail-card-clip', { title: `${filesCount} attachment(s)` }), + m( + 'span.mail-card-spam-btn[role=button]', + { + class: isSpam ? 'spammed' : '', + title: isSpam ? 'Mark as not spam' : 'Mark as spam', + onclick: (e) => { + e.stopPropagation(); + e.preventDefault(); + const next = !isSpam; + if (details.msgflags !== undefined) { + if (next) details.msgflags |= RS_MSG_SPAM; + else details.msgflags &= ~RS_MSG_SPAM; + } + if (msg.msgflags !== undefined) { + if (next) msg.msgflags |= RS_MSG_SPAM; + else msg.msgflags &= ~RS_MSG_SPAM; + } + if (MessageCache[msg.msgId]) { + if (next) MessageCache[msg.msgId].msgflags |= RS_MSG_SPAM; + else MessageCache[msg.msgId].msgflags &= ~RS_MSG_SPAM; + } + rs.rsJsonApiRequest('/rsMail/MessageJunk', { msgId: msg.msgId, mark: next }); + triggerMessageUpdated(msg.msgId, RS_MSG_SPAM, next); + m.redraw(); + }, + }, + m('i.fas.fa-fire') + ), + m( + 'span.mail-card-star-btn[role=button]', + { + class: isStarred ? 'starred' : '', + title: isStarred ? 'Unstar' : 'Star', + onclick: (e) => { + e.stopPropagation(); + e.preventDefault(); + const next = !isStarred; + rs.rsJsonApiRequest('/rsMail/MessageStar', { msgId: msg.msgId, mark: next }); + if (details.msgflags !== undefined) { + if (next) details.msgflags |= RS_MSG_STAR; + else details.msgflags &= ~RS_MSG_STAR; + } + if (msg.msgflags !== undefined) { + if (next) msg.msgflags |= RS_MSG_STAR; + else msg.msgflags &= ~RS_MSG_STAR; + } + if (MessageCache[msg.msgId]) { + if (next) MessageCache[msg.msgId].msgflags |= RS_MSG_STAR; + else MessageCache[msg.msgId].msgflags &= ~RS_MSG_STAR; + } + triggerMessageUpdated(msg.msgId, RS_MSG_STAR, next); + m.redraw(); + }, + }, + m('i.fas.fa-star') + ), + ]), + ]), + snippet && m('.mail-card-snippet', snippet), + tags.length > 0 && + m( + '.mail-card-tags', + tags.map((tagId) => { + const tag = getTagDetails(tagId); + return m( + 'span.mail-card-tag-badge', + { + title: tag.name, + style: `background-color: ${tag.color}20; color: ${tag.color}; border: 1px solid ${tag.color}40;`, + }, + [m('span.mail-card-tag-dot', { style: `background-color: ${tag.color};` }), tag.name] + ); + }) + ), + ]), + ] + ); + }, }; }; const AttachmentSection = () => { function handleAttachmentDownload(item) { - const { fname: fileName, hash, size: xstr64 } = item; + const { fname: fileName, hash, size } = item; + const xstr64 = typeof size === 'object' ? size.xstr64 : String(size); const flags = util.RS_FILE_REQ_ANONYMOUS_ROUTING; rs.rsJsonApiRequest( '/rsFiles/FileRequest', @@ -279,90 +587,103 @@ const AttachmentSection = () => { } return { view: (v) => - m('table.attachment-container', [ - m('tr.attachment-header', [ - m('th', 'File Name'), - m('th', 'From'), - m('th', 'Size'), - m('th', 'Date'), - m('th', 'Download'), - ]), - m( - 'tbody', - v.attrs.files.map((file) => - m('tr.attachment', [ - m('td.attachment__name', [m('i.fas.fa-file'), m('span', file.fname)]), - m('td.attachment__from', rs.userList.userMap[file.from._addr_string] || '[Unknown]'), - m('td.attachment__size', humanReadableSize(file.size.xint64)), - m('td.attachment__date', new Date(file.ts * 1000).toLocaleString()), - m('td', m('button', { onclick: () => handleAttachmentDownload(file) }, 'Download')), - ]) - ) - ), + m('.attachments-wrapper', [ + v.attrs.files.map((file) => { + const fileSizeNum = file.size ? (typeof file.size === 'object' ? file.size.xint64 || parseInt(file.size.xstr64) || 0 : Number(file.size) || 0) : 0; + return m('.attachment-card', [ + m('.attachment-icon', m('i.fas.fa-paperclip')), + m('.attachment-info', [ + m('.attachment-name', file.fname), + m('.attachment-size', humanReadableSize(fileSizeNum)), + ]), + m( + 'button.btn-attachment-download', + { onclick: () => handleAttachmentDownload(file) }, + [m('i.fas.fa-download'), m('span.btn-text', ' Download')] + ), + ]); + }), ]), }; }; +const ReadingPanePlaceholder = { + view: () => + m('.mail-reading-placeholder', [ + m('.mail-reading-placeholder__icon', m('i.fas.fa-envelope-open-text')), + m('h3.mail-reading-placeholder__title', 'Select an email to read'), + m('p.mail-reading-placeholder__subtitle', 'Choose a message from the list to display its full content here.'), + ]), +}; + const MessageView = () => { let showCompose = false; let composeType = 'reply'; - // setFunction like react to show/hide popup + let isStarred = false; + let isSpam = false; + let currentMsgId = null; + function setShowCompose(bool) { showCompose = bool; } + const MailData = { msgId: '', message: '', subject: '', sender: {}, + avatar: null, recipients: [], toList: {}, ccList: {}, bccList: {}, timeStamp: '', files: [], + msgtags: [], }; - function deleteMail() { - rs.rsJsonApiRequest('/rsMail/MessageToTrash', { msgId: MailData.msgId, bTrash: true }); - rs.rsJsonApiRequest('/rsMail/MessageDelete', { msgId: MailData.msgId }).then((res) => { - widget.popupMessage( - m('.widget', [ - m('.widget__heading', m('h3', res.body.retval ? 'Success' : 'Error')), - m('.widget__body', m('p', res.body.retval ? 'Mail Deleted.' : 'Error in Deleting.')), - ]) - ); - m.route.set('/mail/:tab', { tab: m.route.param().tab }); - }); - } - function confirmMailDelete() { - widget.popupMessage([ - m('p', 'Are you sure you want to delete this mail?'), - m('button', { onclick: deleteMail }, 'Delete'), - ]); - } - return { - oninit: async (v) => { - const res = await rs.rsJsonApiRequest('/rsMail/getMessage', { - msgId: v.attrs.msgId, - }); - if (res.body.retval) { - const msgDetails = await res.body.msg; - msgDetails.files.forEach((element) => - MailData.files.push({ ...element, from: msgDetails.from, ts: msgDetails.ts }) - ); - // regex to detect html tags, better regex? /<[a-z][\s\S]*>/gi - MailData.message = /<\/*[a-z][^>]+?>/gi.test(msgDetails.msg) - ? msgDetails.msg - : `

${msgDetails.msg}

`; - document.querySelector('#msgView').innerHTML = MailData.message; + function loadMail(msgId) { + if (!msgId) return; + currentMsgId = msgId; + MailData.msgId = msgId; + MailData.files = []; + MailData.toList = {}; + MailData.ccList = {}; + MailData.bccList = {}; + MailData.avatar = null; + MailData.subject = ''; + MailData.message = ''; + MailData.sender = {}; + MailData.timeStamp = ''; + MailData.msgtags = []; + + markMessageRead(msgId); + + rs.rsJsonApiRequest('/rsMail/getMessage', { msgId }).then(async (res) => { + if (res && res.body && res.body.retval) { + const msgDetails = res.body.msg; + msgDetails.msgflags &= ~(RS_MSG_NEW | RS_MSG_UNREAD_BY_USER); + MessageCache[msgId] = msgDetails; MailData.msgId = msgDetails.msgId; MailData.sender = msgDetails.from; - MailData.subject = msgDetails.title; + MailData.subject = msgDetails.title || '(No Subject)'; MailData.timeStamp = msgDetails.ts; - MailData.recipients = msgDetails.destinations; - MailData?.recipients?.forEach((destDetail) => { - const { _addr_string: addrString, _mode: mode } = destDetail; // destructuring + renaming + MailData.msgtags = msgDetails.msgtags || (MessageCache[msgId] && MessageCache[msgId].msgtags) || []; + isStarred = (msgDetails.msgflags & 0xf00) === RS_MSG_STAR; + isSpam = Boolean(msgDetails.msgflags & RS_MSG_SPAM); + + MailData.files = []; + (msgDetails.files || []).forEach((element) => + MailData.files.push({ ...element, from: msgDetails.from, ts: msgDetails.ts }) + ); + + MailData.message = /<\/*[a-z][^>]+?>/gi.test(msgDetails.msg) + ? msgDetails.msg + : `

${msgDetails.msg}

`; + + MailData.recipients = msgDetails.destinations || []; + MailData.recipients.forEach((destDetail) => { + const { _addr_string: addrString, _mode: mode } = destDetail; if (mode === MSG_ADDRESS_MODE_TO && !MailData.toList[addrString]) { MailData.toList[addrString] = destDetail; } else if (mode === MSG_ADDRESS_MODE_CC && !MailData.ccList[addrString]) { @@ -371,156 +692,272 @@ const MessageView = () => { MailData.bccList[addrString] = destDetail; } if (addrString && !UserNicknamesCache[addrString]) { - rs.rsJsonApiRequest( - '/rsIdentity/getIdDetails', - { id: addrString }, - (data) => { - if (data?.details) { - UserNicknamesCache[addrString] = data.details.mNickname || ''; - } + rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id: addrString }, (data) => { + if (data?.details) { + UserNicknamesCache[addrString] = data.details.mNickname || ''; + MailGxsDetailsCache[addrString] = data.details; + m.redraw(); } - ); + }); } }); - rs.rsJsonApiRequest( - '/rsIdentity/getIdDetails', - { id: MailData?.sender?._addr_string }, - (data) => { + + if (MailData.sender?._addr_string) { + const sAddr = MailData.sender._addr_string; + rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id: sAddr }, (data) => { if (data?.details) { MailData.avatar = data.details.mAvatar; - UserNicknamesCache[MailData.sender._addr_string] = data.details.mNickname || ''; + UserNicknamesCache[sAddr] = data.details.mNickname || ''; + MailGxsDetailsCache[sAddr] = data.details; + m.redraw(); } - } - ); + }); + } + m.redraw(); + } + }); + } + + function toggleStar() { + isStarred = !isStarred; + rs.rsJsonApiRequest('/rsMail/MessageStar', { msgId: MailData.msgId, mark: isStarred }, () => { + if (MessageCache[MailData.msgId]) { + if (isStarred) MessageCache[MailData.msgId].msgflags |= RS_MSG_STAR; + else MessageCache[MailData.msgId].msgflags &= ~RS_MSG_STAR; + } + triggerMessageUpdated(MailData.msgId, RS_MSG_STAR, isStarred); + m.redraw(); + }); + } + + function toggleSpam() { + isSpam = !isSpam; + rs.rsJsonApiRequest('/rsMail/MessageJunk', { msgId: MailData.msgId, mark: isSpam }, () => { + if (MessageCache[MailData.msgId]) { + if (isSpam) MessageCache[MailData.msgId].msgflags |= RS_MSG_SPAM; + else MessageCache[MailData.msgId].msgflags &= ~RS_MSG_SPAM; + } + triggerMessageUpdated(MailData.msgId, RS_MSG_SPAM, isSpam); + widget.popupMessage([ + m('i.fas.fa-fire'), + m('h3', isSpam ? 'Marked as spam' : 'Removed from spam'), + ]); + m.redraw(); + }); + } + + function markUnread() { + rs.rsJsonApiRequest('/rsMail/MessageRead', { msgId: MailData.msgId, unreadByUser: true }, () => { + if (MessageCache[MailData.msgId]) { + MessageCache[MailData.msgId].msgflags |= RS_MSG_UNREAD_BY_USER; + } + triggerMessageUpdated(MailData.msgId, RS_MSG_UNREAD_BY_USER, true); + widget.popupMessage([ + m('i.fas.fa-envelope'), + m('h3', 'Marked as unread'), + ]); + m.redraw(); + }); + } + + function deleteMail(vnode) { + rs.rsJsonApiRequest('/rsMail/MessageToTrash', { msgId: MailData.msgId, bTrash: true }); + rs.rsJsonApiRequest('/rsMail/MessageDelete', { msgId: MailData.msgId }).then((res) => { + widget.popupMessage( + m('.widget', [ + m('.widget__heading', m('h3', res.body.retval ? 'Success' : 'Error')), + m('.widget__body', m('p', res.body.retval ? 'Mail Deleted.' : 'Error in Deleting.')), + ]) + ); + if (vnode.attrs.onDeleted) { + vnode.attrs.onDeleted(MailData.msgId); + } else { + m.route.set('/mail/:tab', { tab: m.route.param().tab || 'inbox' }); + } + }); + } + + function confirmMailDelete(vnode) { + widget.popupMessage([ + m('p', 'Are you sure you want to delete this mail?'), + m('button.red', { onclick: () => deleteMail(vnode) }, 'Delete'), + ]); + } + + return { + oninit: (v) => { + loadMail(v.attrs.msgId); + }, + onupdate: (v) => { + if (v.attrs.msgId && v.attrs.msgId !== currentMsgId) { + loadMail(v.attrs.msgId); } }, - view: () => - m( - '.msg-view', + view: (v) => { + const senderAddr = MailData.sender?._addr_string; + const senderName = (senderAddr && UserNicknamesCache[senderAddr]) || (senderAddr && rs.userList.username(senderAddr)) || '[Unknown]'; + const toKeys = Object.keys(MailData.toList || {}); + const ccKeys = Object.keys(MailData.ccList || {}); + const bccKeys = Object.keys(MailData.bccList || {}); + + return m( + '.msg-view.mail-reading-card', [ m('.msg-view-nav', [ m( - 'a[title=Back]', - { onclick: () => m.route.set('/mail/:tab', { tab: m.route.param().tab }) }, - m('i.fas.fa-arrow-left') + 'button.mail-view-back-btn[type=button][title=Back][aria-label=Back]', + { + onclick: () => { + if (v.attrs.onBack) v.attrs.onBack(); + else m.route.set('/mail/:tab', { tab: m.route.param().tab || 'inbox' }); + }, + }, + m('i.fas.fa-chevron-left') ), m('.msg-view-nav__action', [ - m('button', { onclick: () => { composeType = 'reply'; setShowCompose(true); } }, 'Reply'), - m('button', { onclick: () => { composeType = 'replyAll'; setShowCompose(true); } }, 'Reply All'), - m('button', { onclick: () => { composeType = 'forward'; setShowCompose(true); } }, 'Forward'), - m('button', { onclick: confirmMailDelete }, 'Delete'), + m('button.mail-action-btn', { + title: 'Reply', + onclick: () => { composeType = 'reply'; setShowCompose(true); }, + }, [m('i.fas.fa-reply'), m('span.btn-text', ' Reply')]), + m('button.mail-action-btn', { + title: 'Forward', + onclick: () => { composeType = 'forward'; setShowCompose(true); }, + }, [m('i.fas.fa-forward'), m('span.btn-text', ' Forward')]), + m('button.mail-action-btn', { + title: 'Reply All', + onclick: () => { composeType = 'replyAll'; setShowCompose(true); }, + }, [m('i.fas.fa-reply-all'), m('span.btn-text', ' Reply All')]), + m('button.mail-action-btn', { + title: isStarred ? 'Unstar' : 'Star', + class: isStarred ? 'mail-action-btn--starred' : '', + onclick: toggleStar, + }, [m('i.fas.fa-star'), m('span.btn-text', isStarred ? ' Starred' : ' Star')]), + m('button.mail-action-btn', { + title: isSpam ? 'Remove from spam' : 'Mark as spam', + class: isSpam ? 'mail-action-btn--spam' : '', + onclick: toggleSpam, + }, [m('i.fas.fa-fire'), m('span.btn-text', isSpam ? ' Spam' : ' Spam')]), + m('button.mail-action-btn', { + title: 'Mark as unread', + onclick: markUnread, + }, [m('i.fas.fa-envelope'), m('span.btn-text', ' Unread')]), + m('button.mail-action-btn.mail-action-btn--delete', { + title: 'Delete mail', + onclick: () => confirmMailDelete(v), + }, [m('i.fas.fa-trash-alt'), m('span.btn-text', ' Delete')]), ]), ]), m('.msg-view__header', [ - m('h3', MailData.subject), + m('.mail-reading-title-row', [ + m('h2.msg-view__title', MailData.subject), + MailData.msgtags && MailData.msgtags.length > 0 && + m('.mail-reading-tags', MailData.msgtags.map((tagId) => { + const tag = getTagDetails(tagId); + return m('span.mail-card-tag-badge', { + title: tag.name, + style: `background-color: ${tag.color}20; color: ${tag.color}; border: 1px solid ${tag.color}40;`, + }, [m('span.mail-card-tag-dot', { style: `background-color: ${tag.color};` }), tag.name]); + })), + ]), m('.msg-details', [ MailData.sender && - m(peopleUtil.UserAvatar, { - avatar: MailData.avatar, - firstLetter: (UserNicknamesCache[MailData.sender._addr_string] || rs.userList.username(MailData.sender._addr_string) || '').slice(0, 1).toUpperCase(), - identityId: MailData.sender._addr_string, - }), + m(peopleUtil.UserAvatar, { + avatar: MailData.avatar, + firstLetter: senderName.slice(0, 1).toUpperCase(), + identityId: senderAddr, + size: 46, + }), m('.msg-details__info', [ - MailData.sender && - m('.msg-details__info-item', { - style: { cursor: 'pointer', display: 'inline-flex', gap: '0.25rem', alignItems: 'center' }, - onmouseenter: (e) => { - if (!MailData.sender._addr_string) return; - const gxsId = MailData.sender._addr_string; - const name = UserNicknamesCache[gxsId] || rs.userList.username(gxsId) || 'Unknown'; - const rect = e.currentTarget.getBoundingClientRect(); - MailHoverState.hoveredUser = { gxsId, name, rect }; - if (!MailGxsDetailsCache[gxsId]) { - rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id: gxsId }, (d) => { - if (d && d.details) { - MailGxsDetailsCache[gxsId] = d.details; - m.redraw(); - } - }); - } - m.redraw(); - }, - onmouseleave: () => { - MailHoverState.hoveredUser = null; - m.redraw(); - } - }, [ - m('b', 'From: '), - UserNicknamesCache[MailData.sender._addr_string] || rs.userList.username(MailData.sender._addr_string) || 'Unknown', - ]), - m('.msg-details__info-item', [ - m('b', 'To: '), - MailData.toList && Object.keys(MailData.toList).length > 0 - ? [ - m('#truncate.truncated-view', [ - Object.keys(MailData.toList).map((key, index) => - m('span', { key: index }, `${UserNicknamesCache[key] || rs.userList.username(key) || 'Unknown'}, `) - ), - ]), - m( - 'button.toggle-truncate', - { - style: { - display: Object.keys(MailData.toList).length > 10 ? 'block' : 'none', - }, - onclick: () => { - document - .querySelector('#truncate') - .classList.toggle('truncated-view'); - }, - }, - '...' - ), - ] - : m('span', 'Unknown'), - ]), - MailData.ccList && - Object.keys(MailData.ccList).length > 0 && - m('.msg-details__info-item', [ - m('b', 'Cc: '), - Object.keys(MailData.ccList).map((key, index) => - m('span', { key: index }, `${UserNicknamesCache[key] || rs.userList.username(key) || 'Unknown'}, `) - ), - ]), - MailData.bccList && - Object.keys(MailData.bccList).length > 0 && - m('.msg-details__info-item', [ - m('b', 'Bcc: '), - Object.keys(MailData.bccList).map((key, index) => - m('span', { key: index }, `${UserNicknamesCache[key] || rs.userList.username(key) || 'Unknown'}, `) + m('.msg-details__info-row', [ + m( + '.msg-sender-name', + { + onmouseenter: (e) => { + if (!senderAddr) return; + const rect = e.currentTarget.getBoundingClientRect(); + MailHoverState.hoveredUser = { gxsId: senderAddr, name: senderName, rect }; + m.redraw(); + }, + onmouseleave: () => { + MailHoverState.hoveredUser = null; + m.redraw(); + }, + }, + senderName ), + MailData.timeStamp && + m('.msg-timestamp', { title: new Date(MailData.timeStamp * 1000).toLocaleString() }, + new Date(MailData.timeStamp * 1000).toLocaleString() + ), ]), + toKeys.length > 0 && + m('.msg-recipients-row', [ + m('span.recipient-label', 'To:'), + toKeys.map((addr) => { + const name = UserNicknamesCache[addr] || rs.userList.username(addr) || addr.slice(0, 8); + return m('span.recipient-chip', { title: addr }, name); + }), + ]), + ccKeys.length > 0 && + m('.msg-recipients-row', [ + m('span.recipient-label', 'Cc:'), + ccKeys.map((addr) => { + const name = UserNicknamesCache[addr] || rs.userList.username(addr) || addr.slice(0, 8); + return m('span.recipient-chip', { title: addr }, name); + }), + ]), + // Own sent mail carries its Bcc list; the old view showed it. + bccKeys.length > 0 && + m('.msg-recipients-row', [ + m('span.recipient-label', 'Bcc:'), + bccKeys.map((addr) => { + const name = UserNicknamesCache[addr] || rs.userList.username(addr) || addr.slice(0, 8); + return m('span.recipient-chip', { title: addr }, name); + }), + ]), ]), ]), ]), - m('.msg-view__body', m('#msgView')), - MailData.files.length > 0 && - m('.msg-view__attachment', [ - m('h3', 'Attachments'), - m('.msg-view__attachment-items', m(AttachmentSection, { files: MailData.files })), + MailData.files && MailData.files.length > 0 && + m('.msg-view__attachment', [ + m('h4.attachments-title', [ + m('i.fas.fa-paperclip'), + m('span', `Attachments (${MailData.files.length})`), + ]), + m('.msg-view__attachment-items', m(AttachmentSection, { files: MailData.files })), + ]), + m('.msg-view__body', [ + m('.mail-body-container', m.trust(MailData.message || '

(No message content)

')), ]), - ], - showCompose && m( - '.composePopupOverlay#mailComposerPopup', - m( - '.composePopup', - MailData.sender._addr_string - ? m(compose, { - msgType: composeType, - senderId: MailData.sender._addr_string, - recipientList: MailData.toList, - ccList: MailData.ccList, - subject: MailData.subject, - replyMessage: MailData.message, - timeStamp: new Date(MailData.timeStamp * 1000), - setShowCompose, - }) - : m('.widget', m('.widget__heading', m('h3', 'Sender is not known'))), - m('button.red.close-btn', { onclick: () => setShowCompose(false) }, m('i.fas.fa-times')) - ) - ), - renderMailUserTooltip(), - ), + showCompose && + m( + '.composePopupOverlay#mailComposerPopup', + m( + '.composePopup', + senderAddr + ? m(compose, { + msgType: composeType, + senderId: senderAddr, + recipientList: MailData.toList, + ccList: MailData.ccList, + // The prefix depends on the ACTION, not on whatever + // prefix the subject already has: forwarding "Re: X" + // must send "Fwd: Re: X", not "Re: X". + subject: composeType === 'forward' + ? (MailData.subject.startsWith('Fwd:') ? MailData.subject : `Fwd: ${MailData.subject}`) + : (MailData.subject.startsWith('Re:') ? MailData.subject : `Re: ${MailData.subject}`), + replyMessage: MailData.message, + timeStamp: new Date(MailData.timeStamp * 1000), + setShowCompose, + }) + : m('.widget', m('.widget__heading', m('h3', 'Sender is not known'))), + m('button.red.close-btn', { onclick: () => setShowCompose(false) }, m('i.fas.fa-times')) + ) + ), + renderMailUserTooltip(), + ] + ); + }, }; }; @@ -534,7 +971,7 @@ function setSort(column) { SortState.direction = SortState.direction === 'asc' ? 'desc' : 'asc'; } else { SortState.column = column; - SortState.direction = (column === 'date' || column === 'attachments' || column === 'starred') ? 'desc' : 'asc'; + SortState.direction = (column === 'date' || column === 'attachments' || column === 'starred' || column === 'spam') ? 'desc' : 'asc'; } } @@ -550,6 +987,13 @@ function sortList(list) { valB = bStarred ? 1 : 0; break; } + case 'spam': { + const aSpam = Boolean((MessageCache[msgA.msgId]?.msgflags & RS_MSG_SPAM) || (msgA.msgflags & RS_MSG_SPAM)); + const bSpam = Boolean((MessageCache[msgB.msgId]?.msgflags & RS_MSG_SPAM) || (msgB.msgflags & RS_MSG_SPAM)); + valA = aSpam ? 1 : 0; + valB = bSpam ? 1 : 0; + break; + } case 'attachments': { const aCount = MessageCache[msgA.msgId]?.files?.length || msgA.count || 0; const bCount = MessageCache[msgB.msgId]?.files?.length || msgB.count || 0; @@ -602,7 +1046,7 @@ const Table = () => { ? (SortState.direction === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'; return m( - 'th.sortable-th', + `th.sortable-th.col-${colName}`, { onclick: () => setSort(colName), style: { cursor: 'pointer', userSelect: 'none' }, @@ -685,7 +1129,9 @@ const Table = () => { renderHeader('attachments', m('i.fas.fa-paperclip'), true), renderHeader('subject', 'Subject'), renderHeader('from', 'From'), + renderHeader('spam', m('i.fas.fa-fire'), true), renderHeader('date', 'Date'), + m('th.col-spacer'), ]), tbody, ]), @@ -736,12 +1182,11 @@ const sidebarIcons = { const Sidebar = () => { return { - view: ({ attrs: { tabs, baseRoute, size } }) => + view: ({ attrs: { tabs, baseRoute, size, onNavigate } }) => m( '.sidebar', tabs.map((panelName, index) => { const displayName = panelName.charAt(0).toUpperCase() + panelName.slice(1); - const labelText = size[panelName] > 0 ? `${displayName} (${size[panelName]})` : displayName; return m( m.route.Link, { @@ -750,12 +1195,14 @@ const Sidebar = () => { onclick: () => { activeSideLink.sideactive = index; activeSideLink.quicksideactive = -1; + if (onNavigate) onNavigate(); }, href: baseRoute + panelName, }, [ sidebarIcons[panelName] || null, - labelText, + m('span.sidebar-link-text', displayName), + size[panelName] > 0 && m('span.sidebar-badge', size[panelName]), ] ); }) @@ -766,13 +1213,12 @@ const Sidebar = () => { const SidebarQuickView = () => { // for the Mail tab, to be moved later. return { - view: ({ attrs: { tabs, baseRoute, size } }) => + view: ({ attrs: { tabs, baseRoute, size, onNavigate } }) => m( '.sidebarquickview', m('h6.bold', 'Quick View'), tabs.map((panelName, index) => { const displayName = panelName.charAt(0).toUpperCase() + panelName.slice(1); - const labelText = size[panelName] > 0 ? `${displayName} (${size[panelName]})` : displayName; return m( m.route.Link, { @@ -782,12 +1228,14 @@ const SidebarQuickView = () => { onclick: () => { activeSideLink.quicksideactive = index; activeSideLink.sideactive = -1; + if (onNavigate) onNavigate(); }, href: baseRoute + panelName, }, [ sidebarIcons[panelName] || null, - labelText, + m('span.sidebar-link-text', displayName), + size[panelName] > 0 && m('span.sidebar-badge', size[panelName]), ] ); }) @@ -797,7 +1245,9 @@ const SidebarQuickView = () => { module.exports = { MessageSummary, + MessageCard, MessageView, + ReadingPanePlaceholder, AttachmentSection, Table, SearchBar, @@ -823,4 +1273,8 @@ module.exports = { RS_MSGTAGTYPE_TODO, RS_MSGTAGTYPE_WORK, BOX_ALL, + markMessageRead, + onMessageUpdated, + triggerMessageUpdated, + MessageCache, }; diff --git a/webui-src/app/main.js b/webui-src/app/main.js index 867acd3..327022a 100644 --- a/webui-src/app/main.js +++ b/webui-src/app/main.js @@ -1,5 +1,9 @@ const m = require('mithril'); +// Bumped at every change of the web UI; shown in the rail, the phone header +// and the Debug page. +const WEBUI_VERSION = 'v173'; + const login = require('login'); const rs = require('rswebui'); const home = require('home'); @@ -12,25 +16,90 @@ const channels = require('channels/channels'); const forums = require('forums/forums'); const boards = require('boards/boards'); const config = require('config/config_resolver'); +const statistics = require('statistics/statistics'); +const debug = require('debug/debug'); const statusbar = require('statusbar'); +const Dialog = require('dialog'); +const networkState = require('network/network_state'); +const peopleState = require('people/people_state'); +const { ChatRoomsModel, receiveLobbyChatMessage } = require('chat/chat_state'); -const navIcon = { - home: m('i.fas.fa-home.sidenav-icon'), - network: m('i.fas.fa-share-alt.sidenav-icon'), - people: m('i.fas.fa-users.sidenav-icon'), - chat: m('i.fas.fa-comments.sidenav-icon'), - mail: m('i.fas.fa-envelope.sidenav-icon'), - files: m('i.fas.fa-folder-open.sidenav-icon'), - channels: m('i.fas.fa-tv.sidenav-icon'), - forums: m('i.fas.fa-bullhorn.sidenav-icon'), - boards: m('i.fas.fa-globe.sidenav-icon'), - config: m('i.fas.fa-cogs.sidenav-icon'), -}; +const sumCounts = (counts) => Object.values(counts || {}) + .reduce((total, count) => total + Number(count || 0), 0); + +// Shared by the desktop rail, mobile tabs, and mobile More sheet. +// Count callbacks read current state on every render. +const navigationItems = [ + { + name: 'home', href: '/home', label: 'Home', + icon: 'i.fas.fa-home.sidenav-icon', mobile: 'primary', + }, + { + name: 'network', href: '/network', label: 'Network', + icon: 'i.fas.fa-share-alt.sidenav-icon', mobile: 'primary', + count: () => sumCounts(networkState.State.unreadChatCount), + }, + { + name: 'people', href: '/people/MyContacts', label: 'People', + icon: 'i.fas.fa-users.sidenav-icon', mobile: 'primary', + count: () => sumCounts(peopleState.State.unreadChatCount), + }, + { + name: 'chat', href: '/chat', label: 'Chat', + icon: 'i.fas.fa-comments.sidenav-icon', mobile: 'primary', + count: () => sumCounts(ChatRoomsModel.unreadCount) + ChatRoomsModel.invitationCount(), + }, + { + name: 'mail', href: '/mail/inbox', label: 'Mail', + icon: 'i.fas.fa-envelope.sidenav-icon', mobile: 'primary', + count: () => mail.Messages.unreadCount(), + }, + { + name: 'files', href: '/files/files', label: 'Files', + icon: 'i.fas.fa-folder-open.sidenav-icon', mobile: 'more', + }, + { + name: 'channels', href: '/channels/MyChannels', label: 'Channels', + icon: 'i.fas.fa-tv.sidenav-icon', mobile: 'more', + }, + { + name: 'forums', href: '/forums/MyForums', label: 'Forums', + icon: 'i.fas.fa-bullhorn.sidenav-icon', mobile: 'more', + }, + { + name: 'boards', href: '/boards/MyBoards', label: 'Boards', + icon: 'i.fas.fa-globe.sidenav-icon', mobile: 'more', + }, + { + name: 'statistics', href: '/statistics', label: 'Statistics', + icon: 'i.fas.fa-chart-pie.sidenav-icon', mobile: 'more', + }, + { + name: 'config', href: '/config/network', label: 'Config', + icon: 'i.fas.fa-cogs.sidenav-icon', mobile: 'more', + }, + { + name: 'debug', href: '/debug', label: 'Debug', + icon: 'i.fas.fa-bug.sidenav-icon', mobile: 'more', + }, +]; + +const mobileItems = navigationItems.filter((item) => item.mobile === 'primary'); +const mobileMoreItems = navigationItems.filter((item) => item.mobile === 'more'); + +function navigationContent(item) { + const count = item.count ? item.count() : 0; + return [ + m(item.icon), + m('span', item.label), + count > 0 && m('b.nav-unread-badge', count), + ]; +} const navbar = () => { let isCollapsed = true; return { - view: (vnode) => + view: () => m( 'nav.nav-menu', { @@ -58,15 +127,15 @@ const navbar = () => { m('.nav-menu__logo-text', [m('h5', 'RetroShare')]), ]), m('.nav-menu__box', { style: { flex: 1 } }, [ - Object.keys(vnode.attrs.links).map((linkName) => { - const active = m.route.get().split('/')[1] === linkName; + navigationItems.map((item) => { + const active = m.route.get().split('/')[1] === item.name; return m( m.route.Link, { - href: vnode.attrs.links[linkName], + href: item.href, class: (active ? 'active-link' : '') + ' item', }, - [navIcon[linkName], m('span', linkName.charAt(0).toUpperCase() + linkName.slice(1))] + navigationContent(item) ); }), m( @@ -114,7 +183,7 @@ const navbar = () => { ? 'Connected to RetroShare Core' : 'Connection Lost', }), - m('span.webui-version', { style: { fontSize: '0.7em' } }, 'v131'), + m('span.webui-version', { style: { fontSize: '0.7em' } }, WEBUI_VERSION), m('i.fas.fa-sync-alt.refresh-icon', { style: { cursor: 'pointer', fontSize: '0.8em' }, onclick: () => window.location.reload(true), @@ -160,24 +229,150 @@ const navbar = () => { }; }; +const MobileStatus = () => { + let isOpen = false; + return { + view: () => { + const state = statusbar.State; + const summary = statusbar.getMobileStatusSummary(); + const isHiddenMode = state.hiddenType === 2 || state.hiddenType === 4; + return [ + m('.mobile-app-header', [ + m('.mobile-app-header__brand', [ + m('img', { src: 'images/retroshare.svg', alt: '' }), + m('strong', 'RetroShare'), + m('span.mobile-app-header__version', WEBUI_VERSION), + ]), + m('button.mobile-status-trigger[type=button]', { + 'aria-label': `Open connection status. ${summary.label}`, + 'aria-expanded': String(isOpen), + 'aria-haspopup': 'dialog', + onclick: () => (isOpen = true), + }, [ + m('span.mobile-status-trigger__dot', { style: { backgroundColor: summary.color } }), + m('span', `${state.onlineCount}/${state.friendCount}`), + m('i.fas.fa-chevron-up'), + ]), + ]), + isOpen && m(Dialog, { + label: 'Connection status', + overlayClass: 'mobile-status-overlay', + sheetClass: 'mobile-status-sheet', + onclose: () => (isOpen = false), + }, [ + m('.mobile-status-sheet__handle'), + m('.mobile-status-sheet__heading', [ + m('div', [ + m('span.mobile-status-trigger__dot', { style: { backgroundColor: summary.color } }), + m('strong', summary.label), + ]), + m('button[type=button][aria-label=Close status]', { + onclick: () => (isOpen = false), + }, m('i.fas.fa-times')), + ]), + m('.mobile-status-sheet__grid', [ + m('.mobile-status-sheet__item', [m('span', 'Friends online'), m('strong', `${state.onlineCount}/${state.friendCount}`)]), + isHiddenMode + ? m('.mobile-status-sheet__item', [ + m('span', state.hiddenType === 2 ? 'Tor' : 'I2P'), + m('strong', state.torChecking ? 'Checking' : state.torProxyOk ? 'Ready' : 'Unavailable'), + ]) + : [ + m('.mobile-status-sheet__item', [m('span', 'NAT'), m('strong', summary.label)]), + m('.mobile-status-sheet__item', [m('span', 'DHT'), m('strong', state.dhtActive ? state.dhtOk ? 'Connected' : 'Searching' : 'Disabled')]), + ], + m('.mobile-status-sheet__item', [ + m('span', [m('i.fas.fa-arrow-down'), ' Download']), + m('strong', `${state.rateIn.toFixed(1)} kB/s`), + m('small', statusbar.formatBytes(state.totalIn)), + ]), + m('.mobile-status-sheet__item', [ + m('span', [m('i.fas.fa-arrow-up'), ' Upload']), + m('strong', `${state.rateOut.toFixed(1)} kB/s`), + m('small', statusbar.formatBytes(state.totalOut)), + ]), + ]), + m('.mobile-status-sheet__version', [ + 'WebUI ' + WEBUI_VERSION, + // The page keeps the code it loaded until it is reloaded, and a + // phone browser hides that action away. A new build shows up + // here only after this. + m('button[type=button]', { + onclick: () => window.location.reload(true), + }, [m('i.fas.fa-sync-alt'), ' Reload']), + ]), + ]), + ]; + }, + }; +}; + +const MobileNavigation = () => { + let isMoreOpen = false; + const routeName = () => m.route.get().split('/')[1]; + const link = (item, className = '') => m(m.route.Link, { + href: item.href, + class: `${className}${routeName() === item.name ? ' active' : ''}`.trim(), + onclick: () => (isMoreOpen = false), + }, navigationContent(item)); + + return { + view: () => [ + isMoreOpen && m(Dialog, { + label: 'More navigation', + overlayClass: 'mobile-more-overlay', + sheetClass: 'mobile-more-sheet', + onclose: () => (isMoreOpen = false), + }, [ + m('.mobile-more-sheet__handle'), + m('h3', 'More'), + m('.mobile-more-sheet__links', mobileMoreItems.map((item) => link(item))), + m('.mobile-more-sheet__actions', [ + m('button[type=button]', { onclick: () => (isMoreOpen = false) }, 'Close'), + m('button[type=button]', { onclick: () => window.location.reload(true) }, [m('i.fas.fa-sync-alt'), ' Reload']), + m('button[type=button]', { onclick: () => rs.logout() }, [m('i.fas.fa-sign-out-alt'), ' Logout']), + ]), + ]), + m('nav.mobile-bottom-nav[aria-label=Main navigation]', [ + mobileItems.map((item) => link(item, 'mobile-bottom-nav__item')), + m('button.mobile-bottom-nav__item[type=button]', { + class: isMoreOpen || mobileMoreItems.some((item) => item.name === routeName()) ? 'active' : '', + 'aria-expanded': String(isMoreOpen), + 'aria-haspopup': 'dialog', + onclick: () => (isMoreOpen = !isMoreOpen), + }, [m('i.fas.fa-bars.sidenav-icon'), m('span', 'More')]), + ]), + ], + }; +}; + const Layout = () => { return { + oninit: () => { + mail.Messages.load(); + [rs.RsEventsType.MAIL_STATUS, rs.RsEventsType.MAIL_TAG].forEach((eventType) => { + if (!rs.events[eventType]) { + rs.events[eventType] = { + handler: (event, owner) => owner.notify(event), + notify: () => {}, + }; + } + rs.events[eventType].notify = () => mail.Messages.refreshSoon(); + }); + if (!rs.events[15]) return; + rs.events[15].notify = (messageOrEvent) => { + if (messageOrEvent && messageOrEvent.mEventCode !== undefined) { + ChatRoomsModel.receiveAdministrativeEvent(messageOrEvent); + return; + } + networkState.receiveDirectChatMessage(messageOrEvent); + peopleState.receiveDistantChatMessage(messageOrEvent); + receiveLobbyChatMessage(messageOrEvent); + }; + }, view: (vnode) => m('.content', [ - m(navbar, { - links: { - home: '/home', - network: '/network', - people: '/people/MyContacts', - chat: '/chat', - mail: '/mail/inbox', - files: '/files/files', - channels: '/channels/MyChannels', - forums: '/forums/MyForums', - boards: '/boards/MyBoards', - config: '/config/network', - }, - }), + m(navbar), m( '.main-container', { @@ -190,8 +385,10 @@ const Layout = () => { }, }, [ + m(MobileStatus), m('.tab-content', { style: { flex: '1', overflow: 'auto' } }, vnode.children), m(statusbar), + m(MobileNavigation), ] ), ]), @@ -264,6 +461,12 @@ m.route(document.getElementById('main'), '/', { '/config/:tab': { render: (v) => m(Layout, m(config, v.attrs)), }, + '/statistics': { + render: () => m(Layout, m(statistics)), + }, + '/debug': { + render: () => m(Layout, m(debug, { version: WEBUI_VERSION })), + }, }); // v51 architectural fix: ensure event queue starts on direct route refresh diff --git a/webui-src/app/network/network.js b/webui-src/app/network/network.js index 99d7752..89cf713 100644 --- a/webui-src/app/network/network.js +++ b/webui-src/app/network/network.js @@ -1,5 +1,4 @@ const m = require('mithril'); -const rs = require('rswebui'); const Data = require('network/network_data'); const compose = require('mail/mail_compose'); const { @@ -9,23 +8,27 @@ const { fetchIdDetails, startDirectChat, getOnlineSslId, + preloadNetworkChatHistory, + loadDirectChatMessages, + markDirectChatRead, } = require('network/network_state'); const { OwnProfileCard, FriendsList } = require('network/network_friends_list'); const DetailsTab = require('network/network_details_tab'); const ChatTab = require('network/network_chat_tab'); +const NetworkGraph = require('network/network_graph'); const NetworkLayout = () => { return { oninit: () => { - Data.refreshGpgDetails().then(() => m.redraw()); + // Keep the active-chat list current even when no conversation is open. + loadDirectChatMessages(); + Data.refreshGpgDetails().then(() => { + preloadNetworkChatHistory(); + m.redraw(); + }); loadOwnProfile(); loadGxsIdentities(); }, - onremove: () => { - if (rs.events[15]) { - rs.events[15].notify = () => {}; - } - }, view: () => { const selectedFriend = State.selectedFriendGpgId ? Data.gpgDetails[State.selectedFriendGpgId] @@ -39,17 +42,23 @@ const NetworkLayout = () => { State.gxsIdentities.forEach((gxsId) => fetchIdDetails(gxsId)); } - return m('.network-container', [ + return m('.network-container' + (State.mobilePane === 'detail' ? '.mobile-detail-open' : ''), [ m('.network-left-pane', [m(OwnProfileCard), m(FriendsList)]), m('.network-right-pane', [ - selectedFriend - ? [ - m('.network-tabs', [ + m('.mobile-pane-header', [ + m('button.mobile-back-button', { + type: 'button', + onclick: () => { State.mobilePane = 'list'; }, + }, [m('i.fas.fa-chevron-left'), ' Network']), + m('strong', State.activeTab === 'graph' ? 'Network Graph' : (selectedFriend ? selectedFriend.name : 'Friend')), + ]), + m('.network-tabs', [ m( 'button.tab-btn' + (State.activeTab === 'details' ? '.active' : ''), { onclick: () => { State.activeTab = 'details'; + State.mobilePane = 'detail'; }, }, 'Details View' @@ -59,6 +68,8 @@ const NetworkLayout = () => { { onclick: () => { State.activeTab = 'chat'; + State.mobilePane = 'detail'; + markDirectChatRead(State.selectedFriendGpgId); const sslId = getOnlineSslId(State.selectedFriendGpgId); if (sslId && !State.currentChatPeerId) { startDirectChat(sslId); @@ -67,16 +78,23 @@ const NetworkLayout = () => { }, 'Chat Conversation' ), - ]), - m('.network-tab-content', [ + m( + 'button.tab-btn' + (State.activeTab === 'graph' ? '.active' : ''), + { onclick: () => { State.activeTab = 'graph'; State.mobilePane = 'detail'; } }, + [m('i.fas.fa-project-diagram'), ' Network Graph'] + ), + ]), + State.activeTab === 'graph' + ? m('.network-tab-content.network-graph-tab', m(NetworkGraph)) + : selectedFriend + ? m('.network-tab-content' + (State.activeTab === 'chat' ? '.network-chat-tab-content' : ''), [ State.activeTab === 'details' ? m(DetailsTab) : m(ChatTab), - ]), - ] - : m('.network-pane-placeholder', [ + ]) + : m('.network-pane-placeholder', [ m('i.fas.fa-network-wired'), m( 'p', - 'Select a friend node from the left side panel to view locations details or start a private chat.' + 'Select a friend for details or chat, or open the Network Graph tab.' ), ]), ]), diff --git a/webui-src/app/network/network_chat_tab.js b/webui-src/app/network/network_chat_tab.js index a10bf86..67a3ff3 100644 --- a/webui-src/app/network/network_chat_tab.js +++ b/webui-src/app/network/network_chat_tab.js @@ -1,9 +1,120 @@ const m = require('mithril'); +const rs = require('rswebui'); const Data = require('network/network_data'); -const { State, startDirectChat, getOnlineSslId, sendDirectChatMessage } = require('network/network_state'); +const { + State, + startDirectChat, + getOnlineSslId, + sendDirectChatMessage, + loadAllDirectChatHistory, +} = require('network/network_state'); +const { renderChatMessage, autoResizeTextarea, openChatImageViewer } = require('chat/chat_state'); +const chatEmoji = require('chat/chat_emoji'); +const HistoryBrowserModal = require('people/people_history'); + +// Direct peer-to-peer chat images do NOT require 200KB compression limit +function formatDirectChatImage(file, callback) { + if (!file) return; + const reader = new FileReader(); + reader.onload = (evt) => { + const img = new Image(); + img.onload = () => { + const maxWidth = 1920; + const maxHeight = 1080; + let width = img.width; + let height = img.height; + + if (width > maxWidth || height > maxHeight) { + const ratio = Math.min(maxWidth / width, maxHeight / height); + width = Math.round(width * ratio); + height = Math.round(height * ratio); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, width, height); + const dataUrl = canvas.toDataURL('image/jpeg', 0.92); + callback(``, dataUrl); + } else { + callback(``, evt.target.result); + } + }; + img.onerror = () => { + if (evt.target.result) { + callback(``, evt.target.result); + } else { + callback(null, null); + } + }; + img.src = evt.target.result; + }; + reader.readAsDataURL(file); +} + +const HASH_TIMEOUT_MS = 5 * 60 * 1000; +let hashJob = null; + +function cancelDirectChatHash(error = '') { + if (hashJob) { + clearTimeout(hashJob.pollTimer); + clearTimeout(hashJob.deadlineTimer); + hashJob = null; + } + State.isHashing = false; + State.hashingError = error; +} + +function isActiveHashJob(job) { + if (hashJob !== job) return false; + if (State.currentChatPeerId !== job.peerId || State.selectedFriendGpgId !== job.friendId) { + cancelDirectChatHash(); + return false; + } + return true; +} + +function pollHashStatusForDirectChat(localpath, job) { + if (!isActiveHashJob(job)) return; + rs.rsJsonApiRequest('/rsFiles/ExtraFileStatus', { localpath }, (data, success) => { + if (!isActiveHashJob(job)) return; + if (!success) { + cancelDirectChatHash('Could not check file hashing. Please try again.'); + m.redraw(); + return; + } + if (data && data.retval && data.info && data.info.hash && data.info.hash !== '0000000000000000000000000000000000000000') { + const info = data.info; + const sizeNum = info.size.xint64 || parseInt(info.size.xstr64) || info.size; + const fileLink = `${info.name} (${rs.formatBytes(sizeNum)})`; + + State.chatInputMsg = State.chatInputMsg ? State.chatInputMsg + '\n' + fileLink : fileLink; + State.showAttachModal = false; + cancelDirectChatHash(); + State.attachPath = ''; + m.redraw(); + } else { + job.pollTimer = setTimeout(() => pollHashStatusForDirectChat(localpath, job), 1000); + } + }); +} const ChatTab = () => { + let showAttachmentMenu = false; + + function onDocClick(e) { + if (showAttachmentMenu && !e.target.closest('.mobile-chat-attachment')) { + showAttachmentMenu = false; + m.redraw(); + } + } + return { + oncreate: () => document.addEventListener('click', onDocClick, true), + onremove: () => { + document.removeEventListener('click', onDocClick, true); + cancelDirectChatHash(); + }, view: () => { const gpgId = State.selectedFriendGpgId; const friend = Data.gpgDetails[gpgId]; @@ -70,53 +181,338 @@ const ChatTab = () => { }), m('span', { style: { color: locOnline ? '#10b981' : '#ef4444', fontWeight: '500' } }, locOnline ? 'Online' : 'Offline') ]) - ]) + ]), + m('button.blue.history-btn', { + title: 'View all direct chat history with this friend', + style: 'padding: 0.25rem 0.75rem; border-radius: 0.25rem; font-size: 0.85rem; display: flex; align-items: center; gap: 0.35rem; border: none; cursor: pointer; background-color: #3b82f6; color: #ffffff; font-weight: 600;', + onclick: () => { + State.showHistoryModal = true; + State.historySearchQuery = ''; + loadAllDirectChatHistory(); + }, + }, [m('i.fas.fa-history'), 'History']) ]); })(), m( '.chat-messages[id=chat-messages-container]', State.chatMessages.map((msg) => { - const isOwn = msg.own === true; + const isOwn = msg.own === true || msg.incoming === false; const senderName = isOwn ? (State.ownProfile.name || 'Me') : friend.name; - const time = new Date(msg.sendTime * 1000).toLocaleTimeString(); - const text = (msg.msg || '') - .replaceAll('
', '\n') - .replace(new RegExp('|<[^>]*>', 'gm'), ''); + const time = new Date((msg.sendTime || msg.recvTime || 0) * 1000).toLocaleTimeString(); + const text = msg.msg || msg.message || ''; return m( '.chat-bubble-container' + (isOwn ? '.outgoing' : '.incoming'), [ !isOwn && m('.chat-sender', senderName), - m('.chat-bubble', text), + m('.chat-bubble', renderChatMessage(text)), m('.chat-time', time), ] ); }) ), - m('.chat-input-area', [ + m(HistoryBrowserModal, { + state: State, + name: friend.name, + ownName: State.ownProfile.name || 'You', + }), + State.attachedImage && m('.chat-attachment-preview', [ + m('.chat-attachment-preview__item', [ + m('img.chat-attachment-preview__thumb', { + src: State.attachedImage.dataUrl, + alt: 'Preview', + title: 'Click to view full image', + onclick: () => openChatImageViewer(State.attachedImage.dataUrl), + }), + m('button.chat-attachment-preview__remove', { + type: 'button', + title: 'Remove image', + onclick: () => { + State.attachedImage = null; + }, + }, m('i.fas.fa-times')), + ]), + m('.chat-attachment-preview__info', [ + m('span.chat-attachment-preview__name', State.attachedImage.name || 'Image attached'), + m('span.chat-attachment-preview__hint', 'Will be sent with your message'), + ]), + ]), + + m('.chat-input-area', { style: 'display: flex; align-items: flex-end; gap: 0.5rem; padding: 0.75rem; background: #ffffff; border-top: 1px solid #cbd5e1;' }, [ + m('button.chat-hub-action-btn.desktop-chat-attachment', { + title: 'Attach file link', + onclick: () => { + State.showAttachModal = true; + State.attachPath = ''; + State.attachBrowseHint = false; + State.hashingError = ''; + m.redraw(); + } + }, m('i.fas.fa-paperclip')), + + m('.mobile-chat-attachment', [ + m('button.chat-hub-action-btn', { + title: 'Add attachment', + onclick: (e) => { + e.stopPropagation(); + showAttachmentMenu = !showAttachmentMenu; + State.showEmojiPicker = false; + }, + }, m('i.fas.fa-paperclip')), + showAttachmentMenu && m('.mobile-chat-attachment__menu', [ + m('button.mobile-chat-attachment__option', { + type: 'button', + onclick: () => { + showAttachmentMenu = false; + State.showAttachModal = true; + State.attachPath = ''; + State.attachBrowseHint = false; + State.hashingError = ''; + }, + }, [m('i.fas.fa-file'), ' File']), + m('label.mobile-chat-attachment__option', [ + m('i.fas.fa-image'), + ' Picture', + m('input[type=file][accept=image/*]', { + style: 'display: none;', + onchange: (e) => { + if (!e.target.files || !e.target.files[0]) return; + const file = e.target.files[0]; + formatDirectChatImage(file, (imgTag, dataUrl) => { + if (imgTag && dataUrl) { + State.attachedImage = { imgTag, dataUrl, name: file.name || 'Image' }; + m.redraw(); + } + }); + showAttachmentMenu = false; + e.target.value = ''; + }, + }), + ]), + ]), + ]), + + m('.emoji-picker-wrapper', { style: 'position: relative;' }, [ + m('button.chat-hub-action-btn', { + title: 'Insert emoji', + onclick: (e) => { + e.stopPropagation(); + State.showEmojiPicker = !State.showEmojiPicker; + } + }, m('i.fas.fa-smile')), + State.showEmojiPicker && m(chatEmoji.EmojiPicker, { + onSelect: (emoji) => { + State.chatInputMsg = (State.chatInputMsg || '') + emoji; + State.showEmojiPicker = false; + m.redraw(); + } + }), + ]), + + m('label.chat-hub-action-btn.desktop-chat-attachment', { + title: 'Send image', + style: 'cursor: pointer;', + }, [ + m('i.fas.fa-image'), + m('input[type=file][accept=image/*]', { + style: 'display: none;', + onchange: (e) => { + if (!e.target.files || !e.target.files[0]) return; + const file = e.target.files[0]; + formatDirectChatImage(file, (imgTag, dataUrl) => { + if (imgTag && dataUrl) { + State.attachedImage = { imgTag, dataUrl, name: file.name || 'Image' }; + m.redraw(); + } + }); + e.target.value = ''; + } + }) + ]), + m('textarea.chat-textarea', { - placeholder: 'Type your message... Press Enter to send', + placeholder: State.attachedImage ? 'Add a caption... (optional)' : 'Type a message here...', value: State.chatInputMsg, + rows: 1, + style: 'flex: 1; resize: none; border: 1px solid #cbd5e1; border-radius: 0.625rem; padding: 0.55rem 0.75rem; font-family: inherit; font-size: 0.9rem; line-height: 1.45; outline: none; min-height: 40px; max-height: 160px; height: 40px; box-sizing: border-box; overflow-y: hidden;', + oncreate: (vnode) => autoResizeTextarea(vnode.dom), + onupdate: (vnode) => autoResizeTextarea(vnode.dom), oninput: (e) => { State.chatInputMsg = e.target.value; + autoResizeTextarea(e.target); + }, + onpaste: (e) => { + const items = (e.clipboardData || (e.originalEvent && e.originalEvent.clipboardData))?.items; + if (!items) return; + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + e.preventDefault(); + const blob = items[i].getAsFile(); + formatDirectChatImage(blob, (imgTag, dataUrl) => { + if (imgTag && dataUrl) { + State.attachedImage = { imgTag, dataUrl, name: 'Pasted image' }; + m.redraw(); + } + }); + break; + } + } }, onkeydown: (e) => { - if (e.code === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendDirectChatMessage(); + if (e.key === 'Enter' || e.code === 'Enter' || e.keyCode === 13) { + if (!e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault(); + sendDirectChatMessage(); + } else if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + if (!document.execCommand || !document.execCommand('insertText', false, '\n')) { + const start = e.target.selectionStart || 0; + const end = e.target.selectionEnd || 0; + const val = e.target.value; + const newVal = val.substring(0, start) + '\n' + val.substring(end); + State.chatInputMsg = newVal; + e.target.value = newVal; + e.target.selectionStart = e.target.selectionEnd = start + 1; + } else { + State.chatInputMsg = e.target.value; + } + autoResizeTextarea(e.target); + } } }, }), m( - 'button.send-btn', + 'button.send-btn.blue', { + style: 'height: 38px;', onclick: () => sendDirectChatMessage(), }, [m('i.fas.fa-paper-plane'), ' Send'] ), ]), + + State.showAttachModal && m('.attach-modal-overlay', { + onclick: (e) => { + if (e.target === e.currentTarget && !State.isHashing) { + State.showAttachModal = false; + State.attachPath = ''; + State.attachBrowseHint = false; + State.hashingError = ''; + } + } + }, [ + m('.attach-modal', [ + m('.attach-modal-header', [ + m('i.fas.fa-paperclip.attach-modal-icon'), + m('h4', 'Attach File to Direct Chat'), + ]), + m('p', 'Browse for a file or type the absolute path on your local system:'), + m('input#direct-attach-file-picker[type=file]', { + style: 'display:none', + onchange: (e) => { + const file = e.target.files && e.target.files[0]; + if (file) { + const fullPath = file.path; + const hasFullPath = fullPath && (fullPath.includes('/') || fullPath.includes('\\')) && fullPath !== file.name; + if (hasFullPath) { + State.attachPath = fullPath; + State.attachBrowseHint = false; + } else { + State.attachPath = file.name; + State.attachBrowseHint = true; + } + e.target.value = ''; + State.hashingError = ''; + m.redraw(); + } + }, + }), + m('.attach-path-row', [ + m('input[type=text]', { + placeholder: 'e.g. C:\\Downloads\\file.zip', + value: State.attachPath, + oninput: (e) => { + State.attachPath = e.target.value; + State.attachBrowseHint = false; + }, + disabled: State.isHashing, + }), + m('button.attach-browse-btn', { + type: 'button', + disabled: State.isHashing, + title: 'Browse for file', + onclick: () => { + const picker = document.getElementById('direct-attach-file-picker'); + if (picker) picker.click(); + }, + }, [m('i.fas.fa-folder-open'), m('span', ' Browse…')]), + ]), + State.attachBrowseHint && m('.attach-path-hint', [ + m('i.fas.fa-info-circle'), + m('span', [ + ' Your browser cannot expose the full file path. ', + m('strong', 'Edit the path above'), + ' and add your folder prefix — e.g. change ', + m('code', 'file.zip'), + ' to ', + m('code', 'C:\\Downloads\\file.zip'), + ' — then click Attach.', + ]), + ]), + State.isHashing && m('.hashing-spinner', [ + m('i.fas.fa-spinner.fa-spin'), + m('span', ' Hashing file... Please wait.') + ]), + !State.attachBrowseHint && State.hashingError && m('p.error-text', State.hashingError), + m('.modal-buttons', [ + m('button.btn.blue', { + disabled: State.isHashing || !State.attachPath.trim() || State.attachBrowseHint, + onclick: () => { + const path = State.attachPath.trim(); + cancelDirectChatHash(); + const job = { + peerId: State.currentChatPeerId, + friendId: State.selectedFriendGpgId, + }; + hashJob = job; + job.deadlineTimer = setTimeout(() => { + if (hashJob !== job) return; + cancelDirectChatHash('File hashing timed out after 5 minutes. Please try again.'); + m.redraw(); + }, HASH_TIMEOUT_MS); + State.isHashing = true; + State.hashingError = ''; + m.redraw(); + + rs.rsJsonApiRequest('/rsFiles/ExtraFileHash', { + localpath: path, + period: 86400 * 7, + flags: 0 + }, (data, success) => { + if (!isActiveHashJob(job)) return; + if (success && data.retval) { + pollHashStatusForDirectChat(path, job); + } else { + cancelDirectChatHash('Failed to initiate file hashing. Check the path and try again.'); + m.redraw(); + } + }); + } + }, [m('i.fas.fa-link'), m('span', ' Attach')]), + m('button.btn.red', { + onclick: () => { + cancelDirectChatHash(); + State.showAttachModal = false; + State.attachPath = ''; + State.attachBrowseHint = false; + State.hashingError = ''; + } + }, 'Cancel') + ]) + ]) + ]), ]); }, }; diff --git a/webui-src/app/network/network_data.js b/webui-src/app/network/network_data.js index 427cbf3..3add09a 100644 --- a/webui-src/app/network/network_data.js +++ b/webui-src/app/network/network_data.js @@ -1,3 +1,4 @@ +const m = require('mithril'); const rs = require('rswebui'); async function refreshIds() { @@ -6,95 +7,363 @@ async function refreshIds() { return sslIds; } -async function loadSslDetails() { - const sslDetails = []; - const sslIds = await refreshIds(); - await Promise.all( - sslIds.map((sslId) => - rs.rsJsonApiRequest('/rsPeers/getPeerDetails', { sslId }, (data) => sslDetails.push(data.det)) - ) - ); - return sslDetails; +// The friend list is read one location at a time -- there is no bulk +// getPeerDetails -- and a node can have two thousand of them. Fired all at +// once they fill the browser's six sockets for minutes on a slow link, and +// every interactive request (opening a chat, the status poll) queues behind. +// So: a few at a time, the list filling as answers land, and the result kept +// for a while, since every page mount used to redo the whole sweep. +const SWEEP_CONCURRENCY = 3; +const GPG_DETAILS_TTL_MS = 5 * 60 * 1000; +let refreshInFlight = null; +let rerunQueued = false; +let refreshedAt = 0; + +function runQueued(tasks, concurrency = SWEEP_CONCURRENCY) { + return new Promise((resolve) => { + let next = 0; + let finished = 0; + if (tasks.length === 0) { + resolve(); + return; + } + const startNext = () => { + if (next >= tasks.length) return; + const task = tasks[next++]; + Promise.resolve() + .then(task) + .catch(() => {}) + .then(() => { + finished += 1; + if (finished >= tasks.length) resolve(); + else startNext(); + }); + }; + for (let i = 0; i < concurrency && i < tasks.length; i++) startNext(); + }); +} + +async function loadOnlineIds() { + let ids = []; + await rs.rsJsonApiRequest('/rsPeers/getOnlineList', {}, (data) => { + if (data && data.sslIds) ids = data.sslIds; + }); + return new Set(ids); +} + +let cacheLogin = null; +let cachedDetails = {}; + +function currentCacheLogin() { + const login = rs.loginKey || {}; + return JSON.stringify([login.url, login.username, login.isVerified, login.generation]); +} + +function ensureCacheLogin() { + const key = currentCacheLogin(); + if (cacheLogin !== key) { + cacheLogin = key; + cachedDetails = {}; + refreshInFlight = null; + refreshedAt = 0; + // A rerun queued under the previous login must not swallow the next + // login's first force call. + rerunQueued = false; + } + return key; } const Data = { - gpgDetails: {}, + get gpgDetails() { + ensureCacheLogin(); + return cachedDetails; + }, + set gpgDetails(details) { + ensureCacheLogin(); + cachedDetails = details; + }, + runQueued, }; -Data.refreshGpgDetails = async function () { + +// A remembered friend is a placeholder shown while the core catches up with an +// addSslOnlyFriend / loadCertificateFromString that has just returned. That is +// a matter of seconds; anything older means the add did not stick, or the peer +// has since been removed, and the placeholder has to go rather than be +// re-injected into the friend list on every refresh, browser restarts included. +const PENDING_FRIEND_TTL_MS = 5 * 60 * 1000; + +// Keyed per node: several RetroShare profiles can be reached from the same +// browser, and their friend lists have nothing to do with each other. Read +// lazily, since the login is not known when this module is first imported. +let pendingFriends = null; +let pendingFriendsKey = null; + +function storageKey() { + const login = rs.loginKey || {}; + return 'rs-webui-pending-friends:' + (login.url || '') + '|' + (login.username || ''); +} + +function loadPendingFriends() { + const key = storageKey(); + if (pendingFriends !== null && pendingFriendsKey === key) return pendingFriends; + pendingFriendsKey = key; + try { + pendingFriends = JSON.parse(localStorage.getItem(key) || '{}'); + } catch (_) { + pendingFriends = {}; + } + return pendingFriends; +} + +function savePendingFriends() { + try { + localStorage.setItem(pendingFriendsKey || storageKey(), JSON.stringify(pendingFriends || {})); + } catch (_) { + // The in-memory entry still works when private browsing blocks storage. + } +} + +Data.forgetPendingFriend = function (gpgId) { + const pending = loadPendingFriends(); + const key = String(gpgId || '').toLowerCase(); + if (!key || !pending[key]) return; + delete pending[key]; + delete Data.gpgDetails[key]; + savePendingFriends(); +}; + +function hasValidatedFingerprint(value) { + const fingerprint = String(value || '').replace(/\s/g, ''); + return /[1-9a-f]/i.test(fingerprint); +} + +Data.rememberPendingFriend = function (peerDetails) { + const pending = loadPendingFriends(); + const data = peerDetails || {}; + const gpgId = String(data.gpg_id || data.pgpId || '').toLowerCase(); + const sslId = String(data.id || data.sslId || ''); + if (!gpgId || !sslId) return; + const pendingValidation = !hasValidatedFingerprint(data.fpr || data.fingerprint); + + pending[gpgId] = { + rememberedAt: Date.now(), + name: data.name || (pendingValidation + ? `Profile ID ${gpgId.toUpperCase()} (Not yet validated)` + : `Profile ID ${gpgId.toUpperCase()}`), + fingerprint: data.fpr || '', + isSearched: false, + isOnline: false, + pendingValidation, + locations: [{ + name: data.location || 'Unknown location', + id: sslId, + lastSeen: data.lastConnect || 0, + isOnline: false, + gpg_id: gpgId, + customState: '', + statusValue: 0, + statusTimestamp: 0, + avatar: '', + peerDetails: data, + }], + customState: '', + statusValue: 0, + statusTimestamp: 0, + avatar: '', + }; + Data.gpgDetails[gpgId] = pending[gpgId]; + savePendingFriends(); +}; + +function normalizeStatusValue(value, fallback) { + if (value && typeof value === 'object') value = value.value ?? value.status ?? value.xint32; + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric; + const names = { OFFLINE: 0, AWAY: 1, BUSY: 2, ONLINE: 3, INACTIVE: 4 }; + const match = Object.keys(names).find((name) => value.toUpperCase().includes(name)); + if (match) return names[match]; + } + return fallback; +} + +Data.getStatusPresentation = function (statusValue, isOnline = false) { + const value = normalizeStatusValue(statusValue, isOnline ? 3 : 0); + return { + value, + label: ['Offline', 'Away', 'Busy', 'Online', 'Inactive'][value] || (isOnline ? 'Online' : 'Offline'), + color: ['#94a3b8', '#eab308', '#ef4444', '#10b981', '#f59e0b'][value] || '#94a3b8', + }; +}; + +// `force` redoes the sweep whatever its age: after adding or removing a +// friend. Otherwise a fresh enough result is only touched up with the online +// list, one request, and concurrent callers share the sweep in flight. +Data.refreshGpgDetails = function (options = {}) { + const login = ensureCacheLogin(); + const force = Boolean(options && options.force); + if (refreshInFlight) { + if (!force) return refreshInFlight; + // force is called right after adding or removing a friend, and the + // sweep in flight read its friend list BEFORE that change: joining it + // answers with the world as it was -- the added friend missing, the + // removed one back on screen. Chain ONE fresh sweep behind it; more + // force calls while it waits share that rerun. + if (rerunQueued) return refreshInFlight; + rerunQueued = true; + const rerun = refreshInFlight.catch(() => {}).then(() => { + rerunQueued = false; + if (refreshInFlight === rerun) refreshInFlight = null; + if (currentCacheLogin() !== login) return undefined; + if (refreshInFlight) return refreshInFlight; + return Data.refreshGpgDetails({ force: true }); + }); + refreshInFlight = rerun; + return rerun; + } + if (!force && refreshedAt && Date.now() - refreshedAt < GPG_DETAILS_TTL_MS) { + return refreshOnlineFlags(login); + } + refreshInFlight = sweepGpgDetails(login) + .then(() => { if (currentCacheLogin() === login) refreshedAt = Date.now(); }) + .finally(() => { if (currentCacheLogin() === login) refreshInFlight = null; }); + return refreshInFlight; +}; + +async function refreshOnlineFlags(login) { + const online = await loadOnlineIds(); + if (currentCacheLogin() !== login) return; + Object.values(Data.gpgDetails || {}).forEach((friend) => { + let anyOnline = false; + (friend.locations || []).forEach((loc) => { + loc.isOnline = online.has(loc.id); + anyOnline = anyOnline || loc.isOnline; + }); + friend.isOnline = anyOnline; + }); +} + +async function sweepGpgDetails(login) { const details = {}; - const sslDetails = await loadSslDetails(); - await Promise.all( - sslDetails.map((data) => { - let isOnline = false; - return rs - .rsJsonApiRequest( - '/rsPeers/isOnline', - { sslId: data.id }, - (stat) => (isOnline = stat.retval) - ) - .then(() => { - let customState = ''; - return rs - .rsJsonApiRequest( - '/rsChats/getCustomStateString', - { peer_id: data.id }, - (statusData) => { - if (statusData && statusData.retval) { - customState = statusData.retval; - } - } - ) - .catch(() => {}) - .then(() => { - let avatar = ''; - return rs - .rsJsonApiRequest( - '/rsChats/getAvatar', - { pid: data.id }, - (avatarData) => { - if (avatarData && avatarData.retval && avatarData.avatar_base64_string) { - avatar = avatarData.avatar_base64_string; - } - } - ) - .catch(() => {}) - .then(() => { - const gpgId = (data.gpg_id || '').toLowerCase(); - const loc = { - name: data.location, - id: data.id, - lastSeen: data.lastConnect, - isOnline, - gpg_id: gpgId, - customState, - avatar, - }; + const sslIds = await refreshIds(); + if (currentCacheLogin() !== login) return; + const online = await loadOnlineIds(); + if (currentCacheLogin() !== login) return; - if (details[gpgId] === undefined) { - details[gpgId] = { - name: data.name, - isSearched: true, - isOnline, - locations: [loc], - customState, - avatar: avatar || '', - }; - } else { - details[gpgId].locations.push(loc); - if (avatar) { - details[gpgId].avatar = avatar; - } - if (!details[gpgId].customState || (isOnline && customState)) { - details[gpgId].customState = customState; - } - } - details[gpgId].isOnline = details[gpgId].isOnline || isOnline; - }); - }); - }); - }) - ); + // A first load shows the list as it fills rather than nothing for the + // whole sweep; a refresh keeps the old list on screen until it is done. + const firstLoad = Object.keys(Data.gpgDetails || {}).length === 0; + if (firstLoad) Data.gpgDetails = details; + let sinceRedraw = 0; + const addLocation = (data, isOnline, customState, statusValue, statusTimestamp) => { + const gpgId = (data.gpg_id || '').toLowerCase(); + const loc = { + name: data.location, + id: data.id, + lastSeen: data.lastConnect, + isOnline, + gpg_id: gpgId, + customState, + statusValue, + statusTimestamp, + avatar: '', + peerDetails: data, + }; + + if (details[gpgId] === undefined) { + details[gpgId] = { + name: data.name, + fingerprint: data.fpr || '', + isSearched: true, + isOnline, + locations: [loc], + customState, + statusValue, + statusTimestamp, + avatar: '', + }; + } else { + details[gpgId].locations.push(loc); + if (!details[gpgId].fingerprint && data.fpr) { + details[gpgId].fingerprint = data.fpr; + } + if (!details[gpgId].customState || (isOnline && customState)) { + details[gpgId].customState = customState; + } + if (isOnline || !details[gpgId].isOnline) { + details[gpgId].statusValue = statusValue; + details[gpgId].statusTimestamp = statusTimestamp; + } + } + details[gpgId].isOnline = details[gpgId].isOnline || isOnline; + + if (firstLoad && ++sinceRedraw >= 25) { + sinceRedraw = 0; + m.redraw(); + } + }; + + // Status string and status value only mean something for a peer that is + // connected: two requests per online peer instead of two per location. + const tasks = sslIds.map((sslId) => async () => { + if (currentCacheLogin() !== login) return; + let data = null; + await rs.rsJsonApiRequest('/rsPeers/getPeerDetails', { sslId }, (res) => { + if (res && res.det) data = res.det; + }); + if (!data || currentCacheLogin() !== login) return; + + const isOnline = online.has(sslId); + let customState = ''; + let statusValue = isOnline ? 3 : 0; + let statusTimestamp = 0; + if (isOnline) { + await rs.rsJsonApiRequest('/rsChats/getCustomStateString', { peer_id: sslId }, (statusData) => { + if (statusData && statusData.retval) customState = statusData.retval; + }); + if (currentCacheLogin() !== login) return; + await rs.rsJsonApiRequest('/rsStatus/getStatus', { id: sslId }, (statusData) => { + if (statusData && statusData.retval && statusData.statusInfo) { + statusValue = normalizeStatusValue(statusData.statusInfo.status, statusValue); + statusTimestamp = statusData.statusInfo.time_stamp || 0; + } + }); + } + if (currentCacheLogin() !== login) return; + addLocation(data, isOnline, customState, statusValue, statusTimestamp); + }); + await runQueued(tasks, SWEEP_CONCURRENCY); + if (currentCacheLogin() !== login) return; + + const remembered = loadPendingFriends(); + let rememberedChanged = false; + Object.entries(remembered).forEach(([gpgId, pending]) => { + if (details[gpgId]) { + const nativeFriend = details[gpgId]; + const isValidated = hasValidatedFingerprint(nativeFriend.fingerprint || pending.fingerprint); + + // Unvalidated short-invite peers are returned with an empty profile + // name and an all-zero fingerprint. Keep the name parsed from the + // RetroShare ID until the core has validated the PGP profile. + if (!nativeFriend.name) nativeFriend.name = pending.name; + if (isValidated) { + delete remembered[gpgId]; + rememberedChanged = true; + } else { + nativeFriend.pendingValidation = true; + } + } else if (Date.now() - (pending.rememberedAt || 0) < PENDING_FRIEND_TTL_MS) { + // The core does not know this profile yet: keep showing the placeholder, + // but only for as long as it can plausibly still be catching up. + details[gpgId] = pending; + } else { + delete remembered[gpgId]; + rememberedChanged = true; + } + }); + if (rememberedChanged) savePendingFriends(); Data.gpgDetails = details; -}; +} module.exports = Data; diff --git a/webui-src/app/network/network_details_tab.js b/webui-src/app/network/network_details_tab.js index 44793fc..c096ace 100644 --- a/webui-src/app/network/network_details_tab.js +++ b/webui-src/app/network/network_details_tab.js @@ -5,6 +5,59 @@ const Data = require('network/network_data'); const peopleUtil = require('people/people_util'); const { State, startDirectChat, getOnlineSslId } = require('network/network_state'); +function formatFingerprint(fingerprint) { + return String(fingerprint || '') + .replace(/\s/g, '') + .match(/.{1,4}/g) + ?.join(' ') || ''; +} + +function isUsableAddress(address) { + const value = String(address || '').trim(); + return value !== '' && !value.toUpperCase().includes('INVALID') && value !== '0.0.0.0'; +} + +// An entry of RsPeerDetails::ipAddressList is what sockaddr_storage_tostring() +// produced -- ipv4://1.2.3.4:1234, or ipv6://[fe80::1]:1234 since RsUrl wraps +// IPv6 hosts in brackets -- followed by the core's own marker, " 123 sec +// loc" or " 123 sec ext" (p3peers.cc, getPeerDetails). +// +// That marker is the answer to "local or external", and it is worth more than +// deducing it from the address range: a peer behind CGNAT (100.64/10) or a +// double NAT has a private looking external address, and a loopback entry is +// not external at all. +// +// One entry carries no marker: GetRetroshareInvite() clears extAddr and pushes +// the address here with a trailing space when it is IPv6, because the +// certificate format only carries IPv4 numbers. It is external by construction +// -- and it is exactly the one a peer added by short invite arrives with. +function parseLocator(entry) { + const text = String(entry || ''); + const match = text.match(/^\s*(?:ipv4|ipv6):\/\/(\[[^\]]+\]|[^:/\s]+):(\d+)/i); + if (!match) return null; + // RsUrl escapes the % of a link-local scope id as %25, as the RFC asks; put + // it back rather than showing fe80::1%25eth0 to a human. + const host = (match[1].startsWith('[') ? match[1].slice(1, -1) : match[1]).replace(/%25/gi, '%'); + return { + address: host, + port: Number(match[2]), + scope: /\bsec\s+loc\b/i.test(text) ? 'local' : 'external', + }; +} + +function displayedAddresses(detail, knownAddresses) { + const locators = knownAddresses.map(parseLocator).filter(Boolean); + const localLocator = locators.find((locator) => locator.scope === 'local'); + const externalLocator = locators.find((locator) => locator.scope === 'external'); + + return { + localAddress: isUsableAddress(detail.localAddr) ? detail.localAddr : localLocator && localLocator.address, + localPort: Number(detail.localPort) > 0 ? detail.localPort : localLocator && localLocator.port, + externalAddress: isUsableAddress(detail.extAddr) ? detail.extAddr : externalLocator && externalLocator.address, + externalPort: Number(detail.extPort) > 0 ? detail.extPort : externalLocator && externalLocator.port, + }; +} + const ConfirmRemove = () => { return { view: (vnode) => [ @@ -14,12 +67,19 @@ const ConfirmRemove = () => { m( 'button', { - onclick: () => { - rs.rsJsonApiRequest('/rsPeers/removeFriend', { + onclick: async () => { + // Drop the placeholder first: refreshGpgDetails() re-injects any + // remembered friend the core does not return, so removing one added + // by short ID would otherwise put it straight back in the list. + Data.forgetPendingFriend(vnode.attrs.gpg_id); + // And wait for the removal before asking for the list again, or the + // refresh races the core and shows the friend as still there. + await rs.rsJsonApiRequest('/rsPeers/removeFriend', { pgpId: vnode.attrs.gpg_id, }); State.selectedFriendGpgId = null; - Data.refreshGpgDetails().then(() => m.redraw()); + await Data.refreshGpgDetails({ force: true }); + m.redraw(); widget.popupMessage(m('p', 'Friend removed successfully.')); }, }, @@ -29,6 +89,109 @@ const ConfirmRemove = () => { }; }; +// Version and short invite of a node do not change while the web UI is open, +// and the dialog is reopened often. Cached by node id so that reopening it +// paints filled in, instead of showing "Loading..." and asking the core again. +const locationDetailsCache = {}; + +const LocationDetails = () => { + let activeTab = 'details'; + let version = 'Loading...'; + let retroshareId = 'Loading...'; + + return { + oninit: (vnode) => { + const nodeId = vnode.attrs.loc.id; + const cached = locationDetailsCache[nodeId]; + if (cached) { + version = cached.version; + retroshareId = cached.retroshareId; + return; + } + locationDetailsCache[nodeId] = { version, retroshareId }; + + // rsJsonApiRequest never rejects: it resolves undefined when the request + // fails, so the failure has to be read off the resolved value rather than + // waited for in a catch. + rs.rsJsonApiRequest('/rsGossipDiscovery/getPeerVersion', { id: nodeId }) + .then((response) => { + version = response && response.body && response.body.retval + ? response.body.version || 'Unknown' + : 'Unavailable'; + locationDetailsCache[nodeId].version = version; + m.redraw(); + }); + rs.rsJsonApiRequest('/rsPeers/getShortInvite', { sslId: nodeId }) + .then((response) => { + retroshareId = response && response.body && response.body.retval + ? rs.cleanRetroshareId(response.body.invite) || 'Unavailable' + : 'Unavailable'; + locationDetailsCache[nodeId].retroshareId = retroshareId; + m.redraw(); + }); + }, + view: (vnode) => { + const loc = vnode.attrs.loc; + const detail = loc.peerDetails || {}; + const status = Data.getStatusPresentation(loc.statusValue, loc.isOnline); + const knownAddresses = detail.ipAddressList || []; + const addresses = displayedAddresses(detail, knownAddresses); + const infoRow = (label, value) => [ + m('.info-label', label), + m('.info-value', value || 'None'), + ]; + + const detailContent = m('.info-grid', [ + infoRow('Profile', `${detail.name || 'Unknown'} (${loc.gpg_id})`), + infoRow('Node ID', loc.id), + infoRow('Node Name', loc.name), + infoRow('Status', status.label), + infoRow('Connection', detail.connectStateString || status.label), + infoRow('Last Contact', new Date(loc.lastSeen * 1000).toLocaleString()), + infoRow('RetroShare Version', version), + infoRow('Status Message', loc.customState || 'None'), + ]); + const connectivityContent = [ + m('.info-grid', detail.isHiddenNode ? [ + infoRow('Hidden Address', detail.hiddenNodeAddress), + infoRow('Port', detail.hiddenNodePort), + ] : [ + infoRow('Local Address', addresses.localAddress), + infoRow('Local Port', addresses.localPort), + infoRow('External Address', addresses.externalAddress), + infoRow('External Port', addresses.externalPort), + infoRow('Dynamic DNS', detail.dyndns), + ]), + m('h4', `Known Addresses (${knownAddresses.length})`), + knownAddresses.length + ? m('pre.known-addresses-list', knownAddresses.join('\n')) + : m('p', 'No address history available.'), + ]; + const tabs = [ + ['details', 'Details'], + ['connectivity', 'Connectivity'], + ['retroshare-id', 'RetroShare ID'], + ]; + + return m('.location-details-dialog', [ + m('h3', `${detail.name || 'Profile'} (${loc.name || 'Location'})`), + m('.network-tabs.location-detail-tabs', tabs.map(([id, label]) => m( + `button.tab-btn${activeTab === id ? '.active' : ''}`, + { onclick: () => (activeTab = id) }, + label + ))), + m('.location-detail-content', + activeTab === 'details' + ? detailContent + : activeTab === 'connectivity' + ? connectivityContent + : m('pre.retroshare-id-text', retroshareId) + ), + ]); + }, + }; +}; + const DetailsTab = () => { return { view: () => { @@ -37,6 +200,10 @@ const DetailsTab = () => { if (!friend) return null; const friendGxsId = State.gpgToGxsIdMap[gpgId.toLowerCase()]; + const status = friend.pendingValidation + ? { label: 'Pending validation', color: '#b45309' } + : Data.getStatusPresentation(friend.statusValue, friend.isOnline); + const fingerprint = formatFingerprint(friend.fingerprint); return m('.network-detail-view', [ m('.detail-header', [ @@ -52,30 +219,30 @@ const DetailsTab = () => { m('i.fas.fa-fingerprint'), m('span', 'GPG ID: ' + gpgId), ]), - ]), - m('.detail-actions', [ - m( - 'button', - { - onclick: () => { - const sslId = getOnlineSslId(gpgId); - if (sslId) { - State.activeTab = 'chat'; - startDirectChat(sslId); - } + m('.detail-actions', { style: 'margin-top: 0.75rem;' }, [ + m( + 'button', + { + onclick: () => { + const sslId = getOnlineSslId(gpgId); + if (sslId) { + State.activeTab = 'chat'; + startDirectChat(sslId); + } + }, }, - }, - [m('i.fas.fa-comments'), ' Start Chat'] - ), - m( - 'button', - { - onclick: () => { - State.showMailCompose = true; + [m('i.fas.fa-comments'), m('span.btn-text', ' Start Chat')] + ), + m( + 'button', + { + onclick: () => { + State.showMailCompose = true; + }, }, - }, - [m('i.fas.fa-envelope'), ' Send Mail'] - ), + [m('i.fas.fa-envelope'), m('span.btn-text', ' Send Mail')] + ), + ]), ]), ]), @@ -85,8 +252,8 @@ const DetailsTab = () => { m('.info-label', 'Status'), m( '.info-value', - { style: friend.isOnline ? 'color: #10b981; font-weight: 600;' : '' }, - friend.isOnline ? 'Online' : 'Offline' + { style: `color: ${status.color}; font-weight: 600;` }, + status.label ), m('.info-label', 'Custom Status'), m( @@ -100,6 +267,8 @@ const DetailsTab = () => { ] : null, m('.info-label', 'Node GPG Key'), m('.info-value', gpgId), + m('.info-label', 'PGP Fingerprint'), + m('.info-value', fingerprint || 'Unavailable'), ]), ]), @@ -110,13 +279,15 @@ const DetailsTab = () => { friend.locations .slice() .sort((a, b) => (a.isOnline === b.isOnline ? 0 : a.isOnline ? -1 : 1)) - .map((loc) => - m('.location-card', { key: loc.id }, [ + .map((loc) => { + const locStatus = Data.getStatusPresentation(loc.statusValue, loc.isOnline); + return m('.location-card', { key: loc.id }, [ m('.loc-header', [ m('.loc-name', loc.name), m( - '.loc-status' + (loc.isOnline ? '.online' : '.offline'), - loc.isOnline ? 'Online' : 'Offline' + '.loc-status', + { style: { color: locStatus.color } }, + locStatus.label ), ]), m('.loc-body', [ @@ -126,6 +297,16 @@ const DetailsTab = () => { m('.loc-val', new Date(loc.lastSeen * 1000).toLocaleString()), ]), m('.loc-footer', [ + m( + 'button', + { + onclick: () => widget.popupMessage( + m(LocationDetails, { loc }), + 'location-details-modal' + ), + }, + [m('i.fas.fa-info-circle'), ' View Details'] + ), m( 'button.red', { @@ -139,8 +320,8 @@ const DetailsTab = () => { 'Remove Location' ), ]), - ]) - ) + ]); + }) ), ]), ]); diff --git a/webui-src/app/network/network_friends_list.js b/webui-src/app/network/network_friends_list.js index cee5328..edabec1 100644 --- a/webui-src/app/network/network_friends_list.js +++ b/webui-src/app/network/network_friends_list.js @@ -1,29 +1,125 @@ const m = require('mithril'); const Data = require('network/network_data'); const peopleUtil = require('people/people_util'); -const { State, startDirectChat, getOnlineSslId } = require('network/network_state'); +const chatPreviewText = require('chat/chat_preview'); +const { + State, + startDirectChat, + getOnlineSslId, + setOwnCustomStateString, + setOwnStatus, + markDirectChatRead, +} = require('network/network_state'); + +function formatRelativeTime(ts) { + if (!ts) return ''; + const now = Math.floor(Date.now() / 1000); + const diff = now - ts; + if (diff < 30) return 'Just Now'; + if (diff < 3600) return `${Math.floor(diff / 60)} min${Math.floor(diff / 60) > 1 ? 's' : ''}`; + if (diff < 86400) return `${Math.floor(diff / 3600)} hr${Math.floor(diff / 3600) > 1 ? 's' : ''}`; + return `${Math.floor(diff / 86400)} d`; +} const OwnProfileCard = () => { + let isEditing = false; + let isPresenceMenuOpen = false; + let statusInputText = ''; + return { view: () => { const avatar = State.ownProfile.avatar ? { mData: { base64: State.ownProfile.avatar } } : undefined; const firstLetter = (State.ownProfile.name || 'U').slice(0, 1).toUpperCase(); + const displayName = State.ownProfile.location + ? `${State.ownProfile.name || 'Unknown'} (${State.ownProfile.location})` + : State.ownProfile.name || 'Loading...'; + const status = Data.getStatusPresentation(State.ownProfile.statusValue, true); return m('.own-profile-card', [ m('.profile-header', [ - m(peopleUtil.UserAvatar, { avatar, firstLetter, seed: State.ownProfile.name }), + m('.profile-avatar-wrapper', [ + m(peopleUtil.UserAvatar, { avatar, firstLetter, seed: State.ownProfile.name }), + m('button.status-dot.profile-status-button', { + 'aria-label': `Change status. Current status: ${status.label}`, + 'aria-expanded': String(isPresenceMenuOpen), + style: { backgroundColor: status.color }, + title: `Status: ${status.label}. Click to change.`, + onclick: () => { + isPresenceMenuOpen = !isPresenceMenuOpen; + }, + }), + isPresenceMenuOpen && m('.profile-presence-menu', [ + [ + { value: 3, label: 'Online' }, + { value: 1, label: 'Away' }, + { value: 2, label: 'Busy' }, + ].map((option) => { + const optionStatus = Data.getStatusPresentation(option.value, true); + return m('button.profile-presence-option', { + class: status.value === option.value ? 'active' : '', + onclick: () => { + setOwnStatus(option.value); + isPresenceMenuOpen = false; + }, + }, [ + m('span', { style: { backgroundColor: optionStatus.color } }), + option.label, + status.value === option.value && m('i.fas.fa-check'), + ]); + }), + ]), + ]), m('.profile-info', [ - m('.profile-name', State.ownProfile.name || 'Loading...'), - m('.profile-status', 'Online'), - State.ownProfile.customState && - m( - '.profile-custom-status', - { - style: 'font-size: 0.8rem; color: #94a3b8; font-style: italic; margin-top: 2px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; max-width: 150px;', - title: State.ownProfile.customState, - }, - State.ownProfile.customState - ), + m('.profile-name', { title: displayName }, displayName), + isEditing + ? m('.profile-custom-status-edit', { + style: 'display: flex; align-items: center; gap: 4px; margin-top: 3px;' + }, [ + m('input[type=text]', { + value: statusInputText, + placeholder: 'Set custom status...', + style: 'font-size: 0.8rem; padding: 2px 6px; border: 1px solid #3ba4d7; border-radius: 4px; width: 125px; outline: none; background: #ffffff;', + oninput: (e) => { statusInputText = e.target.value; }, + onkeydown: (e) => { + if (e.key === 'Enter') { + setOwnCustomStateString(statusInputText); + isEditing = false; + } else if (e.key === 'Escape') { + isEditing = false; + } + }, + oncreate: (vnode) => vnode.dom.focus(), + }), + m('i.fas.fa-check', { + style: 'cursor: pointer; color: #10b981; font-size: 0.85rem; padding: 2px;', + title: 'Save status', + onclick: () => { + setOwnCustomStateString(statusInputText); + isEditing = false; + }, + }), + m('i.fas.fa-times', { + style: 'cursor: pointer; color: #ef4444; font-size: 0.85rem; padding: 2px;', + title: 'Cancel', + onclick: () => { + isEditing = false; + }, + }), + ]) + : m( + '.profile-custom-status', + { + style: State.ownProfile.customState + ? 'font-size: 0.825rem; color: #64748b; font-style: italic; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; max-width: 180px; cursor: pointer; margin-top: 2px;' + : 'font-size: 0.825rem; color: #94a3b8; font-style: italic; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; max-width: 180px; cursor: pointer; margin-top: 2px;', + title: 'Edit status message', + onclick: () => { + statusInputText = State.ownProfile.customState || ''; + isEditing = true; + }, + }, + State.ownProfile.customState || 'Set custom status...' + ), ]), ]), ]); @@ -35,66 +131,201 @@ const FriendsList = () => { return { view: () => { const search = State.searchString.toLowerCase(); - const filteredFriends = Object.entries(Data.gpgDetails).filter( - ([gpgId, friend]) => (friend.name || '').toLowerCase().includes(search) - ); + const allGpgEntries = Object.entries(Data.gpgDetails || {}); + + // Compute active chats count + let unreadChatsCount = 0; + allGpgEntries.forEach(([gpgId]) => { + unreadChatsCount += State.unreadChatCount[gpgId] || 0; + }); + + let displayFriends; + + if (State.mainTab === 'network') { + displayFriends = allGpgEntries.filter(([gpgId, friend]) => + (friend.name || '').toLowerCase().includes(search) + ); + displayFriends.sort((a, b) => + a[1].isOnline === b[1].isOnline ? 0 : a[1].isOnline ? -1 : 1 + ); + } else { + // Chats Tab: filter friends with chat history + displayFriends = allGpgEntries.filter(([gpgId, friend]) => { + const hist = State.chatHistoryMap && State.chatHistoryMap[gpgId]; + if (!hist || !hist.lastMsg) return false; + return (friend.name || '').toLowerCase().includes(search); + }); + + displayFriends.sort((a, b) => { + const histA = State.chatHistoryMap[a[0]]; + const histB = State.chatHistoryMap[b[0]]; + const timeA = histA ? histA.lastTime : 0; + const timeB = histB ? histB.lastTime : 0; + return timeB - timeA; + }); + } return m('.friends-list-container', [ - m('.searchbar-container', [ - m('input.searchbar', { - type: 'text', - placeholder: 'Search friends...', - value: State.searchString, - oninput: (e) => { - State.searchString = e.target.value; - }, - }), + m('.people-sidebar-header', [ + m('.searchbar-wrapper', [ + m('i.fas.fa-search'), + m('input.searchbar-input', { + type: 'text', + placeholder: State.mainTab === 'network' ? 'Search friends...' : 'Search chats...', + value: State.searchString, + oninput: (e) => { + State.searchString = e.target.value; + }, + }), + ]), + m('.segmented-control', [ + m( + 'button.segment-tab' + (State.mainTab === 'network' ? '.active' : ''), + { + onclick: () => { + State.mainTab = 'network'; + }, + }, + [m('i.fas.fa-users'), ' Network'] + ), + m( + 'button.segment-tab' + (State.mainTab === 'chats' ? '.active' : ''), + { + onclick: () => { + State.mainTab = 'chats'; + }, + }, + [ + m('i.fas.fa-comments'), + ' Chats', + unreadChatsCount > 0 && m('span.segment-badge', unreadChatsCount), + ] + ), + m( + 'button.segment-tab.mobile-graph-shortcut', + { + onclick: () => { + State.activeTab = 'graph'; + State.mobilePane = 'detail'; + }, + }, + [m('i.fas.fa-project-diagram'), ' Graph'] + ), + ]), ]), m('.friends-scroll', [ - filteredFriends.length === 0 - ? m('p', { style: 'padding: 1rem; color: #94a3b8; text-align: center;' }, 'No friends found') - : filteredFriends - .sort((a, b) => (a[1].isOnline === b[1].isOnline ? 0 : a[1].isOnline ? -1 : 1)) - .map(([gpgId, friend]) => { - const avatar = friend.avatar ? { mData: { base64: friend.avatar } } : undefined; - const firstLetter = (friend.name || '?').slice(0, 1).toUpperCase(); - const isSelected = State.selectedFriendGpgId === gpgId; + displayFriends.length === 0 + ? m( + 'p', + { style: 'padding: 1rem; color: #94a3b8; text-align: center;' }, + State.mainTab === 'network' ? 'No friends found' : 'No active chats found' + ) + : displayFriends.map(([gpgId, friend]) => { + const avatar = friend.avatar ? { mData: { base64: friend.avatar } } : undefined; + const firstLetter = (friend.name || '?').slice(0, 1).toUpperCase(); + const isSelected = State.selectedFriendGpgId === gpgId; + const hist = State.chatHistoryMap && State.chatHistoryMap[gpgId]; + const status = friend.pendingValidation + ? { value: 0, label: 'Pending validation', color: '#f59e0b' } + : Data.getStatusPresentation(friend.statusValue, friend.isOnline); + const isOnlineOrActive = friend.isOnline || (status && status.value > 0); + + if (State.mainTab === 'chats') { + // Render Chat List Item return m( - `.friend-list-item${isSelected ? '.selected' : ''}`, + `.chat-item${isSelected ? '.selected' : ''}`, { key: gpgId, onclick: () => { State.selectedFriendGpgId = gpgId; - State.currentChatPeerId = null; - State.chatMessages = []; - if (State.activeTab === 'chat') { - const sslId = getOnlineSslId(gpgId); - if (sslId) startDirectChat(sslId); - } + State.activeTab = 'chat'; + State.mobilePane = 'detail'; + markDirectChatRead(gpgId); + const sslId = getOnlineSslId(gpgId); + if (sslId) startDirectChat(sslId); }, }, [ - m('.friend-avatar', m(peopleUtil.UserAvatar, { avatar, firstLetter, seed: gpgId })), - m('.friend-meta', [ - m('.friend-name', friend.name), + m('.chat-avatar-wrapper', [ + m(peopleUtil.UserAvatar, { avatar, firstLetter, seed: gpgId }), + m('.status-dot', { + style: { + backgroundColor: status.color, + }, + title: status.label, + }), + ]), + m('.chat-info', [ m( - `.friend-status${friend.isOnline ? '.online' : ''}`, - friend.isOnline ? 'Online' : 'Offline' + '.chat-name', + { + style: isOnlineOrActive ? { color: status.color, fontWeight: '700' } : {}, + }, + friend.name ), - friend.customState && - m( - '.friend-custom-status', - { - style: 'font-size: 0.85rem; color: #64748b; margin-top: 2px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; max-width: 160px;', - title: friend.customState, - }, - friend.customState - ), + m('.chat-last-msg', hist ? chatPreviewText(hist.lastMsg) : ''), + ]), + m('.chat-meta', [ + hist && hist.lastTime && m('.chat-time', formatRelativeTime(hist.lastTime)), + (State.unreadChatCount[gpgId] || 0) > 0 && + m('.chat-unread-badge', State.unreadChatCount[gpgId]), ]), ] ); - }), + } + + // Render Network Friend List Item + return m( + `.friend-list-item${isSelected ? '.selected' : ''}`, + { + key: gpgId, + onclick: () => { + State.selectedFriendGpgId = gpgId; + State.activeTab = 'details'; + State.mobilePane = 'detail'; + State.currentChatPeerId = null; + State.chatMessages = []; + if (State.activeTab === 'chat') { + const sslId = getOnlineSslId(gpgId); + if (sslId) startDirectChat(sslId); + } + }, + }, + [ + m('.friend-avatar', [ + m(peopleUtil.UserAvatar, { avatar, firstLetter, seed: gpgId }), + m('.status-dot', { + style: { backgroundColor: status.color }, + title: status.label, + }), + ]), + m('.friend-meta', [ + m( + '.friend-name', + { + style: isOnlineOrActive ? { color: status.color, fontWeight: '700' } : {}, + }, + friend.name + ), + friend.customState && + m( + '.friend-custom-status', + { + style: + 'font-size: 0.85rem; color: #64748b; margin-top: 2px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; max-width: 180px;', + title: friend.customState, + }, + friend.customState + ), + friend.pendingValidation && + m('.friend-custom-status', { + style: 'font-size: 0.8rem; color: #b45309; margin-top: 2px;', + }, 'Pending validation'), + ]), + ] + ); + }), ]), ]); }, diff --git a/webui-src/app/network/network_graph.js b/webui-src/app/network/network_graph.js new file mode 100644 index 0000000..e2393cc --- /dev/null +++ b/webui-src/app/network/network_graph.js @@ -0,0 +1,376 @@ +const m = require('mithril'); +const rs = require('rswebui'); +const Data = require('network/network_data'); +const { State } = require('network/network_state'); + +const WIDTH = 1000; +const HEIGHT = 650; +const NODE_LIMIT = 200; + +function friendIdsFrom(response) { + const body = (response && response.body) || {}; + const values = body.gpg_friends || body.gpgFriends || body.friends || + (Array.isArray(body.retval) ? body.retval : []); + return Array.isArray(values) ? values.map(String).filter(Boolean) : []; +} + +function uniqueEdgeKey(a, b) { + return [a, b].sort().join('|'); +} + +function initialPosition(index, count, level) { + if (level === 0) return { x: WIDTH / 2, y: HEIGHT / 2 }; + const angle = (index / Math.max(count, 1)) * Math.PI * 2; + const radius = level === 1 ? 190 : 285; + return { + x: WIDTH / 2 + Math.cos(angle) * radius, + y: HEIGHT / 2 + Math.sin(angle) * radius, + }; +} + +function* layoutGraphSteps(nodes, edges, edgeLength) { + const positions = {}; + const byLevel = [0, 1, 2].map((level) => nodes.filter((node) => node.level === level)); + byLevel.forEach((levelNodes, level) => { + levelNodes.forEach((node, index) => { + positions[node.id] = initialPosition(index, levelNodes.length, level); + }); + }); + + const own = nodes.find((node) => node.level === 0); + let work = 0; + for (let iteration = 0; iteration < 140; iteration++) { + const force = Object.fromEntries(nodes.map((node) => [node.id, { x: 0, y: 0 }])); + + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const a = positions[nodes[i].id]; + const b = positions[nodes[j].id]; + let dx = a.x - b.x; + let dy = a.y - b.y; + const distanceSq = Math.max(dx * dx + dy * dy, 100); + const distance = Math.sqrt(distanceSq); + const strength = 2400 / distanceSq; + dx /= distance; + dy /= distance; + force[nodes[i].id].x += dx * strength; + force[nodes[i].id].y += dy * strength; + force[nodes[j].id].x -= dx * strength; + force[nodes[j].id].y -= dy * strength; + if (++work % 256 === 0) yield; + } + } + + for (const edge of edges) { + if (++work % 256 === 0) yield; + const a = positions[edge.source]; + const b = positions[edge.target]; + if (!a || !b) continue; + const dx = b.x - a.x; + const dy = b.y - a.y; + const distance = Math.max(Math.sqrt(dx * dx + dy * dy), 1); + const strength = (distance - edgeLength) * 0.012; + force[edge.source].x += (dx / distance) * strength; + force[edge.source].y += (dy / distance) * strength; + force[edge.target].x -= (dx / distance) * strength; + force[edge.target].y -= (dy / distance) * strength; + } + + nodes.forEach((node) => { + if (own && node.id === own.id) return; + const position = positions[node.id]; + position.x = Math.max(35, Math.min(WIDTH - 35, position.x + force[node.id].x)); + position.y = Math.max(35, Math.min(HEIGHT - 35, position.y + force[node.id].y)); + }); + } + return positions; +} + +async function layoutGraph(nodes, edges, edgeLength, isCurrent) { + const steps = layoutGraphSteps(nodes, edges, edgeLength); + while (isCurrent()) { + // Yield to input and painting between short batches, including within + // a single iteration of the quadratic repulsion calculation. + await new Promise((resolve) => setTimeout(resolve, 0)); + if (!isCurrent()) return null; + const started = performance.now(); + do { + const step = steps.next(); + if (step.done) return step.value; + } while (performance.now() - started < 4); + } + return null; +} + +// Module level, not fields of the component: the graph tab is mounted only +// while it is the active tab, so leaving it and coming back rebuilds the +// component. Kept here, a return to the tab shows the graph that was already +// computed -- instead of replaying the discovery requests and a layout that +// costs up to 729 ms -- and the zoom, the search and the level are still what +// the user left them at. +let nodes = []; +let edges = []; +let positions = {}; +let loading = true; +let error = ''; +let friendshipLevel = 1; +let edgeLength = 105; +let zoom = 1; +let search = ''; +let loadedAt = 0; +// A newer load makes an older one drop its results instead of writing them +// over the fresh ones: changing the friendship level while a load is running +// starts a second one, and they do not necessarily finish in order. +let loadToken = 0; +let layoutToken = 0; +const GRAPH_CACHE_MS = 60000; + +const NetworkGraph = () => { + let draggedId = null; + + async function discoveredFriends(id) { + try { + return friendIdsFrom( + await rs.rsJsonApiRequest('/rsGossipDiscovery/getDiscPgpFriends', { pgpid: id }) + ); + } catch (_) { + return []; + } + } + + // A browser opens about six connections per host: asking for two hundred + // discoveries at once does not make them arrive sooner, it just queues them + // all in the tab and, past a certain point, starts failing them outright -- + // the request storm that made the channel list unusable. Six at a time. + async function discoverInBatches(ids, size = 6) { + const relations = []; + for (let i = 0; i < ids.length; i += size) { + const slice = ids.slice(i, i + size); + relations.push(...await Promise.all( + slice.map(async (id) => [id, await discoveredFriends(id)]) + )); + } + return relations; + } + + async function loadGraph() { + const token = ++loadToken; + ++layoutToken; + loading = true; + error = ''; + + // The mobile Graph shortcut can be opened before NetworkLayout's initial + // friend refresh finishes. Wait for fresh peer data so we do not cache a + // graph containing only the local node. + await Data.refreshGpgDetails(); + if (token !== loadToken) return; + + const ownId = State.ownProfile.gpg_id; + if (!ownId) { + loading = false; + error = 'Your network identity is still loading. Try redraw in a moment.'; + m.redraw(); + return; + } + + const directIds = Object.keys(Data.gpgDetails || {}).filter(Boolean); + const levels = new Map([[ownId, 0]]); + directIds.forEach((id) => levels.set(id, 1)); + const adjacency = new Map([[ownId, directIds]]); + + const directRelations = await discoverInBatches(directIds); + if (token !== loadToken) return; + directRelations.forEach(([id, friends]) => { + adjacency.set(id, friends); + if (friendshipLevel > 1) { + friends.forEach((friendId) => { + if (!levels.has(friendId) && levels.size < NODE_LIMIT) levels.set(friendId, 2); + }); + } + }); + + if (friendshipLevel > 1) { + const secondLevelIds = Array.from(levels).filter(([, level]) => level === 2).map(([id]) => id); + const secondRelations = await discoverInBatches(secondLevelIds); + if (token !== loadToken) return; + secondRelations.forEach(([id, friends]) => adjacency.set(id, friends)); + } + + nodes = Array.from(levels, ([id, level]) => { + const friend = Data.gpgDetails[id]; + return { + id, + level, + name: level === 0 + ? State.ownProfile.name || 'You' + : (friend && friend.name) || `${id.slice(0, 10)}…`, + online: level === 0 || Boolean(friend && friend.isOnline), + }; + }); + + const edgeKeys = new Set(); + edges = []; + adjacency.forEach((friends, source) => { + friends.forEach((target) => { + if (!levels.has(source) || !levels.has(target) || source === target) return; + const key = uniqueEdgeKey(source, target); + if (edgeKeys.has(key)) return; + edgeKeys.add(key); + edges.push({ source, target }); + }); + }); + + const layout = ++layoutToken; + const result = await layoutGraph(nodes, edges, edgeLength, + () => token === loadToken && layout === layoutToken); + if (!result || token !== loadToken || layout !== layoutToken) return; + positions = result; + loadedAt = Date.now(); + loading = false; + m.redraw(); + } + + async function redrawLayout() { + if (loading) return; + const token = ++layoutToken; + const result = await layoutGraph(nodes, edges, edgeLength, () => token === layoutToken); + if (!result || token !== layoutToken) return; + positions = result; + m.redraw(); + } + + function setZoom(value) { + zoom = Math.max(0.5, Math.min(2.5, Number(value))); + } + + function pointerPosition(event) { + const svg = event.currentTarget.ownerSVGElement || event.currentTarget; + const bounds = svg.getBoundingClientRect(); + const rawX = ((event.clientX - bounds.left) / bounds.width) * WIDTH; + const rawY = ((event.clientY - bounds.top) / bounds.height) * HEIGHT; + return { + x: WIDTH / 2 + (rawX - WIDTH / 2) / zoom, + y: HEIGHT / 2 + (rawY - HEIGHT / 2) / zoom, + }; + } + + return { + oninit: () => { + if (nodes.length === 0 || Date.now() - loadedAt > GRAPH_CACHE_MS) loadGraph(); + }, + onremove: () => { + ++loadToken; + ++layoutToken; + if (loading) loadedAt = 0; + loading = false; + }, + view: () => m('.network-graph', [ + m('.network-graph__toolbar', [ + m('button.network-graph__redraw[type=button][title=Redraw graph][aria-label=Redraw graph]', { onclick: loadGraph, disabled: loading }, [ + m('i.fas.fa-sync-alt', { class: loading ? 'fa-spin' : '' }), + m('span', 'Redraw'), + ]), + m('label', [ + 'Friendship level', + m('select', { + value: friendshipLevel, + onchange: (event) => { + friendshipLevel = Number(event.target.value); + loadGraph(); + }, + }, [m('option[value=1]', '1'), m('option[value=2]', '2')]), + ]), + m('label.network-graph__edge-control', [ + `Edge length ${edgeLength}`, + m('input[type=range][min=60][max=180][step=5]', { + value: edgeLength, + // Update the label while dragging; start the batched layout on release. + oninput: (event) => { + edgeLength = Number(event.target.value); + }, + onchange: redrawLayout, + }), + ]), + m('.network-graph__zoom-control', [ + m('button[type=button][title=Zoom out][aria-label=Zoom out]', { + onclick: () => setZoom(zoom - 0.1), + }, m('i.fas.fa-minus')), + m('label', [ + `Zoom ${Math.round(zoom * 100)}%`, + m('input[type=range][min=0.5][max=2.5][step=0.1]', { + value: zoom, + oninput: (event) => setZoom(event.target.value), + }), + ]), + m('button[type=button][title=Zoom in][aria-label=Zoom in]', { + onclick: () => setZoom(zoom + 0.1), + }, m('i.fas.fa-plus')), + m('button[type=button][title=Reset zoom]', { + onclick: () => setZoom(1), + }, '100%'), + ]), + m('.network-graph__search', [ + m('i.fas.fa-search'), + m('input[type=search][placeholder=Find a peer…]', { + value: search, + oninput: (event) => (search = event.target.value), + }), + ]), + ]), + loading + ? m('.network-graph__message', [m('i.fas.fa-spinner.fa-spin'), ' Loading network graph…']) + : error + ? m('.network-graph__message.network-graph__message--error', error) + : m('svg.network-graph__canvas', { + viewBox: `0 0 ${WIDTH} ${HEIGHT}`, + role: 'img', + 'aria-label': `Network graph with ${nodes.length} peers and ${edges.length} connections`, + onwheel: (event) => { + event.preventDefault(); + setZoom(zoom + (event.deltaY < 0 ? 0.1 : -0.1)); + }, + onpointermove: (event) => { + if (!draggedId) return; + positions[draggedId] = pointerPosition(event); + }, + onpointerup: () => (draggedId = null), + onpointerleave: () => (draggedId = null), + }, m('g.network-graph__zoom-layer', { + transform: `translate(${WIDTH / 2} ${HEIGHT / 2}) scale(${zoom}) translate(${-WIDTH / 2} ${-HEIGHT / 2})`, + }, [ + m('g.network-graph__edges', edges.map((edge) => { + const source = positions[edge.source]; + const target = positions[edge.target]; + return source && target && m('line', { + x1: source.x, y1: source.y, x2: target.x, y2: target.y, + }); + })), + m('g.network-graph__nodes', nodes.map((node) => { + const position = positions[node.id]; + const matches = search && node.name.toLowerCase().includes(search.toLowerCase()); + const color = node.level === 0 ? '#d6d91f' : node.online ? '#16a34a' : '#64748b'; + return m('g.network-graph__node', { + class: matches ? 'is-match' : '', + transform: `translate(${position.x} ${position.y})`, + onpointerdown: (event) => { + draggedId = node.id; + event.currentTarget.setPointerCapture(event.pointerId); + }, + }, [ + m('title', `${node.name}\n${node.id}`), + m('circle', { r: node.level === 0 ? 13 : 10, fill: color }), + m('text', { x: 14, y: 4 }, node.name), + ]); + })), + ])), + !loading && !error && m('.network-graph__legend', [ + m('span', [m('i.network-graph__key.network-graph__key--own'), ' You']), + m('span', [m('i.network-graph__key.network-graph__key--online'), ' Online']), + m('span', [m('i.network-graph__key.network-graph__key--offline'), ' Offline / discovered']), + m('span', `${nodes.length} peers · ${edges.length} connections`), + ]), + ]), + }; +}; + +module.exports = NetworkGraph; diff --git a/webui-src/app/network/network_state.js b/webui-src/app/network/network_state.js index bea212f..62b8af7 100644 --- a/webui-src/app/network/network_state.js +++ b/webui-src/app/network/network_state.js @@ -6,53 +6,126 @@ const peopleUtil = require('people/people_util'); const State = { ownProfile: { name: 'Loading...', + location: '', ssl_id: '', gpg_id: '', customState: '', + statusValue: 3, + statusTimestamp: 0, avatar: '', }, ownGxsIds: [], selectedOwnGxsId: '', selectedOwnGxsDetails: null, selectedFriendGpgId: null, - activeTab: 'details', // 'details' | 'chat' + mainTab: 'network', // 'network' | 'chats' + activeTab: 'details', // 'details' | 'chat' | 'graph' + mobilePane: 'list', // Phone master/detail navigation: 'list' | 'detail' searchString: '', gpgToGxsIdMap: {}, gxsIdToDetailsMap: {}, gxsIdentities: [], + chatHistoryMap: {}, // gpgId -> { lastMsg, lastTime } + unreadChatCount: {}, // gpgId -> unread messages received during this session currentChatPeerId: null, chatMessages: [], chatInputMsg: '', + attachedImage: null, showMailCompose: false, + showAttachModal: false, + attachPath: '', + attachBrowseHint: false, + isHashing: false, + hashingError: '', + showEmojiPicker: false, + showHistoryModal: false, + historySearchQuery: '', + fullHistoryMessages: [], + isHistoryLoading: false, }; function loadOwnProfile() { + rs.rsJsonApiRequest('/rsStatus/getOwnStatus', {}, (statusData) => { + if (statusData && statusData.retval && statusData.statusInfo) { + State.ownProfile.statusValue = statusData.statusInfo.status; + State.ownProfile.statusTimestamp = statusData.statusInfo.time_stamp || 0; + m.redraw(); + } + }).catch(() => {}); + + const fetchOwnCustomState = () => { + rs.rsJsonApiRequest('/rsChats/getOwnCustomStateString', {}, (statusData) => { + if (statusData) { + let customState; + if (typeof statusData.retval === 'string') { + customState = statusData.retval; + } else if (typeof statusData === 'string') { + customState = statusData; + } else if (statusData.retval && typeof statusData.retval === 'object') { + customState = + statusData.retval.status || + statusData.retval.customState || + statusData.retval.custom_state || + statusData.retval.status_string || + ''; + } else { + customState = + statusData.customState || + statusData.custom_state || + statusData.status || + statusData.status_string || + statusData.ownCustomStateString || + ''; + } + State.ownProfile.customState = customState; + m.redraw(); + } + }).catch(() => { + if (State.ownProfile.ssl_id) { + rs.rsJsonApiRequest( + '/rsChats/getCustomStateString', + { peer_id: State.ownProfile.ssl_id }, + (statusData) => { + if (statusData) { + const customState = + typeof statusData.retval === 'string' + ? statusData.retval + : statusData.customState || statusData.custom_state || statusData.status || ''; + State.ownProfile.customState = customState; + m.redraw(); + } + } + ).catch(() => {}); + } + }); + }; + + fetchOwnCustomState(); + rs.rsJsonApiRequest('/rsConfig/getConfigNetStatus', {}, (data) => { if (data && data.status) { State.ownProfile.name = data.status.ownName || 'Unknown'; State.ownProfile.ssl_id = data.status.ownId || ''; if (State.ownProfile.ssl_id) { - rs.rsJsonApiRequest('/rsChats/getCustomStateString', { peer_id: State.ownProfile.ssl_id }, (statusData) => { - if (statusData && statusData.retval) { - State.ownProfile.customState = statusData.retval; - m.redraw(); - } - }); + fetchOwnCustomState(); rs.rsJsonApiRequest('/rsPeers/getPeerDetails', { sslId: State.ownProfile.ssl_id }, (detData) => { - if (detData && detData.det && detData.det.gpg_id) { - State.ownProfile.gpg_id = detData.det.gpg_id; + if (detData && detData.det) { + State.ownProfile.gpg_id = detData.det.gpg_id || ''; + State.ownProfile.location = detData.det.location || ''; m.redraw(); } }); + /* Disabled getAvatar API call to avoid 404 network errors rs.rsJsonApiRequest('/rsChats/getAvatar', { pid: State.ownProfile.ssl_id }, (avatarData) => { if (avatarData && avatarData.retval && avatarData.avatar_base64_string) { State.ownProfile.avatar = avatarData.avatar_base64_string; m.redraw(); } }); + */ } m.redraw(); } @@ -115,7 +188,16 @@ function loadGxsIdentities() { function startDirectChat(sslId) { State.currentChatPeerId = sslId; State.chatMessages = []; + State.attachedImage = null; + const normalizedSslId = String(sslId || '').toLowerCase(); + const matchingFriend = Object.entries(Data.gpgDetails || {}).find(([, friend]) => + ((friend && friend.locations) || []).some( + (location) => String(location.id || '').toLowerCase() === normalizedSslId + ) + ); + if (matchingFriend) markDirectChatRead(matchingFriend[0]); loadDirectChatMessages(); + loadRecentDirectChatHistory(); } function getOnlineSslId(gpgId) { @@ -125,43 +207,223 @@ function getOnlineSslId(gpgId) { return onlineLoc ? onlineLoc.id : friend.locations[0].id; } -function loadDirectChatMessages() { - rs.events[15].notify = (chatMessage) => { - if ( - chatMessage.chat_id && - (chatMessage.chat_id.type === 1 || chatMessage.chat_id.type === 2) && - rs.idToHex(chatMessage.chat_id) === State.currentChatPeerId - ) { - State.chatMessages.push(chatMessage); - m.redraw(); +function isSystemMsg(msg) { + if (!msg) return false; + const str = String(msg); + return ( + str.includes('Distant chat requested') || + str.includes('Distant chat established') || + str.includes('Distant chat closed') || + str.includes('Distant chat status') + ); +} + +let historyPreloadInFlight = null; +let historyPreloadLogin = null; + +function preloadNetworkChatHistory() { + // The preload belongs to one login (rs.loginKey.generation bumps at every + // credential change): the next login must not share the old in-flight + // run -- which read the OLD friend list -- and the old run's answers must + // not write previews under the new session. + const login = rs.loginKey.generation; + if (historyPreloadInFlight && historyPreloadLogin === login) return historyPreloadInFlight; + historyPreloadLogin = login; + const gpgIds = Object.keys(Data.gpgDetails || {}); + const tasks = gpgIds.map((gpgId) => async () => { + if (!gpgId || gpgId === '0000000000000000') return; + + const friend = Data.gpgDetails[gpgId]; + const sslIds = Array.from(new Set( + ((friend && friend.locations) || []).map((location) => location.id).filter(Boolean) + )); + + // Each queued friend loads its locations sequentially, keeping the total + // number of history requests within the network queue's concurrency limit. + const messageGroups = []; + for (const sslId of sslIds) { + await rs.rsJsonApiRequest( + '/rsHistory/getMessages', + { chatPeerId: directChatId(sslId), loadCount: 20 }, + (msgData, success) => messageGroups.push( + success && msgData && Array.isArray(msgData.msgs) ? msgData.msgs : [] + ) + ).catch(() => {}); + } + const userMsgs = messageGroups.flat().filter( + (message) => !message.isSystem && !isSystemMsg(message.message || message.msg) + ).sort( + (a, b) => (a.sendTime || a.recvTime || 0) - (b.sendTime || b.recvTime || 0) + ); + if (userMsgs.length === 0) return; + // An answer from the previous login's run must not write previews into + // the next session. + if (rs.loginKey.generation !== login) return; + const last = userMsgs[userMsgs.length - 1]; + State.chatHistoryMap[gpgId] = { + lastMsg: last.message || last.msg || '', + lastTime: last.sendTime || last.recvTime || Math.floor(Date.now() / 1000), + }; + m.redraw(); + }); + historyPreloadInFlight = Data.runQueued(tasks) + .finally(() => { if (historyPreloadLogin === login) historyPreloadInFlight = null; }); + return historyPreloadInFlight; +} + +function receiveDirectChatMessage(chatMessage) { + const messagePeerId = chatMessage.chat_id && chatMessage.chat_id.peer_id + ? rs.idToHex(chatMessage.chat_id.peer_id) + : ''; + if (!chatMessage.chat_id || chatMessage.chat_id.type !== 1 || !messagePeerId) return; + + const normalizedPeerId = messagePeerId.toLowerCase(); + const matchingFriend = Object.entries(Data.gpgDetails || {}).find(([, friend]) => + ((friend && friend.locations) || []).some( + (location) => String(location.id || '').toLowerCase() === normalizedPeerId + ) + ); + const gpgId = matchingFriend ? matchingFriend[0] : null; + + // Update the Chats list for every private message, not only for the + // conversation that happens to be visible. + if (gpgId) { + State.chatHistoryMap[gpgId] = { + lastMsg: chatMessage.msg || chatMessage.message || '', + lastTime: chatMessage.sendTime || chatMessage.recvTime || Math.floor(Date.now() / 1000), + }; + + const isOpenConversation = + m.route.get().split('/')[1] === 'network' && + State.activeTab === 'chat' && + State.selectedFriendGpgId === gpgId && + normalizedPeerId === String(State.currentChatPeerId || '').toLowerCase() && + (window.innerWidth > 700 || State.mobilePane === 'detail'); + if (chatMessage.incoming === true && !isOpenConversation) { + State.unreadChatCount[gpgId] = (State.unreadChatCount[gpgId] || 0) + 1; + } + } + + if (normalizedPeerId === String(State.currentChatPeerId || '').toLowerCase()) { + State.chatMessages = mergeDirectChatMessages(State.chatMessages.concat(chatMessage)); scrollChatToBottom(); } + m.redraw(); +} + +function loadDirectChatMessages() { + // Kept for older callers. Incoming messages are now dispatched globally by + // main.js so counters continue to work while another page is open. +} + +function markDirectChatRead(gpgId) { + if (!gpgId || !State.unreadChatCount[gpgId]) return; + State.unreadChatCount[gpgId] = 0; +} + +function directChatId(peerId) { + return { + broadcast_status_peer_id: '00000000000000000000000000000000', + type: 1, + peer_id: peerId, + distant_chat_id: '00000000000000000000000000000000', + lobby_id: { xstr64: '0' }, }; } -function sendDirectChatMessage() { - if (!State.chatInputMsg.trim() || !State.currentChatPeerId) return; +function mergeDirectChatMessages(messages) { + const unique = new Map(); + messages.forEach((message) => { + const text = message.msg || message.message || ''; + const time = message.sendTime || message.recvTime || 0; + const incoming = message.incoming === true; + unique.set(`${time}_${incoming}_${text}`, message); + }); + return Array.from(unique.values()).sort( + (a, b) => (a.sendTime || a.recvTime || 0) - (b.sendTime || b.recvTime || 0) + ); +} - const msg = State.chatInputMsg; +function loadRecentDirectChatHistory() { + const peerId = State.currentChatPeerId; + if (!peerId) return; + rs.rsJsonApiRequest('/rsHistory/getMessages', { + chatPeerId: directChatId(peerId), + loadCount: 20, + }, (data, success) => { + if (peerId !== State.currentChatPeerId) return; + if (success && data && Array.isArray(data.msgs)) { + State.chatMessages = mergeDirectChatMessages(data.msgs.concat(State.chatMessages)); + m.redraw(); + scrollChatToBottom(); + } + }); +} + +function loadAllDirectChatHistory() { + const peerId = State.currentChatPeerId; + if (!peerId) return; + State.isHistoryLoading = true; + State.fullHistoryMessages = []; + m.redraw(); + rs.rsJsonApiRequest('/rsHistory/getMessages', { + chatPeerId: directChatId(peerId), + loadCount: 0, + }, (data, success) => { + if (peerId !== State.currentChatPeerId) return; + State.fullHistoryMessages = success && data && Array.isArray(data.msgs) + ? mergeDirectChatMessages(data.msgs) + : []; + State.isHistoryLoading = false; + m.redraw(); + }); +} + +function sendDirectChatMessage() { + const text = (State.chatInputMsg || '').trim(); + const attached = State.attachedImage; + if ((!text && !attached) || !State.currentChatPeerId) return; + + const fullMsg = attached + ? (text ? `${text}\n${attached.imgTag}` : attached.imgTag) + : text; + + // The selected conversation may change before the send completes. + const peerId = State.currentChatPeerId; + const friendGpgId = State.selectedFriendGpgId; + const msg = fullMsg; State.chatInputMsg = ''; + State.attachedImage = null; rs.rsJsonApiRequest( '/rsChats/sendChat', { - id: { type: 1, peer_id: State.currentChatPeerId }, + id: { type: 1, peer_id: peerId }, msg, }, (data, success) => { if (success) { - State.chatMessages.push({ - chat_id: { type: 1, peer_id: State.currentChatPeerId }, - msg, - sendTime: Date.now() / 1000, - incoming: false, - own: true, - }); + const nowSec = Math.floor(Date.now() / 1000); + const isCurrentChat = State.currentChatPeerId === peerId + && State.selectedFriendGpgId === friendGpgId; + if (isCurrentChat) { + State.chatMessages.push({ + chat_id: { type: 1, peer_id: peerId }, + msg, + sendTime: nowSec, + incoming: false, + own: true, + }); + scrollChatToBottom(); + } + + if (friendGpgId) { + State.chatHistoryMap[friendGpgId] = { + lastMsg: msg, + lastTime: nowSec, + }; + } m.redraw(); - scrollChatToBottom(); } else { console.error('[RS] Failed to send direct chat message'); } @@ -176,15 +438,50 @@ function scrollChatToBottom() { }, 100); } +function setOwnCustomStateString(statusString) { + const str = (statusString || '').trim(); + rs.rsJsonApiRequest('/rsChats/setCustomStateString', { status_string: str }, () => { + State.ownProfile.customState = str; + m.redraw(); + }).catch(() => { + State.ownProfile.customState = str; + m.redraw(); + }); +} + +async function setOwnStatus(statusValue) { + const value = Number(statusValue); + if (![1, 2, 3].includes(value)) return false; + + try { + const response = await rs.rsJsonApiRequest('/rsStatus/sendStatus', { status: value }); + if (response && response.body && response.body.retval === false) return false; + + State.ownProfile.statusValue = value; + State.ownProfile.statusTimestamp = Math.floor(Date.now() / 1000); + m.redraw(); + return true; + } catch (_) { + return false; + } +} + module.exports = { State, loadOwnProfile, + setOwnCustomStateString, + setOwnStatus, loadSelectedOwnGxsDetails, fetchIdDetails, loadGxsIdentities, startDirectChat, getOnlineSslId, + preloadNetworkChatHistory, loadDirectChatMessages, + receiveDirectChatMessage, + markDirectChatRead, + loadRecentDirectChatHistory, + loadAllDirectChatHistory, sendDirectChatMessage, scrollChatToBottom, }; diff --git a/webui-src/app/people/people.js b/webui-src/app/people/people.js index 249ecf4..9dc87e9 100644 --- a/webui-src/app/people/people.js +++ b/webui-src/app/people/people.js @@ -2,6 +2,7 @@ const m = require('mithril'); const rs = require('rswebui'); const Data = require('network/network_data'); const compose = require('mail/mail_compose'); +const peopleUtil = require('people/people_util'); const { State, fetchIdDetails, @@ -12,6 +13,10 @@ const { startStatusPolling, stopStatusPolling, initializeDistantChat, + selectChatContact, + getDistantChatSession, + drainBufferedChatMessages, + markDistantChatRead, } = require('people/people_state'); const PeopleSidebar = require('people/people_sidebar'); @@ -19,6 +24,7 @@ const DetailsTab = require('people/people_details_tab'); const ChatTab = require('people/people_chat_tab'); const PeopleLayout = () => { + let stopWatchingOwnIds; const dismissMenu = () => { if (State.activeMenu) { State.activeMenu = null; @@ -29,75 +35,52 @@ const PeopleLayout = () => { return { oninit: (vnode) => { syncFilter(vnode.attrs.tab); - Data.refreshGpgDetails().then(() => m.redraw()); + // The friend list carries the locations the direct chat history is keyed + // by, so the preload only has its full candidate set once it landed. + Data.refreshGpgDetails().then(() => { + preloadAllChatHistory(); + m.redraw(); + }); loadGxsIdentities(); - loadOwnGxsIds().then(() => preloadAllChatHistory()); - preloadAllChatHistory(); + loadOwnGxsIds().then(() => { + preloadAllChatHistory(); + // "Start private chat" from a chat room routes here with the chat tab + // preselected, but nothing ever opened the tunnel: the pane sat on its + // Connecting spinner for good. That intent is explicit, so it is + // honoured -- once the own identities needed to open a tunnel are in. + if (State.pendingChatOpen && State.pendingChatOpen === State.selectedId) { + State.pendingChatOpen = null; + initializeDistantChat(); + } else { + State.pendingChatOpen = null; + } + }); + stopWatchingOwnIds = peopleUtil.watchOwnIds((ids) => { + State.ownGxsIds = ids || []; + if (!peopleUtil.isUsableIdentityId(State.selectedId)) { + State.selectedId = State.ownGxsIds[0] || null; + State.mobilePane = State.selectedId ? State.mobilePane : 'list'; + } + if (!State.selectedOwnGxsIdForChat && State.ownGxsIds.length) { + State.selectedOwnGxsIdForChat = State.ownGxsIds[0]; + } + m.redraw(); + }); window.addEventListener('click', dismissMenu); - // Register for chatEvents to receive live incoming messages - rs.events[15].notify = (chatMessage) => { - const msgCid = chatMessage.chat_id; - if (msgCid && msgCid.type === 2) { - const msgPid = rs.idToHex(msgCid.distant_chat_id); - - // Find active session matching this distant chat PID - let session = null; - let targetGxsId = null; - Object.keys(State.activeDistantChats || {}).forEach((id) => { - if (State.activeDistantChats[id] && State.activeDistantChats[id].pid === msgPid) { - session = State.activeDistantChats[id]; - targetGxsId = id; - } - }); - - if (session) { - const isNearDuplicate = session.messages.some( - (m) => (m.msg || m.message) === chatMessage.msg && Math.abs(m.sendTime - chatMessage.sendTime) < 5 - ); - if (!isNearDuplicate) { - session.messages.push(chatMessage); - session.messages.sort((a, b) => a.sendTime - b.sendTime); - if (targetGxsId) { - State.chatHistoryMap[targetGxsId] = { - lastMsg: chatMessage.msg || chatMessage.message || '', - lastTime: chatMessage.sendTime || Math.floor(Date.now() / 1000), - }; - } - m.redraw(); - if (State.selectedId === targetGxsId) { - setTimeout(() => { - const element = document.querySelector('.chat-messages'); - if (element) element.scrollTop = element.scrollHeight; - }, 100); - } - } - } else if (State.chatPid && msgPid === State.chatPid) { - const isNearDuplicate = State.chatMessages.some( - (m) => (m.msg || m.message) === chatMessage.msg && Math.abs(m.sendTime - chatMessage.sendTime) < 5 - ); - if (!isNearDuplicate) { - State.chatMessages.push(chatMessage); - State.chatMessages.sort((a, b) => a.sendTime - b.sendTime); - m.redraw(); - setTimeout(() => { - const element = document.querySelector('.chat-messages'); - if (element) element.scrollTop = element.scrollHeight; - }, 100); - } - } - } - }; - - if (State.chatPid && !State.chatDisconnected) { + // Only poll a tunnel that is the selected contact's own; anything + // else is left over from a previous selection. + const selectedSession = State.selectedId ? getDistantChatSession(State.selectedId) : null; + if (State.chatPid && !State.chatDisconnected && selectedSession && selectedSession.pid === State.chatPid) { + // Messages received while the tab was unmounted sit in the event + // queue buffer: pick them up before the first redraw. + drainBufferedChatMessages(selectedSession); startStatusPolling(); } }, onremove: () => { - if (rs.events[15]) { - rs.events[15].notify = () => {}; - } stopStatusPolling(); + if (stopWatchingOwnIds) stopWatchingOwnIds(); window.removeEventListener('click', dismissMenu); }, @@ -109,12 +92,19 @@ const PeopleLayout = () => { const details = State.selectedId ? State.gxsIdToDetailsMap[State.selectedId] : null; const name = details ? details.mNickname || details.mGroupName || 'Unknown' : ''; - return m('.people-container', [ + return m('.people-container' + (State.mobilePane === 'detail' ? '.mobile-detail-open' : ''), [ // Left Side Panel m(PeopleSidebar), // Right Side Details / Actions Pane m('.people-right-pane', [ + m('.mobile-pane-header', [ + m('button.mobile-back-button', { + type: 'button', + onclick: () => { State.mobilePane = 'list'; }, + }, [m('i.fas.fa-chevron-left'), State.mainTab === 'chats' ? ' Chats' : ' People']), + m('strong', name || 'Profile'), + ]), State.selectedId && details ? [ m('.network-tabs', [ @@ -123,6 +113,7 @@ const PeopleLayout = () => { { onclick: () => { State.activeTab = 'details'; + State.mobilePane = 'detail'; stopStatusPolling(); }, }, @@ -133,13 +124,15 @@ const PeopleLayout = () => { { onclick: () => { State.activeTab = 'chat'; + State.mobilePane = 'detail'; + markDistantChatRead(State.selectedId); initializeDistantChat(); }, }, 'Chat Conversation' ), ]), - m('.network-tab-content', [ + m('.network-tab-content' + (State.activeTab === 'chat' ? '.network-chat-tab-content' : ''), [ State.activeTab === 'details' ? m(DetailsTab) : m(ChatTab), ]), ] @@ -199,7 +192,10 @@ PeopleLayout.setSelectedId = (id, activeTab = 'details', showCompose = false) => State.activeFilter = filter; State.selectedId = id; + selectChatContact(id); State.activeTab = activeTab; + State.pendingChatOpen = activeTab === 'chat' ? id : null; + State.mobilePane = 'detail'; if (showCompose) { State.showMailCompose = true; } diff --git a/webui-src/app/people/people_attach.js b/webui-src/app/people/people_attach.js new file mode 100644 index 0000000..445ae71 --- /dev/null +++ b/webui-src/app/people/people_attach.js @@ -0,0 +1,77 @@ +const m = require('mithril'); +const rs = require('rswebui'); +const { State, setChatDraft } = require('people/people_state'); + +// Attaching a file means publishing it as an extra file and sending the +// retroshare:// link the core answers with. What the paperclip used to do was +// paste the path itself into the message, so the peer received "/home/me/x.iso" +// and nothing else. The chat page does this properly; this is the same flow, +// without its ChatHubState. +// +// Hashing a large file takes minutes, so there is no deadline. The poll just +// must not ask at full speed: it backs off from one second towards ten, each +// request being a fresh connection since the JSON API answers Connection:close. +const HASH_POLL_START_MS = 1000; +const HASH_POLL_MAX_MS = 10000; + +function pollHashStatus(localpath, delay = HASH_POLL_START_MS) { + rs.rsJsonApiRequest('/rsFiles/ExtraFileStatus', { localpath }, (data) => { + // Cancelled, or another file started in the meantime: this chain lives in + // a setTimeout, not in a component, so it has to check for itself. + if (!State.isHashing || State.attachPath !== localpath) return; + + const info = data && data.retval ? data.info : null; + if (info && info.hash && info.hash !== '0000000000000000000000000000000000000000') { + const size = info.size && typeof info.size === 'object' + ? (info.size.xint64 || parseInt(info.size.xstr64) || 0) + : Number(info.size) || 0; + const link = `${info.name} (${rs.formatBytes(size)})`; + + const draft = State.chatInputMsg || ''; + setChatDraft(draft ? draft + '\n' + link : link); + stopAttachHash(); + m.redraw(); + return; + } + + setTimeout( + () => pollHashStatus(localpath, Math.min(delay * 2, HASH_POLL_MAX_MS)), + delay + ); + }); +} + +// The core drops a file it failed to hash without a word -- ftExtraList only +// records successes -- so a file that exists but cannot be read would leave +// this polling for ever. Stopping must therefore always be possible. +function stopAttachHash() { + State.isHashing = false; + State.attachPath = ''; +} + +function startAttachHash(path) { + const localpath = String(path || '').trim(); + if (!localpath) return; + + State.attachPath = localpath; + State.isHashing = true; + State.attachError = ''; + m.redraw(); + + rs.rsJsonApiRequest( + '/rsFiles/ExtraFileHash', + { localpath, period: 86400 * 7, flags: 0 }, + (data, success) => { + if (success && data && data.retval) { + pollHashStatus(localpath); + return; + } + stopAttachHash(); + State.attachError = 'Could not hash that file. Check the path — it is read on the RetroShare node, not in the browser.'; + m.redraw(); + } + ); +} + +module.exports = { startAttachHash, stopAttachHash }; diff --git a/webui-src/app/people/people_chat_tab.js b/webui-src/app/people/people_chat_tab.js index aba1ae9..94e6c5c 100644 --- a/webui-src/app/people/people_chat_tab.js +++ b/webui-src/app/people/people_chat_tab.js @@ -7,15 +7,25 @@ const { getStatusTooltip, initializeDistantChat, sendDistantChatMessage, - stopStatusPolling, - loadAllHistoryForSelectedPeer, + leaveDistantChat, + loadOlderChatHistory, + setChatDraft, + switchChatIdentity, + getDistantChatSession, } = require('people/people_state'); -const { renderChatMessage } = require('chat/chat_state'); +const { startAttachHash, stopAttachHash } = require('people/people_attach'); +const { renderChatMessage, autoResizeTextarea, openChatImageViewer } = require('chat/chat_state'); const chatEmoji = require('chat/chat_emoji'); const peopleUtil = require('people/people_util'); const HistoryBrowserModal = require('people/people_history'); -// Mirroring C++ Distant Chat packet size limit (200KB) +// Distant chat has no size limit of its own -- getMaxMessageSecuritySize() +// answers 0 for it and the core slices anything past 15000 characters -- but a +// photo straight from a phone is several megabytes of base64 crawling through a +// turtle tunnel. So the picture is scaled into a 800x600 box and its quality +// stepped down until it fits in a couple of hundred kilobytes. +const MAX_IMAGE_CHARS = 200000; + function formatChatImage(file, callback) { if (!file) return; const reader = new FileReader(); @@ -43,26 +53,57 @@ function formatChatImage(file, callback) { // Dynamically step down JPEG quality until base64 string is under 190,000 characters (190KB) let quality = 0.85; let dataUrl = canvas.toDataURL('image/jpeg', quality); - while (dataUrl.length > 190000 && quality > 0.20) { + while (dataUrl.length > MAX_IMAGE_CHARS * 0.95 && quality > 0.20) { quality -= 0.10; dataUrl = canvas.toDataURL('image/jpeg', quality); } - if (dataUrl.length <= 200000) { - callback(``); + if (dataUrl.length <= MAX_IMAGE_CHARS) { + callback(``, dataUrl); } else { - alert('Image file is too large to send over Distant Chat 200KB packet size limit.'); - callback(null); + alert('That picture stays too heavy once compressed to be worth sending over a distant chat tunnel.'); + callback(null, null); } }; - img.onerror = () => callback(null); + img.onerror = () => callback(null, null); img.src = evt.target.result; }; reader.readAsDataURL(file); } const ChatTab = () => { + let showAttachmentMenu = false; + + function onDocClick(e) { + if (showAttachmentMenu && !e.target.closest('.mobile-chat-attachment')) { + showAttachmentMenu = false; + m.redraw(); + } + } + + function attachFileLink() { + if (State.isHashing) return; + // The path is read by the RetroShare node, not by the browser, so a file + // picker would name something the core cannot open. + const path = prompt('Path of the file to share, as seen by the RetroShare node:'); + if (path && path.trim()) startAttachHash(path); + } + + function attachImage(file) { + if (!file) return; + formatChatImage(file, (imgTag, dataUrl) => { + if (imgTag && dataUrl) { + State.attachedImage = { imgTag, dataUrl, name: file.name || 'Image' }; + const session = getDistantChatSession(State.selectedId); + if (session) session.attachedImage = State.attachedImage; + m.redraw(); + } + }); + } + return { + oncreate: () => document.addEventListener('click', onDocClick, true), + onremove: () => document.removeEventListener('click', onDocClick, true), view: () => { fetchIdDetails(State.selectedId); const details = State.selectedId ? State.gxsIdToDetailsMap[State.selectedId] : null; @@ -71,31 +112,35 @@ const ChatTab = () => { const name = details.mNickname || details.mGroupName || 'Unknown'; if (State.ownGxsIds.length === 0) { - return m('.chat-warning', [ + return m('.network-chat-view', m('.chat-warning', [ m('i.fas.fa-exclamation-triangle'), m('h4', 'No Identities Found'), m('p', 'You need to create a GXS identity in the "My Identities" tab before you can start distant chats.'), - ]); + ])); } if (State.chatDisconnected) { - return m('.chat-warning', [ + return m('.network-chat-view', m('.chat-warning', [ m('i.fas.fa-unlink', { style: 'font-size: 2rem; color: #ef4444; margin-bottom: 1rem;' }), m('h4', 'Conversation Ended'), - m('p', 'You have closed the distant chat tunnel. Click below to reconnect.'), + m('p', State.chatCloseFoundNothing + ? 'The tunnel was already gone: the core had no connection left to close. Click below to open a new one.' + : State.chatEndedByPoll + ? 'The tunnel went away: closed by your contact, or dropped by the core. Click below to open a new one.' + : 'You have closed the distant chat tunnel. Click below to reconnect.'), m('button.blue', { style: 'margin-top: 1rem; padding: 0.5rem 1.5rem; border-radius: 0.375rem; border: none; font-weight: 600; cursor: pointer;', onclick: () => initializeDistantChat(), }, 'Reconnect'), - ]); + ])); } if (!State.chatPid) { - return m('.chat-warning', [ + return m('.network-chat-view', m('.chat-warning', [ m('i.fas.fa-spinner.fa-spin'), m('h4', 'Connecting...'), m('p', 'Initiating distant chat tunnel to the peer identity...'), - ]); + ])); } const canTalk = State.distantChatStatus && State.distantChatStatus.status === 2; @@ -105,7 +150,7 @@ const ChatTab = () => { style: 'padding: 0.5rem 1rem; background-color: #ffffff; border-bottom: 1px solid #cbd5e1; display: flex; align-items: center; justify-content: space-between; font-size: 0.85rem;', }, [ m('.chat-tunnel-status', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [ - m('span', { style: 'color: #64748b; font-weight: 500;' }, 'Distant Chat Tunnel'), + m('span.tunnel-label', { style: 'color: #64748b; font-weight: 500;' }, 'Distant Chat Tunnel'), m('i.fas.fa-circle', { style: { color: getStatusColor(State.distantChatStatus ? State.distantChatStatus.status : 0), @@ -117,7 +162,7 @@ const ChatTab = () => { ]), m('.chat-actions', { style: 'display: flex; align-items: center; gap: 0.75rem;' }, [ m('.select-own-profile', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [ - m('span', { style: 'color: #64748b;' }, 'Chatting as:'), + m('span.chatting-as-label', { style: 'color: #64748b;' }, 'Chatting as:'), (() => { const ownId = State.selectedOwnGxsIdForChat; if (ownId) fetchIdDetails(ownId); @@ -131,10 +176,7 @@ const ChatTab = () => { m('select', { style: 'padding: 0.25rem 0.5rem; border-radius: 0.25rem; border: 1px solid #cbd5e1; outline: none; background: #f8fafc; font-weight: 600;', value: ownId, - onchange: (e) => { - State.selectedOwnGxsIdForChat = e.target.value; - initializeDistantChat(true); - }, + onchange: (e) => switchChatIdentity(e.target.value), }, State.ownGxsIds.map((id) => m('option', { value: id }, rs.userList.username(id)))), ]); })(), @@ -142,10 +184,10 @@ const ChatTab = () => { m('button.blue.history-btn', { style: 'padding: 0.25rem 0.75rem; border-radius: 0.25rem; font-size: 0.85rem; display: flex; align-items: center; gap: 0.35rem; border: none; cursor: pointer; background-color: #3b82f6; color: #ffffff; font-weight: 600;', title: 'View all past chat history with this contact', + // The modal loads the history when it opens; asking here too + // ran the whole "every message ever" query twice. onclick: () => { State.showHistoryModal = true; - State.historySearchQuery = ''; - loadAllHistoryForSelectedPeer(); }, }, [ m('i.fas.fa-history', { style: 'color: #ffffff;' }), @@ -161,17 +203,11 @@ const ChatTab = () => { pid: State.chatPid, }, (data, success) => { - if (success) { - if (State.selectedId && State.activeDistantChats[State.selectedId]) { - delete State.activeDistantChats[State.selectedId]; - } - State.chatPid = null; - State.chatMessages = []; - State.distantChatStatus = null; - State.chatDisconnected = true; - stopStatusPolling(); - m.redraw(); - } + // `success` is the HTTP status, not the answer: the core + // says in retval whether it had anything to close. Taking + // 200 for a closed tunnel is how this button could report + // a conversation as ended while the tunnel lived on. + leaveDistantChat(Boolean(success && data && data.retval)); } ); } @@ -184,7 +220,23 @@ const ChatTab = () => { ]), ]), - m('.chat-messages', [ + m('.chat-messages', { + // Near the top: ask for an older slice. It is inserted above what is + // on screen, so its height is given back to scrollTop and the + // reader does not move (same as the chat rooms). + onscroll: (e) => { + const element = e.target; + if (element.scrollTop > 120) return; + const previousHeight = element.scrollHeight; + const previousTop = element.scrollTop; + loadOlderChatHistory(() => { + requestAnimationFrame(() => { + const pane = document.querySelector('.chat-messages'); + if (pane) pane.scrollTop = previousTop + (pane.scrollHeight - previousHeight); + }); + }); + }, + }, [ State.chatMessages.length === 0 ? m('.chat-warning', [ m('i.fas.fa-comments'), @@ -224,21 +276,95 @@ const ChatTab = () => { }), ]), - m('.chat-input-area', { style: 'display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; background: #ffffff; border-top: 1px solid #cbd5e1;' }, [ - m('button.chat-hub-action-btn', { + // Hashing has no deadline and the core never reports a failure, so + // the wait must be visible and must always have a way out. + (State.isHashing || State.attachError) && m('.chat-attach-status', { + style: 'display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-top: 1px solid #cbd5e1; background: #f8fafc; font-size: 0.85rem; color: #475569;', + }, State.isHashing + ? [ + m('i.fas.fa-spinner.fa-spin', { style: 'color: #3b82f6;' }), + m('span', { style: 'flex: 1; word-break: break-all;' }, `Hashing ${State.attachPath}…`), + m('button.btn.red', { + style: 'padding: 0.2rem 0.6rem; border-radius: 0.25rem; border: none; cursor: pointer; background-color: #ef4444; color: #ffffff;', + onclick: () => stopAttachHash(), + }, 'Stop'), + ] + : [ + m('i.fas.fa-exclamation-triangle', { style: 'color: #ef4444;' }), + m('span', { style: 'flex: 1;' }, State.attachError), + m('button.btn', { + style: 'padding: 0.2rem 0.6rem; border-radius: 0.25rem; border: 1px solid #cbd5e1; cursor: pointer; background: #ffffff;', + onclick: () => { State.attachError = ''; }, + }, 'Dismiss'), + ]), + + State.attachedImage && m('.chat-attachment-preview', [ + m('.chat-attachment-preview__item', [ + m('img.chat-attachment-preview__thumb', { + src: State.attachedImage.dataUrl, + alt: 'Preview', + title: 'Click to view full image', + onclick: () => openChatImageViewer(State.attachedImage.dataUrl), + }), + m('button.chat-attachment-preview__remove', { + type: 'button', + title: 'Remove image', + onclick: () => { + State.attachedImage = null; + const session = getDistantChatSession(State.selectedId); + if (session) session.attachedImage = null; + }, + }, m('i.fas.fa-times')), + ]), + m('.chat-attachment-preview__info', [ + m('span.chat-attachment-preview__name', State.attachedImage.name || 'Image attached'), + m('span.chat-attachment-preview__hint', 'Will be sent with your message'), + ]), + ]), + + m('.chat-input-area', { style: 'display: flex; align-items: flex-end; gap: 0.5rem; padding: 0.75rem; background: #ffffff; border-top: 1px solid #cbd5e1;' }, [ + m('button.chat-hub-action-btn.desktop-chat-attachment', { disabled: !canTalk, style: !canTalk ? 'opacity: 0.5; cursor: not-allowed;' : '', title: 'Attach file link', - onclick: () => { - const path = prompt('Enter file path to attach as Retroshare link:'); - if (path && path.trim()) { - const val = State.chatInputMsg || ''; - State.chatInputMsg = val ? val + '\n' + path.trim() : path.trim(); - m.redraw(); - } - } + onclick: attachFileLink, }, m('i.fas.fa-paperclip')), + m('.mobile-chat-attachment', [ + m('button.chat-hub-action-btn', { + disabled: !canTalk, + style: !canTalk ? 'opacity: 0.5; cursor: not-allowed;' : '', + title: 'Add attachment', + onclick: (e) => { + e.stopPropagation(); + showAttachmentMenu = !showAttachmentMenu; + State.showEmojiPicker = false; + }, + }, m('i.fas.fa-paperclip')), + showAttachmentMenu && m('.mobile-chat-attachment__menu', [ + m('button.mobile-chat-attachment__option', { + type: 'button', + onclick: () => { + showAttachmentMenu = false; + attachFileLink(); + }, + }, [m('i.fas.fa-file'), ' File']), + m('label.mobile-chat-attachment__option', [ + m('i.fas.fa-image'), + ' Picture', + m('input[type=file][accept=image/*]', { + style: 'display: none;', + disabled: !canTalk, + onchange: (e) => { + attachImage(e.target.files && e.target.files[0]); + showAttachmentMenu = false; + e.target.value = ''; + }, + }), + ]), + ]), + ]), + m('.emoji-picker-wrapper', { style: 'position: relative;' }, [ m('button.chat-hub-action-btn', { disabled: !canTalk, @@ -251,14 +377,14 @@ const ChatTab = () => { }, m('i.fas.fa-smile')), State.showEmojiPicker && m(chatEmoji.EmojiPicker, { onSelect: (emoji) => { - State.chatInputMsg = (State.chatInputMsg || '') + emoji; + setChatDraft((State.chatInputMsg || '') + emoji); State.showEmojiPicker = false; m.redraw(); } }), ]), - m('label.chat-hub-action-btn', { + m('label.chat-hub-action-btn.desktop-chat-attachment', { title: 'Send image', style: `cursor: ${canTalk ? 'pointer' : 'not-allowed'}; opacity: ${canTalk ? 1 : 0.5};`, }, [ @@ -268,25 +394,25 @@ const ChatTab = () => { disabled: !canTalk, onchange: (e) => { if (!e.target.files || !e.target.files[0]) return; - const file = e.target.files[0]; - formatChatImage(file, (imgTag) => { - if (imgTag) { - State.chatInputMsg = (State.chatInputMsg || '') + imgTag; - m.redraw(); - } - }); + attachImage(e.target.files[0]); e.target.value = ''; } }) ]), m('textarea.chat-textarea', { - placeholder: canTalk ? 'Type your encrypted message here... (or paste image)' : 'Waiting for tunnel to be secured...', + placeholder: !canTalk + ? 'Waiting for tunnel to be secured...' + : (State.attachedImage ? 'Add a caption... (optional)' : 'Type a message here...'), disabled: !canTalk, value: State.chatInputMsg, - style: 'flex: 1; resize: none; border: 1px solid #cbd5e1; border-radius: 6px; padding: 0.5rem; font-family: inherit; font-size: 0.9rem; outline: none; min-height: 40px; max-height: 120px;', + rows: 1, + style: 'flex: 1; resize: none; border: 1px solid #cbd5e1; border-radius: 0.625rem; padding: 0.55rem 0.75rem; font-family: inherit; font-size: 0.9rem; line-height: 1.45; outline: none; min-height: 40px; max-height: 160px; height: 40px; box-sizing: border-box; overflow-y: hidden;', + oncreate: (vnode) => autoResizeTextarea(vnode.dom), + onupdate: (vnode) => autoResizeTextarea(vnode.dom), oninput: (e) => { - State.chatInputMsg = e.target.value; + setChatDraft(e.target.value); + autoResizeTextarea(e.target); }, onpaste: (e) => { if (!canTalk) return; @@ -296,9 +422,11 @@ const ChatTab = () => { if (items[i].type.indexOf('image') !== -1) { e.preventDefault(); const blob = items[i].getAsFile(); - formatChatImage(blob, (imgTag) => { - if (imgTag) { - State.chatInputMsg = (State.chatInputMsg || '') + imgTag; + formatChatImage(blob, (imgTag, dataUrl) => { + if (imgTag && dataUrl) { + State.attachedImage = { imgTag, dataUrl, name: 'Pasted image' }; + const session = getDistantChatSession(State.selectedId); + if (session) session.attachedImage = State.attachedImage; m.redraw(); } }); @@ -307,9 +435,25 @@ const ChatTab = () => { } }, onkeydown: (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - if (canTalk) sendDistantChatMessage(); + if (e.key === 'Enter' || e.keyCode === 13) { + if (!e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault(); + if (canTalk) sendDistantChatMessage(); + } else if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + if (!document.execCommand || !document.execCommand('insertText', false, '\n')) { + const start = e.target.selectionStart || 0; + const end = e.target.selectionEnd || 0; + const val = e.target.value; + const newVal = val.substring(0, start) + '\n' + val.substring(end); + setChatDraft(newVal); + e.target.value = newVal; + e.target.selectionStart = e.target.selectionEnd = start + 1; + } else { + setChatDraft(e.target.value); + } + autoResizeTextarea(e.target); + } } }, }), diff --git a/webui-src/app/people/people_details_tab.js b/webui-src/app/people/people_details_tab.js index 8dd1657..fe611e6 100644 --- a/webui-src/app/people/people_details_tab.js +++ b/webui-src/app/people/people_details_tab.js @@ -7,6 +7,7 @@ const { EditIdentity, DeleteIdentity } = ownIdsLayout; const { State, fetchIdDetails, + refreshSelectedIdDetails, getSafeAvatar, get64Num, createUsageString, @@ -16,6 +17,7 @@ const { const DetailsTab = () => { return { + oninit: () => refreshSelectedIdDetails(), view: () => { fetchIdDetails(State.selectedId); const details = State.selectedId ? State.gxsIdToDetailsMap[State.selectedId] : null; @@ -97,10 +99,11 @@ const DetailsTab = () => { widget.popupMessage( m(EditIdentity, { details, - }) + }), + 'edit-identity-modal' ), }, - [m('i.fas.fa-edit'), ' Edit'] + [m('i.fas.fa-edit'), m('span.btn-text', ' Edit')] ), m( 'button.btn.red', @@ -113,7 +116,7 @@ const DetailsTab = () => { }) ), }, - [m('i.fas.fa-trash-alt'), ' Delete'] + [m('i.fas.fa-trash-alt'), m('span.btn-text', ' Delete')] ), ] : [ @@ -125,7 +128,7 @@ const DetailsTab = () => { initializeDistantChat(); }, }, - [m('i.fas.fa-comment-alt'), ' Start Chat'] + [m('i.fas.fa-comment-alt'), m('span.btn-text', ' Start Chat')] ), m( 'button.btn.blue', @@ -134,7 +137,7 @@ const DetailsTab = () => { State.showMailCompose = true; }, }, - [m('i.fas.fa-envelope'), ' Send Mail'] + [m('i.fas.fa-envelope'), m('span.btn-text', ' Send Mail')] ), m( 'button.btn' + (isContact ? '.red' : '.blue'), @@ -151,8 +154,8 @@ const DetailsTab = () => { }, }, isContact - ? [m('i.fas.fa-user-minus'), ' Remove Contact'] - : [m('i.fas.fa-user-plus'), ' Add Contact'] + ? [m('i.fas.fa-user-minus'), m('span.btn-text', ' Remove Contact')] + : [m('i.fas.fa-user-plus'), m('span.btn-text', ' Add Contact')] ), ], ]), @@ -164,23 +167,26 @@ const DetailsTab = () => { m('.info-label', 'GXS ID'), m('.info-value', details.mId), m('.info-label', 'Type'), - m('.info-value', details.mFlags === 14 ? 'Signed ID' : 'Anonymous ID'), + // mFlags is a bitfield: RS_IDENTITY_FLAGS_PGP_LINKED is 0x2. + // Comparing the whole word against 14 -- PGP_LINKED | PGP_KNOWN | + // IS_OWN_ID -- only ever matched our own signed identities, so + // every signed identity of somebody else read "Anonymous". + m('.info-value', (details.mFlags & 0x2) ? 'Signed ID' : 'Anonymous ID'), m('.info-label', 'Owner Node GPG'), m('.info-value', pgpId && pgpId !== '0000000000000000' ? pgpId : 'None'), m('.info-label', 'Created On'), - m( - '.info-value', - typeof details.mPublishTS === 'object' - ? new Date(details.mPublishTS.xint64 * 1000).toLocaleString() - : 'Unknown' - ), + // get64Num exists for these: a 64 bit field arrives as + // {xint64, xstr64}, and large values carry xstr64 alone -- reading + // .xint64 straight then dates the identity to "Invalid Date". + m('.info-value', (() => { + const ts = get64Num(details.mPublishTS); + return ts > 0 ? new Date(ts * 1000).toLocaleString() : 'Unknown'; + })()), m('.info-label', 'Last Used'), - m( - '.info-value', - typeof details.mLastUsageTS === 'object' - ? new Date(details.mLastUsageTS.xint64 * 1000).toLocaleDateString() - : 'Unknown' - ), + m('.info-value', (() => { + const ts = get64Num(details.mLastUsageTS); + return ts > 0 ? new Date(ts * 1000).toLocaleDateString() : 'Unknown'; + })()), m('.info-label', 'Friend votes'), m('.info-value', details.mReputation && (details.mReputation.mFriendsPositiveVotes > 0 || details.mReputation.mFriendsNegativeVotes > 0) ? `${details.mReputation.mFriendsPositiveVotes} positive, ${details.mReputation.mFriendsNegativeVotes} negative` diff --git a/webui-src/app/people/people_history.js b/webui-src/app/people/people_history.js index a5ba3b0..82a5219 100644 --- a/webui-src/app/people/people_history.js +++ b/webui-src/app/people/people_history.js @@ -3,33 +3,54 @@ const rs = require('rswebui'); const peopleState = require('people/people_state'); const HistoryBrowserModal = () => { + // This modal is mounted with its page and draws nothing until it is asked + // for, so oninit is the moment the *conversation* opens, not the moment the + // browser does. Loading there meant every chat opening ran "give me every + // message ever stored" -- loadCount 0 -- for a panel nobody had asked for. + let wasOpen = false; + + const loadOnOpen = (vnode) => { + const chatState = require('chat/chat_state'); + const isRoom = vnode.attrs && vnode.attrs.isRoom; + const externalState = vnode.attrs && vnode.attrs.state; + + if (externalState) { + externalState.historySearchQuery = ''; + return; + } + if (isRoom) { + chatState.ChatHubState.historySearchQuery = ''; + const lobbyId = chatState.ChatLobbyModel.currentLobby + ? rs.idToHex(chatState.ChatLobbyModel.currentLobby.lobby_id) + : null; + if (lobbyId) chatState.ChatLobbyModel.loadAllHistoryForRoom(lobbyId); + return; + } + peopleState.State.historySearchQuery = ''; + peopleState.loadAllHistoryForSelectedPeer(); + }; + return { - oninit: (vnode) => { - const chatState = require('chat/chat_state'); - const isRoom = vnode.attrs && vnode.attrs.isRoom; - if (isRoom) { - chatState.ChatHubState.historySearchQuery = ''; - const lobbyId = chatState.ChatLobbyModel.currentLobby ? rs.idToHex(chatState.ChatLobbyModel.currentLobby.lobby_id) : null; - if (lobbyId) { - chatState.ChatLobbyModel.loadAllHistoryForRoom(lobbyId); - } - } else { - peopleState.State.historySearchQuery = ''; - peopleState.loadAllHistoryForSelectedPeer(); - } - }, view: (vnode) => { const chatState = require('chat/chat_state'); const isRoom = vnode.attrs && vnode.attrs.isRoom; - const stateObj = isRoom ? chatState.ChatHubState : peopleState.State; + const externalState = vnode.attrs && vnode.attrs.state; + const stateObj = externalState || (isRoom ? chatState.ChatHubState : peopleState.State); - if (!stateObj.showHistoryModal) return null; + if (!stateObj.showHistoryModal) { + wasOpen = false; + return null; + } + if (!wasOpen) { + wasOpen = true; + loadOnOpen(vnode); + } - let name = 'Chat History'; - if (isRoom) { + let name = (vnode.attrs && vnode.attrs.name) || 'Chat History'; + if (!externalState && isRoom) { const lobby = chatState.ChatLobbyModel.currentLobby; name = lobby ? lobby.lobby_name : 'Chat Room'; - } else { + } else if (!externalState) { const details = peopleState.State.selectedId ? peopleState.State.gxsIdToDetailsMap[peopleState.State.selectedId] : null; name = details ? (details.mNickname || details.mGroupName || 'Contact') : 'Contact'; } @@ -42,13 +63,13 @@ const HistoryBrowserModal = () => { }); return m('.history-modal-overlay', { - style: 'position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(15, 23, 42, 0.4); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 2000;', + style: 'position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; height: 100dvh; background-color: rgba(15, 23, 42, 0.4); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 2000;', onclick: (e) => { if (e.target === e.currentTarget) stateObj.showHistoryModal = false; } }, [ m('.history-modal', { - style: 'background: #ffffff; border-radius: 0.5rem; width: 780px; max-width: 92%; height: 85vh; max-height: 85vh; display: flex; flex-direction: column; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); overflow: hidden;' + style: 'background: #ffffff; border-radius: 0.5rem; width: 780px; max-width: 92%; height: 85vh; height: 85dvh; max-height: 85dvh; display: flex; flex-direction: column; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); overflow: hidden;' }, [ // Header m('.history-modal-header', { @@ -59,8 +80,17 @@ const HistoryBrowserModal = () => { m('h3', { style: 'margin: 0; font-size: 1.1rem; font-weight: 700; color: #1e293b;' }, `Chat History Browser — ${name}`), ]), m('button.close-btn', { - style: 'background: transparent; border: none; font-size: 1.25rem; color: #64748b; cursor: pointer; padding: 0.25rem; border-radius: 0.25rem;', + type: 'button', + style: 'background: transparent; border: none; box-shadow: none !important; font-size: 1.25rem; color: #64748b; cursor: pointer; padding: 0.35rem; border-radius: 0.375rem; width: auto; height: auto; min-width: unset; line-height: 1; display: inline-flex; align-items: center; justify-content: center; transition: background 0.15s ease, color 0.15s ease;', title: 'Close history browser', + onmouseenter: (e) => { + e.currentTarget.style.background = '#e2e8f0'; + e.currentTarget.style.color = '#1e293b'; + }, + onmouseleave: (e) => { + e.currentTarget.style.background = 'transparent'; + e.currentTarget.style.color = '#64748b'; + }, onclick: () => (stateObj.showHistoryModal = false), }, m('i.fas.fa-times')), ]), @@ -93,13 +123,15 @@ const HistoryBrowserModal = () => { ]) : filteredHistory.length === 0 ? m('.empty-history', { style: 'text-align: center; padding: 3rem; color: #64748b;' }, [ - m('i.far.fa-comments', { style: 'font-size: 2.5rem; color: #cbd5e1; margin-bottom: 0.75rem;' }), + m('i.fas.fa-comments', { style: 'font-size: 2.5rem; color: #cbd5e1; margin-bottom: 0.75rem;' }), m('p', 'No past chat messages found matching your query.'), ]) : filteredHistory.map((msg) => { const isIncoming = msg.incoming; let senderName = msg.peerName || (isIncoming ? name : 'You'); - if (!isIncoming) { + if (!isIncoming && externalState) { + senderName = (vnode.attrs && vnode.attrs.ownName) || 'You'; + } else if (!isIncoming) { const ownId = isRoom ? (chatState.ChatLobbyModel.currentLobby ? chatState.ChatLobbyModel.currentLobby.gxs_id : '') : peopleState.State.selectedOwnGxsIdForChat; senderName = rs.userList.username(ownId) || 'You'; } diff --git a/webui-src/app/people/people_own_contacts.js b/webui-src/app/people/people_own_contacts.js deleted file mode 100644 index 3787f84..0000000 --- a/webui-src/app/people/people_own_contacts.js +++ /dev/null @@ -1,24 +0,0 @@ -const m = require('mithril'); -const rs = require('rswebui'); -const peopleUtil = require('people/people_util'); - -const MyContacts = () => { - const list = peopleUtil.contactlist(rs.userList.users); - return { - view: () => { - return m('.widget', [ - m('.widget__heading', [ - m('h3', 'MyContacts', m('span.counter', list.length)), - m(peopleUtil.SearchBar), - ]), - m('.widget__body', [list.map((id) => m(peopleUtil.regularcontactInfo, { id }))]), - ]); - }, - }; -}; - -module.exports = { - view: () => { - return m(MyContacts); - }, -}; diff --git a/webui-src/app/people/people_ownids.js b/webui-src/app/people/people_ownids.js index 91eed52..8934840 100644 --- a/webui-src/app/people/people_ownids.js +++ b/webui-src/app/people/people_ownids.js @@ -3,116 +3,160 @@ const rs = require('rswebui'); const widget = require('widgets'); const peopleUtil = require('people/people_util'); -const SignedIdentiy = () => { - let passphase = ''; +const SignedIdentity = () => { + let passphrase = ''; + let submitting = false; + + const submit = async (v) => { + if (submitting || !passphrase) return; + submitting = true; + const previousIds = await peopleUtil.ownIds(); + rs.rsJsonApiRequest( + '/rsIdentity/createIdentity', + { + name: v.attrs.name, + avatar: { mData: { base64: v.attrs.avatar } }, + pseudonimous: false, + pgpPassword: passphrase, + }, + async (data) => { + // Only .catch() used to clear this, and a refused passphrase is a + // perfectly valid answer: the button stayed on "Creating…" for good. + submitting = false; + if (data && data.retval) await peopleUtil.refreshOwnIds(previousIds); + const message = data && data.retval + ? 'Successfully created identity.' + : 'Could not create the identity. Check your profile password and try again.'; + m.redraw(); + widget.popupMessage( + m('.signed-identity-result', [m('h3', 'Create new identity'), m('p', message)]), + 'signed-identity-modal' + ); + } + ).catch(() => { + submitting = false; + m.redraw(); + }); + }; return { - view: (v) => [ - m('i.fas.fa-user-edit'), - m('h3', 'Enter your passpharse'), - m('hr'), - - m('input[type=password][placeholder=Passpharse]', { - style: 'margin-top:50px;width:80%', - oninput: (e) => { - passphase = e.target.value; - }, + view: (v) => m('form.signed-identity-form', { + onsubmit: (event) => { + event.preventDefault(); + submit(v); + }, + }, [ + m('.signed-identity-form__heading', [ + m('i.fas.fa-user-edit'), + m('div', [ + m('h3', 'Create signed identity'), + m('p', 'Enter your RetroShare profile password to link this identity.'), + ]), + ]), + m('label[for=signed-identity-password]', 'Profile password'), + m('input#signed-identity-password[type=password][placeholder=Password][autocomplete=current-password]', { + value: passphrase, + autofocus: true, + oninput: (e) => (passphrase = e.target.value), }), - m( - 'button', - { - style: 'margin-top:160px;', - onclick: () => { - rs.rsJsonApiRequest('/rsIdentity/getOwnSignedIds', {}, (owns) => { - - owns.ids.length > 0 - ? rs.rsJsonApiRequest( - '/rsIdentity/createIdentity', - { - id: owns.ids[0], - name: v.attrs.name, - pseudonimous: false, - pgpPassword: passphase, - }, - (data) => { - const message = data.retval - ? 'Successfully created identity.' - : 'An error occured while creating identity.'; - widget.popupMessage([m('h3', 'Create new Identity'), m('hr'), message]); - } - ) - : widget.popupMessage([ - m('h3', 'Create new Identity'), - m('hr'), - 'An error occured while creating identity.', - ]); - }); - }, - }, - 'Enter' - ), - ], + m('button.signed-identity-form__submit[type=submit]', { + disabled: !passphrase || submitting, + }, submitting ? 'Creating…' : 'Create identity'), + ]), }; }; const CreateIdentity = () => { - // TODO: set user avatar let name = '', pseudonimous = false; + let avatar; + let avatarPreview = ''; + let avatarFileName = ''; return { - view: (v) => [ - m('i.fas.fa-user-plus'), - m('h3', 'Create new Identity'), - m('hr'), - m('input[type=text][placeholder=Name]', { + view: () => m('.create-identity-form', [ + m('.create-identity-form__heading', [ + m('i.fas.fa-user-plus'), + m('div', [ + m('h3', 'Create new Identity'), + m('p', 'Choose a name, identity type, and optional custom avatar.'), + ]), + ]), + m('input.create-identity-form__name[type=text][placeholder=Identity name]', { value: name, oninput: (e) => (name = e.target.value), }), - m( - 'div', - { - style: 'display:inline; margin-left:5px;', - }, - [ - 'Type:', - m( - 'select', - { - value: pseudonimous, - style: 'border:1px solid black', - oninput: (e) => { - pseudonimous = e.target.value === 'true'; - }, - }, - [ - m('option[value=false][selected]', 'Linked to your Profile'), - m('option[value=true]', 'Pseudonymous'), - ] - ), - ] - ), - m('br'), - - m( - 'p', + m('.create-identity-form__avatar', [ + m('.create-identity-avatar-preview', [ + avatarPreview + ? m('img', { src: avatarPreview, alt: 'Identity avatar preview' }) + : m(peopleUtil.UserAvatar, { + identityId: `new-identity:${name || 'identity'}`, + firstLetter: (name || '?').slice(0, 1).toUpperCase(), + size: 128, + isSquare: true, + }), + ]), + m('span.create-identity-form__avatar-label', 'Avatar'), + m('input.create-identity-form__file-input[type=file][id=create-identity-avatar][accept=image/*]', { + onchange: (e) => { + const file = e.target.files[0]; + if (!file) return; + avatarFileName = file.name; + const reader = new FileReader(); + reader.onloadend = () => { + avatarPreview = reader.result; + avatar = avatarPreview.substring(avatarPreview.indexOf(',') + 1); + m.redraw(); + }; + reader.readAsDataURL(file); + }, + }), + m('label.create-identity-form__file-button[for=create-identity-avatar]', { + title: avatarFileName || 'Choose a custom avatar', + }, [m('i.fas.fa-upload'), avatarPreview ? ' Change avatar' : ' Choose avatar']), + avatarPreview && m('button.create-identity-form__remove-avatar[type=button]', { + onclick: () => { + avatar = undefined; + avatarPreview = ''; + avatarFileName = ''; + }, + }, 'Use default'), + m('small', avatarPreview ? 'Custom avatar selected.' : 'A unique default avatar is generated automatically.'), + ]), + m('.create-identity-form__field', [ + m('label[for=create-identity-type]', 'Identity type'), + m('select.config-style-select[id=create-identity-type]', { + value: String(pseudonimous), + onchange: (e) => (pseudonimous = e.target.value === 'true'), + }, [ + m('option[value=false]', 'Linked to your Profile'), + m('option[value=true]', 'Pseudonymous'), + ]), + ]), + m('p.create-identity-form__help', 'You can have one or more identities. ' + 'They are used when you chat in lobbies, ' + 'forums and channel comments. ' + 'They act as the destination for distant chat and ' + 'the Retroshare distant mail system.' ), - m( - 'button', + m('button.create-identity-form__submit', { + disabled: !name.trim(), onclick: () => { !pseudonimous - ? widget.popupMessage(m(SignedIdentiy, { name })) + ? widget.popupMessage( + m(SignedIdentity, { name: name.trim(), avatar }), + 'signed-identity-modal' + ) : rs.rsJsonApiRequest( '/rsIdentity/createIdentity', { - name, + name: name.trim(), + avatar: { mData: { base64: avatar } }, pseudonimous, }, - (data) => { + async (data) => { + if (data.retval) await peopleUtil.refreshOwnIds(); const message = data.retval ? 'Successfully created identity.' : 'An error occured while creating identity.'; @@ -123,42 +167,58 @@ const CreateIdentity = () => { }, 'Create' ), - ], + ]), }; }; +// updateIdentity(id, name, avatar, pseudonimous, pgpPassword) takes the avatar +// as a mandatory parameter and p3IdService assigns it unconditionally +// (`group.mImage = avatar`). Leaving it out of the request does not mean "keep +// the one you have", it means "replace it with nothing": every edit used to +// erase the picture. So the current one is always sent back, unless the user +// picked another. +function avatarPayload(details, replacement) { + if (replacement !== undefined) return { mData: { base64: replacement } }; + const current = details && details.mAvatar && details.mAvatar.mData + ? details.mAvatar.mData.base64 || '' + : ''; + return { mData: { base64: current } }; +} + const SignedEditIdentity = () => { - let passphase = ''; + let passphrase = ''; return { view: (v) => [ m('i.fas.fa-user-edit'), - m('h3', 'Enter your passpharse'), + m('h3', 'Enter your profile passphrase'), m('hr'), - m('input[type=password][placeholder=Passpharse]', { + m('input[type=password][placeholder=Passphrase]', { style: 'margin-top:50px;width:80%', oninput: (e) => { - passphase = e.target.value; + passphrase = e.target.value; }, }), m( 'button', { style: 'margin-top:160px;', + disabled: !passphrase, onclick: () => rs.rsJsonApiRequest( '/rsIdentity/updateIdentity', { id: v.attrs.details.mId, name: v.attrs.name, + avatar: avatarPayload(v.attrs.details, v.attrs.avatar), pseudonimous: false, - pgpPassword: passphase, + pgpPassword: passphrase, }, (data) => { - const message = data.retval - ? 'Successfully created identity.' - : 'An error occured while creating identity.'; - widget.popupMessage([m('h3', 'Create new Identity'), m('hr'), message]); + const message = data && data.retval + ? 'Identity updated.' + : 'Could not update the identity. Check your profile password and try again.'; + widget.popupMessage([m('h3', 'Update Identity'), m('hr'), message]); } ), }, @@ -169,52 +229,102 @@ const SignedEditIdentity = () => { }; const EditIdentity = () => { - let name = ''; + // The field used to open empty and Save sent it as it stood, so an edit + // meant for the avatar alone renamed the identity to nothing. + let name; + let avatar; + let avatarPreview = ''; + return { - view: (v) => [ - m('i.fas.fa-user-edit'), - m('h3', 'Edit Identity'), - m('hr'), - m('input[type=text][placeholder=Name]', { - value: name, - oninput: (e) => { - name = e.target.value; - }, - }), - m('canvas'), - m( - 'button', - { - onclick: () => { - !peopleUtil.checksudo(v.attrs.details.mPgpId) - ? widget.popupMessage([ - m(SignedEditIdentity, { - name, - details: v.attrs.details, - }), - ]) - : rs.rsJsonApiRequest( - '/rsIdentity/updateIdentity', - { - id: v.attrs.details.mId, + view: (v) => { + const details = v.attrs.details || {}; + if (name === undefined) name = details.mNickname || details.mGroupName || ''; + const hasAvatar = Boolean(details.mAvatar && details.mAvatar.mData + && details.mAvatar.mData.base64); - name, - - // avatar: v.attrs.details.mAvatar.mData.base64, - pseudonimous: true, - }, - (data) => { - const message = data.retval - ? 'Successfully Updated identity.' - : 'An error occured while updating identity.'; - widget.popupMessage([m('h3', 'Update Identity'), m('hr'), message]); - } - ); + return m('.edit-identity-form', [ + m('.edit-identity-form__heading', [ + m('i.fas.fa-user-edit'), + m('h3', 'Edit Identity'), + ]), + m('label.edit-identity-form__name-label[for=edit-identity-name]', 'Identity name'), + m('input.edit-identity-form__name[type=text][placeholder=Name][id=edit-identity-name]', { + value: name, + oninput: (e) => { + name = e.target.value; }, - }, - 'Save' - ), - ], + }), + m('.edit-identity-form__avatar', [ + m(peopleUtil.UserAvatar, { + avatar: avatarPreview + ? { mData: { base64: avatarPreview.substring(avatarPreview.indexOf(',') + 1) } } + : (hasAvatar ? details.mAvatar : null), + identityId: details.mId, + firstLetter: (name || '?').slice(0, 1).toUpperCase(), + size: 64, + isSquare: true, + }), + m('input[type=file][accept=image/*][id=edit-identity-avatar]', { + style: 'display:none;', + onchange: (e) => { + const file = e.target.files && e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onloadend = () => { + avatarPreview = reader.result; + avatar = avatarPreview.substring(avatarPreview.indexOf(',') + 1); + m.redraw(); + }; + reader.readAsDataURL(file); + }, + }), + m('label.edit-identity-form__avatar-button[for=edit-identity-avatar]', + [m('i.fas.fa-upload'), ' Change avatar']), + avatarPreview && m('button.edit-identity-form__keep[type=button]', { + onclick: () => { + avatar = undefined; + avatarPreview = ''; + }, + }, 'Keep current'), + ]), + m( + 'button', + { + class: 'edit-identity-form__save', + disabled: !String(name).trim(), + onclick: () => { + const trimmed = String(name).trim(); + if (!trimmed) return; + + !peopleUtil.checksudo(details.mPgpId) + ? widget.popupMessage([ + m(SignedEditIdentity, { + name: trimmed, + avatar, + details, + }), + ]) + : rs.rsJsonApiRequest( + '/rsIdentity/updateIdentity', + { + id: details.mId, + name: trimmed, + avatar: avatarPayload(details, avatar), + pseudonimous: true, + }, + (data) => { + const message = data && data.retval + ? 'Identity updated.' + : 'Could not update the identity.'; + widget.popupMessage([m('h3', 'Update Identity'), m('hr'), message]); + } + ); + }, + }, + 'Save' + ), + ]); + }, }; }; @@ -234,13 +344,25 @@ const DeleteIdentity = () => { { id: v.attrs.id, }, - () => { + async (data) => { + // Nothing used to refresh the own identities after this, and + // watchOwnIds only listens for the event refreshOwnIds emits: + // the deleted identity stayed in the list. The answer was not + // read either -- a refused delete still announced success. + const done = Boolean(data && data.retval); + if (done) { + peopleUtil.invalidateOwnIds(); + await peopleUtil.refreshOwnIds(); + } widget.popupMessage([ - m('i.fas.fa-user-edit'), + m('i.fas.fa-user-times'), m('h3', 'Delete Identity: ' + v.attrs.name), m('hr'), - m('p', 'Identity Deleted successfuly.'), + m('p', done + ? 'Identity deleted.' + : 'The core refused to delete this identity.'), ]); + m.redraw(); } ), }, @@ -250,119 +372,7 @@ const DeleteIdentity = () => { }; }; -const Identity = () => { - let details = {}; - - return { - oninit: (v) => - rs.rsJsonApiRequest( - '/rsIdentity/getIdDetails', - { - id: v.attrs.id, - }, - (data) => { - details = data.details; - } - ), - view: (v) => - m( - '.identity', - { - key: details.mId, - }, - [ - m('h4', details.mNickname), - details.mNickname && - m(peopleUtil.UserAvatar, { - avatar: details.mAvatar, - firstLetter: details.mNickname.slice(0, 1).toUpperCase(), - identityId: details.mId, - }), - m('.details', [ - m('p', 'ID:'), - m('p', details.mId), - m('p', 'Type:'), - m('p', details.mFlags === 14 ? 'Signed ID' : 'Anonymous ID'), - m('p', 'Owner node ID:'), - m('p', details.mPgpId), - m('p', 'Created on:'), - m( - 'p', - typeof details.mPublishTS === 'object' - ? new Date(details.mPublishTS.xint64 * 1000).toLocaleString() - : 'undefiend' - ), - m('p', 'Last used:'), - m( - 'p', - typeof details.mLastUsageTS === 'object' - ? new Date(details.mLastUsageTS.xint64 * 1000).toLocaleDateString() - : 'undefiend' - ), - ]), - m( - 'button', - { - onclick: () => - m.route.set('/chat/:userid/createdistantchat', { - userid: details.mId, - }), - }, - 'Chat' - ), - m( - 'button', - { - onclick: () => - widget.popupMessage( - m(EditIdentity, { - details, - }) - ), - }, - 'Edit' - ), - m( - 'button.red', - { - onclick: () => - widget.popupMessage( - m(DeleteIdentity, { - id: details.mId, - name: details.mNickname, - }) - ), - }, - 'Delete' - ), - ] - ), - }; -}; - -const Layout = () => { - let ownIds = []; - return { - oninit: () => peopleUtil.ownIds((data) => (ownIds = data)), - view: () => - m('.widget', [ - m('.widget__heading', [ - m('h3', 'Own Identities', m('span.counter', ownIds.length)), - m( - 'button', - { - onclick: () => widget.popupMessage(m(CreateIdentity)), - }, - 'New Identity' - ), - ]), - m('.widget__body', [ownIds.map((id) => m(Identity, { id }))]), - ]), - }; -}; - -Layout.CreateIdentity = CreateIdentity; -Layout.EditIdentity = EditIdentity; -Layout.DeleteIdentity = DeleteIdentity; - -module.exports = Layout; +// Only these three are reachable: the details pane and the sidebar open them +// as modals. The "Own Identities" widget that used to be exported here, and +// the Identity card it rendered, were routed nowhere. +module.exports = { CreateIdentity, EditIdentity, DeleteIdentity }; diff --git a/webui-src/app/people/people_sidebar.js b/webui-src/app/people/people_sidebar.js index e9de2db..80f2cf4 100644 --- a/webui-src/app/people/people_sidebar.js +++ b/webui-src/app/people/people_sidebar.js @@ -2,6 +2,7 @@ const m = require('mithril'); const rs = require('rswebui'); const widget = require('widgets'); const peopleUtil = require('people/people_util'); +const chatPreviewText = require('chat/chat_preview'); const ownIdsLayout = require('people/people_ownids'); const { CreateIdentity } = ownIdsLayout; const { @@ -14,8 +15,12 @@ const { get64Num, stopStatusPolling, initializeDistantChat, + markDistantChatRead, + isDistantChatActive, } = require('people/people_state'); +const LIST_RENDER_CAP = 200; + function formatRelativeTime(ts) { if (!ts) return ''; const now = Math.floor(Date.now() / 1000); @@ -35,16 +40,17 @@ const PeopleSidebar = () => { // 1. Determine list based on mainTab ('people' vs 'chats') let displayItems; - // 0. Compute active chats count (conversations with real message history) - const allUserGroupIds = new Set((rs.userList.users || []).map((u) => u.mGroupId)); - Object.keys(State.chatHistoryMap || {}).forEach((id) => allUserGroupIds.add(id)); - let activeChatsCount = 0; - allUserGroupIds.forEach((gxsId) => { - const hist = State.chatHistoryMap && State.chatHistoryMap[gxsId]; - if (hist && hist.lastMsg && !isSystemMsg(hist.lastMsg)) { - activeChatsCount++; - } + // 0. The conversations we know of. Only peers with a real message ever + // get an entry in chatHistoryMap, so reading it directly answers both + // the badge and the list. Sweeping the whole identity list instead -- + // tens of thousands of them on an old node -- costs that sweep on every + // redraw, and it counts nothing the map does not already hold. + const chatPeerIds = Object.keys(State.chatHistoryMap || {}).filter((gxsId) => { + const hist = State.chatHistoryMap[gxsId]; + return Boolean(hist && hist.lastMsg && !isSystemMsg(hist.lastMsg)); }); + const unreadChatsCount = Object.values(State.unreadChatCount || {}) + .reduce((total, count) => total + count, 0); if (State.mainTab === 'people') { let baseList; @@ -67,25 +73,19 @@ const PeopleSidebar = () => { return nameA.localeCompare(nameB); }); } else { - // Chats Tab: ONLY contacts and identities that have real chat history (ignoring system tunnel status logs) - displayItems = Array.from(allUserGroupIds) + // Chats Tab: ONLY identities that have real chat history (ignoring system tunnel status logs) + displayItems = chatPeerIds .map((gxsId) => { + // Details are fetched for the handful of peers actually listed, + // not for every identity the node has ever seen. + fetchIdDetails(gxsId); const entry = rs.userList.userMap[gxsId]; const name = entry && entry.name ? entry.name : (rs.userList.username(gxsId) || 'Unknown'); return { mGroupId: gxsId, mGroupName: name }; }) - .filter((item) => { - const gxsId = item.mGroupId; - fetchIdDetails(gxsId); - const hist = State.chatHistoryMap && State.chatHistoryMap[gxsId]; - - const hasRealHistory = Boolean(hist && hist.lastMsg && !isSystemMsg(hist.lastMsg)); - - if (!hasRealHistory) return false; - - const name = item.mGroupName || 'Unknown'; - return name.toLowerCase().includes(State.searchString.toLowerCase()); - }); + .filter((item) => (item.mGroupName || 'Unknown') + .toLowerCase() + .includes(State.searchString.toLowerCase())); // Sort by chat timestamp descending displayItems.sort((a, b) => { @@ -100,6 +100,13 @@ const PeopleSidebar = () => { }); } + // "All Users" is every identity the node has ever seen -- tens of + // thousands on an old profile. Rendering them all builds that many DOM + // rows and fires one getIdDetails per row from inside this view. The + // list is capped instead, and the search narrows it. + const shownItems = displayItems.slice(0, LIST_RENDER_CAP); + const hiddenCount = displayItems.length - shownItems.length; + return m('.people-left-pane', [ // Sidebar Header Container m('.people-sidebar-header', [ @@ -138,7 +145,7 @@ const PeopleSidebar = () => { [ m('i.fas.fa-comments'), ' Chats', - activeChatsCount > 0 && m('span.segment-badge', activeChatsCount), + unreadChatsCount > 0 && m('span.segment-badge', unreadChatsCount), ] ), ]), @@ -173,7 +180,7 @@ const PeopleSidebar = () => { m( 'button.btn-add-id[title=Create New Identity]', { - onclick: () => widget.popupMessage(m(CreateIdentity)), + onclick: () => widget.popupMessage(m(CreateIdentity), 'create-identity-modal'), }, m('i.fas.fa-plus') ), @@ -185,18 +192,21 @@ const PeopleSidebar = () => { m('.friends-scroll', [ displayItems.length === 0 ? m('.network-pane-placeholder', { style: 'padding: 2rem 0;' }, State.mainTab === 'chats' ? 'No active chats' : 'No identities found') - : displayItems.map((item) => { - let gxsId, displayName; + : shownItems.map((item) => { + let gxsId; if (State.mainTab === 'people' && State.activeFilter === 'own') { gxsId = item; - displayName = rs.userList.username(gxsId) || 'Unknown'; } else { gxsId = item.mGroupId; - displayName = item.mGroupName || 'Unknown'; } fetchIdDetails(gxsId); const itemDetails = State.gxsIdToDetailsMap[gxsId]; + const displayName = (itemDetails && (itemDetails.mNickname || itemDetails.mGroupName)) + || (State.mainTab === 'people' && State.activeFilter === 'own' + ? rs.userList.username(gxsId) + : item.mGroupName) + || 'Loading…'; const itemAvatar = getSafeAvatar(itemDetails); const itemFirstLetter = (displayName || '?').slice(0, 1).toUpperCase(); const isSelected = State.selectedId === gxsId; @@ -204,11 +214,14 @@ const PeopleSidebar = () => { const itemEntry = rs.userList.userMap[gxsId]; const itemIsContact = itemEntry && itemEntry.isContact; const itemIsOwn = State.ownGxsIds.includes(gxsId); + const hasActiveTunnel = isDistantChatActive(gxsId); const hist = State.chatHistoryMap[gxsId]; const lastTS = hist ? hist.lastTime : (itemDetails ? get64Num(itemDetails.mLastUsageTS) : 0); const relativeTimeStr = formatRelativeTime(lastTS); - const lastMsgText = hist && hist.lastMsg ? hist.lastMsg : (itemIsOwn ? 'My Identity' : itemIsContact ? 'Saved Contact' : 'Distant Chat'); + const lastMsgText = hist && hist.lastMsg + ? chatPreviewText(hist.lastMsg) + : (itemIsOwn ? 'My Identity' : itemIsContact ? 'Saved Contact' : 'Distant Chat'); if (State.mainTab === 'chats') { return m( @@ -221,6 +234,8 @@ const PeopleSidebar = () => { State.activeMenu = null; State.selectedId = gxsId; State.activeTab = 'chat'; + State.mobilePane = 'detail'; + markDistantChatRead(gxsId); initializeDistantChat(); m.redraw(); }, @@ -248,8 +263,11 @@ const PeopleSidebar = () => { }), m('.status-dot', { style: { - backgroundColor: itemIsContact || itemIsOwn ? '#22c55e' : '#cbd5e1', + backgroundColor: hasActiveTunnel ? '#22c55e' : '#cbd5e1', }, + title: hasActiveTunnel + ? 'Distant chat tunnel active' + : 'Distant chat tunnel inactive', }), ]), m('.chat-info', [ @@ -258,6 +276,8 @@ const PeopleSidebar = () => { ]), m('.chat-meta', [ relativeTimeStr && m('.chat-time', relativeTimeStr), + (State.unreadChatCount[gxsId] || 0) > 0 + && m('.chat-unread-badge', State.unreadChatCount[gxsId]), ]), ] ); @@ -275,13 +295,19 @@ const PeopleSidebar = () => { const idChanged = State.selectedId !== gxsId; State.selectedId = gxsId; + State.mobilePane = 'detail'; if (idChanged) { State.chatPid = null; State.chatMessages = []; stopStatusPolling(); - if (State.activeTab === 'chat') { - initializeDistantChat(); - } + // Selecting somebody is not asking to talk to them. + // The chat tab is sticky, so inheriting it here meant + // that once a conversation had been opened, every + // later click in the list silently requested a GXS + // tunnel toward the contact -- an action the peer + // sees. Show the profile; the tunnel waits for the + // Chat Conversation tab. + State.activeTab = 'details'; } m.redraw(); }, @@ -320,6 +346,9 @@ const PeopleSidebar = () => { ] ); }), + hiddenCount > 0 && m('.friends-list-more', { + style: 'padding: 0.75rem 1rem; color: #64748b; font-size: 0.85rem; font-style: italic;', + }, `${hiddenCount} more identities — search to narrow the list`), ]), // Context Menu @@ -327,66 +356,91 @@ const PeopleSidebar = () => { const menu = State.activeMenu; const isOwn = State.ownGxsIds.includes(menu.gxsId); - return m('.people-context-menu', { - style: { - top: `${menu.top}px`, - left: menu.left !== undefined ? `${menu.left}px` : '10px', - position: 'absolute', - zIndex: 1000, - }, - onclick: (e) => { - e.stopPropagation(); - }, - }, [ - !isOwn && m('.menu-item', { - onclick: () => { + return [ + m('.menu-backdrop', { + style: { + position: 'fixed', + inset: 0, + zIndex: 9998, + }, + onclick: (e) => { + e.preventDefault(); + e.stopPropagation(); State.activeMenu = null; - State.selectedId = menu.gxsId; - State.activeTab = 'chat'; - State.chatPid = null; - State.chatMessages = []; - initializeDistantChat(); m.redraw(); }, - }, [ - m('i.fas.fa-comments', { style: 'color: #3b82f6; margin-right: 0.5rem;' }), - 'Start chat', - ]), - !isOwn && m('.menu-item', { - onclick: () => { + oncontextmenu: (e) => { + e.preventDefault(); + e.stopPropagation(); State.activeMenu = null; - State.selectedId = menu.gxsId; - State.activeTab = 'details'; - State.showMailCompose = true; m.redraw(); }, - }, [ - m('i.fas.fa-envelope', { style: 'color: #10b981; margin-right: 0.5rem;' }), - 'Send mail', - ]), - !isOwn && m('.menu-item', { - onclick: () => { - State.activeMenu = null; - rs.rsJsonApiRequest( - '/rsIdentity/setAsRegularContact', - { id: menu.gxsId, isContact: !menu.isContact }, - (data, success) => { - if (success) { - loadGxsIdentities(); - } - } - ); + }), + m('.people-context-menu', { + style: { + top: `${menu.top}px`, + left: menu.left !== undefined ? `${menu.left}px` : '10px', + position: 'absolute', + zIndex: 9999, + }, + onclick: (e) => { + e.stopPropagation(); }, }, [ - m('i.fas' + (menu.isContact ? '.fa-user-minus' : '.fa-user-plus'), { - style: { - color: menu.isContact ? '#ef4444' : '#3b82f6', - marginRight: '0.5rem', + !isOwn && m('.menu-item', { + onclick: () => { + State.activeMenu = null; + State.selectedId = menu.gxsId; + State.activeTab = 'chat'; + State.chatPid = null; + State.chatMessages = []; + initializeDistantChat(); + m.redraw(); }, - }), - menu.isContact ? 'Remove from Contacts' : 'Add to Contacts', + }, [ + m('i.fas.fa-comments', { style: 'color: #3b82f6; margin-right: 0.5rem;' }), + 'Start chat', + ]), + !isOwn && m('.menu-item', { + onclick: () => { + State.activeMenu = null; + State.selectedId = menu.gxsId; + State.activeTab = 'details'; + State.showMailCompose = true; + m.redraw(); + }, + }, [ + m('i.fas.fa-envelope', { style: 'color: #10b981; margin-right: 0.5rem;' }), + 'Send mail', + ]), + !isOwn && m('.menu-item', { + onclick: () => { + State.activeMenu = null; + rs.rsJsonApiRequest( + '/rsIdentity/setAsRegularContact', + { id: menu.gxsId, isContact: !menu.isContact }, + (data, success) => { + if (success) { + // isContact is read from rs.userList.userMap, which + // only loadUsers() refreshes: reloading the identity + // summaries alone left the list showing the old state. + rs.userList.loadUsers(); + loadGxsIdentities(); + } + } + ); + }, + }, [ + m('i.fas' + (menu.isContact ? '.fa-user-minus' : '.fa-user-plus'), { + style: { + color: menu.isContact ? '#ef4444' : '#3b82f6', + marginRight: '0.5rem', + }, + }), + menu.isContact ? 'Remove from Contacts' : 'Add to Contacts', + ]), ]), - ]); + ]; })(), ]), ]); diff --git a/webui-src/app/people/people_state.js b/webui-src/app/people/people_state.js index d1ad93e..a1850b8 100644 --- a/webui-src/app/people/people_state.js +++ b/webui-src/app/people/people_state.js @@ -12,12 +12,15 @@ const State = { ownGxsIds: [], gpgToGxsIdMap: {}, chatHistoryMap: {}, // gxsId -> { lastMsg, lastTime } + unreadChatCount: {}, // gxsId -> new incoming messages not yet opened showMailCompose: false, activeTab: 'details', + mobilePane: 'list', // Phone master/detail navigation: 'list' | 'detail' selectedOwnGxsIdForChat: '', chatPid: null, chatMessages: [], chatInputMsg: '', + attachedImage: null, distantChatStatus: null, statusPollInterval: null, chatDisconnected: false, @@ -27,6 +30,14 @@ const State = { historySearchQuery: '', fullHistoryMessages: [], isHistoryLoading: false, + pendingChatOpen: null, // gxsId a chat was explicitly asked for from another page + chatCloseFoundNothing: false, // the core had no connection left to close + chatEndedByPoll: false, // the status poll saw the tunnel go, we did not close it + statusPollFailures: 0, // consecutive getDistantChatStatus answers of false + showEmojiPicker: false, + attachPath: '', // file being hashed for a retroshare:// link + isHashing: false, + attachError: '', }; function getDistantChatSession(gxsId) { @@ -36,26 +47,155 @@ function getDistantChatSession(gxsId) { pid: null, status: null, messages: [], + msgKeys: new Set(), inputMsg: '', + attachedImage: null, disconnected: false, }; } - return State.activeDistantChats[gxsId]; + const session = State.activeDistantChats[gxsId]; + // Sessions created by an older build of this file have no key set. + if (!session.msgKeys) session.msgKeys = new Set(); + return session; +} + +// The chat view renders `State.chatMessages`, while the live event handler and +// the history loader work on the per peer `session.messages`. Those two MUST +// remain the very same array: the moment one side is *reassigned* instead of +// mutated, the other one becomes an orphan and the messages written into it +// are never displayed. That is exactly what used to happen -- the history +// answer rebound `State.chatMessages` to a fresh array, so every incoming +// message landed in the now invisible `session.messages` and the conversation +// looked one-way. Everything below therefore mutates the session array in +// place, and `State.chatMessages` is only ever re-pointed *at* it. +function chatMessageKey(msg) { + const text = msg.msg || msg.message || ''; + // System notices are identified by their text alone: they are re-emitted on + // every status poll and must not pile up. + if (msg.isSystem) return 'sys_' + text; + const time = msg.sendTime || msg.recvTime || 0; + return (msg.incoming ? 'in_' : 'out_') + time + '_' + text; +} + +// The text being typed belongs to the conversation it is being typed in. It +// used to live in State.chatInputMsg alone, which nothing cleared when the +// selected peer changed: a message written to one contact stayed in the box +// when the next conversation opened, one Enter away from the wrong recipient. +function setChatDraft(text) { + State.chatInputMsg = text; + const session = State.selectedId ? getDistantChatSession(State.selectedId) : null; + if (session) session.inputMsg = text; +} + +function scrollChatToBottom() { + setTimeout(() => { + const element = document.querySelector('.chat-messages'); + if (element) element.scrollTop = element.scrollHeight; + }, 100); +} + +// Returns true when at least one message was really new, so callers can skip +// the redraw/scroll when the core just replayed something already displayed. +function addSessionMessages(session, msgs) { + if (!session || !msgs || msgs.length === 0) return false; + let added = false; + msgs.forEach((msg) => { + if (!msg) return; + const key = chatMessageKey(msg); + if (session.msgKeys.has(key)) return; + session.msgKeys.add(key); + session.messages.push(msg); + added = true; + }); + if (!added) return false; + session.messages.sort( + (a, b) => (a.sendTime || a.recvTime || 0) - (b.sendTime || b.recvTime || 0) + ); + return true; +} + +function resetSessionMessages(session, msgs) { + if (!session) return; + session.messages.length = 0; + session.msgKeys.clear(); + addSessionMessages(session, msgs); +} + +function addSessionSystemMessage(session, text) { + return addSessionMessages(session, [{ + incoming: true, + isSystem: true, + msg: text, + sendTime: Math.floor(Date.now() / 1000), + }]); +} + +// Messages received while the People tab was not mounted -- or before its +// event handler was installed -- are still sitting in the rswebui event queue +// buffer, keyed by chat type and distant chat id. +function drainBufferedChatMessages(session) { + if (!session || !session.pid) return false; + const owner = rs.events && rs.events[15]; + const buckets = owner && owner.messages ? owner.messages[2] : null; + const buffered = buckets ? buckets[session.pid] : null; + if (!buffered || buffered.length === 0) return false; + return addSessionMessages(session, buffered); } -function fetchIdDetails(gxsId) { - if (!gxsId) return; +// Details are fetched once and kept for good, which is what makes the lists +// cheap. The identity being *looked at* is another matter: its reputation, +// its usage record and its avatar move while the pane is open, so that one is +// allowed to go stale and be asked again. +const SELECTED_DETAILS_TTL_MS = 60 * 1000; +const detailsFetchedAt = {}; + +function refreshSelectedIdDetails() { + const gxsId = State.selectedId; + if (!peopleUtil.isUsableIdentityId(gxsId)) return; + const at = detailsFetchedAt[gxsId] || 0; + if (Date.now() - at < SELECTED_DETAILS_TTL_MS) return; + + // Asked again over what is already displayed, never by clearing it first: + // the entry is what the pane renders, and emptying it would blank the whole + // profile for the length of a round trip. + detailsFetchedAt[gxsId] = Date.now(); + rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id: gxsId }, (detData) => { + const details = detData && detData.details; + if (details && peopleUtil.isUsableIdentityId(String(details.mId || ''))) { + State.gxsIdToDetailsMap[gxsId] = details; + m.redraw(); + } + }); +} + +function fetchIdDetails(gxsId, attempt = 0) { + if (!peopleUtil.isUsableIdentityId(gxsId)) return; if (State.gxsIdToDetailsMap[gxsId] === undefined) { + detailsFetchedAt[gxsId] = Date.now(); State.gxsIdToDetailsMap[gxsId] = null; // Mark as loading rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id: gxsId }, (detData) => { - if (detData && detData.details) { + const details = detData && detData.details; + const detailsId = details && String(details.mId || ''); + if (details && peopleUtil.isUsableIdentityId(detailsId)) { State.gxsIdToDetailsMap[gxsId] = detData.details; const pgpId = detData.details.mPgpId; if (pgpId && pgpId !== '0000000000000000') { State.gpgToGxsIdMap[pgpId.toLowerCase()] = gxsId; } m.redraw(); + } else if (attempt < 5) { + setTimeout(() => { + State.gxsIdToDetailsMap[gxsId] = undefined; + fetchIdDetails(gxsId, attempt + 1); + }, 250 * (attempt + 1)); + } else { + // Give up on this id, but do NOT restore `undefined`: that is the + // value which makes this function fire a request, and two of the + // callers sit inside a view (people_sidebar). Every redraw would then + // start the whole six request chain again, forever, for any id the + // core never resolves. `null` keeps the entry marked as attempted. + State.gxsIdToDetailsMap[gxsId] = null; } }); } @@ -90,18 +230,32 @@ function get64Num(val) { return Number(val) || 0; } +// RsIdentityUsage::mServiceId is an RsServiceType (rsserviceids.h), a 16 bit +// service number -- 0x0215 for the forums, 0x0217 for the channels. Matching it +// against 1..8 could never succeed, so every line of the usage panel used to +// read "Unknown (533)". +const SERVICE_NAMES = { + 0x0012: 'Chat', + 0x0022: 'Mail', + 0x0023: 'Direct mail', + 0x0024: 'Distant mail', + 0x0027: 'Distant chat', + 0x0028: 'GXS tunnels', + 0x0211: 'Identities', + 0x0213: 'Wiki', + 0x0214: 'Wire', + 0x0215: 'Forums', + 0x0216: 'Boards', + 0x0217: 'Channels', + 0x0218: 'Circles', + 0x0219: 'Reputation', + 0x0221: 'Calendar', + 0x0230: 'Distant messages', +}; + function getServiceName(serviceId) { - switch (serviceId) { - case 1: return 'Channels'; - case 2: return 'Forums'; - case 3: return 'Boards'; - case 4: return 'Chat'; - case 5: return 'GxsCircles'; - case 6: return 'GxsMail'; - case 7: return 'GxsCircles'; - case 8: return 'Wire'; - default: return 'Unknown (' + serviceId + ')'; - } + const id = Number(serviceId); + return SERVICE_NAMES[id] || ('Unknown (0x' + id.toString(16) + ')'); } function createUsageString(u) { @@ -214,49 +368,62 @@ function getStatusTooltip(status) { function pollDistantChatStatus() { if (!State.chatPid) return; - const session = State.selectedId ? getDistantChatSession(State.selectedId) : null; + // Captured now: the answer lands seconds later on a slow link, and by then + // the user may be on another contact, or the page on another tunnel. An + // answer about a stale pid used to mark the new conversation as ended. + const pid = State.chatPid; + const askedFor = State.selectedId; + const session = askedFor ? getDistantChatSession(askedFor) : null; rs.rsJsonApiRequest( '/rsChats/getDistantChatStatus', { - pid: State.chatPid, + pid, }, (detail, success) => { - if (success && detail.retval) { - State.distantChatStatus = detail.info; - if (session) session.status = detail.info; - - if (detail.info.status === 2) { - const text = 'Tunnel is secured. You can talk!'; - const exists = State.chatMessages.some( - (m) => m.isSystem && (m.msg === text || m.message === text) - ); - if (!exists) { - State.chatMessages.push({ - incoming: true, - isSystem: true, - msg: text, - sendTime: Math.floor(Date.now() / 1000), - }); - State.chatMessages.sort((a, b) => a.sendTime - b.sendTime); - } - } else if (detail.info.status === 3) { - const text = 'Your partner closed the conversation.'; - const exists = State.chatMessages.some( - (m) => m.isSystem && (m.msg === text || m.message === text) - ); - if (!exists) { - State.chatMessages.push({ - incoming: true, - isSystem: true, - msg: text, - sendTime: Math.floor(Date.now() / 1000), - }); - State.chatMessages.sort((a, b) => a.sendTime - b.sendTime); + if (State.chatPid !== pid || State.selectedId !== askedFor) return; + // getDistantChatStatus answers false once the tunnel is gone from the + // core -- died of inaction, closed by the peer, closed by us. Ignoring + // that answer left the last known status on screen for good: a dead + // conversation kept its green dot and its "You can talk", and the Leave + // button then had nothing left to close. + if (!success || !detail || !detail.retval) { + State.statusPollFailures += 1; + if (State.statusPollFailures >= 2) { + if (session) { + addSessionSystemMessage(session, 'The distant chat tunnel is gone.'); + session.disconnected = true; } + State.distantChatStatus = null; + State.chatDisconnected = true; + State.chatCloseFoundNothing = false; + State.chatEndedByPoll = true; + stopStatusPolling(); + m.redraw(); } - m.redraw(); + return; } + + State.statusPollFailures = 0; + State.distantChatStatus = detail.info; + if (session) { + session.status = detail.info; + + // A status line is a message like any other: when one really lands + // (the helper drops what is already there) the pane has to follow it, + // or "You can talk" sits below the fold and the tunnel looks stuck. + let statusLineAdded = false; + if (detail.info.status === 2) { + statusLineAdded = addSessionSystemMessage(session, 'Tunnel is secured. You can talk!'); + // The tunnel just went up: anything the peer sent while it was still + // pending is waiting in the event buffer. + drainBufferedChatMessages(session); + } else if (detail.info.status === 3) { + statusLineAdded = addSessionSystemMessage(session, 'Your partner closed the conversation.'); + } + if (statusLineAdded && State.selectedId === askedFor) scrollChatToBottom(); + } + m.redraw(); } ); } @@ -297,30 +464,93 @@ function initializeDistantChat(force = false) { State.chatMessages = session.messages; State.distantChatStatus = session.status; State.chatDisconnected = session.disconnected; + State.chatInputMsg = session.inputMsg || ''; + drainBufferedChatMessages(session); loadChatMessages(); pollDistantChatStatus(); startStatusPolling(); return; } - // Otherwise, start a new tunnel for this peer + // A live tunnel to this peer may exist without this page knowing: opened + // from the desktop window, or by the peer, possibly under another of our + // identities. Its id is sha1(sorted(own || peer)), so every candidate can + // be asked for by id. When one is up, chat as that identity: asking the + // core for any other pair digs a second tunnel, and the page then sat on + // "Connecting" beside a green tunnel in the desktop UI. + // + // Only a tunnel that can talk (status 2) counts. The core also keeps + // entries for tunnels that died -- a peer-opened one it cannot re-dig + // itself -- and settling on one of those left the page waiting for good. + // Either way the conversation is then opened through + // initiateDistantChatConnexion: for an existing pair the core just hands + // back the same tunnel id, and its notify pops the desktop window as it + // always did. Explicit identity switches (force) skip the probe. + if (!force) { + const askedFor = State.selectedId; + findLiveTunnelIdentity(askedFor, (ownId) => { + // The answers come back later; the user may have moved on. + if (State.selectedId !== askedFor) return; + if (ownId) State.selectedOwnGxsIdForChat = ownId; + openDistantChat(session); + }); + return; + } + + openDistantChat(session); +} + +// Ask the core about every tunnel id we could share with this peer, one per +// own identity, and answer with the identity of the one that can talk. +function findLiveTunnelIdentity(peerGxsId, done) { + const candidates = (State.ownGxsIds || []) + .map((ownId) => ({ ownId, pid: peopleUtil.distantChatPid(ownId, peerGxsId) })) + .filter((c) => c.pid); + if (candidates.length === 0) { + done(null); + return; + } + + const found = []; + let left = candidates.length; + candidates.forEach((c) => { + rs.rsJsonApiRequest('/rsChats/getDistantChatStatus', { pid: c.pid }, (detail, success) => { + if (success && detail && detail.retval && detail.info) { + found.push({ ...c, info: detail.info }); + } + left -= 1; + if (left > 0) return; + const live = found.find((f) => f.info.status === 2); + done(live ? live.ownId : null); + }); + }); +} + +function openDistantChat(session) { + // Captured now: the initiate answer can land seconds later, after the + // user moved to another contact. + const askedFor = State.selectedId; session.pid = null; session.status = null; - session.messages = [ + resetSessionMessages(session, [ { incoming: true, isSystem: true, msg: 'Starting distant chat... Please wait for secure tunnel.', sendTime: Math.floor(Date.now() / 1000), } - ]; + ]); session.disconnected = false; State.chatPid = null; State.chatMessages = session.messages; State.distantChatStatus = null; State.chatDisconnected = false; + State.chatCloseFoundNothing = false; + State.chatEndedByPoll = false; + State.statusPollFailures = 0; + State.chatInputMsg = session.inputMsg || ''; m.redraw(); rs.rsJsonApiRequest( @@ -331,11 +561,34 @@ function initializeDistantChat(force = false) { notify: true, }, (res) => { - if (res && res.pid) { - const hexPid = rs.idToHex(res.pid); + // A refused initiate (unknown own identity, for one) answers with a + // null id: taking "000...0" for a tunnel makes the status poll chase + // it and declare the conversation gone. + const hexPid = res && res.pid ? rs.idToHex(res.pid) : ''; + if (!hexPid || /^0+$/.test(hexPid)) { + // Refused (unknown own identity, for one). Without a terminal state + // the pane said "Please wait for secure tunnel" forever. + session.disconnected = true; + addSessionSystemMessage(session, 'The core refused to open the tunnel.'); + if (State.selectedId === askedFor) { + State.chatDisconnected = true; + State.chatEndedByPoll = true; + m.redraw(); + } + return; + } + { + // The session keeps its pid whatever is on screen by now; the + // page-wide state and the loads/polls belong to the conversation + // still being looked at. Without this, a late answer clobbered + // State.chatPid and every downstream guard that compares against + // it, merging the old contact's tunnel into the new one's view. session.pid = hexPid; + if (State.selectedId !== askedFor) return; + State.chatPid = hexPid; State.distantChatStatus = null; + drainBufferedChatMessages(session); loadChatMessages(); pollDistantChatStatus(); startStatusPolling(); @@ -345,183 +598,533 @@ function initializeDistantChat(force = false) { } +function loadHistorySlice(session, chatPeerId, count) { + rs.rsJsonApiRequest('/rsHistory/getMessages', { chatPeerId, loadCount: count }, (data, success) => { + if (!success || !data || !data.msgs || !session) return; + if (addSessionMessages(session, data.msgs) && session.pid === State.chatPid) { + State.chatMessages = session.messages; + m.redraw(); + } + }); +} + function loadChatMessages() { if (!State.chatPid) return; - const chatPeerId = { - broadcast_status_peer_id: '00000000000000000000000000000000', - type: 2, // TYPE_PRIVATE_DISTANT - peer_id: '00000000000000000000000000000000', - distant_chat_id: State.chatPid, - lobby_id: { xstr64: '0' }, - }; + // Captured now: the answer may come back after the user selected another + // peer, and it must then land in the session -- and the Chats preview + // line -- it was asked for. + const askedFor = State.selectedId; + const session = askedFor ? getDistantChatSession(askedFor) : null; + // The current tunnel first, then whatever else the core holds with this + // contact (other identities, direct chat), so the pane shows the whole + // conversation and not only the file of the tunnel just opened. + const sources = historySourcesFor(State.selectedId); + const chatPeerId = distantChatIdFor(State.chatPid); + sources.forEach((other) => { + if (other.distant_chat_id !== State.chatPid) loadHistorySlice(session, other, HISTORY_PAGE); + }); rs.rsJsonApiRequest( '/rsHistory/getMessages', { chatPeerId, - loadCount: 50, + loadCount: HISTORY_PAGE, }, (data, success) => { if (success && data.msgs) { - State.chatMessages = data.msgs; + if (session) { + // Merge, never replace: the session array is the one the view and + // the live event handler share. + addSessionMessages(session, data.msgs); + if (session.pid === State.chatPid) State.chatMessages = session.messages; + } else if (State.selectedId === askedFor) { + State.chatMessages = data.msgs; + } + // The preview line and the scroll belong to the contact this was + // asked for: a late answer after a switch must not write the old + // conversation's last message under the new contact's key -- nor + // delete the new contact's entry when the old query was empty. const realUserMsgs = data.msgs.filter( (m) => !m.isSystem && !isSystemMsg(m.message || m.msg) ); - if (realUserMsgs.length > 0 && State.selectedId) { + if (realUserMsgs.length > 0 && askedFor) { const last = realUserMsgs[realUserMsgs.length - 1]; - State.chatHistoryMap[State.selectedId] = { + State.chatHistoryMap[askedFor] = { lastMsg: last.message || last.msg || '', lastTime: last.sendTime || last.recvTime || Math.floor(Date.now() / 1000), }; - } else if (State.selectedId) { - delete State.chatHistoryMap[State.selectedId]; + } else if (askedFor) { + delete State.chatHistoryMap[askedFor]; } m.redraw(); - setTimeout(() => { - const element = document.querySelector('.chat-messages'); - if (element) element.scrollTop = element.scrollHeight; - }, 100); + if (State.selectedId === askedFor) scrollChatToBottom(); } } ); } function sendDistantChatMessage() { - if (!State.chatInputMsg.trim() || !State.chatPid) return; + const text = (State.chatInputMsg || '').trim(); + const attached = State.attachedImage; + if ((!text && !attached) || !State.chatPid || !State.selectedId) return; + const fullMsg = attached + ? (text ? `${text}\n${attached.imgTag}` : attached.imgTag) + : text; + + // Capture the recipient and sender before the request can outlive this view. + const recipientId = State.selectedId; + const ownId = State.selectedOwnGxsIdForChat; + const session = getDistantChatSession(recipientId); + const chatPid = State.chatPid; + const isCurrentChat = () => State.selectedId === recipientId + && State.selectedOwnGxsIdForChat === ownId + && State.chatPid === chatPid + && State.activeDistantChats[recipientId] === session; const cid = { broadcast_status_peer_id: '00000000000000000000000000000000', type: 2, // TYPE_PRIVATE_DISTANT peer_id: '00000000000000000000000000000000', - distant_chat_id: State.chatPid, + distant_chat_id: chatPid, lobby_id: { xstr64: '0' }, }; - const text = State.chatInputMsg; - State.chatInputMsg = ''; + setChatDraft(''); + State.attachedImage = null; + if (session) session.attachedImage = null; rs.rsJsonApiRequest( '/rsChats/sendChat', { id: cid, - msg: text, + msg: fullMsg, }, (data, success) => { if (success) { const echoMsg = { chat_id: cid, - msg: text, + msg: fullMsg, sendTime: Math.floor(Date.now() / 1000), incoming: false, - lobby_peer_gxs_id: State.selectedOwnGxsIdForChat, + lobby_peer_gxs_id: ownId, + }; + addSessionMessages(session, [echoMsg]); + if (isCurrentChat()) State.chatMessages = session.messages; + State.chatHistoryMap[recipientId] = { + lastMsg: fullMsg, + lastTime: echoMsg.sendTime, }; - State.chatMessages.push(echoMsg); - if (State.selectedId) { - State.chatHistoryMap[State.selectedId] = { - lastMsg: text, - lastTime: Math.floor(Date.now() / 1000), - }; - } m.redraw(); - setTimeout(() => { - const element = document.querySelector('.chat-messages'); - if (element) element.scrollTop = element.scrollHeight; - }, 100); + if (isCurrentChat()) scrollChatToBottom(); } else { console.error('[RS] Failed to send distant chat message:', data); - alert('Failed to send distant chat message. The image/payload exceeds RetroShare max chat packet size.'); - State.chatInputMsg = text; + // No size limit is involved: getMaxMessageSecuritySize() answers 0, + // unlimited, for distant chat, and the core slices anything longer + // than 15000 characters and reassembles it on the other side. Blaming + // the payload was a guess, and a wrong one. + alert('Failed to send the message. The tunnel may have closed -- check the connection state above.'); + // Restore only the saved conversation's draft, preserving newer typing. + if (!session.inputMsg) session.inputMsg = text; + if (isCurrentChat() && !State.chatInputMsg) State.chatInputMsg = session.inputMsg; m.redraw(); } } ); } -function preloadAllChatHistory() { - rs.rsJsonApiRequest('/rsIdentity/getIdentitiesSummaries', {}, (data) => { - const ids = (data && data.ids) ? data.ids : (rs.userList.users || []); - if (!ids || ids.length === 0) return; +// Changing the identity we talk as means another tunnel: its id is +// sha1(sorted(own || peer)), so the one built for the previous identity is a +// different tunnel, and nothing but this closes it -- it used to be left open +// and digging. +function switchChatIdentity(ownGxsId) { + const previousPid = State.chatPid; + State.selectedOwnGxsIdForChat = ownGxsId; - ids.forEach((u) => { - const gxsId = typeof u === 'object' ? u.mGroupId : u; - if (!gxsId) return; + if (!previousPid) { + initializeDistantChat(true); + return; + } + rs.rsJsonApiRequest( + '/rsChats/closeDistantChatConnexion', + { pid: previousPid }, + () => initializeDistantChat(true) + ); +} - // Check Distant Chat History (type: 2 - TYPE_PRIVATE_DISTANT) - const distantPeerId = { - broadcast_status_peer_id: '00000000000000000000000000000000', - type: 2, // TYPE_PRIVATE_DISTANT - peer_id: '00000000000000000000000000000000', - distant_chat_id: gxsId, - lobby_id: { xstr64: '0' }, - }; +// Ending the conversation on our side. `closed` is what the core answered: +// false means it had no connection left for that tunnel id, which the card +// then says rather than claiming the user just closed something. +function leaveDistantChat(closed) { + if (State.selectedId && State.activeDistantChats[State.selectedId]) { + delete State.activeDistantChats[State.selectedId]; + } + State.chatPid = null; + State.chatMessages = []; + State.distantChatStatus = null; + State.chatDisconnected = true; + State.chatCloseFoundNothing = !closed; + State.chatEndedByPoll = false; + State.statusPollFailures = 0; + stopStatusPolling(); + m.redraw(); +} - rs.rsJsonApiRequest( - '/rsHistory/getMessages', - { - chatPeerId: distantPeerId, - loadCount: 20, - }, - (msgData, success) => { - if (success && msgData && msgData.msgs) { - const userMsgs = msgData.msgs.filter( - (m) => !m.isSystem && !isSystemMsg(m.message || m.msg) - ); - if (userMsgs.length > 0) { - const last = userMsgs[userMsgs.length - 1]; - State.chatHistoryMap[gxsId] = { - lastMsg: last.message || last.msg || '', - lastTime: last.sendTime || last.recvTime || Math.floor(Date.now() / 1000), - }; - m.redraw(); - } - } - } - ); +function findDistantChatSession(msgPid) { + let session = null; + let targetGxsId = null; + Object.keys(State.activeDistantChats || {}).forEach((id) => { + const candidate = State.activeDistantChats[id]; + if (candidate && candidate.pid === msgPid) { + session = candidate; + targetGxsId = id; + } + }); - // Also check Private Chat History (type: 1) if PGP ID is known - const details = State.gxsIdToDetailsMap[gxsId]; - const pgpId = details ? details.mPgpId : (typeof u === 'object' ? u.mPgpId : null); - if (pgpId && pgpId !== '0000000000000000') { - const privatePeerId = { - broadcast_status_peer_id: '00000000000000000000000000000000', - type: 1, // PRIVATE - peer_id: pgpId, - distant_chat_id: '00000000000000000000000000000000', - lobby_id: { xstr64: '0' }, - }; + // The tunnel can be answered before `initiateDistantChatConnexion` has + // registered its pid on the session: adopt the visible conversation. + if (!session && State.chatPid === msgPid && State.selectedId) { + targetGxsId = State.selectedId; + session = getDistantChatSession(targetGxsId); + session.pid = msgPid; + } + return { session, targetGxsId }; +} - rs.rsJsonApiRequest( - '/rsHistory/getMessages', - { - chatPeerId: privatePeerId, - loadCount: 20, - }, - (msgData, success) => { - if (success && msgData && msgData.msgs) { - const userMsgs = msgData.msgs.filter( - (m) => !m.isSystem && !isSystemMsg(m.message || m.msg) - ); - if (userMsgs.length > 0) { - const last = userMsgs[userMsgs.length - 1]; - const existing = State.chatHistoryMap[gxsId]; - const lastTime = last.sendTime || last.recvTime || Math.floor(Date.now() / 1000); - if (!existing || lastTime > existing.lastTime) { - State.chatHistoryMap[gxsId] = { - lastMsg: last.message || last.msg || '', - lastTime, - }; - m.redraw(); - } - } - } - } - ); +// The page-wide chat fields (pid, status, messages) belong to one contact at +// a time. Selecting another one must not leave them pointing at the previous +// tunnel: the mount-time poll then asked about a pid the core may have +// dropped, and its "gone" answer ended the new conversation before it began. +function selectChatContact(gxsId) { + stopStatusPolling(); + const session = gxsId ? getDistantChatSession(gxsId) : null; + State.chatPid = session ? session.pid : null; + State.chatMessages = session ? session.messages : []; + State.distantChatStatus = session ? session.status : null; + State.chatDisconnected = session ? Boolean(session.disconnected) : false; + State.chatCloseFoundNothing = false; + State.chatEndedByPoll = false; + State.statusPollFailures = 0; + State.chatInputMsg = session ? (session.inputMsg || '') : ''; + State.attachedImage = session ? (session.attachedImage || null) : null; +} + +function isDistantChatActive(gxsId) { + const session = gxsId && State.activeDistantChats[gxsId]; + return Boolean( + session + && session.pid + && !session.disconnected + && session.status + && session.status.status === 2 + ); +} + +// A tunnel can survive a page reload, while activeDistantChats cannot. Resolve +// its deterministic id against the small set of identities we can actually be +// chatting with, so background messages still reach the People counter. +async function resolveDistantChatPeer(msgPid) { + const ownIds = State.ownGxsIds.length > 0 + ? State.ownGxsIds + : await peopleUtil.ownIds(); + if (State.ownGxsIds.length === 0) State.ownGxsIds = ownIds || []; + + const candidates = new Set([ + State.selectedId, + ...Object.keys(State.chatHistoryMap || {}), + ...Object.keys(State.activeDistantChats || {}), + ].filter(peopleUtil.isUsableIdentityId)); + peopleUtil.contactlist(rs.userList.users || []).forEach((user) => { + if (user && peopleUtil.isUsableIdentityId(user.mGroupId)) candidates.add(user.mGroupId); + }); + + for (const ownId of ownIds || []) { + for (const peerId of candidates) { + if (peopleUtil.distantChatPid(ownId, peerId) === msgPid) return peerId; + } + } + return null; +} + +function recordDistantChatMessage(chatMessage, msgPid, session, targetGxsId) { + if (!session || !targetGxsId) return; + + if (!addSessionMessages(session, [chatMessage])) return; + + if (targetGxsId) { + State.chatHistoryMap[targetGxsId] = { + lastMsg: chatMessage.msg || chatMessage.message || '', + lastTime: chatMessage.sendTime || chatMessage.recvTime || Math.floor(Date.now() / 1000), + }; + const isOpenConversation = m.route.get().split('/')[1] === 'people' + && State.activeTab === 'chat' + && State.selectedId === targetGxsId + && (window.innerWidth > 700 || State.mobilePane === 'detail'); + if (chatMessage.incoming === true && !isOpenConversation) { + State.unreadChatCount[targetGxsId] = (State.unreadChatCount[targetGxsId] || 0) + 1; + } + } + + // The view renders State.chatMessages, so it has to point at the session + // that just received the message when that session is the visible one. + if (session.pid === State.chatPid) State.chatMessages = session.messages; + + m.redraw(); + if (State.selectedId === targetGxsId) scrollChatToBottom(); +} + +// Live incoming distant chat message, coming from the rsEvents stream. +function receiveDistantChatMessage(chatMessage) { + const msgCid = chatMessage && chatMessage.chat_id; + if (!msgCid || msgCid.type !== 2) return; + + const msgPid = rs.idToHex(msgCid.distant_chat_id); + if (!msgPid) return; + + const known = findDistantChatSession(msgPid); + if (known.session) { + recordDistantChatMessage(chatMessage, msgPid, known.session, known.targetGxsId); + return; + } + + resolveDistantChatPeer(msgPid).then((targetGxsId) => { + if (!targetGxsId) return; + const session = getDistantChatSession(targetGxsId); + session.pid = msgPid; + recordDistantChatMessage(chatMessage, msgPid, session, targetGxsId); + }); +} + +function markDistantChatRead(gxsId) { + if (gxsId) State.unreadChatCount[gxsId] = 0; +} + +// Two /rsHistory/getMessages per known identity, and a node knows hundreds of +// them. Fired all at once they fill the browser's six sockets and the JSON +// API's single service thread, so everything the user is actually waiting for +// -- the chat room list, an avatar, a forum -- queues behind the preload. Run +// them a few at a time: the same work gets done, but interactive requests keep +// getting a slot. +const HISTORY_PRELOAD_CONCURRENCY = 4; + +function runQueued(tasks, concurrency, onDone) { + let next = 0; + let finished = 0; + const startNext = () => { + if (finished >= tasks.length) return; + if (next >= tasks.length) return; + tasks[next++](() => { + finished++; + if (finished >= tasks.length) { + if (onDone) onDone(); + return; } + startNext(); + }); + }; + for (let i = 0; i < concurrency && i < tasks.length; i++) startNext(); +} + +// `newerOnly` keeps the previous behaviour of the two callers: the distant +// history is the first answer and simply wins, the private one only replaces +// it when it carries a more recent message. +function rememberLastHistoryMessage(gxsId, msgData, success, newerOnly) { + if (!success || !msgData || !msgData.msgs) return; + const userMsgs = msgData.msgs.filter( + (m) => !m.isSystem && !isSystemMsg(m.message || m.msg) + ); + if (userMsgs.length === 0) return; + + const last = userMsgs[userMsgs.length - 1]; + const lastTime = last.sendTime || last.recvTime || Math.floor(Date.now() / 1000); + const existing = State.chatHistoryMap[gxsId]; + if (newerOnly && existing && lastTime <= existing.lastTime) return; + + State.chatHistoryMap[gxsId] = { + lastMsg: last.message || last.msg || '', + lastTime, + }; + m.redraw(); +} + +function historyPreloadTask(gxsId, chatPeerId, newerOnly) { + return (done) => rs.rsJsonApiRequest( + '/rsHistory/getMessages', + { + chatPeerId, + loadCount: 20, + }, + (msgData, success) => { + // done() must run whatever happens: rswebui swallows exceptions thrown + // by callbacks, and a lost slot would stall the queue for good. + try { + rememberLastHistoryMessage(gxsId, msgData, success, newerOnly); + } finally { + done(); + } + } + ); +} + +function distantChatIdFor(pid) { + return { + broadcast_status_peer_id: '00000000000000000000000000000000', + type: 2, // TYPE_PRIVATE_DISTANT + peer_id: '00000000000000000000000000000000', + distant_chat_id: pid, + lobby_id: { xstr64: '0' }, + }; +} + +function privateChatIdFor(sslId) { + return { + broadcast_status_peer_id: '00000000000000000000000000000000', + type: 1, // TYPE_PRIVATE + peer_id: sslId, + distant_chat_id: '00000000000000000000000000000000', + lobby_id: { xstr64: '0' }, + }; +} + +// Private chat history is keyed by the *location* (SSL) id of the friend, not +// by their PGP id. A PGP id is half the length of an RsPeerId, so the core +// cannot parse it: it builds a null id -- which happens to be the key of the +// public/broadcast history -- and prints a stack trace for every single +// request. One identity per known PGP key means hundreds of those per visit. +function locationIdsOf(gxsId) { + const details = State.gxsIdToDetailsMap[gxsId]; + const pgpId = details ? details.mPgpId : null; + if (!pgpId || pgpId === '0000000000000000') return []; + const friend = Data.gpgDetails[pgpId.toLowerCase()]; + if (!friend || !friend.locations) return []; + return friend.locations.map((loc) => loc && loc.id).filter(Boolean); +} + +// Only peers we can actually have a conversation with are probed. Sweeping +// every identity the node ever saw -- tens of thousands on an old profile -- +// is what made the Chats badge climb for minutes at every visit, one tick per +// answer, and start over at every click on the tab. +function chatPeerCandidates() { + const ids = new Set(); + + peopleUtil.contactlist(rs.userList.users || []).forEach((u) => { + if (u && u.mGroupId) ids.add(u.mGroupId); + }); + Object.keys(State.chatHistoryMap || {}).forEach((id) => ids.add(id)); + Object.keys(State.activeDistantChats || {}).forEach((id) => ids.add(id)); + + // Identities that belong to one of our own friends: their direct chat + // history is part of the same conversation as far as the user is concerned. + (rs.userList.users || []).forEach((u) => { + const gxsId = u && u.mGroupId; + if (gxsId && !ids.has(gxsId) && locationIdsOf(gxsId).length > 0) ids.add(gxsId); + }); + + return Array.from(ids).filter(peopleUtil.isUsableIdentityId); +} + +// Repeated calls are the norm here: the layout, the sidebar and the Chats tab +// all ask for a preload, and the tab asks again at every click. +const HISTORY_PRELOAD_MIN_INTERVAL_MS = 30 * 1000; +let historyPreloadRunning = false; +let historyPreloadedAt = 0; + +function preloadAllChatHistory() { + if (historyPreloadRunning) return; + const now = Date.now(); + if (historyPreloadedAt && now - historyPreloadedAt < HISTORY_PRELOAD_MIN_INTERVAL_MS) return; + + historyPreloadRunning = true; + historyPreloadedAt = now; + + peopleUtil.ownIds((ownIds) => { + // The candidates come from the friend list and the contact flags, which + // are loaded in parallel with this: nothing to probe yet only means the + // answers have not landed, so the interval must not lock the next try out. + const tasks = []; + + chatPeerCandidates().forEach((gxsId) => { + (ownIds || []).forEach((ownId) => { + const pid = peopleUtil.distantChatPid(ownId, gxsId); + if (pid) tasks.push(historyPreloadTask(gxsId, distantChatIdFor(pid), false)); + }); + + // Direct messages are keyed only by an SSL location, not by the GXS + // identity used for a distant chat. Assigning that same SSL history to + // every GXS identity belonging to the PGP friend creates duplicate chat + // rows. Direct chat belongs to Network; this list is keyed by the exact + // GXS identity whose distant tunnel history matched above. + }); + + if (tasks.length === 0) { + historyPreloadRunning = false; + historyPreloadedAt = 0; + return; + } + runQueued(tasks, HISTORY_PRELOAD_CONCURRENCY, () => { + historyPreloadRunning = false; }); }); } +// Everything the core may hold with this contact: one distant chat file per +// own identity we could have talked as (the tunnel id is derived from the +// pair), plus the direct chat file of every location of the friend behind +// the identity. The conversation pane and the history browser read the same. +function historySourcesFor(gxsId) { + const queries = []; + const pids = new Set(); + (State.ownGxsIds || []).forEach((ownId) => { + const pid = peopleUtil.distantChatPid(ownId, gxsId); + if (pid) pids.add(pid); + }); + pids.forEach((pid) => queries.push(distantChatIdFor(pid))); + locationIdsOf(gxsId).forEach((sslId) => queries.push(privateChatIdFor(sslId))); + return queries; +} + +// Reading further back. p3HistoryMgr::getMessages takes a count and always +// answers with the newest ones -- no cursor -- so older text means asking +// every source for a bigger slice and letting addSessionMessages() drop what +// is already here. Same mechanism as the chat rooms (chat_state.js). +const HISTORY_PAGE = 50; + +function loadOlderChatHistory(done) { + const gxsId = State.selectedId; + const session = gxsId ? getDistantChatSession(gxsId) : null; + if (!session || session.historyLoading || session.historyExhausted) return false; + + const queries = historySourcesFor(gxsId); + if (queries.length === 0) return false; + + session.historyLoading = true; + const wanted = (session.historyLoaded || HISTORY_PAGE) + HISTORY_PAGE * 2; + let left = queries.length; + let anyFull = false; + + queries.forEach((chatPeerId) => { + rs.rsJsonApiRequest('/rsHistory/getMessages', { chatPeerId, loadCount: wanted }, (data, success) => { + if (success && data && data.msgs) { + if (data.msgs.length >= wanted) anyFull = true; + addSessionMessages(session, data.msgs); + } + left -= 1; + if (left > 0) return; + session.historyLoading = false; + session.historyLoaded = wanted; + // Every source answered with fewer than asked: nothing older is left. + if (!anyFull) session.historyExhausted = true; + if (session.pid === State.chatPid) State.chatMessages = session.messages; + m.redraw(); + // done() restores a scroll position measured in the pane of the + // conversation that asked; fired after a switch it would perturb the + // new one (same rule as the rooms' loadOlderHistory). + if (done && State.selectedId === gxsId) done(); + }); + }); + return true; +} + function loadAllHistoryForSelectedPeer(callback) { if (!State.selectedId) return; @@ -529,41 +1132,13 @@ function loadAllHistoryForSelectedPeer(callback) { State.fullHistoryMessages = []; m.redraw(); - const queries = []; + const queries = historySourcesFor(State.selectedId); - // Query 1: Distant Chat History by active chatPid (type: 2 - TYPE_PRIVATE_DISTANT) - if (State.chatPid) { - queries.push({ - broadcast_status_peer_id: '00000000000000000000000000000000', - type: 2, // TYPE_PRIVATE_DISTANT - peer_id: '00000000000000000000000000000000', - distant_chat_id: State.chatPid, - lobby_id: { xstr64: '0' }, - }); - } - - // Query 2: Distant Chat History by selectedId if different (type: 2 - TYPE_PRIVATE_DISTANT) - if (State.selectedId && State.selectedId !== State.chatPid) { - queries.push({ - broadcast_status_peer_id: '00000000000000000000000000000000', - type: 2, // TYPE_PRIVATE_DISTANT - peer_id: '00000000000000000000000000000000', - distant_chat_id: State.selectedId, - lobby_id: { xstr64: '0' }, - }); - } - - // Query 3: Private Chat History by PGP ID if available (type: 1 - TYPE_PRIVATE) - const details = State.gxsIdToDetailsMap[State.selectedId]; - const pgpId = details ? details.mPgpId : null; - if (pgpId && pgpId !== '0000000000000000') { - queries.push({ - broadcast_status_peer_id: '00000000000000000000000000000000', - type: 1, // TYPE_PRIVATE - peer_id: pgpId, - distant_chat_id: '00000000000000000000000000000000', - lobby_id: { xstr64: '0' }, - }); + if (queries.length === 0) { + State.isHistoryLoading = false; + m.redraw(); + if (callback) callback(); + return; } let accumulatedMsgs = []; @@ -603,9 +1178,16 @@ function loadAllHistoryForSelectedPeer(callback) { module.exports = { State, getDistantChatSession, + isDistantChatActive, + addSessionMessages, + drainBufferedChatMessages, + receiveDistantChatMessage, + markDistantChatRead, + scrollChatToBottom, isSystemMsg, preloadAllChatHistory, loadAllHistoryForSelectedPeer, + loadOlderChatHistory, fetchIdDetails, loadGxsIdentities, loadOwnGxsIds, @@ -624,5 +1206,10 @@ module.exports = { initializeDistantChat, loadChatMessages, sendDistantChatMessage, + leaveDistantChat, + selectChatContact, + setChatDraft, + switchChatIdentity, + refreshSelectedIdDetails, }; diff --git a/webui-src/app/people/people_util.js b/webui-src/app/people/people_util.js index 46036cc..babb3c3 100644 --- a/webui-src/app/people/people_util.js +++ b/webui-src/app/people/people_util.js @@ -6,6 +6,68 @@ function checksudo(id) { return id === '0000000000000000'; } +// Distant chat history is not stored under the peer's GXS id but under the +// *tunnel* id, and the core builds that one as +// RsGxsTunnelId(sha1(sorted(own_id || peer_id))) truncated to 16 bytes +// (p3GxsTunnelService::makeGxsTunnelId). Asking `/rsHistory/getMessages` for a +// GXS id therefore always answers an empty list. There is no API to derive it +// remotely and no crypto.subtle outside a secure context -- the web UI is +// served over plain HTTP on the LAN -- so the digest is computed here. +function sha1Hex(bytes) { + const ml = bytes.length; + const withPad = bytes.slice(); + withPad.push(0x80); + while (withPad.length % 64 !== 56) withPad.push(0); + const bits = ml * 8; + // Ids are 32 bytes at most, so the high word of the length is always zero. + withPad.push(0, 0, 0, 0); + withPad.push((bits >>> 24) & 0xff, (bits >>> 16) & 0xff, (bits >>> 8) & 0xff, bits & 0xff); + + let h0 = 0x67452301, h1 = 0xEFCDAB89, h2 = 0x98BADCFE, h3 = 0x10325476, h4 = 0xC3D2E1F0; + const w = new Array(80); + const rotl = (v, n) => ((v << n) | (v >>> (32 - n))) >>> 0; + + for (let i = 0; i < withPad.length; i += 64) { + for (let j = 0; j < 16; j++) { + w[j] = ((withPad[i + 4 * j] << 24) | (withPad[i + 4 * j + 1] << 16) + | (withPad[i + 4 * j + 2] << 8) | withPad[i + 4 * j + 3]) >>> 0; + } + for (let j = 16; j < 80; j++) w[j] = rotl(w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16], 1); + + let a = h0, b = h1, c = h2, d = h3, e = h4; + for (let j = 0; j < 80; j++) { + let f, k; + if (j < 20) { f = (b & c) | (~b & d); k = 0x5A827999; } + else if (j < 40) { f = b ^ c ^ d; k = 0x6ED9EBA1; } + else if (j < 60) { f = (b & c) | (b & d) | (c & d); k = 0x8F1BBCDC; } + else { f = b ^ c ^ d; k = 0xCA62C1D6; } + const t = (rotl(a, 5) + f + e + k + w[j]) >>> 0; + e = d; d = c; c = rotl(b, 30); b = a; a = t; + } + h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0; + h3 = (h3 + d) >>> 0; h4 = (h4 + e) >>> 0; + } + + return [h0, h1, h2, h3, h4].map((v) => ('0000000' + v.toString(16)).slice(-8)).join(''); +} + +function hexToBytes(hex) { + const out = []; + for (let i = 0; i + 1 < hex.length; i += 2) out.push(parseInt(hex.substr(i, 2), 16)); + return out; +} + +// Both ids are sorted first, so the two ends of a conversation compute the +// same tunnel. The core sorts the raw bytes; on equal length lowercase hex, +// a plain string comparison gives the same order. +function distantChatPid(ownGxsId, peerGxsId) { + const own = String(ownGxsId || '').toLowerCase(); + const peer = String(peerGxsId || '').toLowerCase(); + if (own.length !== 32 || peer.length !== 32) return null; + const joined = own < peer ? own + peer : peer + own; + return sha1Hex(hexToBytes(joined)).slice(0, 32); +} + function getAvatarColor(seed) { let hash = 0; if (seed) { @@ -17,6 +79,25 @@ function getAvatarColor(seed) { return `hsl(${hue}, 60%, 60%)`; } +// jdenticon draws a fresh SVG on every call, and this runs in the view of every +// avatar on screen -- so once per avatar per redraw, and a redraw happens on +// every answer of every polling request. The drawing depends on nothing but the +// id and the size. +const jdenticonCache = new Map(); + +function jdenticonSvg(identityId, pxSize) { + const key = identityId + '|' + pxSize; + let svg = jdenticonCache.get(key); + if (svg === undefined) { + svg = jdenticon.toSvg(identityId, pxSize); + // A node knows tens of thousands of identities: keep this to what a few + // lists can show rather than growing it for ever. + if (jdenticonCache.size > 512) jdenticonCache.clear(); + jdenticonCache.set(key, svg); + } + return svg; +} + const UserAvatar = () => ({ view: (v) => { const imageURI = v.attrs.avatar; @@ -32,18 +113,29 @@ const UserAvatar = () => ({ style: { width: sizeStr, height: sizeStr, - borderRadius: isSquare ? '0' : '', + minWidth: sizeStr, + minHeight: sizeStr, + flexShrink: '0', + aspectRatio: '1', + objectFit: 'cover', + borderRadius: isSquare ? '0' : '50%', } }); } if (identityId && identityId !== '0000000000000000') { - const svgString = jdenticon.toSvg(identityId, pxSize); + const svgString = jdenticonSvg(identityId, pxSize); return m('div.jdenticon-avatar', { style: { - display: 'inline-block', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', width: sizeStr, height: sizeStr, + minWidth: sizeStr, + minHeight: sizeStr, + flexShrink: '0', + aspectRatio: '1', borderRadius: isSquare ? '0' : '50%', overflow: 'hidden', verticalAlign: 'middle', @@ -67,8 +159,15 @@ const UserAvatar = () => ({ 'div.defaultAvatar', { style: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', width: sizeStr, height: sizeStr, + minWidth: sizeStr, + minHeight: sizeStr, + flexShrink: '0', + aspectRatio: '1', borderRadius: isSquare ? '0' : '50%', backgroundColor, } @@ -85,148 +184,196 @@ const UserAvatar = () => ({ }, }); +const identityDetailsCache = new Map(); + +function loadIdentityDetails(id) { + if (!id || id === '0000000000000000') return Promise.resolve(null); + const cached = identityDetailsCache.get(id); + if (cached && Object.prototype.hasOwnProperty.call(cached, 'details')) { + return Promise.resolve(cached.details); + } + if (cached && cached.promise) return cached.promise; + + const promise = rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id }) + .then((response) => { + const details = response && response.body ? response.body.details : null; + identityDetailsCache.set(id, { details }); + m.redraw(); + return details; + }) + .catch(() => { + identityDetailsCache.set(id, { details: null }); + return null; + }); + + identityDetailsCache.set(id, { promise }); + return promise; +} + +const IdentityAvatar = () => ({ + oninit: (vnode) => loadIdentityDetails(vnode.attrs.identityId), + onbeforeupdate: (vnode, old) => { + if (vnode.attrs.identityId !== old.attrs.identityId) { + loadIdentityDetails(vnode.attrs.identityId); + } + }, + view: (vnode) => { + const id = vnode.attrs.identityId; + const cached = identityDetailsCache.get(id); + const details = cached && cached.details; + const name = vnode.attrs.name || (details && details.mNickname) || ''; + + return m(UserAvatar, { + avatar: details && details.mAvatar, + identityId: id, + firstLetter: name.slice(0, 1).toUpperCase(), + seed: id || name, + size: vnode.attrs.size || 38, + }); + }, +}); + function contactlist(list) { if (list === undefined) return []; return list.filter((id) => { - id.isSearched = true; const entry = rs.userList.userMap[id.mGroupId]; return entry && entry.isContact; }); } function sortUsers(list) { - if (list !== undefined) { - const result = []; - list.map((id) => { - id.isSearched = true; - result.push(id); - }); - - result.sort((a, b) => a.mGroupName.localeCompare(b.mGroupName)); - return result; - } - return list; + if (list === undefined) return list; + // Copied, not sorted in place: this is rs.userList.users, shared with every + // other page. The isSearched marking that used to happen here belonged to a + // search box that no longer exists. + return [...list].sort((a, b) => a.mGroupName.localeCompare(b.mGroupName)); } function sortIds(list) { if (list !== undefined) { const result = [...list]; - result.sort((a, b) => rs.userList.username(a).localeCompare(rs.userList.username(b))); + result.sort((a, b) => { + const nameA = rs.userList.username(a) || String(a); + const nameB = rs.userList.username(b) || String(b); + return nameA.localeCompare(nameB); + }); return result; } return list; } -async function ownIds(consumer = () => { }, onlySigned = false) { - await rs.rsJsonApiRequest('/rsIdentity/getOwnSignedIds', {}, (owns) => { - if (onlySigned) { - consumer(sortIds(owns.ids)); - } else { - rs.rsJsonApiRequest('/rsIdentity/getOwnPseudonimousIds', {}, (pseudo) => { - if (pseudo.ids) consumer(sortIds(pseudo.ids.concat(owns.ids))); - }); - } +const OWN_IDS_CACHE_MS = 30000; +const ownIdsCache = { + all: { ids: null, loadedAt: 0, promise: null }, + signed: { ids: null, loadedAt: 0, promise: null }, +}; +const OWN_IDS_CHANGED_EVENT = 'rs-own-identities-changed'; +const OWN_ID_REFRESH_DELAYS = [0, 200, 500, 1000, 2000, 4000]; + +function isUsableIdentityId(id) { + const value = String(id || ''); + return value !== '' && !/^0+$/.test(value); +} + +function normalizeOwnIds(ids) { + return sortIds(Array.from(new Set((ids || []).filter(isUsableIdentityId)))); +} + +function invalidateOwnIds() { + Object.values(ownIdsCache).forEach((cache) => { + cache.ids = null; + cache.loadedAt = 0; }); } -const SearchBar = () => { - let searchString = ''; - return { - view: () => - m('input.searchbar', { - type: 'text', - placeholder: 'search', - value: searchString, - oninput: (e) => { - searchString = e.target.value.toLowerCase(); +async function refreshOwnIds(previousIds = null) { + const previous = new Set(normalizeOwnIds(previousIds || [])); + const waitForNewIdentity = previousIds !== null; + let ids = []; - rs.userList.users.map((id) => { - if (id.mGroupName.toLowerCase().indexOf(searchString) > -1) { - id.isSearched = true; - } else { - id.isSearched = false; - } - }); - }, - }), + for (const delay of OWN_ID_REFRESH_DELAYS) { + if (delay) await new Promise((resolve) => setTimeout(resolve, delay)); + ids = normalizeOwnIds(await loadOwnIds(false)); + if (!waitForNewIdentity || ids.some((id) => !previous.has(id))) break; + } + + ownIdsCache.all.ids = ids; + ownIdsCache.all.loadedAt = Date.now(); + ownIdsCache.signed.ids = null; + ownIdsCache.signed.loadedAt = 0; + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(OWN_IDS_CHANGED_EVENT, { detail: { ids } })); + } + return ids; +} + +function watchOwnIds(consumer) { + const listener = (event) => consumer([...(event.detail.ids || [])]); + if (typeof window !== 'undefined') window.addEventListener(OWN_IDS_CHANGED_EVENT, listener); + ownIds(consumer); + return () => { + if (typeof window !== 'undefined') window.removeEventListener(OWN_IDS_CHANGED_EVENT, listener); }; -}; +} -const regularcontactInfo = () => { - let details = {}; +async function loadOwnIds(onlySigned) { + if (onlySigned) { + const response = await rs.rsJsonApiRequest('/rsIdentity/getOwnSignedIds', {}); + return (response && response.body && response.body.ids) || []; + } - return { - oninit: (v) => - rs.rsJsonApiRequest( - '/rsIdentity/getIdDetails', - { - id: v.attrs.id.mGroupId, - }, - (data) => { - details = data.details; - } - ), - view: (v) => - m( - '.identity', - { - key: details.mId, - style: 'display:' + (v.attrs.id.isSearched ? 'block' : 'none'), - }, - [ - m('h4', details.mNickname), - details.mNickname && - m(UserAvatar, { - avatar: details.mAvatar, - firstLetter: details.mNickname.slice(0, 1).toUpperCase(), - identityId: details.mId || v.attrs.id.mGroupId, - }), - m('.details', [ - m('p', 'ID:'), - m('p', details.mId), - m('p', 'Type:'), - m('p', details.mFlags === 14 ? 'Signed ID' : 'Anonymous ID'), - m('p', 'Owner node ID:'), - m('p', details.mPgpId), - m('p', 'Created on:'), - m( - 'p', - typeof details.mPublishTS === 'object' - ? new Date(details.mPublishTS.xint64 * 1000).toLocaleString() - : 'undefiend' - ), - m('p', 'Last used:'), - m( - 'p', - typeof details.mLastUsageTS === 'object' - ? new Date(details.mLastUsageTS.xint64 * 1000).toLocaleDateString() - : 'undefiend' - ), - ]), - m( - 'button', - { - onclick: () => - m.route.set('/chat/:userid/createdistantchat', { - userid: v.attrs.id.mGroupId, - }), - }, - 'Chat' - ), - m('button.red', {}, 'Mail'), - ] - ), - }; -}; + // The complete list is these two calls put together. /rsIdentity/getOwnIds + // is not an alternative to them: it is the deprecated one, it carries no + // @jsonapi annotation, and the core answers 404. + const [signedResponse, pseudonymousResponse] = await Promise.all([ + rs.rsJsonApiRequest('/rsIdentity/getOwnSignedIds', {}), + rs.rsJsonApiRequest('/rsIdentity/getOwnPseudonimousIds', {}), + ]); + const signedIds = (signedResponse && signedResponse.body && signedResponse.body.ids) || []; + const pseudonymousIds = (pseudonymousResponse && pseudonymousResponse.body && pseudonymousResponse.body.ids) || []; + return pseudonymousIds.concat(signedIds); +} +async function ownIds(consumer = () => { }, onlySigned = false) { + const cache = onlySigned ? ownIdsCache.signed : ownIdsCache.all; + try { + if (cache.ids && Date.now() - cache.loadedAt < OWN_IDS_CACHE_MS) { + const cachedIds = [...cache.ids]; + consumer(cachedIds); + return cachedIds; + } + + if (!cache.promise) { + cache.promise = loadOwnIds(onlySigned) + .then((ids) => { + cache.ids = normalizeOwnIds(ids); + cache.loadedAt = Date.now(); + return cache.ids; + }) + .finally(() => { cache.promise = null; }); + } + + const ids = [...await cache.promise]; + consumer(ids); + return ids; + } catch (error) { + console.warn('Unable to load own identities', error); + consumer([]); + return []; + } +} module.exports = { sortUsers, sortIds, ownIds, + invalidateOwnIds, + refreshOwnIds, + watchOwnIds, checksudo, UserAvatar, + IdentityAvatar, contactlist, - SearchBar, - regularcontactInfo, + isUsableIdentityId, + distantChatPid, }; diff --git a/webui-src/app/rswebui.js b/webui-src/app/rswebui.js index 8e433c6..1b4df8c 100644 --- a/webui-src/app/rswebui.js +++ b/webui-src/app/rswebui.js @@ -68,6 +68,7 @@ const RsEventsType = { const API_URL = 'http://127.0.0.1:9092'; const loginKey = { + generation: 0, username: sessionStorage.getItem('rs_username') || '', passwd: sessionStorage.getItem('rs_passwd') || '', isVerified: sessionStorage.getItem('rs_isVerified') === 'true', @@ -76,6 +77,8 @@ const loginKey = { // Make this as object property? function setKeys(username, password, url = API_URL, verified = true) { + if (loginKey.username !== username || loginKey.passwd !== password + || loginKey.url !== url || loginKey.isVerified !== verified) loginKey.generation += 1; loginKey.username = username; loginKey.passwd = password; loginKey.url = url; @@ -96,6 +99,45 @@ function logout() { m.route.set('/'); } +// What the API is doing, seen from this browser. Shown on the Debug page: a +// request that takes ten seconds shows here, and whether it was slow on its +// own or queued behind others (pending) is what tells the two apart. +const apiStats = { + pending: 0, + total: 0, + // Last /rsChats/sendChat: the one round trip the user feels directly. + lastSend: null, + // The five slowest requests since load, newest first on a tie. + slowest: [], + // The last twenty requests, newest first. + recent: [], + // Event stream: bytes received since (re)connection, last event time, + // number of reconnections. + eventsBytes: 0, + lastEventAt: 0, + eventsRestarts: 0, + startedAt: Date.now(), +}; + +function recordRequestTime(path, ms) { + apiStats.pending = Math.max(0, apiStats.pending - 1); + const entry = { path, ms: Math.round(ms), at: Date.now() }; + if (path === '/rsChats/sendChat') apiStats.lastSend = entry; + apiStats.slowest.push(entry); + apiStats.slowest.sort((a, b) => b.ms - a.ms); + if (apiStats.slowest.length > 5) apiStats.slowest.length = 5; + apiStats.recent.unshift(entry); + if (apiStats.recent.length > 20) apiStats.recent.length = 20; +} + +function resetApiStats() { + apiStats.total = 0; + apiStats.lastSend = null; + apiStats.slowest = []; + apiStats.recent = []; + apiStats.eventsRestarts = 0; +} + const connectionState = { status: true, }; @@ -116,6 +158,12 @@ function rsJsonApiRequest( headers['Authorization'] = 'Basic ' + btoa(loginKey.username + ':' + loginKey.passwd); } } + apiStats.pending += 1; + apiStats.total += 1; + const startedAt = performance.now(); + // Keep status local to this request, including when deserialization fails. + // A request that receives no HTTP response must retain status 0. + let httpStatus = 0; // NOTE: After upgrading to mithrilv2, options.extract is no longer required // since the status will become part of return value and then // handleDeserialize can also be simply passed as options.deserialize @@ -125,6 +173,7 @@ function rsJsonApiRequest( url: loginKey.url + path, async, extract: (xhr) => { + httpStatus = xhr.status; // Empty string is not valid json and fails on parse const response = xhr.responseText || '""'; return { @@ -140,6 +189,7 @@ function rsJsonApiRequest( xhr: config, }) .then((result) => { + recordRequestTime(path, performance.now() - startedAt); if (result.status === 200) { connectionState.status = true; try { @@ -148,7 +198,12 @@ function rsJsonApiRequest( console.error('[RS] Error in success callback for path:', path, e); } } else { - connectionState.status = false; + // An answer, whatever its code, proves the core is there. A 404 on an + // endpoint this build does not expose, or a 401 on a stale password, + // is not a lost connection: only status 0, i.e. no HTTP response at + // all, is. Flipping the flag on every error made the status LED blink + // red on each optional endpoint that is probed. + connectionState.status = result.status !== 0; if (result.status === 401 || result.status === 403) { setKeys(loginKey.username, loginKey.passwd, loginKey.url, false); m.route.set('/'); @@ -166,13 +221,24 @@ function rsJsonApiRequest( return result; }) .catch(function (e) { - connectionState.status = false; + recordRequestTime(path, performance.now() - startedAt); + // Reaching here after a valid 200 means the body could not be parsed, + // i.e. the response was cut short. The core answered and is still there; + // it is the answer that did not survive the trip. + connectionState.status = httpStatus === 200; try { callback(e, false); } catch (cbErr) { // console.error('[RS] Error in catch callback for path:', path, cbErr); } console.error('[RS] Error: While sending request for path:', path, '\ninfo:', e); + // Resolve to the same shape as a real answer, with an empty body. Most + // call sites go straight for res.body.retval, and resolving undefined + // turned every failed request into a TypeError thrown inside an onclick, + // where nothing catches it: the button silently does nothing. Every + // defensive check in the code base tests res.body.retval or res.body, so + // an empty body still reads as a failure to all of them. + return { status: httpStatus, statusText: 'request failed', body: {} }; }); } @@ -240,14 +306,31 @@ const eventQueue = { } }, handler: (event, owner) => { - if (event && event.mChatMessage && event.mChatMessage.chat_id) { - owner.chatMessages(event.mChatMessage.chat_id, owner, (r) => { - r.push(event.mChatMessage); - owner.notify(event.mChatMessage); + // Two event shapes carry a chat message on RsEventType::CHAT_SERVICE. + // A message from a peer is posted twice by the core: as an + // RsChatServiceEvent {mEventCode: CHAT_MESSAGE_RECEIVED, mMsg} and as + // an RsChatMessageEvent {mChatMessage}. A message we send ourselves + // -- from the desktop GUI, or from any other client of the same core + // -- is posted once, as the RsChatServiceEvent only + // (DistributedChatService::sendLobbyChat, p3ChatService::sendChat). + // Reading mChatMessage alone therefore showed every peer's line and + // none of our own typed elsewhere. Take our own messages from the + // RsChatServiceEvent as well, and only those: a peer's message must + // keep coming through once, because the room and direct chat unread + // counters are bumped before the receivers dedup by message key. + const chatMessage = event && ( + (event.mChatMessage && event.mChatMessage.chat_id && event.mChatMessage) + || (Number(event.mEventCode) === 1 && event.mMsg && event.mMsg.chat_id + && event.mMsg.incoming === false && event.mMsg) + ); + if (chatMessage) { + owner.chatMessages(chatMessage.chat_id, owner, (r) => { + r.push(chatMessage); + owner.notify(chatMessage); }); - } else if (event && event.mCid) { + } else if (event && (event.mCid || event.mEventCode !== undefined)) { // Administrative chat event (e.g. lobby info change, peer join/leave) - // Silent for now to avoid console spam, as actual messages use mChatMessage + owner.notify(event); } }, notify: () => { }, @@ -387,6 +470,8 @@ function startEventQueue( xhr.onprogress = (ev) => { const currIndex = xhr.responseText.length; + apiStats.eventsBytes = currIndex; + apiStats.lastEventAt = Date.now(); if (currIndex > lastIndex) { const parts = xhr.responseText.substring(lastIndex, currIndex); lastIndex = currIndex; @@ -430,6 +515,7 @@ function startEventQueue( xhr.onload = () => { }; xhr.onerror = (err) => { + apiStats.eventsRestarts += 1; console.error('[RS] Event Queue XHR error occurred:', err); // Retry after 5 seconds to avoid silent event loss setTimeout(() => { @@ -482,10 +568,30 @@ function hexId(id) { return String(id); } +// A RetroShare ID can be pasted bare, or inside a retroshare://... link where +// it sits url-encoded behind rsInvite=. Both the Add friend wizard and the +// location details dialog had their own copy of this; they now share one, the +// variant that trims after decoding, since a pasted link often carries a +// trailing newline. +function cleanRetroshareId(value) { + const input = String(value || '').trim(); + const marker = 'rsInvite='; + const markerPosition = input.indexOf(marker); + const id = markerPosition >= 0 ? input.slice(markerPosition + marker.length) : input; + + try { + return decodeURIComponent(id).trim(); + } catch (_) { + return id.trim(); + } +} + module.exports = { rsJsonApiRequest, idToHex: hexId, connectionState, + apiStats, + resetApiStats, setKeys, setBackgroundTask, logon, @@ -495,4 +601,5 @@ module.exports = { loginKey, formatBytes, logout, + cleanRetroshareId, }; diff --git a/webui-src/app/scss/_responsive.scss b/webui-src/app/scss/_responsive.scss new file mode 100644 index 0000000..97d71ce --- /dev/null +++ b/webui-src/app/scss/_responsive.scss @@ -0,0 +1,308 @@ +// ----------------------------------------------------------------------------- +// Phone adaptations, inside the pages. +// +// Loaded LAST from main.scss so these rules win over the page stylesheets +// without needing !important. Everything here is scoped inside the `mobile` +// (phone, portrait or landscape) or `touch` (no hover) mixins from +// abstracts/_mixins.scss, so the desktop rendering is untouched. +// +// Neither the navigation, nor the two-pane pages, nor the mail list are this +// file's business: main.js renders a bottom tab bar and a status sheet of its +// own on a phone, components/_navbar.scss hides the desktop rail below 700px, +// the network, people and chat pages switch to master-detail on their own +// through a .mobile-detail-open class driven from their own state, and +// pages/_mail.scss turns table.mails into a card list. This file starts where +// all of that stops -- inside the pages, still laid out for a wide screen: +// * multi column grids and wide tables collapse to a single column, +// * anything only reachable on :hover gets a touch fallback. +// +// The page stylesheets carry a small screen layout of their own, with their own +// breakpoints (768px for network and people, 899px for the chat, 700px for +// files and config). This file neither repeats nor edits them: it takes over +// below the phone breakpoint, and the few places where a tablet compromise does +// not survive on a phone are overridden with !important and carry a comment +// saying which rule they beat; there should be no other !important here. +// ----------------------------------------------------------------------------- +@use 'abstracts' as *; + +/* ========================================================================= + 1. Multi column grids collapse + ========================================================================= */ + +@include mobile { + /* Network friend details, identity details, location cards */ + .network-detail-view { + gap: 1rem; + + .detail-header { + flex-direction: column; + align-items: flex-start; + /* gap comes from pages/_network.scss, which sets it !important */ + padding-bottom: 1rem; + + .detail-title h2 { + font-size: 1.35rem; + } + + .detail-actions { + flex-wrap: wrap; + width: 100%; + } + } + + .detail-section { + padding: 0.875rem; + + .info-grid { + grid-template-columns: 1fr; + row-gap: 0.25rem; + + .info-label { + margin-top: 0.5rem; + } + } + } + + .locations-grid { + grid-template-columns: 1fr; + } + + .location-card .loc-body { + grid-template-columns: 1fr; + } + } + + /* Config: proxy rows are 160+220+220px wide, i.e. 620px minimum */ + .proxy-row { + grid-template-columns: 1fr; + gap: 0.35rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #e2e8f0; + } + + /* Generic two column form grid used across the config pages */ + .grid-2col { + grid-template-columns: 1fr; + + & input[type='checkbox'] { + margin-top: 0; + } + } + + /* People: identity detail card */ + .identity { + margin: 0.5rem; + padding: 0.75rem; + + & .details { + grid-template-columns: 1fr; + grid-row-gap: 0.125rem; + } + } + + .widget { + padding: 0.5rem; + } + + .widget-half { + max-width: 100%; + } +} + +/* ========================================================================= + 2. Tables + ========================================================================= */ + +@include mobile { + /* The global `table-layout: fixed` plus percentage column widths squeeze + every column below readability. Let the content drive the width instead. */ + table { + table-layout: auto; + font-size: 1rem; + } + + table td, + table th { + word-break: break-word; + } + + /* Files tables: drop the hardcoded 50% name column */ + table.myfiles th:nth-child(2), + table.friendsfiles th:nth-child(2) { + width: auto; + } + + /* --- Channel comments: 8 columns, unusable as a table ----------------- + The header row carries .comments-head (set in channels_util.js) because + mithril builds the DOM through the DOM API and therefore does not get the + implicit the HTML parser would insert: neither tr:first-child nor a + `> tbody >` path is reliable here. */ + table.comments { + /* The table box has to go: a switched to block/flex inside a table box + is wrapped back into an anonymous table cell and re-sized by the column + algorithm, which shreds the row. */ + display: block; + + & tbody { + display: block; + } + + & tr.comments-head { + display: none; + } + + & tr:not(.comments-head) { + display: block; + padding: 0.5rem 0; + + & > td { + display: block; + text-align: start; + padding: 0.125rem 0; + } + + /* author / date / score / votes fold into one small meta line */ + & > td:nth-child(n + 3) { + display: inline-block; + margin-right: 0.75rem; + font-size: 0.8rem; + color: #64748b; + } + + & > td:nth-child(5)::before { + content: 'Score: '; + } + + & > td:nth-child(6)::before { + content: '\002B06 '; + } + + & > td:nth-child(7)::before { + content: '\002B07 '; + } + } + } +} + +/* ========================================================================= + 3. Statusbar + ========================================================================= */ + +@include mobile { + .statusbar { + /* padding and font-size are already shrunk by components/_statusbar.scss */ + gap: 0.5rem; + /* Keep every counter reachable rather than dropping data */ + overflow-x: auto; + white-space: nowrap; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + &-left, + &-right { + flex-shrink: 0; + gap: 0.5rem !important; + } + + &-divider { + display: none; + } + } +} + +/* ========================================================================= + 4. Forms and inputs + ========================================================================= */ + +@include mobile { + /* iOS zooms the whole page in when a focused field is under 16px */ + input[type='text'], + input[type='password'], + input[type='number'], + input[type='search'], + input[type='email'], + select, + textarea { + font-size: 16px; + } + + input { + &.stretched, + &.searchbar { + width: 100%; + } + + &.small { + max-width: 100%; + } + } + + /* Page level sidebars turned into a horizontal tab strip by _navbar.scss: + make the targets tall enough for a finger. */ + .sidebar a { + min-height: $touch-target; + display: inline-flex !important; + align-items: center; + } + + /* Tooltips are anchored with a fixed -120px offset, which pushes them off + screen on a narrow viewport */ + .tooltiptext { + left: 0; + margin-left: 0; + min-width: 0; + width: max-content; + max-width: 80vw; + } +} + +/* ========================================================================= + 5. Touch fallbacks (independent of the viewport width) + ========================================================================= */ + +@include touch { + /* Hover-revealed helpers are unreachable without a pointer */ + .tooltip .tooltiptext { + /* still hidden by default, but reachable by tapping the element */ + &:focus, + &:focus-within { + visibility: visible; + } + } + + .tooltip:focus-within .tooltiptext { + visibility: visible; + } + + /* Recipient autocomplete stays open while the field has focus; the list is + only kept alive by :hover on desktop. */ + .compose-mail__recipients .recipients__input:focus-within .recipients__input-list { + display: flex; + } +} + +/* ========================================================================= + Pull-to-refresh + ========================================================================= */ + +/* Reading older messages means scrolling to the top of a pane, and on a + phone that gesture, once the pane has nothing left to scroll, is also the + browser's pull-to-refresh: the whole page reloaded mid-conversation. + The panes stop the gesture at their edge, and the page itself never + turns it into a reload. */ +@include touch { + html, + body { + overscroll-behavior-y: none; + } + + .chat-messages, + .chat-hub-messages, + .tab-content, + .main-container { + overscroll-behavior-y: contain; + } +} diff --git a/webui-src/app/scss/abstracts/_mixins.scss b/webui-src/app/scss/abstracts/_mixins.scss index 4665aa0..a22f30c 100644 --- a/webui-src/app/scss/abstracts/_mixins.scss +++ b/webui-src/app/scss/abstracts/_mixins.scss @@ -3,6 +3,29 @@ // ----------------------------------------------------------------------------- @use 'sass:color'; @use './colors' as *; +@use './variables' as *; + +/// Responsive helpers +/// Always go through these instead of hardcoding a pixel value, so the +/// breakpoints stay defined in a single place (abstracts/_variables.scss). + +/// The phone layer: portrait, plus landscape, which is wide enough to escape a +/// width-only breakpoint but far too short for a two-pane layout. Between the +/// two lies the small screen layout of the page stylesheets, which this file +/// deliberately leaves alone. +@mixin mobile { + @media (max-width: $bp-mobile), (max-width: $bp-narrow) and (max-height: $bp-short) { + @content; + } +} + +/// Devices without a real pointer: :hover never fires reliably, so anything +/// that is only reachable on hover has to be made permanently visible here. +@mixin touch { + @media (hover: none), (pointer: coarse) { + @content; + } +} /// Button Mixin @mixin button($bg-color) { diff --git a/webui-src/app/scss/abstracts/_variables.scss b/webui-src/app/scss/abstracts/_variables.scss index ee61e01..b8ca322 100644 --- a/webui-src/app/scss/abstracts/_variables.scss +++ b/webui-src/app/scss/abstracts/_variables.scss @@ -4,3 +4,20 @@ $FontPath: './webfonts' !default; $FontName: 'Roboto' !default; $FontVersion: '3.008' !default; + +// Responsive breakpoints. +// +// The page stylesheets already carry a small screen layout of their own: they +// stack the two panes of network and people below 768px, the chat below 899px, +// and turn the mail sidebar into a drawer. _responsive.scss does not redo that +// work and does not edit those files; it adds the phone layer underneath, and +// overrides them only where a tablet compromise does not survive a phone. +// +// $bp-mobile therefore means "a phone", not "a small window". +$bp-mobile: 700px !default; // phone, portrait +$bp-narrow: 899px !default; // widest small screen rule of the page stylesheets +$bp-short: 500px !default; // a phone held sideways is wide, but never tall + +// Minimum comfortable size for a touch target (Material/HIG recommend ~44px) +$touch-target: 2.75rem !default; + diff --git a/webui-src/app/scss/base/_base.scss b/webui-src/app/scss/base/_base.scss index 5067a96..e490d86 100644 --- a/webui-src/app/scss/base/_base.scss +++ b/webui-src/app/scss/base/_base.scss @@ -5,6 +5,10 @@ General site-wide rules #main { height: 100vh; + /* Mobile browsers count the collapsible URL bar in 100vh, which pushes the + statusbar and the bottom navigation off screen. dvh follows the visible + viewport; the vh above stays as the fallback. */ + height: 100dvh; } /* Main base div for tabs used by m.route */ diff --git a/webui-src/app/scss/components/_buttons.scss b/webui-src/app/scss/components/_buttons.scss index 19a68cd..93b6b11 100644 --- a/webui-src/app/scss/components/_buttons.scss +++ b/webui-src/app/scss/components/_buttons.scss @@ -5,8 +5,34 @@ button { @include button($primary-color); + white-space: nowrap !important; + flex-shrink: 0 !important; } button.red { @include button($red-color); + white-space: nowrap !important; + flex-shrink: 0 !important; +} + +// Control buttons, close buttons, and icon buttons must not inherit 3D inset box shadows +button.close-btn, +.close-btn, +.modal-close, +.history-modal .close-btn, +.rightbar-close, +button.icon-btn, +button.btn-icon, +button.mail-tool-btn, +button.mail-filter-pill, +button.mail-view-back-btn, +button.mail-action-btn, +button.spam-btn, +button.mail-compose-btn, +button[class*="close"] { + box-shadow: none !important; + + &:active { + box-shadow: none !important; + } } diff --git a/webui-src/app/scss/components/_comments.scss b/webui-src/app/scss/components/_comments.scss new file mode 100644 index 0000000..9206ca4 --- /dev/null +++ b/webui-src/app/scss/components/_comments.scss @@ -0,0 +1,385 @@ +/* Shared Threaded Comments and Composer component */ + +.comments, +.board-comments { + margin-top: 1.5rem; + max-width: 900px; + color: #0f172a; +} + +.comments__heading, +.board-comments__heading { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.25rem; + + h3 { margin: 0; font-size: 1.15rem; } + span { color: #64748b; font-size: .8rem; font-weight: 600; } + i { margin-right: .35rem; } +} + +.comments__voter, +.board-comments__voter { + display: inline-flex; + align-items: center; + gap: .4rem; + margin-left: auto; + color: #64748b; + font-size: .75rem; + font-weight: 600; + white-space: nowrap; + + select { + max-width: 180px; + padding: .25rem .4rem; + font-size: .78rem; + } +} + +.comment-composer, +.board-comment-composer, +.comment, +.board-comment { + display: flex; + gap: .8rem; +} + +.comment-avatar, +.board-comment-avatar { + display: flex; + flex: 0 0 38px; + width: 38px; + height: 38px; + align-items: center; + justify-content: center; + border-radius: 50%; + background: linear-gradient(135deg, #2563eb, #7c3aed); + color: #fff; + font-size: .78rem; + font-weight: 700; + + > .avatar, + > .jdenticon-avatar, + > .defaultAvatar { + width: 100% !important; + height: 100% !important; + min-width: 100% !important; + min-height: 100% !important; + margin-right: 0 !important; + } +} + +.comment-composer__body, +.board-comment-composer__body, +.comment__content, +.board-comment__content { + min-width: 0; + flex: 1; +} + +.comment-composer__identity, +.board-comment-composer__identity { + max-width: 240px; + margin-bottom: .45rem; + font-size: .8rem; +} + +.comment-composer__input, +.board-comment-composer__input { + width: 100%; + min-height: 36px; + padding: .45rem 0; + resize: vertical; + border: 0; + border-bottom: 1px solid #94a3b8; + border-radius: 0; + background: transparent; + color: #0f172a; + font: inherit; + line-height: 1.4; + box-sizing: border-box; + + &:focus { + outline: 0; + border-bottom: 2px solid #2563eb; + } + + &:disabled { + cursor: not-allowed; + opacity: .6; + } +} + +.comment-composer__actions, +.board-comment-composer__actions, +.comment__actions, +.board-comment__actions { + display: flex; + align-items: center; + gap: .45rem; + margin-top: .55rem; +} + +.comment-composer__actions, +.board-comment-composer__actions { + justify-content: flex-end; + + button { + border: 0; + background: transparent; + color: #475569; + cursor: pointer; + font-size: .78rem; + font-weight: 700; + } +} + +.comment__actions, +.board-comment__actions { + gap: .65rem; + margin-top: .35rem; + + button { + padding: .25rem .2rem; + color: #0f172a; + border: 0; + background: transparent; + cursor: pointer; + font-size: .78rem; + font-weight: 700; + + &:hover { + color: #2563eb; + } + } + + i { + margin-right: .2rem; + } +} + +.comment-composer__submit, +.board-comment-composer__submit { + padding: .45rem .85rem !important; + border-radius: 999px !important; + background: #2563eb !important; + color: #fff !important; + + &:disabled { + background: #dbe3ef !important; + color: #94a3b8 !important; + cursor: not-allowed; + } +} + +.comment-composer__cancel:hover, +.board-comment-composer__cancel:hover { + color: #2563eb; +} + +.comment-composer__replying, +.board-comment-composer__replying { + display: flex; + align-items: center; + gap: .25rem; + margin-bottom: .35rem; + color: #64748b; + font-size: .8rem; + + button { + margin-left: .3rem; + border: 0; + background: transparent; + color: #475569; + cursor: pointer; + font-size: .78rem; + font-weight: 700; + } +} + +.comment-composer__hint, +.board-comment-composer__hint, +.comment-composer__error, +.board-comment-composer__error { + margin: .4rem 0 0; + font-size: .78rem; +} + +.comment-composer__hint, +.board-comment-composer__hint { + color: #64748b; +} + +.comment-composer__error, +.board-comment-composer__error { + color: #dc2626; +} + +.comment-composer__emoji, +.board-comment-composer__emoji { + position: relative; + margin-right: auto; +} + +.comment-emoji-popover, +.board-comment-emoji-popover { + position: absolute; + z-index: 20; + top: 38px; + left: 0; + width: 250px; + max-height: 180px; + overflow-y: auto; + padding: .5rem; + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: .2rem; + background: #fff; + border: 1px solid #cbd5e1; + border-radius: 8px; + box-shadow: 0 8px 20px rgba(0, 0, 0, .16); +} + +.comments__list, +.board-comments__list { + margin-top: 1.8rem; +} + +.comment, +.board-comment { + margin-top: 1.35rem; +} + +.comment--reply, +.board-comment--reply { + margin-top: 1rem; +} + +.comment__header, +.board-comment__header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 18px; +} + +.comment__meta, +.board-comment__meta { + display: flex; + align-items: baseline; + gap: .55rem; + font-size: .8rem; + + b { color: #1e293b; } + span { color: #64748b; font-size: .75rem; } +} + +.comment__text, +.board-comment__text { + margin: .2rem 0 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.45; +} + +.comment__replies-toggle, +.board-comment__replies-toggle { + position: relative; + margin-top: .3rem; + padding: .25rem .35rem; + border: 0; + background: transparent; + color: #2563eb; + cursor: pointer; + font-size: .78rem; + font-weight: 700; + + &:hover { + background: #eff6ff; + border-radius: 4px; + } + + i { + margin-left: .15rem; + } + + &::before { + content: ''; + position: absolute; + left: -3.15rem; + bottom: .8rem; + width: 1.5rem; + height: 2.2rem; + border-left: 1px solid #e2e8f0; + border-bottom: 1px solid #e2e8f0; + border-radius: 0 0 0 .75rem; + pointer-events: none; + } +} + +.comment__replies, +.board-comment__replies { + margin-top: .2rem; + padding-left: 1rem; + border-left: 2px solid #e2e8f0; +} + +.comments__status, +.board-comments__status, +.comments__empty, +.board-comments__empty { + margin: 2rem 0; + color: #64748b; + text-align: center; + + i { + font-size: 1.5rem; + } +} + +@media (max-width: 560px) { + .comments__heading, + .board-comments__heading { + flex-wrap: wrap; + justify-content: space-between; + gap: .5rem; + } + + .comments__voter, + .board-comments__voter { + width: 100%; + margin-left: 0; + + select { + flex: 1; + max-width: none; + } + } + + .comment-composer, + .board-comment-composer, + .comment, + .board-comment { + gap: .6rem; + } + + .comment-avatar, + .board-comment-avatar { + flex-basis: 32px; + width: 32px; + height: 32px; + font-size: .68rem; + } + + .comment__replies, + .board-comment__replies { + padding-left: .6rem; + } + + .comment__replies-toggle::before, + .board-comment__replies-toggle::before { + left: -2.65rem; + width: 1.2rem; + } +} diff --git a/webui-src/app/scss/components/_index.scss b/webui-src/app/scss/components/_index.scss index ee8ea28..6a76547 100644 --- a/webui-src/app/scss/components/_index.scss +++ b/webui-src/app/scss/components/_index.scss @@ -1,4 +1,5 @@ @forward 'buttons'; +@forward 'comments'; @forward 'media'; @forward 'navbar'; @forward 'posts'; diff --git a/webui-src/app/scss/components/_media.scss b/webui-src/app/scss/components/_media.scss index a4ca593..c9d4194 100644 --- a/webui-src/app/scss/components/_media.scss +++ b/webui-src/app/scss/components/_media.scss @@ -21,4 +21,15 @@ &__desc { flex-basis: 60%; } + + @media (max-width: 768px) { + &__desc { + display: none !important; + } + + &__details { + flex-basis: 100% !important; + width: 100% !important; + } + } } \ No newline at end of file diff --git a/webui-src/app/scss/components/_navbar.scss b/webui-src/app/scss/components/_navbar.scss index ac38821..9a10aa7 100644 --- a/webui-src/app/scss/components/_navbar.scss +++ b/webui-src/app/scss/components/_navbar.scss @@ -34,6 +34,7 @@ position: relative; .item { + position: relative; margin: 0; padding: 0.675rem 0.5rem; width: 10rem; @@ -57,6 +58,20 @@ } } + .nav-unread-badge { + margin-left: auto; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.35rem; + border-radius: 999px; + display: grid; + place-items: center; + background: #ef4444; + color: white; + font-size: 0.7rem; + line-height: 1; + } + .item.item-selected { color: $primary-light-color; background-color: color.adjust($primary-light-color, $alpha: -0.85); @@ -106,6 +121,17 @@ & p { display: none !important; } + + .nav-unread-badge { + display: grid !important; + position: absolute; + top: 0.1rem; + right: -0.15rem; + min-width: 1rem; + height: 1rem; + padding: 0 0.2rem; + font-size: 0.6rem; + } } } @@ -145,6 +171,10 @@ } } +.sidebar-mobile-toggle, +.sidebar-drawer__title, +.sidebar-drawer__backdrop { display: none !important; } + .sidebarquickview { &>h6 { padding: 0.5rem; @@ -225,4 +255,406 @@ .sidebarquickview>h6 { display: none !important; } + + /* Boards, Channels and Forums use a drawer instead of the horizontal tabs. */ + .sidebar-drawer { + display: block; + position: relative; + width: 100%; + height: 44px; + z-index: 1000; + } + + .sidebar-mobile-toggle { + display: inline-flex !important; + width: 40px; + height: 40px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 6px; + background: #ffffff; + color: #0f172a; + cursor: pointer; + font-size: 1.15rem; + } + + .sidebar-mobile-toggle:hover { background: #f1f5f9; } + + .sidebar-drawer .sidebar { + position: fixed !important; + top: 0; + left: 0; + display: flex !important; + flex-direction: column !important; + width: min(82vw, 300px) !important; + height: 100dvh !important; + padding: 1rem 0 !important; + overflow-y: auto !important; + overflow-x: hidden !important; + transform: translateX(-105%); + transition: transform 180ms ease; + border: 0 !important; + border-right: 1px solid #e2e8f0 !important; + background: #ffffff !important; + opacity: 1 !important; + pointer-events: auto !important; + box-shadow: 8px 0 24px rgba(15, 23, 42, 0.16); + white-space: normal !important; + // Override the generic mobile sidebar's z-index: 50 !important. + z-index: 1002 !important; + } + + .sidebar-drawer .sidebar.sidebar--mobile-open { transform: translateX(0); } + .sidebar-drawer .sidebar a { + display: block !important; + padding: .8rem 1.25rem !important; + border: 0 !important; + border-left: 4px solid transparent !important; + color: #334155 !important; + font-size: .95rem; + } + + .sidebar-drawer .sidebar .selected-sidebar-link { + border-left-color: #3ba4d7 !important; + border-bottom: 0 !important; + background: #f0f9ff; + color: #0f172a !important; + } + + .sidebar-drawer__title { + display: block !important; + margin: 0 1.25rem .65rem; + padding-bottom: .75rem; + border-bottom: 1px solid #e2e8f0; + color: #64748b; + font-size: .75rem; + font-weight: 700; + letter-spacing: .06em; + text-transform: uppercase; + } + + .sidebar-drawer__backdrop { + display: block !important; + position: fixed; + inset: 0; + background: rgba(15, 23, 42, .35); + /* Rendered only while open; tapping outside closes the drawer. */ + pointer-events: auto; + z-index: 1001; + } } + +@media (min-width: 701px) { + .sidebar-drawer { display: contents; } + .sidebar-mobile-toggle, + .sidebar-drawer__title, + .sidebar-drawer__backdrop { display: none !important; } +} + +.mobile-app-header, +.mobile-bottom-nav, +.mobile-more-overlay, +.mobile-status-overlay { + display: none; +} + +@media (max-width: 700px) { + .nav-menu { + display: none !important; + } + + .mobile-app-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: calc(44px + env(safe-area-inset-top)); + padding: env(safe-area-inset-top) 0.7rem 0; + box-sizing: border-box; + color: #f8fafc; + background: $dark-color; + flex-shrink: 0; + + &__brand { + display: flex; + align-items: center; + gap: 0.55rem; + + img { + width: 1.35rem; + height: 1.35rem; + } + + strong { font-size: 0.85rem; } + } + } + + .mobile-status-trigger { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.5rem; + color: #e2e8f0; + background: rgba(255, 255, 255, 0.08); + border: 0; + border-radius: 999px; + box-shadow: none; + font-size: 0.75rem; + + &__dot { + display: inline-block; + width: 0.65rem; + height: 0.65rem; + flex: 0 0 auto; + border: 2px solid rgba(255, 255, 255, 0.7); + border-radius: 50%; + } + + i { + font-size: 0.6rem; + } + } + + .mobile-bottom-nav { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + min-height: calc(50px + env(safe-area-inset-bottom)); + padding: 0 0 env(safe-area-inset-bottom); + box-sizing: border-box; + background: #ffffff; + border-top: 1px solid #cbd5e1; + box-shadow: 0 -4px 14px rgba(15, 23, 42, 0.08); + flex-shrink: 0; + z-index: 900; + + &__item { + position: relative; + display: flex; + width: 100%; + align-items: center; + justify-content: center; + flex-direction: column; + min-width: 0; + margin: 0; + min-height: 48px; + padding: 0.2rem 0.1rem; + gap: 0.1rem; + color: #64748b; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + text-decoration: none; + + &.active { + color: #0284c7; + } + + i { + width: auto; + height: auto; + font-size: 1.15rem; + } + + span { + overflow: hidden; + max-width: 100%; + font-size: 0.6rem; + text-overflow: ellipsis; + } + + .nav-unread-badge { + position: absolute; + top: 0.1rem; + left: calc(50% + 0.45rem); + display: grid; + place-items: center; + min-width: 1.05rem; + height: 1.05rem; + padding: 0 0.22rem; + box-sizing: border-box; + border: 2px solid #ffffff; + border-radius: 999px; + background: #ef4444; + color: #ffffff; + font-size: 0.6rem; + font-weight: 700; + line-height: 1; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.25); + } + } + } + + .mobile-more-overlay, + .mobile-status-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: flex-end; + background: rgba(15, 23, 42, 0.45); + z-index: 1100; + } + + .mobile-more-sheet, + .mobile-status-sheet { + width: 100%; + max-height: min(75dvh, 38rem); + padding: 0.55rem 1rem max(1rem, env(safe-area-inset-bottom)); + box-sizing: border-box; + overflow-y: auto; + color: #1e293b; + background: #ffffff; + border-radius: 1rem 1rem 0 0; + box-shadow: 0 -12px 32px rgba(15, 23, 42, 0.2); + + &__handle { + width: 2.5rem; + height: 0.25rem; + margin: 0 auto 0.75rem; + background: #cbd5e1; + border-radius: 999px; + } + + h3 { + margin: 0 0 0.75rem; + } + } + + .mobile-more-sheet { + &__links { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.4rem; + + a { + display: flex; + align-items: center; + padding: 0.75rem; + gap: 0.65rem; + color: #334155; + background: #f8fafc; + border-radius: 0.55rem; + text-decoration: none; + + &.active { + color: #0369a1; + background: #e0f2fe; + } + } + } + + &__actions { + display: flex; + padding-top: 0.75rem; + margin-top: 0.75rem; + gap: 0.5rem; + border-top: 1px solid #e2e8f0; + + button { + flex: 1; + } + } + } + + .mobile-status-sheet { + &__heading, + &__heading > div { + display: flex; + align-items: center; + } + + &__heading { + justify-content: space-between; + margin-bottom: 1rem; + + > div { + gap: 0.55rem; + } + + button { + padding: 0.4rem; + color: #64748b; + background: transparent; + border: 0; + box-shadow: none; + } + } + + &__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; + } + + &__item { + display: flex; + flex-direction: column; + min-width: 0; + padding: 0.75rem; + gap: 0.2rem; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 0.55rem; + + span, + small { + color: #64748b; + font-size: 0.72rem; + } + + strong { + overflow-wrap: anywhere; + font-size: 0.9rem; + } + } + + &__version { + margin-top: 0.75rem; + color: #94a3b8; + font-size: 0.7rem; + text-align: center; + } + } +} + +/* Version label in the phone header, and the reload button beside the + * version in the status sheet (main.js MobileStatus). */ +.mobile-app-header__version { + margin-left: 0.4rem; + font-size: 0.7rem; + font-weight: 600; + color: #64748b; + align-self: flex-end; + padding-bottom: 0.15rem; +} + +.mobile-status-sheet__version { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + + button { + border: 1px solid #cbd5e1; + background: #f8fafc; + color: #334155; + border-radius: 0.375rem; + padding: 0.3rem 0.6rem; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + } +} + +// Reset native dialog dimensions so mobile overlays still fill the viewport. +dialog.accessible-dialog { + margin: 0; + padding: 0; + border: 0; + width: 100%; + height: 100%; + max-width: none; + max-height: none; + box-sizing: border-box; +} +dialog.accessible-dialog::backdrop { background: transparent; } diff --git a/webui-src/app/scss/components/_statusbar.scss b/webui-src/app/scss/components/_statusbar.scss index 0f88078..0428599 100644 --- a/webui-src/app/scss/components/_statusbar.scss +++ b/webui-src/app/scss/components/_statusbar.scss @@ -15,15 +15,15 @@ flex-shrink: 0; &-left { - @include flex($align: center); + @include flex($align: center, $gap: 0.75rem); } &-right { - @include flex($align: center, $gap: 1.5rem); + @include flex($align: center, $gap: 0.75rem); } &-item { - @include flex($align: center); + @include flex($align: center, $gap: 0.3rem); } &-divider { @@ -40,3 +40,9 @@ display: inline-block; box-shadow: 0 0 4px rgba(0, 0, 0, 0.5); } + +@media (max-width: 700px) { + .statusbar { + display: none !important; + } +} diff --git a/webui-src/app/scss/layouts/_modal-container.scss b/webui-src/app/scss/layouts/_modal-container.scss index 8496122..6cd2a03 100644 --- a/webui-src/app/scss/layouts/_modal-container.scss +++ b/webui-src/app/scss/layouts/_modal-container.scss @@ -3,7 +3,7 @@ #modal-container { display: none; position: fixed; - z-index: 1; + z-index: 1300; height: 100%; top: 0; left: 0; @@ -36,3 +36,129 @@ padding: 0; } } + +.modal-content.location-details-modal { + width: min(760px, calc(100% - 2rem)); + height: min(480px, calc(100% - 2rem)); + max-height: calc(100% - 2rem); + min-height: 0; + box-sizing: border-box; + overflow: hidden; +} + +.location-details-dialog { + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + + h3 { + margin: 0 2.5rem 0.75rem 0; + color: #1e293b; + } + + .location-detail-tabs { + overflow-x: auto; + margin: 0 -1.5rem; + padding: 0 1rem; + + .tab-btn { + flex: 0 0 auto !important; + margin: 0 !important; + } + } + + .location-detail-content { + flex: 1 1 auto; + min-height: 0; + padding: 1rem 0.25rem 0.25rem 0; + overflow-y: auto; + } + + .info-grid { + display: grid; + grid-template-columns: 140px minmax(0, 1fr); + gap: 0.7rem 1rem; + } + + .info-label { + color: #64748b; + font-weight: 600; + } + + .info-value { + min-width: 0; + color: #1e293b; + overflow-wrap: anywhere; + } + + .retroshare-id-text { + max-height: 100%; + box-sizing: border-box; + overflow: auto; + padding: 0.75rem; + border: 1px solid #cbd5e1; + background: #f8fafc; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .known-addresses-list { + height: 10rem; + max-height: 25vh; + margin-bottom: 0; + overflow: auto; + padding: 0.75rem; + border: 1px solid #cbd5e1; + background: #f8fafc; + white-space: pre; + } +} + +@media (max-width: 600px) { + // Every modal that gets a phone layout drops its padding from 1.5rem to 1rem + // -- copy confirmation, add friend, create identity, create forum, location + // details -- but the close button is positioned against the box, not the + // content, so it stayed 1.5rem from the edge and no longer lined up with + // anything. Corrected once here rather than in each of them. + .modal-content .close-btn { + right: 1rem; + } + + .modal-content.location-details-modal { + width: calc(100% - 1rem); + height: max-content; + max-height: calc(100dvh - 5rem); + padding: 1rem; + } + + .location-details-dialog { + .location-detail-tabs { + margin: 0 -1rem; + padding: 0 0.5rem; + + .tab-btn { + padding-right: 0.75rem; + padding-left: 0.75rem; + font-size: 0.85rem; + } + } + + .info-grid { + grid-template-columns: 1fr; + gap: 0.2rem; + } + + .info-value { + margin-bottom: 0.65rem; + } + + .known-addresses-list { + height: 8rem; + max-height: 20vh; + font-size: 0.75rem; + } + } +} diff --git a/webui-src/app/scss/main.scss b/webui-src/app/scss/main.scss index 48fc71a..351bf94 100644 --- a/webui-src/app/scss/main.scss +++ b/webui-src/app/scss/main.scss @@ -20,3 +20,7 @@ // 6. Page-specific styles @use 'pages'; + +// 7. Small screen / touch adaptations. Must stay last: it overrides the +// layouts defined above without relying on !important. +@use 'responsive'; diff --git a/webui-src/app/scss/pages/_board.scss b/webui-src/app/scss/pages/_board.scss index 70c305d..38db8a7 100644 --- a/webui-src/app/scss/pages/_board.scss +++ b/webui-src/app/scss/pages/_board.scss @@ -1,48 +1,1332 @@ -/* subject */ -table.boards th:nth-child(1) { - width: 50%; - text-align: start; -} -/* subject */ -table.boards td:nth-child(1) { - text-align: start; - /* Truncate text with '...' */ - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +/* ========================================================= + Global Modal & Lightbox Overlay (#popupmessage) + ========================================================= */ + +#popupmessage { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + /* Mobile browsers count the collapsible URL bar in 100vh, so a full height + overlay is taller than the screen and its bottom is unreachable. dvh + follows the bar; the vh line stays as a fallback for older engines. */ + height: 100vh; + height: 100dvh; + background-color: rgba(15, 23, 42, 0.75); + backdrop-filter: blur(4px); + z-index: 999999; + display: none; + align-items: center; + justify-content: center; + box-sizing: border-box; } -table.boards tr:hover { - background-color: #eef3f6; +.popup { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1000000; + max-width: 90vw; + max-height: 90vh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.popup-content { + position: relative; + background: #ffffff; + border-radius: 12px; + padding: 1rem; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.2); + max-width: 90vw; + max-height: 90vh; + overflow: auto; + box-sizing: border-box; +} + +.popup-content span.close { + position: absolute; + top: 0.5rem; + right: 0.75rem; + font-size: 1.75rem; + font-weight: 700; + color: #64748b; + cursor: pointer; + line-height: 1; + z-index: 10; + transition: color 0.15s ease; +} + +.popup-content span.close:hover { + color: #ef4444; +} + +.board-view-container { + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; + max-width: 100%; + overflow-x: hidden; + padding: 0.5rem 0; + box-sizing: border-box; +} + +.board-table { + width: 100%; + border-collapse: collapse; +} + +.board-table th, +.board-table td { + text-align: left; + padding: 0.65rem 0.85rem; +} + +/* Toolbar Header Controls */ +.board-toolbar { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; + padding: 0.5rem 0.85rem; + background-color: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + box-sizing: border-box; +} + +.board-toolbar__left { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; + flex: 1; + min-width: 0; +} + +.board-toolbar__right { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.65rem; + flex-shrink: 0; +} + +.board-toolbar__count-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.65rem; + background-color: #f1f5f9; + color: #475569; + font-size: 0.825rem; + font-weight: 600; + border-radius: 16px; + border: 1px solid #e2e8f0; + white-space: nowrap; +} + +.board-toolbar__count-badge i { + color: #007bff; +} + +.board-toolbar__search { + position: relative; + display: flex; + align-items: center; + flex: 1; + max-width: 380px; + min-width: 160px; +} + +.board-toolbar__search-icon { + position: absolute; + left: 0.75rem; + color: #94a3b8; + font-size: 0.85rem; + pointer-events: none; +} + +input.board-toolbar__search-input { + width: 100%; + padding: 0.35rem 0.65rem 0.35rem 2.1rem; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 0.85rem; + background-color: #ffffff; + color: #1e293b; +} + +input.board-toolbar__search-input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.15); +} + +.board-toolbar__voter { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: #475569; + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; +} + +.board-toolbar__voter select { + max-width: 180px; + min-width: 110px; + padding: 0.3rem 0.45rem; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + color: #1e293b; + font-size: 0.8rem; +} + +.board-toolbar__voter select:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.15); +} + +.board-toolbar__view-toggle { + display: inline-flex; + flex-direction: row; + align-items: center; + background-color: #f1f5f9; + padding: 2px; + border-radius: 6px; + border: 1px solid #cbd5e1; +} + +.board-toolbar__toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.3rem 0.65rem; + border: none; + background: transparent; + color: #64748b; + font-size: 0.825rem; + font-weight: 500; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; + transition: all 0.2s ease; +} + +.board-toolbar__toggle-btn i { + font-size: 0.85rem; +} + +.board-toolbar__toggle-btn:hover { + color: #1e293b; + background-color: rgba(255, 255, 255, 0.6); +} + +.board-toolbar__toggle-btn--active, +.board-toolbar__toggle-btn--active:hover { + background-color: #007bff; + color: #ffffff; + font-weight: 600; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.board-toolbar__toggle-btn--active i { + color: #ffffff; +} + +@media (max-width: 768px) { + .board-toolbar { + flex-wrap: wrap; + } + .board-toolbar__left { + flex-basis: 100%; + } + .board-toolbar__toggle-btn span { + display: none; + } + .board-toolbar__toggle-btn { + padding: 0.35rem 0.55rem; + } +} + +.board-post-voting { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin: .85rem 0; + padding: .65rem .75rem; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: .5rem; +} +.board-post-voting__identity { display: flex; align-items: center; gap: .5rem; color: #64748b; font-size: .8rem; font-weight: 600; } +.board-post-voting__identity select { max-width: 220px; padding: .3rem .45rem; font-size: .8rem; } +.board-post-voting__buttons { display: flex; align-items: center; gap: .35rem; } +.board-post-voting__buttons button { min-width: 52px; padding: .3rem .55rem; box-shadow: none; } +.board-post-voting__score { min-width: 2rem; color: #334155; font-weight: 700; text-align: center; } + +@media (max-width: 560px) { + .board-post-voting { align-items: stretch; flex-direction: column; } + .board-post-voting__identity select { flex: 1; min-width: 0; max-width: none; } + .board-post-voting__buttons { justify-content: center; } + .board-card__notes-btn span { display: none; } +} + +/* Pagination Controls (< 1 - 25 >) on exact same level right next to toggle */ +.board-pagination { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 0.25rem; + background-color: #ffffff; + padding: 2px 4px; + border-radius: 6px; + border: 1px solid #cbd5e1; +} + +.board-pagination__btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + min-width: 26px; + min-height: 26px; + padding: 0; + margin: 0; + border: 1px solid #cbd5e1; + border-radius: 4px; + background: #ffffff; + color: #007bff; + font-size: 0.825rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.board-pagination__btn i { + font-size: 0.825rem; + color: #007bff; +} + +.board-pagination__btn:hover:not(:disabled) { + background-color: #007bff; + color: #ffffff; + border-color: #007bff; +} + +.board-pagination__btn:hover:not(:disabled) i { + color: #ffffff; +} + +.board-pagination__btn:disabled { + opacity: 0.4; + cursor: not-allowed; + color: #94a3b8; + border-color: #e2e8f0; + background-color: #f1f5f9; +} + +.board-pagination__btn:disabled i { + color: #94a3b8; +} + +.board-pagination__label { + font-size: 0.825rem; + font-weight: 700; + color: #334155; + padding: 0 0.35rem; + white-space: nowrap; + user-select: none; +} + +/* Bottom Pagination Container */ +.board-view-footer { + display: flex; + justify-content: center; + align-items: center; + padding: 1rem 0 0.5rem 0; + width: 100%; +} + +/* Board Grid Layout Modes */ +.board-grid { + display: flex; + flex-direction: column; + width: 100%; + box-sizing: border-box; +} + +.board-grid--compact { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; +} + +.board-grid--card { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.25rem; + width: 100%; +} + +.board-grid__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + background: #ffffff; + border: 2px dashed #cbd5e1; + border-radius: 12px; + width: 100%; + box-sizing: border-box; +} + +.board-grid__empty-icon { + font-size: 3rem; + color: #cbd5e1; + margin-bottom: 1rem; +} + +.board-grid__empty-title { + font-size: 1.15rem; + font-weight: 600; + color: #475569; + margin: 0 0 0.5rem 0; +} + +.board-grid__empty-desc { + font-size: 0.9rem; + color: #94a3b8; + margin: 0; +} + +/* =================================================== + COMPACT VIEW MODE: FULL-WIDTH HORIZONTAL ROW BANNER + (Identical to Retroshare C++ Qt GUI Posted design) + =================================================== */ +.board-card { + box-sizing: border-box; +} + +.board-card--compact { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + min-height: 70px; + padding: 0.45rem 0.75rem; + background-color: #eef2f5; + border: 1px solid #d1d5db; + border-radius: 4px; + gap: 0.75rem; + box-sizing: border-box; + margin-bottom: 0.35rem; +} + +.board-card--compact:hover { + background-color: #e2e8f0; + border-color: #9ca3af; +} + +/* Vote Pill (Matching Comments Button style with normal-sized icons) */ +.board-card__vote-pill { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.2rem 0.55rem; + background-color: #f1f5f9; + border: 1px solid #cbd5e1; + border-radius: 20px; + box-sizing: border-box; +} + +button.board-card__vote-btn, +.board-card__vote-pill .board-card__vote-btn { + background: transparent; + border: none; + box-shadow: none; + padding: 0.1rem 0.2rem; + margin: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + border-radius: 4px; + transition: background-color 0.15s ease; + outline: none; +} + +.board-card__vote-pill .board-card__vote-btn:hover { + background-color: #e2e8f0; + box-shadow: none; +} + +.board-card__vote-pill .board-card__vote-btn--up i { + color: #16a34a; + font-size: 1.15rem; +} + +.board-card__vote-pill .board-card__vote-btn--down i { + color: #dc2626; + font-size: 1.15rem; +} + +.board-card__vote-pill .board-card__vote-score { + font-size: 0.9rem; + font-weight: 700; + color: #1e293b; + padding: 0 0.15rem; + line-height: 1; + min-width: 1rem; + text-align: center; +} + +/* Thumbnail Image Container */ +.board-card--compact .board-card__image-container { + width: 110px; + height: 62px; + flex-shrink: 0; + border-radius: 4px; + overflow: hidden; + background-color: #cbd5e1; +} + +.board-card--compact .board-card__image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.board-card--compact .board-card__placeholder-wrapper { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%); +} + +.board-card__placeholder-content { + display: flex; + flex-direction: column; + align-items: center; + gap: .3rem; + color: #64748b; + font-size: .72rem; + font-weight: 600; +} +.board-card__placeholder-content i { font-size: 1.35rem; } + +.board-card--compact .board-card__placeholder-img { + width: 24px; + height: 24px; + color: #64748b; +} + +/* Main Details Section */ +.board-card--compact .board-card__content { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1; + min-width: 0; + padding: 0; +} + +.board-card__title-button, +.board-card__title-button:active { + all: unset; + box-sizing: border-box; + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; cursor: pointer; } -table.boards tr.hidden { +.board-card__title-button:focus-visible { + outline: 2px solid currentColor; + outline-offset: -2px; +} + +/* Title: Blue bold underlined italicized link (Matches Qt GUI) */ +.board-card--compact .board-card__title { + font-size: 1.05rem; + font-weight: 700; + color: #2255aa; + text-decoration: underline; + font-style: italic; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0 0 0.15rem 0; + cursor: pointer; +} + +.board-card--compact .board-card__title:hover { + color: #1d4ed8; +} + +/* Meta line: Posted by */ +.board-card--compact .board-card__meta { + font-size: 0.8rem; + color: #475569; + margin-bottom: 0.2rem; +} + +.board-card--compact .board-card__meta b { + color: #1e293b; +} + +/* Footer: Comment button matching Qt GUI */ +.board-card--compact .board-card__footer { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0; + border: none; + margin: 0; +} + +.board-card__comments-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0.5rem; + background: transparent; + border: none; + box-shadow: none; + color: #64748b; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + outline: none; +} + +.board-card__comments-btn:hover { + color: #007bff; + text-decoration: underline; + box-shadow: none; +} + +.board-card__notes-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0.5rem; + background: transparent; + border: none; + box-shadow: none; + color: #64748b; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; +} + +.board-card__notes-btn:hover { color: #007bff; text-decoration: underline; } + +/* =================================================== + CARD VIEW MODE: ELEVATED GRID CARD + =================================================== */ +.board-card--card { + display: flex; + flex-direction: column; + background-color: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); +} + +.board-card--card:hover { + transform: translateY(-3px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08); + border-color: #cbd5e1; +} + +.board-card--card .board-card__vote-col { display: none; } -#toggleunsub { +.board-card--card .board-card__image-container { + width: 100%; + height: 170px; + overflow: hidden; + background-color: #f1f5f9; +} + +.board-card--card .board-card__image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.board-card--card .board-card__placeholder-wrapper { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); +} + +.board-card--card .board-card__placeholder-img { + width: 36px; + height: 36px; + color: #94a3b8; +} + +.board-card--card .board-card__content { + display: flex; + flex-direction: column; + flex: 1; + padding: 1rem; +} + +.board-card--card .board-card__title { + font-size: 1.05rem; + font-weight: 700; + color: #0f172a; + margin: 0 0 0.4rem 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} + +.board-card--card .board-card__title:hover { + color: #007bff; +} + +.board-card--card .board-card__meta { + font-size: 0.8rem; + color: #64748b; + margin-bottom: 0.5rem; +} + +.board-card--card .board-card__notes-wrapper { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 0.75rem; +} + +.board-card--card .board-card__notes { + font-size: 0.85rem; + line-height: 1.45; + color: #475569; + background-color: #f8fafc; + border-left: 3px solid #cbd5e1; + padding: 0.4rem 0.6rem; + word-break: break-word; +} + +.board-card--card .board-card__notes--clamped { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: pre-line; +} + +.board-card--card .board-card__notes--expanded { + display: block; + white-space: pre-line; +} + +.board-card--card .board-card__notes-toggle { + align-self: flex-start; + background: none; + border: none; + padding: 0.1rem 0.3rem; + color: #007bff; + font-size: 0.775rem; + font-weight: 600; + cursor: pointer; +} + +.board-card--card .board-card__notes-toggle:hover { + text-decoration: underline; +} + +.board-card--card .board-card__footer { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: auto; + padding-top: 0.65rem; + border-top: 1px solid #f1f5f9; +} + +.board-card--card .board-card__comments-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.6rem; + background-color: #f1f5f9; + color: #475569; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; +} + +.board-card--card .board-card__comments-btn:hover { + background-color: #007bff; + color: #ffffff; + border-color: #007bff; +} + +.board-card--card .board-card__comments-btn:hover i { + color: #ffffff; +} + +.board-card--card .board-card__notes-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.6rem; + background-color: #f8fafc; + color: #475569; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 500; +} + +.board-card--card .board-card__notes-btn:hover { background-color: #e0f2fe; color: #0369a1; border-color: #7dd3fc; text-decoration: none; } + +.board-notes-dialog { min-width: min(560px, 75vw); max-width: 75vw; } +.board-notes-dialog h3 { margin: 0 2rem .8rem 0; color: #0f172a; } +.board-notes-dialog__label { margin: 0 0 .35rem; color: #64748b; font-size: .78rem; font-weight: 700; text-transform: uppercase; } +.board-notes-dialog__content { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: #1e293b; line-height: 1.55; } + +/* ========================================================= + PhotoView Lightbox Modal Styles (Matching Qt GUI PhotoView) + ========================================================= */ + +/* Fullscreen overlay element appended to body by openPhotoModal() */ +#photo-view-overlay { + display: none; + position: fixed; + inset: 0; + z-index: 999999; + background-color: rgba(0, 0, 0, 0.85); + align-items: center; + justify-content: center; +} + +.photo-view-dialog { + background-color: #f8fafc; + border-radius: 8px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + max-width: 90vw; + max-height: 90vh; + width: 820px; + overflow: hidden; position: relative; - background: gray; + z-index: 1; } -#options { - width: 100px; - text-align: center; - font-size: medium; - margin-left: 20px; - height: 40px; +.photo-view-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background-color: #ffffff; + border-bottom: 1px solid #e2e8f0; } -#composepopup { - height: 80%; - width: 70%; +.photo-view-title { + font-size: 1.05rem; + font-style: italic; + font-weight: 700; + color: #1e293b; + margin: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -#mtags { - width: 160px; - text-align: center; - font-size: medium; - margin-left: 10px; - height: 40px; +.photo-view-close-btn { + background: none; + border: none; + font-size: 1.6rem; + line-height: 1; + color: #64748b; + cursor: pointer; + padding: 0 0.4rem; +} + +.photo-view-close-btn:hover { + color: #ef4444; +} + +.photo-view-body { + display: flex; + flex-direction: row; + align-items: stretch; + background-color: #0f172a; + flex: 1; + min-height: 380px; + max-height: 68vh; + overflow: hidden; +} + +/* Left/right nav columns (fixed 56px wide, vertically center the button) */ +.photo-view-nav-col { + width: 56px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.25); +} + +/* Image wrapper fills remaining space */ +.photo-view-img-wrap { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + padding: 0.75rem; +} + +.photo-view-img { + max-width: 100%; + max-height: 65vh; + object-fit: contain; + display: block; + border-radius: 4px; +} + +.photo-view-no-img { + color: #94a3b8; + font-size: 0.95rem; +} + +/* Nav button — static in flex column, no absolute positioning */ +.photo-view-nav-btn { + width: 38px; + height: 38px; + background-color: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 6px; + color: #007bff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + transition: all 0.15s ease; + flex-shrink: 0; +} + +.photo-view-nav-btn i { + font-size: 1rem; + color: #007bff; +} + +.photo-view-nav-btn:hover { + background-color: #007bff; + color: #ffffff; + border-color: #007bff; +} + +.photo-view-nav-btn:hover i { + color: #ffffff; +} + +// The global button:active (0,1,1) presses an inset shadow over this class +// (0,1,0) while clicked; keep the button's own elevation instead. +.photo-view-nav-btn:active { + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); +} + +.photo-view-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background-color: #ffffff; + border-top: 1px solid #e2e8f0; +} + +.photo-view-meta { + font-size: 0.875rem; + color: #475569; +} + +.photo-view-meta b { + color: #0f172a; +} + +.board-detail-default-thumbnail { + display: flex; + flex: 0 0 6rem; + align-items: center; + justify-content: center; + width: 6rem; + height: 6rem; + border: 1px solid #cbd5e1; + border-radius: 4px; + background: linear-gradient(135deg, #eef6fb, #dbeafe); + color: #3ba4d7; + box-sizing: border-box; + + i { font-size: 2.75rem; } +} + +/* Create Board dialog mirrors the Create Channel form language. */ +.create-board-form { + display: grid; + grid-template-columns: 11rem minmax(0, 1fr); + gap: 1rem 1.5rem; + + &__heading, + &__title, + &__description, + &__submit { grid-column: 1 / -1; } + + &__heading h3 { margin: 0; color: #1e293b; } + &__heading p { margin: .25rem 0 0; color: #64748b; font-size: .9rem; } + &__title { width: 100%; } + &__visual { + grid-row: span 2; + display: flex; + flex-direction: column; + align-items: center; + gap: .6rem; + padding: .75rem; + border: 1px solid #e2e8f0; + border-radius: .65rem; + background: #f8fafc; + } + .board-thumbnail-preview { + width: 9rem; + height: 9rem; + overflow: hidden; + border: 1px solid #cbd5e1; + border-radius: .5rem; + background: linear-gradient(135deg, #eef2ff, #dbeafe); + } + .board-thumbnail-preview img { width: 100%; height: 100%; display: block; object-fit: cover; } + .board-thumbnail-preview__placeholder { + width: 100%; height: 100%; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: .5rem; + } + .board-thumbnail-preview__placeholder i { color: #3ba4d7; font-size: 2rem; } + .board-thumbnail-preview__placeholder span, + &__visual-label { color: #334155; font-weight: 700; } + &__file-input { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; } + &__file-button { + display: inline-flex; align-items: center; gap: .35rem; cursor: pointer; + padding: .35rem .65rem; border: 1px solid #cbd5e1; border-radius: 4px; + background: #fff; color: #334155; font-size: .85rem; + } + &__image-error { color: #b91c1c; font-size: .85rem; text-align: center; } + &__file-button:hover { border-color: #3ba4d7; color: #0879b2; } + &__visual small { color: #64748b; text-align: center; } + &__field { display: flex; flex-direction: column; gap: .35rem; min-width: 0; } + &__field label { color: #475569; font-size: .85rem; font-weight: 700; } + &__field .config-style-select { + width: 100%; + max-width: 320px; + min-width: 0; + box-sizing: border-box; + padding: .4rem; + border: 1px solid #cbd5e1; + border-radius: 4px; + background-color: #fff; + color: #1e293b; + font-size: .95rem; + } + &__description { width: 100%; min-height: 7rem; resize: vertical; } + &__submit { justify-self: end; } +} + +.modal-content.create-board-modal { + width: min(760px, calc(100% - 2rem)); + max-height: calc(100% - 2rem); + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; +} + +.create-board-post { + display: flex; + flex-direction: column; + gap: .9rem; + + &__heading h3 { margin: 0; color: #1e293b; } + &__heading p { margin: .25rem 0 0; color: #64748b; font-size: .9rem; } + &__modes { display: flex; gap: .4rem; border-bottom: 1px solid #cbd5e1; } + &__modes button { + border: 0; border-bottom: 3px solid transparent; border-radius: 0; + background: transparent; color: #475569; box-shadow: none; padding: .55rem .8rem; + } + &__modes button.active { color: #0788cb; border-bottom-color: #0788cb; } + &__title, + &__link, + &__notes { width: 100%; box-sizing: border-box; } + &__notes { resize: vertical; min-height: 10rem; } + &__image { display: flex; flex-direction: column; align-items: center; gap: .65rem; } + &__preview { + width: min(100%, 30rem); height: 16rem; overflow: hidden; + border: 1px solid #cbd5e1; border-radius: .5rem; background: #f8fafc; + } + &__preview img { width: 100%; height: 100%; object-fit: contain; display: block; } + &__placeholder { + height: 100%; display: flex; flex-direction: column; align-items: center; + justify-content: center; gap: .6rem; color: #64748b; + } + &__placeholder i { color: #3ba4d7; font-size: 2.5rem; } + &__file { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; } + &__file-button { + display: inline-flex; align-items: center; gap: .35rem; cursor: pointer; + padding: .4rem .7rem; border: 1px solid #cbd5e1; border-radius: 4px; background: #fff; + } + &__author { display: flex; align-items: center; gap: .65rem; } + &__author label { color: #475569; font-weight: 700; } + &__author select.network-style-select { + flex: 1; + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: .375rem .5rem; + border: 1px solid #cbd5e1; + border-radius: .375rem; + background-color: #fff; + color: #334155; + outline: none; + cursor: pointer; + font-size: .85rem; + font-weight: 600; + transition: border-color .2s; + } + &__author select.network-style-select:focus { border-color: #3ba4d7; } + &__submit { align-self: flex-end; min-width: 6rem; } +} + +.posts > .posts__heading.board-posts-heading { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + width: 100%; + gap: 1rem; + flex-wrap: wrap; + text-align: left; +} +.posts > .posts__heading.board-posts-heading h3 { margin: 0; text-align: left; } +.board-posts-heading__create { display: inline-flex; align-items: center; gap: .3rem; } + +.modal-content.create-board-post-modal { + width: min(760px, calc(100% - 2rem)); max-height: calc(100% - 2rem); + box-sizing: border-box; overflow-x: hidden; overflow-y: auto; +} + +@media (max-width: 600px) { + .modal-content.create-board-modal { + width: calc(100% - 1rem); + max-height: calc(100% - 1rem); + padding: 1rem; + } + .create-board-form { + width: 100%; + min-width: 0; + padding: 0; + overflow: visible; + grid-template-columns: 1fr; + + &__heading, + &__title, + &__visual, + &__field, + &__description, + &__submit { grid-column: 1; } + &__visual { grid-row: auto; } + &__field .config-style-select { max-width: 100%; } + &__description { box-sizing: border-box; } + &__submit { width: 100%; } + } + .create-board-form .board-thumbnail-preview { width: 8rem; height: 8rem; } + .modal-content.create-board-post-modal { width: calc(100% - 1rem); max-height: calc(100% - 1rem); padding: 1rem; } + .create-board-post__modes button { flex: 1; padding: .5rem .25rem; } + .create-board-post__preview { height: 12rem; } + .create-board-post__author { align-items: stretch; flex-direction: column; gap: .35rem; } + .create-board-post__submit { width: 100%; } + .board-posts-heading__create span { display: none; } + .board-posts-heading__create { min-width: 2.25rem; justify-content: center; padding-inline: .55rem; } +} + +// Match the shared button:active specificity so pressing a flat control does +// not restore the global inset shadow. +.board-toolbar__toggle-btn:active, +.board-card__comments-btn:active, +.board-card__notes-btn:active, +.board-card__vote-pill .board-card__vote-btn:active { + box-shadow: none; +} + +// Keep this after the card variants at the same specificity as their padding. +@media (max-width: 560px) { + .board-card .board-card__notes-btn { + width: 30px; + height: 30px; + justify-content: center; + padding: 0; + } + .board-card__notes-btn i { margin: 0; } +} + +.board-detail-navigation { display: flex; align-items: center; justify-content: space-between; } +.board-mobile-actions { display: none; } + +@media (max-width: 700px) { + .widget > .top-heading.boards-subscribed-list-toolbar { + justify-content: flex-end; + } +} + +.my-boards-create, +.other-boards-create, +.popular-boards-create { display: none; } + +@media (max-width: 700px) { + .my-boards-create, + .other-boards-create, + .popular-boards-create { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + font-size: .85rem; + } +} + +.board-toolbar__create-post { display: none; } + +@media (max-width: 700px) { + .posts > .posts__heading.board-posts-heading { display: none; } + .board-toolbar__create-post { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + font-size: .85rem; + padding: 0; + } + .board-toolbar__left .board-toolbar__search { + min-width: 0; + max-width: none; + } +} + +@media (max-width: 700px) { + .tab-content:has(.boards-detail-widget) { position: relative; } + + .widget.boards-detail-widget { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 36px auto minmax(0, 1fr); + row-gap: 4px; + + > .top-heading { + grid-area: 1 / 1; + margin-left: 44px; + justify-content: flex-end; + min-width: 0; + } + > .board-detail-navigation { + grid-area: 1 / 1; + width: 44px; + } + > .widget__heading { grid-area: 2 / 1; } + > .widget__body { grid-area: 3 / 1; min-height: 0; } + + .posts { margin-top: 1px; } + + .board-mobile-actions { + position: absolute; + top: 0; + right: .5rem; + z-index: 1001; + } + } +} + +@media (max-width: 700px) { + .boards-create-button--mobile-hidden, + .board-subscription-button--subscribed { display: none; } + .board-mobile-actions { + display: block; + position: relative; + summary { + display: flex; align-items: center; justify-content: center; + width: 44px; height: 44px; border-radius: .375rem; + cursor: pointer; list-style: none; + } + summary::-webkit-details-marker { display: none; } + summary:focus-visible { outline: 2px solid #0788cb; } + &__items { + position: absolute; right: 0; top: 100%; z-index: 10; + min-width: 10rem; padding: .35rem; background: #fff; + border: 1px solid #cbd5e1; border-radius: .375rem; + box-shadow: 0 4px 12px rgba(15, 23, 42, .15); + } + &__items button { + width: 100%; min-height: 44px; text-align: left; + background: #fff; color: #334155; box-shadow: none; + } + &__items button:hover { background: #f1f5f9; } + } +} + + +@media (max-width: 700px) { + a.board-back[title='Back'] { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + flex-shrink: 0; + border-radius: 50%; + background-color: #e8f4fc; + color: #0788cb; + font-size: 1.15rem; + text-decoration: none; + } + a.board-back[title='Back']:hover { background-color: #d5ebfa; } + .board-back .fa-arrow-left::before { content: "\f053"; } } diff --git a/webui-src/app/scss/pages/_channel.scss b/webui-src/app/scss/pages/_channel.scss index 962f99d..ebba87c 100644 --- a/webui-src/app/scss/pages/_channel.scss +++ b/webui-src/app/scss/pages/_channel.scss @@ -78,6 +78,417 @@ table { } } } + +.posts-container-card .channel-post__placeholder { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: .35rem; + color: #64748b; + background: linear-gradient(135deg, #f8fafc, #dbe5f1); +} +.channel-post__placeholder i { font-size: 1.35rem; color: #64748b; } +.channel-post__placeholder span { font-size: 2rem; font-weight: 700; color: #2563eb; } +.channel-post__placeholder small { font-size: .72rem; font-weight: 600; } + +/* Compact YouTube-style post description with an opt-in expansion control. */ +.post-description { + margin: 1rem 0; + padding: .85rem 1rem; + border-radius: 10px; + background: #f1f5f9; + color: #1e293b; +} +.post-description__text { overflow-wrap: anywhere; line-height: 1.5; } +.post-description__text > :first-child { margin-top: 0; } +.post-description__text > :last-child { margin-bottom: 0; } +.post-description__text--collapsed { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} +.post-description__toggle { + margin-top: .35rem; + padding: 0 !important; + border: 0 !important; + box-shadow: none !important; + background: transparent !important; + color: #0f172a !important; + font-size: .85rem !important; + font-weight: 700 !important; +} +.post-description__toggle:hover { color: #2563eb !important; text-decoration: underline; } + +.create-channel-form { + display: grid !important; + grid-template-columns: 11rem minmax(0, 1fr); + gap: 1rem 1.5rem !important; + + &__heading, + &__title, + &__description, + &__submit { + grid-column: 1 / -1; + } + + &__heading h3 { margin: 0; color: #1e293b; } + &__heading p { margin: .25rem 0 0; color: #64748b; font-size: .9rem; } + &__title { width: 100%; } + + &__thumbnail { + grid-row: span 2; + display: flex; + flex-direction: column; + align-items: center; + gap: .5rem; + padding: .75rem; + border: 1px solid #e2e8f0; + border-radius: .65rem; + background: #f8fafc; + } + + &__thumbnail-label, + &__field label { + color: #475569; + font-size: .85rem; + font-weight: 700; + } + + &__file-input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + opacity: 0; + pointer-events: none; + } + &__file-button { + padding: .4rem .65rem; + border: 1px solid #0284c7; + border-radius: .35rem; + background: #0284c7; + color: #fff; + font-size: .8rem; + font-weight: 600; + cursor: pointer; + } + &__thumbnail small { color: #64748b; text-align: center; } + &__field { display: flex; flex-direction: column; gap: .35rem; min-width: 0; } + &__field .config-style-select { + width: 100%; + max-width: 320px; + min-width: 0; + box-sizing: border-box; + padding: .4rem; + border: 1px solid #cbd5e1; + border-radius: 4px; + background-color: #fff; + color: #1e293b; + font-size: .95rem; + } + &__description { width: 100%; min-height: 7rem; resize: vertical; } + &__submit { justify-self: end; } +} + +.modal-content.create-channel-modal { + width: min(760px, calc(100% - 2rem)); + max-height: calc(100% - 2rem); + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; +} + +.channel-thumbnail-preview { + width: 9rem; + height: 9rem; + overflow: hidden; + border: 1px solid #cbd5e1; + border-radius: 0.5rem; + background: #f8fafc; + + img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; + } + + &__placeholder { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: .35rem; + background: linear-gradient(135deg, #eef2ff, #dbeafe); + color: #64748b; + } + + &__placeholder i { color: #3ba4d7; font-size: 2rem; } + &__placeholder span { color: #334155; font-weight: 700; } + &__placeholder small { font-size: .72rem; } +} + +.channel-detail-default-thumbnail { + display: flex; + flex: 0 0 6rem; + align-items: center; + justify-content: center; + width: 6rem; + height: 6rem; + border: 1px solid #cbd5e1; + border-radius: 4px; + background: linear-gradient(135deg, #eef6fb, #dbeafe); + color: #3ba4d7; + box-sizing: border-box; + + i { font-size: 2.75rem; } +} + +.create-channel-post-form { + display: grid !important; + grid-template-columns: 11rem minmax(0, 1fr); + gap: 1rem 1.5rem !important; + + &__heading, + &__title, + &__description, + &__submit { grid-column: 1 / -1; } + &__heading h3 { margin: 0; color: #1e293b; } + &__heading p { margin: .25rem 0 0; color: #64748b; font-size: .9rem; } + &__title, + &__description { width: 100%; box-sizing: border-box; } + &__thumbnail { + grid-row: span 2; display: flex; flex-direction: column; align-items: center; + gap: .5rem; padding: .75rem; border: 1px solid #e2e8f0; + border-radius: .65rem; background: #f8fafc; + } + &__thumbnail-label, + &__attachments > label:first-child { color: #475569; font-size: .85rem; font-weight: 700; } + &__file-input { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; } + &__file-button, + &__attachment-button { + display: inline-flex; align-items: center; justify-content: center; gap: .35rem; + padding: .4rem .65rem; border: 1px solid #0284c7; border-radius: .35rem; + background: #0284c7; color: #fff; font-size: .8rem; font-weight: 600; cursor: pointer; + } + &__thumbnail small, + &__attachments small { color: #64748b; text-align: center; } + &__attachments { + display: flex; flex-direction: column; align-items: flex-start; + align-self: start; gap: .5rem; min-width: 0; + padding: .8rem; border: 1px solid #e2e8f0; border-radius: .5rem; background: #f8fafc; + } + &__attachment-list { + width: 100%; max-height: 9rem; overflow-y: auto; box-sizing: border-box; + border: 1px solid #cbd5e1; border-radius: .35rem; background: #fff; + } + &__attachment-item { + display: flex; align-items: center; gap: .55rem; padding: .45rem .55rem; + border-bottom: 1px solid #e2e8f0; + } + &__attachment-item:last-child { border-bottom: 0; } + &__attachment-item > i { flex: 0 0 auto; color: #64748b; } + &__attachment-info { display: flex; flex-direction: column; min-width: 0; flex: 1; } + &__attachment-info span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #334155; } + &__attachment-info small { text-align: left; } + &__attachment-remove { + flex: 0 0 auto; padding: .25rem .4rem; border: 0; box-shadow: none; + background: transparent; color: #dc2626; + } + &__attachment-remove:hover { background: #fee2e2; } + &__description { min-height: 9rem; resize: vertical; } + &__submit { justify-self: end; } +} + +.channel-post-thumbnail-preview { + width: 9rem; height: 9rem; overflow: hidden; + border: 1px solid #cbd5e1; border-radius: .5rem; background: #f8fafc; + img { width: 100%; height: 100%; display: block; object-fit: cover; } + &__placeholder { + width: 100%; height: 100%; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: .35rem; + background: linear-gradient(135deg, #eef2ff, #dbeafe); color: #64748b; + } + &__placeholder i { color: #3ba4d7; font-size: 2rem; } + &__placeholder span { color: #334155; font-weight: 700; text-align: center; } + &__placeholder small { font-size: .72rem; } +} + +.modal-content.create-channel-post-modal { + width: min(760px, calc(100% - 2rem)); max-height: calc(100% - 2rem); + box-sizing: border-box; overflow-x: hidden; overflow-y: auto; +} + +.posts > .posts__heading.channel-posts-heading { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + width: 100%; + gap: 1rem; + flex-wrap: wrap; + text-align: left; +} +.posts > .posts__heading.channel-posts-heading h3 { margin: 0; text-align: left; } +.channel-posts-heading__create { display: inline-flex; align-items: center; gap: .35rem; } + +@media (max-width: 600px) { + .modal-content.create-channel-modal { + width: calc(100% - 1rem); + max-height: calc(100% - 1rem); + padding: 1rem; + } + + .create-channel-form { + width: 100%; + min-width: 0; + padding: 0; + overflow: visible; + grid-template-columns: 1fr; + + &__heading, + &__title, + &__thumbnail, + &__field, + &__description, + &__submit { grid-column: 1; } + + &__thumbnail { grid-row: auto; } + &__thumbnail input[type=file] { max-width: 100%; } + &__field .config-style-select { max-width: 100%; } + &__description { box-sizing: border-box; } + &__submit { width: 100%; } + } + + .channel-thumbnail-preview { + width: 8rem; + height: 8rem; + } + + .modal-content.create-channel-post-modal { + width: calc(100% - 1rem); max-height: calc(100% - 1rem); padding: 1rem; + } + .create-channel-post-form { + width: 100%; min-width: 0; padding: 0; grid-template-columns: minmax(0, 1fr); + &__heading, + &__title, + &__thumbnail, + &__attachments, + &__description, + &__submit { grid-column: 1; } + &__thumbnail { grid-row: auto; } + &__attachments { align-items: stretch; box-sizing: border-box; } + &__submit { width: 100%; } + } + .channel-post-thumbnail-preview { width: 8rem; height: 8rem; } + .channel-posts-heading__create span { display: none; } + .channel-posts-heading__create { min-width: 2.25rem; justify-content: center; padding-inline: .55rem; } +} + +/* Keep image and generated-thumbnail cards the same size instead of stretching + * a sparse grid row to the full height of the Posts panel. */ +.posts-container { align-content: start; grid-auto-rows: 240px; } +.posts-container-card { + position: relative; + display: flex; + align-self: start; + height: 240px; + min-height: 0; + overflow: hidden; +} +.posts-container-card > img, +.posts-container-card > .channel-post__placeholder { min-height: 0; } + +.channel-post-comment-badge { + position: absolute; + z-index: 2; + top: 0.5rem; + right: 0.5rem; + display: inline-flex; + align-items: center; + gap: 0.3rem; + min-width: 1.75rem; + min-height: 1.75rem; + padding: 0.25rem 0.45rem; + box-sizing: border-box; + color: #0f172a; + background: rgba(255, 255, 255, 0.94); + border: 1px solid rgba(148, 163, 184, 0.7); + border-radius: 999px; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.22); + font-size: 0.75rem; + font-weight: 700; + pointer-events: none; +} + +.channel-post-comment-badge i { + color: #3ba4d7; +} + +@media (max-width: 700px) { + .posts-container { grid-auto-rows: 210px; gap: 1rem; } + .posts-container-card { height: 210px; } +} + +/* Channel attachment table -> compact cards on phones. */ +@media (max-width: 700px) { + .file-section { margin-top: 1.25rem; } + + table.channel-files, + table.channel-files tbody, + table.channel-files tr, + table.channel-files td { + display: block; + width: 100% !important; + box-sizing: border-box; + } + + table.channel-files { padding: 0; table-layout: auto; } + table.channel-files thead { display: none; } + + table.channel-files tr { + margin: 0 0 .7rem; + padding: .7rem; + border: 1px solid #dbe3ef; + border-radius: 8px; + background: #fff; + } + + table.channel-files td { + min-width: 0; + padding: .25rem 0; + border: 0; + text-align: left; + } + + table.channel-files td::before { + display: block; + margin-bottom: .1rem; + color: #64748b; + content: attr(data-label); + font-size: .72rem; + font-weight: 700; + text-transform: uppercase; + } + + table.channel-files .channel-file__name { + overflow-wrap: anywhere; + color: #0f172a; + font-weight: 600; + line-height: 1.35; + } + + table.channel-files .channel-file__size { color: #475569; } + table.channel-files .channel-file__action { padding-top: .5rem; } + table.channel-files .channel-file__action > button { min-width: 116px; } + table.channel-files .channel-file__action .file-view { margin-top: .6rem; } +} /* #options{ width: 100px; text-align: center; @@ -92,3 +503,74 @@ table { margin-left: 10px; height: 40px; } + + +.channel-detail-navigation { display: flex; align-items: center; } +.channel-mobile-search, .channel-mobile-actions, .channel-mobile-create { display: none; } + +@media (max-width: 700px) { + .tab-content:has(.channels-detail-widget) { position: relative; } + .widget.channels-detail-widget { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 36px auto minmax(0, 1fr); + row-gap: 4px; + > .top-heading { display: none; } + > .widget__body { min-height: 0; } + .channel-subscription--subscribed, + .posts > .channel-posts-heading { display: none; } + .posts { margin-top: 9px; min-height: 0; } + .posts-container { padding: .5rem; } + } + .channel-detail-navigation { justify-content: space-between; gap: .5rem; } + a.channel-back[title='Back'] { + display: inline-flex; align-items: center; justify-content: center; + width: 36px; height: 36px; padding: 0; flex-shrink: 0; + border-radius: 50%; background: #e8f4fc; color: #0788cb; font-size: 1.15rem; + } + a.channel-back[title='Back']:hover { background: #d5ebfa; } + .channel-back .fa-arrow-left::before { content: "\f053"; } + .channel-mobile-search { + display: flex; align-items: center; justify-content: flex-end; + gap: .5rem; min-width: 0; flex: 1; + input { min-width: 0; max-width: 100%; } + } + .channel-mobile-create { + display: inline-flex; align-items: center; justify-content: center; + width: 32px; height: 32px; padding: 0; font-size: .85rem; + } + .channel-mobile-actions { + display: block; position: absolute; top: 0; right: .5rem; z-index: 1001; + summary { + display: flex; align-items: center; justify-content: center; + width: 44px; height: 44px; cursor: pointer; list-style: none; + } + summary::-webkit-details-marker { display: none; } + summary:focus-visible { outline: 2px solid #0788cb; } + &__items { + position: absolute; right: 0; top: 100%; min-width: 10rem; + padding: .35rem; background: #fff; border: 1px solid #cbd5e1; + border-radius: .375rem; box-shadow: 0 4px 12px rgba(15, 23, 42, .15); + } + &__items button { + width: 100%; min-height: 44px; text-align: left; + background: #fff; color: #334155; box-shadow: none; + } + &__items button:hover { background: #f1f5f9; } + } +} + + +.channels-heading-create { display: none; } +@media (max-width: 700px) { + .channels-create-button { display: none; } + .channels-heading-create { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + font-size: .85rem; + } +} diff --git a/webui-src/app/scss/pages/_chat.scss b/webui-src/app/scss/pages/_chat.scss index 6ac65bd..d079570 100644 --- a/webui-src/app/scss/pages/_chat.scss +++ b/webui-src/app/scss/pages/_chat.scss @@ -1,1617 +1,2392 @@ -@use '../abstracts' as *; - -.lobby { - margin: 10px; - border: 1px solid #aaa; - border-radius: 20px; -} - -.lobby .mainname { - margin: 20px; - font-weight: 100; - font-size: 1.2em; -} - -.topic { - color: #666; -} - -.lobby>.topic { - font-size: 0.95em; - margin-left: 25px; - margin-bottom: 5px; -} - -.lefttitle { - margin-top: 15px; - margin-bottom: 0; - font-weight: 100; - font-size: 1.2em; -} - -.leftname { - margin-top: 5px; - margin-bottom: 5px; - padding: 5px; - font-weight: 100; - font-size: 1em; -} - -.leftlobby>.topic { - font-size: 0.75em; - margin-left: 15px; - margin-bottom: 5px; -} - -.subscribed, -.public { - cursor: pointer; -} - -.leftlobby { - border: 1px solid #aaa; - border-radius: 10px; - margin-top: 5px; - background-color: white; -} - -.leftlobby.selected-lobby, -.selectedidentity { - color: white; - background-color: #3ba4d7; -} - -.rightbar { - position: absolute; - width: 185px; - background-color: white; - overflow: auto; - top: 130px; - bottom: 15px; - right: 15px; -} - -.user { - padding: 5px; -} - -.lobbyName { - padding: 15px; - margin-top: 2rem; -} - -.lobbies { - position: absolute; - width: 185px; - left: 165px; - bottom: 15px; - top: 130px; - overflow: auto; -} - -.messages, -.setup { - position: absolute; - background-color: white; - top: 130px; - left: 360px; - right: 215px; - overflow: auto; -} - -.messages { - bottom: 115px; -} - -.messagetext { - white-space: break-spaces; - margin-right: 5px; -} - -.message>* { - margin-left: 5px; -} - -.username { - color: darkgreen; - font-weight: bolder; -} - -.chatMessage { - position: absolute; - background-color: white; - height: 85px; - bottom: 15px; - right: 215px; - left: 360px; -} - -textarea.chatMsg { - height: 100%; - width: 100%; -} - -.chatatchar { - margin-left: 0.2em; - margin-right: 0.2em; - color: silver; -} - -.setupicon { - margin-left: 1em; - cursor: pointer; -} - -.leaveicon { - margin-left: 1em; - cursor: pointer; - color: #d40000; -} - -.selectidentity { - margin: 15px; - font-size: 1.2em; -} - -.setup>.identity { - cursor: pointer; -} - -.setup { - bottom: 15px; -} - -.createDistantChat { - margin-top: 1em; -} - -.no-lobbies { - - .messages, - .chatMessage, - .setup { - left: 165px; - } -} - -/* CHAT ROOM (Single Chat) - Desktop Grid Layout */ -@media (min-width: 900px) { - .node-panel.chat-room { - display: grid !important; - grid-template-columns: 250px 1fr 200px !important; - /* Lobbies, Chat, Users */ - grid-template-rows: auto 1fr auto !important; - /* Header, Messages, Input */ - grid-template-areas: - "lobbies header rightbar" - "lobbies messages rightbar" - "lobbies input rightbar" !important; - padding: 0 !important; - height: 100% !important; - } - - .node-panel.chat-room .lobbyName { - grid-area: header; - padding: 10px; - border-bottom: 1px solid #eee; - margin: 0; - z-index: 10; - background: white; - } - - .node-panel.chat-room .lobbies { - grid-area: lobbies; - position: static !important; - width: auto !important; - height: auto !important; - border-right: 1px solid #ccc; - overflow-y: auto; - display: block !important; - top: auto !important; - bottom: auto !important; - left: auto !important; - } - - .node-panel.chat-room .messages { - grid-area: messages; - position: static !important; - width: auto !important; - height: auto !important; - overflow-y: auto; - padding: 10px; - left: auto !important; - right: auto !important; - top: auto !important; - bottom: auto !important; - margin: 0 !important; - } - - .node-panel.chat-room .rightbar { - grid-area: rightbar; - position: static !important; - width: auto !important; - border-left: 1px solid #ccc; - overflow-y: auto; - display: block !important; - } - - .node-panel.chat-room .chatMessage { - grid-area: input; - position: static !important; - width: auto !important; - height: auto !important; - border-top: 1px solid #eee; - left: auto !important; - right: auto !important; - bottom: auto !important; - flex: 0 0 auto; - padding: 10px !important; - background: white; - z-index: 10; - } -} - -/* Mobile Overrides - Ensure Flex Column */ -@media (max-width: 899px) { - .node-panel.chat-room { - display: flex !important; - flex-direction: column !important; - height: 100% !important; - position: relative !important; - } - - .node-panel.chat-room .lobbyName { - flex: 0 0 auto; - } - - .node-panel.chat-room .messages { - flex: 1 !important; - overflow-y: auto !important; - position: relative !important; - top: 0 !important; - bottom: 0 !important; - left: 0 !important; - right: 0 !important; - width: 100% !important; - height: auto !important; - margin: 0 !important; - } - - .node-panel.chat-room .chatMessage { - flex: 0 0 auto !important; - position: relative !important; - bottom: 0 !important; - left: 0 !important; - right: 0 !important; - width: 100% !important; - height: auto !important; - z-index: 100; - } - - .node-panel.chat-room .rightbar, - .node-panel.chat-room .lobbies { - display: none !important; - position: fixed !important; - top: 60px !important; - bottom: 0 !important; - width: 80% !important; - background: white !important; - z-index: 200 !important; - box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2) !important; - } - - .node-panel.chat-room.show-lobbies .lobbies { - display: block !important; - left: 0 !important; - } - - .node-panel.chat-room.show-users .rightbar { - display: block !important; - right: 0 !important; - } - - .chat-overlay { - display: none; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.4); - z-index: 150; - } - - .show-lobbies .chat-overlay, - .show-users .chat-overlay { - display: block; - } - - /* Mobile Icons in Header */ - .mobile-menu-icons { - display: flex; - gap: 15px; - font-size: 1.2rem; - } - - .mobile-menu-icons i { - cursor: pointer; - padding: 5px; - } -} - -@media (min-width: 900px) { - .mobile-menu-icons { - display: none; - } -} - -/* ===================================================== - CHAT HUB - Two-Pane Layout (matching Network page) - ===================================================== */ - -.chat-hub-container { - display: flex; - height: 100%; - width: 100%; - overflow: hidden; - background-color: #f1f5f9; -} - -.chat-hub-left-pane { - width: 320px; - min-width: 300px; - max-width: 350px; - border-right: 1px solid #cbd5e1; - display: flex; - flex-direction: column; - background: #ffffff; - box-shadow: 2px 0 5px rgba(0, 0, 0, 0.05); -} - -.chat-own-profile-card { - padding: 1.25rem; - border-bottom: 1px solid #e2e8f0; - background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); - position: relative; // anchors the absolutely-positioned .chat-create-lobby-btn - - .profile-header { - display: flex; - align-items: center; - gap: 1rem; - } - - .profile-info { - display: flex; - flex-direction: column; - flex: 1; - overflow: hidden; - - .profile-name { - font-weight: 700; - color: #1e293b; - font-size: 1.1rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .profile-status { - font-size: 0.85rem; - color: #10b981; - font-weight: 500; - display: flex; - align-items: center; - gap: 0.35rem; - - &::before { - content: ''; - display: inline-block; - width: 8px; - height: 8px; - background-color: #10b981; - border-radius: 50%; - } - } - } -} - -.chat-rooms-list-container { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - - .searchbar-container { - padding: 0.75rem 1rem; - border-bottom: 1px solid #e2e8f0; - - input.searchbar { - width: 100%; - padding: 0.5rem 0.75rem; - font-size: 0.9rem; - border: 1px solid #cbd5e1; - border-radius: 0.375rem; - background-color: #f8fafc; - outline: none; - transition: all 0.2s; - - &:focus { - background-color: #ffffff; - border-color: #3ba4d7; - box-shadow: 0 0 0 3px rgba(59, 164, 215, 0.15); - } - } - } - - .rooms-scroll { - flex: 1; - overflow-y: auto; - padding: 0.5rem 0; - } -} - -.rooms-section-title { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1rem 0.375rem; - font-size: 0.75rem; - font-weight: 700; - color: #64748b; - text-transform: uppercase; - letter-spacing: 0.05em; - - i { - font-size: 0.7rem; - color: #94a3b8; - } -} - -.chat-room-list-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem; - margin: 0.125rem 0.5rem; - border-radius: 0.5rem; - cursor: pointer; - transition: all 0.2s; - - &:hover { - background-color: #f1f5f9; - } - - &.selected { - background-color: #e0f2fe; - - .room-name { - color: #0369a1; - font-weight: 600; - } - } - - .room-icon { - flex-shrink: 0; - width: 36px; - height: 36px; - border-radius: 0.5rem; - background: linear-gradient(135deg, #3ba4d7, #0ea5e9); - display: flex; - align-items: center; - justify-content: center; - color: #ffffff; - font-size: 1.35rem; - } - - &.public-room .room-icon { - background: linear-gradient(135deg, #10b981, #059669); - } - - .room-meta { - flex: 1; - min-width: 0; - - .room-name { - font-size: 0.95rem; - color: #334155; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - transition: color 0.2s; - } - - .room-topic { - font-size: 0.8rem; - color: #94a3b8; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .room-badge { - flex-shrink: 0; - min-width: 24px; - height: 24px; - border-radius: 12px; - background-color: #e2e8f0; - color: #475569; - font-size: 0.75rem; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; - padding: 0 0.375rem; - } -} - -.chat-hub-right-pane { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - background-color: #f8fafc; -} - -.chat-pane-placeholder { - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - color: #94a3b8; - gap: 1rem; - padding: 2rem; - text-align: center; - - i { - font-size: 4rem; - color: #cbd5e1; - } - - p { - font-size: 1.1rem; - max-width: 400px; - } -} - -.chat-hub-tab-content { - flex: 1; - overflow-y: auto; - padding: 1.5rem; -} - -.chat-room-detail-view { - display: flex; - flex-direction: column; - gap: 1.5rem; - - .detail-header { - display: flex; - align-items: flex-start; - gap: 1.5rem; - padding-bottom: 1.5rem; - border-bottom: 1px solid #e2e8f0; - flex-wrap: wrap; - - .detail-title { - flex: 1; - min-width: 200px; - - h2 { - font-size: 1.75rem; - font-weight: 800; - color: #1e293b; - margin-bottom: 0.25rem; - } - - .detail-subtitle { - font-size: 0.9rem; - color: #64748b; - display: flex; - align-items: center; - gap: 0.5rem; - } - } - - .detail-actions { - display: flex; - gap: 0.75rem; - flex-wrap: wrap; - - button { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - font-size: 0.9rem; - } - } - } - - .detail-section { - background-color: #ffffff; - border-radius: 0.5rem; - border: 1px solid #e2e8f0; - padding: 1.25rem; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); - - h3 { - font-size: 1.1rem; - font-weight: 700; - color: #334155; - margin-bottom: 1rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid #f1f5f9; - } - - .info-grid { - display: grid; - grid-template-columns: 130px 1fr; - row-gap: 0.75rem; - font-size: 0.9rem; - - .info-label { - font-weight: 600; - color: #64748b; - } - - .info-value { - color: #1e293b; - word-break: break-all; - } - } - } -} - -.participants-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - gap: 0.5rem; -} - -.participant-card { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - background-color: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 0.375rem; - - .participant-name { - font-size: 0.875rem; - color: #334155; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } -} - -.no-participants { - color: #94a3b8; - font-size: 0.9rem; - font-style: italic; -} - -.detail-actions-footer { - display: flex; - gap: 0.75rem; - - button { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - font-size: 0.9rem; - } -} - -.join-description { - color: #64748b; - font-size: 0.9rem; - margin-bottom: 1rem; -} - -.identities-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 0.75rem; -} - -.identity-card { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.75rem 1rem; - background-color: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 0.5rem; - cursor: pointer; - transition: all 0.2s; - - &:hover { - background-color: #e0f2fe; - border-color: #3ba4d7; - } - - .identity-name { - font-size: 0.95rem; - font-weight: 600; - color: #334155; - } - - i { - color: #3ba4d7; - font-size: 0.9rem; - } -} - -.no-rooms { - padding: 1rem; - color: #94a3b8; - text-align: center; - font-style: italic; -} - -/* Chat Hub Responsive - Mobile */ -@media (max-width: 899px) { - .chat-hub-container { - flex-direction: column; - } - - .chat-hub-left-pane { - width: 100%; - min-width: 0; - max-width: none; - max-height: 45%; - border-right: none; - border-bottom: 1px solid #cbd5e1; - } - - .chat-hub-right-pane { - flex: 1; - min-height: 0; - } -} - -/* ===================================================== - CHAT HUB - Right Pane Conversation & Tabs Styling - ===================================================== */ - -.chat-hub-header-bar { - padding: 0.75rem 1.5rem; - background-color: #ffffff; - border-bottom: 1px solid #e2e8f0; - display: flex; - align-items: center; - justify-content: space-between; - height: 65px; - flex-shrink: 0; -} - -.chat-hub-header-bar .chat-header-info { - display: flex; - flex-direction: column; - overflow: hidden; -} - -.chat-hub-header-bar .chat-header-info .chat-header-name { - font-size: 1.15rem; - font-weight: 800; - color: #1e293b; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.chat-hub-header-bar .chat-header-info .chat-header-topic { - font-size: 0.85rem; - color: #64748b; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-top: 0.125rem; -} - -.chat-hub-header-bar .chat-header-actions { - display: flex; - gap: 0.5rem; -} - -.chat-hub-header-bar .chat-header-actions button { - display: flex; - align-items: center; - gap: 0.35rem; - padding: 0.375rem 0.75rem; - font-size: 0.85rem; -} - -.chat-hub-tabs-container { - background-color: #ffffff; - border-bottom: 1px solid #cbd5e1; - padding: 0.5rem 1.5rem 0; -} - -.chat-hub-tabs { - display: flex; - gap: 0.5rem; -} - -.chat-hub-tabs .tab-btn { - padding: 0.625rem 1.25rem; - font-size: 0.95rem; - font-weight: 600; - color: #64748b; - background: transparent; - border: none; - border-radius: 0.375rem 0.375rem 0 0; - border-bottom: 3px solid transparent; - cursor: pointer; - box-shadow: none; - transition: all 0.2s; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.chat-hub-tabs .tab-btn:hover { - color: #334155; - background-color: #f1f5f9; -} - -.chat-hub-tabs .tab-btn.active { - color: #3ba4d7; - border-bottom-color: #3ba4d7; - background-color: transparent; -} - -.chat-hub-tab-content { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - background-color: #f8fafc; -} - -.chat-hub-conversation-layout { - display: flex; - flex-direction: row; - height: 100%; - width: 100%; - overflow: hidden; -} - -.chat-hub-conversation-main { - display: flex; - flex-direction: column; - flex: 1; - height: 100%; - overflow: hidden; -} - -.chat-hub-rightbar { - width: 200px; - border-left: 1px solid #cbd5e1; - background-color: #ffffff; - display: flex; - flex-direction: column; - flex-shrink: 0; - position: relative; // anchors the hovered-participant .user-tooltip (top offset is measured against this) -} - -.chat-hub-rightbar .rightbar-title { - padding: 0.75rem 1rem; - font-size: 0.85rem; - font-weight: 700; - color: #64748b; - text-transform: uppercase; - letter-spacing: 0.05em; - border-bottom: 1px solid #e2e8f0; -} - -.chat-hub-rightbar .rightbar-users-list { - flex: 1; - overflow-y: auto; - padding: 0.5rem; -} - -.chat-hub-rightbar .user { - padding: 0.5rem 0.75rem; - font-size: 0.9rem; - color: #334155; - border-radius: 0.375rem; - transition: all 0.2s; - display: flex; - align-items: center; - gap: 0.5rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - position: relative; -} - -.chat-hub-rightbar .user:hover { - background-color: #f1f5f9; - color: #0f172a; -} - -.chat-hub-rightbar .user .defaultAvatar { - width: 2rem; - height: 2rem; - font-size: 0.9rem; - flex-shrink: 0; -} - -.chat-hub-rightbar .user img.avatar { - width: 2rem; - height: 2rem; - flex-shrink: 0; -} - -@media (max-width: 899px) { - .chat-hub-rightbar { - display: none; - } -} - -.chat-hub-messages { - flex: 1; - overflow-y: auto; - padding: 1.25rem 1.5rem; - display: flex; - flex-direction: column; - gap: 1rem; -} - -/* Chat bubble overrides for two-pane layout */ -.chat-hub-messages .message { - display: flex; - flex-direction: column; - max-width: 70%; - padding: 0.625rem 0.875rem; - border-radius: 0.75rem; - font-size: 0.925rem; - line-height: 1.4; - word-break: break-word; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); -} - -.chat-hub-messages .message.incoming { - align-self: flex-start; - align-items: flex-start; - background-color: #ffffff; - color: #1e293b; - border: 1px solid #e2e8f0; - border-bottom-left-radius: 0.125rem; -} - -.chat-hub-messages .message.outgoing { - align-self: flex-end; - align-items: flex-end; - background-color: #3ba4d7; - color: #ffffff; - border-bottom-right-radius: 0.125rem; -} - -.chat-hub-messages .message .username { - font-size: 0.75rem; - margin-bottom: 0.25rem; - padding: 0 0.125rem; - font-weight: 700; -} - -.chat-hub-messages .message.incoming .username { - color: #0369a1; -} - -.chat-hub-messages .message.outgoing .username { - color: #e0f2fe; -} - -.chat-hub-messages .message .messagetext { - white-space: break-spaces; - margin: 0; -} - -.chat-hub-messages .message .datetime { - font-size: 0.7rem; - margin-top: 0.25rem; - padding: 0 0.125rem; - opacity: 0.8; -} - -.chat-hub-messages .message.incoming .datetime { - color: #64748b; -} - -.chat-hub-messages .message.outgoing .datetime { - color: #f1f5f9; -} - -.chat-hub-input-area { - padding: 0.75rem 1.5rem; - background-color: #ffffff; - border-top: 1px solid #cbd5e1; - display: flex; - gap: 0.75rem; - align-items: flex-end; - flex-shrink: 0; -} - -.chat-hub-input-area textarea.chat-hub-textarea { - flex: 1; - resize: vertical; - min-height: 40px; - max-height: 250px; - height: 40px; - padding: 0.5rem 0.75rem; - border: 1px solid #cbd5e1; - border-radius: 0.375rem; - font-size: 0.9rem; - outline: none; - transition: border-color 0.2s, box-shadow 0.2s; - background-color: #f8fafc; -} - -.chat-hub-input-area textarea.chat-hub-textarea:focus { - background-color: #ffffff; - border-color: #3ba4d7; - box-shadow: 0 0 0 3px rgba(59, 164, 215, 0.15); -} - -.chat-hub-input-area button.chat-hub-send-btn { - padding: 0.5rem 1.25rem; - font-size: 0.9rem; - height: 40px; - display: flex; - align-items: center; - gap: 0.5rem; - border-radius: 0.375rem; -} - -/* Compact Room Chat Style (No bubbles, unique nickname colors, IRC-style) */ -.chat-hub-messages.compact-container, -.messages.compact-container { - gap: 0 !important; - padding: 0.75rem 1rem !important; - background-color: #ffffff !important; - display: flex !important; - flex-direction: column !important; - - .message.compact { - display: block !important; - max-width: 100% !important; - padding: 0.1rem 0 !important; - border-radius: 0 !important; - background-color: transparent !important; - border: none !important; - box-shadow: none !important; - align-self: flex-start !important; - font-size: 0.875rem !important; - line-height: 1.45 !important; - margin: 0 !important; - white-space: nowrap !important; - // overflow: hidden !important; - // text-overflow: ellipsis !important; - - &:hover { - background-color: #f8fafc !important; - overflow: visible !important; - white-space: normal !important; - } - - .datetime { - color: #a0a0a0 !important; - margin-right: 0.4rem !important; - font-size: 0.78rem !important; - font-family: monospace !important; - opacity: 1 !important; - display: inline !important; - } - - .username { - font-weight: bold !important; - margin-right: 0.2rem !important; - font-size: 0.875rem !important; - display: inline !important; - } - - .messagetext { - color: #1e293b !important; - white-space: normal !important; - word-break: break-word !important; - display: inline !important; - margin: 0 !important; - } - } -} - - - - - -// Chat-hub extras: user tooltip, right-bar context menu, attach-file modal, -// emoji picker, create-lobby button (chat.js). Recovered from compiled styles.css. - -.chat-create-lobby-btn { - position: absolute; - bottom: 0.5rem; - right: 1.25rem; - background-color: #0084ff; - color: #ffffff; - border: none; - border-radius: 0.375rem; - padding: 0.35rem 0.75rem; - font-size: 0.85rem; - font-weight: 600; - cursor: pointer; - box-shadow: 0 4px 6px -1px rgba(0, 132, 255, 0.2), 0 2px 4px -1px rgba(0, 132, 255, 0.1); - transition: background-color 0.2s, transform 0.2s; - display: flex; - align-items: center; - gap: 0.25rem; -} - -.chat-create-lobby-btn:hover { - background-color: #0073e6; - transform: translateY(-1px); -} - -.chat-create-lobby-btn:active { - transform: translateY(0); -} - -.chat-hub-rightbar .user .user-name { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex: 1; -} - -.user-tooltip { - position: absolute; - width: 260px; - background-color: #ffffe1; - border: 1px solid #7f7f7f; - box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.25); - padding: 0.5rem; - border-radius: 0.25rem; - z-index: 10000; - white-space: normal; - display: flex; - gap: 0.5rem; - align-items: flex-start; -} - -.chat-hub-rightbar .user-tooltip { - left: -275px; - transform: translateY(-50%); - z-index: 1000; -} - -.user-tooltip .tooltip-avatar { - flex-shrink: 0; -} - -.user-tooltip .tooltip-details { - display: flex; - flex-direction: column; - gap: 0.25rem; - font-size: 0.8rem; - color: #000000; - text-align: left; -} - -.user-tooltip .tooltip-row { - line-height: 1.2; -} - -.user-tooltip .tooltip-label { - font-weight: bold; -} - -.user-tooltip .tooltip-value { - font-weight: normal; - word-break: break-all; -} - -.user-tooltip .tooltip-value.tooltip-id { - font-family: monospace; -} - -.chat-hub-rightbar .rightbar-context-menu { - position: absolute; - right: 1rem; - width: 210px; - background-color: #ffffff; - border: 1px solid #e2e8f0; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); - border-radius: 0.375rem; - z-index: 1010; - padding: 0.25rem 0; - display: flex; - flex-direction: column; -} - -.chat-hub-rightbar .rightbar-context-menu .menu-item { - padding: 0.5rem 1rem; - font-size: 0.85rem; - color: #334155; - cursor: pointer; - display: flex; - align-items: center; - transition: background-color 0.2s; -} - -.chat-hub-rightbar .rightbar-context-menu .menu-item:hover { - background-color: #f1f5f9; - color: #0f172a; -} - -.chat-emoji { - font-size: 1.45em; - line-height: 1; - vertical-align: -0.15em; - display: inline-block; -} - -.chat-hub-attach-btn, -.chat-hub-action-btn { - background-color: transparent !important; - border: none !important; - font-size: 1.15rem !important; - color: #64748b !important; - cursor: pointer !important; - padding: 0.4rem 0.5rem !important; - border-radius: 0.375rem !important; - flex-shrink: 0 !important; - display: inline-flex !important; - align-items: center !important; - justify-content: center !important; - transition: all 0.2s !important; - box-shadow: none !important; - margin: 0 !important; - line-height: 1 !important; - height: 36px !important; - width: 36px !important; -} - -.chat-hub-attach-btn:hover, -.chat-hub-action-btn:hover { - background-color: #f1f5f9 !important; - color: #3b82f6 !important; - transform: none !important; -} - -.attach-modal-overlay { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background-color: rgba(15, 23, 42, 0.4); - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - z-index: 2000; -} - -.attach-modal { - background-color: #ffffff; - border-radius: 0.5rem; - width: 450px; - max-width: 90%; - padding: 1.5rem; - box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); - display: flex; - flex-direction: column; - gap: 1rem; -} - -.attach-modal .attach-modal-header { - display: flex; - align-items: center; - gap: 0.6rem; - margin-bottom: 0.25rem; -} - -.attach-modal .attach-modal-icon { - font-size: 1.2rem; - color: #3b82f6; -} - -.attach-modal h4 { - margin: 0; - font-size: 1.2rem; - color: #0f172a; -} - -.attach-modal p { - margin: 0; - font-size: 0.9rem; - color: #475569; -} - -.attach-modal .attach-path-row { - display: flex; - gap: 0.5rem; - align-items: center; -} - -.attach-modal .attach-path-row input[type="text"] { - flex: 1; - padding: 0.75rem; - border: 1px solid #cbd5e1; - border-radius: 0.375rem; - font-size: 0.9rem; - outline: none; - transition: border-color 0.2s; - min-width: 0; -} - -.attach-modal .attach-path-row input[type="text"]:focus { - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); -} - -.attach-browse-btn { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 0.35rem; - padding: 0.625rem 0.9rem; - font-size: 0.875rem; - background-color: #f1f5f9; - color: #334155; - border: 1px solid #cbd5e1; - border-radius: 0.375rem; - cursor: pointer; - box-shadow: none; - transition: background-color 0.2s, border-color 0.2s; - white-space: nowrap; -} - -.attach-browse-btn:hover { - background-color: #e2e8f0; - border-color: #94a3b8; -} - -.attach-path-hint { - display: flex; - align-items: flex-start; - gap: 0.5rem; - padding: 0.6rem 0.75rem; - background-color: #fffbeb; - border: 1px solid #fcd34d; - border-left: 3px solid #f59e0b; - border-radius: 0.375rem; - font-size: 0.825rem; - color: #92400e; - line-height: 1.45; -} - -.attach-path-hint i { - color: #f59e0b; - margin-top: 0.1rem; - flex-shrink: 0; -} - -.attach-path-hint code { - font-family: monospace; - background-color: rgba(245, 158, 11, 0.15); - padding: 0.05rem 0.25rem; - border-radius: 0.2rem; -} - -.attach-modal .hashing-spinner { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.9rem; - color: #3b82f6; -} - -.attach-modal .error-text { - color: #ef4444; - font-size: 0.85rem; - margin: 0; -} - -.attach-modal .modal-buttons { - display: flex; - justify-content: flex-end; - gap: 0.75rem; - margin-top: 0.5rem; -} - -.attach-modal .modal-buttons button { - padding: 0.5rem 1rem; - font-size: 0.9rem; - border-radius: 0.25rem; - border: none; - cursor: pointer; - transition: opacity 0.2s; -} - -.attach-modal .modal-buttons button:hover { - opacity: 0.9; -} - -.chat-hub-emoji-btn { - background-color: transparent; - border: none; - font-size: 1.3rem; - cursor: pointer; - padding: 0.35rem 0.4rem; - margin-right: 0.25rem; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - border-radius: 0.375rem; - line-height: 1; - transition: background-color 0.15s, transform 0.15s; - box-shadow: none; -} - -.chat-hub-emoji-btn:hover { - background-color: #f1f5f9; - transform: scale(1.1); -} - -.emoji-picker-wrapper { - position: relative; - flex-shrink: 0; - display: flex; - align-items: center; -} - -.emoji-picker { - position: absolute; - bottom: calc(100% + 0.5rem); - left: 0; - width: 320px; - background-color: #ffffff; - border: 1px solid #e2e8f0; - border-radius: 0.625rem; - box-shadow: 0 8px 30px -4px rgba(0, 0, 0, 0.18), 0 4px 12px -2px rgba(0, 0, 0, 0.1); - z-index: 3000; - display: flex; - flex-direction: column; - overflow: hidden; - animation: emoji-pop 0.15s ease-out; -} - -.emoji-search-row { - display: flex; - align-items: center; - gap: 0.4rem; - padding: 0.6rem 0.75rem 0.4rem; - border-bottom: 1px solid #f1f5f9; -} - -.emoji-search-icon { - color: #94a3b8; - font-size: 0.8rem; - flex-shrink: 0; -} - -.emoji-search-input { - flex: 1; - border: 1px solid #e2e8f0; - border-radius: 0.375rem; - padding: 0.3rem 0.5rem; - font-size: 0.85rem; - outline: none; - background-color: #f8fafc; - transition: border-color 0.15s; -} - -.emoji-search-input:focus { - border-color: #3ba4d7; - background-color: #fff; -} - -.emoji-search-clear { - background: none; - border: none; - cursor: pointer; - color: #94a3b8; - padding: 0.2rem; - font-size: 0.8rem; - box-shadow: none; - display: flex; - align-items: center; -} - -.emoji-search-clear:hover { - color: #475569; -} - -.emoji-categories { - display: flex; - gap: 0.1rem; - padding: 0.35rem 0.5rem; - border-bottom: 1px solid #f1f5f9; - overflow-x: auto; - scrollbar-width: none; -} - -.emoji-categories::-webkit-scrollbar { - display: none; -} - -.emoji-cat-btn { - background: none; - border: none; - cursor: pointer; - font-size: 1.2rem; - padding: 0.3rem 0.35rem; - border-radius: 0.375rem; - line-height: 1; - box-shadow: none; - transition: background-color 0.1s; - flex-shrink: 0; -} - -.emoji-cat-btn:hover { - background-color: #f1f5f9; -} - -.emoji-cat-btn.active { - background-color: #e0f2fe; - box-shadow: inset 0 -2px 0 #3ba4d7; -} - -.emoji-grid { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 0; - padding: 0.4rem 0.35rem; - max-height: 220px; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: #cbd5e1 transparent; -} - -.emoji-grid::-webkit-scrollbar { - width: 4px; -} - -.emoji-grid::-webkit-scrollbar-track { - background: transparent; -} - -.emoji-grid::-webkit-scrollbar-thumb { - background-color: #cbd5e1; - border-radius: 4px; -} - -.emoji-btn { - background: none; - border: none; - cursor: pointer; - font-size: 1.7rem; - padding: 0.25rem; - border-radius: 0.3rem; - line-height: 1; - box-shadow: none; - text-align: center; - transition: background-color 0.1s, transform 0.1s; - display: flex; - align-items: center; - justify-content: center; - aspect-ratio: 1; -} - -.emoji-btn:hover { - background-color: #f1f5f9; - transform: scale(1.2); -} - -@keyframes emoji-pop { - from { opacity: 0; transform: scale(0.92) translateY(6px); } - to { opacity: 1; transform: scale(1) translateY(0); } -} +@use '../abstracts' as *; + +.lobby { + margin: 10px; + border: 1px solid #aaa; + border-radius: 20px; +} + +.lobby .mainname { + margin: 20px; + font-weight: 100; + font-size: 1.2em; +} + +.topic { + color: #666; +} + +.lobby>.topic { + font-size: 0.95em; + margin-left: 25px; + margin-bottom: 5px; +} + +.lefttitle { + margin-top: 15px; + margin-bottom: 0; + font-weight: 100; + font-size: 1.2em; +} + +.leftname { + margin-top: 5px; + margin-bottom: 5px; + padding: 5px; + font-weight: 100; + font-size: 1em; +} + +.leftlobby>.topic { + font-size: 0.75em; + margin-left: 15px; + margin-bottom: 5px; +} + +.subscribed, +.public { + cursor: pointer; +} + +.leftlobby { + border: 1px solid #aaa; + border-radius: 10px; + margin-top: 5px; + background-color: white; +} + +.leftlobby.selected-lobby, +.selectedidentity { + color: white; + background-color: #3ba4d7; +} + +.rightbar { + position: absolute; + width: 185px; + background-color: white; + overflow: auto; + top: 130px; + bottom: 15px; + right: 15px; +} + +.user { + padding: 5px; +} + +.lobbyName { + padding: 15px; + margin-top: 2rem; +} + +.lobbies { + position: absolute; + width: 185px; + left: 165px; + bottom: 15px; + top: 130px; + overflow: auto; +} + +.messages, +.setup { + position: absolute; + background-color: white; + top: 130px; + left: 360px; + right: 215px; + overflow: auto; +} + +.messages { + bottom: 115px; +} + +.messagetext { + white-space: break-spaces; + margin-right: 5px; +} + +.message>* { + margin-left: 5px; +} + +.username { + color: darkgreen; + font-weight: bolder; +} + +.chatMessage { + position: absolute; + background-color: white; + height: 85px; + bottom: 15px; + right: 215px; + left: 360px; +} + +textarea.chatMsg { + height: 100%; + width: 100%; +} + +.chatatchar { + margin-left: 0.2em; + margin-right: 0.2em; + color: silver; +} + +.setupicon { + margin-left: 1em; + cursor: pointer; +} + +.leaveicon { + margin-left: 1em; + cursor: pointer; + color: #d40000; +} + +.selectidentity { + margin: 15px; + font-size: 1.2em; +} + +.setup>.identity { + cursor: pointer; +} + +.setup { + bottom: 15px; +} + +.createDistantChat { + margin-top: 1em; +} + +.no-lobbies { + + .messages, + .chatMessage, + .setup { + left: 165px; + } +} + +/* CHAT ROOM (Single Chat) - Desktop Grid Layout */ +@media (min-width: 900px) { + .node-panel.chat-room { + display: grid !important; + grid-template-columns: 250px 1fr 200px !important; + /* Lobbies, Chat, Users */ + grid-template-rows: auto 1fr auto !important; + /* Header, Messages, Input */ + grid-template-areas: + "lobbies header rightbar" + "lobbies messages rightbar" + "lobbies input rightbar" !important; + padding: 0 !important; + height: 100% !important; + } + + .node-panel.chat-room .lobbyName { + grid-area: header; + padding: 10px; + border-bottom: 1px solid #eee; + margin: 0; + z-index: 10; + background: white; + } + + .node-panel.chat-room .lobbies { + grid-area: lobbies; + position: static !important; + width: auto !important; + height: auto !important; + border-right: 1px solid #ccc; + overflow-y: auto; + display: block !important; + top: auto !important; + bottom: auto !important; + left: auto !important; + } + + .node-panel.chat-room .messages { + grid-area: messages; + position: static !important; + width: auto !important; + height: auto !important; + overflow-y: auto; + padding: 10px; + left: auto !important; + right: auto !important; + top: auto !important; + bottom: auto !important; + margin: 0 !important; + } + + .node-panel.chat-room .rightbar { + grid-area: rightbar; + position: static !important; + width: auto !important; + border-left: 1px solid #ccc; + overflow-y: auto; + display: block !important; + } + + .node-panel.chat-room .chatMessage { + grid-area: input; + position: static !important; + width: auto !important; + height: auto !important; + border-top: 1px solid #eee; + left: auto !important; + right: auto !important; + bottom: auto !important; + flex: 0 0 auto; + padding: 10px !important; + background: white; + z-index: 10; + } +} + +/* Mobile Overrides - Ensure Flex Column */ +@media (max-width: 899px) { + .node-panel.chat-room { + display: flex !important; + flex-direction: column !important; + height: 100% !important; + position: relative !important; + } + + .node-panel.chat-room .lobbyName { + flex: 0 0 auto; + } + + .node-panel.chat-room .messages { + flex: 1 !important; + overflow-y: auto !important; + position: relative !important; + top: 0 !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + width: 100% !important; + height: auto !important; + margin: 0 !important; + } + + .node-panel.chat-room .chatMessage { + flex: 0 0 auto !important; + position: relative !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + width: 100% !important; + height: auto !important; + z-index: 100; + } + + .node-panel.chat-room .rightbar, + .node-panel.chat-room .lobbies { + display: none !important; + position: fixed !important; + top: 60px !important; + bottom: 0 !important; + width: 80% !important; + background: white !important; + z-index: 200 !important; + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2) !important; + } + + .node-panel.chat-room.show-lobbies .lobbies { + display: block !important; + left: 0 !important; + } + + .node-panel.chat-room.show-users .rightbar { + display: block !important; + right: 0 !important; + } + + .chat-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 150; + } + + .show-lobbies .chat-overlay, + .show-users .chat-overlay { + display: block; + } + + /* Mobile Icons in Header */ + .mobile-menu-icons { + display: flex; + gap: 15px; + font-size: 1.2rem; + } + + .mobile-menu-icons i { + cursor: pointer; + padding: 5px; + } +} + +@media (max-width: 700px) { + .chat-hub-container { + display: block; + } + + .chat-hub-container .chat-hub-left-pane { + width: 100%; + min-width: 0; + height: 100%; + max-width: none; + max-height: none; + border: 0; + } + + .chat-hub-container .chat-hub-right-pane { + display: none; + width: 100%; + height: 100%; + } + + .chat-hub-container.mobile-detail-open .chat-hub-left-pane { + display: none; + } + + .chat-hub-container.mobile-detail-open .chat-hub-right-pane { + display: flex; + } + + .chat-hub-container .chat-hub-tab-content { + min-height: 0; + } +} + + +@media (min-width: 900px) { + .mobile-menu-icons { + display: none; + } +} + +/* ===================================================== + CHAT HUB - Two-Pane Layout (matching Network page) + ===================================================== */ + +.chat-hub-container { + display: flex; + height: 100%; + width: 100%; + overflow: hidden; + background-color: #f1f5f9; +} + +.chat-hub-left-pane { + width: 320px; + min-width: 300px; + max-width: 350px; + border-right: 1px solid #cbd5e1; + display: flex; + flex-direction: column; + background: #ffffff; + box-shadow: 2px 0 5px rgba(0, 0, 0, 0.05); +} + +.chat-own-profile-card { + padding: 0.85rem 1.25rem !important; + border-bottom: 1px solid #e2e8f0 !important; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%) !important; + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + gap: 0.75rem !important; + position: relative; + + .profile-header { + display: flex !important; + align-items: center !important; + gap: 0.75rem !important; + flex: 1 !important; + min-width: 0 !important; + } + + + + .chat-create-room-btn { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + + // Desktop: hide the + icon + i { + display: none; + } + + // Phone mode: show only the + icon and hide the Create text + @media (max-width: 700px), (max-width: 899px) and (max-height: 500px) { + width: 32px !important; + min-width: 32px !important; + height: 32px !important; + padding: 0 !important; + + i { + display: inline-block !important; + margin: 0 !important; + font-size: 0.95rem !important; + } + + .btn-text { + display: none !important; + } + } + } + + .profile-info { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + + .profile-name { + font-weight: 700; + color: #1e293b; + font-size: 1.1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .profile-status { + font-size: 0.85rem; + color: #10b981; + font-weight: 500; + display: flex; + align-items: center; + gap: 0.35rem; + + &::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + background-color: #10b981; + border-radius: 50%; + } + } + } +} + +.chat-rooms-list-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + + .searchbar-container { + padding: 0.75rem 1rem; + border-bottom: 1px solid #e2e8f0; + + input.searchbar { + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + border: 1px solid #cbd5e1; + border-radius: 0.375rem; + background-color: #f8fafc; + outline: none; + transition: all 0.2s; + + &:focus { + background-color: #ffffff; + border-color: #3ba4d7; + box-shadow: 0 0 0 3px rgba(59, 164, 215, 0.15); + } + } + } + + .rooms-scroll { + flex: 1; + overflow-y: auto; + padding: 0.5rem 0; + } +} + +.rooms-section-title { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem 0.375rem; + font-size: 0.75rem; + font-weight: 700; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + + i { + font-size: 0.7rem; + color: #94a3b8; + } +} + +.chat-room-list-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + margin: 0.125rem 0.5rem; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background-color: #f1f5f9; + } + + &.selected { + background-color: #e0f2fe; + + .room-name { + color: #0369a1; + font-weight: 600; + } + } + + .room-icon { + flex-shrink: 0; + width: 36px; + height: 36px; + border-radius: 0.5rem; + background: linear-gradient(135deg, #3ba4d7, #0ea5e9); + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + font-size: 1.35rem; + } + + &.public-room .room-icon { + background: linear-gradient(135deg, #10b981, #059669); + } + + .room-meta { + flex: 1; + min-width: 0; + + .room-name { + font-size: 0.95rem; + color: #334155; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: color 0.2s; + } + + .room-topic { + font-size: 0.8rem; + color: #94a3b8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .room-badge { + flex-shrink: 0; + min-width: 24px; + height: 24px; + border-radius: 12px; + background-color: #e2e8f0; + color: #475569; + font-size: 0.75rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + padding: 0 0.375rem; + } +} + +.chat-hub-right-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background-color: #f8fafc; +} + +.chat-pane-placeholder { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: #94a3b8; + gap: 1rem; + padding: 2rem; + text-align: center; + + i { + font-size: 4rem; + color: #cbd5e1; + } + + p { + font-size: 1.1rem; + max-width: 400px; + } +} + +.chat-hub-tab-content { + flex: 1; + overflow-y: auto; + padding: 1.5rem; +} + +.chat-room-detail-view { + display: flex; + flex-direction: column; + gap: 1.5rem; + + .detail-header { + display: flex; + align-items: flex-start; + gap: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid #e2e8f0; + flex-wrap: wrap; + + .detail-title { + flex: 1; + min-width: 200px; + + h2 { + font-size: 1.75rem; + font-weight: 800; + color: #1e293b; + margin-bottom: 0.25rem; + } + + .detail-subtitle { + font-size: 0.9rem; + color: #64748b; + display: flex; + align-items: center; + gap: 0.5rem; + } + } + + .detail-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + + button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.9rem; + } + } + } + + .detail-section { + background-color: #ffffff; + border-radius: 0.5rem; + border: 1px solid #e2e8f0; + padding: 1.25rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); + + h3 { + font-size: 1.1rem; + font-weight: 700; + color: #334155; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid #f1f5f9; + } + + .info-grid { + display: grid; + grid-template-columns: 130px 1fr; + row-gap: 0.75rem; + font-size: 0.9rem; + + .info-label { + font-weight: 600; + color: #64748b; + } + + .info-value { + color: #1e293b; + word-break: break-all; + } + } + } +} + +.participants-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 0.5rem; +} + +.participant-card { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background-color: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 0.375rem; + + > .jdenticon-avatar, + > .defaultAvatar, + > img.avatar { + margin-right: 0 !important; + } + + .participant-name { + flex: 1; + min-width: 0; + font-size: 0.875rem; + color: #334155; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.participant-actions { + display: none; +} + +.participant-more { + display: none; +} + +@media (max-width: 700px) { + .chat-hub-tab-content.details-content { + min-height: 0; + overflow-y: auto; + overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; + } + + .chat-hub-tab-content.details-content .participants-grid { + grid-template-columns: 1fr; + } + + .chat-hub-tab-content.details-content .participant-card { + min-height: 3rem; + flex-wrap: wrap; + } + + .chat-hub-tab-content.details-content .participant-card.has-actions { + cursor: pointer; + } + + .chat-hub-tab-content.details-content .participant-card.has-actions:focus-visible { + outline: 2px solid #0284c7; + outline-offset: 1px; + } + + .chat-hub-tab-content.details-content .participant-more { + display: block; + color: #94a3b8; + font-size: 0.75rem; + transition: transform 0.15s ease; + } + + .chat-hub-tab-content.details-content .participant-card.actions-open .participant-more { + transform: rotate(180deg); + } + + .chat-hub-tab-content.details-content .participant-actions { + display: flex; + width: 100%; + gap: 0.4rem; + padding-top: 0.25rem; + } + + .chat-hub-tab-content.details-content .participant-action { + display: inline-flex; + flex: 1; + align-items: center; + justify-content: center; + min-width: 0; + gap: 0.3rem; + padding: 0.4rem 0.35rem; + border: 1px solid #cbd5e1; + border-radius: 0.375rem; + background: #ffffff; + color: #0369a1; + box-shadow: none; + font-size: 0.75rem; + font-weight: 600; + } + + .chat-hub-tab-content.details-content .participant-action:active { + background: #e0f2fe; + box-shadow: none; + } +} + +.no-participants { + color: #94a3b8; + font-size: 0.9rem; + font-style: italic; +} + +.detail-actions-footer { + display: flex; + gap: 0.75rem; + + button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.9rem; + } +} + +.join-description { + color: #64748b; + font-size: 0.9rem; + margin-bottom: 1rem; +} + +.identities-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.75rem; +} + +// Refusing is the secondary action next to the identity cards that accept: +// outlined rather than filled, and full width on a phone like the other +// buttons of these panels. +.chat-invite-decline { + display: inline-flex; + align-items: center; + gap: .5rem; + margin-top: 1rem; + padding: .5rem 1rem; + border: 1px solid #fca5a5; + border-radius: .5rem; + background: #fff; + color: #b91c1c; + font-weight: 600; + cursor: pointer; + + &:hover { background: #fef2f2; } + + &:disabled { + opacity: .5; + cursor: not-allowed; + } + + @media (max-width: 700px) { + width: 100%; + justify-content: center; + } +} + +.identity-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background-color: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background-color: #e0f2fe; + border-color: #3ba4d7; + } + + &__identity { + display: flex; + align-items: center; + min-width: 0; + gap: 0.65rem; + + > .avatar, + > .jdenticon-avatar, + > .defaultAvatar { + margin-right: 0 !important; + } + } + + .identity-name { + overflow: hidden; + font-size: 0.95rem; + font-weight: 600; + color: #334155; + text-overflow: ellipsis; + white-space: nowrap; + } + + i { + color: #3ba4d7; + font-size: 0.9rem; + } +} + +.no-rooms { + padding: 1rem; + color: #94a3b8; + text-align: center; + font-style: italic; +} + +/* Chat Hub Responsive - Mobile */ +@media (max-width: 899px) { + .chat-hub-container { + flex-direction: column; + } + + .chat-hub-left-pane { + width: 100%; + min-width: 0; + max-width: none; + max-height: 45%; + border-right: none; + border-bottom: 1px solid #cbd5e1; + } + + .chat-hub-right-pane { + flex: 1; + min-height: 0; + } +} + +/* ===================================================== + CHAT HUB - Right Pane Conversation & Tabs Styling + ===================================================== */ + +.chat-hub-header-bar { + padding: 0.75rem 1.5rem; + background-color: #ffffff; + border-bottom: 1px solid #e2e8f0; + display: flex; + align-items: center; + justify-content: space-between; + height: 65px; + flex-shrink: 0; +} + +.chat-hub-header-bar .chat-header-info { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.chat-hub-header-bar .chat-header-info .chat-header-name { + font-size: 1.15rem; + font-weight: 800; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-hub-header-bar .chat-header-info .chat-header-topic { + font-size: 0.85rem; + color: #64748b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.125rem; +} + +.chat-hub-header-bar .chat-header-actions { + display: flex; + gap: 0.5rem; +} + +.chat-hub-header-bar .chat-header-actions button { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.375rem 0.75rem; + font-size: 0.85rem; +} + +/* Room-header actions remain available on phones without consuming the + * message area: their accessible title still describes each icon. */ +@media (max-width: 700px) { + .chat-hub-header-bar { + height: auto; + min-height: 48px; + padding: .45rem .55rem; + gap: .45rem; + } + + .chat-hub-header-bar .chat-header-info { + min-width: 0; + flex: 1; + } + + .chat-hub-header-bar .chat-header-info .chat-header-name { + font-size: .95rem; + } + + .chat-hub-header-bar .chat-header-actions { + flex: 0 0 auto; + gap: .25rem; + } + + .chat-hub-header-bar .chat-header-actions button { + width: 32px; + min-width: 32px; + height: 32px; + padding: 0; + justify-content: center; + font-size: 0; + } + + .chat-hub-header-bar .chat-header-actions button i { + margin: 0; + font-size: .95rem; + } +} + +.chat-hub-tabs-container { + background-color: #ffffff; + border-bottom: 1px solid #cbd5e1; + padding: 0.5rem 1.5rem 0; +} + +.chat-hub-tabs { + display: flex; + gap: 0.5rem; +} + +.chat-hub-tabs .tab-btn { + padding: 0.625rem 1.25rem; + font-size: 0.95rem; + font-weight: 600; + color: #64748b; + background: transparent; + border: none; + border-radius: 0.375rem 0.375rem 0 0; + border-bottom: 3px solid transparent; + cursor: pointer; + box-shadow: none; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.chat-hub-tabs .tab-btn:hover { + color: #334155; + background-color: #f1f5f9; +} + +.chat-hub-tabs .tab-btn.active { + color: #3ba4d7; + border-bottom-color: #3ba4d7; + background-color: transparent; +} + +.chat-hub-tab-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background-color: #f8fafc; +} + +.chat-hub-conversation-layout { + display: flex; + flex-direction: row; + height: 100%; + width: 100%; + overflow: hidden; +} + +.chat-hub-conversation-main { + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + overflow: hidden; +} + +.chat-hub-rightbar { + width: 200px; + border-left: 1px solid #cbd5e1; + background-color: #ffffff; + display: flex; + flex-direction: column; + flex-shrink: 0; + position: relative; // anchors the hovered-participant .user-tooltip (top offset is measured against this) +} + +.chat-hub-rightbar .rightbar-title { + padding: 0.75rem 1rem; + font-size: 0.85rem; + font-weight: 700; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid #e2e8f0; +} + +.chat-hub-rightbar .rightbar-users-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.chat-hub-rightbar .user { + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + color: #334155; + border-radius: 0.375rem; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; + position: relative; + + .user-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.chat-hub-rightbar .user:hover { + background-color: #f1f5f9; + color: #0f172a; +} + +.chat-hub-rightbar .user .defaultAvatar { + width: 2rem; + height: 2rem; + font-size: 0.9rem; + flex-shrink: 0; +} + +.chat-hub-rightbar .user img.avatar { + width: 2rem; + height: 2rem; + flex-shrink: 0; +} + +/* Below 900px the participants column is not laid out: the room header's + * .participants-toggle opens it as a sheet over the messages instead, and + * a tap on a participant opens its menu (chat.js). Above, the column is + * always there, so the toggle and the sheet's close button are hidden. */ +.chat-hub-rightbar .rightbar-title { + display: flex; + align-items: center; + justify-content: space-between; +} + +.chat-hub-rightbar .rightbar-close { + display: none; + border: none; + background: transparent; + color: #64748b; + font-size: 1rem; + padding: 0.25rem 0.5rem; + cursor: pointer; +} + +@media (min-width: 900px) { + .chat-hub-header-bar .chat-header-actions .participants-toggle { + display: none; + } +} + +@media (max-width: 899px) { + .chat-hub-conversation-layout { + position: relative; + } + + .chat-hub-rightbar { + display: none; + } + + .chat-hub-conversation-layout.show-participants .chat-hub-rightbar { + display: flex; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: min(40vw, 260px); + z-index: 60; + box-shadow: -4px 0 16px rgba(0, 0, 0, 0.15); + } + + .chat-hub-conversation-layout.show-participants .rightbar-close { + display: inline-flex; + } +} + +@media (max-width: $bp-mobile), (max-width: $bp-narrow) and (max-height: $bp-short) { + .chat-hub-conversation-layout.show-participants .chat-hub-rightbar { + width: min(50vw, 200px); + + .rightbar-title { + padding: 0.6rem 0.75rem; + font-size: 0.8rem; + } + + .rightbar-users-list { + padding: 0.25rem; + } + + .user { + padding: 0.35rem 0.5rem; + gap: 0.4rem; + font-size: 0.85rem; + + .defaultAvatar, + img.avatar { + width: 1.75rem; + height: 1.75rem; + } + } + } +} + +.chat-hub-messages { + flex: 1; + overflow-y: auto; + padding: 1.25rem 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Chat bubble overrides for two-pane layout */ +.chat-hub-messages .message { + display: flex; + flex-direction: column; + max-width: 70%; + padding: 0.625rem 0.875rem; + border-radius: 0.75rem; + font-size: 0.925rem; + line-height: 1.4; + word-break: break-word; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.chat-hub-messages .message.incoming { + align-self: flex-start; + align-items: flex-start; + background-color: #ffffff; + color: #1e293b; + border: 1px solid #e2e8f0; + border-bottom-left-radius: 0.125rem; +} + +.chat-hub-messages .message.outgoing { + align-self: flex-end; + align-items: flex-end; + background-color: #3ba4d7; + color: #ffffff; + border-bottom-right-radius: 0.125rem; +} + +.chat-hub-messages .message .username { + font-size: 0.75rem; + margin-bottom: 0.25rem; + padding: 0 0.125rem; + font-weight: 700; +} + +.chat-hub-messages .message.incoming .username { + color: #0369a1; +} + +.chat-hub-messages .message.outgoing .username { + color: #e0f2fe; +} + +.chat-hub-messages .message .messagetext { + white-space: break-spaces; + margin: 0; +} + +.chat-hub-messages .message .datetime { + font-size: 0.7rem; + margin-top: 0.25rem; + padding: 0 0.125rem; + opacity: 0.8; +} + +.chat-hub-messages .message.incoming .datetime { + color: #64748b; +} + +.chat-hub-messages .message.outgoing .datetime { + color: #f1f5f9; +} + +.chat-attachment-preview { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.25rem; + background-color: #f8fafc; + border-top: 1px solid #e2e8f0; + flex-shrink: 0; + + &__item { + position: relative; + display: inline-flex; + flex-shrink: 0; + } + + &__thumb { + width: auto; + max-width: 220px; + height: 120px; + min-width: 90px; + object-fit: cover; + border-radius: 0.625rem; + border: 1px solid #cbd5e1; + background-color: #ffffff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + + &:hover { + transform: scale(1.02); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); + } + } + + &__remove { + position: absolute; + top: -8px; + right: -8px; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #ef4444; + color: #ffffff; + border: 2px solid #ffffff; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); + padding: 0; + line-height: 1; + transition: background-color 0.15s, transform 0.15s; + + &:hover { + background-color: #dc2626; + transform: scale(1.1); + } + } + + &__info { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; + overflow: hidden; + } + + &__name { + font-size: 0.85rem; + font-weight: 600; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__hint { + font-size: 0.75rem; + color: #64748b; + } +} + +.chat-hub-input-area { + padding: 0.75rem 1.5rem; + background-color: #ffffff; + border-top: 1px solid #cbd5e1; + display: flex; + gap: 0.75rem; + align-items: flex-end; + flex-shrink: 0; +} + +.chat-hub-input-area textarea.chat-hub-textarea { + flex: 1; + resize: none !important; + min-height: 40px; + max-height: 160px; + height: 40px; + padding: 0.55rem 0.75rem; + border: 1px solid #cbd5e1; + border-radius: 0.625rem; + font-size: 0.9rem; + line-height: 1.45; + outline: none; + overflow-y: hidden; + box-sizing: border-box; + transition: border-color 0.2s, box-shadow 0.2s; + background-color: #f8fafc; +} + +.chat-hub-input-area textarea.chat-hub-textarea:focus { + background-color: #ffffff; + border-color: #3ba4d7; + box-shadow: 0 0 0 3px rgba(59, 164, 215, 0.15); +} + +.chat-hub-input-area button.chat-hub-send-btn { + padding: 0.5rem 1.25rem; + font-size: 0.9rem; + height: 40px; + display: flex; + align-items: center; + gap: 0.5rem; + border-radius: 0.375rem; +} + +button.chat-hub-action-btn, +label.chat-hub-action-btn, +.chat-hub-action-btn { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 36px !important; + height: 36px !important; + min-width: 36px !important; + padding: 0 !important; + margin: 0 !important; + background: transparent !important; + border: none !important; + box-shadow: none !important; + color: #64748b !important; + font-size: 1.15rem !important; + border-radius: 0.375rem !important; + cursor: pointer !important; + transition: all 0.15s ease !important; + outline: none !important; +} + +button.chat-hub-action-btn:hover, +label.chat-hub-action-btn:hover, +.chat-hub-action-btn:hover { + background-color: #e2e8f0 !important; + color: #3b82f6 !important; + box-shadow: none !important; +} + +button.chat-hub-action-btn i, +label.chat-hub-action-btn i, +.chat-hub-action-btn i { + font-size: 1.15rem !important; + color: inherit !important; +} + +.mobile-chat-attachment { + display: none; + position: relative; + flex: 0 0 auto; +} + +.mobile-chat-attachment__menu { + position: absolute; + z-index: 20; + bottom: calc(100% + .5rem); + left: 0; + display: flex; + min-width: 9rem; + flex-direction: column; + padding: .25rem; + border: 1px solid #cbd5e1; + border-radius: .5rem; + background: #fff; + box-shadow: 0 8px 24px rgba(15, 23, 42, .18); +} + +button.mobile-chat-attachment__option, +label.mobile-chat-attachment__option { + display: flex; + width: 100%; + align-items: center; + gap: .6rem; + padding: .55rem .7rem; + border: 0; + border-radius: .35rem; + background: transparent; + box-shadow: none; + color: #334155; + cursor: pointer; + font-size: .9rem; + text-align: left; +} + +button.mobile-chat-attachment__option:hover, +label.mobile-chat-attachment__option:hover { + background: #f1f5f9; +} + +@media (max-width: 700px) { + .chat-attachment-preview { + padding: 0.35rem 0.6rem; + gap: 0.5rem; + + &__thumb { + height: 80px; + max-width: 130px; + } + } + + .chat-hub-input-area .desktop-chat-attachment, + .network-chat-view .chat-input-area .desktop-chat-attachment { + display: none !important; + } + + .chat-hub-input-area .mobile-chat-attachment, + .network-chat-view .chat-input-area .mobile-chat-attachment { + display: block; + } + + .chat-hub-input-area { + gap: 0.2rem; + align-items: center; + padding: 0.35rem 0.45rem; + } + + .chat-hub-input-area .mobile-chat-attachment { + order: 1; + } + + .chat-hub-input-area textarea.chat-hub-textarea { + order: 2; + min-width: 0; + min-height: 40px; + height: 40px; + max-height: 140px; + padding: 0.55rem 0.7rem; + resize: none !important; + border-color: #dbe2ea; + border-radius: 1.25rem; + background: #ffffff; + box-sizing: border-box; + overflow-y: hidden; + line-height: 1.4; + } + + .chat-hub-input-area .emoji-picker-wrapper { + order: 3; + } + + .chat-hub-input-area .emoji-picker-wrapper .emoji-picker { + right: -2.7rem; + left: auto; + width: min(320px, calc(100vw - 1rem)); + max-height: min(420px, calc(100dvh - 8rem)); + } + + .chat-hub-input-area .mobile-chat-attachment .chat-hub-action-btn, + .chat-hub-input-area .emoji-picker-wrapper .chat-hub-action-btn { + width: 32px !important; + height: 32px !important; + padding: 0.25rem !important; + } + + .chat-hub-input-area button.chat-hub-send-btn { + order: 4; + width: 40px; + min-width: 40px; + height: 40px; + padding: 0; + justify-content: center; + border-radius: 50%; + } + + .chat-hub-input-area button.chat-hub-send-btn i { + margin: 0; + } +} + +/* Compact Room Chat Style (No bubbles, unique nickname colors, IRC-style) */ +.chat-hub-messages.compact-container, +.messages.compact-container { + gap: 0 !important; + padding: 0.75rem 1rem !important; + background-color: #ffffff !important; + display: flex !important; + flex-direction: column !important; + + .message.compact { + display: block !important; + max-width: 100% !important; + padding: 0.1rem 0 !important; + border-radius: 0 !important; + background-color: transparent !important; + border: none !important; + box-shadow: none !important; + align-self: flex-start !important; + font-size: 0.875rem !important; + line-height: 1.45 !important; + margin: 0 !important; + white-space: nowrap !important; + // overflow: hidden !important; + // text-overflow: ellipsis !important; + + &:hover { + background-color: #f8fafc !important; + overflow: visible !important; + white-space: normal !important; + } + + .datetime { + color: #a0a0a0 !important; + margin-right: 0.4rem !important; + font-size: 0.78rem !important; + font-family: monospace !important; + opacity: 1 !important; + display: inline !important; + } + + .username { + font-weight: bold !important; + margin-right: 0.2rem !important; + font-size: 0.875rem !important; + display: inline !important; + } + + .messagetext { + color: #1e293b !important; + white-space: normal !important; + word-break: break-word !important; + display: inline !important; + margin: 0 !important; + } + } +} + + + + + +// Chat-hub extras: user tooltip, right-bar context menu, attach-file modal, +// emoji picker, create-lobby button (chat.js). Recovered from compiled styles.css. + + + +.chat-hub-rightbar .user .user-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.chat-hub-rightbar .rightbar-context-menu { + position: absolute; + right: 1rem; + width: 210px; + background-color: #ffffff; + border: 1px solid #e2e8f0; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); + border-radius: 0.375rem; + z-index: 1010; + padding: 0.25rem 0; + display: flex; + flex-direction: column; +} + +.chat-hub-rightbar .rightbar-context-menu .menu-item { + padding: 0.5rem 1rem; + font-size: 0.85rem; + color: #334155; + cursor: pointer; + display: flex; + align-items: center; + transition: background-color 0.2s; +} + +.chat-hub-rightbar .rightbar-context-menu .menu-item:hover { + background-color: #f1f5f9; + color: #0f172a; +} + +.chat-emoji { + font-size: 1.45em; + line-height: 1; + vertical-align: -0.15em; + display: inline-block; +} + +.chat-hub-attach-btn, +.chat-hub-action-btn { + background-color: transparent !important; + border: none !important; + font-size: 1.15rem !important; + color: #64748b !important; + cursor: pointer !important; + padding: 0.4rem 0.5rem !important; + border-radius: 0.375rem !important; + flex-shrink: 0 !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + transition: all 0.2s !important; + box-shadow: none !important; + margin: 0 !important; + line-height: 1 !important; + height: 36px !important; + width: 36px !important; +} + +.chat-hub-attach-btn:hover, +.chat-hub-action-btn:hover { + background-color: #f1f5f9 !important; + color: #3b82f6 !important; + transform: none !important; +} + +.attach-modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + /* dvh follows the collapsible URL bar of mobile browsers, which 100vh + ignores; the vh line stays as a fallback for older engines. */ + height: 100vh; + height: 100dvh; + background-color: rgba(15, 23, 42, 0.4); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; +} + +.attach-modal { + background-color: #ffffff; + border-radius: 0.5rem; + width: 450px; + max-width: 90%; + padding: 1.5rem; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + gap: 1rem; +} + +.attach-modal .attach-modal-header { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.25rem; +} + +.attach-modal .attach-modal-icon { + font-size: 1.2rem; + color: #3b82f6; +} + +.attach-modal h4 { + margin: 0; + font-size: 1.2rem; + color: #0f172a; +} + +.attach-modal p { + margin: 0; + font-size: 0.9rem; + color: #475569; +} + +.attach-modal .attach-path-row { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.attach-modal .attach-path-row input[type="text"] { + flex: 1; + padding: 0.75rem; + border: 1px solid #cbd5e1; + border-radius: 0.375rem; + font-size: 0.9rem; + outline: none; + transition: border-color 0.2s; + min-width: 0; +} + +.attach-modal .attach-path-row input[type="text"]:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +.attach-browse-btn { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.625rem 0.9rem; + font-size: 0.875rem; + background-color: #f1f5f9; + color: #334155; + border: 1px solid #cbd5e1; + border-radius: 0.375rem; + cursor: pointer; + box-shadow: none; + transition: background-color 0.2s, border-color 0.2s; + white-space: nowrap; +} + +.attach-browse-btn:hover { + background-color: #e2e8f0; + border-color: #94a3b8; +} + +.attach-path-hint { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + background-color: #fffbeb; + border: 1px solid #fcd34d; + border-left: 3px solid #f59e0b; + border-radius: 0.375rem; + font-size: 0.825rem; + color: #92400e; + line-height: 1.45; +} + +.attach-path-hint i { + color: #f59e0b; + margin-top: 0.1rem; + flex-shrink: 0; +} + +.attach-path-hint code { + font-family: monospace; + background-color: rgba(245, 158, 11, 0.15); + padding: 0.05rem 0.25rem; + border-radius: 0.2rem; +} + +.attach-modal .hashing-spinner { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + color: #3b82f6; +} + +.attach-modal .error-text { + color: #ef4444; + font-size: 0.85rem; + margin: 0; +} + +.attach-modal .modal-buttons { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.attach-modal .modal-buttons button { + padding: 0.5rem 1rem; + font-size: 0.9rem; + border-radius: 0.25rem; + border: none; + cursor: pointer; + transition: opacity 0.2s; +} + +.attach-modal .modal-buttons button:hover { + opacity: 0.9; +} + +.chat-hub-emoji-btn { + background-color: transparent; + border: none; + font-size: 1.3rem; + cursor: pointer; + padding: 0.35rem 0.4rem; + margin-right: 0.25rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.375rem; + line-height: 1; + transition: background-color 0.15s, transform 0.15s; + box-shadow: none; +} + +.chat-hub-emoji-btn:hover { + background-color: #f1f5f9; + transform: scale(1.1); +} + +.chat-hub-messages.compact-container .message.compact, +.messages.compact-container .message.compact { + display: block !important; + max-width: 100% !important; + padding: 0.1rem 0 !important; + border-radius: 0 !important; + background-color: transparent !important; + border: none !important; + box-shadow: none !important; + align-self: flex-start !important; + font-size: 0.875rem !important; + line-height: 1.45 !important; + margin: 0 !important; + white-space: nowrap !important; +} + +.chat-hub-messages.compact-container .message.compact:hover, +.messages.compact-container .message.compact:hover { + background-color: #f8fafc !important; + overflow: visible !important; + white-space: normal !important; +} + +.chat-hub-messages.compact-container .message.compact .datetime, +.messages.compact-container .message.compact .datetime { + color: #a0a0a0 !important; + margin-right: 0.4rem !important; + font-size: 0.78rem !important; + font-family: monospace !important; + opacity: 1 !important; + display: inline !important; +} + +.chat-hub-messages.compact-container .message.compact .username, +.messages.compact-container .message.compact .username { + font-weight: bold !important; + margin-right: 0.2rem !important; + font-size: 0.875rem !important; + display: inline !important; +} + +.chat-hub-messages.compact-container .message.compact .messagetext, +.messages.compact-container .message.compact .messagetext { + color: #1e293b !important; + white-space: normal !important; + word-break: break-word !important; + display: inline !important; + margin: 0 !important; +} + +/* User Tooltip Styling (Standalone Fixed Floating Tooltip) */ +.user-tooltip { + position: fixed !important; + width: 280px !important; + background-color: #ffffe1 !important; + border: 1px solid #7f7f7f !important; + box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.25) !important; + padding: 0.5rem !important; + border-radius: 0.25rem !important; + z-index: 10000 !important; + white-space: normal !important; + display: flex !important; + gap: 0.5rem !important; + align-items: flex-start !important; + color: #000000 !important; + font-size: 0.8rem !important; + text-align: left !important; + pointer-events: none !important; +} + +.user-tooltip .tooltip-avatar { + flex-shrink: 0 !important; +} + +.user-tooltip .tooltip-avatar .jdenticon-avatar, +.user-tooltip .tooltip-avatar .defaultAvatar, +.user-tooltip .tooltip-avatar img.avatar { + width: 56px !important; + height: 56px !important; + min-width: 56px !important; + min-height: 56px !important; + border-radius: 2px !important; + border: 1px solid #999999 !important; + box-shadow: none !important; + object-fit: cover !important; +} + +.user-tooltip .tooltip-details { + display: flex !important; + flex-direction: column !important; + gap: 0.2rem !important; + min-width: 0 !important; + flex: 1 !important; +} + +.user-tooltip .tooltip-details .tooltip-row { + line-height: 1.2 !important; + display: flex !important; + flex-direction: row !important; + align-items: baseline !important; + gap: 0.35rem !important; + white-space: normal !important; + word-break: break-all !important; +} + +.user-tooltip .tooltip-details .tooltip-row .tooltip-label { + font-weight: bold !important; + color: #000000 !important; + font-size: 0.8rem !important; + flex-shrink: 0 !important; +} + +.user-tooltip .tooltip-details .tooltip-row .tooltip-value { + font-weight: normal !important; + color: #000000 !important; + font-size: 0.8rem !important; + overflow: hidden !important; + text-overflow: ellipsis !important; +} + +.user-tooltip .tooltip-details .tooltip-row .tooltip-value.tooltip-id { + font-family: monospace !important; + font-size: 0.75rem !important; + color: #0000bb !important; +} + +/* Chat Modal Dialogs (Create Room, Attach File, Invite Friends) */ +.attach-modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + /* dvh follows the collapsible URL bar of mobile browsers, which 100vh + ignores; the vh line stays as a fallback for older engines. */ + height: 100vh; + height: 100dvh; + background-color: rgba(15, 23, 42, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.attach-modal { + background: #ffffff; + border-radius: 0.5rem; + width: 480px; + max-width: 92vw; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 8px 10px -6px rgba(0, 0, 0, 0.1); + padding: 1.5rem; + display: flex; + flex-direction: column; + color: #1e293b; + box-sizing: border-box; +} + +.attach-modal h4 { + margin: 0 0 1rem 0; + font-size: 1.15rem; + font-weight: 700; + color: #0f172a; +} + +.attach-modal .attach-modal-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.attach-modal .attach-modal-header h4 { + margin: 0; +} + +.attach-modal .attach-modal-header .attach-modal-icon { + font-size: 1.25rem; + color: #3b82f6; +} + +/* Emoji Picker Dropdown & Grid Styling */ +.emoji-picker-wrapper { + position: relative; + flex-shrink: 0; + display: flex; + align-items: center; +} + +.emoji-picker { + position: absolute; + bottom: calc(100% + 0.5rem); + left: 0; + width: 320px; + background-color: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 0.625rem; + box-shadow: 0 8px 30px -4px rgba(0, 0, 0, 0.18), 0 4px 12px -2px rgba(0, 0, 0, 0.1); + z-index: 9999; + display: flex; + flex-direction: column; + overflow: hidden; + animation: emoji-pop 0.15s ease-out; +} + +.emoji-search-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.6rem 0.75rem 0.4rem; + border-bottom: 1px solid #f1f5f9; +} + +.emoji-search-icon { + color: #94a3b8; + font-size: 0.8rem; + flex-shrink: 0; +} + +/* input.--, not .-- alone: the field is an input[type=text], and the global + * input[type='text'] of base/_base.scss (0,1,1) out-ranks a bare class + * (0,1,0) -- border, radius, padding and font-size all lost to it. The old + * nested picker rule used to win on nesting. */ +input.emoji-search-input { + flex: 1; + border: 1px solid #e2e8f0; + border-radius: 0.375rem; + padding: 0.3rem 0.5rem; + font-size: 0.85rem; + outline: none; + background-color: #f8fafc; + transition: border-color 0.15s; +} + +input.emoji-search-input:focus { + border-color: #3ba4d7; + background-color: #fff; + /* base's input:focus paints an inset shadow; the picker field stays flat */ + box-shadow: none; +} + +.emoji-search-clear { + background: none !important; + border: none !important; + cursor: pointer; + color: #94a3b8; + padding: 0.2rem; + font-size: 0.8rem; + box-shadow: none !important; + display: flex; + align-items: center; +} + +.emoji-search-clear:hover { + color: #475569; +} + +.emoji-categories { + display: flex; + gap: 0.1rem; + padding: 0.35rem 0.5rem; + border-bottom: 1px solid #f1f5f9; + overflow-x: auto; + scrollbar-width: none; +} + +.emoji-categories::-webkit-scrollbar { + display: none; +} + +.emoji-cat-btn { + background: transparent !important; + border: none !important; + cursor: pointer; + font-size: 1.15rem !important; + padding: 0.25rem 0.35rem !important; + border-radius: 6px !important; + line-height: 1 !important; + box-shadow: none !important; + transition: background-color 0.15s ease, transform 0.1s ease !important; + flex-shrink: 0; + width: auto !important; + height: auto !important; + min-width: unset !important; +} + +.emoji-cat-btn:hover { + background-color: #f1f5f9 !important; + transform: scale(1.15); +} + +.emoji-cat-btn.active { + background-color: #e0f2fe !important; + border-radius: 6px !important; + box-shadow: none !important; +} + +.emoji-grid { + display: grid !important; + grid-template-columns: repeat(8, 1fr) !important; + gap: 2px !important; + padding: 0.4rem 0.35rem !important; + max-height: 220px !important; + overflow-y: auto !important; + overflow-x: hidden !important; + scrollbar-width: thin; + scrollbar-color: #cbd5e1 transparent; +} + +.emoji-grid::-webkit-scrollbar { + width: 5px; +} + +.emoji-grid::-webkit-scrollbar-track { + background: transparent; +} + +.emoji-grid::-webkit-scrollbar-thumb { + background-color: #cbd5e1; + border-radius: 3px; +} + +.emoji-btn { + background: transparent !important; + border: none !important; + cursor: pointer; + font-size: 1.25rem !important; + padding: 0.35rem 0 !important; + border-radius: 6px !important; + line-height: 1 !important; + box-shadow: none !important; + text-align: center; + transition: background-color 0.1s ease, transform 0.1s ease !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: auto !important; + height: auto !important; + min-width: unset !important; +} + +.emoji-btn:hover { + background-color: #e2e8f0 !important; + transform: scale(1.2); +} + +@keyframes emoji-pop { + from { opacity: 0; transform: scale(0.92) translateY(6px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} +/* Rightbar / Chat Message Context Menus */ +.rightbar-context-menu, +.chat-msg-context-menu { + z-index: 9999 !important; + background-color: #ffffff !important; + border: 1px solid #cbd5e1 !important; + border-radius: 8px !important; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.15), 0 8px 10px -6px rgba(0, 0, 0, 0.1) !important; + padding: 0.35rem 0 !important; + font-family: inherit !important; + overflow: hidden !important; + + .menu-item, + .context-menu-item { + display: flex !important; + align-items: center !important; + padding: 0.55rem 0.85rem !important; + font-size: 0.875rem !important; + font-weight: 500 !important; + color: #1e293b !important; + cursor: pointer !important; + transition: background-color 0.15s ease, color 0.15s ease !important; + user-select: none !important; + + &:hover { + background-color: #f1f5f9 !important; + color: #0284c7 !important; + } + + i { + font-size: 0.95rem !important; + width: 1.25rem !important; + text-align: center !important; + } + } +} + +.rightbar-context-menu { + position: absolute !important; + right: 10px !important; + min-width: 220px !important; + /* Above its own .menu-backdrop (position: fixed, z-index 9998), which + * otherwise sits over the menu inside the phone participants sheet -- + * the sheet's z-index makes a stacking context of its own. */ + z-index: 9999 !important; +} + +.chat-msg-context-menu { + position: fixed !important; + right: auto !important; + width: max-content !important; + min-width: 180px !important; + max-width: calc(100vw - 16px) !important; + box-sizing: border-box !important; + + .context-menu-item { + white-space: nowrap !important; + } +} + +.chat-image-viewer { + position: fixed; + inset: 0; + z-index: 1000000; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: 3.5rem 1rem 1rem; + background: rgba(15, 23, 42, 0.98); + + &__image { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + &__close { + position: absolute; + top: max(0.5rem, env(safe-area-inset-top)); + right: max(0.5rem, env(safe-area-inset-right)); + width: 2.75rem; + height: 2.75rem; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.5); + border-radius: 50%; + background: rgba(0, 0, 0, 0.45); + color: #fff; + font-size: 2rem; + line-height: 1; + } +} diff --git a/webui-src/app/scss/pages/_config.scss b/webui-src/app/scss/pages/_config.scss index 19d1256..3f9b5f3 100644 --- a/webui-src/app/scss/pages/_config.scss +++ b/webui-src/app/scss/pages/_config.scss @@ -21,9 +21,10 @@ border: none; } &__color { - width: 1.25rem; - height: 1.25rem; + width: 18px; + height: 18px; aspect-ratio: 1; + border-radius: 50%; } &__name { font-size: 1.125rem; @@ -145,3 +146,157 @@ color: #1e293b; } +/* Network configuration uses desktop-sized inline styles. Override them on + * phones so labels, fields, proxy details, and long IP addresses stay inside + * the viewport. */ +@media (max-width: 700px) { + .config-network { + min-width: 0; + overflow-x: hidden; + } + + .config-network .widget { + min-width: 0; + padding: .8rem; + } + + .config-network .nw-config-row { + display: flex !important; + flex-direction: column !important; + align-items: stretch !important; + gap: .35rem !important; + min-width: 0; + } + + .config-network .nw-config-row > label, + .config-network .nw-config-row > p { + margin: 0 !important; + } + + .config-network .nw-mode-group, + .config-network .nat-control-group, + .config-network .addr-control-group, + .config-network .proxy-control-group, + .config-network .addr-port-group { + width: 100%; + min-width: 0; + gap: .5rem !important; + } + + .config-network input[type=text], + .config-network input[type=number], + .config-network select { + width: 100% !important; + max-width: none !important; + min-width: 0 !important; + box-sizing: border-box; + } + + .config-network .port-group, + .config-network .status-indicator { + margin-left: 0 !important; + } + + .config-network .port-group input[type=number] { + width: 90px !important; + } + + .config-network .external-address { + width: 100%; + height: auto; + max-height: 9rem; + padding-left: 1.25rem; + overflow: auto; + overflow-wrap: anywhere; + word-break: break-word; + box-sizing: border-box; + } +} + +/* Chat settings: replace the desktop grid/table with phone-friendly rows. */ +@media (max-width: 700px) { + .node-config .config-grid { + display: flex !important; + flex-direction: column !important; + align-items: stretch !important; + gap: .6rem !important; + min-width: 0; + box-sizing: border-box; + } + + .node-config .default-id-selector { + width: 100%; + min-width: 0; + } + + .node-config .default-id-selector select, + .node-config .config-grid > select { + width: 100% !important; + min-width: 0 !important; + max-width: none !important; + box-sizing: border-box; + } + + .node-config .storage-input-group { + justify-content: flex-start; + } + + .node-config .table-container { + overflow: visible !important; + } + + .node-config .history-config-table, + .node-config .history-config-table tbody, + .node-config .history-config-table tr, + .node-config .history-config-table td { + display: block; + width: 100% !important; + box-sizing: border-box; + } + + .node-config .history-config-table { + table-layout: auto; + } + + .node-config .history-config-table thead { + display: none; + } + + .node-config .history-config-table tr { + margin: 0; + padding: .75rem; + border-bottom: 1px solid #e2e8f0 !important; + } + + .node-config .history-config-table tr:last-child { + border-bottom: 0 !important; + } + + .node-config .history-config-table td { + padding: .25rem 0 !important; + text-align: left !important; + } + + .node-config .history-config-table td:nth-child(2), + .node-config .history-config-table td:nth-child(3) { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; + } + + .node-config .history-config-table td:nth-child(2)::before { + content: 'Enable history'; + color: #64748b; + font-size: .8rem; + font-weight: 600; + } + + .node-config .history-config-table td:nth-child(3)::before { + content: 'Max saved messages'; + color: #64748b; + font-size: .8rem; + font-weight: 600; + } +} + diff --git a/webui-src/app/scss/pages/_debug.scss b/webui-src/app/scss/pages/_debug.scss new file mode 100644 index 0000000..88e10c7 --- /dev/null +++ b/webui-src/app/scss/pages/_debug.scss @@ -0,0 +1,647 @@ +// --------------------------------------------------------------------------- +// Debug & Diagnostics page (debug/debug.js) +// Follows the modern design patterns used in Network, People, Chat, and Statistics. +// --------------------------------------------------------------------------- +@use '../abstracts' as *; + +.debug-page { + max-width: 1280px; + margin: 0 auto; + padding: 1.5rem; + color: #1e293b; +} + +// ── Header Section ── +.debug-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; + + &__title { + display: flex; + align-items: center; + gap: 1rem; + } + + &__icon { + width: 48px; + height: 48px; + border-radius: 0.75rem; + background: #e0f2fe; + color: #0284c7; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + flex-shrink: 0; + } + + &__text { + h1 { + font-size: 1.6rem; + font-weight: 800; + color: #1e293b; + margin: 0 0 0.25rem; + line-height: 1.2; + } + + p { + font-size: 0.9rem; + color: #64748b; + margin: 0; + } + } + + &__actions { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + } +} + +.debug-btn { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.55rem 1rem; + border-radius: 0.5rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + border: 1px solid #cbd5e1; + background: #ffffff; + color: #334155; + transition: all 0.15s ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + + &:hover { + background: #f8fafc; + border-color: #94a3b8; + color: #1e293b; + } + + &--danger { + color: #dc2626; + border-color: #fecaca; + background: #ffffff; + + &:hover { + background: #fef2f2; + border-color: #fca5a5; + color: #b91c1c; + } + } +} + +// ── KPI Summary Cards ── +.debug-kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.debug-kpi-card { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 0.75rem; + padding: 1.15rem 1.25rem; + display: flex; + align-items: center; + gap: 1rem; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); + transition: transform 0.15s ease, box-shadow 0.15s ease; + + &:hover { + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); + } + + &__icon { + width: 46px; + height: 46px; + border-radius: 0.6rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + flex-shrink: 0; + + &--blue { + background: #e0f2fe; + color: #0284c7; + } + + &--purple { + background: #f3e8ff; + color: #7e22ce; + } + + &--green { + background: #dcfce7; + color: #16a34a; + } + + &--amber { + background: #fef3c7; + color: #d97706; + } + } + + &__body { + min-width: 0; + flex: 1; + } + + &__label { + font-size: 0.78rem; + font-weight: 600; + color: #64748b; + margin-bottom: 0.2rem; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + &__value { + font-size: 1.35rem; + font-weight: 800; + color: #1e293b; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__subtext { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.78rem; + color: #64748b; + margin-top: 0.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +// ── Status Dots ── +.debug-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; + + &.online { + background: #10b981; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.25); + } + + &.offline { + background: #ef4444; + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.25); + } +} + +// ── Detail Grid 2-Column ── +.debug-grid-2col { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.25rem; + margin-bottom: 1.25rem; +} + +// ── Detail Sections ── +.debug-section { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 0.75rem; + padding: 1.25rem 1.5rem; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); + margin-bottom: 1.25rem; + + &__header { + display: flex; + align-items: center; + gap: 0.6rem; + padding-bottom: 0.85rem; + border-bottom: 1px solid #f1f5f9; + margin-bottom: 1rem; + + i { + color: #0788cb; + font-size: 1.15rem; + } + + h3 { + font-size: 1.05rem; + font-weight: 700; + color: #1e293b; + margin: 0; + } + } +} + +// ── Info List ── +.debug-info-list { + display: flex; + flex-direction: column; +} + +.debug-info-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.55rem 0; + border-bottom: 1px solid #f8fafc; + + &:last-child { + border-bottom: 0; + } +} + +.debug-info-label { + font-size: 0.88rem; + font-weight: 600; + color: #64748b; +} + +.debug-info-value { + font-size: 0.9rem; + font-weight: 600; + color: #1e293b; + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.debug-sublabel { + color: #94a3b8; + font-size: 0.78rem; + font-weight: 400; +} + +// ── Badges ── +.debug-badge { + display: inline-flex; + align-items: center; + padding: 0.18rem 0.55rem; + border-radius: 0.35rem; + font-size: 0.78rem; + font-weight: 700; + + &--blue { + background: #e0f2fe; + color: #0369a1; + } + + &--slate { + background: #f1f5f9; + color: #334155; + } + + &--green { + background: #dcfce7; + color: #15803d; + } +} + +// ── Callout Box ── +.debug-callout { + display: flex; + gap: 0.75rem; + align-items: flex-start; + margin-top: 1rem; + padding: 0.85rem 1rem; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 0.5rem; + + i { + color: #0284c7; + font-size: 1.1rem; + margin-top: 0.15rem; + flex-shrink: 0; + } + + p { + margin: 0; + font-size: 0.82rem; + color: #0369a1; + line-height: 1.45; + } +} + +// ── API Tables Grid ── +.debug-tables-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.25rem; +} + +.debug-table-panel { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + padding: 1rem; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + + h4 { + margin: 0; + font-size: 0.95rem; + font-weight: 700; + color: #334155; + } + + .debug-count-badge { + font-size: 0.75rem; + color: #64748b; + background: #e2e8f0; + padding: 0.15rem 0.5rem; + border-radius: 0.3rem; + font-weight: 600; + } + } +} + +.debug-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + border-radius: 0.375rem; + border: 1px solid #e2e8f0; +} + +.debug-table { + width: 100%; + border-collapse: collapse; + font-size: 0.84rem; + + th { + text-align: left; + padding: 0.55rem 0.65rem; + font-size: 0.74rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #64748b; + border-bottom: 2px solid #e2e8f0; + background: #ffffff; + white-space: nowrap; + } + + td { + padding: 0.55rem 0.65rem; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + background: #ffffff; + white-space: nowrap; + } + + tr:last-child td { + border-bottom: 0; + } + + tbody tr:hover td { + background: #f8fafc; + } + + code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.82rem; + color: #0284c7; + background: #f0f9ff; + padding: 0.15rem 0.35rem; + border-radius: 0.25rem; + border: 1px solid #e0f2fe; + display: inline-block; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + } + + .debug-latency-badge { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.5rem; + border-radius: 0.3rem; + font-size: 0.78rem; + font-weight: 700; + white-space: nowrap; + + &.debug-latency--fast { + background: #dcfce7; + color: #166534; + } + + &.debug-latency--moderate { + background: #fef3c7; + color: #92400e; + } + + &.debug-latency--slow { + background: #fee2e2; + color: #991b1b; + } + } + + .debug-table__when { + color: #94a3b8; + font-size: 0.8rem; + white-space: nowrap; + } +} + +.debug-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 2rem 1rem; + color: #94a3b8; + text-align: center; + + i { + font-size: 2rem; + color: #cbd5e1; + } + + p { + margin: 0; + font-size: 0.85rem; + } +} + +// ── Mobile Responsiveness (Phone Mode) ── +@media (max-width: 900px) { + .debug-kpi-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .debug-grid-2col { + grid-template-columns: 1fr; + } + + .debug-tables-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 700px) { + .debug-page { + padding: 0.75rem; + } + + .debug-header { + margin-bottom: 1rem; + gap: 0.75rem; + + &__title { + gap: 0.75rem; + } + + &__icon { + width: 40px; + height: 40px; + font-size: 1.2rem; + border-radius: 0.5rem; + } + + &__text h1 { + font-size: 1.3rem; + } + + &__text p { + font-size: 0.82rem; + } + + &__actions { + width: 100%; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.4rem; + } + } + + .debug-btn { + padding: 0.5rem 0.25rem; + justify-content: center; + font-size: 0.78rem; + min-height: 40px; + + span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .debug-kpi-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + margin-bottom: 1rem; + } + + .debug-kpi-card { + padding: 0.75rem 0.65rem; + gap: 0.6rem; + border-radius: 0.5rem; + + &__icon { + width: 36px; + height: 36px; + font-size: 1rem; + border-radius: 0.45rem; + } + + &__label { + font-size: 0.7rem; + } + + &__value { + font-size: 1.1rem; + } + + &__subtext { + font-size: 0.72rem; + } + } + + .debug-section { + padding: 0.85rem; + border-radius: 0.5rem; + margin-bottom: 0.75rem; + + &__header { + padding-bottom: 0.6rem; + margin-bottom: 0.65rem; + + h3 { + font-size: 0.95rem; + } + } + } + + .debug-info-row { + padding: 0.45rem 0; + } + + .debug-info-label { + font-size: 0.82rem; + } + + .debug-info-value { + font-size: 0.82rem; + } + + .debug-table-panel { + padding: 0.65rem; + } + + .debug-table { + font-size: 0.8rem; + + th, td { + padding: 0.45rem 0.5rem; + } + + code { + max-width: 150px; + } + } +} + +@media (max-width: 480px) { + .debug-header__actions { + grid-template-columns: 1fr; + } + + .debug-kpi-grid { + grid-template-columns: 1fr; + } + + .debug-info-row { + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; + + .debug-info-value { + justify-content: flex-start; + width: 100%; + } + } +} + diff --git a/webui-src/app/scss/pages/_files.scss b/webui-src/app/scss/pages/_files.scss index c440100..9885be7 100644 --- a/webui-src/app/scss/pages/_files.scss +++ b/webui-src/app/scss/pages/_files.scss @@ -55,16 +55,26 @@ table.friendsfiles td { word-wrap: break-word; } table.friendsfiles th:nth-child(1) { - width: 2%; + width: 1.5rem; + padding-left: 0.25rem; + padding-right: 0; } table.friendsfiles th:nth-child(2) { width: 50%; + text-align: left; + padding-left: 0.25rem; } table.friendsfiles th:nth-child(4) { width: 40%; } table.friendsfiles td:nth-child(2) { text-align: start; + padding-left: 0.25rem; +} +table.friendsfiles td:nth-child(1) { + width: 1.5rem; + padding-left: 0.25rem; + padding-right: 0; } // File Search @@ -173,14 +183,204 @@ table.friendsfiles td:nth-child(2) { } } +/* File search results use div rows, not table rows. Give those rows their + own grid instead of relying on the older table selectors above. */ +.file-search-container { + align-items: stretch; + min-height: 16rem; + padding: 1rem; + background: #fff; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); + + &__keywords { + flex: 0 0 13rem; + padding: 0 1rem 0 0; + + .keywords-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + } + + .keywords-header h5 { + margin: 0; + font-size: 1rem; + } + + .clear-btn { + padding: 0.35rem 0.7rem; + } + + .keywords-container a { + padding: 0.45rem 0.55rem; + border-radius: 0.35rem; + font-size: 0.95rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover, + &.selected { + background: rgba(0, 154, 235, 0.1); + color: $primary-color; + } + } + } + + &__results { + flex: 1 1 auto; + min-width: 0; + overflow: visible; + + > h5 { + margin: 0; + color: #64748b; + } + } +} + +.results-container { + width: 100%; + border: 1px solid #dbe3ec; + border-radius: 0.5rem; + overflow: hidden; +} + +.results-row { + display: grid; + grid-template-columns: minmax(12rem, 2fr) minmax(5.5rem, 0.6fr) minmax(12rem, 1.6fr) auto; + gap: 1rem; + align-items: center; +} + +.results-header { + background: #f1f5f9; + border-bottom: 1px solid #dbe3ec; + color: #475569; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + + .results-row { + padding: 0.65rem 0.85rem; + } +} + +.results-list .file-item { + padding: 0.75rem 0.85rem; + border-bottom: 1px solid #edf2f7; + transition: background-color 0.15s ease; + + &:last-child { + border-bottom: 0; + } + + &:hover { + background: #f8fafc; + } +} + +.results-cell { + min-width: 0; +} + +.results-cell.name-col { + display: flex; + align-items: center; + gap: 0.55rem; + color: #0f172a; + font-weight: 600; + + i { + color: #0284c7; + font-size: 1.1rem; + } + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.results-cell.size-col { + color: #475569; + white-space: nowrap; +} + +.results-cell.hash-col { + overflow: hidden; + color: #64748b; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 0.78rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.download-btn-v65 { + padding: 0.45rem 0.75rem; + white-space: nowrap; +} + +.modal-content.add-file-modal { + width: min(32rem, calc(100% - 2rem)); + box-sizing: border-box; +} + +.add-file-dialog { + display: flex; + flex-direction: column; + min-width: 0; + gap: 0.75rem; + + &__heading { + display: flex; + align-items: center; + padding-right: 3rem; + gap: 0.6rem; + + h3 { + margin: 0; + white-space: nowrap; + } + } + + hr { + width: 100%; + margin: 0; + } + + label { + color: #334155; + font-weight: 600; + } + + input[type='text'] { + width: 100%; + min-width: 0; + box-sizing: border-box; + } + + button[type='submit'] { + align-self: flex-end; + } +} + .shareManagerPopupOverlay { @include popupOverlay; + z-index: 1200; + .shareManagerPopup { position: absolute; inset: 0; margin: auto; width: 80%; height: 90%; + max-width: 72rem; + box-sizing: border-box; + & > .widget { padding: 1.5rem; } @@ -194,6 +394,8 @@ table.friendsfiles td:nth-child(2) { .share-manager { @include flex(column, $justify: space-between); + min-height: 0; + &__table { margin: 1rem 0 auto; thead { @@ -241,6 +443,12 @@ table.friendsfiles td:nth-child(2) { &__actions { @include flex($justify: space-between); } + + &__empty td { + padding: 2rem 1rem; + color: #64748b; + text-align: center; + } &__form { @include flex(column, $gap: 0.5rem); &_input { @@ -284,6 +492,112 @@ table.friendsfiles td:nth-child(2) { /* FILES MODULE RESPONSIVENESS */ @media (max-width: 700px) { + .modal-content.add-file-modal { + width: calc(100% - 1rem); + min-height: 0; + padding: 1rem; + + > .close-btn { + top: 0.75rem; + right: 0.75rem; + padding: 0.55rem 0.75rem; + } + } + + .add-file-dialog { + gap: 0.85rem; + + &__heading { + min-height: 2.5rem; + + h3 { + font-size: 1.4rem; + } + } + + button[type='submit'] { + width: 100%; + min-height: 2.75rem; + } + } + + /* Share Manager is a viewport-sized sheet on phones. */ + .shareManagerPopupOverlay { + height: 100dvh; + padding: 0.5rem; + box-sizing: border-box; + background-color: rgba(15, 23, 42, 0.55); + + .shareManagerPopup { + position: relative; + width: 100%; + height: 100%; + min-width: 0; + overflow: hidden; + border-radius: 0.75rem; + background: white; + + & > .widget { + min-width: 0; + padding: 1rem; + overflow: hidden; + } + + & .close-btn { + top: 0.85rem; + right: 0.85rem; + padding: 0.55rem 0.75rem; + } + + .widget__heading { + min-height: 2.5rem; + padding-right: 3rem; + } + + .widget__heading h3 { + overflow: hidden; + font-size: 1.45rem; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } + + .share-manager { + flex: 1 1 auto; + height: auto; + overflow: hidden; + + > blockquote.info { + flex: 0 0 auto; + margin: 0.75rem 0 0; + padding: 0.75rem 0.75rem 0.75rem 2rem; + font-size: 0.9rem; + line-height: 1.35; + } + + &__table { + flex: 1 1 auto; + min-height: 0; + margin: 0.75rem 0; + padding: 0; + overflow-y: auto; + } + + &__actions { + flex: 0 0 auto; + gap: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid #e2e8f0; + + button { + flex: 1 1 0 !important; + width: auto; + min-height: 2.75rem; + } + } + } + /* General Files Layout */ .file-view__body-details { flex-direction: column; @@ -331,6 +645,44 @@ table.friendsfiles td:nth-child(2) { padding-left: 0 !important; } + .share-manager__table tr:not(.share-manager__empty) td::before { + display: block; + margin-bottom: 0.2rem; + color: #64748b; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .share-manager__table tr:not(.share-manager__empty) td:nth-child(1)::before { + content: 'Shared directory'; + } + + .share-manager__table tr:not(.share-manager__empty) td:nth-child(2)::before { + content: 'Visible name'; + } + + .share-manager__table tr:not(.share-manager__empty) td:nth-child(3)::before { + content: 'Access'; + } + + .share-manager__table tr:not(.share-manager__empty) td:nth-child(4)::before { + content: 'Visibility'; + } + + .share-manager__table .share-manager__empty { + display: table-row; + margin: 0; + padding: 0; + border: 0; + } + + .share-manager__table .share-manager__empty td { + display: table-cell; + padding: 2rem 1rem !important; + } + /* My Files / Friends Files Tables -> Cards */ table.myfiles, table.myfiles tr, @@ -356,6 +708,93 @@ table.friendsfiles td:nth-child(2) { background: white; } + /* Friends Files is a hierarchy, so keep its controls and details together + as a compact list row instead of stacking every table cell as a card. */ + table.friendsfiles tr { + display: grid; + grid-template-columns: 1.25rem minmax(0, 1fr) auto; + align-items: center; + column-gap: 0.35rem; + margin-bottom: 0.5rem; + padding: 0.65rem 0.5rem; + } + + table.friendsfiles td { + display: block; + width: auto !important; + margin: 0; + padding: 0 !important; + border: 0 !important; + } + + table.friendsfiles td:nth-child(1) { + grid-column: 1; + } + + table.friendsfiles td:nth-child(2) { + grid-column: 2; + left: 0 !important; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + table.friendsfiles td:nth-child(3) { + grid-column: 3; + white-space: nowrap; + } + + table.friendsfiles td:nth-child(4) { + grid-column: 2 / -1; + margin-top: 0.4rem; + } + + table.myfiles tr { + display: grid; + grid-template-columns: 1.25rem minmax(0, 1fr) auto; + align-items: center; + column-gap: 0.35rem; + margin-bottom: 0.5rem; + padding: 0.65rem 0.5rem; + } + + table.myfiles td { + display: block; + width: auto !important; + margin: 0; + padding: 0 !important; + border: 0 !important; + } + + table.myfiles td:nth-child(1) { + grid-column: 1; + } + + table.myfiles td:nth-child(2) { + grid-column: 2; + left: 0 !important; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + table.myfiles td:nth-child(3) { + grid-column: 3; + white-space: nowrap; + } + + .my-files__configure-shares { + width: 2.4rem; + min-width: 2.4rem; + padding: 0.55rem !important; + } + + .my-files__configure-shares span { + display: none; + } + /* Search Container Layout */ .file-search-container { flex-direction: column; @@ -393,4 +832,61 @@ table.friendsfiles td:nth-child(2) { margin-bottom: 0.5rem; word-break: break-all; } + + .search-form { + width: auto; + flex: 1; + max-width: 18rem; + } + + .file-search-container { + gap: 0.75rem; + padding: 0.75rem; + + &__keywords { + padding: 0 0 0.75rem; + margin: 0; + } + + &__results { + width: 100%; + } + } + + .results-header { + display: none; + } + + .results-list .file-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'name action' + 'size hash'; + gap: 0.45rem 0.75rem; + padding: 0.8rem; + } + + .results-cell.name-col { + grid-area: name; + } + + .results-cell.size-col { + grid-area: size; + font-size: 0.82rem; + } + + .results-cell.hash-col { + grid-area: hash; + max-width: 10rem; + text-align: right; + } + + .results-cell.action-col { + grid-area: action; + } + + .download-btn-v65 { + padding: 0.4rem 0.6rem; + } } diff --git a/webui-src/app/scss/pages/_forums.scss b/webui-src/app/scss/pages/_forums.scss index 158860c..5d6f010 100644 --- a/webui-src/app/scss/pages/_forums.scss +++ b/webui-src/app/scss/pages/_forums.scss @@ -5,6 +5,37 @@ animation: fadein 0.5s; } +.forum-thread-view { + width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.forum-post-content { + max-width: 100%; + box-sizing: border-box; + overflow-wrap: anywhere; + + img, + video, + iframe { + max-width: 100%; + height: auto; + } + + pre { + max-width: 100%; + overflow: auto; + white-space: pre-wrap; + } + + table { + display: block; + max-width: 100%; + overflow-x: auto; + } +} + /* subject */ table.forums th:nth-child(1) { width: 50%; @@ -38,6 +69,62 @@ table.forums tr.hidden { position: relative; padding: 10px; } + +.forum-detail-navigation { + display: flex; + align-items: center; +} + +.forum-mobile-search, +.forum-mobile-actions, +.forum-mobile-create, +.forums-heading-create { + display: none; +} + +.forum-detail-heading { + margin-top: .75rem; + + h3 { margin: 0; } +} + +.forum-detail-default-thumbnail { + display: flex; + flex: 0 0 6rem; + align-items: center; + justify-content: center; + width: 6rem; + height: 6rem; + border: 1px solid #cbd5e1; + border-radius: 4px; + background: linear-gradient(135deg, #eef6fb, #dbeafe); + color: #3ba4d7; + box-sizing: border-box; + + i { font-size: 2.75rem; } +} + +.forum-threads { + margin-top: 1rem; + + &__heading { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 1rem; + padding-bottom: .35rem; + border-bottom: 1px solid #d7dee8; + } + + &__heading h3 { margin: 0; } + + &__create { + display: inline-flex; + align-items: center; + gap: .35rem; + } +} + .p { margin: 0; } @@ -46,12 +133,293 @@ table.forums tr.hidden { background: gray; } -table.threads tr:hover { - background-color: #eef3f6; - cursor: pointer; +table.threads { + width: 100%; + border-collapse: collapse; + + tr.forum-thread-row { + border-bottom: 1px solid #f1f5f9; + cursor: pointer; + transition: background-color .15s ease; + + &:hover { + background-color: #f8fafc; + } + + &.forum-thread-row--unread { + background-color: #f0f9ff; + + .forum-thread-row__title { + font-weight: 700; + color: #0369a1; + } + } + } + + .forum-thread-row__cell { + padding: .65rem .5rem; + text-align: left !important; + } + + .forum-thread-row__title { + font-size: 1.05rem; + font-weight: 600; + color: #1e293b; + margin-bottom: .25rem; + word-break: break-word; + line-height: 1.3; + } + + .forum-thread-row__meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: .35rem; + font-size: .82rem; + color: #64748b; + } + + .forum-thread-row__author { + font-style: normal; + color: #475569; + font-weight: 500; + } + + .forum-thread-row__bullet { + color: #cbd5e1; + font-size: .75rem; + } + + .forum-thread-row__date { + color: #94a3b8; + } + + td { + word-wrap: break-word; + } } -table.threads td { - word-wrap: break-word; + +@media (max-width: 700px) { + .tab-content:has(.forums-detail-widget), + .tab-content:has(.forums-thread-widget) { + position: relative; + } + + .widget.forums-detail-widget { + display: flex; + flex-direction: column; + row-gap: 4px; + + > .top-heading { + display: none; + } + + .forum-subscription--subscribed, + .forum-threads > .forum-threads__heading { + display: none; + } + + .forum-threads { + margin-top: 6px; + min-height: 0; + } + } + + .widget.forums-thread-widget { + > .top-heading { + display: none; + } + } + + .forum-detail-navigation { + justify-content: space-between; + gap: .5rem; + } + + a.forum-back[title='Back'] { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + flex-shrink: 0; + border-radius: 50%; + background: #e8f4fc; + color: #0788cb; + font-size: 1.15rem; + text-decoration: none; + cursor: pointer; + + &:hover { + background: #d5ebfa; + } + + .fa-arrow-left::before { + content: "\f053"; + } + } + + .forum-mobile-search { + display: flex; + align-items: center; + min-width: 0; + flex: 1; + margin-right: 44px; + + input { + min-width: 0; + width: 100%; + height: 36px; + padding: 0 .75rem; + border: 1px solid #cbd5e1; + border-radius: .375rem; + font-size: .85rem; + background: #fff; + box-sizing: border-box; + + &:focus { + border-color: #0788cb; + outline: none; + } + } + } + + .forum-mobile-create { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + font-size: .85rem; + border-radius: .375rem; + border: 0; + background: #0788cb; + color: #fff; + cursor: pointer; + + &:hover { + background: #0672aa; + } + } + + .forum-mobile-actions { + display: block; + position: absolute; + top: 0; + right: .5rem; + z-index: 1001; + + summary { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + cursor: pointer; + list-style: none; + } + + summary::-webkit-details-marker { + display: none; + } + + summary:focus-visible { + outline: 2px solid #0788cb; + } + + &__items { + position: absolute; + right: 0; + top: 100%; + min-width: 10rem; + padding: .35rem; + background: #fff; + border: 1px solid #cbd5e1; + border-radius: .375rem; + box-shadow: 0 4px 12px rgba(15, 23, 42, .15); + } + + &__items button { + width: 100%; + min-height: 44px; + text-align: left; + background: #fff; + color: #334155; + box-shadow: none; + border: 0; + padding: 0 .75rem; + border-radius: .25rem; + cursor: pointer; + } + + &__items button:hover { + background: #f1f5f9; + } + } + + .forum-detail-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: .5rem; + margin-top: .25rem; + margin-bottom: .25rem; + padding-bottom: .25rem; + border-bottom: 1px solid #e2e8f0; + + h3 { + font-size: 1.2rem; + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + + table.threads { + .forum-thread-row__cell { + padding: .4rem .25rem; + text-align: left !important; + } + + .forum-thread-row__title { + font-size: .92rem; + margin-bottom: .15rem; + } + + .forum-thread-row__meta { + font-size: .76rem; + gap: .25rem; + } + } + + #searchforum { + margin-left: 0; + width: 100%; + } + + .forums-create-button { + display: none; + } + + .forums-heading-create { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + font-size: .85rem; + border-radius: .375rem; + border: 0; + background: #0788cb; + color: #fff; + cursor: pointer; + } } table.threadreply th:nth-child(2) { width: 50%; @@ -70,3 +438,247 @@ table.threadreply tr:hover { background-color: #eef3f6; cursor: pointer; } + +/* Create Forum uses the same compact form language as Create Channel. */ +.create-forum-form { + display: grid !important; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem 1.5rem !important; + + &__heading, + &__title, + &__moderators, + &__description, + &__submit { grid-column: 1 / -1; } + &__heading h3 { margin: 0; color: #1e293b; } + &__heading p { margin: .25rem 0 0; color: #64748b; font-size: .9rem; } + &__title, + &__description { width: 100%; box-sizing: border-box; } + &__description { min-height: 7rem; resize: vertical; } + &__field { display: flex; flex-direction: column; gap: .35rem; min-width: 0; } + &__field label, + &__moderators-heading label { color: #475569; font-size: .85rem; font-weight: 700; } + &__field .config-style-select { + width: 100%; min-width: 0; box-sizing: border-box; padding: .4rem; + border: 1px solid #cbd5e1; border-radius: 4px; background: #fff; + color: #1e293b; font-size: .95rem; + } + &__moderators { min-width: 0; } + &__moderators-heading { display: flex; justify-content: space-between; margin-bottom: .4rem; } + &__moderators-heading span { color: #64748b; font-size: .8rem; } + &__moderators-toggle { display: inline-flex; align-items: center; gap: .45rem; cursor: pointer; } + &__moderators-toggle input { margin: 0; } + &__moderator-controls { display: flex; flex-direction: column; gap: .4rem; } + &__moderator-controls > .config-style-select { + width: 100%; box-sizing: border-box; padding: .4rem; + border: 1px solid #cbd5e1; border-radius: 4px; background: #fff; + } + &__search { position: relative; } + &__search i { position: absolute; left: .65rem; top: 50%; transform: translateY(-50%); color: #94a3b8; } + &__search input { width: 100%; box-sizing: border-box; padding-left: 2rem; } + &__moderator-list { + max-height: 13rem; overflow-y: auto; + border: 1px solid #cbd5e1; border-radius: 4px; background: #fff; + } + &__moderator { + display: flex; align-items: center; gap: .65rem; padding: .45rem .65rem; + border-bottom: 1px solid #e2e8f0; cursor: pointer; + } + &__moderator:last-child { border-bottom: 0; } + &__moderator:hover { background: #f1f5f9; } + &__moderator > span { display: flex; flex-direction: column; min-width: 0; } + &__moderator > .jdenticon-avatar, + &__moderator > .defaultAvatar, + &__moderator > img.avatar { flex: 0 0 30px; margin-right: 0; } + &__moderator b { color: #1e293b; font-size: .9rem; } + &__moderator small { color: #64748b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + &__empty { padding: 1rem; color: #64748b; text-align: center; } + &__submit { justify-self: end; } +} + +.modal-content.create-forum-modal { + width: min(720px, calc(100% - 2rem)); + max-height: calc(100% - 2rem); + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; +} + +@media (max-width: 600px) { + .modal-content.create-forum-modal { width: calc(100% - 1rem); max-height: calc(100% - 1rem); padding: 1rem; } + .create-forum-form { + grid-template-columns: minmax(0, 1fr); + &__heading, + &__title, + &__field, + &__moderators, + &__description, + &__submit { grid-column: 1; } + &__moderator-list { max-height: 10rem; } + &__submit { width: 100%; } + } +} + +.forum-thread-composer { + width: min(720px, 100%); + height: auto; + box-sizing: border-box; + gap: .85rem; + + &__heading { padding-right: 5.5rem; } + &__heading-copy { min-width: 0; } + &__heading h3 { margin: 0; color: #1e293b; } + &__heading p { margin: .25rem 0 0; color: #64748b; font-size: .9rem; } + &__fullscreen { position: absolute; z-index: 2; top: 1.25rem; right: 4.25rem; display: flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem; margin: 0; padding: 0; border: 0; border-radius: 4px; background: #f1f5f9; color: #475569; box-shadow: none; } + &__fullscreen:hover { background: #e0f2fe; color: #0284c7; } + &__reply { padding: .6rem .75rem; border-radius: 4px; background: #f1f5f9; color: #475569; } + &__title { width: 100%; box-sizing: border-box; } + &__field { display: flex; flex-direction: column; gap: .35rem; } + &__field label { color: #475569; font-size: .85rem; font-weight: 700; } + &__field select { width: 100%; box-sizing: border-box; padding: .45rem; border: 1px solid #cbd5e1; border-radius: 4px; background: #fff; } + &__editor { position: relative; } + &__editor textarea { display: block; width: 100%; min-height: 8rem; padding: .75rem; border-radius: 4px 4px 0 0; box-sizing: border-box; resize: vertical; } + &__toolbar { position: relative; display: flex; gap: .25rem; padding: .4rem .5rem; border: 1px solid #cbd5e1; border-top: 0; border-radius: 0 0 4px 4px; background: #f8fafc; } + &__toolbar > input[type=file] { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; } + &__tool { display: inline-flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #0284c7; cursor: pointer; box-shadow: none; box-sizing: border-box; } + &__tool:hover, + &__tool.active { background: #e0f2fe; } + &__emoji-picker { position: absolute; left: 2.75rem; bottom: 3rem; z-index: 10; width: min(22rem, calc(100vw - 4rem)); padding: .5rem; border: 1px solid #cbd5e1; border-radius: 8px; background: #fff; box-shadow: 0 10px 25px rgba(15, 23, 42, .18); } + &__emoji-categories { display: flex; gap: .15rem; overflow-x: auto; padding-bottom: .35rem; border-bottom: 1px solid #e2e8f0; } + // color too: the global `button` rule paints its text white for a coloured + // background, and this panel is white. Without this the emojis are there and + // clickable, just invisible. + &__emoji-categories button, + &__emoji-grid button { border: 0; background: transparent; box-shadow: none; cursor: pointer; color: #0f172a; } + &__emoji-categories button { flex: 0 0 2rem; padding: .3rem; border-radius: 4px; } + &__emoji-categories button.active { background: #e0f2fe; } + &__emoji-grid { display: grid; grid-template-columns: repeat(8, 1fr); max-height: 12rem; overflow-y: auto; padding-top: .4rem; } + &__emoji-grid button { padding: .3rem; font-size: 1.15rem; } + &__file-panel { display: flex; flex-direction: column; gap: .35rem; padding: .65rem; border: 1px solid #cbd5e1; border-top: 0; background: #f8fafc; } + &__file-panel > div { display: flex; gap: .4rem; min-width: 0; } + &__file-panel input { flex: 1 1 auto; min-width: 0; box-sizing: border-box; } + &__file-panel label { display: flex; flex: 0 0 2.4rem; align-items: center; justify-content: center; border: 1px solid #cbd5e1; border-radius: 4px; background: #fff; color: #0284c7; cursor: pointer; } + &__file-panel button { flex: 0 0 auto; } + &__file-panel small { color: #64748b; } + &__file-panel small.error-text { color: #dc2626; } + &__inline-images { display: flex; flex-direction: column; gap: .65rem; padding: .75rem; border: 1px solid #cbd5e1; border-top: 0; background: #fff; } + &__inline-image { position: relative; align-self: flex-start; max-width: 100%; } + &__inline-image img { display: block; max-width: 100%; max-height: 12rem; border-radius: 6px; object-fit: contain; } + &__inline-image button { position: absolute; top: .4rem; right: .4rem; display: flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; padding: 0; border: 0; border-radius: 50%; background: rgba(15, 23, 42, .78); color: #fff; box-shadow: none; } + &__attachments { padding: .65rem; border: 1px solid #d7dee8; border-radius: 6px; background: #f8fafc; } + &__attachments-heading { display: flex; align-items: center; gap: .4rem; margin-bottom: .5rem; color: #475569; font-size: .85rem; font-weight: 700; } + &__attachment-list { display: flex; flex-wrap: wrap; gap: .5rem; max-height: 6rem; overflow-y: auto; } + &__attachment { display: flex; align-items: center; gap: .5rem; max-width: 18rem; padding: .35rem .5rem; border: 1px solid #cbd5e1; border-radius: 6px; background: #fff; } + &__attachment > img { width: 2rem; height: 2rem; border-radius: 4px; object-fit: cover; } + &__attachment > i { width: 2rem; color: #3b82f6; text-align: center; } + &__attachment > span { display: flex; flex-direction: column; min-width: 0; } + &__attachment b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .82rem; } + &__attachment small { color: #64748b; } + &__attachment button { margin-left: auto; padding: .25rem; border: 0; background: transparent; color: #ef4444; box-shadow: none; } + &__capacity { color: #64748b; font-size: .78rem; text-align: right; } + &__capacity.is-over-limit { color: #dc2626; font-weight: 700; } + &__actions { display: flex; justify-content: flex-end; } +} + +.modal-content.create-forum-thread-modal { + width: min(720px, calc(100% - 2rem)); + height: fit-content; + max-height: calc(100% - 2rem); + min-height: 0; + padding: 1.25rem; + box-sizing: border-box; + overflow: hidden; + + > .forum-thread-composer { + flex: 0 1 auto; + height: auto; + max-height: calc(100vh - 4.5rem); + min-height: 0; + padding-right: .25rem; + overflow-x: hidden; + overflow-y: auto; + } +} + +.modal-content.create-forum-thread-modal:not(.is-fullscreen) { + height: fit-content !important; + + > .forum-thread-composer { + flex-grow: 0; + flex-shrink: 1; + align-self: stretch; + } +} + +.modal-content.create-forum-thread-modal.is-fullscreen { + width: min(1100px, calc(100% - 3rem)); + height: min(760px, calc(100vh - 3rem)); + max-width: 1100px; + max-height: calc(100vh - 3rem); + min-height: 0; + overflow: hidden; + + > .forum-thread-composer { + width: 100%; + height: auto; + max-height: 100%; + min-height: 0; + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; + } + + .forum-thread-composer__title, + .forum-thread-composer__field, + .forum-thread-composer__editor, + .forum-thread-composer__toolbar, + .forum-thread-composer__attachments, + .forum-thread-composer__inline-images { + width: 100%; + box-sizing: border-box; + } + + .forum-thread-composer__editor textarea { + width: 100%; + height: clamp(14rem, calc(100vh - 25rem), 28rem); + min-height: 14rem; + max-height: 28rem; + box-sizing: border-box; + } +} + +@media (max-width: 600px) { + .modal-content.create-forum-thread-modal { + width: calc(100% - 1rem); + height: auto; + max-height: calc(100% - 1rem); + padding: 1rem; + overflow-y: auto; + } + .modal-content.create-forum-thread-modal > .forum-thread-composer { + width: 100%; + height: auto; + max-height: calc(100vh - 3rem); + padding: 0; + overflow-y: visible; + } + .forum-thread-composer__fullscreen { display: none; } + .forum-thread-composer__editor textarea { min-height: 7rem; } + .forum-thread-composer__emoji-picker { left: 0; width: calc(100vw - 3rem); box-sizing: border-box; } + .forum-thread-composer__emoji-grid { grid-template-columns: repeat(6, 1fr); } + .forum-thread-composer__file-panel > div { flex-wrap: wrap; } + .forum-thread-composer__file-panel input { flex-basis: calc(100% - 3rem); } + .forum-thread-composer__file-panel button { flex: 1 1 100%; } + .forum-thread-composer__attachment { max-width: 100%; flex: 1 1 100%; } + .forum-thread-composer__actions button { width: 100%; } +} + +/* The shared media-item hides its description at phone widths (_media.scss, + * !important) -- right for channel/board media grids, wrong for the forum + * header, whose description was visible on phone before the shared component + * replaced the old detail card. */ +@media (max-width: 768px) { + .forum-detail-heading + .media-item .media-item__desc { + display: block !important; + } +} diff --git a/webui-src/app/scss/pages/_home.scss b/webui-src/app/scss/pages/_home.scss index 5090d03..e66622d 100644 --- a/webui-src/app/scss/pages/_home.scss +++ b/webui-src/app/scss/pages/_home.scss @@ -119,4 +119,413 @@ } } } -} \ No newline at end of file +} + +/* Responsive Mobile Home Page Styles */ +@media (max-width: 768px) { + .homepage { + margin: 1rem auto !important; + padding: 0 1rem !important; + gap: 2rem !important; + max-width: 100% !important; + box-sizing: border-box !important; + + .logo { + flex-direction: column !important; + gap: 0.5rem !important; + text-align: center !important; + + & img { + width: 60px !important; + } + + .retroshareText { + .retrotext { + font-size: 1.6rem !important; + } + & > b { + font-size: 0.75rem !important; + } + } + } + + .certificate { + gap: 2rem !important; + + &__heading { + & > h1 { + font-size: 1.35rem !important; + margin-bottom: 0.5rem !important; + } + font-size: 0.85rem !important; + } + + &__content { + padding: 1rem 0.75rem !important; + gap: 1.25rem !important; + + .retroshareID { + padding: 0.5rem !important; + font-size: 0.85rem !important; + max-width: 100% !important; + + .textArea { + font-size: 0.8rem !important; + word-break: break-all !important; + overflow-wrap: anywhere !important; + } + } + } + } + } +} + +.modal-content.web-help-modal { + width: min(30rem, calc(100% - 2rem)); + min-height: 0; + max-height: calc(100dvh - 2rem); + box-sizing: border-box; + overflow-y: auto; +} + +.web-help-confirmation { + min-width: 0; + + h3 { + margin: 0 3rem 0.75rem 0; + font-size: 1.5rem; + } + + p { + margin: 0.75rem 0; + line-height: 1.5; + } + + &__url { + padding: 0.65rem 0.75rem; + color: color.adjust($dark-color, $alpha: -0.2); + background: color.adjust($primary-retro-color, $alpha: -0.92); + border-radius: 4px; + overflow-wrap: anywhere; + word-break: break-word; + } +} + +@media (max-width: 600px) { + .modal-content.web-help-modal { + width: calc(100% - 1rem); + max-height: calc(100dvh - 1rem); + padding: 1rem; + } + + .web-help-confirmation { + h3 { + font-size: 1.25rem; + } + + button:last-child { + width: 100%; + } + } +} + +.modal-content.copy-confirmation-modal { + width: min(28rem, calc(100% - 2rem)); + min-height: 0; + box-sizing: border-box; + + h3 { + margin: 0 3rem 0.75rem 0; + font-size: 1.5rem; + } + + p { + line-height: 1.5; + } +} + +@media (max-width: 600px) { + .modal-content.copy-confirmation-modal { + width: calc(100% - 1rem); + max-height: calc(100% - 1rem); + padding: 1rem; + + h3 { + font-size: 1.25rem; + } + + button:last-child { + width: 100%; + } + } +} + +.add-friend-wizard { + width: 100%; + min-width: 0; + box-sizing: border-box; + + &__heading { + @include flex($align: center, $gap: 1rem); + margin-bottom: 1.5rem; + + > i { + display: grid; + place-items: center; + width: 3rem; + height: 3rem; + flex: 0 0 auto; + color: white; + background: $primary-retro-color; + border-radius: 50%; + font-size: 1.25rem; + } + + h3, + p { + margin: 0; + } + + p { + margin-top: 0.25rem; + color: color.adjust($dark-color, $alpha: -0.25); + } + } + + .cert-drop-zone { + padding: 1.25rem; + border: 2px dashed color.adjust($primary-retro-color, $alpha: -0.55); + border-radius: 6px; + transition: border-color 120ms ease, background-color 120ms ease; + + &--active { + border-color: $primary-retro-color; + background: color.adjust($primary-retro-color, $alpha: -0.92); + } + + > label:first-child { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + } + + textarea { + display: block; + width: 100%; + box-sizing: border-box; + padding: 0.75rem; + resize: vertical; + font-family: monospace; + overflow-wrap: anywhere; + } + } + + &__divider { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 1rem 0; + color: color.adjust($dark-color, $alpha: -0.4); + + &::before, + &::after { + content: ''; + height: 1px; + flex: 1; + background: color.adjust($dark-color, $alpha: -0.85); + } + } + + &__file { + @include flex($align: center, $gap: 0.75rem); + color: color.adjust($dark-color, $alpha: -0.3); + + input { + display: none; + } + + label { + margin: 0; + white-space: nowrap; + cursor: pointer; + } + } + + &__actions { + display: flex; + justify-content: flex-end; + margin-top: 1.25rem; + + button:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } +} + +.modal-content.add-friend-modal { + width: min(42rem, calc(100% - 2rem)); + max-height: calc(100% - 2rem); + min-height: 0; + box-sizing: border-box; + overflow: auto; +} + +@media (max-width: 600px) { + .modal-content.add-friend-modal { + width: calc(100% - 1rem); + max-height: calc(100% - 1rem); + padding: 1rem; + } + + .add-friend-wizard { + &__heading { + align-items: flex-start; + padding-right: 2.25rem; + + > i { + width: 2.5rem; + height: 2.5rem; + font-size: 1rem; + } + + h3 { + font-size: 1.5rem; + } + } + + .cert-drop-zone { + padding: 0.75rem; + + textarea { + min-height: 8rem; + } + } + + &__file { + align-items: stretch; + flex-direction: column; + + label { + width: 100%; + box-sizing: border-box; + text-align: center; + } + + span { + overflow-wrap: anywhere; + } + } + + &__actions button { + width: 100%; + } + } +} + +.modal-content.friend-confirmation-modal { + width: min(34rem, calc(100% - 2rem)); + max-height: calc(100% - 2rem); + min-height: 0; + box-sizing: border-box; + overflow: auto; +} + +.friend-confirmation { + min-width: 0; + + &__heading { + @include flex($align: center, $gap: 1rem); + padding-right: 2.5rem; + margin-bottom: 1.25rem; + + > i { + display: grid; + place-items: center; + width: 3rem; + height: 3rem; + flex: 0 0 auto; + color: white; + background: $primary-retro-color; + border-radius: 50%; + } + + h3, + p { + margin: 0; + } + + p { + margin-top: 0.25rem; + color: color.adjust($dark-color, $alpha: -0.3); + } + } + + &__details { + overflow: hidden; + border: 1px solid color.adjust($dark-color, $alpha: -0.85); + border-radius: 6px; + } + + &__row { + display: grid; + grid-template-columns: 7rem minmax(0, 1fr); + gap: 1rem; + padding: 0.75rem 1rem; + + + .friend-confirmation__row { + border-top: 1px solid color.adjust($dark-color, $alpha: -0.9); + } + + code, + span, + strong { + min-width: 0; + overflow-wrap: anywhere; + } + } + + &__label { + color: color.adjust($dark-color, $alpha: -0.35); + font-weight: 600; + } + + &__actions { + display: flex; + justify-content: flex-end; + margin-top: 1.25rem; + } +} + +@media (max-width: 600px) { + .modal-content.friend-confirmation-modal { + width: calc(100% - 1rem); + max-height: calc(100% - 1rem); + padding: 1rem; + } + + .friend-confirmation { + &__heading { + align-items: flex-start; + + > i { + width: 2.5rem; + height: 2.5rem; + } + + h3 { + font-size: 1.5rem; + } + } + + &__row { + grid-template-columns: 1fr; + gap: 0.25rem; + padding: 0.65rem 0.75rem; + } + + &__actions button { + width: 100%; + } + } +} diff --git a/webui-src/app/scss/pages/_index.scss b/webui-src/app/scss/pages/_index.scss index 9e3bdd9..2391f96 100644 --- a/webui-src/app/scss/pages/_index.scss +++ b/webui-src/app/scss/pages/_index.scss @@ -9,3 +9,5 @@ @forward "forums"; @forward "board"; @forward "config"; +@forward "statistics"; +@forward "debug"; diff --git a/webui-src/app/scss/pages/_mail.scss b/webui-src/app/scss/pages/_mail.scss index f6bd375..e2d4e93 100644 --- a/webui-src/app/scss/pages/_mail.scss +++ b/webui-src/app/scss/pages/_mail.scss @@ -1,86 +1,1643 @@ @use 'sass:color'; @use '../abstracts/' as *; -.side-bar { - @include flex(column); - background: white; - .mail-compose-btn { - width: 96%; - margin: 0.25rem; - padding: 0.75rem 0; +// ========================================================================== +// OUTLOOK-STYLE 3-PANE MAIL CONTAINER (Desktop & Mobile) +// ========================================================================== + +.mail-outlook-container { + display: flex; + width: 100%; + height: 100%; + overflow: hidden; + background-color: #f1f5f9; + position: relative; +} + +// Drawer backdrop for mobile screens +.mail-drawer-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.45); + backdrop-filter: blur(2px); + z-index: 1150; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +// ========================================================================== +// 1. LEFT PANE: Folders & Categories Navigation +// ========================================================================== + +.mail-folders-pane { + width: 250px; + min-width: 230px; + max-width: 280px; + height: 100%; + background: #ffffff; + border-right: 1px solid #e2e8f0; + display: flex; + flex-direction: column; + flex-shrink: 0; + z-index: 10; +} + +.mail-folders-header { + padding: 1rem; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); +} + +.mail-compose-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.55rem; + width: 100%; + padding: 0.65rem 1.15rem; + border-radius: 4px; + background-color: #0284c7; + color: #ffffff; + font-weight: 600; + font-size: 0.95rem; + border: none; + cursor: pointer; + box-shadow: 0 1px 3px rgba(2, 132, 199, 0.25); + transition: background-color 0.15s ease, box-shadow 0.15s ease, transform 0.12s ease; + + i { + font-size: 1.05rem; + line-height: 1; + } + + span { + line-height: 1; + } + + &:hover { + background-color: #0369a1; + box-shadow: 0 2px 6px rgba(2, 132, 199, 0.35); + } + + &:active { + background-color: #075985; + transform: translateY(1px); + box-shadow: none; + } +} + +.mail-nav-scroll { + flex: 1; + overflow-y: auto; + padding: 0.5rem 0.75rem 1.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.mail-nav-section-title { + font-size: 0.725rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + padding: 0.75rem 0.6rem 0.25rem; +} + +.mail-nav-list { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.mail-nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 0.75rem; + border-radius: 0.5rem; + color: #334155; + font-size: 0.9rem; + font-weight: 500; + text-decoration: none; + transition: all 0.15s ease; + user-select: none; + border-left: 3px solid transparent; + + i { + font-size: 1.05rem; + width: 20px; + text-align: center; + flex-shrink: 0; + color: #1e293b; + transition: color 0.15s ease; + } + + .mail-category-dot { + width: 18px; + height: 18px; + min-width: 18px; + border-radius: 50%; + margin: 0 1px; + flex-shrink: 0; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + } + + .mail-nav-label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .mail-nav-badge { + font-size: 0.725rem; + font-weight: 600; + padding: 0.15rem 0.45rem; + border-radius: 999px; + background: #f1f5f9; + color: #64748b; + margin-left: auto; + } + + .mail-nav-badge--unread { + background: #0284c7; + color: #ffffff; + font-weight: 700; + } + + &:hover { + background: #f8fafc; + color: #0f172a; + + i { + color: #0f172a; + } + } + + &.active { + background: #e0f2fe; + color: #0369a1; + font-weight: 600; + border-left-color: #0284c7; + + i { + color: #0284c7; + } + } +} + +// ========================================================================== +// 2. MIDDLE PANE: Message List (Cards or Table) +// ========================================================================== + +.mail-list-pane { + width: 400px; + min-width: 350px; + max-width: 460px; + height: 100%; + background: #ffffff; + border-right: 1px solid #e2e8f0; + display: flex; + flex-direction: column; + flex-shrink: 0; + + &--table-view { + width: auto; + min-width: 0; + max-width: none; + flex: 1; + border-right: none; + } + + &--table-selected-hidden { + display: none !important; + } +} + +.mail-list-header { + padding: 0.85rem 1rem 0.75rem; + border-bottom: 1px solid #e2e8f0; + background: #ffffff; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.mail-list-header-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.mail-mobile-nav-toggle { + display: none; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + background: #f8fafc; + color: #334155; + cursor: pointer; + font-size: 1.1rem; + + &:hover { + background: #f1f5f9; + color: #0284c7; + } +} + +.mail-folder-title-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; + + i { + font-size: 1.15rem; + color: #1e293b; + } + + .mail-folder-heading { + margin: 0; + font-size: 1.2rem; + font-weight: 700; + color: #0f172a; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .mail-folder-count { + font-size: 0.85rem; + color: #64748b; + font-weight: 500; + } +} + +.mail-view-toggle { + display: inline-flex; + background: #f1f5f9; + padding: 2px; + border-radius: 0.45rem; + gap: 2px; + border: 1px solid #e2e8f0; +} + +.mail-toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 30px; + height: 28px; + padding: 0 0.45rem; + border: none; + border-radius: 0.35rem; + background: transparent; + color: #64748b; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s ease; + + i { + font-size: 0.85rem; + } + + &:hover { + color: #0f172a; + } + + &.active { + background-color: #0284c7; + color: #ffffff; + box-shadow: 0 1px 3px rgba(2, 132, 199, 0.35); + + i { + color: #ffffff; + } + + &:hover { + background-color: #0369a1; + color: #ffffff; + + i { + color: #ffffff; + } + } + } +} + +.mail-search-wrapper { + position: relative; + display: flex; + align-items: center; + + .mail-search-icon { + position: absolute; + left: 0.75rem; + color: #94a3b8; + font-size: 0.85rem; + pointer-events: none; + } + + .mail-search-input { + width: 100%; + padding: 0.45rem 1.85rem 0.45rem 2.15rem; + border: 1px solid #cbd5e1; + border-radius: 0.5rem; + background: #f8fafc; + font-size: 0.85rem; + color: #1e293b; + outline: none; + transition: all 0.15s ease; + + &:focus { + background: #ffffff; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12); + } + } + + .mail-search-clear { + position: absolute; + right: 0.5rem; + background: transparent; + border: none; + color: #94a3b8; + cursor: pointer; + padding: 0.25rem; + font-size: 0.75rem; + + &:hover { + color: #334155; + } + } +} + +.mail-filter-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.mail-filter-tabs { + display: flex; + gap: 0.35rem; +} + +.mail-filter-pill { + border: none !important; + background: transparent; + box-shadow: none !important; + outline: none !important; + padding: 0.25rem 0.65rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; + color: #64748b; + cursor: pointer; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 0.35rem; + + &:hover { + background: #f1f5f9 !important; + color: #1e293b; + box-shadow: none !important; + } + + &.active { + background: #e0f2fe !important; + color: #0284c7; + box-shadow: none !important; + + &:hover { + background: #bae6fd !important; + color: #0284c7; + box-shadow: none !important; + } + } + + &:active { + box-shadow: none !important; + transform: scale(0.96); + } + + .mail-unread-pill-count { + background: #0284c7; + color: #ffffff; + font-size: 0.7rem; + padding: 0.05rem 0.35rem; + border-radius: 999px; + font-weight: 700; + box-shadow: none !important; + } +} + +.mail-tag-select { + border: 1px solid #e2e8f0; + background: #f8fafc; + font-size: 0.775rem; + font-weight: 500; + color: #475569; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + outline: none; + cursor: pointer; + + &:focus { + border-color: #3b82f6; + } +} + +.mail-list-body { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + background: #f8fafc; +} + +// Empty state +.mail-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3.5rem 1.5rem; + text-align: center; + color: #94a3b8; + + .mail-empty-icon { + font-size: 3rem; + color: #cbd5e1; + margin-bottom: 0.75rem; + } + + h4 { + margin: 0 0 0.35rem; + font-size: 1.1rem; + font-weight: 600; + color: #475569; + } + + p { + margin: 0; + font-size: 0.85rem; + max-width: 250px; + } +} + +// ========================================================================== +// CARD VIEW: Outlook-style Mail Preview Cards +// ========================================================================== + +.mail-cards-container { + display: flex; + flex-direction: column; + gap: 1px; + background: #e2e8f0; +} + +.mail-card-item { + position: relative; + display: flex; + gap: 0.75rem; + padding: 0.85rem 1rem; + background: #ffffff; + cursor: pointer; + transition: all 0.15s ease; + border-left: 3.5px solid transparent; + + &:hover { + background: #f8fafc; + } + + &.selected { + background: #e0f2fe !important; + border-left-color: #0284c7 !important; + + &:hover { + background: #bae6fd !important; + } + + .mail-card-sender { + color: #0f172a; + font-weight: 600; + } + + .mail-card-subject { + color: #0369a1; + font-weight: 600; + } + + .mail-card-date { + color: #64748b; + } + + .mail-card-snippet { + color: #334155; + } + + .mail-card-clip { + color: #64748b; + } + + .mail-card-star-btn { + color: #94a3b8; + + &.starred, + &:hover { + color: #f59e0b; + } + } + + .mail-card-spam-btn { + color: #94a3b8; + + &.spammed, + &:hover { + color: #f97316; + } + } + } + + &.unread { + background: #f0f9ff; + + .mail-card-subject { + font-weight: 700; + color: #0f172a; + } + + .mail-card-sender { + font-weight: 700; + color: #0f172a; + } + } + + .mail-card-unread-dot { + position: absolute; + top: 1.15rem; + left: 0.35rem; + width: 7px; + height: 7px; + border-radius: 50%; + background: #0284c7; + } + + .mail-card-avatar-col { + flex-shrink: 0; + padding-top: 0.1rem; + } + + .mail-card-content-col { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; + } + + .mail-card-row-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + } + + .mail-card-sender { + font-size: 0.9rem; + font-weight: 600; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .mail-card-date { + font-size: 0.75rem; + color: #94a3b8; + flex-shrink: 0; + font-weight: 500; + } + + .mail-card-row-subject { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + } + + .mail-card-subject { + font-size: 0.875rem; + color: #334155; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + flex: 1; + min-width: 0; + } + + .mail-card-indicators { + display: flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; + } + + .mail-card-clip { + font-size: 0.8rem; + color: #94a3b8; + } + + .mail-card-spam-btn { + background: transparent !important; + background-color: transparent !important; + border: none !important; + box-shadow: none !important; + outline: none !important; + padding: 0.15rem !important; + width: auto !important; + height: auto !important; + font-size: 0.85rem; + color: #cbd5e1; + cursor: pointer; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0 !important; + transition: color 0.15s ease, transform 0.12s ease; + + &:hover, + &:active, + &:focus { + background: transparent !important; + background-color: transparent !important; + box-shadow: none !important; + outline: none !important; + } + + &.spammed { + color: #f97316 !important; + } + + &:hover { + color: #f97316 !important; + transform: scale(1.15); + } + } + + .mail-card-star-btn { + background: transparent !important; + background-color: transparent !important; + border: none !important; + box-shadow: none !important; + outline: none !important; + padding: 0.15rem !important; + width: auto !important; + height: auto !important; + font-size: 0.9rem; + color: #cbd5e1; + cursor: pointer; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0 !important; + transition: color 0.15s ease, transform 0.12s ease; + + &:hover, + &:active, + &:focus { + background: transparent !important; + background-color: transparent !important; + box-shadow: none !important; + outline: none !important; + } + + &.starred { + color: #f59e0b !important; + } + + &:hover { + color: #f59e0b !important; + transform: scale(1.15); + } + } + + .mail-card-snippet { + font-size: 0.8rem; + color: #64748b; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .mail-card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.2rem; + } + + .mail-card-tag-badge { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.7rem; + font-weight: 600; + padding: 0.1rem 0.45rem; + border-radius: 4px; + } + + .mail-card-tag-dot { + width: 6px; + height: 6px; + border-radius: 50%; + } +} + +// ========================================================================== +// TABLE VIEW (Classic) +// ========================================================================== + +.table-pagination-container { + display: flex; + flex-direction: column; + height: 100%; + background: #ffffff; + overflow-x: hidden; + + table.mails { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + + .mobile-subject-clip { + display: none; + } + + col.col-starred, + th.col-starred, + td.cell-star { + width: 44px; + min-width: 44px; + max-width: 44px; + text-align: center; + padding-left: 0.25rem; + padding-right: 0.25rem; + } + + col.col-attachments, + th.col-attachments, + td.cell-attachment { + width: 38px; + min-width: 38px; + max-width: 38px; + text-align: center; + padding-left: 0.25rem; + padding-right: 0.25rem; + } + + col.col-subject, + th.col-subject, + td.cell-subject { + width: 430px; + min-width: 320px; + max-width: 430px; + text-align: left; + overflow: hidden; + + div { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + overflow: hidden; + } + + span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + col.col-from, + th.col-from, + td.cell-from { + width: 210px; + min-width: 170px; + max-width: 250px; + text-align: left; + overflow: hidden; + + div { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + overflow: hidden; + } + + span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + col.col-spam, + th.col-spam, + td.cell-spam { + width: 38px; + min-width: 38px; + max-width: 38px; + text-align: center; + padding-left: 0.25rem; + padding-right: 0.25rem; + } + + col.col-date, + th.col-date, + td.cell-date { + width: 105px; + min-width: 95px; + max-width: 120px; + text-align: right; + padding-right: 0.75rem; + white-space: nowrap; + } + + col.col-spacer, + th.col-spacer, + td.cell-spacer { + width: auto; + padding: 0; + } + + tr { + border-bottom: 1px solid #f1f5f9; + transition: background-color 0.15s ease; + height: 42px; + + &:hover { + background-color: #f8fafc; + cursor: pointer; + } + + &.selected { + background-color: #e0f2fe !important; + + &:hover { + background-color: #bae6fd !important; + } + + td { + color: #1e293b; + } + + td.cell-subject span { + color: #0369a1; + font-weight: 600; + } + + td.cell-from span { + color: #0f172a; + font-weight: 600; + } + + td.cell-date { + color: #64748b; + } + + td.cell-attachment i { + color: #64748b; + } + + td.cell-star label.star-check { + color: #cbd5e1; + + &.starred { + color: #f59e0b !important; + } + + &:hover { + color: #f59e0b !important; + } + } + + td.cell-spam button.spam-btn { + color: #94a3b8; + + &.spammed { + color: #f97316 !important; + } + + &:hover { + color: #f97316 !important; + } + } + } + + &.unread { + background-color: #f0f9ff; + + td.cell-subject span { + font-weight: 700; + color: #0f172a; + } + + td.cell-from span { + font-weight: 700; + color: #0f172a; + } + } + } + + th { + padding: 0.65rem 0.75rem; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + color: #64748b; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + position: sticky; + top: 0; + z-index: 2; + box-sizing: border-box; + + &.sortable-th:hover { + background: #f1f5f9; + color: #0f172a; + } + } + + td { + padding: 0.55rem 0.75rem; + font-size: 0.85rem; + color: #334155; + vertical-align: middle; + box-sizing: border-box; + } + + td.cell-star { + label.star-check { + cursor: pointer; + color: #cbd5e1; + transition: color 0.15s ease; + + &.starred { + color: #eab308; + } + + &:hover { + color: #eab308; + } + } + } + + td.cell-spam { + button.spam-btn { + background: transparent !important; + background-color: transparent !important; + border: none !important; + box-shadow: none !important; + outline: none !important; + padding: 0 !important; + margin: 0 !important; + width: auto !important; + height: auto !important; + font-size: 0.85rem; + color: #cbd5e1; + cursor: pointer; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0 !important; + transition: color 0.15s ease, transform 0.12s ease; + + &:hover, + &:active, + &:focus { + background: transparent !important; + background-color: transparent !important; + box-shadow: none !important; + outline: none !important; + } + + &.spammed { + color: #f97316 !important; + } + + &:hover { + color: #f97316 !important; + transform: scale(1.15); + } + } + } + + td.cell-attachment { + color: #94a3b8; + } + + td.cell-date { + color: #94a3b8; + font-size: 0.775rem; + } + } + + .pagination { + margin-top: auto; + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + border-top: 1px solid #e2e8f0; + background: #ffffff; + font-size: 0.85rem; + color: #64748b; + } +} + +// Hide raw star checkbox +input.star-check { + display: none; +} + +// ========================================================================== +// 3. RIGHT PANE: Reading View & Placeholders +// ========================================================================== + +.mail-reading-pane { + flex: 1; + height: 100%; + overflow-y: auto; + background: #f8fafc; + display: flex; + flex-direction: column; + + &--table-view { + flex: 1; + width: auto; + background: #ffffff; + + .mail-view-back-btn { + display: inline-flex !important; + } + + .msg-view.mail-reading-card { + width: 100%; + max-width: none; + } + } +} + +.mail-reading-placeholder { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + text-align: center; + color: #94a3b8; + + .mail-reading-placeholder__icon { + font-size: 4.5rem; + color: #cbd5e1; + margin-bottom: 1.25rem; + } + + .mail-reading-placeholder__title { + font-size: 1.35rem; + font-weight: 700; + color: #475569; + margin: 0 0 0.5rem; + } + + .mail-reading-placeholder__subtitle { + font-size: 0.95rem; + color: #94a3b8; + max-width: 320px; + margin: 0; + line-height: 1.5; + } +} + +.msg-view.mail-reading-card { + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; +} + +.msg-view-nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + background: #ffffff; + border-bottom: 1px solid #e2e8f0; + position: sticky; + top: 0; + z-index: 5; +} + +.mail-view-back-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + min-width: 36px; + padding: 0; + flex-shrink: 0; + border-radius: 50%; + background-color: #e8f4fc; + color: #0788cb; + border: none; + font-size: 1.15rem; + cursor: pointer; + box-shadow: none; + text-decoration: none; + transition: background-color 0.15s ease, color 0.15s ease, transform 0.12s ease; + + i { + font-size: 1.05rem; + line-height: 1; + transition: transform 0.15s ease; + } + + .fa-arrow-left::before { + content: "\f053"; + } + + &:hover { + background-color: #d5ebfa; + color: #0788cb; + + i { + transform: translateX(-2px); + } + } + + &:active { + transform: scale(0.92); + } +} + +.msg-view-nav__action { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.mail-action-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.75rem; + border-radius: 0.375rem; + border: 1px solid #e2e8f0; + background: #ffffff; + color: #334155; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + + i { + font-size: 0.85rem; + } + + &:hover { + background: #f1f5f9; + border-color: #cbd5e1; + color: #0f172a; + } + + &.mail-action-btn--starred { + color: #eab308; + border-color: #fef08a; + background: #fefce8; + } + + &.mail-action-btn--spam { + color: #ea580c; + border-color: #fed7aa; + background: #fff7ed; + + &:hover { + background: #ffedd5; + border-color: #fdba74; + } + } + + &.mail-action-btn--delete { + color: #ef4444; + border-color: #fecaca; + + &:hover { + background: #fef2f2; + border-color: #fca5a5; + } + } +} + +.msg-view__header { + padding: 1.5rem 1.5rem 1rem; + background: #ffffff; + border-bottom: 1px solid #f1f5f9; +} + +.mail-reading-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + + h2.msg-view__title { + margin: 0; + font-size: 1.45rem; + font-weight: 700; + color: #0f172a; + line-height: 1.3; + } + + .mail-reading-tags { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; + } +} + +.msg-details { + display: flex; + gap: 1rem; + align-items: flex-start; +} + +.msg-details__info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.msg-details__info-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; +} + +.msg-sender-name { + font-size: 1.05rem; + font-weight: 700; + color: #1e293b; + cursor: pointer; + transition: color 0.15s ease; + + &:hover { + color: #0284c7; + } +} + +.msg-timestamp { + font-size: 0.825rem; + color: #64748b; + font-weight: 500; + flex-shrink: 0; +} + +.msg-recipients-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.35rem; + font-size: 0.825rem; + color: #64748b; + + .recipient-label { + font-weight: 600; + color: #475569; + margin-right: 0.2rem; + } + + .recipient-chip { + background: #f1f5f9; + padding: 0.15rem 0.5rem; + border-radius: 999px; + font-size: 0.775rem; + color: #334155; + font-weight: 500; + } +} + +.msg-view__attachment { + padding: 1rem 1.5rem; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + + .attachments-title { + margin: 0 0 0.5rem; + font-size: 0.9rem; + font-weight: 600; + color: #475569; + display: flex; + align-items: center; + gap: 0.4rem; + } +} + +.msg-view__body { + flex: 1; + padding: 1.5rem; + background: #f8fafc; + overflow-y: auto; +} + +.mail-body-container { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 0.75rem; + padding: 1.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03); + font-size: 0.95rem; + line-height: 1.65; + color: #1e293b; + min-height: 220px; + word-break: break-word; + + a { + color: #0284c7; + text-decoration: underline; + + &:hover { + color: #0369a1; + } + } + + blockquote { + border-left: 3px solid #cbd5e1; + padding-left: 1rem; + margin-left: 0; + color: #64748b; + } +} + +// ========================================================================== +// ATTACHMENT CARDS & DOWNLOADS +// ========================================================================== + +.attachments-wrapper { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.attachment-card { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + padding: 0.75rem 1rem; + display: flex; + align-items: center; + gap: 0.75rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); + + .attachment-icon { + width: 38px; + height: 38px; + border-radius: 0.5rem; + background: #eff6ff; + color: #0284c7; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.15rem; + flex-shrink: 0; + } + + .attachment-info { + flex: 1; + min-width: 0; + + .attachment-name { + font-weight: 600; + font-size: 0.875rem; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .attachment-size { + font-size: 0.75rem; + color: #64748b; + } + } + + .btn-attachment-download { + padding: 0.4rem 0.75rem; + font-size: 0.8rem; + border-radius: 0.375rem; + background: #0284c7; + color: #ffffff; + border: none; + cursor: pointer; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.35rem; + + &:hover { + background: #0369a1; + } + } +} + +// ========================================================================== +// COMPOSE MODAL OVERLAY & POPUP +// ========================================================================== + +.composePopupOverlay { + @include popupOverlay; + z-index: 1200; + + .composePopup { + position: absolute; + inset: 0; + margin: auto; + width: min(850px, 92vw); + height: min(750px, 90vh); + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 20px 40px rgba(15, 23, 42, 0.25); + display: flex; + flex-direction: column; + overflow: hidden; + + & > .widget { + padding: 1.5rem; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .close-btn { + position: absolute; + top: 1rem; + right: 1rem; + z-index: 10; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: #f1f5f9; + color: #64748b; + border: none; + cursor: pointer; + + &:hover { + background: #fee2e2; + color: #ef4444; + } + } } } .compose-mail { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + &__from { @include flex($justify: flex-start, $align: center, $gap: 0.5rem); padding-bottom: 0.5rem; - border-bottom: 2px solid $light-color; + border-bottom: 1px solid #e2e8f0; } + &__recipients { padding: 0.5rem 0; @include flex(column, $gap: 0.5rem); - border-bottom: 2px solid $light-color; + border-bottom: 1px solid #e2e8f0; + &__container { @include flex($gap: 0.5rem); + & > label { text-transform: capitalize; + font-weight: 600; + color: #475569; + font-size: 0.85rem; + width: 35px; } + .recipients { width: 100%; @include flex($gap: 0.5rem); flex-wrap: wrap; + &__selected { - padding: 0.125rem 0.5rem; - @include flex($align: center, $gap: 0.5rem); - border: 1px solid $light-color; - border-radius: 3px; - cursor: default; - & i { + padding: 0.2rem 0.6rem; + @include flex($align: center, $gap: 0.4rem); + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #f1f5f9; + font-size: 0.8rem; + color: #334155; + + i { cursor: pointer; - padding: 0.25rem; + color: #94a3b8; + &:hover { + color: #ef4444; + } } } + &__input { display: flex; position: relative; flex-grow: 1; + &-field { flex-grow: 1; - min-width: 200px; - padding: 0; + min-width: 180px; + padding: 0.25rem 0.5rem; border: none; box-shadow: none; + font-size: 0.85rem; + outline: none; + &:focus + .recipients__input-list { display: flex; } } + &-list { - z-index: 1; + z-index: 10; position: absolute; - top: 1rem; + top: 2rem; padding: 0; - width: 100%; + width: min(24rem, calc(100vw - 3rem)); + max-width: 100%; max-height: 15rem; flex-direction: column; overflow: auto; display: none; background: white; - border-top: 1px solid $light-color; - border-bottom: 1px solid $light-color; + border: 1px solid #cbd5e1; + border-radius: 0.5rem; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + &:hover { display: flex; } - & li { + + li { list-style: none; - padding: 0.25rem 0.5rem; + padding: 0.5rem 0.75rem; cursor: pointer; background: white; - border: 1px solid $light-color; - border-top: 0px; + border-bottom: 1px solid #f1f5f9; + font-size: 0.85rem; + &:hover { - background: $light-color; + background: #eff6ff; + color: #0284c7; } + &:last-child { border-bottom: 0px; } @@ -89,290 +1646,532 @@ } } } - .remove-recipient { - padding: 0.125rem 0.5rem; - } } + input[type='text'].compose-mail__subject { - padding: 0.5rem 0; + padding: 0.65rem 0.25rem; border: none; box-shadow: none; - border-bottom: 2px solid $light-color; + border-bottom: 1px solid #e2e8f0; border-radius: 0; + font-size: 0.95rem; + font-weight: 600; + outline: none; + + &:focus { + border-bottom-color: #0284c7; + } } + &__message { margin: 0.5rem 0; - height: 100%; + flex: 1; @include flex(column); overflow: auto; + &-body { - height: 100%; + flex: 1; + min-height: 180px; outline: transparent; + padding: 0.5rem; + font-size: 0.95rem; + line-height: 1.5; } } - &__send-btn { - @include flex($align: center, $gap: 0.5rem); - & i { - transform: translateY(-1px); + + .mail-compose-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + background: #ffffff; + border: 1px solid #cbd5e1; + border-top: 1px solid #e2e8f0; + border-radius: 0 0 0.375rem 0.375rem; + position: relative; + + .toolbar-left { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .toolbar-divider { + width: 1px; + height: 22px; + background: #cbd5e1; + margin: 0 0.25rem; + } + + button.mail-compose-send-btn { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + gap: 0.5rem !important; + padding: 0.5rem 1.25rem !important; + border-radius: 999px !important; + background: #0284c7 !important; + background-image: none !important; + color: #ffffff !important; + font-weight: 600 !important; + font-size: 0.9rem !important; + border: none !important; + cursor: pointer !important; + box-shadow: 0 2px 8px rgba(2, 132, 199, 0.28) !important; + transition: all 0.2s ease !important; + + i { + font-size: 0.85rem; + } + + &:hover { + background: #0369a1 !important; + background-image: none !important; + transform: translateY(-1px); + box-shadow: 0 4px 14px rgba(2, 132, 199, 0.38) !important; + } + + &:active { + transform: translateY(0); + box-shadow: 0 2px 4px rgba(2, 132, 199, 0.2) !important; + } + } + + button.mail-tool-btn { + width: 34px !important; + height: 34px !important; + min-width: 34px !important; + max-width: 34px !important; + border-radius: 50% !important; + border: none !important; + background: transparent !important; + background-image: none !important; + box-shadow: none !important; + outline: none !important; + padding: 0 !important; + margin: 0 !important; + color: #64748b !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + cursor: pointer !important; + transition: background 0.15s ease, color 0.15s ease, transform 0.1s ease !important; + + i { + font-size: 1.05rem; + color: inherit; + } + + &:hover { + background: #f1f5f9 !important; + background-image: none !important; + box-shadow: none !important; + color: #0284c7 !important; + } + + &:active, + &:focus { + background: #f1f5f9 !important; + background-image: none !important; + box-shadow: none !important; + outline: none !important; + } + + &.active { + background: #e0f2fe !important; + background-image: none !important; + box-shadow: none !important; + color: #0284c7 !important; + } } } } -.msg-view { - height: 100%; - @include flex(column, $gap: 1rem); - overflow: auto; - &-nav { - @include flex($justify: space-between, $align: column); - &__action { - @include flex($gap: 0.5rem); +.mobile-fab-compose { + display: none; +} + +// ========================================================================== +// RESPONSIVE / MOBILE ADAPTABILITY +// ========================================================================== + +@media (max-width: 1024px) { + .mail-folders-pane { + width: 210px; + min-width: 200px; + } + + .mail-list-pane { + width: 340px; + min-width: 310px; + } +} + +@media (max-width: 768px) { + .mail-mobile-nav-toggle { + display: inline-flex !important; + } + + // Left folders pane becomes off-canvas drawer on phones + .mail-folders-pane { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: min(82vw, 290px); + max-width: 85vw; + z-index: 1200; + background: #ffffff; + box-shadow: 8px 0 28px rgba(15, 23, 42, 0.2); + transform: translateX(-105%); + transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); + + &--open { + transform: translateX(0); } } - &__header { - @include flex(column, $gap: 1rem); - & > h3 { - line-height: 1; + + // Middle List Pane expands to 100% width on phone + .mail-list-pane { + width: 100% !important; + min-width: 0 !important; + max-width: 100% !important; + border-right: none !important; + + &--mobile-hidden { + display: none !important; } - & .msg-details { - @include flex($gap: 1rem); - &__avatar { - height: max-content; + } + + // Table view responsiveness on mobile screens + .table-pagination-container { + overflow-x: hidden !important; + width: 100% !important; + max-width: 100% !important; + + table.mails { + width: 100% !important; + max-width: 100% !important; + table-layout: fixed !important; + + // Hide separate attachment and spacer columns on mobile + col.col-attachments, + th.col-attachments, + td.cell-attachment, + col.col-spacer, + th.col-spacer, + td.cell-spacer { + display: none !important; + width: 0 !important; + padding: 0 !important; } - &__info { - @include flex(column); - &-item { - @include flex($gap: 0.5rem); + + // Compact Star column + col.col-starred, + th.col-starred, + td.cell-star { + width: 30px !important; + min-width: 30px !important; + max-width: 30px !important; + padding: 0.35rem 0.1rem !important; + text-align: center !important; + } + + // Flexible Subject column with inline attachment icon + col.col-subject, + th.col-subject, + td.cell-subject { + width: auto !important; + min-width: 0 !important; + max-width: none !important; + padding: 0.35rem 0.35rem 0.35rem 0.2rem !important; + + div { + gap: 0.3rem !important; + } + + .mobile-subject-clip { + display: inline-flex !important; + align-items: center; + font-size: 0.72rem; + color: #94a3b8; + flex-shrink: 0; + } + + span { + font-size: 0.8rem !important; } } - } - } - &__body { - height: 100%; - overflow: auto; - font-size: 14px !important; - } - &__attachment { - height: 50%; - overflow: auto; - @include flex(column); - &-items { - height: 100%; - overflow: auto; - } - } -} -.mail-tag { - width: 8rem; - padding: 0.5rem; -} -.msgHeader { - display: flex; -} -.msgHeaderDetails { - @include flex(column); -} + // Compact From column (hide avatar to maximize space for sender name) + col.col-from, + th.col-from, + td.cell-from { + width: 78px !important; + min-width: 68px !important; + max-width: 88px !important; + padding: 0.35rem 0.2rem !important; -table.mails { - & th { - /* star */ - &:nth-child(1) { - width: 5%; - color: $golden-yellow-color; + .user-avatar { + display: none !important; + } + + div { + gap: 0 !important; + } + + span { + font-size: 0.75rem !important; + } + } + + // Compact Spam button column + col.col-spam, + th.col-spam, + td.cell-spam { + width: 26px !important; + min-width: 26px !important; + max-width: 26px !important; + padding: 0.35rem 0.1rem !important; + text-align: center !important; + + button.spam-btn { + font-size: 0.75rem !important; + } + } + + // Compact Date column + col.col-date, + th.col-date, + td.cell-date { + width: 56px !important; + min-width: 50px !important; + max-width: 64px !important; + padding: 0.35rem 0.35rem 0.35rem 0.1rem !important; + font-size: 0.72rem !important; + text-align: right !important; + } + + th { + font-size: 0.68rem !important; + padding-top: 0.45rem !important; + padding-bottom: 0.45rem !important; + letter-spacing: 0.02em !important; + white-space: nowrap !important; + + i.fas.fa-sort, + i.fas.fa-sort-up, + i.fas.fa-sort-down { + font-size: 0.62rem !important; + margin-left: 0.15rem !important; + } + } + + tr { + height: 38px !important; + } } - /* attachments */ - &:nth-child(2) { - width: 5%; - color: color.adjust($light-color, $lightness: -50%); - } - /* subject */ - &:nth-child(3) { - width: 50%; - text-align: start; - } - /* From */ - &:nth-child(4), - /* date */ - &:nth-child(5) { - width: 20%; - text-align: start; + + .pagination { + padding: 0.5rem 0.75rem !important; + gap: 0.5rem !important; + font-size: 0.8rem !important; } } - & td { - /* subject */ - &:nth-child(3) { - text-align: start; - /* Truncate text with '...' */ - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + // Reading Pane expands to 100% width on phone + .mail-reading-pane { + width: 100% !important; + + &--mobile-hidden { + display: none !important; } - /* From */ - &:nth-child(4), - /* date */ - &:nth-child(5) { - text-align: start; + + .mail-view-back-btn { + display: inline-flex !important; } } - & tr { - &:hover { - background-color: $light-color; - cursor: pointer; - } - &.unread { - color: black; - background-color: $light-color; - } - } - - //Remove hover effects from the table head - & > tr:hover { - cursor: auto; - background-color: white; - } - - & th.sortable-th { + // Mobile FAB for Compose + .mobile-fab-compose { + display: flex !important; + position: fixed; + right: 1.25rem; + bottom: calc(56px + env(safe-area-inset-bottom) + 1rem); + width: 52px; + height: 52px; + border-radius: 50%; + background: #0284c7; + color: #ffffff; + align-items: center; + justify-content: center; + font-size: 1.2rem; + box-shadow: 0 6px 18px rgba(2, 132, 199, 0.45); + z-index: 1050; + border: none; cursor: pointer; - user-select: none; - transition: background-color 0.2s, color 0.2s; + transition: transform 0.15s ease; - &:hover { - background-color: $light-color; - color: color.adjust($light-color, $lightness: -80%); - } - } -} - -/* hide checkbox */ -input.star-check { - display: none; - /* use label with 'for' to manipulate checkbox */ - & + label.star-check { - color: grey; - } - &:checked + label.star-check { - color: $golden-yellow-color; - } -} - -/* mail_util.js styles */ -#truncate { - height: 6rem; - overflow: auto; - &.truncated-view { - height: 1.75rem; - overflow: hidden; - } -} -.toggle-truncate { - font-size: 0.75rem; - padding: 0 0.25rem; - background: #999; - color: $dark-color; - box-shadow: none; - border-radius: 2px; -} - -// Normal mail attachment view -table.attachment-container { - padding: 0; - - & > tr { - border: 0; - } - - .attachment-header { - width: 100%; - @include flex($justify: space-between); - - th { - text-align: start; - - &:nth-child(1) { - flex-basis: 45%; - } - &:nth-child(2) { - flex-basis: 15%; - } - &:nth-child(3) { - flex-basis: 10%; - } - &:nth-child(4) { - flex-basis: 20%; - } - &:nth-child(5) { - text-align: center; - flex-basis: 10%; - } + &:active { + transform: scale(0.92); } } - .attachment { - width: 100%; - @include flex($justify: space-between); - text-align: start; + // Full-screen Compose on phone + .composePopupOverlay .composePopup { + top: calc(44px + env(safe-area-inset-top) + 0.5rem) !important; + right: 0 !important; + bottom: calc(50px + env(safe-area-inset-bottom) + 0.5rem) !important; + left: 0 !important; + width: calc(100% - 1rem) !important; + height: auto !important; + margin: 0 auto !important; - &__name { - flex-basis: 45%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - span { - margin-left: 8px; - } - } - - &__from { - flex-basis: 15%; - } - &__size { - flex-basis: 10%; - } - &__date { - flex-basis: 20%; - } - & td:nth-child(5) { - @include flex($justify: center, $align: center); - flex-basis: 10%; - - & button { - font-size: 0.875rem; - } - } - } -} - -// Attachment Section attachment view -.view-toggle { - height: max-content; - border: 1px solid $primary-color; - border-radius: 4px; - display: flex; - - & * { - padding: 4px 12px; - border-radius: 4px; - } -} - -.composePopupOverlay { - @include popupOverlay; - .composePopup { - position: absolute; - inset: 0; - margin: auto; - width: 80%; - height: 90%; & > .widget { - padding: 2rem; + padding: 1rem; } - & .close-btn { - position: absolute; - top: 1.5rem; - right: 1.5rem; + + .close-btn { + top: 0.75rem; + right: 0.75rem; + } + } + + // Mail Viewer Navigation and Flat Icon-Only Action Buttons on Phone + .msg-view-nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem !important; + gap: 0.35rem; + position: sticky; + top: 0; + background: #ffffff; + border-bottom: 1px solid #f1f5f9; + z-index: 10; + } + + .msg-view-nav__action { + display: flex; + align-items: center; + gap: 0.25rem; + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + + &::-webkit-scrollbar { + display: none; + } + } + + .mail-action-btn { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 36px !important; + height: 36px !important; + min-width: 36px !important; + padding: 0 !important; + border: none !important; + background: transparent !important; + box-shadow: none !important; + border-radius: 50% !important; + color: #475569 !important; + transition: all 0.15s ease; + + i { + font-size: 1.05rem !important; + line-height: 1 !important; + } + + .btn-text { + display: none !important; + } + + &:hover, + &:active { + background: #f1f5f9 !important; + color: #0f172a !important; + } + + &.mail-action-btn--starred { + color: #f59e0b !important; + background: transparent !important; + + &:hover, + &:active { + background: #fefce8 !important; + color: #d97706 !important; + } + } + + &.mail-action-btn--spam { + color: #f97316 !important; + background: transparent !important; + + &:hover, + &:active { + background: #fff7ed !important; + color: #ea580c !important; + } + } + + &.mail-action-btn--delete { + color: #ef4444 !important; + background: transparent !important; + + &:hover, + &:active { + background: #fef2f2 !important; + color: #dc2626 !important; + } + } + } +} + +@media (max-width: 480px) { + .mail-list-header { + padding: 0.65rem 0.75rem; + } + + .msg-view__header { + padding: 1rem 0.75rem; + } + + .mail-reading-title-row h2.msg-view__title { + font-size: 1.2rem; + } + + .msg-view__body { + padding: 0.75rem; + } + + .mail-body-container { + padding: 1rem; + } +} + +.mail-cards-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 0.75rem; + border-top: 1px solid #e2e8f0; + color: #475569; + font-size: 0.9rem; + + button { + padding: 0.3rem 0.7rem; + border: 1px solid #cbd5e1; + background: #f8fafc; + color: #334155; + border-radius: 0.375rem; + box-shadow: none; + + &:disabled { + opacity: 0.45; + cursor: default; } } } diff --git a/webui-src/app/scss/pages/_network.scss b/webui-src/app/scss/pages/_network.scss index ff1e9ce..5011fc6 100644 --- a/webui-src/app/scss/pages/_network.scss +++ b/webui-src/app/scss/pages/_network.scss @@ -31,6 +31,79 @@ display: flex; align-items: center; gap: 1rem; + + .profile-avatar-wrapper { + position: relative; + flex-shrink: 0; + + .status-dot { + position: absolute; + bottom: -1px; + right: -1px; + width: 13px; + height: 13px; + border-radius: 50%; + border: 2px solid #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + } + + .profile-status-button { + padding: 0; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + + &:hover, + &:focus-visible { + transform: scale(1.2); + box-shadow: 0 0 0 3px rgba(59, 164, 215, 0.25); + outline: none; + } + } + + .profile-presence-menu { + position: absolute; + z-index: 20; + top: calc(100% + 0.5rem); + left: 0; + width: 9rem; + padding: 0.3rem; + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 0.5rem; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.18); + } + + .profile-presence-option { + display: grid; + grid-template-columns: 0.65rem 1fr 0.75rem; + align-items: center; + width: 100%; + margin: 0; + padding: 0.45rem 0.55rem; + gap: 0.5rem; + color: #334155; + background: transparent; + border: 0; + border-radius: 0.35rem; + text-align: left; + + &:hover, + &.active { + background: #f1f5f9; + } + + > span { + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + } + + > i { + color: #3ba4d7; + font-size: 0.7rem; + } + } + } } .profile-info { @@ -61,7 +134,7 @@ display: inline-block; width: 8px; height: 8px; - background-color: #10b981; + background-color: var(--profile-status-color, #10b981); border-radius: 50%; } } @@ -159,7 +232,19 @@ } .friend-avatar { + position: relative; flex-shrink: 0; + + .status-dot { + position: absolute; + bottom: -1px; + right: -1px; + width: 13px; + height: 13px; + border-radius: 50%; + border: 2px solid #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + } } .friend-meta { @@ -250,6 +335,238 @@ } } +.chat-unread-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.25rem; + height: 1.25rem; + margin-top: .2rem; + padding: 0 .35rem; + border-radius: 999px; + background: #0284c7; + color: #fff; + font-size: .7rem; + font-weight: 700; + line-height: 1; +} + +/* Shared phone master/detail header. It is intentionally absent on desktop. */ +.mobile-pane-header, +.mobile-graph-shortcut { + display: none !important; +} + +.network-tab-content.network-graph-tab { + min-height: 0; + padding: 0; + overflow: hidden; +} + +.network-graph { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; + padding: 1rem; + box-sizing: border-box; + + &__toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem 1rem; + margin-bottom: 0.75rem; + + label { + display: flex; + align-items: center; + gap: 0.45rem; + color: #475569; + font-size: 0.8rem; + font-weight: 600; + } + + select { + padding: 0.3rem 0.45rem; + } + } + + &__edge-control input { + width: 8rem; + } + + &__zoom-control { + display: flex; + align-items: center; + gap: 0.3rem; + + button { + min-width: 1.9rem; + padding: 0.3rem 0.45rem; + } + + label { + flex-direction: column; + align-items: flex-start; + gap: 0.1rem; + } + + input { + width: 7rem; + } + } + + &__search { + display: flex; + align-items: center; + min-width: 10rem; + margin-left: auto; + padding: 0.35rem 0.6rem; + gap: 0.4rem; + background: #fff; + border: 1px solid #cbd5e1; + border-radius: 0.4rem; + + input { + min-width: 0; + padding: 0; + border: 0; + outline: 0; + } + } + + &__canvas { + flex: 1; + width: 100%; + min-height: 20rem; + background: #fff; + border: 1px solid #cbd5e1; + border-radius: 0.5rem; + touch-action: none; + } + + &__edges line { + stroke: #94a3b8; + stroke-width: 1.25; + opacity: 0.65; + } + + &__node { + cursor: grab; + + circle { + stroke: #fff; + stroke-width: 2; + filter: drop-shadow(0 1px 2px rgba(15, 23, 42, 0.35)); + } + + text { + fill: #1e293b; + font-size: 12px; + paint-order: stroke; + stroke: #fff; + stroke-width: 3px; + stroke-linejoin: round; + } + + &.is-match circle { + stroke: #f97316; + stroke-width: 5; + } + } + + &__message { + display: grid; + flex: 1; + place-items: center; + color: #64748b; + + &--error { + color: #b91c1c; + } + } + + &__legend { + display: flex; + align-items: center; + flex-wrap: wrap; + margin-top: 0.65rem; + gap: 0.5rem 1rem; + color: #64748b; + font-size: 0.75rem; + + span:last-child { + margin-left: auto; + } + } + + &__key { + display: inline-block; + width: 0.65rem; + height: 0.65rem; + margin-right: 0.3rem; + border-radius: 50%; + + &--own { background: #d6d91f; } + &--online { background: #16a34a; } + &--offline { background: #64748b; } + } +} + +@media (max-width: 700px) { + .network-tabs .tab-btn { + padding-inline: 0.65rem; + font-size: 0.8rem; + } + + .network-graph { + padding: 0.5rem; + + &__redraw { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + min-width: 2.25rem; + height: 2.25rem; + padding: 0; + + span { + display: none; + } + } + + &__toolbar { + align-items: stretch; + } + + &__search { + width: 100%; + box-sizing: border-box; + } + + &__toolbar &__edge-control, + &__zoom-control { + display: none; + } + + &__search { + margin-left: 0; + } + + &__canvas { + min-height: 18rem; + } + + &__legend span:last-child { + width: 100%; + margin-left: 0; + } + } +} + .network-tab-content { flex: 1; overflow-y: auto; @@ -487,17 +804,22 @@ border-top: 1px solid #cbd5e1; display: flex; gap: 0.75rem; - align-items: center; + align-items: flex-end; textarea.chat-textarea { flex: 1; - resize: none; + resize: none !important; + min-height: 40px; + max-height: 160px; height: 40px; - padding: 0.5rem 0.75rem; + padding: 0.55rem 0.75rem; border: 1px solid #cbd5e1; - border-radius: 0.375rem; + border-radius: 0.625rem; font-size: 0.9rem; + line-height: 1.45; outline: none; + overflow-y: hidden; + box-sizing: border-box; transition: all 0.2s; &:focus { @@ -543,3 +865,198 @@ } } } + +/* Responsive Layout for Mobile/Small Screens */ +@media (max-width: 768px) { + .network-tab-content.network-chat-tab-content { + min-height: 0; + padding: 0; + overflow: hidden; + } + + .network-container { + flex-direction: column !important; + } + + .network-left-pane { + width: 100% !important; + max-width: none !important; + height: 45% !important; + border-right: none !important; + border-bottom: 1px solid #cbd5e1 !important; + } + + .network-right-pane { + height: 55% !important; + flex: 1 !important; + } + + /* Hide text labels on mobile screens so profile action buttons show icons only */ + .detail-actions button .btn-text, + .detail-header .detail-actions button .btn-text { + display: none !important; + } + + .detail-actions button, + .detail-header .detail-actions button { + padding: 0.45rem 0.65rem !important; + min-width: 38px !important; + height: 38px !important; + justify-content: center !important; + align-items: center !important; + } + + .detail-actions button i, + .detail-header .detail-actions button i { + margin: 0 !important; + font-size: 1.05rem !important; + } + + .network-detail-view .detail-header { + flex-direction: column !important; + align-items: flex-start !important; + gap: 1rem !important; + + .friend-avatar { + margin-bottom: 0.25rem !important; + } + } + + .locations-grid { + grid-template-columns: 1fr !important; + } + + /* Direct-chat composer: preserve room for typing on a narrow screen. */ + .network-chat-view .chat-input-area { + align-items: flex-end !important; + padding: .35rem .45rem !important; + gap: .2rem !important; + min-width: 0; + } + + .network-chat-view .chat-input-area .mobile-chat-attachment { + order: 1; + } + + .network-chat-view .chat-input-area .chat-hub-action-btn, + .network-chat-view .chat-input-area label.chat-hub-action-btn { + width: 32px !important; + height: 32px !important; + min-width: 32px !important; + padding: .25rem !important; + font-size: .95rem !important; + } + + .network-chat-view .chat-input-area .emoji-picker-wrapper { + order: 3; + flex: 0 0 32px; + } + + .network-chat-view .chat-input-area .emoji-picker-wrapper .emoji-picker { + right: -2.7rem; + left: auto; + width: min(320px, calc(100vw - 1rem)); + max-height: min(420px, calc(100dvh - 8rem)); + } + + .network-chat-view .chat-input-area textarea.chat-textarea { + order: 2; + min-width: 0 !important; + min-height: 40px !important; + height: 40px !important; + max-height: 140px !important; + padding: .55rem .7rem !important; + border-color: #dbe2ea !important; + border-radius: 1.25rem !important; + background: #fff !important; + box-sizing: border-box; + overflow-y: hidden; + line-height: 1.4; + } + + .network-chat-view .chat-input-area .send-btn { + order: 4; + width: 40px !important; + min-width: 40px !important; + height: 40px !important; + padding: 0 !important; + font-size: 0 !important; + justify-content: center; + border-radius: 50% !important; + } + + .network-chat-view .chat-input-area .send-btn i { + margin: 0 !important; + font-size: 1rem !important; + } +} + +@media (max-width: 700px) { + .network-container { + display: block !important; + } + + .network-container .network-left-pane { + width: 100% !important; + min-width: 0 !important; + height: 100% !important; + max-width: none !important; + border: 0 !important; + } + + .network-container .network-right-pane { + display: none !important; + width: 100% !important; + height: 100% !important; + } + + .network-container.mobile-detail-open .network-left-pane { + display: none !important; + } + + .network-container.mobile-detail-open .network-right-pane { + display: flex !important; + } + + .mobile-pane-header { + display: grid !important; + grid-template-columns: minmax(5rem, auto) minmax(0, 1fr) minmax(5rem, auto); + align-items: center; + min-height: 46px; + padding: .35rem .65rem; + border-bottom: 1px solid #e2e8f0; + background: #fff; + flex: 0 0 auto; + } + + .mobile-pane-header strong { + grid-column: 2; + overflow: hidden; + color: #1e293b; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mobile-back-button { + grid-column: 1; + justify-self: start; + display: inline-flex !important; + align-items: center; + gap: .4rem; + width: auto !important; + min-width: 0 !important; + margin: 0 !important; + padding: .4rem .25rem !important; + border: 0 !important; + box-shadow: none !important; + background: transparent !important; + color: #0284c7 !important; + font-weight: 700; + } + + .network-container .network-tabs { + flex: 0 0 auto; + overflow-x: auto; + } +} diff --git a/webui-src/app/scss/pages/_people.scss b/webui-src/app/scss/pages/_people.scss index d835421..2ca2743 100644 --- a/webui-src/app/scss/pages/_people.scss +++ b/webui-src/app/scss/pages/_people.scss @@ -229,100 +229,432 @@ img.avatar { color: #0f172a; } +/* Main People Page Flex Layout Container */ .people-container { - display: flex; - height: calc(100vh - 55px); - width: 100%; - overflow: hidden; + display: flex !important; + flex-direction: row !important; + height: 100% !important; + width: 100% !important; + overflow: hidden !important; + background-color: #f1f5f9 !important; } +/* Left Sidebar Pane (320px fixed width on Desktop) */ .people-left-pane { - width: 320px; - border-right: 1px solid #cbd5e1; - display: flex; - flex-direction: column; - background-color: #ffffff; - overflow: hidden; + width: 320px !important; + min-width: 300px !important; + max-width: 350px !important; + height: 100% !important; + border-right: 1px solid #cbd5e1 !important; + display: flex !important; + flex-direction: column !important; + background: #ffffff !important; + box-shadow: 2px 0 5px rgba(0, 0, 0, 0.05) !important; + flex-shrink: 0 !important; + overflow: hidden !important; } +/* Right Content Details Pane */ .people-right-pane { - flex: 1; + flex: 1 !important; + min-width: 0 !important; + height: 100% !important; + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; + background-color: #f8fafc !important; +} + +/* Scrollable list section inside left sidebar */ +.people-list-container { + flex: 1 !important; + overflow-y: auto !important; + padding: 0.5rem 0 !important; +} + +/* Active Chats list item */ +.chat-item { + display: flex !important; + align-items: center !important; + gap: 0.75rem !important; + padding: 0.65rem 0.85rem !important; + margin: 0.2rem 0.5rem !important; + border-radius: 0.5rem !important; + cursor: pointer !important; + transition: all 0.2s ease !important; + position: relative !important; + + &:hover { + background-color: #f1f5f9 !important; + } + + &.selected { + background-color: #e0f2fe !important; + + .chat-name { + color: #0369a1 !important; + font-weight: 700 !important; + } + } + + .chat-avatar-wrapper { + position: relative !important; + flex-shrink: 0 !important; + + .status-dot { + position: absolute !important; + bottom: -1px !important; + right: -1px !important; + width: 13px !important; + height: 13px !important; + border-radius: 50% !important; + border: 2px solid #ffffff !important; + } + } + + .chat-info { + flex: 1 !important; + min-width: 0 !important; + display: flex !important; + flex-direction: column !important; + + .chat-name { + font-size: 0.95rem !important; + font-weight: 600 !important; + color: #1e293b !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + } + + .chat-last-msg { + font-size: 0.825rem !important; + color: #64748b !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + margin-top: 0.1rem !important; + } + } + + .chat-meta { + display: flex !important; + flex-direction: column !important; + align-items: flex-end !important; + flex-shrink: 0 !important; + + .chat-time { + font-size: 0.75rem !important; + color: #94a3b8 !important; + font-weight: 500 !important; + } + } +} + +/* Responsive Layout for Mobile/Small Screens */ +@media (max-width: 768px) { + .people-container { + flex-direction: column !important; + } + .people-left-pane { + width: 100% !important; + max-width: none !important; + height: 45% !important; + border-right: none !important; + border-bottom: 1px solid #cbd5e1 !important; + } + .people-right-pane { + height: 55% !important; + } + + /* Hide text labels on mobile screens so profile action buttons show icons only */ + .detail-actions button .btn-text, + .detail-header .detail-actions button .btn-text { + display: none !important; + } + + /* Hide Distant Chat Tunnel and Chatting as text on mobile screens to save header space */ + .chat-tunnel-status .tunnel-label, + .select-own-profile .chatting-as-label { + display: none !important; + } + + .detail-actions button, + .detail-header .detail-actions button { + padding: 0.45rem 0.65rem !important; + min-width: 38px !important; + height: 38px !important; + justify-content: center !important; + align-items: center !important; + } +} + +/* People Sidebar Right-Click Context Menu */ +.create-identity-form { + display: grid !important; + grid-template-columns: 11rem minmax(0, 1fr); + gap: 1rem 1.5rem !important; + + &__heading, + &__name, + &__help, + &__submit { grid-column: 1 / -1; } + &__heading { display: flex; align-items: center; gap: .75rem; } + &__heading > i { color: #3ba4d7; font-size: 1.5rem; } + &__heading h3 { margin: 0; color: #1e293b; } + &__heading p { margin: .2rem 0 0; color: #64748b; font-size: .9rem; } + &__name { width: 100%; } + &__avatar { + grid-row: span 2; display: flex; flex-direction: column; align-items: center; + gap: .5rem; padding: .75rem; border: 1px solid #e2e8f0; + border-radius: .65rem; background: #f8fafc; + } + &__avatar-label, + &__field label { color: #475569; font-size: .85rem; font-weight: 700; } + &__file-input { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; } + &__file-button { + display: inline-flex; align-items: center; gap: .35rem; padding: .4rem .65rem; + border: 1px solid #0284c7; border-radius: .35rem; background: #0284c7; + color: #fff; font-size: .8rem; font-weight: 600; cursor: pointer; + } + &__remove-avatar { + padding: .25rem .5rem; border: 0; box-shadow: none; + background: transparent; color: #64748b; font-size: .78rem; + } + &__remove-avatar:hover { color: #dc2626; text-decoration: underline; } + &__avatar small { color: #64748b; text-align: center; } + &__field { display: flex; flex-direction: column; gap: .35rem; min-width: 0; } + &__field .config-style-select { + width: 100%; max-width: 320px; min-width: 0; box-sizing: border-box; + padding: .375rem .5rem; border: 1px solid #cbd5e1; border-radius: .375rem; + background: #fff; color: #334155; outline: none; font-weight: 600; + } + &__field .config-style-select:focus { border-color: #3ba4d7; } + &__help { color: #475569; line-height: 1.5; } + &__submit { justify-self: end; } +} + +@media (max-width: 700px) { + .people-container { + display: block !important; + } + + .people-container .people-left-pane { + width: 100% !important; + min-width: 0 !important; + height: 100% !important; + max-width: none !important; + border: 0 !important; + } + + .people-container .people-right-pane { + display: none !important; + width: 100% !important; + height: 100% !important; + } + + .people-container.mobile-detail-open .people-left-pane { + display: none !important; + } + + .people-container.mobile-detail-open .people-right-pane { + display: flex !important; + } + + .people-container .network-tabs { + flex: 0 0 auto; + } + + .people-container .network-tab-content { + min-height: 0; + } +} + +.create-identity-avatar-preview { + width: 8rem; height: 8rem; overflow: hidden; + border: 1px solid #cbd5e1; border-radius: .5rem; background: #eef2ff; + display: flex; align-items: center; justify-content: center; + > img { width: 100%; height: 100%; object-fit: cover; } + > .jdenticon-avatar, + > .defaultAvatar { margin: 0 !important; } +} + +.modal-content.create-identity-modal { + width: min(720px, calc(100% - 2rem)); max-height: calc(100% - 2rem); + box-sizing: border-box; overflow-x: hidden; overflow-y: auto; +} + +.modal-content.signed-identity-modal { + width: min(440px, calc(100% - 2rem)); + min-height: 0; + box-sizing: border-box; +} + +.modal-content.edit-identity-modal { + width: min(440px, calc(100% - 2rem)); + min-height: 0; + box-sizing: border-box; +} + +.edit-identity-form { display: flex; flex-direction: column; - overflow: hidden; - background-color: #f8fafc; -} - -.people-left-pane .chat-item { - display: flex; - align-items: center; - padding: 0.75rem 1rem; - gap: 0.75rem; - border-bottom: 1px solid #f1f5f9; - cursor: pointer; - transition: background-color 0.15s ease; - position: relative; -} - -.people-left-pane .chat-item:hover { - background-color: #f8fafc; -} - -.people-left-pane .chat-item.selected { - background-color: #eff6ff; - border-left: 3px solid #3b82f6; -} - -.people-left-pane .chat-item .chat-avatar-wrapper { - position: relative; - flex-shrink: 0; -} - -.people-left-pane .chat-item .chat-avatar-wrapper .status-dot { - position: absolute; - bottom: 0; - right: 0; - width: 10px; - height: 10px; - border-radius: 50%; - border: 2px solid #ffffff; -} - -.people-left-pane .chat-item .chat-info { - flex: 1; + width: 100%; min-width: 0; + gap: .65rem; + + &__heading { + display: flex; + align-items: center; + gap: .65rem; + padding-right: 2.5rem; + } + &__heading > i { color: #3ba4d7; font-size: 1.35rem; } + &__heading h3 { margin: 0; color: #1e293b; } + &__name-label { color: #475569; font-size: .85rem; font-weight: 700; } + &__name { width: 100%; min-width: 0; } + &__avatar { + display: flex; + align-items: center; + min-width: 0; + gap: .75rem; + padding: .75rem; + border: 1px solid #e2e8f0; + border-radius: .65rem; + background: #f8fafc; + } + &__avatar > .avatar, + &__avatar > .jdenticon-avatar, + &__avatar > .defaultAvatar { flex: 0 0 auto; margin: 0 !important; } + &__avatar-button { + display: inline-flex; + align-items: center; + gap: .4rem; + min-width: 0; + padding: .45rem .7rem; + border-radius: .35rem; + background: #0284c7; + color: #fff; + font-size: .85rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + } + &__keep { padding: .35rem .55rem; font-size: .8rem; } + &__save { align-self: flex-end; margin-top: .25rem !important; } +} + +.signed-identity-form { display: flex; flex-direction: column; - gap: 0.15rem; + gap: .75rem; + width: 100%; + + &__heading { + display: flex; + align-items: flex-start; + gap: .75rem; + padding-right: 2.5rem; + } + &__heading > i { color: #3ba4d7; font-size: 1.5rem; margin-top: .15rem; } + &__heading h3 { margin: 0; color: #1e293b; } + &__heading p { margin: .25rem 0 0; color: #64748b; line-height: 1.4; } + label { color: #475569; font-size: .85rem; font-weight: 700; } + input { + width: 100%; + box-sizing: border-box; + min-width: 0; + } + &__submit { align-self: flex-end; margin-top: .5rem !important; } } -.people-left-pane .chat-item .chat-info .chat-name { - font-size: 0.9rem; - font-weight: 700; - color: #1e293b; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.signed-identity-result { + padding-right: 2.5rem; + h3 { margin: 0; color: #1e293b; } + p { margin: .75rem 0 0; color: #475569; line-height: 1.5; } } -.people-left-pane .chat-item .chat-info .chat-last-msg { - font-size: 0.8rem; - color: #64748b; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +@media (max-width: 600px) { + .modal-content.create-identity-modal { + width: calc(100% - 1rem) !important; + max-width: none !important; + max-height: calc(100% - 1rem); + padding: 1rem; + } + .create-identity-form { + width: 100%; min-width: 0; grid-template-columns: minmax(0, 1fr); + gap: .75rem !important; + &__heading, + &__name, + &__avatar, + &__field, + &__help, + &__submit { grid-column: 1; } + &__avatar { grid-row: auto; } + &__field .config-style-select { max-width: none; } + &__heading { padding-right: 2.5rem; } + &__heading h3 { font-size: 1.35rem; white-space: nowrap; } + &__heading p { font-size: .82rem; } + &__avatar { padding: .6rem; } + &__help { font-size: .85rem; } + &__submit { width: 100%; } + } + .create-identity-avatar-preview { width: 7rem; height: 7rem; } + .modal-content.signed-identity-modal { + width: calc(100% - 1rem) !important; + max-width: none !important; + padding: 1rem; + } + .modal-content.edit-identity-modal { + width: calc(100% - 1rem) !important; + max-width: none !important; + max-height: calc(100dvh - 1rem); + padding: 1rem; + overflow-y: auto; + } + .edit-identity-form { + &__heading h3 { font-size: 1.35rem; white-space: nowrap; } + &__avatar { flex-direction: column; padding: .85rem; } + &__avatar-button { justify-content: center; width: 100%; box-sizing: border-box; } + &__keep, + &__save { width: 100%; } + } + .signed-identity-form__submit { width: 100%; } } -.people-left-pane .chat-item .chat-meta { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.25rem; - flex-shrink: 0; +.people-context-menu { + position: absolute !important; + z-index: 9999 !important; + background-color: #ffffff !important; + border: 1px solid #cbd5e1 !important; + border-radius: 8px !important; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.15), 0 8px 10px -6px rgba(0, 0, 0, 0.1) !important; + padding: 0.35rem 0 !important; + min-width: 180px !important; + font-family: inherit !important; + overflow: hidden !important; + + .menu-item { + display: flex !important; + align-items: center !important; + padding: 0.6rem 0.9rem !important; + font-size: 0.875rem !important; + font-weight: 500 !important; + color: #1e293b !important; + cursor: pointer !important; + transition: background-color 0.15s ease, color 0.15s ease !important; + user-select: none !important; + + &:hover { + background-color: #f1f5f9 !important; + color: #0284c7 !important; + } + + i { + font-size: 1rem !important; + width: 1.25rem !important; + text-align: center !important; + } + } } -.people-left-pane .chat-item .chat-meta .chat-time { - font-size: 0.75rem; - color: #94a3b8; - white-space: nowrap; -} diff --git a/webui-src/app/scss/pages/_statistics.scss b/webui-src/app/scss/pages/_statistics.scss new file mode 100644 index 0000000..6f9c84f --- /dev/null +++ b/webui-src/app/scss/pages/_statistics.scss @@ -0,0 +1,707 @@ +// --------------------------------------------------------------------------- +// Statistics page — two-pane master-detail layout +// Mirrors the pattern used in Chat, Network, and People pages. +// --------------------------------------------------------------------------- +@use '../abstracts' as *; + +// ── Container: full-height flex row ── +.statistics-container { + display: flex; + height: 100%; + width: 100%; + overflow: hidden; + background-color: #f1f5f9; +} + +// ── Left pane: sidebar with header card + navigation ── +.statistics-left-pane { + width: 280px; + min-width: 260px; + max-width: 320px; + border-right: 1px solid #cbd5e1; + display: flex; + flex-direction: column; + background: #ffffff; + box-shadow: 2px 0 5px rgba(0, 0, 0, 0.05); + overflow-y: auto; +} + +// ── Header card (top of left pane) ── +.statistics-header-card { + padding: 1rem 1.25rem; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + + &__title { + display: flex; + align-items: center; + gap: 0.75rem; + + > i { + font-size: 1.4rem; + color: #0788cb; + } + + h1 { + font-size: 1.15rem; + font-weight: 700; + color: #1e293b; + margin: 0; + } + + p { + margin: 0; + font-size: 0.8rem; + color: #64748b; + } + } +} + +// ── Navigation list ── +.statistics-nav { + display: flex; + flex-direction: column; + padding: 0.5rem 0; + flex: 1; +} + +.statistics-nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 1.25rem; + cursor: pointer; + color: #334155; + border-left: 3px solid transparent; + background: none; + box-shadow: none; + border-radius: 0; + font-size: 0.9rem; + font-weight: 500; + text-align: left; + width: 100%; + white-space: nowrap; + transition: background-color 0.15s ease, color 0.15s ease; + + &:active { + box-shadow: none; + } + + i { + width: 1.1rem; + text-align: center; + color: #64748b; + font-size: 0.95rem; + flex-shrink: 0; + } + + &:hover:not(.active) { + background-color: #f1f5f9; + } + + &.active { + background-color: #e0f2fe; + color: #0369a1; + font-weight: 600; + border-left-color: #0284c7; + + i { + color: #0284c7; + } + } +} + +// ── Mobile tab bar (hidden on desktop) ── +.statistics-mobile-tabs { + display: none; +} + +// ── Right pane: content area ── +.statistics-right-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 1.5rem; + background-color: #f8fafc; +} + +// ── Content header ── +.statistics-content-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.25rem; + + &__title { + h2 { + font-size: 1.5rem; + font-weight: 800; + color: #1e293b; + margin: 0 0 0.25rem; + } + + p { + margin: 0; + font-size: 0.9rem; + color: #64748b; + } + } + + .statistics-refresh-btn { + display: inline-flex; + align-items: center; + justify-content: center; + + // Desktop mode: text only (no icon) for a more compact appearance + i { + display: none; + } + + .btn-text { + display: inline; + } + } +} + +// ── Traffic panel cards ── +.statistics-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.25rem; + align-items: start; +} + +.traffic-panel { + min-width: 0; + padding: 1.25rem; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + background: #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); + overflow: hidden; +} + +.traffic-panel__heading { + display: flex; + align-items: flex-start; + gap: 0.6rem; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #f1f5f9; + + > i { + color: #0788cb; + font-size: 1.05rem; + margin-top: 0.15rem; + flex-shrink: 0; + } + + h3 { + margin: 0 0 0.2rem; + font-size: 1.1rem; + font-weight: 700; + color: #334155; + } + + p { + margin: 0; + font-size: 0.82rem; + color: #64748b; + } +} + +// ── Pie/Donut chart ── +.traffic-pie { + display: grid; + grid-template-columns: minmax(10rem, 42%) 1fr; + align-items: center; + gap: 1.25rem; + margin: 1rem 0; + + svg { + width: 100%; + max-height: 16rem; + transform: rotate(-90deg); + } +} + +.traffic-pie__track, +.traffic-pie__segment { + fill: none; + stroke-width: 18; +} + +.traffic-pie__track { + stroke: #e8eef3; +} + +.traffic-pie__segment { + transition: stroke-dasharray 0.25s ease; +} + +.traffic-pie__value, +.traffic-pie__caption { + transform: rotate(90deg); + transform-origin: 60px 60px; + fill: #1e293b; +} + +.traffic-pie__value { + font-size: 9px; + font-weight: 700; +} + +.traffic-pie__caption { + font-size: 5px; + fill: #64748b; +} + +// ── Legend ── +.traffic-legend { + min-width: 0; + max-height: 16rem; + overflow-y: auto; +} + +.traffic-legend__item { + display: grid; + grid-template-columns: 0.7rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0; + font-size: 0.84rem; +} + +.traffic-legend__swatch { + width: 0.7rem; + height: 0.7rem; + border-radius: 50%; +} + +.traffic-legend__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// ── Data table ── +.traffic-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + width: 100%; +} + +.traffic-table { + width: 100%; + font-size: 0.85rem; + border-collapse: collapse; + + th, td { + padding: 0.55rem 0.45rem; + text-align: right; + border-bottom: 1px solid #f1f5f9; + white-space: nowrap; + } + + th { + font-weight: 600; + color: #475569; + border-bottom: 2px solid #e2e8f0; + } + + th:first-child, td:first-child { + text-align: left; + white-space: normal; + } + + tbody tr:hover { + background-color: #f8fafc; + } +} + +// ── Empty state ── +.traffic-empty { + display: grid; + place-items: center; + gap: 0.5rem; + min-height: 14rem; + color: #94a3b8; + text-align: center; + + i { + font-size: 3rem; + } +} + +// ── Error banner ── +.statistics-error { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 1rem; + padding: 0.8rem 1rem; + color: #991b1b; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 0.5rem; +} + +// ── Footer note ── +.statistics-note { + margin: 1rem 0 0; + color: #64748b; + font-size: 0.8rem; + text-align: right; +} + +// ── Placeholder section (Coming soon) ── +.statistics-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + min-height: 50vh; + text-align: center; + color: #94a3b8; + + i { + font-size: 4rem; + color: #cbd5e1; + } + + h3 { + font-size: 1.25rem; + font-weight: 700; + color: #64748b; + margin: 0; + } + + p { + font-size: 0.95rem; + margin: 0; + } +} + +// ── Bandwidth section ── +.bandwidth-view { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.bandwidth-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; +} + +.bandwidth-stat-card { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + padding: 1rem 1.25rem; + display: flex; + align-items: center; + gap: 1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); + + &__icon { + width: 44px; + height: 44px; + border-radius: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + flex-shrink: 0; + + &--in { + background: #e0f2fe; + color: #0284c7; + } + + &--out { + background: #dcfce7; + color: #16a34a; + } + + &--queue { + background: #fef3c7; + color: #d97706; + } + + &--session, + &--drain { + background: #f3e8ff; + color: #7e22ce; + } + } + + &__body { + min-width: 0; + } + + &__value { + font-size: 1.15rem; + font-weight: 700; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__label { + font-size: 0.8rem; + color: #64748b; + margin-top: 0.15rem; + white-space: nowrap; + } +} + +.bandwidth-panel { + width: 100%; +} + +.bandwidth-table { + font-size: 0.82rem; + min-width: 1050px; + width: 100%; + border-collapse: separate; + border-spacing: 0; + + th, td { + padding: 0.6rem 0.65rem; + white-space: nowrap !important; + } + + th:first-child, td:first-child { + text-align: left; + white-space: nowrap !important; + position: sticky; + left: 0; + z-index: 2; + background: #ffffff; + box-shadow: 2px 0 5px rgba(0, 0, 0, 0.06); + } + + th:first-child { + background: #ffffff; + z-index: 3; + } + + tbody tr:hover td:first-child { + background: #f8fafc; + } + + .bandwidth-peer-name { + font-weight: 600; + color: #1e293b; + display: inline-block; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + } + + .bandwidth-peerid-cell { + font-family: monospace; + font-size: 0.78rem; + color: #64748b; + } + + .bandwidth-totals-row { + background-color: #f1f5f9; + font-weight: 600; + + td { + border-bottom: 2px solid #cbd5e1; + } + + td:first-child { + background: #f1f5f9; + } + + &:hover { + background-color: #e8eef3; + + td:first-child { + background: #e8eef3; + } + } + } +} + +.bandwidth-drain-badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-align: center; + white-space: nowrap; + background: #f1f5f9; + color: #475569; + + &--warning { + background: #fef3c7; + color: #b45309; + font-weight: 700; + } + + &--critical { + background: #fee2e2; + color: #dc2626; + font-weight: 700; + } +} + +// ── Responsive: medium screens (stack traffic cards & 2-column stats) ── +@media (max-width: 1200px) { + .bandwidth-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1100px) { + .statistics-grid { + grid-template-columns: 1fr; + } +} + +// ── Responsive: phone mode ── +@media (max-width: $bp-mobile), (max-width: $bp-narrow) and (max-height: $bp-short) { + // Hide left sidebar on mobile + .statistics-left-pane { + display: none; + } + + // Show mobile tab bar + .statistics-mobile-tabs { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: #ffffff; + border-bottom: 1px solid #e2e8f0; + overflow: hidden; + flex-shrink: 0; + } + + .statistics-mobile-tabs__list { + display: flex; + gap: 0.25rem; + overflow-x: auto; + flex: 1; + -webkit-overflow-scrolling: touch; + + &::-webkit-scrollbar { + display: none; + } + } + + .statistics-mobile-tab { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.75rem; + border-radius: 9999px; + font-size: 0.8rem; + font-weight: 500; + white-space: nowrap; + color: #64748b; + background: none; + border: 1px solid transparent; + box-shadow: none; + cursor: pointer; + flex-shrink: 0; + + &:active { + box-shadow: none; + } + + i { + font-size: 0.75rem; + } + + &.active { + background-color: #e0f2fe; + color: #0369a1; + font-weight: 600; + border-color: #bae6fd; + } + + &:hover:not(.active) { + background-color: #f1f5f9; + } + } + + .statistics-mobile-refresh { + width: 32px; + height: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + flex-shrink: 0; + font-size: 0.85rem; + } + + // Container becomes column for mobile + .statistics-container { + flex-direction: column; + } + + // Right pane takes full width + .statistics-right-pane { + padding: 0.75rem; + } + + .statistics-content-header { + margin-bottom: 0.75rem; + + &__title h2 { + font-size: 1.25rem; + } + + // Hide desktop refresh button on mobile (the mobile tab bar has its own icon button) + .statistics-refresh-btn { + display: none; + } + } + + // Stack cards + .statistics-grid { + grid-template-columns: 1fr; + } + + .traffic-panel { + padding: 0.8rem; + } + + // Stack chart and legend vertically on phone + .traffic-pie { + grid-template-columns: 1fr; + + svg { + max-height: 14rem; + } + } + + // Bandwidth mobile + .bandwidth-summary-grid { + grid-template-columns: 1fr; + gap: 0.65rem; + } + + .bandwidth-stat-card { + padding: 0.75rem 1rem; + } + + .bandwidth-panel { + padding: 0.8rem 0.5rem; + } +} diff --git a/webui-src/app/scss/vendors/_solid.scss b/webui-src/app/scss/vendors/_solid.scss index fe4bdb1..2ec2ab6 100644 --- a/webui-src/app/scss/vendors/_solid.scss +++ b/webui-src/app/scss/vendors/_solid.scss @@ -16,7 +16,8 @@ } .fa, -.fas { +.fas, +.far { font-family: 'Font Awesome 5 Free'; font-weight: 900; } diff --git a/webui-src/app/statistics/bandwidth.js b/webui-src/app/statistics/bandwidth.js new file mode 100644 index 0000000..c87cc0b --- /dev/null +++ b/webui-src/app/statistics/bandwidth.js @@ -0,0 +1,368 @@ +const m = require('mithril'); +const rs = require('rswebui'); +const NetworkData = require('network/network_data'); + +function idString(value) { + if (!value) return ''; + if (typeof value === 'string') return value; + return rs.idToHex(value); +} + +const formatBytes = rs.formatBytes; + +function friendNamesFromCache() { + const names = {}; + Object.values(NetworkData.gpgDetails || {}).forEach((profile) => { + (profile.locations || []).forEach((location) => { + const id = idString(location.id); + if (id) names[id] = profile.name || location.name || id; + }); + }); + return names; +} + +function number64(value) { + if (!value) return 0; + if (typeof value === 'object') return Number(value.xstr64 || value.xint64) || 0; + return Number(value) || 0; +} + +function parseRates(raw) { + if (!raw) return {}; + return { + rateIn: Number(raw.mRateIn !== undefined ? raw.mRateIn : raw.rateIn) || 0, + rateMaxIn: Number(raw.mRateMaxIn !== undefined ? raw.mRateMaxIn : raw.rateMaxIn) || 0, + allocIn: Number(raw.mAllocIn !== undefined ? raw.mAllocIn : raw.allocIn) || 0, + rateOut: Number(raw.mRateOut !== undefined ? raw.mRateOut : raw.rateOut) || 0, + rateMaxOut: Number(raw.mRateMaxOut !== undefined ? raw.mRateMaxOut : raw.rateMaxOut) || 0, + allowedOut: Number(raw.mAllowedOut !== undefined ? raw.mAllowedOut : raw.allowedOut) || 0, + queueIn: Number(raw.mQueueIn !== undefined ? raw.mQueueIn : raw.queueIn) || 0, + queueOut: Number(raw.mQueueOut !== undefined ? raw.mQueueOut : raw.queueOut) || 0, + queueOutBytes: number64(raw.mQueueOutBytes !== undefined ? raw.mQueueOutBytes : raw.queueOutBytes), + totalIn: number64(raw.mTotalIn !== undefined ? raw.mTotalIn : raw.totalIn), + totalOut: number64(raw.mTotalOut !== undefined ? raw.mTotalOut : raw.totalOut), + }; +} + +function computeDrain(queueOutBytes, rateOut) { + const effectiveSpeed = Math.max(rateOut, 1.0); + return queueOutBytes / (effectiveSpeed * 1024.0); +} + +function formatRate(rate) { + if (!rate || rate <= 0) return '0.0'; + return rate.toFixed(1); +} + +const COLORS = ['#0788cb', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4', '#ec4899', '#84cc16', '#64748b', '#f97316']; + +function DonutChart() { + return { + view(vnode) { + const rows = (vnode.attrs.rows || []).filter((r) => r.value > 0); + const total = vnode.attrs.total !== undefined ? vnode.attrs.total : rows.reduce((s, r) => s + r.value, 0); + const caption = vnode.attrs.caption || 'total'; + let offset = 0; + + if (!rows.length && !total) { + return m('.traffic-empty', [ + m('i.fas.fa-chart-pie'), + m('p', 'No session data recorded yet.'), + ]); + } + + return m('.traffic-pie', [ + m('svg[viewBox="0 0 120 120"][role=img]', { 'aria-label': vnode.attrs.label }, [ + m('circle[cx=60][cy=60][r=44].traffic-pie__track'), + m('g[transform="rotate(-90 60 60)"]', rows.map((row, index) => { + const length = total ? (row.value / total) * 276.46 : 0; + const segment = m('circle[cx=60][cy=60][r=44].traffic-pie__segment', { + stroke: COLORS[index % COLORS.length], + 'stroke-dasharray': `${length} ${276.46 - length}`, + 'stroke-dashoffset': -offset, + }, m('title', `${row.label}: ${formatBytes(row.value)}`)); + offset += length; + return segment; + })), + m('text[x=60][y=57][text-anchor=middle].traffic-pie__value', formatBytes(total)), + m('text[x=60][y=70][text-anchor=middle].traffic-pie__caption', caption), + ]), + m('.traffic-legend', rows.map((row, index) => + m('.traffic-legend__item', [ + m('span.traffic-legend__swatch', { style: { backgroundColor: COLORS[index % COLORS.length] } }), + m('span.traffic-legend__name', row.label), + m('strong', `${total ? ((row.value / total) * 100).toFixed(1) : 0}%`), + ]) + )), + ]); + }, + }; +} + +function DrainBadge(drainSec) { + let badgeClass = 'bandwidth-drain-badge'; + if (drainSec >= 60) { + badgeClass += ' bandwidth-drain-badge--critical'; + } else if (drainSec >= 30) { + badgeClass += ' bandwidth-drain-badge--warning'; + } + const display = Math.round(drainSec); + return m('span', { class: badgeClass }, `${display} s`); +} + +// A named object, not an anonymous export: mithril mounts a POJO component +// as Object.create(component) and runs hooks with `this` = that instance, so +// state written through `this` shadowed the module object -- and the Stats +// page's Refresh button, calling load() on the MODULE, updated state the +// mounted instance never read. Every reference below goes through Bandwidth +// so the instance, the timer and external callers share one state. +const Bandwidth = { + totalRates: null, + peerRates: [], + loading: false, + error: '', + timer: null, + + async load() { + if (Bandwidth.loading) return; + Bandwidth.loading = true; + try { + const [totalRes, allRes] = await Promise.all([ + rs.rsJsonApiRequest('/rsConfig/getTotalBandwidthRates'), + rs.rsJsonApiRequest('/rsConfig/getAllBandwidthRates'), + ]); + + const totalBody = totalRes.body || totalRes || {}; + const allBody = allRes.body || allRes || {}; + + if (totalRes.status === 200 && totalBody.retval) { + const rawTotals = totalBody.rates || {}; + Bandwidth.totalRates = parseRates(rawTotals); + Bandwidth.totalRates.drain = computeDrain(Bandwidth.totalRates.queueOutBytes, Bandwidth.totalRates.rateOut); + Bandwidth.error = ''; + } else { + Bandwidth.totalRates = null; + } + + if (allRes.status === 200 && allBody.retval) { + const friendNames = friendNamesFromCache(); + const rawMap = allBody.ratemap || {}; + const entries = Array.isArray(rawMap) + ? rawMap + : Object.entries(rawMap).map(([key, value]) => ({ key, value })); + + Bandwidth.peerRates = entries.map((entry) => { + const id = idString(entry.key); + const rates = parseRates(entry.value); + const drain = computeDrain(rates.queueOutBytes, rates.rateOut); + const name = friendNames[id] || (id ? `Peer ${id.slice(0, 8)}…` : 'Unknown peer'); + return { + id, + name, + ...rates, + drain, + }; + }).sort((a, b) => b.rateIn + b.rateOut - (a.rateIn + a.rateOut)); + } else { + Bandwidth.peerRates = []; + } + } catch (err) { + Bandwidth.error = 'Failed to load bandwidth statistics from RetroShare Core.'; + } finally { + Bandwidth.loading = false; + m.redraw(); + } + }, + + oninit() { + Bandwidth.totalRates = null; + Bandwidth.peerRates = []; + Bandwidth.error = ''; + Bandwidth.load(); + Bandwidth.timer = setInterval(() => Bandwidth.load(), 5000); + }, + + onremove() { + if (Bandwidth.timer) { + clearInterval(Bandwidth.timer); + Bandwidth.timer = null; + } + }, + + view() { + const totals = Bandwidth.totalRates; + const peers = Bandwidth.peerRates; + + return m('.bandwidth-view', [ + Bandwidth.error && m('.statistics-error', [m('i.fas.fa-exclamation-triangle'), Bandwidth.error]), + + // ── Top summary cards ── + totals && m('.bandwidth-summary-grid', [ + m('.bandwidth-stat-card', [ + m('.bandwidth-stat-card__icon.bandwidth-stat-card__icon--in', m('i.fas.fa-arrow-down')), + m('.bandwidth-stat-card__body', [ + m('.bandwidth-stat-card__value', formatBytes(totals.totalIn)), + m('.bandwidth-stat-card__label', 'Session In'), + ]), + ]), + m('.bandwidth-stat-card', [ + m('.bandwidth-stat-card__icon.bandwidth-stat-card__icon--out', m('i.fas.fa-arrow-up')), + m('.bandwidth-stat-card__body', [ + m('.bandwidth-stat-card__value', formatBytes(totals.totalOut)), + m('.bandwidth-stat-card__label', 'Session Out'), + ]), + ]), + m('.bandwidth-stat-card', [ + m('.bandwidth-stat-card__icon.bandwidth-stat-card__icon--queue', m('i.fas.fa-layer-group')), + m('.bandwidth-stat-card__body', [ + m('.bandwidth-stat-card__value', formatBytes(totals.queueOutBytes)), + m('.bandwidth-stat-card__label', 'Queue Size'), + ]), + ]), + m('.bandwidth-stat-card', [ + m('.bandwidth-stat-card__icon.bandwidth-stat-card__icon--drain', m('i.fas.fa-stopwatch')), + m('.bandwidth-stat-card__body', [ + m('.bandwidth-stat-card__value', `${totals.queueOut.toLocaleString()} ${totals.queueOut === 1 ? 'pkt' : 'pkts'} / ${Math.round(totals.drain)}s`), + m('.bandwidth-stat-card__label', 'Queue Packets & Drain'), + ]), + ]), + ]), + + // ── Session distribution charts by friends ── + (() => { + const inRows = peers + .map((p) => ({ label: p.name, value: p.totalIn })) + .filter((r) => r.value > 0) + .sort((a, b) => b.value - a.value); + + const peerInSum = inRows.reduce((sum, r) => sum + r.value, 0); + const pastIn = totals ? Math.max(0, totals.totalIn - peerInSum) : 0; + if (pastIn > 0) { + inRows.push({ label: 'Disconnected peers', value: pastIn }); + } + + const outRows = peers + .map((p) => ({ label: p.name, value: p.totalOut })) + .filter((r) => r.value > 0) + .sort((a, b) => b.value - a.value); + + const peerOutSum = outRows.reduce((sum, r) => sum + r.value, 0); + const pastOut = totals ? Math.max(0, totals.totalOut - peerOutSum) : 0; + if (pastOut > 0) { + outRows.push({ label: 'Disconnected peers', value: pastOut }); + } + + return m('.statistics-grid', [ + m('section.traffic-panel', [ + m('.traffic-panel__heading', [ + m('i.fas.fa-arrow-down'), + m('div', [ + m('h3', 'Session Received by friend'), + m('p', 'Incoming data transferred per friend during this session.'), + ]), + ]), + m(DonutChart, { + rows: inRows, + total: totals ? totals.totalIn : peerInSum, + label: 'Session received distribution', + caption: 'session in', + }), + ]), + m('section.traffic-panel', [ + m('.traffic-panel__heading', [ + m('i.fas.fa-arrow-up'), + m('div', [ + m('h3', 'Session Sent by friend'), + m('p', 'Outgoing data transferred per friend during this session.'), + ]), + ]), + m(DonutChart, { + rows: outRows, + total: totals ? totals.totalOut : peerOutSum, + label: 'Session sent distribution', + caption: 'session out', + }), + ]), + ]); + })(), + + // ── Bandwidth detailed rates table ── + m('section.traffic-panel.bandwidth-panel', [ + m('.traffic-panel__heading', [ + m('i.fas.fa-tachometer-alt'), + m('div', [ + m('h3', 'Bandwidth Control Rates'), + m('p', 'Real-time throughput, allocation limits, output queues, and estimated drain time per peer.'), + ]), + ]), + + m('.traffic-table-wrap.bandwidth-table-wrap', [ + m('table.traffic-table.bandwidth-table', [ + m('thead', [ + m('tr', [ + m('th', 'Peer'), + m('th', 'Peer ID'), + m('th', { title: 'Current real-time download speed' }, 'In Rate (kB/s)'), + m('th', { title: 'Total data received this session' }, 'Session In'), + m('th', { title: 'Maximum download speed allocated to this peer' }, 'In Max (kB/s)'), + m('th', { title: 'Incoming data packets waiting to be processed' }, 'In Queue'), + m('th', { title: 'Current real-time upload speed' }, 'Out Rate (kB/s)'), + m('th', { title: 'Total data sent this session' }, 'Session Out'), + m('th', { title: 'Maximum upload speed allocated to this peer' }, 'Out Max (kB/s)'), + m('th', { title: 'Upload limit requested by remote peer' }, 'Out Allowed (kB/s)'), + m('th', { title: 'Outgoing data packets waiting to be sent' }, 'Out Queue'), + m('th', { title: 'Total size buffered in output queue' }, 'Queue Size'), + m('th', { title: 'Estimated time to empty output queue at current speed' }, 'Drain'), + ]), + ]), + m('tbody', [ + // Pinned Totals row at top (same as Qt BwCtrlWindow) + totals && m('tr.bandwidth-totals-row', [ + m('td', m('strong', 'Totals')), + m('td.bandwidth-peerid-cell', '—'), + m('td', m('strong', formatRate(totals.rateIn))), + m('td', m('strong', formatBytes(totals.totalIn))), + m('td', formatRate(totals.rateMaxIn)), + m('td', totals.queueIn.toLocaleString()), + m('td', m('strong', formatRate(totals.rateOut))), + m('td', m('strong', formatBytes(totals.totalOut))), + m('td', formatRate(totals.rateMaxOut)), + m('td', '—'), + m('td', totals.queueOut.toLocaleString()), + m('td', formatBytes(totals.queueOutBytes)), + m('td', DrainBadge(totals.drain)), + ]), + + // Peer rows + peers.length + ? peers.map((p) => + m('tr', [ + m('td', { title: p.id }, m('span.bandwidth-peer-name', p.name)), + m('td.bandwidth-peerid-cell', { title: p.id }, `${p.id.slice(0, 8)}…`), + m('td', formatRate(p.rateIn)), + m('td', formatBytes(p.totalIn)), + m('td', p.rateMaxIn > 0 ? formatRate(p.rateMaxIn) : '—'), + m('td', p.queueIn.toLocaleString()), + m('td', formatRate(p.rateOut)), + m('td', formatBytes(p.totalOut)), + m('td', p.rateMaxOut > 0 ? formatRate(p.rateMaxOut) : '—'), + m('td', p.allowedOut > 0 ? formatRate(p.allowedOut) : '—'), + m('td', p.queueOut.toLocaleString()), + m('td', formatBytes(p.queueOutBytes)), + m('td', DrainBadge(p.drain)), + ]) + ) + : (!totals + ? m('tr', [m('td[colspan=13]', m('.traffic-empty', [m('i.fas.fa-tachometer-alt'), m('p', 'No bandwidth data available.')]))]) + : null), + ]), + ]), + ]), + ]), + + m('p.statistics-note', 'Bandwidth rates reflect current peer socket transfer states and refresh every 5 seconds.'), + ]); + }, +}; + +module.exports = Bandwidth; diff --git a/webui-src/app/statistics/statistics.js b/webui-src/app/statistics/statistics.js new file mode 100644 index 0000000..de2b0dc --- /dev/null +++ b/webui-src/app/statistics/statistics.js @@ -0,0 +1,347 @@ +const m = require('mithril'); +const rs = require('rswebui'); +const NetworkData = require('network/network_data'); +const Bandwidth = require('statistics/bandwidth'); + +const COLORS = ['#0788cb', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4', '#ec4899', '#84cc16', '#64748b', '#f97316']; +const SERVICE_NAMES = { + 0x0001: 'File index', 0x0011: 'Discovery', 0x0012: 'Chat', 0x0013: 'Messages', + 0x0014: 'Turtle routing', 0x0015: 'Tunnel', 0x0016: 'Heartbeat', 0x0017: 'File transfer', + 0x0018: 'Generic routing', 0x0019: 'File database', 0x0020: 'Service info', + 0x0021: 'Bandwidth control', 0x0022: 'Mail', 0x0023: 'Direct mail', + 0x0024: 'Distant mail', 0x0026: 'Service control', 0x0027: 'Distant chat', + 0x0028: 'GXS tunnel', 0x0101: 'Ban list', 0x0102: 'Status', 0x0103: 'Friend server', + 0x0200: 'Network exchange', 0x0211: 'Identities', 0x0215: 'Forums', + 0x0216: 'Boards', 0x0217: 'Channels', 0x0218: 'Circles', 0x0219: 'Reputation', + 0x0220: 'GXS recognition', 0x0230: 'GXS mail', 0x0240: 'JSON API', + 0x1011: 'RTT', +}; + +function idString(value) { + if (!value) return ''; + if (typeof value === 'string') return value; + return rs.idToHex(value); +} + +// rs.formatBytes is the one every other page uses -- the statusbar, the chat, +// the file lists. A second formatter here would print 1.5 MiB where the rest of +// the interface prints 1.5 MB for the same number. +const formatBytes = rs.formatBytes; + +// network_data already fetches the friend list and each peer's details, and +// keeps the result in NetworkData.gpgDetails for the whole session. Reading +// that costs nothing, where a second collection of its own would mean one +// getPeerDetails per friend, all at once, next to the traffic poll. +function friendNamesFromCache() { + const names = {}; + Object.values(NetworkData.gpgDetails || {}).forEach((profile) => { + (profile.locations || []).forEach((location) => { + const id = idString(location.id); + if (id) names[id] = profile.name || location.name || id; + }); + }); + return names; +} + +// Friends do not appear every five seconds: the traffic figures are refreshed +// on their own beat, the friend list on a much slower one. +const FRIENDS_REFRESH_MS = 60000; + +// RetroShare wraps uint64_t values as { xint64, xstr64 } because JSON numbers +// cannot safely represent every 64-bit integer. Prefer the decimal string so a +// large cumulative byte count is not truncated before it reaches this page. +function number64(value) { + if (!value) return 0; + if (typeof value === 'object') return Number(value.xstr64 || value.xint64) || 0; + return Number(value) || 0; +} + +function cumulativeRows(entries, labelFor) { + return (Array.isArray(entries) ? entries : []).map((entry) => { + const stats = entry.value || {}; + const incoming = number64(stats.bytesIn); + const outgoing = number64(stats.bytesOut); + const key = typeof entry.key === 'number' ? String(entry.key) : idString(entry.key) || String(entry.key); + return { + key, + label: labelFor(entry.key), + incoming, + outgoing, + total: incoming + outgoing, + count: (Number(stats.countIn) || 0) + (Number(stats.countOut) || 0), + firstSeen: number64(stats.firstSeen), + lastSeen: number64(stats.lastSeen), + }; + }).sort((a, b) => b.total - a.total); +} + +function aggregate(incoming, outgoing, keyFor, labelFor) { + const rows = new Map(); + const add = (clue, direction) => { + const key = keyFor(clue); + const row = rows.get(key) || { key, label: labelFor(clue, key), incoming: 0, outgoing: 0, count: 0 }; + row[direction] += Number(clue.size) || 0; + row.count += Number(clue.count) || 0; + rows.set(key, row); + }; + incoming.forEach((clue) => add(clue, 'incoming')); + outgoing.forEach((clue) => add(clue, 'outgoing')); + return Array.from(rows.values()).map((row) => ({ ...row, total: row.incoming + row.outgoing })) + .sort((a, b) => b.total - a.total); +} + +function PieChart() { + return { + view(vnode) { + const rows = vnode.attrs.rows.filter((row) => row.total > 0); + const total = rows.reduce((sum, row) => sum + row.total, 0); + let offset = 0; + return m('.traffic-pie', [ + m('svg[viewBox="0 0 120 120"][role=img]', { 'aria-label': vnode.attrs.label }, [ + m('circle[cx=60][cy=60][r=44].traffic-pie__track'), + // A dash offset of zero starts at three o'clock; the group turns the + // segments back to twelve, where a pie chart is read from. Only the + // segments: the totals in the middle stay upright. + m('g[transform="rotate(-90 60 60)"]', rows.map((row, index) => { + const length = total ? (row.total / total) * 276.46 : 0; + const segment = m('circle[cx=60][cy=60][r=44].traffic-pie__segment', { + stroke: COLORS[index % COLORS.length], + 'stroke-dasharray': `${length} ${276.46 - length}`, + 'stroke-dashoffset': -offset, + }, m('title', `${row.label}: ${formatBytes(row.total)}`)); + offset += length; + return segment; + })), + m('text[x=60][y=57][text-anchor=middle].traffic-pie__value', formatBytes(total)), + m('text[x=60][y=70][text-anchor=middle].traffic-pie__caption', 'total traffic'), + ]), + m('.traffic-legend', rows.map((row, index) => + m('.traffic-legend__item', [ + m('span.traffic-legend__swatch', { style: { backgroundColor: COLORS[index % COLORS.length] } }), + m('span.traffic-legend__name', row.label), + m('strong', `${total ? ((row.total / total) * 100).toFixed(1) : 0}%`), + ]) + )), + ]); + }, + }; +} + +function TrafficPanel() { + return { + view(vnode) { + const rows = vnode.attrs.rows; + return m('section.traffic-panel', [ + m('.traffic-panel__heading', [ + m('i.fas.' + (vnode.attrs.icon || 'fa-chart-pie')), + m('div', [m('h3', vnode.attrs.title), m('p', vnode.attrs.description)]), + ]), + rows.length + ? [m(PieChart, { rows, label: `${vnode.attrs.title} traffic distribution` }), + m('.traffic-table-wrap', m('table.traffic-table', [ + m('thead', m('tr', [m('th', vnode.attrs.column), m('th', 'Incoming'), m('th', 'Outgoing'), m('th', 'Total'), m('th', 'Packets')])), + m('tbody', rows.map((row) => m('tr', [ + m('td', row.label), m('td', formatBytes(row.incoming)), m('td', formatBytes(row.outgoing)), + m('td', m('strong', formatBytes(row.total))), m('td', row.count.toLocaleString()), + ]))), + ]))] + : m('.traffic-empty', [m('i.fas.fa-chart-pie'), m('p', 'No traffic has been recorded in the current tracking window.')]), + ]); + }, + }; +} + +// Navigation sections — Traffic and Bandwidth are implemented +const NAV_SECTIONS = [ + { id: 'traffic', label: 'Traffic', icon: 'fa-chart-pie', description: 'Live traffic distribution reported by RetroShare Core.' }, + { id: 'bandwidth', label: 'Bandwidth', icon: 'fa-tachometer-alt', description: 'Real-time bandwidth rates and peer throughput.' }, +]; + +function PlaceholderSection() { + return { + view(vnode) { + const section = vnode.attrs.section; + return m('.statistics-placeholder', [ + m('i.fas.' + section.icon), + m('h3', section.label), + m('p', 'Coming soon — this section is not yet implemented.'), + ]); + }, + }; +} + +module.exports = { + oninit(vnode) { + vnode.state.activeSection = 'traffic'; + vnode.state.incoming = []; + vnode.state.outgoing = []; + vnode.state.error = ''; + vnode.state.cumulativeServices = null; + vnode.state.cumulativePeers = null; + vnode.state.load = async () => { + // Two requests per run, every five seconds, plus whatever the Refresh + // button adds. Without this the runs stack up as soon as one of them is + // slower than the interval -- and on a phone they are, each answer + // closing its connection. + if (vnode.state.loading) return; + vnode.state.loading = true; + try { + if (Date.now() - vnode.state.friendsRefreshedAt > FRIENDS_REFRESH_MS) { + vnode.state.refreshFriends(); + } + const [serviceResponse, peerResponse] = await Promise.all([ + rs.rsJsonApiRequest('/rsConfig/getCumulativeTrafficByService'), + rs.rsJsonApiRequest('/rsConfig/getCumulativeTrafficByPeer'), + ]); + const serviceBody = serviceResponse.body || {}; + const peerBody = peerResponse.body || {}; + if (serviceResponse.status === 200 && peerResponse.status === 200 && serviceBody.retval && peerBody.retval) { + vnode.state.cumulativeServices = serviceBody.stats || []; + vnode.state.cumulativePeers = peerBody.stats || []; + vnode.state.error = ''; + } else { + // Older cores do not expose the cumulative API. Retain the live-window + // view as a compatibility fallback instead of leaving the page empty. + const response = await rs.rsJsonApiRequest('/rsConfig/getTrafficInfo'); + const body = response.body || {}; + if (response.status === 200 && body.retval) { + vnode.state.incoming = Array.isArray(body.in_lst) ? body.in_lst : []; + vnode.state.outgoing = Array.isArray(body.out_lst) ? body.out_lst : []; + vnode.state.error = 'This Core only provides the current traffic window; cumulative totals require the newer traffic statistics API.'; + } else { + vnode.state.error = 'Traffic statistics are not available from this RetroShare Core.'; + } + } + } finally { + // Whatever happened, the page must not stay locked on "loading": the + // guard above would then never let another run through. + vnode.state.loading = false; + m.redraw(); + } + }; + vnode.state.refreshFriends = () => { + vnode.state.friendsRefreshedAt = Date.now(); + NetworkData.refreshGpgDetails().then(() => m.redraw()).catch(() => {}); + }; + vnode.state.friendsRefreshedAt = 0; + vnode.state.loading = false; + vnode.state.load(); + vnode.state.timer = setInterval(() => { + if (vnode.state.activeSection === 'traffic') { + vnode.state.load(); + } + }, 5000); + }, + onremove(vnode) { clearInterval(vnode.state.timer); }, + view(vnode) { + const activeSection = NAV_SECTIONS.find((s) => s.id === vnode.state.activeSection) || NAV_SECTIONS[0]; + + const switchSection = (id) => { + vnode.state.activeSection = id; + if (id === 'traffic') { + vnode.state.load(); + } + }; + + const serviceLabel = (value) => { + const id = Number(value) || 0; + return SERVICE_NAMES[id] || `Service 0x${id.toString(16).padStart(4, '0')}`; + }; + const friendNames = friendNamesFromCache(); + const friendLabel = (value) => { + const id = idString(value) || 'unknown'; + return friendNames[id] || (id === 'unknown' ? 'Unknown peer' : `Peer ${id.slice(0, 8)}…`); + }; + const serviceRows = vnode.state.cumulativeServices + ? cumulativeRows(vnode.state.cumulativeServices, serviceLabel) + : aggregate(vnode.state.incoming, vnode.state.outgoing, + (clue) => Number(clue.service_id) || 0, (_clue, id) => serviceLabel(id)); + const friendRows = vnode.state.cumulativePeers + ? cumulativeRows(vnode.state.cumulativePeers, friendLabel) + : aggregate(vnode.state.incoming, vnode.state.outgoing, + (clue) => idString(clue.peer_id) || 'unknown', (_clue, id) => friendLabel(id)); + + // Refresh handler based on active section + const isBandwidth = activeSection.id === 'bandwidth'; + const isLoading = isBandwidth ? Bandwidth.loading : vnode.state.loading; + const handleRefresh = () => { + if (isBandwidth) { + Bandwidth.load(); + } else { + vnode.state.load(); + } + }; + + // Build the content for the active section + let sectionContent; + if (activeSection.id === 'traffic') { + sectionContent = [ + vnode.state.error && m('.statistics-error', [m('i.fas.fa-exclamation-triangle'), vnode.state.error]), + m('.statistics-grid', [ + m(TrafficPanel, { title: 'By service', icon: 'fa-layer-group', description: 'Which RetroShare services use the most bandwidth.', column: 'Service', rows: serviceRows }), + m(TrafficPanel, { title: 'By friend', icon: 'fa-user-friends', description: 'Traffic exchanged with each friend location.', column: 'Friend', rows: friendRows }), + ]), + m('p.statistics-note', vnode.state.cumulativeServices + ? 'Cumulative values are retained by the Core and refresh every 5 seconds.' + : 'Values cover the current traffic window and refresh every 5 seconds.'), + ]; + } else if (activeSection.id === 'bandwidth') { + sectionContent = m(Bandwidth); + } else { + sectionContent = m(PlaceholderSection, { section: activeSection }); + } + + return m('.statistics-container', [ + // ── Left pane: header card + navigation ── + m('.statistics-left-pane', [ + m('.statistics-header-card', [ + m('.statistics-header-card__title', [ + m('i.fas.fa-chart-pie'), + m('div', [m('h1', 'Statistics'), m('p', 'Traffic & routing stats')]), + ]), + ]), + m('nav.statistics-nav', NAV_SECTIONS.map((section) => + m('button.statistics-nav-item[type=button]', { + class: activeSection.id === section.id ? 'active' : '', + onclick: () => switchSection(section.id), + title: section.description, + }, [m('i.fas.' + section.icon), m('span', section.label)]) + )), + ]), + + // ── Mobile tab bar (visible only on small screens) ── + m('.statistics-mobile-tabs', [ + m('.statistics-mobile-tabs__list', NAV_SECTIONS.map((section) => + m('button.statistics-mobile-tab[type=button]', { + class: activeSection.id === section.id ? 'active' : '', + onclick: () => switchSection(section.id), + }, [m('i.fas.' + section.icon), m('span', section.label)]) + )), + m('button.statistics-mobile-refresh[type=button]', { + disabled: isLoading, + onclick: handleRefresh, + title: 'Refresh', + }, m('i.fas.fa-sync-alt')), + ]), + + // ── Right pane: section content ── + m('.statistics-right-pane', [ + m('.statistics-content-header', [ + m('.statistics-content-header__title', [ + m('h2', activeSection.label), + m('p', activeSection.description), + ]), + m('button.statistics-refresh-btn[type=button]', { + disabled: isLoading, + onclick: handleRefresh, + title: 'Refresh statistics', + }, [ + m('i.fas.fa-sync-alt'), + m('span.btn-text', 'Refresh'), + ]), + ]), + sectionContent, + ]), + ]); + }, +}; diff --git a/webui-src/app/statusbar.js b/webui-src/app/statusbar.js index a9156bd..315ee4e 100644 --- a/webui-src/app/statusbar.js +++ b/webui-src/app/statusbar.js @@ -61,6 +61,28 @@ function formatBytes(rawBytes) { return parseFloat((bytes / Math.pow(k, safeI)).toFixed(1)) + ' ' + sizes[safeI]; } +function getMobileStatusSummary() { + if (!rs.connectionState.status) { + return { color: '#ef4444', label: 'Disconnected from RetroShare Core' }; + } + const isHiddenMode = State.hiddenType === RS_HIDDEN_TYPE_TOR || + State.hiddenType === RS_HIDDEN_TYPE_I2P; + if (isHiddenMode) { + if (State.torChecking) return { color: '#eab308', label: 'Checking hidden service' }; + if (State.torProxyOk === false) return { color: '#ef4444', label: 'Hidden service unavailable' }; + return { + color: State.torProxyOk ? '#22c55e' : '#eab308', + label: `${State.hiddenType === RS_HIDDEN_TYPE_TOR ? 'Tor' : 'I2P'} connection`, + }; + } + if (State.natState === 8 || State.natState === 9) { + return { color: '#22c55e', label: State.natState === 9 ? 'Forwarded port' : 'Connected' }; + } + if ([3, 4].includes(State.natState)) return { color: '#ef4444', label: 'Network problem' }; + if (State.natState === 2) return { color: '#94a3b8', label: 'Offline' }; + return { color: '#eab308', label: State.natState === 6 ? 'Behind firewall' : 'Limited connection' }; +} + /** * Fetch the own peer's hidden type and proxy status using /rsTor API. * Mirrors Qt's TorStatus::getTorStatus(). @@ -346,25 +368,36 @@ const StatusBar = { } return m('.statusbar', [ - m('.statusbar-left', { style: 'display: flex; align-items: center; gap: 0.75rem;' }, [ + m('.statusbar-left', [ m('.statusbar-item', [ - m('i.fas.fa-users', { style: 'margin-right: 0.5rem; color: #94a3b8;' }), - m('span', `Friends: ${State.onlineCount}/${State.friendCount}`), + m('i.fas.fa-users', { style: 'margin-right: 0.35rem; color: #94a3b8;' }), + m('span.statusbar-label', 'Friends:\u00a0'), + m('span.statusbar-value', `${State.onlineCount}/${State.friendCount}`), ]), // NAT — hidden when in hidden/darknet mode (same as Qt) !isHiddenMode && m('.statusbar-divider'), - !isHiddenMode && m('.statusbar-item', { title: natTooltip, style: 'cursor: help;' }, [ - m('span', { style: 'margin-right: 0.5rem;' }, 'NAT:'), - m('.status-bullet', { style: { backgroundColor: natColor } }), + !isHiddenMode && m('.statusbar-item.statusbar-item--nat', { + title: natTooltip, + style: 'cursor: help; margin-left: 0.6rem;', + }, [ + m('span.statusbar-label', { style: 'margin-right: 0.35rem;' }, 'NAT:'), + m('.status-bullet', { + style: { backgroundColor: natColor, marginLeft: '0.15rem', marginRight: '0.45rem' }, + }), ]), // DHT — hidden when in hidden/darknet mode (same as Qt) !isHiddenMode && m('.statusbar-divider'), - !isHiddenMode && m('.statusbar-item', { title: dhtTooltip, style: 'cursor: help;' }, [ - m('span', { style: 'margin-right: 0.5rem;' }, 'DHT:'), - m('.status-bullet', { style: { backgroundColor: dhtColor } }), - State.dhtActive && State.dhtOk && m('span', { style: 'margin-left: 0.5rem;' }, `${formatUnit(State.dhtRsNetSize)} (${formatUnit(State.dhtNetSize)})`), + !isHiddenMode && m('.statusbar-item.statusbar-item--dht', { + title: dhtTooltip, + style: 'cursor: help; margin-left: 0.6rem;', + }, [ + m('span.statusbar-label', { style: 'margin-right: 0.35rem;' }, 'DHT:'), + m('.status-bullet', { + style: { backgroundColor: dhtColor, marginLeft: '0.15rem', marginRight: '0.35rem' }, + }), + State.dhtActive && State.dhtOk && m('span.statusbar-extra-info', { style: 'margin-left: 0.35rem;' }, `${formatUnit(State.dhtRsNetSize)} (${formatUnit(State.dhtNetSize)})`), ]), // Tor / I2P — only shown when in hidden/darknet mode (same as Qt) @@ -379,23 +412,25 @@ const StatusBar = { ]), // RatesStatus — Bandwidth speeds & total cumulative transfer (Down | Up) - m('.statusbar-right', { style: 'display: flex; align-items: center; gap: 0.75rem;' }, [ + m('.statusbar-right', [ m('.statusbar-item', { title: `Downloaded: ${formatBytes(State.totalIn)}`, - style: 'cursor: help; display: flex; align-items: center;' + style: 'cursor: help;' }, [ - m('i.fas.fa-arrow-down', { style: 'color: #22c55e; margin-right: 0.35rem;' }), - m('span', `Down: ${State.rateIn.toFixed(2)} kB/s`), - m('span', { style: 'color: #64748b; font-size: 0.8rem; margin-left: 0.35rem;' }, `(${formatBytes(State.totalIn)})`), + m('i.fas.fa-arrow-down', { style: 'color: #22c55e; margin-right: 0.25rem;' }), + m('span.statusbar-label', 'Down:\u00a0'), + m('span.statusbar-value', `${State.rateIn.toFixed(1)} kB/s`), + m('span.statusbar-total-bytes', { style: 'color: #64748b; font-size: 0.8rem; margin-left: 0.25rem;' }, `(${formatBytes(State.totalIn)})`), ]), m('.statusbar-divider'), m('.statusbar-item', { title: `Uploaded: ${formatBytes(State.totalOut)}`, - style: 'cursor: help; display: flex; align-items: center;' + style: 'cursor: help;' }, [ - m('i.fas.fa-arrow-up', { style: 'color: #3b82f6; margin-right: 0.35rem;' }), - m('span', `Up: ${State.rateOut.toFixed(2)} kB/s`), - m('span', { style: 'color: #64748b; font-size: 0.8rem; margin-left: 0.35rem;' }, `(${formatBytes(State.totalOut)})`), + m('i.fas.fa-arrow-up', { style: 'color: #3b82f6; margin-right: 0.25rem;' }), + m('span.statusbar-label', 'Up:\u00a0'), + m('span.statusbar-value', `${State.rateOut.toFixed(1)} kB/s`), + m('span.statusbar-total-bytes', { style: 'color: #64748b; font-size: 0.8rem; margin-left: 0.25rem;' }, `(${formatBytes(State.totalOut)})`), ]), ]), ]); @@ -403,3 +438,6 @@ const StatusBar = { }; module.exports = StatusBar; +StatusBar.State = State; +StatusBar.formatBytes = formatBytes; +StatusBar.getMobileStatusSummary = getMobileStatusSummary; diff --git a/webui-src/app/widgets.js b/webui-src/app/widgets.js index bad711e..fe0f152 100644 --- a/webui-src/app/widgets.js +++ b/webui-src/app/widgets.js @@ -1,22 +1,36 @@ const m = require('mithril'); const Sidebar = () => { - let active = 0; + let mobileOpen = false; + + const links = (v) => v.attrs.tabs.map((panelName) => { + const href = v.attrs.baseRoute + panelName; + const selected = m.route.get().toLowerCase().startsWith(href.toLowerCase()); + return m('a', { + class: selected ? 'selected-sidebar-link' : '', + href, + onclick: (event) => { + event.preventDefault(); + mobileOpen = false; + m.route.set(href); + }, + }, panelName); + }); + return { - view: (v) => - m( - '.sidebar', - v.attrs.tabs.map((panelName, index) => - m( - m.route.Link, - { - class: index === active ? 'selected-sidebar-link' : '', - onclick: () => (active = index), - href: v.attrs.baseRoute + panelName, - }, - panelName - ) - ) - ), + view: (v) => { + if (!v.attrs.mobileDrawer) return m('.sidebar', links(v)); + return m('.sidebar-drawer', [ + m('button.sidebar-mobile-toggle[type=button][aria-label=Open navigation]', { + 'aria-expanded': mobileOpen, + onclick: () => { mobileOpen = !mobileOpen; }, + }, m('i.fas.fa-bars')), + mobileOpen ? m('.sidebar-drawer__backdrop', { onclick: () => { mobileOpen = false; } }) : null, + m('.sidebar', { class: mobileOpen ? 'sidebar--mobile-open' : '' }, [ + m('.sidebar-drawer__title', 'Navigation'), + ...links(v), + ]), + ]); + }, }; }; const SidebarQuickView = () => { @@ -45,26 +59,74 @@ const SidebarQuickView = () => { // There are ways of doing this inside m.route but it is probably // cleaner and faster when kept outside of the main auto // rendering system -function popupMessage(message) { +function closePopupMessage() { const container = document.getElementById('modal-container'); + if (!container) return; + m.mount(container, null); + container.style.display = 'none'; +} + +function popupMessage(message, modalClass = '') { + const container = document.getElementById('modal-container'); + if (!container) return; container.style.display = 'block'; - m.render( - container, - m('.modal-content', [ + + // A vnode carries the DOM node it owns, so the same one cannot be + // rendered twice. Most call sites hand popupMessage a ready-made vnode + // (or an array of them); re-rendering it on every global redraw needs a + // fresh copy each time -- and a copy all the way down, since mithril + // skips a subtree whose children array is identical (old === vnodes) and + // freezes it at its first render. Component call sites go through m() + // and need no copying. + const freshVnode = (vnode) => { + if (Array.isArray(vnode)) return vnode.map(freshVnode); + if (!vnode || typeof vnode !== 'object' || !vnode.tag) return vnode; + // '<' is m.trust and '[' is m.fragment: neither is a selector m() can + // parse. Rebuilding them with m() would silently turn trusted html into + // an empty div, so they go back through their own factory. '#' is a + // text vnode, whose children is the string itself. + if (vnode.tag === '<') return m.trust(vnode.children); + if (vnode.tag === '#') return vnode.children; + if (vnode.tag === '[') return m.fragment(vnode.attrs, freshVnode(vnode.children)); + if (typeof vnode.tag !== 'string') return m(vnode.tag, vnode.attrs, vnode.children); + return m(vnode.tag, vnode.attrs, freshVnode(vnode.children)); + }; + + const renderContent = () => { + if (typeof message === 'function') { + const res = message(); + if (res && typeof res.view === 'function') { + return m(message); + } + return freshVnode(res); + } + if (message && typeof message.view === 'function') { + return m(message); + } + return freshVnode(message); + }; + + const Popup = { + view: () => m(`.modal-content${modalClass ? `.${modalClass}` : ''}`, [ m( 'button.red.close-btn', { - onclick: () => (container.style.display = 'none'), + onclick: () => { + closePopupMessage(); + }, }, m('i.fas.fa-times') ), - message, - ]) - ); + renderContent(), + ]), + }; + + m.mount(container, Popup); } module.exports = { Sidebar, SidebarQuickView, popupMessage, + closePopupMessage, }; diff --git a/webui-src/styles.css b/webui-src/styles.css index a9de9cb..01c4d00 100644 --- a/webui-src/styles.css +++ b/webui-src/styles.css @@ -4,4 +4,4 @@ */.fa,.fas,.far,.fal,.fab{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-0.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:solid .08em #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fas.fa-pull-left,.far.fa-pull-left,.fal.fa-pull-left,.fab.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fas.fa-pull-right,.far.fa-pull-right,.fal.fa-pull-right,.fab.fa-pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scale(1, -1);transform:scale(1, -1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scale(-1, -1);transform:scale(-1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-flip-both{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:""}.fa-accessible-icon:before{content:""}.fa-accusoft:before{content:""}.fa-acquisitions-incorporated:before{content:""}.fa-ad:before{content:""}.fa-address-book:before{content:""}.fa-address-card:before{content:""}.fa-adjust:before{content:""}.fa-adn:before{content:""}.fa-adobe:before{content:""}.fa-adversal:before{content:""}.fa-affiliatetheme:before{content:""}.fa-air-freshener:before{content:""}.fa-airbnb:before{content:""}.fa-algolia:before{content:""}.fa-align-center:before{content:""}.fa-align-justify:before{content:""}.fa-align-left:before{content:""}.fa-align-right:before{content:""}.fa-alipay:before{content:""}.fa-allergies:before{content:""}.fa-amazon:before{content:""}.fa-amazon-pay:before{content:""}.fa-ambulance:before{content:""}.fa-american-sign-language-interpreting:before{content:""}.fa-amilia:before{content:""}.fa-anchor:before{content:""}.fa-android:before{content:""}.fa-angellist:before{content:""}.fa-angle-double-down:before{content:""}.fa-angle-double-left:before{content:""}.fa-angle-double-right:before{content:""}.fa-angle-double-up:before{content:""}.fa-angle-down:before{content:""}.fa-angle-left:before{content:""}.fa-angle-right:before{content:""}.fa-angle-up:before{content:""}.fa-angry:before{content:""}.fa-angrycreative:before{content:""}.fa-angular:before{content:""}.fa-ankh:before{content:""}.fa-app-store:before{content:""}.fa-app-store-ios:before{content:""}.fa-apper:before{content:""}.fa-apple:before{content:""}.fa-apple-alt:before{content:""}.fa-apple-pay:before{content:""}.fa-archive:before{content:""}.fa-archway:before{content:""}.fa-arrow-alt-circle-down:before{content:""}.fa-arrow-alt-circle-left:before{content:""}.fa-arrow-alt-circle-right:before{content:""}.fa-arrow-alt-circle-up:before{content:""}.fa-arrow-circle-down:before{content:""}.fa-arrow-circle-left:before{content:""}.fa-arrow-circle-right:before{content:""}.fa-arrow-circle-up:before{content:""}.fa-arrow-down:before{content:""}.fa-arrow-left:before{content:""}.fa-arrow-right:before{content:""}.fa-arrow-up:before{content:""}.fa-arrows-alt:before{content:""}.fa-arrows-alt-h:before{content:""}.fa-arrows-alt-v:before{content:""}.fa-artstation:before{content:""}.fa-assistive-listening-systems:before{content:""}.fa-asterisk:before{content:""}.fa-asymmetrik:before{content:""}.fa-at:before{content:""}.fa-atlas:before{content:""}.fa-atlassian:before{content:""}.fa-atom:before{content:""}.fa-audible:before{content:""}.fa-audio-description:before{content:""}.fa-autoprefixer:before{content:""}.fa-avianex:before{content:""}.fa-aviato:before{content:""}.fa-award:before{content:""}.fa-aws:before{content:""}.fa-baby:before{content:""}.fa-baby-carriage:before{content:""}.fa-backspace:before{content:""}.fa-backward:before{content:""}.fa-bacon:before{content:""}.fa-balance-scale:before{content:""}.fa-balance-scale-left:before{content:""}.fa-balance-scale-right:before{content:""}.fa-ban:before{content:""}.fa-band-aid:before{content:""}.fa-bandcamp:before{content:""}.fa-barcode:before{content:""}.fa-bars:before{content:""}.fa-baseball-ball:before{content:""}.fa-basketball-ball:before{content:""}.fa-bath:before{content:""}.fa-battery-empty:before{content:""}.fa-battery-full:before{content:""}.fa-battery-half:before{content:""}.fa-battery-quarter:before{content:""}.fa-battery-three-quarters:before{content:""}.fa-battle-net:before{content:""}.fa-bed:before{content:""}.fa-beer:before{content:""}.fa-behance:before{content:""}.fa-behance-square:before{content:""}.fa-bell:before{content:""}.fa-bell-slash:before{content:""}.fa-bezier-curve:before{content:""}.fa-bible:before{content:""}.fa-bicycle:before{content:""}.fa-biking:before{content:""}.fa-bimobject:before{content:""}.fa-binoculars:before{content:""}.fa-biohazard:before{content:""}.fa-birthday-cake:before{content:""}.fa-bitbucket:before{content:""}.fa-bitcoin:before{content:""}.fa-bity:before{content:""}.fa-black-tie:before{content:""}.fa-blackberry:before{content:""}.fa-blender:before{content:""}.fa-blender-phone:before{content:""}.fa-blind:before{content:""}.fa-blog:before{content:""}.fa-blogger:before{content:""}.fa-blogger-b:before{content:""}.fa-bluetooth:before{content:""}.fa-bluetooth-b:before{content:""}.fa-bold:before{content:""}.fa-bolt:before{content:""}.fa-bomb:before{content:""}.fa-bone:before{content:""}.fa-bong:before{content:""}.fa-book:before{content:""}.fa-book-dead:before{content:""}.fa-book-medical:before{content:""}.fa-book-open:before{content:""}.fa-book-reader:before{content:""}.fa-bookmark:before{content:""}.fa-bootstrap:before{content:""}.fa-border-all:before{content:""}.fa-border-none:before{content:""}.fa-border-style:before{content:""}.fa-bowling-ball:before{content:""}.fa-box:before{content:""}.fa-box-open:before{content:""}.fa-boxes:before{content:""}.fa-braille:before{content:""}.fa-brain:before{content:""}.fa-bread-slice:before{content:""}.fa-briefcase:before{content:""}.fa-briefcase-medical:before{content:""}.fa-broadcast-tower:before{content:""}.fa-broom:before{content:""}.fa-brush:before{content:""}.fa-btc:before{content:""}.fa-buffer:before{content:""}.fa-bug:before{content:""}.fa-building:before{content:""}.fa-bullhorn:before{content:""}.fa-bullseye:before{content:""}.fa-burn:before{content:""}.fa-buromobelexperte:before{content:""}.fa-bus:before{content:""}.fa-bus-alt:before{content:""}.fa-business-time:before{content:""}.fa-buysellads:before{content:""}.fa-calculator:before{content:""}.fa-calendar:before{content:""}.fa-calendar-alt:before{content:""}.fa-calendar-check:before{content:""}.fa-calendar-day:before{content:""}.fa-calendar-minus:before{content:""}.fa-calendar-plus:before{content:""}.fa-calendar-times:before{content:""}.fa-calendar-week:before{content:""}.fa-camera:before{content:""}.fa-camera-retro:before{content:""}.fa-campground:before{content:""}.fa-canadian-maple-leaf:before{content:""}.fa-candy-cane:before{content:""}.fa-cannabis:before{content:""}.fa-capsules:before{content:""}.fa-car:before{content:""}.fa-car-alt:before{content:""}.fa-car-battery:before{content:""}.fa-car-crash:before{content:""}.fa-car-side:before{content:""}.fa-caret-down:before{content:""}.fa-caret-left:before{content:""}.fa-caret-right:before{content:""}.fa-caret-square-down:before{content:""}.fa-caret-square-left:before{content:""}.fa-caret-square-right:before{content:""}.fa-caret-square-up:before{content:""}.fa-caret-up:before{content:""}.fa-carrot:before{content:""}.fa-cart-arrow-down:before{content:""}.fa-cart-plus:before{content:""}.fa-cash-register:before{content:""}.fa-cat:before{content:""}.fa-cc-amazon-pay:before{content:""}.fa-cc-amex:before{content:""}.fa-cc-apple-pay:before{content:""}.fa-cc-diners-club:before{content:""}.fa-cc-discover:before{content:""}.fa-cc-jcb:before{content:""}.fa-cc-mastercard:before{content:""}.fa-cc-paypal:before{content:""}.fa-cc-stripe:before{content:""}.fa-cc-visa:before{content:""}.fa-centercode:before{content:""}.fa-centos:before{content:""}.fa-certificate:before{content:""}.fa-chair:before{content:""}.fa-chalkboard:before{content:""}.fa-chalkboard-teacher:before{content:""}.fa-charging-station:before{content:""}.fa-chart-area:before{content:""}.fa-chart-bar:before{content:""}.fa-chart-line:before{content:""}.fa-chart-pie:before{content:""}.fa-check:before{content:""}.fa-check-circle:before{content:""}.fa-check-double:before{content:""}.fa-check-square:before{content:""}.fa-cheese:before{content:""}.fa-chess:before{content:""}.fa-chess-bishop:before{content:""}.fa-chess-board:before{content:""}.fa-chess-king:before{content:""}.fa-chess-knight:before{content:""}.fa-chess-pawn:before{content:""}.fa-chess-queen:before{content:""}.fa-chess-rook:before{content:""}.fa-chevron-circle-down:before{content:""}.fa-chevron-circle-left:before{content:""}.fa-chevron-circle-right:before{content:""}.fa-chevron-circle-up:before{content:""}.fa-chevron-down:before{content:""}.fa-chevron-left:before{content:""}.fa-chevron-right:before{content:""}.fa-chevron-up:before{content:""}.fa-child:before{content:""}.fa-chrome:before{content:""}.fa-chromecast:before{content:""}.fa-church:before{content:""}.fa-circle:before{content:""}.fa-circle-notch:before{content:""}.fa-city:before{content:""}.fa-clinic-medical:before{content:""}.fa-clipboard:before{content:""}.fa-clipboard-check:before{content:""}.fa-clipboard-list:before{content:""}.fa-clock:before{content:""}.fa-clone:before{content:""}.fa-closed-captioning:before{content:""}.fa-cloud:before{content:""}.fa-cloud-download-alt:before{content:""}.fa-cloud-meatball:before{content:""}.fa-cloud-moon:before{content:""}.fa-cloud-moon-rain:before{content:""}.fa-cloud-rain:before{content:""}.fa-cloud-showers-heavy:before{content:""}.fa-cloud-sun:before{content:""}.fa-cloud-sun-rain:before{content:""}.fa-cloud-upload-alt:before{content:""}.fa-cloudscale:before{content:""}.fa-cloudsmith:before{content:""}.fa-cloudversify:before{content:""}.fa-cocktail:before{content:""}.fa-code:before{content:""}.fa-code-branch:before{content:""}.fa-codepen:before{content:""}.fa-codiepie:before{content:""}.fa-coffee:before{content:""}.fa-cog:before{content:""}.fa-cogs:before{content:""}.fa-coins:before{content:""}.fa-columns:before{content:""}.fa-comment:before{content:""}.fa-comment-alt:before{content:""}.fa-comment-dollar:before{content:""}.fa-comment-dots:before{content:""}.fa-comment-medical:before{content:""}.fa-comment-slash:before{content:""}.fa-comments:before{content:""}.fa-comments-dollar:before{content:""}.fa-compact-disc:before{content:""}.fa-compass:before{content:""}.fa-compress:before{content:""}.fa-compress-arrows-alt:before{content:""}.fa-concierge-bell:before{content:""}.fa-confluence:before{content:""}.fa-connectdevelop:before{content:""}.fa-contao:before{content:""}.fa-cookie:before{content:""}.fa-cookie-bite:before{content:""}.fa-copy:before{content:""}.fa-copyright:before{content:""}.fa-couch:before{content:""}.fa-cpanel:before{content:""}.fa-creative-commons:before{content:""}.fa-creative-commons-by:before{content:""}.fa-creative-commons-nc:before{content:""}.fa-creative-commons-nc-eu:before{content:""}.fa-creative-commons-nc-jp:before{content:""}.fa-creative-commons-nd:before{content:""}.fa-creative-commons-pd:before{content:""}.fa-creative-commons-pd-alt:before{content:""}.fa-creative-commons-remix:before{content:""}.fa-creative-commons-sa:before{content:""}.fa-creative-commons-sampling:before{content:""}.fa-creative-commons-sampling-plus:before{content:""}.fa-creative-commons-share:before{content:""}.fa-creative-commons-zero:before{content:""}.fa-credit-card:before{content:""}.fa-critical-role:before{content:""}.fa-crop:before{content:""}.fa-crop-alt:before{content:""}.fa-cross:before{content:""}.fa-crosshairs:before{content:""}.fa-crow:before{content:""}.fa-crown:before{content:""}.fa-crutch:before{content:""}.fa-css3:before{content:""}.fa-css3-alt:before{content:""}.fa-cube:before{content:""}.fa-cubes:before{content:""}.fa-cut:before{content:""}.fa-cuttlefish:before{content:""}.fa-d-and-d:before{content:""}.fa-d-and-d-beyond:before{content:""}.fa-dashcube:before{content:""}.fa-database:before{content:""}.fa-deaf:before{content:""}.fa-delicious:before{content:""}.fa-democrat:before{content:""}.fa-deploydog:before{content:""}.fa-deskpro:before{content:""}.fa-desktop:before{content:""}.fa-dev:before{content:""}.fa-deviantart:before{content:""}.fa-dharmachakra:before{content:""}.fa-dhl:before{content:""}.fa-diagnoses:before{content:""}.fa-diaspora:before{content:""}.fa-dice:before{content:""}.fa-dice-d20:before{content:""}.fa-dice-d6:before{content:""}.fa-dice-five:before{content:""}.fa-dice-four:before{content:""}.fa-dice-one:before{content:""}.fa-dice-six:before{content:""}.fa-dice-three:before{content:""}.fa-dice-two:before{content:""}.fa-digg:before{content:""}.fa-digital-ocean:before{content:""}.fa-digital-tachograph:before{content:""}.fa-directions:before{content:""}.fa-discord:before{content:""}.fa-discourse:before{content:""}.fa-divide:before{content:""}.fa-dizzy:before{content:""}.fa-dna:before{content:""}.fa-dochub:before{content:""}.fa-docker:before{content:""}.fa-dog:before{content:""}.fa-dollar-sign:before{content:""}.fa-dolly:before{content:""}.fa-dolly-flatbed:before{content:""}.fa-donate:before{content:""}.fa-door-closed:before{content:""}.fa-door-open:before{content:""}.fa-dot-circle:before{content:""}.fa-dove:before{content:""}.fa-download:before{content:""}.fa-draft2digital:before{content:""}.fa-drafting-compass:before{content:""}.fa-dragon:before{content:""}.fa-draw-polygon:before{content:""}.fa-dribbble:before{content:""}.fa-dribbble-square:before{content:""}.fa-dropbox:before{content:""}.fa-drum:before{content:""}.fa-drum-steelpan:before{content:""}.fa-drumstick-bite:before{content:""}.fa-drupal:before{content:""}.fa-dumbbell:before{content:""}.fa-dumpster:before{content:""}.fa-dumpster-fire:before{content:""}.fa-dungeon:before{content:""}.fa-dyalog:before{content:""}.fa-earlybirds:before{content:""}.fa-ebay:before{content:""}.fa-edge:before{content:""}.fa-edit:before{content:""}.fa-egg:before{content:""}.fa-eject:before{content:""}.fa-elementor:before{content:""}.fa-ellipsis-h:before{content:""}.fa-ellipsis-v:before{content:""}.fa-ello:before{content:""}.fa-ember:before{content:""}.fa-empire:before{content:""}.fa-envelope:before{content:""}.fa-envelope-open:before{content:""}.fa-envelope-open-text:before{content:""}.fa-envelope-square:before{content:""}.fa-envira:before{content:""}.fa-equals:before{content:""}.fa-eraser:before{content:""}.fa-erlang:before{content:""}.fa-ethereum:before{content:""}.fa-ethernet:before{content:""}.fa-etsy:before{content:""}.fa-euro-sign:before{content:""}.fa-evernote:before{content:""}.fa-exchange-alt:before{content:""}.fa-exclamation:before{content:""}.fa-exclamation-circle:before{content:""}.fa-exclamation-triangle:before{content:""}.fa-expand:before{content:""}.fa-expand-arrows-alt:before{content:""}.fa-expeditedssl:before{content:""}.fa-external-link-alt:before{content:""}.fa-external-link-square-alt:before{content:""}.fa-eye:before{content:""}.fa-eye-dropper:before{content:""}.fa-eye-slash:before{content:""}.fa-facebook:before{content:""}.fa-facebook-f:before{content:""}.fa-facebook-messenger:before{content:""}.fa-facebook-square:before{content:""}.fa-fan:before{content:""}.fa-fantasy-flight-games:before{content:""}.fa-fast-backward:before{content:""}.fa-fast-forward:before{content:""}.fa-fax:before{content:""}.fa-feather:before{content:""}.fa-feather-alt:before{content:""}.fa-fedex:before{content:""}.fa-fedora:before{content:""}.fa-female:before{content:""}.fa-fighter-jet:before{content:""}.fa-figma:before{content:""}.fa-file:before{content:""}.fa-file-alt:before{content:""}.fa-file-archive:before{content:""}.fa-file-audio:before{content:""}.fa-file-code:before{content:""}.fa-file-contract:before{content:""}.fa-file-csv:before{content:""}.fa-file-download:before{content:""}.fa-file-excel:before{content:""}.fa-file-export:before{content:""}.fa-file-image:before{content:""}.fa-file-import:before{content:""}.fa-file-invoice:before{content:""}.fa-file-invoice-dollar:before{content:""}.fa-file-medical:before{content:""}.fa-file-medical-alt:before{content:""}.fa-file-pdf:before{content:""}.fa-file-powerpoint:before{content:""}.fa-file-prescription:before{content:""}.fa-file-signature:before{content:""}.fa-file-upload:before{content:""}.fa-file-video:before{content:""}.fa-file-word:before{content:""}.fa-fill:before{content:""}.fa-fill-drip:before{content:""}.fa-film:before{content:""}.fa-filter:before{content:""}.fa-fingerprint:before{content:""}.fa-fire:before{content:""}.fa-fire-alt:before{content:""}.fa-fire-extinguisher:before{content:""}.fa-firefox:before{content:""}.fa-first-aid:before{content:""}.fa-first-order:before{content:""}.fa-first-order-alt:before{content:""}.fa-firstdraft:before{content:""}.fa-fish:before{content:""}.fa-fist-raised:before{content:""}.fa-flag:before{content:""}.fa-flag-checkered:before{content:""}.fa-flag-usa:before{content:""}.fa-flask:before{content:""}.fa-flickr:before{content:""}.fa-flipboard:before{content:""}.fa-flushed:before{content:""}.fa-fly:before{content:""}.fa-folder:before{content:""}.fa-folder-minus:before{content:""}.fa-folder-open:before{content:""}.fa-folder-plus:before{content:""}.fa-font:before{content:""}.fa-font-awesome:before{content:""}.fa-font-awesome-alt:before{content:""}.fa-font-awesome-flag:before{content:""}.fa-font-awesome-logo-full:before{content:""}.fa-fonticons:before{content:""}.fa-fonticons-fi:before{content:""}.fa-football-ball:before{content:""}.fa-fort-awesome:before{content:""}.fa-fort-awesome-alt:before{content:""}.fa-forumbee:before{content:""}.fa-forward:before{content:""}.fa-foursquare:before{content:""}.fa-free-code-camp:before{content:""}.fa-freebsd:before{content:""}.fa-frog:before{content:""}.fa-frown:before{content:""}.fa-frown-open:before{content:""}.fa-fulcrum:before{content:""}.fa-funnel-dollar:before{content:""}.fa-futbol:before{content:""}.fa-galactic-republic:before{content:""}.fa-galactic-senate:before{content:""}.fa-gamepad:before{content:""}.fa-gas-pump:before{content:""}.fa-gavel:before{content:""}.fa-gem:before{content:""}.fa-genderless:before{content:""}.fa-get-pocket:before{content:""}.fa-gg:before{content:""}.fa-gg-circle:before{content:""}.fa-ghost:before{content:""}.fa-gift:before{content:""}.fa-gifts:before{content:""}.fa-git:before{content:""}.fa-git-alt:before{content:""}.fa-git-square:before{content:""}.fa-github:before{content:""}.fa-github-alt:before{content:""}.fa-github-square:before{content:""}.fa-gitkraken:before{content:""}.fa-gitlab:before{content:""}.fa-gitter:before{content:""}.fa-glass-cheers:before{content:""}.fa-glass-martini:before{content:""}.fa-glass-martini-alt:before{content:""}.fa-glass-whiskey:before{content:""}.fa-glasses:before{content:""}.fa-glide:before{content:""}.fa-glide-g:before{content:""}.fa-globe:before{content:""}.fa-globe-africa:before{content:""}.fa-globe-americas:before{content:""}.fa-globe-asia:before{content:""}.fa-globe-europe:before{content:""}.fa-gofore:before{content:""}.fa-golf-ball:before{content:""}.fa-goodreads:before{content:""}.fa-goodreads-g:before{content:""}.fa-google:before{content:""}.fa-google-drive:before{content:""}.fa-google-play:before{content:""}.fa-google-plus:before{content:""}.fa-google-plus-g:before{content:""}.fa-google-plus-square:before{content:""}.fa-google-wallet:before{content:""}.fa-gopuram:before{content:""}.fa-graduation-cap:before{content:""}.fa-gratipay:before{content:""}.fa-grav:before{content:""}.fa-greater-than:before{content:""}.fa-greater-than-equal:before{content:""}.fa-grimace:before{content:""}.fa-grin:before{content:""}.fa-grin-alt:before{content:""}.fa-grin-beam:before{content:""}.fa-grin-beam-sweat:before{content:""}.fa-grin-hearts:before{content:""}.fa-grin-squint:before{content:""}.fa-grin-squint-tears:before{content:""}.fa-grin-stars:before{content:""}.fa-grin-tears:before{content:""}.fa-grin-tongue:before{content:""}.fa-grin-tongue-squint:before{content:""}.fa-grin-tongue-wink:before{content:""}.fa-grin-wink:before{content:""}.fa-grip-horizontal:before{content:""}.fa-grip-lines:before{content:""}.fa-grip-lines-vertical:before{content:""}.fa-grip-vertical:before{content:""}.fa-gripfire:before{content:""}.fa-grunt:before{content:""}.fa-guitar:before{content:""}.fa-gulp:before{content:""}.fa-h-square:before{content:""}.fa-hacker-news:before{content:""}.fa-hacker-news-square:before{content:""}.fa-hackerrank:before{content:""}.fa-hamburger:before{content:""}.fa-hammer:before{content:""}.fa-hamsa:before{content:""}.fa-hand-holding:before{content:""}.fa-hand-holding-heart:before{content:""}.fa-hand-holding-usd:before{content:""}.fa-hand-lizard:before{content:""}.fa-hand-middle-finger:before{content:""}.fa-hand-paper:before{content:""}.fa-hand-peace:before{content:""}.fa-hand-point-down:before{content:""}.fa-hand-point-left:before{content:""}.fa-hand-point-right:before{content:""}.fa-hand-point-up:before{content:""}.fa-hand-pointer:before{content:""}.fa-hand-rock:before{content:""}.fa-hand-scissors:before{content:""}.fa-hand-spock:before{content:""}.fa-hands:before{content:""}.fa-hands-helping:before{content:""}.fa-handshake:before{content:""}.fa-hanukiah:before{content:""}.fa-hard-hat:before{content:""}.fa-hashtag:before{content:""}.fa-hat-wizard:before{content:""}.fa-haykal:before{content:""}.fa-hdd:before{content:""}.fa-heading:before{content:""}.fa-headphones:before{content:""}.fa-headphones-alt:before{content:""}.fa-headset:before{content:""}.fa-heart:before{content:""}.fa-heart-broken:before{content:""}.fa-heartbeat:before{content:""}.fa-helicopter:before{content:""}.fa-highlighter:before{content:""}.fa-hiking:before{content:""}.fa-hippo:before{content:""}.fa-hips:before{content:""}.fa-hire-a-helper:before{content:""}.fa-history:before{content:""}.fa-hockey-puck:before{content:""}.fa-holly-berry:before{content:""}.fa-home:before{content:""}.fa-hooli:before{content:""}.fa-hornbill:before{content:""}.fa-horse:before{content:""}.fa-horse-head:before{content:""}.fa-hospital:before{content:""}.fa-hospital-alt:before{content:""}.fa-hospital-symbol:before{content:""}.fa-hot-tub:before{content:""}.fa-hotdog:before{content:""}.fa-hotel:before{content:""}.fa-hotjar:before{content:""}.fa-hourglass:before{content:""}.fa-hourglass-end:before{content:""}.fa-hourglass-half:before{content:""}.fa-hourglass-start:before{content:""}.fa-house-damage:before{content:""}.fa-houzz:before{content:""}.fa-hryvnia:before{content:""}.fa-html5:before{content:""}.fa-hubspot:before{content:""}.fa-i-cursor:before{content:""}.fa-ice-cream:before{content:""}.fa-icicles:before{content:""}.fa-icons:before{content:""}.fa-id-badge:before{content:""}.fa-id-card:before{content:""}.fa-id-card-alt:before{content:""}.fa-igloo:before{content:""}.fa-image:before{content:""}.fa-images:before{content:""}.fa-imdb:before{content:""}.fa-inbox:before{content:""}.fa-indent:before{content:""}.fa-industry:before{content:""}.fa-infinity:before{content:""}.fa-info:before{content:""}.fa-info-circle:before{content:""}.fa-instagram:before{content:""}.fa-intercom:before{content:""}.fa-internet-explorer:before{content:""}.fa-invision:before{content:""}.fa-ioxhost:before{content:""}.fa-italic:before{content:""}.fa-itch-io:before{content:""}.fa-itunes:before{content:""}.fa-itunes-note:before{content:""}.fa-java:before{content:""}.fa-jedi:before{content:""}.fa-jedi-order:before{content:""}.fa-jenkins:before{content:""}.fa-jira:before{content:""}.fa-joget:before{content:""}.fa-joint:before{content:""}.fa-joomla:before{content:""}.fa-journal-whills:before{content:""}.fa-js:before{content:""}.fa-js-square:before{content:""}.fa-jsfiddle:before{content:""}.fa-kaaba:before{content:""}.fa-kaggle:before{content:""}.fa-key:before{content:""}.fa-keybase:before{content:""}.fa-keyboard:before{content:""}.fa-keycdn:before{content:""}.fa-khanda:before{content:""}.fa-kickstarter:before{content:""}.fa-kickstarter-k:before{content:""}.fa-kiss:before{content:""}.fa-kiss-beam:before{content:""}.fa-kiss-wink-heart:before{content:""}.fa-kiwi-bird:before{content:""}.fa-korvue:before{content:""}.fa-landmark:before{content:""}.fa-language:before{content:""}.fa-laptop:before{content:""}.fa-laptop-code:before{content:""}.fa-laptop-medical:before{content:""}.fa-laravel:before{content:""}.fa-lastfm:before{content:""}.fa-lastfm-square:before{content:""}.fa-laugh:before{content:""}.fa-laugh-beam:before{content:""}.fa-laugh-squint:before{content:""}.fa-laugh-wink:before{content:""}.fa-layer-group:before{content:""}.fa-leaf:before{content:""}.fa-leanpub:before{content:""}.fa-lemon:before{content:""}.fa-less:before{content:""}.fa-less-than:before{content:""}.fa-less-than-equal:before{content:""}.fa-level-down-alt:before{content:""}.fa-level-up-alt:before{content:""}.fa-life-ring:before{content:""}.fa-lightbulb:before{content:""}.fa-line:before{content:""}.fa-link:before{content:""}.fa-linkedin:before{content:""}.fa-linkedin-in:before{content:""}.fa-linode:before{content:""}.fa-linux:before{content:""}.fa-lira-sign:before{content:""}.fa-list:before{content:""}.fa-list-alt:before{content:""}.fa-list-ol:before{content:""}.fa-list-ul:before{content:""}.fa-location-arrow:before{content:""}.fa-lock:before{content:""}.fa-lock-open:before{content:""}.fa-long-arrow-alt-down:before{content:""}.fa-long-arrow-alt-left:before{content:""}.fa-long-arrow-alt-right:before{content:""}.fa-long-arrow-alt-up:before{content:""}.fa-low-vision:before{content:""}.fa-luggage-cart:before{content:""}.fa-lyft:before{content:""}.fa-magento:before{content:""}.fa-magic:before{content:""}.fa-magnet:before{content:""}.fa-mail-bulk:before{content:""}.fa-mailchimp:before{content:""}.fa-male:before{content:""}.fa-mandalorian:before{content:""}.fa-map:before{content:""}.fa-map-marked:before{content:""}.fa-map-marked-alt:before{content:""}.fa-map-marker:before{content:""}.fa-map-marker-alt:before{content:""}.fa-map-pin:before{content:""}.fa-map-signs:before{content:""}.fa-markdown:before{content:""}.fa-marker:before{content:""}.fa-mars:before{content:""}.fa-mars-double:before{content:""}.fa-mars-stroke:before{content:""}.fa-mars-stroke-h:before{content:""}.fa-mars-stroke-v:before{content:""}.fa-mask:before{content:""}.fa-mastodon:before{content:""}.fa-maxcdn:before{content:""}.fa-medal:before{content:""}.fa-medapps:before{content:""}.fa-medium:before{content:""}.fa-medium-m:before{content:""}.fa-medkit:before{content:""}.fa-medrt:before{content:""}.fa-meetup:before{content:""}.fa-megaport:before{content:""}.fa-meh:before{content:""}.fa-meh-blank:before{content:""}.fa-meh-rolling-eyes:before{content:""}.fa-memory:before{content:""}.fa-mendeley:before{content:""}.fa-menorah:before{content:""}.fa-mercury:before{content:""}.fa-meteor:before{content:""}.fa-microchip:before{content:""}.fa-microphone:before{content:""}.fa-microphone-alt:before{content:""}.fa-microphone-alt-slash:before{content:""}.fa-microphone-slash:before{content:""}.fa-microscope:before{content:""}.fa-microsoft:before{content:""}.fa-minus:before{content:""}.fa-minus-circle:before{content:""}.fa-minus-square:before{content:""}.fa-mitten:before{content:""}.fa-mix:before{content:""}.fa-mixcloud:before{content:""}.fa-mizuni:before{content:""}.fa-mobile:before{content:""}.fa-mobile-alt:before{content:""}.fa-modx:before{content:""}.fa-monero:before{content:""}.fa-money-bill:before{content:""}.fa-money-bill-alt:before{content:""}.fa-money-bill-wave:before{content:""}.fa-money-bill-wave-alt:before{content:""}.fa-money-check:before{content:""}.fa-money-check-alt:before{content:""}.fa-monument:before{content:""}.fa-moon:before{content:""}.fa-mortar-pestle:before{content:""}.fa-mosque:before{content:""}.fa-motorcycle:before{content:""}.fa-mountain:before{content:""}.fa-mouse-pointer:before{content:""}.fa-mug-hot:before{content:""}.fa-music:before{content:""}.fa-napster:before{content:""}.fa-neos:before{content:""}.fa-network-wired:before{content:""}.fa-neuter:before{content:""}.fa-newspaper:before{content:""}.fa-nimblr:before{content:""}.fa-node:before{content:""}.fa-node-js:before{content:""}.fa-not-equal:before{content:""}.fa-notes-medical:before{content:""}.fa-npm:before{content:""}.fa-ns8:before{content:""}.fa-nutritionix:before{content:""}.fa-object-group:before{content:""}.fa-object-ungroup:before{content:""}.fa-odnoklassniki:before{content:""}.fa-odnoklassniki-square:before{content:""}.fa-oil-can:before{content:""}.fa-old-republic:before{content:""}.fa-om:before{content:""}.fa-opencart:before{content:""}.fa-openid:before{content:""}.fa-opera:before{content:""}.fa-optin-monster:before{content:""}.fa-osi:before{content:""}.fa-otter:before{content:""}.fa-outdent:before{content:""}.fa-page4:before{content:""}.fa-pagelines:before{content:""}.fa-pager:before{content:""}.fa-paint-brush:before{content:""}.fa-paint-roller:before{content:""}.fa-palette:before{content:""}.fa-palfed:before{content:""}.fa-pallet:before{content:""}.fa-paper-plane:before{content:""}.fa-paperclip:before{content:""}.fa-parachute-box:before{content:""}.fa-paragraph:before{content:""}.fa-parking:before{content:""}.fa-passport:before{content:""}.fa-pastafarianism:before{content:""}.fa-paste:before{content:""}.fa-patreon:before{content:""}.fa-pause:before{content:""}.fa-pause-circle:before{content:""}.fa-paw:before{content:""}.fa-paypal:before{content:""}.fa-peace:before{content:""}.fa-pen:before{content:""}.fa-pen-alt:before{content:""}.fa-pen-fancy:before{content:""}.fa-pen-nib:before{content:""}.fa-pen-square:before{content:""}.fa-pencil-alt:before{content:""}.fa-pencil-ruler:before{content:""}.fa-penny-arcade:before{content:""}.fa-people-carry:before{content:""}.fa-pepper-hot:before{content:""}.fa-percent:before{content:""}.fa-percentage:before{content:""}.fa-periscope:before{content:""}.fa-person-booth:before{content:""}.fa-phabricator:before{content:""}.fa-phoenix-framework:before{content:""}.fa-phoenix-squadron:before{content:""}.fa-phone:before{content:""}.fa-phone-alt:before{content:""}.fa-phone-slash:before{content:""}.fa-phone-square:before{content:""}.fa-phone-square-alt:before{content:""}.fa-phone-volume:before{content:""}.fa-photo-video:before{content:""}.fa-php:before{content:""}.fa-pied-piper:before{content:""}.fa-pied-piper-alt:before{content:""}.fa-pied-piper-hat:before{content:""}.fa-pied-piper-pp:before{content:""}.fa-piggy-bank:before{content:""}.fa-pills:before{content:""}.fa-pinterest:before{content:""}.fa-pinterest-p:before{content:""}.fa-pinterest-square:before{content:""}.fa-pizza-slice:before{content:""}.fa-place-of-worship:before{content:""}.fa-plane:before{content:""}.fa-plane-arrival:before{content:""}.fa-plane-departure:before{content:""}.fa-play:before{content:""}.fa-play-circle:before{content:""}.fa-playstation:before{content:""}.fa-plug:before{content:""}.fa-plus:before{content:""}.fa-plus-circle:before{content:""}.fa-plus-square:before{content:""}.fa-podcast:before{content:""}.fa-poll:before{content:""}.fa-poll-h:before{content:""}.fa-poo:before{content:""}.fa-poo-storm:before{content:""}.fa-poop:before{content:""}.fa-portrait:before{content:""}.fa-pound-sign:before{content:""}.fa-power-off:before{content:""}.fa-pray:before{content:""}.fa-praying-hands:before{content:""}.fa-prescription:before{content:""}.fa-prescription-bottle:before{content:""}.fa-prescription-bottle-alt:before{content:""}.fa-print:before{content:""}.fa-procedures:before{content:""}.fa-product-hunt:before{content:""}.fa-project-diagram:before{content:""}.fa-pushed:before{content:""}.fa-puzzle-piece:before{content:""}.fa-python:before{content:""}.fa-qq:before{content:""}.fa-qrcode:before{content:""}.fa-question:before{content:""}.fa-question-circle:before{content:""}.fa-quidditch:before{content:""}.fa-quinscape:before{content:""}.fa-quora:before{content:""}.fa-quote-left:before{content:""}.fa-quote-right:before{content:""}.fa-quran:before{content:""}.fa-r-project:before{content:""}.fa-radiation:before{content:""}.fa-radiation-alt:before{content:""}.fa-rainbow:before{content:""}.fa-random:before{content:""}.fa-raspberry-pi:before{content:""}.fa-ravelry:before{content:""}.fa-react:before{content:""}.fa-reacteurope:before{content:""}.fa-readme:before{content:""}.fa-rebel:before{content:""}.fa-receipt:before{content:""}.fa-recycle:before{content:""}.fa-red-river:before{content:""}.fa-reddit:before{content:""}.fa-reddit-alien:before{content:""}.fa-reddit-square:before{content:""}.fa-redhat:before{content:""}.fa-redo:before{content:""}.fa-redo-alt:before{content:""}.fa-registered:before{content:""}.fa-remove-format:before{content:""}.fa-renren:before{content:""}.fa-reply:before{content:""}.fa-reply-all:before{content:""}.fa-replyd:before{content:""}.fa-republican:before{content:""}.fa-researchgate:before{content:""}.fa-resolving:before{content:""}.fa-restroom:before{content:""}.fa-retweet:before{content:""}.fa-rev:before{content:""}.fa-ribbon:before{content:""}.fa-ring:before{content:""}.fa-road:before{content:""}.fa-robot:before{content:""}.fa-rocket:before{content:""}.fa-rocketchat:before{content:""}.fa-rockrms:before{content:""}.fa-route:before{content:""}.fa-rss:before{content:""}.fa-rss-square:before{content:""}.fa-ruble-sign:before{content:""}.fa-ruler:before{content:""}.fa-ruler-combined:before{content:""}.fa-ruler-horizontal:before{content:""}.fa-ruler-vertical:before{content:""}.fa-running:before{content:""}.fa-rupee-sign:before{content:""}.fa-sad-cry:before{content:""}.fa-sad-tear:before{content:""}.fa-safari:before{content:""}.fa-salesforce:before{content:""}.fa-sass:before{content:""}.fa-satellite:before{content:""}.fa-satellite-dish:before{content:""}.fa-save:before{content:""}.fa-schlix:before{content:""}.fa-school:before{content:""}.fa-screwdriver:before{content:""}.fa-scribd:before{content:""}.fa-scroll:before{content:""}.fa-sd-card:before{content:""}.fa-search:before{content:""}.fa-search-dollar:before{content:""}.fa-search-location:before{content:""}.fa-search-minus:before{content:""}.fa-search-plus:before{content:""}.fa-searchengin:before{content:""}.fa-seedling:before{content:""}.fa-sellcast:before{content:""}.fa-sellsy:before{content:""}.fa-server:before{content:""}.fa-servicestack:before{content:""}.fa-shapes:before{content:""}.fa-share:before{content:""}.fa-share-alt:before{content:""}.fa-share-alt-square:before{content:""}.fa-share-square:before{content:""}.fa-shekel-sign:before{content:""}.fa-shield-alt:before{content:""}.fa-ship:before{content:""}.fa-shipping-fast:before{content:""}.fa-shirtsinbulk:before{content:""}.fa-shoe-prints:before{content:""}.fa-shopping-bag:before{content:""}.fa-shopping-basket:before{content:""}.fa-shopping-cart:before{content:""}.fa-shopware:before{content:""}.fa-shower:before{content:""}.fa-shuttle-van:before{content:""}.fa-sign:before{content:""}.fa-sign-in-alt:before{content:""}.fa-sign-language:before{content:""}.fa-sign-out-alt:before{content:""}.fa-signal:before{content:""}.fa-signature:before{content:""}.fa-sim-card:before{content:""}.fa-simplybuilt:before{content:""}.fa-sistrix:before{content:""}.fa-sitemap:before{content:""}.fa-sith:before{content:""}.fa-skating:before{content:""}.fa-sketch:before{content:""}.fa-skiing:before{content:""}.fa-skiing-nordic:before{content:""}.fa-skull:before{content:""}.fa-skull-crossbones:before{content:""}.fa-skyatlas:before{content:""}.fa-skype:before{content:""}.fa-slack:before{content:""}.fa-slack-hash:before{content:""}.fa-slash:before{content:""}.fa-sleigh:before{content:""}.fa-sliders-h:before{content:""}.fa-slideshare:before{content:""}.fa-smile:before{content:""}.fa-smile-beam:before{content:""}.fa-smile-wink:before{content:""}.fa-smog:before{content:""}.fa-smoking:before{content:""}.fa-smoking-ban:before{content:""}.fa-sms:before{content:""}.fa-snapchat:before{content:""}.fa-snapchat-ghost:before{content:""}.fa-snapchat-square:before{content:""}.fa-snowboarding:before{content:""}.fa-snowflake:before{content:""}.fa-snowman:before{content:""}.fa-snowplow:before{content:""}.fa-socks:before{content:""}.fa-solar-panel:before{content:""}.fa-sort:before{content:""}.fa-sort-alpha-down:before{content:""}.fa-sort-alpha-down-alt:before{content:""}.fa-sort-alpha-up:before{content:""}.fa-sort-alpha-up-alt:before{content:""}.fa-sort-amount-down:before{content:""}.fa-sort-amount-down-alt:before{content:""}.fa-sort-amount-up:before{content:""}.fa-sort-amount-up-alt:before{content:""}.fa-sort-down:before{content:""}.fa-sort-numeric-down:before{content:""}.fa-sort-numeric-down-alt:before{content:""}.fa-sort-numeric-up:before{content:""}.fa-sort-numeric-up-alt:before{content:""}.fa-sort-up:before{content:""}.fa-soundcloud:before{content:""}.fa-sourcetree:before{content:""}.fa-spa:before{content:""}.fa-space-shuttle:before{content:""}.fa-speakap:before{content:""}.fa-speaker-deck:before{content:""}.fa-spell-check:before{content:""}.fa-spider:before{content:""}.fa-spinner:before{content:""}.fa-splotch:before{content:""}.fa-spotify:before{content:""}.fa-spray-can:before{content:""}.fa-square:before{content:""}.fa-square-full:before{content:""}.fa-square-root-alt:before{content:""}.fa-squarespace:before{content:""}.fa-stack-exchange:before{content:""}.fa-stack-overflow:before{content:""}.fa-stackpath:before{content:""}.fa-stamp:before{content:""}.fa-star:before{content:""}.fa-star-and-crescent:before{content:""}.fa-star-half:before{content:""}.fa-star-half-alt:before{content:""}.fa-star-of-david:before{content:""}.fa-star-of-life:before{content:""}.fa-staylinked:before{content:""}.fa-steam:before{content:""}.fa-steam-square:before{content:""}.fa-steam-symbol:before{content:""}.fa-step-backward:before{content:""}.fa-step-forward:before{content:""}.fa-stethoscope:before{content:""}.fa-sticker-mule:before{content:""}.fa-sticky-note:before{content:""}.fa-stop:before{content:""}.fa-stop-circle:before{content:""}.fa-stopwatch:before{content:""}.fa-store:before{content:""}.fa-store-alt:before{content:""}.fa-strava:before{content:""}.fa-stream:before{content:""}.fa-street-view:before{content:""}.fa-strikethrough:before{content:""}.fa-stripe:before{content:""}.fa-stripe-s:before{content:""}.fa-stroopwafel:before{content:""}.fa-studiovinari:before{content:""}.fa-stumbleupon:before{content:""}.fa-stumbleupon-circle:before{content:""}.fa-subscript:before{content:""}.fa-subway:before{content:""}.fa-suitcase:before{content:""}.fa-suitcase-rolling:before{content:""}.fa-sun:before{content:""}.fa-superpowers:before{content:""}.fa-superscript:before{content:""}.fa-supple:before{content:""}.fa-surprise:before{content:""}.fa-suse:before{content:""}.fa-swatchbook:before{content:""}.fa-swimmer:before{content:""}.fa-swimming-pool:before{content:""}.fa-symfony:before{content:""}.fa-synagogue:before{content:""}.fa-sync:before{content:""}.fa-sync-alt:before{content:""}.fa-syringe:before{content:""}.fa-table:before{content:""}.fa-table-tennis:before{content:""}.fa-tablet:before{content:""}.fa-tablet-alt:before{content:""}.fa-tablets:before{content:""}.fa-tachometer-alt:before{content:""}.fa-tag:before{content:""}.fa-tags:before{content:""}.fa-tape:before{content:""}.fa-tasks:before{content:""}.fa-taxi:before{content:""}.fa-teamspeak:before{content:""}.fa-teeth:before{content:""}.fa-teeth-open:before{content:""}.fa-telegram:before{content:""}.fa-telegram-plane:before{content:""}.fa-temperature-high:before{content:""}.fa-temperature-low:before{content:""}.fa-tencent-weibo:before{content:""}.fa-tenge:before{content:""}.fa-terminal:before{content:""}.fa-text-height:before{content:""}.fa-text-width:before{content:""}.fa-th:before{content:""}.fa-th-large:before{content:""}.fa-th-list:before{content:""}.fa-the-red-yeti:before{content:""}.fa-theater-masks:before{content:""}.fa-themeco:before{content:""}.fa-themeisle:before{content:""}.fa-thermometer:before{content:""}.fa-thermometer-empty:before{content:""}.fa-thermometer-full:before{content:""}.fa-thermometer-half:before{content:""}.fa-thermometer-quarter:before{content:""}.fa-thermometer-three-quarters:before{content:""}.fa-think-peaks:before{content:""}.fa-thumbs-down:before{content:""}.fa-thumbs-up:before{content:""}.fa-thumbtack:before{content:""}.fa-ticket-alt:before{content:""}.fa-times:before{content:""}.fa-times-circle:before{content:""}.fa-tint:before{content:""}.fa-tint-slash:before{content:""}.fa-tired:before{content:""}.fa-toggle-off:before{content:""}.fa-toggle-on:before{content:""}.fa-toilet:before{content:""}.fa-toilet-paper:before{content:""}.fa-toolbox:before{content:""}.fa-tools:before{content:""}.fa-tooth:before{content:""}.fa-torah:before{content:""}.fa-torii-gate:before{content:""}.fa-tractor:before{content:""}.fa-trade-federation:before{content:""}.fa-trademark:before{content:""}.fa-traffic-light:before{content:""}.fa-train:before{content:""}.fa-tram:before{content:""}.fa-transgender:before{content:""}.fa-transgender-alt:before{content:""}.fa-trash:before{content:""}.fa-trash-alt:before{content:""}.fa-trash-restore:before{content:""}.fa-trash-restore-alt:before{content:""}.fa-tree:before{content:""}.fa-trello:before{content:""}.fa-tripadvisor:before{content:""}.fa-trophy:before{content:""}.fa-truck:before{content:""}.fa-truck-loading:before{content:""}.fa-truck-monster:before{content:""}.fa-truck-moving:before{content:""}.fa-truck-pickup:before{content:""}.fa-tshirt:before{content:""}.fa-tty:before{content:""}.fa-tumblr:before{content:""}.fa-tumblr-square:before{content:""}.fa-tv:before{content:""}.fa-twitch:before{content:""}.fa-twitter:before{content:""}.fa-twitter-square:before{content:""}.fa-typo3:before{content:""}.fa-uber:before{content:""}.fa-ubuntu:before{content:""}.fa-uikit:before{content:""}.fa-umbrella:before{content:""}.fa-umbrella-beach:before{content:""}.fa-underline:before{content:""}.fa-undo:before{content:""}.fa-undo-alt:before{content:""}.fa-uniregistry:before{content:""}.fa-universal-access:before{content:""}.fa-university:before{content:""}.fa-unlink:before{content:""}.fa-unlock:before{content:""}.fa-unlock-alt:before{content:""}.fa-untappd:before{content:""}.fa-upload:before{content:""}.fa-ups:before{content:""}.fa-usb:before{content:""}.fa-user:before{content:""}.fa-user-alt:before{content:""}.fa-user-alt-slash:before{content:""}.fa-user-astronaut:before{content:""}.fa-user-check:before{content:""}.fa-user-circle:before{content:""}.fa-user-clock:before{content:""}.fa-user-cog:before{content:""}.fa-user-edit:before{content:""}.fa-user-friends:before{content:""}.fa-user-graduate:before{content:""}.fa-user-injured:before{content:""}.fa-user-lock:before{content:""}.fa-user-md:before{content:""}.fa-user-minus:before{content:""}.fa-user-ninja:before{content:""}.fa-user-nurse:before{content:""}.fa-user-plus:before{content:""}.fa-user-secret:before{content:""}.fa-user-shield:before{content:""}.fa-user-slash:before{content:""}.fa-user-tag:before{content:""}.fa-user-tie:before{content:""}.fa-user-times:before{content:""}.fa-users:before{content:""}.fa-users-cog:before{content:""}.fa-usps:before{content:""}.fa-ussunnah:before{content:""}.fa-utensil-spoon:before{content:""}.fa-utensils:before{content:""}.fa-vaadin:before{content:""}.fa-vector-square:before{content:""}.fa-venus:before{content:""}.fa-venus-double:before{content:""}.fa-venus-mars:before{content:""}.fa-viacoin:before{content:""}.fa-viadeo:before{content:""}.fa-viadeo-square:before{content:""}.fa-vial:before{content:""}.fa-vials:before{content:""}.fa-viber:before{content:""}.fa-video:before{content:""}.fa-video-slash:before{content:""}.fa-vihara:before{content:""}.fa-vimeo:before{content:""}.fa-vimeo-square:before{content:""}.fa-vimeo-v:before{content:""}.fa-vine:before{content:""}.fa-vk:before{content:""}.fa-vnv:before{content:""}.fa-voicemail:before{content:""}.fa-volleyball-ball:before{content:""}.fa-volume-down:before{content:""}.fa-volume-mute:before{content:""}.fa-volume-off:before{content:""}.fa-volume-up:before{content:""}.fa-vote-yea:before{content:""}.fa-vr-cardboard:before{content:""}.fa-vuejs:before{content:""}.fa-walking:before{content:""}.fa-wallet:before{content:""}.fa-warehouse:before{content:""}.fa-water:before{content:""}.fa-wave-square:before{content:""}.fa-waze:before{content:""}.fa-weebly:before{content:""}.fa-weibo:before{content:""}.fa-weight:before{content:""}.fa-weight-hanging:before{content:""}.fa-weixin:before{content:""}.fa-whatsapp:before{content:""}.fa-whatsapp-square:before{content:""}.fa-wheelchair:before{content:""}.fa-whmcs:before{content:""}.fa-wifi:before{content:""}.fa-wikipedia-w:before{content:""}.fa-wind:before{content:""}.fa-window-close:before{content:""}.fa-window-maximize:before{content:""}.fa-window-minimize:before{content:""}.fa-window-restore:before{content:""}.fa-windows:before{content:""}.fa-wine-bottle:before{content:""}.fa-wine-glass:before{content:""}.fa-wine-glass-alt:before{content:""}.fa-wix:before{content:""}.fa-wizards-of-the-coast:before{content:""}.fa-wolf-pack-battalion:before{content:""}.fa-won-sign:before{content:""}.fa-wordpress:before{content:""}.fa-wordpress-simple:before{content:""}.fa-wpbeginner:before{content:""}.fa-wpexplorer:before{content:""}.fa-wpforms:before{content:""}.fa-wpressr:before{content:""}.fa-wrench:before{content:""}.fa-x-ray:before{content:""}.fa-xbox:before{content:""}.fa-xing:before{content:""}.fa-xing-square:before{content:""}.fa-y-combinator:before{content:""}.fa-yahoo:before{content:""}.fa-yammer:before{content:""}.fa-yandex:before{content:""}.fa-yandex-international:before{content:""}.fa-yarn:before{content:""}.fa-yelp:before{content:""}.fa-yen-sign:before{content:""}.fa-yin-yang:before{content:""}.fa-yoast:before{content:""}.fa-youtube:before{content:""}.fa-youtube-square:before{content:""}.fa-zhihu:before{content:""}.sr-only{border:0;clip:rect(0, 0, 0, 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}/*! * Font Awesome Free 5.9.0 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) - */@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;font-display:auto;src:url("./webfonts/fa-solid-900.eot");src:url("./webfonts/fa-solid-900.eot?#iefix") format("embedded-opentype"),url("./webfonts/fa-solid-900.woff2") format("woff2"),url("./webfonts/fa-solid-900.woff") format("woff"),url("./webfonts/fa-solid-900.ttf") format("truetype"),url("./webfonts/fa-solid-900.svg#fontawesome") format("svg")}.fa,.fas{font-family:"Font Awesome 5 Free";font-weight:900}html{font-size:87.5%;box-sizing:border-box}*,*::before,*::after{box-sizing:inherit}body,h1,h2,h3,h4,h5,h6,p,figure,blockquote,dl,dd{margin:0;padding:0}ul[role=list],ol[role=list]{list-style:none}html:focus-within{scroll-behavior:smooth}body{text-rendering:optimizeSpeed;line-height:1.5;font-family:"Roboto",Arial,Helvetica,sans-serif !important;letter-spacing:-0.025ch}a:not([class]){text-decoration-skip-ink:auto}img,picture{max-width:100%;display:block}input,button,textarea,select{font:inherit}@media(prefers-reduced-motion: reduce){html:focus-within{scroll-behavior:auto}*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important;scroll-behavior:auto !important}}#main{height:100vh}.content{display:flex;height:100%;overflow:hidden}.tab-content{display:flex;height:100%;width:100%;background-color:#eef3f6;animation:fadein .3s;overflow:auto}input[type=text],input[type=password],input[type=number],textarea{box-sizing:border-box;background:#fff;max-width:100%;font-size:1rem;font-weight:400;border:1px solid #ccc;border-radius:.25rem;padding:.25rem .5rem;outline:rgba(0,0,0,0)}input:focus{border:1px solid #3ba4d7;box-shadow:inset 0 0 5px #ccc}input.stretched{width:90%}input.small{max-width:70%;padding:.1rem}input.searchbar{width:40%}a{cursor:pointer}a[title=Back]{width:max-content;height:max-content;padding:.475rem .75rem;border-radius:50%;transition:100ms}a[title=Back]:hover{background:#eef3f6}table{padding:20px;table-layout:fixed;width:100%;border-collapse:collapse;text-align:center;color:#333;font-size:1.125rem}table th{font-size:1.125rem;color:#000;border-bottom:2px solid #eee}table tr{border-bottom:1px solid #eee}h3{color:#444}hr{margin-left:0;color:#aaa}.grid-2col{display:grid;grid-template-columns:auto auto;gap:1rem;justify-content:start}.grid-2col input[type=checkbox]{margin-top:20px}.error{color:red}.tooltip{color:#333;position:relative;display:inline-block;margin:0 .25rem}.tooltiptext{visibility:hidden;position:absolute;top:100%;left:50%;min-width:250px;margin-left:-120px;z-index:1;color:#ccc;background-color:#333;font-size:.875rem;text-align:center;padding:.25rem;border-radius:.5rem}.tooltip:hover .tooltiptext{visibility:visible;animation:fadein .5s}blockquote{color:#14141b;padding:.75rem 1rem .75rem 2rem;border-radius:.25rem}blockquote.info{position:relative;line-height:1.2;color:rgba(20,20,27,.8);border:1px solid rgba(17,143,204,.8)}blockquote.info::before{font-family:"Font Awesome 5 Free";position:absolute;top:.5rem;left:.5rem;content:"";color:#019dff}@keyframes fadein{from{opacity:0}to{opacity:1}}.fadein{animation:fadein .5s}@keyframes swipe-from-left{from{margin-left:100%}to{margin-left:0}}button{width:max-content;height:max-content;color:#fff;background:#019dff;font-size:1rem;padding:.4rem 1rem;border:0;border-radius:5px;cursor:pointer;box-shadow:inset -3px -3px 0 rgb(0,94.5826771654,154)}button:active{outline:none;box-shadow:inset 3px 3px 0 rgb(0,94.5826771654,154)}button.red{width:max-content;height:max-content;color:#fff;background:#ff3a4a;font-size:1rem;padding:.4rem 1rem;border:0;border-radius:5px;cursor:pointer;box-shadow:inset -3px -3px 0 rgb(211,0,17.1370558376)}button.red:active{outline:none;box-shadow:inset 3px 3px 0 rgb(211,0,17.1370558376)}.media-item{display:flex;margin-top:.5rem;padding:1rem;border:1px solid rgba(20,20,27,.1);border-radius:4px}.media-item__details{flex-basis:40%;display:flex;align-items:start;gap:.5rem}.media-item__details img{width:6rem;object-fit:contain}.media-item__desc{flex-basis:60%}.active-link{background:hsla(0,0%,100%,.1) !important}.nav-menu{background-color:#14141b;box-shadow:0 5px 5px #222;display:flex;flex-direction:column;align-items:center;height:100%;padding:.25rem;margin-right:0rem}.nav-menu__logo{padding:1.2rem 0;display:flex;align-items:center;gap:.3rem}.nav-menu__logo img{width:1.6rem}.nav-menu__logo h5{line-height:1;color:#fff}.nav-menu__box{padding:2rem .125rem;display:flex;flex-direction:column;gap:.5rem;position:relative}.nav-menu__box .item{margin:0;padding:.675rem .5rem;width:10rem;display:flex;align-items:center;line-height:1;border-radius:.5rem;text-decoration:none;color:#ccc;text-transform:capitalize;transition:0ms}.nav-menu__box .item:hover{background-color:rgba(238,243,246,.15)}.nav-menu__box .item i.sidenav-icon{width:2.5rem;height:1.4rem;display:grid;place-items:center}.nav-menu__box .item.item-selected{color:#9bdaff;background-color:rgba(155,218,255,.15);font-weight:medium}.nav-menu__box button.toggle-nav{display:none;position:absolute;padding:0;top:0;right:-1rem;background:rgb(77.5,186.5157480315,255);width:1.5rem;height:1.5rem;aspect-ratio:1;justify-content:center;align-items:center;border-radius:50%;box-shadow:none}.nav-menu.collapsed .nav-menu__logo .logo-container{display:flex;flex-direction:column;align-items:center;gap:.5rem}.nav-menu.collapsed .nav-menu__logo .logo-container>*:not(img){display:block}.nav-menu.collapsed .nav-menu__logo .nav-menu__logo-text{display:none !important}.nav-menu.collapsed .nav-menu__box .item{padding:.675rem 0;width:2.5rem;justify-content:center;transition:300ms}.nav-menu.collapsed .nav-menu__box .item span,.nav-menu.collapsed .nav-menu__box .item p{display:none !important}.nav-menu.collapsed button i{rotate:180deg}.nav-menu:hover button.toggle-nav{display:flex}.sidebar{width:13rem;background-color:#fff;display:flex;flex-direction:column}.sidebar a{text-decoration:none;text-transform:capitalize;padding:1rem;cursor:pointer;color:#999}.sidebar a:hover{color:#222}.sidebar .selected-sidebar-link{font-weight:bold;color:#222;border-left:5px solid #3ba4d7;animation:expand-left-border .1s}.sidebarquickview>h6{padding:.5rem}.sidebarquickview a{text-decoration:none;text-transform:capitalize;padding:.5rem 1rem;display:block;color:#999}.sidebarquickview a a:hover{color:#222}.sidebarquickview .selected-sidebarquickview-link{font-weight:bold;color:#222;border-left:5px solid #3ba4d7;animation:expand-left-border .1s}.node-panel{width:100%;padding:.5rem;animation:fadein .5s}@keyframes expand-left-border{from{border-left:0}to{border-left:5px solid #3ba4d7}}@media(max-width: 700px){.tab-content{flex-direction:column}.sidebar{width:100% !important;flex-direction:row !important;overflow-x:auto !important;overflow-y:hidden !important;white-space:nowrap !important;border-bottom:1px solid rgba(20,20,27,.1) !important;background:#fff !important;z-index:50 !important;flex-shrink:0 !important;height:auto !important;padding:0 !important}.sidebar a{display:inline-block !important;padding:.8rem 1.2rem !important;border-bottom:3px solid rgba(0,0,0,0) !important;border-left:none !important}.sidebar .selected-sidebar-link{border-left:none !important;border-bottom:3px solid #3ba4d7 !important;animation:none !important}.sidebarquickview>h4,.sidebarquickview>h6{display:none !important}}.posts{height:100%;margin-top:1rem;flex-direction:column;overflow:auto}.posts__heading{display:flex;flex-direction:column;justify-content:space-between}.posts-container{height:100%;padding:1rem;display:grid;grid-template-columns:repeat(auto-fill, minmax(150px, 1fr));gap:2rem;border:1px solid rgba(20,20,27,.1);border-radius:4px;overflow:auto}.posts-container-card{min-height:240px;flex-direction:column;border:1px solid rgba(20,20,27,.5);border-radius:4px;cursor:pointer;text-align:center}.posts-container-card img{flex-basis:90%;object-fit:cover}.posts-container-card p{padding:0 .125rem;flex-basis:10%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.progress-bar{width:100%;height:2rem;position:relative;text-align:center;background-color:#eef3f6;border-radius:20px;overflow:hidden}.progress-bar__status{position:absolute;top:0;left:0;height:100%;color:#14141b;background-color:#019dff}.progress-bar__percent{position:absolute;inset:0;margin:auto;width:fit-content;height:fit-content}.progress-bar-chunks{position:relative;margin-top:.5rem;width:100%;height:2rem;display:flex;border-radius:.25rem;overflow:hidden;background-color:#eef3f6}.progress-bar-chunks .chunk{width:100%}.progress-bar-chunks .chunk[data-chunkVal="0"]{background-color:rgba(155,218,255,.2)}.progress-bar-chunks .chunk[data-chunkVal="1"]{background-color:#ff3a4a}.progress-bar-chunks .chunk[data-chunkVal="2"]{background-color:#019dff}.progress-bar-chunks .chunk[data-chunkVal="3"]{background-color:#fcba03}.progress-bar-chunks__percent{position:absolute;inset:0;margin:auto;width:fit-content;height:fit-content}.statusbar{display:flex;justify-content:space-between;align-items:center;height:28px;background-color:#14141b;border-top:1px solid #2e2e38;padding:0 1rem;font-size:.8rem;color:#94a3b8;z-index:100;box-sizing:border-box;user-select:none;flex-shrink:0}.statusbar-left{display:flex;align-items:center}.statusbar-right{display:flex;align-items:center;gap:1.5rem}.statusbar-item{display:flex;align-items:center}.statusbar-divider{width:1px;height:14px;background-color:#2e2e38}.status-bullet{width:8px;height:8px;border-radius:50%;display:inline-block;box-shadow:0 0 4px rgba(0,0,0,.5)}.widget{height:100%;padding:1rem;display:flex;flex-direction:column;gap:.5rem;background-color:#fff;border-radius:.5rem;overflow:auto}.widget .top-heading{display:flex;justify-content:space-between}.widget__heading{display:flex;justify-content:space-between;align-items:center;border-bottom:2px solid #999}.widget__body{height:100%;display:flex;flex-direction:column;overflow:auto}.widget__body-heading{display:flex;justify-content:space-between;align-items:center}.widget__body-heading .action{display:flex;gap:.5rem}.widget__body-content{height:100%;overflow:auto}.widget__body-box{display:flex;flex-direction:column;gap:.5rem}.widget-half{max-width:50%}#modal-container{display:none;position:fixed;z-index:1;height:100%;top:0;left:0;width:100%;background-color:rgba(0,0,0,.2)}.modal-content{position:absolute;color:#555;width:40%;min-height:10rem;height:max-content;padding:1.5rem;inset:0;margin:auto;background-color:#fff;border-radius:.5rem;animation:fadein .5s;display:flex;flex-direction:column}.modal-content button:last-child{margin-top:auto}.modal-content .close-btn{position:absolute;right:1.5rem}.modal-content .widget{padding:0}#notification-container{position:absolute;bottom:0;right:0}.login-page{background-image:linear-gradient(-45deg, rgba(1, 157, 255, 0.75), rgba(17, 143, 204, 0.75));height:100%;animation:fadein .5s}.login-page .login-container{background-color:#fff;box-shadow:3px 3px 5px rgba(20,20,27,.4);margin:auto;position:relative;top:100px;max-width:400px;max-height:500px;border-radius:5px;display:flex;flex-direction:column;align-items:center}.login-page .login-container input{padding:.375rem .75rem;border-radius:.275rem}.login-page .login-container *{margin-bottom:1rem}.login-page .login-container>img{margin:1rem 0 2rem}.login-page .login-container extra{margin:0}.login-page .login-container>a{text-decoration:underline;cursor:pointer}.login-page .extra>label,.login-page .extra>br,.login-page .extra>input{margin-bottom:0}.homepage{margin:2rem auto 0;display:flex;flex-direction:column;gap:4rem}.homepage .logo{display:flex;justify-content:center;align-items:center}.homepage .logo img{width:90px}.homepage .logo .retroshareText{display:flex;flex-direction:column;align-items:center}.homepage .logo .retroshareText .retrotext{font-size:36px;font-weight:600;line-height:1.125}.homepage .logo .retroshareText .retrotext>span{color:#118fcc}.homepage .logo .retroshareText>b{font-size:14px;line-height:1}.homepage .certificate{display:flex;flex-direction:column;gap:4rem}.homepage .certificate__heading{text-align:center}.homepage .certificate__heading>h1{margin-bottom:1rem}.homepage .certificate__content{display:flex;flex-direction:column;gap:2rem;padding:2rem;text-align:center;border:1.5px solid rgba(17,143,204,.2);border-radius:6px;box-shadow:0px 0px 8px 2px rgba(20,20,27,.05)}.homepage .certificate__content .rsId>p{margin-bottom:.5rem;color:#118fcc}.homepage .certificate__content .retroshareID{padding:.25rem;display:flex;align-items:center;justify-self:start;font-size:1.25rem;border-radius:4px;background:rgba(20,20,27,.05)}.homepage .certificate__content .retroshareID .textArea{padding:0;width:100%;height:auto;font-size:1rem;font-family:monospace;background:rgba(0,0,0,0);border:none;resize:none;overflow:hidden;field-sizing:content}.homepage .certificate__content .retroshareID i{color:#118fcc}.homepage .certificate__content .retroshareID>i{margin:0 .5rem;cursor:pointer}.homepage .certificate__content .webhelp{padding:.5rem;background:#f5f5f5;display:flex;justify-content:center;align-items:center;gap:.5rem;border-radius:4px;border:1px solid rgba(20,20,27,.5);width:fit-content;cursor:pointer}.homepage .certificate__content .webhelp-container{display:grid;place-items:center}.homepage .certificate__content .webhelp:hover{background:#eef3f6;border:1px solid #14141b}.homepage .certificate__content .webhelp>i{font-size:1.2rem;color:green}.homepage .certificate__content .add-friend>h6,.homepage .certificate__content .webhelp-container>h6{font-weight:normal;margin-bottom:.5rem}.network-container{display:flex;height:100%;width:100%;overflow:hidden;background-color:#f1f5f9}.network-left-pane{width:320px;min-width:300px;max-width:350px;border-right:1px solid #cbd5e1;display:flex;flex-direction:column;background:#fff;box-shadow:2px 0 5px rgba(0,0,0,.05)}.own-profile-card{padding:1.25rem;border-bottom:1px solid #e2e8f0;background:linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);display:flex;flex-direction:column;gap:.75rem}.own-profile-card .profile-header{display:flex;align-items:center;gap:1rem}.own-profile-card .profile-info{display:flex;flex-direction:column;flex:1;overflow:hidden}.own-profile-card .profile-info .profile-name{font-weight:700;color:#1e293b;font-size:1.1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.own-profile-card .profile-info .profile-status{font-size:.85rem;color:#10b981;font-weight:500;display:flex;align-items:center;gap:.35rem}.own-profile-card .profile-info .profile-status::before{content:"";display:inline-block;width:8px;height:8px;background-color:#10b981;border-radius:50%}.own-profile-card .own-identity-select-container{display:flex;flex-direction:column;gap:.25rem}.own-profile-card .own-identity-select-container label{font-size:.75rem;color:#64748b;font-weight:600;text-transform:uppercase;letter-spacing:.05em}.own-profile-card .own-identity-select-container select.own-identity-select{width:100%;padding:.375rem .5rem;font-size:.85rem;border:1px solid #cbd5e1;border-radius:.375rem;background-color:#fff;color:#334155;outline:none;cursor:pointer;transition:border-color .2s}.own-profile-card .own-identity-select-container select.own-identity-select:focus{border-color:#3ba4d7}.friends-list-container{flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative}.friends-list-container .searchbar-container{padding:.75rem 1rem;border-bottom:1px solid #e2e8f0}.friends-list-container .searchbar-container input.searchbar{width:100%;padding:.5rem .75rem;font-size:.9rem;border:1px solid #cbd5e1;border-radius:.375rem;background-color:#f8fafc;outline:none;transition:all .2s}.friends-list-container .searchbar-container input.searchbar:focus{background-color:#fff;border-color:#3ba4d7;box-shadow:0 0 0 3px rgba(59,164,215,.15)}.friends-list-container .friends-scroll{flex:1;overflow-y:auto;padding:.5rem 0}.friend-list-item{display:flex;align-items:center;gap:.75rem;padding:.75rem 1rem;margin:.125rem .5rem;border-radius:.5rem;cursor:pointer;transition:all .2s}.friend-list-item:hover{background-color:#f1f5f9}.friend-list-item.selected{background-color:#e0f2fe}.friend-list-item.selected .friend-name{color:#0369a1;font-weight:600}.friend-list-item .friend-avatar{flex-shrink:0}.friend-list-item .friend-meta{flex:1;min-width:0}.friend-list-item .friend-meta .friend-name{font-size:.95rem;color:#334155;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:color .2s}.friend-list-item .friend-meta .friend-status{font-size:.8rem;color:#94a3b8}.friend-list-item .friend-meta .friend-status.online{color:#10b981;font-weight:500}.network-right-pane{flex:1;display:flex;flex-direction:column;overflow:hidden;background-color:#f8fafc}.network-pane-placeholder{flex:1;display:flex;flex-direction:column;justify-content:center;align-items:center;color:#94a3b8;gap:1rem;padding:2rem;text-align:center}.network-pane-placeholder i{font-size:4rem;color:#cbd5e1}.network-pane-placeholder p{font-size:1.1rem;max-width:400px}.network-tabs{display:flex;background-color:#fff;border-bottom:1px solid #cbd5e1;padding:.5rem 1rem 0;gap:.5rem}.network-tabs .tab-btn{padding:.625rem 1.25rem;font-size:.95rem;font-weight:600;color:#64748b;background:rgba(0,0,0,0);border:none;border-radius:.375rem .375rem 0 0;border-bottom:3px solid rgba(0,0,0,0);cursor:pointer;box-shadow:none;transition:all .2s}.network-tabs .tab-btn:hover{color:#334155;background-color:#f1f5f9}.network-tabs .tab-btn.active{color:#3ba4d7;border-bottom-color:#3ba4d7;background-color:rgba(0,0,0,0)}.network-tab-content{flex:1;overflow-y:auto;padding:1.5rem}.network-detail-view{display:flex;flex-direction:column;gap:1.5rem}.network-detail-view .detail-header{display:flex;align-items:center;gap:1.5rem;padding-bottom:1.5rem;border-bottom:1px solid #e2e8f0}.network-detail-view .detail-header .detail-title{flex:1}.network-detail-view .detail-header .detail-title h2{font-size:1.75rem;font-weight:800;color:#1e293b;margin-bottom:.25rem}.network-detail-view .detail-header .detail-title .detail-subtitle{font-size:.9rem;color:#64748b;display:flex;align-items:center;gap:.5rem}.network-detail-view .detail-header .detail-actions{display:flex;gap:.75rem}.network-detail-view .detail-header .detail-actions button{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;font-size:.9rem}.network-detail-view .detail-section{background-color:#fff;border-radius:.5rem;border:1px solid #e2e8f0;padding:1.25rem;box-shadow:0 1px 3px rgba(0,0,0,.02)}.network-detail-view .detail-section h3{font-size:1.1rem;font-weight:700;color:#334155;margin-bottom:1rem;padding-bottom:.5rem;border-bottom:1px solid #f1f5f9}.network-detail-view .detail-section .info-grid{display:grid;grid-template-columns:120px 1fr;row-gap:.75rem;font-size:.9rem}.network-detail-view .detail-section .info-grid .info-label{font-weight:600;color:#64748b}.network-detail-view .detail-section .info-grid .info-value{color:#1e293b;word-break:break-all}.network-detail-view .locations-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:1rem}.network-detail-view .location-card{background-color:#fff;border:1px solid #e2e8f0;border-radius:.5rem;padding:1rem;display:flex;flex-direction:column;gap:.5rem;box-shadow:0 1px 3px rgba(0,0,0,.02)}.network-detail-view .location-card .loc-header{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #f1f5f9;padding-bottom:.5rem;margin-bottom:.25rem}.network-detail-view .location-card .loc-header .loc-name{font-weight:700;color:#334155;font-size:.95rem}.network-detail-view .location-card .loc-header .loc-status{font-size:.75rem;font-weight:600;padding:.125rem .5rem;border-radius:.25rem}.network-detail-view .location-card .loc-header .loc-status.online{background-color:#d1fae5;color:#065f46}.network-detail-view .location-card .loc-header .loc-status.offline{background-color:#f1f5f9;color:#475569}.network-detail-view .location-card .loc-body{font-size:.85rem;display:grid;grid-template-columns:80px 1fr;row-gap:.25rem}.network-detail-view .location-card .loc-body .loc-label{color:#64748b}.network-detail-view .location-card .loc-body .loc-val{color:#334155;word-break:break-all}.network-detail-view .location-card .loc-footer{margin-top:.5rem;display:flex;justify-content:flex-end}.network-detail-view .location-card .loc-footer button{font-size:.8rem;padding:.25rem .75rem}.network-chat-view{display:flex;flex-direction:column;height:100%;overflow:hidden;background-color:#f8fafc}.network-chat-view .chat-messages{flex:1;overflow-y:auto;padding:1.25rem;display:flex;flex-direction:column;gap:1rem}.network-chat-view .chat-bubble-container{display:flex;flex-direction:column;max-width:70%}.network-chat-view .chat-bubble-container.outgoing{align-self:flex-end;align-items:flex-end}.network-chat-view .chat-bubble-container.outgoing .chat-bubble{background-color:#3ba4d7;color:#fff;border-bottom-right-radius:.125rem}.network-chat-view .chat-bubble-container.incoming{align-self:flex-start;align-items:flex-start}.network-chat-view .chat-bubble-container.incoming .chat-bubble{background-color:#fff;color:#1e293b;border:1px solid #e2e8f0;border-bottom-left-radius:.125rem}.network-chat-view .chat-bubble-container .chat-sender{font-size:.75rem;color:#64748b;margin-bottom:.25rem;padding:0 .25rem}.network-chat-view .chat-bubble-container .chat-bubble{padding:.625rem .875rem;border-radius:.75rem;font-size:.925rem;line-height:1.4;white-space:break-spaces;word-break:break-word;box-shadow:0 1px 2px rgba(0,0,0,.05)}.network-chat-view .chat-bubble-container .chat-time{font-size:.7rem;color:#94a3b8;margin-top:.25rem;padding:0 .25rem}.network-chat-view .chat-input-area{padding:1rem;background-color:#fff;border-top:1px solid #cbd5e1;display:flex;gap:.75rem;align-items:center}.network-chat-view .chat-input-area textarea.chat-textarea{flex:1;resize:none;height:40px;padding:.5rem .75rem;border:1px solid #cbd5e1;border-radius:.375rem;font-size:.9rem;outline:none;transition:all .2s}.network-chat-view .chat-input-area textarea.chat-textarea:focus{border-color:#3ba4d7;box-shadow:0 0 0 3px rgba(59,164,215,.15)}.network-chat-view .chat-input-area button.send-btn{padding:.5rem 1.25rem;font-size:.9rem;height:40px;display:flex;align-items:center;gap:.5rem}.network-chat-view .chat-warning{flex:1;display:flex;flex-direction:column;justify-content:center;align-items:center;color:#64748b;text-align:center;padding:2rem;gap:1rem}.network-chat-view .chat-warning i{font-size:3rem;color:#cbd5e1}.network-chat-view .chat-warning h4{font-weight:700;color:#334155}.network-chat-view .chat-warning p{max-width:350px;font-size:.9rem}.identity{color:#444;font-size:1.1em;margin:20px;padding:10px;border:1px solid #aaa;border-radius:20px}.identity>h4{margin:5px;font-size:1.3em}.identity button{font-size:.9em}.identity .details{display:grid;grid-template-columns:140px auto;grid-row-gap:5px;justify-content:left}.defaultAvatar{width:3rem;height:3rem;aspect-ratio:1;background:#b0c4de;border-radius:50%;display:grid;place-items:center}.defaultAvatar p{font-weight:900;color:#666f7f;transform:translateY(1px)}img.avatar{display:block;width:3rem;height:max-content;aspect-ratio:1;margin-right:.3em;border-radius:50%}.counter{margin-left:.5em}.counter:before{content:"("}.counter:after{content:")"}.chatInit{margin-left:.5em;color:green;cursor:pointer}.people-sidebar-header{display:flex;flex-direction:column;padding:.75rem 1rem .5rem 1rem;gap:.75rem;border-bottom:1px solid #e2e8f0;background-color:#fff}.people-sidebar-header .searchbar-wrapper{position:relative;display:flex;align-items:center}.people-sidebar-header .searchbar-wrapper i.fa-search{position:absolute;left:.85rem;color:#94a3b8;font-size:.9rem}.people-sidebar-header .searchbar-wrapper input.searchbar-input{width:100%;padding:.5rem .75rem .5rem 2.25rem;border:1px solid #e2e8f0;border-radius:.5rem;font-size:.9rem;background-color:#f8fafc;color:#1e293b;outline:none;transition:all .2s ease}.people-sidebar-header .searchbar-wrapper input.searchbar-input:focus{border-color:#3b82f6;background-color:#fff;box-shadow:0 0 0 3px rgba(59,130,246,.1)}.people-sidebar-header .segmented-control{display:flex;background-color:#f1f5f9;padding:3px;border-radius:.5rem;gap:4px}.people-sidebar-header .segmented-control button.segment-tab{flex:1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:.5rem .75rem;font-size:.9rem;font-weight:600;color:#64748b;background:rgba(0,0,0,0);border:none;border-radius:.375rem;cursor:pointer;box-shadow:none;transition:all .2s ease}.people-sidebar-header .segmented-control button.segment-tab:hover{color:#1e293b}.people-sidebar-header .segmented-control button.segment-tab.active{background-color:#fff;color:#0f172a;box-shadow:0 1px 3px rgba(0,0,0,.1),0 1px 2px rgba(0,0,0,.06)}.people-sidebar-header .segmented-control button.segment-tab.active .segment-badge{background-color:#019dff;color:#fff}.people-sidebar-header .segmented-control button.segment-tab .segment-badge{display:inline-flex;align-items:center;justify-content:center;background-color:#cbd5e1;color:#334155;font-size:.75rem;font-weight:700;min-width:1.25rem;height:1.25rem;padding:0 .35rem;border-radius:9999px;line-height:1;transition:all .2s ease}.people-sidebar-header .sub-filter-row{display:flex;align-items:center;justify-content:space-between;gap:.5rem;min-height:32px}.people-sidebar-header .sub-filter-row select.filter-select{padding:.35rem .6rem;border:1px solid #cbd5e1;border-radius:.375rem;font-size:.85rem;font-weight:600;color:#475569;background-color:#fff;cursor:pointer;outline:none}.people-sidebar-header .sub-filter-row .btn-add-id{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:.375rem;background-color:#3b82f6;color:#fff;border:none;cursor:pointer;font-size:.9rem;transition:background-color .2s}.people-sidebar-header .sub-filter-row .btn-add-id:hover{background-color:#2563eb}.friends-list-container .people-context-menu{position:absolute;left:2rem;width:210px;background-color:#fff;border:1px solid #e2e8f0;box-shadow:0 4px 10px rgba(0,0,0,.15);border-radius:.375rem;z-index:1010;padding:.25rem 0;display:flex;flex-direction:column}.friends-list-container .people-context-menu .menu-item{padding:.5rem 1rem;font-size:.85rem;color:#334155;cursor:pointer;display:flex;align-items:center;transition:background-color .2s}.friends-list-container .people-context-menu .menu-item:hover{background-color:#f1f5f9;color:#0f172a}.people-container{display:flex;height:calc(100vh - 55px);width:100%;overflow:hidden}.people-left-pane{width:320px;border-right:1px solid #cbd5e1;display:flex;flex-direction:column;background-color:#fff;overflow:hidden}.people-right-pane{flex:1;display:flex;flex-direction:column;overflow:hidden;background-color:#f8fafc}.people-left-pane .chat-item{display:flex;align-items:center;padding:.75rem 1rem;gap:.75rem;border-bottom:1px solid #f1f5f9;cursor:pointer;transition:background-color .15s ease;position:relative}.people-left-pane .chat-item:hover{background-color:#f8fafc}.people-left-pane .chat-item.selected{background-color:#eff6ff;border-left:3px solid #3b82f6}.people-left-pane .chat-item .chat-avatar-wrapper{position:relative;flex-shrink:0}.people-left-pane .chat-item .chat-avatar-wrapper .status-dot{position:absolute;bottom:0;right:0;width:10px;height:10px;border-radius:50%;border:2px solid #fff}.people-left-pane .chat-item .chat-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:.15rem}.people-left-pane .chat-item .chat-info .chat-name{font-size:.9rem;font-weight:700;color:#1e293b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.people-left-pane .chat-item .chat-info .chat-last-msg{font-size:.8rem;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.people-left-pane .chat-item .chat-meta{display:flex;flex-direction:column;align-items:flex-end;gap:.25rem;flex-shrink:0}.people-left-pane .chat-item .chat-meta .chat-time{font-size:.75rem;color:#94a3b8;white-space:nowrap}.lobby{margin:10px;border:1px solid #aaa;border-radius:20px}.lobby .mainname{margin:20px;font-weight:100;font-size:1.2em}.topic{color:#666}.lobby>.topic{font-size:.95em;margin-left:25px;margin-bottom:5px}.lefttitle{margin-top:15px;margin-bottom:0;font-weight:100;font-size:1.2em}.leftname{margin-top:5px;margin-bottom:5px;padding:5px;font-weight:100;font-size:1em}.leftlobby>.topic{font-size:.75em;margin-left:15px;margin-bottom:5px}.subscribed,.public{cursor:pointer}.leftlobby{border:1px solid #aaa;border-radius:10px;margin-top:5px;background-color:#fff}.leftlobby.selected-lobby,.selectedidentity{color:#fff;background-color:#3ba4d7}.rightbar{position:absolute;width:185px;background-color:#fff;overflow:auto;top:130px;bottom:15px;right:15px}.user{padding:5px}.lobbyName{padding:15px;margin-top:2rem}.lobbies{position:absolute;width:185px;left:165px;bottom:15px;top:130px;overflow:auto}.messages,.setup{position:absolute;background-color:#fff;top:130px;left:360px;right:215px;overflow:auto}.messages{bottom:115px}.messagetext{white-space:break-spaces;margin-right:5px}.message>*{margin-left:5px}.username{color:#006400;font-weight:bolder}.chatMessage{position:absolute;background-color:#fff;height:85px;bottom:15px;right:215px;left:360px}textarea.chatMsg{height:100%;width:100%}.chatatchar{margin-left:.2em;margin-right:.2em;color:silver}.setupicon{margin-left:1em;cursor:pointer}.leaveicon{margin-left:1em;cursor:pointer;color:#d40000}.selectidentity{margin:15px;font-size:1.2em}.setup>.identity{cursor:pointer}.setup{bottom:15px}.createDistantChat{margin-top:1em}.no-lobbies .messages,.no-lobbies .chatMessage,.no-lobbies .setup{left:165px}@media(min-width: 900px){.node-panel.chat-room{display:grid !important;grid-template-columns:250px 1fr 200px !important;grid-template-rows:auto 1fr auto !important;grid-template-areas:"lobbies header rightbar" "lobbies messages rightbar" "lobbies input rightbar" !important;padding:0 !important;height:100% !important}.node-panel.chat-room .lobbyName{grid-area:header;padding:10px;border-bottom:1px solid #eee;margin:0;z-index:10;background:#fff}.node-panel.chat-room .lobbies{grid-area:lobbies;position:static !important;width:auto !important;height:auto !important;border-right:1px solid #ccc;overflow-y:auto;display:block !important;top:auto !important;bottom:auto !important;left:auto !important}.node-panel.chat-room .messages{grid-area:messages;position:static !important;width:auto !important;height:auto !important;overflow-y:auto;padding:10px;left:auto !important;right:auto !important;top:auto !important;bottom:auto !important;margin:0 !important}.node-panel.chat-room .rightbar{grid-area:rightbar;position:static !important;width:auto !important;border-left:1px solid #ccc;overflow-y:auto;display:block !important}.node-panel.chat-room .chatMessage{grid-area:input;position:static !important;width:auto !important;height:auto !important;border-top:1px solid #eee;left:auto !important;right:auto !important;bottom:auto !important;flex:0 0 auto;padding:10px !important;background:#fff;z-index:10}}@media(max-width: 899px){.node-panel.chat-room{display:flex !important;flex-direction:column !important;height:100% !important;position:relative !important}.node-panel.chat-room .lobbyName{flex:0 0 auto}.node-panel.chat-room .messages{flex:1 !important;overflow-y:auto !important;position:relative !important;top:0 !important;bottom:0 !important;left:0 !important;right:0 !important;width:100% !important;height:auto !important;margin:0 !important}.node-panel.chat-room .chatMessage{flex:0 0 auto !important;position:relative !important;bottom:0 !important;left:0 !important;right:0 !important;width:100% !important;height:auto !important;z-index:100}.node-panel.chat-room .rightbar,.node-panel.chat-room .lobbies{display:none !important;position:fixed !important;top:60px !important;bottom:0 !important;width:80% !important;background:#fff !important;z-index:200 !important;box-shadow:2px 0 10px rgba(0,0,0,.2) !important}.node-panel.chat-room.show-lobbies .lobbies{display:block !important;left:0 !important}.node-panel.chat-room.show-users .rightbar{display:block !important;right:0 !important}.chat-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:150}.show-lobbies .chat-overlay,.show-users .chat-overlay{display:block}.mobile-menu-icons{display:flex;gap:15px;font-size:1.2rem}.mobile-menu-icons i{cursor:pointer;padding:5px}}@media(min-width: 900px){.mobile-menu-icons{display:none}}.chat-hub-container{display:flex;height:100%;width:100%;overflow:hidden;background-color:#f1f5f9}.chat-hub-left-pane{width:320px;min-width:300px;max-width:350px;border-right:1px solid #cbd5e1;display:flex;flex-direction:column;background:#fff;box-shadow:2px 0 5px rgba(0,0,0,.05)}.chat-own-profile-card{padding:1.25rem;border-bottom:1px solid #e2e8f0;background:linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);position:relative}.chat-own-profile-card .profile-header{display:flex;align-items:center;gap:1rem}.chat-own-profile-card .profile-info{display:flex;flex-direction:column;flex:1;overflow:hidden}.chat-own-profile-card .profile-info .profile-name{font-weight:700;color:#1e293b;font-size:1.1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.chat-own-profile-card .profile-info .profile-status{font-size:.85rem;color:#10b981;font-weight:500;display:flex;align-items:center;gap:.35rem}.chat-own-profile-card .profile-info .profile-status::before{content:"";display:inline-block;width:8px;height:8px;background-color:#10b981;border-radius:50%}.chat-rooms-list-container{flex:1;display:flex;flex-direction:column;overflow:hidden}.chat-rooms-list-container .searchbar-container{padding:.75rem 1rem;border-bottom:1px solid #e2e8f0}.chat-rooms-list-container .searchbar-container input.searchbar{width:100%;padding:.5rem .75rem;font-size:.9rem;border:1px solid #cbd5e1;border-radius:.375rem;background-color:#f8fafc;outline:none;transition:all .2s}.chat-rooms-list-container .searchbar-container input.searchbar:focus{background-color:#fff;border-color:#3ba4d7;box-shadow:0 0 0 3px rgba(59,164,215,.15)}.chat-rooms-list-container .rooms-scroll{flex:1;overflow-y:auto;padding:.5rem 0}.rooms-section-title{display:flex;align-items:center;gap:.5rem;padding:.75rem 1rem .375rem;font-size:.75rem;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em}.rooms-section-title i{font-size:.7rem;color:#94a3b8}.chat-room-list-item{display:flex;align-items:center;gap:.75rem;padding:.75rem 1rem;margin:.125rem .5rem;border-radius:.5rem;cursor:pointer;transition:all .2s}.chat-room-list-item:hover{background-color:#f1f5f9}.chat-room-list-item.selected{background-color:#e0f2fe}.chat-room-list-item.selected .room-name{color:#0369a1;font-weight:600}.chat-room-list-item .room-icon{flex-shrink:0;width:36px;height:36px;border-radius:.5rem;background:linear-gradient(135deg, #3ba4d7, #0ea5e9);display:flex;align-items:center;justify-content:center;color:#fff;font-size:1.35rem}.chat-room-list-item.public-room .room-icon{background:linear-gradient(135deg, #10b981, #059669)}.chat-room-list-item .room-meta{flex:1;min-width:0}.chat-room-list-item .room-meta .room-name{font-size:.95rem;color:#334155;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:color .2s}.chat-room-list-item .room-meta .room-topic{font-size:.8rem;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.chat-room-list-item .room-badge{flex-shrink:0;min-width:24px;height:24px;border-radius:12px;background-color:#e2e8f0;color:#475569;font-size:.75rem;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 .375rem}.chat-hub-right-pane{flex:1;display:flex;flex-direction:column;overflow:hidden;background-color:#f8fafc}.chat-pane-placeholder{flex:1;display:flex;flex-direction:column;justify-content:center;align-items:center;color:#94a3b8;gap:1rem;padding:2rem;text-align:center}.chat-pane-placeholder i{font-size:4rem;color:#cbd5e1}.chat-pane-placeholder p{font-size:1.1rem;max-width:400px}.chat-hub-tab-content{flex:1;overflow-y:auto;padding:1.5rem}.chat-room-detail-view{display:flex;flex-direction:column;gap:1.5rem}.chat-room-detail-view .detail-header{display:flex;align-items:flex-start;gap:1.5rem;padding-bottom:1.5rem;border-bottom:1px solid #e2e8f0;flex-wrap:wrap}.chat-room-detail-view .detail-header .detail-title{flex:1;min-width:200px}.chat-room-detail-view .detail-header .detail-title h2{font-size:1.75rem;font-weight:800;color:#1e293b;margin-bottom:.25rem}.chat-room-detail-view .detail-header .detail-title .detail-subtitle{font-size:.9rem;color:#64748b;display:flex;align-items:center;gap:.5rem}.chat-room-detail-view .detail-header .detail-actions{display:flex;gap:.75rem;flex-wrap:wrap}.chat-room-detail-view .detail-header .detail-actions button{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;font-size:.9rem}.chat-room-detail-view .detail-section{background-color:#fff;border-radius:.5rem;border:1px solid #e2e8f0;padding:1.25rem;box-shadow:0 1px 3px rgba(0,0,0,.02)}.chat-room-detail-view .detail-section h3{font-size:1.1rem;font-weight:700;color:#334155;margin-bottom:1rem;padding-bottom:.5rem;border-bottom:1px solid #f1f5f9}.chat-room-detail-view .detail-section .info-grid{display:grid;grid-template-columns:130px 1fr;row-gap:.75rem;font-size:.9rem}.chat-room-detail-view .detail-section .info-grid .info-label{font-weight:600;color:#64748b}.chat-room-detail-view .detail-section .info-grid .info-value{color:#1e293b;word-break:break-all}.participants-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:.5rem}.participant-card{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;background-color:#f8fafc;border:1px solid #e2e8f0;border-radius:.375rem}.participant-card .participant-name{font-size:.875rem;color:#334155;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.no-participants{color:#94a3b8;font-size:.9rem;font-style:italic}.detail-actions-footer{display:flex;gap:.75rem}.detail-actions-footer button{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;font-size:.9rem}.join-description{color:#64748b;font-size:.9rem;margin-bottom:1rem}.identities-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(200px, 1fr));gap:.75rem}.identity-card{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;background-color:#f8fafc;border:1px solid #e2e8f0;border-radius:.5rem;cursor:pointer;transition:all .2s}.identity-card:hover{background-color:#e0f2fe;border-color:#3ba4d7}.identity-card .identity-name{font-size:.95rem;font-weight:600;color:#334155}.identity-card i{color:#3ba4d7;font-size:.9rem}.no-rooms{padding:1rem;color:#94a3b8;text-align:center;font-style:italic}@media(max-width: 899px){.chat-hub-container{flex-direction:column}.chat-hub-left-pane{width:100%;min-width:0;max-width:none;max-height:45%;border-right:none;border-bottom:1px solid #cbd5e1}.chat-hub-right-pane{flex:1;min-height:0}}.chat-hub-header-bar{padding:.75rem 1.5rem;background-color:#fff;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;justify-content:space-between;height:65px;flex-shrink:0}.chat-hub-header-bar .chat-header-info{display:flex;flex-direction:column;overflow:hidden}.chat-hub-header-bar .chat-header-info .chat-header-name{font-size:1.15rem;font-weight:800;color:#1e293b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.chat-hub-header-bar .chat-header-info .chat-header-topic{font-size:.85rem;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:.125rem}.chat-hub-header-bar .chat-header-actions{display:flex;gap:.5rem}.chat-hub-header-bar .chat-header-actions button{display:flex;align-items:center;gap:.35rem;padding:.375rem .75rem;font-size:.85rem}.chat-hub-tabs-container{background-color:#fff;border-bottom:1px solid #cbd5e1;padding:.5rem 1.5rem 0}.chat-hub-tabs{display:flex;gap:.5rem}.chat-hub-tabs .tab-btn{padding:.625rem 1.25rem;font-size:.95rem;font-weight:600;color:#64748b;background:rgba(0,0,0,0);border:none;border-radius:.375rem .375rem 0 0;border-bottom:3px solid rgba(0,0,0,0);cursor:pointer;box-shadow:none;transition:all .2s;display:flex;align-items:center;gap:.5rem}.chat-hub-tabs .tab-btn:hover{color:#334155;background-color:#f1f5f9}.chat-hub-tabs .tab-btn.active{color:#3ba4d7;border-bottom-color:#3ba4d7;background-color:rgba(0,0,0,0)}.chat-hub-tab-content{flex:1;display:flex;flex-direction:column;overflow:hidden;background-color:#f8fafc}.chat-hub-conversation-layout{display:flex;flex-direction:row;height:100%;width:100%;overflow:hidden}.chat-hub-conversation-main{display:flex;flex-direction:column;flex:1;height:100%;overflow:hidden}.chat-hub-rightbar{width:200px;border-left:1px solid #cbd5e1;background-color:#fff;display:flex;flex-direction:column;flex-shrink:0;position:relative}.chat-hub-rightbar .rightbar-title{padding:.75rem 1rem;font-size:.85rem;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid #e2e8f0}.chat-hub-rightbar .rightbar-users-list{flex:1;overflow-y:auto;padding:.5rem}.chat-hub-rightbar .user{padding:.5rem .75rem;font-size:.9rem;color:#334155;border-radius:.375rem;transition:all .2s;display:flex;align-items:center;gap:.5rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;position:relative}.chat-hub-rightbar .user:hover{background-color:#f1f5f9;color:#0f172a}.chat-hub-rightbar .user .defaultAvatar{width:2rem;height:2rem;font-size:.9rem;flex-shrink:0}.chat-hub-rightbar .user img.avatar{width:2rem;height:2rem;flex-shrink:0}@media(max-width: 899px){.chat-hub-rightbar{display:none}}.chat-hub-messages{flex:1;overflow-y:auto;padding:1.25rem 1.5rem;display:flex;flex-direction:column;gap:1rem}.chat-hub-messages .message{display:flex;flex-direction:column;max-width:70%;padding:.625rem .875rem;border-radius:.75rem;font-size:.925rem;line-height:1.4;word-break:break-word;box-shadow:0 1px 2px rgba(0,0,0,.05)}.chat-hub-messages .message.incoming{align-self:flex-start;align-items:flex-start;background-color:#fff;color:#1e293b;border:1px solid #e2e8f0;border-bottom-left-radius:.125rem}.chat-hub-messages .message.outgoing{align-self:flex-end;align-items:flex-end;background-color:#3ba4d7;color:#fff;border-bottom-right-radius:.125rem}.chat-hub-messages .message .username{font-size:.75rem;margin-bottom:.25rem;padding:0 .125rem;font-weight:700}.chat-hub-messages .message.incoming .username{color:#0369a1}.chat-hub-messages .message.outgoing .username{color:#e0f2fe}.chat-hub-messages .message .messagetext{white-space:break-spaces;margin:0}.chat-hub-messages .message .datetime{font-size:.7rem;margin-top:.25rem;padding:0 .125rem;opacity:.8}.chat-hub-messages .message.incoming .datetime{color:#64748b}.chat-hub-messages .message.outgoing .datetime{color:#f1f5f9}.chat-hub-input-area{padding:.75rem 1.5rem;background-color:#fff;border-top:1px solid #cbd5e1;display:flex;gap:.75rem;align-items:flex-end;flex-shrink:0}.chat-hub-input-area textarea.chat-hub-textarea{flex:1;resize:vertical;min-height:40px;max-height:250px;height:40px;padding:.5rem .75rem;border:1px solid #cbd5e1;border-radius:.375rem;font-size:.9rem;outline:none;transition:border-color .2s,box-shadow .2s;background-color:#f8fafc}.chat-hub-input-area textarea.chat-hub-textarea:focus{background-color:#fff;border-color:#3ba4d7;box-shadow:0 0 0 3px rgba(59,164,215,.15)}.chat-hub-input-area button.chat-hub-send-btn{padding:.5rem 1.25rem;font-size:.9rem;height:40px;display:flex;align-items:center;gap:.5rem;border-radius:.375rem}.chat-hub-messages.compact-container,.messages.compact-container{gap:0 !important;padding:.75rem 1rem !important;background-color:#fff !important;display:flex !important;flex-direction:column !important}.chat-hub-messages.compact-container .message.compact,.messages.compact-container .message.compact{display:block !important;max-width:100% !important;padding:.1rem 0 !important;border-radius:0 !important;background-color:rgba(0,0,0,0) !important;border:none !important;box-shadow:none !important;align-self:flex-start !important;font-size:.875rem !important;line-height:1.45 !important;margin:0 !important;white-space:nowrap !important}.chat-hub-messages.compact-container .message.compact:hover,.messages.compact-container .message.compact:hover{background-color:#f8fafc !important;overflow:visible !important;white-space:normal !important}.chat-hub-messages.compact-container .message.compact .datetime,.messages.compact-container .message.compact .datetime{color:#a0a0a0 !important;margin-right:.4rem !important;font-size:.78rem !important;font-family:monospace !important;opacity:1 !important;display:inline !important}.chat-hub-messages.compact-container .message.compact .username,.messages.compact-container .message.compact .username{font-weight:bold !important;margin-right:.2rem !important;font-size:.875rem !important;display:inline !important}.chat-hub-messages.compact-container .message.compact .messagetext,.messages.compact-container .message.compact .messagetext{color:#1e293b !important;white-space:normal !important;word-break:break-word !important;display:inline !important;margin:0 !important}.chat-create-lobby-btn{position:absolute;bottom:.5rem;right:1.25rem;background-color:#0084ff;color:#fff;border:none;border-radius:.375rem;padding:.35rem .75rem;font-size:.85rem;font-weight:600;cursor:pointer;box-shadow:0 4px 6px -1px rgba(0,132,255,.2),0 2px 4px -1px rgba(0,132,255,.1);transition:background-color .2s,transform .2s;display:flex;align-items:center;gap:.25rem}.chat-create-lobby-btn:hover{background-color:#0073e6;transform:translateY(-1px)}.chat-create-lobby-btn:active{transform:translateY(0)}.chat-hub-rightbar .user .user-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}.user-tooltip{position:absolute;width:260px;background-color:#ffffe1;border:1px solid #7f7f7f;box-shadow:2px 2px 6px rgba(0,0,0,.25);padding:.5rem;border-radius:.25rem;z-index:10000;white-space:normal;display:flex;gap:.5rem;align-items:flex-start}.chat-hub-rightbar .user-tooltip{left:-275px;transform:translateY(-50%);z-index:1000}.user-tooltip .tooltip-avatar{flex-shrink:0}.user-tooltip .tooltip-details{display:flex;flex-direction:column;gap:.25rem;font-size:.8rem;color:#000;text-align:left}.user-tooltip .tooltip-row{line-height:1.2}.user-tooltip .tooltip-label{font-weight:bold}.user-tooltip .tooltip-value{font-weight:normal;word-break:break-all}.user-tooltip .tooltip-value.tooltip-id{font-family:monospace}.chat-hub-rightbar .rightbar-context-menu{position:absolute;right:1rem;width:210px;background-color:#fff;border:1px solid #e2e8f0;box-shadow:0 4px 10px rgba(0,0,0,.15);border-radius:.375rem;z-index:1010;padding:.25rem 0;display:flex;flex-direction:column}.chat-hub-rightbar .rightbar-context-menu .menu-item{padding:.5rem 1rem;font-size:.85rem;color:#334155;cursor:pointer;display:flex;align-items:center;transition:background-color .2s}.chat-hub-rightbar .rightbar-context-menu .menu-item:hover{background-color:#f1f5f9;color:#0f172a}.chat-emoji{font-size:1.45em;line-height:1;vertical-align:-0.15em;display:inline-block}.chat-hub-attach-btn,.chat-hub-action-btn{background-color:rgba(0,0,0,0) !important;border:none !important;font-size:1.15rem !important;color:#64748b !important;cursor:pointer !important;padding:.4rem .5rem !important;border-radius:.375rem !important;flex-shrink:0 !important;display:inline-flex !important;align-items:center !important;justify-content:center !important;transition:all .2s !important;box-shadow:none !important;margin:0 !important;line-height:1 !important;height:36px !important;width:36px !important}.chat-hub-attach-btn:hover,.chat-hub-action-btn:hover{background-color:#f1f5f9 !important;color:#3b82f6 !important;transform:none !important}.attach-modal-overlay{position:fixed;top:0;left:0;width:100vw;height:100vh;background-color:rgba(15,23,42,.4);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:2000}.attach-modal{background-color:#fff;border-radius:.5rem;width:450px;max-width:90%;padding:1.5rem;box-shadow:0 10px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);display:flex;flex-direction:column;gap:1rem}.attach-modal .attach-modal-header{display:flex;align-items:center;gap:.6rem;margin-bottom:.25rem}.attach-modal .attach-modal-icon{font-size:1.2rem;color:#3b82f6}.attach-modal h4{margin:0;font-size:1.2rem;color:#0f172a}.attach-modal p{margin:0;font-size:.9rem;color:#475569}.attach-modal .attach-path-row{display:flex;gap:.5rem;align-items:center}.attach-modal .attach-path-row input[type=text]{flex:1;padding:.75rem;border:1px solid #cbd5e1;border-radius:.375rem;font-size:.9rem;outline:none;transition:border-color .2s;min-width:0}.attach-modal .attach-path-row input[type=text]:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.15)}.attach-browse-btn{flex-shrink:0;display:flex;align-items:center;gap:.35rem;padding:.625rem .9rem;font-size:.875rem;background-color:#f1f5f9;color:#334155;border:1px solid #cbd5e1;border-radius:.375rem;cursor:pointer;box-shadow:none;transition:background-color .2s,border-color .2s;white-space:nowrap}.attach-browse-btn:hover{background-color:#e2e8f0;border-color:#94a3b8}.attach-path-hint{display:flex;align-items:flex-start;gap:.5rem;padding:.6rem .75rem;background-color:#fffbeb;border:1px solid #fcd34d;border-left:3px solid #f59e0b;border-radius:.375rem;font-size:.825rem;color:#92400e;line-height:1.45}.attach-path-hint i{color:#f59e0b;margin-top:.1rem;flex-shrink:0}.attach-path-hint code{font-family:monospace;background-color:rgba(245,158,11,.15);padding:.05rem .25rem;border-radius:.2rem}.attach-modal .hashing-spinner{display:flex;align-items:center;gap:.5rem;font-size:.9rem;color:#3b82f6}.attach-modal .error-text{color:#ef4444;font-size:.85rem;margin:0}.attach-modal .modal-buttons{display:flex;justify-content:flex-end;gap:.75rem;margin-top:.5rem}.attach-modal .modal-buttons button{padding:.5rem 1rem;font-size:.9rem;border-radius:.25rem;border:none;cursor:pointer;transition:opacity .2s}.attach-modal .modal-buttons button:hover{opacity:.9}.chat-hub-emoji-btn{background-color:rgba(0,0,0,0);border:none;font-size:1.3rem;cursor:pointer;padding:.35rem .4rem;margin-right:.25rem;flex-shrink:0;display:flex;align-items:center;justify-content:center;border-radius:.375rem;line-height:1;transition:background-color .15s,transform .15s;box-shadow:none}.chat-hub-emoji-btn:hover{background-color:#f1f5f9;transform:scale(1.1)}.emoji-picker-wrapper{position:relative;flex-shrink:0;display:flex;align-items:center}.emoji-picker{position:absolute;bottom:calc(100% + .5rem);left:0;width:320px;background-color:#fff;border:1px solid #e2e8f0;border-radius:.625rem;box-shadow:0 8px 30px -4px rgba(0,0,0,.18),0 4px 12px -2px rgba(0,0,0,.1);z-index:3000;display:flex;flex-direction:column;overflow:hidden;animation:emoji-pop .15s ease-out}.emoji-search-row{display:flex;align-items:center;gap:.4rem;padding:.6rem .75rem .4rem;border-bottom:1px solid #f1f5f9}.emoji-search-icon{color:#94a3b8;font-size:.8rem;flex-shrink:0}.emoji-search-input{flex:1;border:1px solid #e2e8f0;border-radius:.375rem;padding:.3rem .5rem;font-size:.85rem;outline:none;background-color:#f8fafc;transition:border-color .15s}.emoji-search-input:focus{border-color:#3ba4d7;background-color:#fff}.emoji-search-clear{background:none;border:none;cursor:pointer;color:#94a3b8;padding:.2rem;font-size:.8rem;box-shadow:none;display:flex;align-items:center}.emoji-search-clear:hover{color:#475569}.emoji-categories{display:flex;gap:.1rem;padding:.35rem .5rem;border-bottom:1px solid #f1f5f9;overflow-x:auto;scrollbar-width:none}.emoji-categories::-webkit-scrollbar{display:none}.emoji-cat-btn{background:none;border:none;cursor:pointer;font-size:1.2rem;padding:.3rem .35rem;border-radius:.375rem;line-height:1;box-shadow:none;transition:background-color .1s;flex-shrink:0}.emoji-cat-btn:hover{background-color:#f1f5f9}.emoji-cat-btn.active{background-color:#e0f2fe;box-shadow:inset 0 -2px 0 #3ba4d7}.emoji-grid{display:grid;grid-template-columns:repeat(7, 1fr);gap:0;padding:.4rem .35rem;max-height:220px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:#cbd5e1 rgba(0,0,0,0)}.emoji-grid::-webkit-scrollbar{width:4px}.emoji-grid::-webkit-scrollbar-track{background:rgba(0,0,0,0)}.emoji-grid::-webkit-scrollbar-thumb{background-color:#cbd5e1;border-radius:4px}.emoji-btn{background:none;border:none;cursor:pointer;font-size:1.7rem;padding:.25rem;border-radius:.3rem;line-height:1;box-shadow:none;text-align:center;transition:background-color .1s,transform .1s;display:flex;align-items:center;justify-content:center;aspect-ratio:1}.emoji-btn:hover{background-color:#f1f5f9;transform:scale(1.2)}@keyframes emoji-pop{from{opacity:0;transform:scale(0.92) translateY(6px)}to{opacity:1;transform:scale(1) translateY(0)}}.side-bar{display:flex;flex-direction:column;background:#fff}.side-bar .mail-compose-btn{width:96%;margin:.25rem;padding:.75rem 0}.compose-mail__from{display:flex;justify-content:flex-start;align-items:center;gap:.5rem;padding-bottom:.5rem;border-bottom:2px solid #eef3f6}.compose-mail__recipients{padding:.5rem 0;display:flex;flex-direction:column;gap:.5rem;border-bottom:2px solid #eef3f6}.compose-mail__recipients__container{display:flex;gap:.5rem}.compose-mail__recipients__container>label{text-transform:capitalize}.compose-mail__recipients__container .recipients{width:100%;display:flex;gap:.5rem;flex-wrap:wrap}.compose-mail__recipients__container .recipients__selected{padding:.125rem .5rem;display:flex;align-items:center;gap:.5rem;border:1px solid #eef3f6;border-radius:3px;cursor:default}.compose-mail__recipients__container .recipients__selected i{cursor:pointer;padding:.25rem}.compose-mail__recipients__container .recipients__input{display:flex;position:relative;flex-grow:1}.compose-mail__recipients__container .recipients__input-field{flex-grow:1;min-width:200px;padding:0;border:none;box-shadow:none}.compose-mail__recipients__container .recipients__input-field:focus+.recipients__input-list{display:flex}.compose-mail__recipients__container .recipients__input-list{z-index:1;position:absolute;top:1rem;padding:0;width:100%;max-height:15rem;flex-direction:column;overflow:auto;display:none;background:#fff;border-top:1px solid #eef3f6;border-bottom:1px solid #eef3f6}.compose-mail__recipients__container .recipients__input-list:hover{display:flex}.compose-mail__recipients__container .recipients__input-list li{list-style:none;padding:.25rem .5rem;cursor:pointer;background:#fff;border:1px solid #eef3f6;border-top:0px}.compose-mail__recipients__container .recipients__input-list li:hover{background:#eef3f6}.compose-mail__recipients__container .recipients__input-list li:last-child{border-bottom:0px}.compose-mail__recipients .remove-recipient{padding:.125rem .5rem}.compose-mail input[type=text].compose-mail__subject{padding:.5rem 0;border:none;box-shadow:none;border-bottom:2px solid #eef3f6;border-radius:0}.compose-mail__message{margin:.5rem 0;height:100%;display:flex;flex-direction:column;overflow:auto}.compose-mail__message-body{height:100%;outline:rgba(0,0,0,0)}.compose-mail__send-btn{display:flex;align-items:center;gap:.5rem}.compose-mail__send-btn i{transform:translateY(-1px)}.msg-view{height:100%;display:flex;flex-direction:column;gap:1rem;overflow:auto}.msg-view-nav{display:flex;justify-content:space-between;align-items:column}.msg-view-nav__action{display:flex;gap:.5rem}.msg-view__header{display:flex;flex-direction:column;gap:1rem}.msg-view__header>h3{line-height:1}.msg-view__header .msg-details{display:flex;gap:1rem}.msg-view__header .msg-details__avatar{height:max-content}.msg-view__header .msg-details__info{display:flex;flex-direction:column}.msg-view__header .msg-details__info-item{display:flex;gap:.5rem}.msg-view__body{height:100%;overflow:auto;font-size:14px !important}.msg-view__attachment{height:50%;overflow:auto;display:flex;flex-direction:column}.msg-view__attachment-items{height:100%;overflow:auto}.mail-tag{width:8rem;padding:.5rem}.msgHeader{display:flex}.msgHeaderDetails{display:flex;flex-direction:column}table.mails th:nth-child(1){width:5%;color:#fcba03}table.mails th:nth-child(2){width:5%;color:hsl(202.5,30.7692307692%,44.9019607843%)}table.mails th:nth-child(3){width:50%;text-align:start}table.mails th:nth-child(4),table.mails th:nth-child(5){width:20%;text-align:start}table.mails td:nth-child(3){text-align:start;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}table.mails td:nth-child(4),table.mails td:nth-child(5){text-align:start}table.mails tr:hover{background-color:#eef3f6;cursor:pointer}table.mails tr.unread{color:#000;background-color:#eef3f6}table.mails>tr:hover{cursor:auto;background-color:#fff}table.mails th.sortable-th{cursor:pointer;user-select:none;transition:background-color .2s,color .2s}table.mails th.sortable-th:hover{background-color:#eef3f6;color:hsl(202.5,30.7692307692%,14.9019607843%)}input.star-check{display:none}input.star-check+label.star-check{color:gray}input.star-check:checked+label.star-check{color:#fcba03}#truncate{height:6rem;overflow:auto}#truncate.truncated-view{height:1.75rem;overflow:hidden}.toggle-truncate{font-size:.75rem;padding:0 .25rem;background:#999;color:#14141b;box-shadow:none;border-radius:2px}table.attachment-container{padding:0}table.attachment-container>tr{border:0}table.attachment-container .attachment-header{width:100%;display:flex;justify-content:space-between}table.attachment-container .attachment-header th{text-align:start}table.attachment-container .attachment-header th:nth-child(1){flex-basis:45%}table.attachment-container .attachment-header th:nth-child(2){flex-basis:15%}table.attachment-container .attachment-header th:nth-child(3){flex-basis:10%}table.attachment-container .attachment-header th:nth-child(4){flex-basis:20%}table.attachment-container .attachment-header th:nth-child(5){text-align:center;flex-basis:10%}table.attachment-container .attachment{width:100%;display:flex;justify-content:space-between;text-align:start}table.attachment-container .attachment__name{flex-basis:45%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}table.attachment-container .attachment__name span{margin-left:8px}table.attachment-container .attachment__from{flex-basis:15%}table.attachment-container .attachment__size{flex-basis:10%}table.attachment-container .attachment__date{flex-basis:20%}table.attachment-container .attachment td:nth-child(5){display:flex;justify-content:center;align-items:center;flex-basis:10%}table.attachment-container .attachment td:nth-child(5) button{font-size:.875rem}.view-toggle{height:max-content;border:1px solid #019dff;border-radius:4px;display:flex}.view-toggle *{padding:4px 12px;border-radius:4px}.composePopupOverlay{position:fixed;width:100%;height:100%;top:0;left:0;z-index:1;background-color:rgba(0,0,0,.2)}.composePopupOverlay .composePopup{position:absolute;inset:0;margin:auto;width:80%;height:90%}.composePopupOverlay .composePopup>.widget{padding:2rem}.composePopupOverlay .composePopup .close-btn{position:absolute;top:1.5rem;right:1.5rem}.file-view{width:100%;padding:1rem;margin-top:1.5rem;border-radius:8px;border:1px solid #ccc;animation:fadein .5s}.file-view__heading{display:flex;justify-content:space-between;margin-bottom:.5rem}.file-view__heading-chunk{display:flex;gap:1rem}.file-view__body{display:flex;flex-direction:column;gap:1rem}.file-view__body-details{display:flex;align-items:center}.file-view__body-details-stat{width:100%;display:grid;grid-template-columns:repeat(5, 1fr)}.file-view__body-details-stat span>i{margin-right:.5rem}.file-view__body-details-action{display:flex;gap:1rem;height:100%}.file-view__body-details-action button,.file-view__body-details-action button.red{padding:.25rem .75rem}table.myfiles td{word-wrap:break-word}table.myfiles th:nth-child(1){width:2%}table.myfiles th:nth-child(2){width:50%}table.myfiles td:nth-child(2){text-align:start}table.friendsfiles td{word-wrap:break-word}table.friendsfiles th:nth-child(1){width:2%}table.friendsfiles th:nth-child(2){width:50%}table.friendsfiles th:nth-child(4){width:40%}table.friendsfiles td:nth-child(2){text-align:start}.file-search-container{margin-top:1rem;padding:8px;display:flex;gap:8px;border:1px solid rgba(20,20,27,.2);border-radius:6px;height:100%;overflow:auto}.file-search-container__keywords{flex-basis:15%;padding-right:.25rem;border-right:1px solid rgba(20,20,27,.1)}.file-search-container__keywords .keywords-container{display:flex;flex-direction:column;border-top:2.5px solid rgba(20,20,27,.08);margin-top:.125rem;padding-top:.25rem}.file-search-container__keywords .keywords-container a{font-size:1.2rem;text-decoration:none;color:#14141b}.file-search-container__keywords .keywords-container a.selected{color:#019dff}.file-search-container__results{flex-basis:85%;height:100%;overflow:auto}.file-search-container__results .results-container .results-header tr{display:flex}.file-search-container__results .results-container .results-header tr th{font-size:1.25rem;font-weight:bold;text-align:left}.file-search-container__results .results-container .results-header tr th:nth-child(1){flex-basis:40%}.file-search-container__results .results-container .results-header tr th:nth-child(2){flex-basis:10%;text-align:center}.file-search-container__results .results-container .results-header tr th:nth-child(3){flex-basis:40%}.file-search-container__results .results-container .results-header tr th:nth-child(4){flex-basis:10%}.file-search-container__results .results-container .results{height:100%;overflow:auto}.file-search-container__results .results-container .results tr{display:flex}.file-search-container__results .results-container .results tr .results__hash,.file-search-container__results .results-container .results tr .results__name{text-align:left;flex-basis:40%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.file-search-container__results .results-container .results tr .results__hash span,.file-search-container__results .results-container .results tr .results__name span{margin-left:8px}.file-search-container__results .results-container .results tr .results__size{flex-basis:10%}.file-search-container__results .results-container .results tr .results__download{flex-basis:10%;display:flex;justify-content:start;align-items:center}.search-form{display:flex;width:40%}.search-form input{width:100%}.search-form button{margin-left:.5rem}.shareManagerPopupOverlay{position:fixed;width:100%;height:100%;top:0;left:0;z-index:1;background-color:rgba(0,0,0,.2)}.shareManagerPopupOverlay .shareManagerPopup{position:absolute;inset:0;margin:auto;width:80%;height:90%}.shareManagerPopupOverlay .shareManagerPopup>.widget{padding:1.5rem}.shareManagerPopupOverlay .shareManagerPopup .close-btn{position:absolute;top:1.5rem;right:1.5rem}.share-manager{display:flex;flex-direction:column;justify-content:space-between}.share-manager__table{margin:1rem 0 auto}.share-manager__table thead{font-weight:bold;text-align:left}.share-manager__table thead td:nth-child(1),.share-manager__table thead td:nth-child(2){padding-left:.5rem}.share-manager__table thead td:nth-child(3) .tooltip,.share-manager__table thead td:nth-child(4) .tooltip{font-weight:normal;font-size:1rem}.share-manager__table tbody{text-align:left}.share-manager__table tbody td:nth-child(4){font-size:1rem}.share-manager__table td input{border:0 !important}.share-manager__table td input[type=text]{width:100%}.share-manager__table td:nth-child(1){width:45%}.share-manager__table td:nth-child(2){width:20%}.share-manager__table td:nth-child(3){width:10%}.share-manager__table td:nth-child(4){width:25%}.share-manager__actions{display:flex;justify-content:space-between}.share-manager__form{display:flex;flex-direction:column;gap:.5rem}.share-manager__form_input{display:flex;flex-direction:column;gap:.5rem}.share-manager__form_input input{flex-grow:1}.share-manager .share-flags input.share-flags-check{display:none}.share-manager .share-flags input.share-flags-check+label.share-flags-label{color:gray;margin-right:.25rem;padding:.25rem .25rem .125rem;border:1px solid #6d6d6d;border-radius:.5rem}.share-manager .share-flags input.share-flags-check:checked+label.share-flags-label{color:#118fcc}.share-manager label span{display:inline-block;width:1.125rem}.manage-visibility label{width:100%;cursor:pointer}.manage-visibility{display:flex;justify-content:space-between}@media(max-width: 700px){.file-view__body-details{flex-direction:column;align-items:flex-start;gap:1rem}.file-view__body-details-stat{grid-template-columns:1fr;gap:.5rem}.file-view__body-details-stat span{display:flex;align-items:center}.share-manager__table,.share-manager__table thead,.share-manager__table tbody,.share-manager__table tr,.share-manager__table td{display:block;width:100% !important}.share-manager__table thead{display:none}.share-manager__table tr{border:1px solid #ccc;border-radius:8px;margin-bottom:1rem;padding:.5rem;background:#fff}.share-manager__table td{margin-bottom:.5rem;border:none !important;padding-left:0 !important}table.myfiles,table.myfiles tr,table.myfiles td,table.friendsfiles,table.friendsfiles tr,table.friendsfiles td{display:block;width:100% !important}table.myfiles th,table.friendsfiles th{display:none}table.myfiles tr,table.friendsfiles tr{border:1px solid #ccc;border-radius:8px;margin-bottom:1rem;padding:.5rem;background:#fff}.file-search-container{flex-direction:column}.file-search-container__keywords{flex-basis:auto;width:100%;border-right:none;border-bottom:1px solid rgba(20,20,27,.1);padding-bottom:1rem;margin-bottom:1rem}.results-container,.results-container thead,.results-container tbody,.results-container tr,.results-container td{display:block;width:100% !important}.results-container thead{display:none}.results-container tr{border-bottom:1px solid #eee;padding:1rem 0}.results-container td{margin-bottom:.5rem;word-break:break-all}}.file-section{margin-top:2rem;display:flex;flex-direction:column}.comments-section{margin-top:2rem;display:flex;justify-content:space-between}.comments-section__menu{display:flex;gap:1rem}.comments-section__menu-id{display:flex;align-items:center;gap:.25rem}#toggleunsub{position:relative;background:gray}table.channels th:nth-child(1){width:50%;text-align:start}table.channels td:nth-child(1){text-align:start;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}table.channels tr:hover{background-color:#eef3f6;cursor:pointer}table.channels tr.hidden{display:none}table{padding:.5rem}table.comments{border:1px solid #eee}table.comments th{height:40px}table.comments th:nth-child(1){width:2%}table.comments th:nth-child(2){width:40%}table.comments td{word-wrap:break-word}table.comments td:nth-child(2){text-align:start}table.files th:first-child{text-align:start;width:60%}table.files tr td:first-child{text-align:start}table.files td{word-wrap:break-word}#mtags{width:160px;text-align:center;font-size:medium;margin-left:10px;height:40px}.forums-node-panel{position:relative;bottom:200px;margin-left:200px;animation:fadein .5s}table.forums th:nth-child(1){width:50%;text-align:start}table.forums td:nth-child(1){text-align:start;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}table.forums tr:hover{background-color:#eef3f6;cursor:pointer}table.forums tr.hidden{display:none}#searchforum{position:relative;margin-left:250px}#forumdetails{position:relative;padding:10px}.p{margin:0}#toggleunsub{position:relative;background:gray}table.threads tr:hover{background-color:#eef3f6;cursor:pointer}table.threads td{word-wrap:break-word}table.threadreply th:nth-child(2){width:50%}table.threadreply th:nth-child(1){width:2%}table.threadreply td:nth-child(2){width:50%;text-align:start}table.threadreply td{word-wrap:break-word}table.threadreply tr:hover{background-color:#eef3f6;cursor:pointer}table.boards th:nth-child(1){width:50%;text-align:start}table.boards td:nth-child(1){text-align:start;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}table.boards tr:hover{background-color:#eef3f6;cursor:pointer}table.boards tr.hidden{display:none}#toggleunsub{position:relative;background:gray}#options{width:100px;text-align:center;font-size:medium;margin-left:20px;height:40px}#composepopup{height:80%;width:70%}#mtags{width:160px;text-align:center;font-size:medium;margin-left:10px;height:40px}.mail .permission-flag{margin-bottom:1rem;display:flex;gap:1rem}.mail-tags{padding:.5rem;border:1px solid rgba(20,20,27,.2);border-radius:6px}.mail-tags__container{display:flex;flex-direction:column}.mail-tags__container .tag-item{display:flex;align-items:center;gap:4px;border-bottom:1px solid rgba(20,20,27,.1);padding:2px 0}.mail-tags__container .tag-item:last-child{border:none}.mail-tags__container .tag-item__color{width:1.25rem;height:1.25rem;aspect-ratio:1}.mail-tags__container .tag-item__name{font-size:1.125rem}.mail-tags__container .tag-item__modify{margin-left:auto;font-size:.75rem;display:flex;gap:4px}.mail-tags__container .tag-item:hover{background-color:#eef3f6}.mail-tags__container .tag-item button,.mail-tags__container .tag-item button.red{padding:.25rem .6rem}.mail-tags-form .input-field{margin-bottom:.5rem}.mail-tags-form .input-field label{margin-right:.5rem}.external-address{margin:0;padding-left:1rem;height:100px;overflow:hidden auto}.external-address::-webkit-scrollbar{display:none}.proxy-server{display:flex;flex-direction:column;gap:4px}.proxy-server__tor>h4,.proxy-server__i2p>h4{margin-bottom:.25rem}.proxy-server__tor>input,.proxy-server__i2p>input{margin-right:.5rem}.proxy-server__tor .proxy-outgoing,.proxy-server__i2p .proxy-outgoing{display:inline-flex;align-items:center;gap:.5rem}.proxy-server__tor .proxy-outgoing__status,.proxy-server__i2p .proxy-outgoing__status{width:1rem;height:1rem;aspect-ratio:1;border:1px solid #000;border-radius:50%}.config-files{display:flex;flex-direction:column;gap:1rem}.proxy-server-container{width:100%;display:flex;flex-direction:column;gap:1rem}.proxy-description{color:#334155;font-size:.95rem;margin-bottom:.5rem}.proxy-rows-container{display:flex;flex-direction:column;gap:.75rem;width:100%}.proxy-row{display:grid;grid-template-columns:160px 220px 220px auto;gap:.75rem;align-items:center;width:100%}.proxy-label{font-size:.95rem;font-weight:500;color:#1e293b}.proxy-addr-input,.proxy-port-input{width:100% !important;max-width:none !important}.proxy-status-container{display:flex;align-items:center;gap:.5rem}.proxy-status-bullet{width:14px;height:14px;border-radius:50%;display:inline-block;border:1px solid #475569}.proxy-status-text{font-size:.95rem;color:#1e293b} + */@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;font-display:auto;src:url("./webfonts/fa-solid-900.eot");src:url("./webfonts/fa-solid-900.eot?#iefix") format("embedded-opentype"),url("./webfonts/fa-solid-900.woff2") format("woff2"),url("./webfonts/fa-solid-900.woff") format("woff"),url("./webfonts/fa-solid-900.ttf") format("truetype"),url("./webfonts/fa-solid-900.svg#fontawesome") format("svg")}.fa,.fas,.far{font-family:"Font Awesome 5 Free";font-weight:900}html{font-size:87.5%;box-sizing:border-box}*,*::before,*::after{box-sizing:inherit}body,h1,h2,h3,h4,h5,h6,p,figure,blockquote,dl,dd{margin:0;padding:0}ul[role=list],ol[role=list]{list-style:none}html:focus-within{scroll-behavior:smooth}body{text-rendering:optimizeSpeed;line-height:1.5;font-family:"Roboto",Arial,Helvetica,sans-serif !important;letter-spacing:-0.025ch}a:not([class]){text-decoration-skip-ink:auto}img,picture{max-width:100%;display:block}input,button,textarea,select{font:inherit}@media(prefers-reduced-motion: reduce){html:focus-within{scroll-behavior:auto}*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important;scroll-behavior:auto !important}}#main{height:100vh;height:100dvh}.content{display:flex;height:100%;overflow:hidden}.tab-content{display:flex;height:100%;width:100%;background-color:#eef3f6;animation:fadein .3s;overflow:auto}input[type=text],input[type=password],input[type=number],textarea{box-sizing:border-box;background:#fff;max-width:100%;font-size:1rem;font-weight:400;border:1px solid #ccc;border-radius:.25rem;padding:.25rem .5rem;outline:rgba(0,0,0,0)}input:focus{border:1px solid #3ba4d7;box-shadow:inset 0 0 5px #ccc}input.stretched{width:90%}input.small{max-width:70%;padding:.1rem}input.searchbar{width:40%}a{cursor:pointer}a[title=Back]{width:max-content;height:max-content;padding:.475rem .75rem;border-radius:50%;transition:100ms}a[title=Back]:hover{background:#eef3f6}table{padding:20px;table-layout:fixed;width:100%;border-collapse:collapse;text-align:center;color:#333;font-size:1.125rem}table th{font-size:1.125rem;color:#000;border-bottom:2px solid #eee}table tr{border-bottom:1px solid #eee}h3{color:#444}hr{margin-left:0;color:#aaa}.grid-2col{display:grid;grid-template-columns:auto auto;gap:1rem;justify-content:start}.grid-2col input[type=checkbox]{margin-top:20px}.error{color:red}.tooltip{color:#333;position:relative;display:inline-block;margin:0 .25rem}.tooltiptext{visibility:hidden;position:absolute;top:100%;left:50%;min-width:250px;margin-left:-120px;z-index:1;color:#ccc;background-color:#333;font-size:.875rem;text-align:center;padding:.25rem;border-radius:.5rem}.tooltip:hover .tooltiptext{visibility:visible;animation:fadein .5s}blockquote{color:#14141b;padding:.75rem 1rem .75rem 2rem;border-radius:.25rem}blockquote.info{position:relative;line-height:1.2;color:rgba(20,20,27,.8);border:1px solid rgba(17,143,204,.8)}blockquote.info::before{font-family:"Font Awesome 5 Free";position:absolute;top:.5rem;left:.5rem;content:"";color:#019dff}@keyframes fadein{from{opacity:0}to{opacity:1}}.fadein{animation:fadein .5s}@keyframes swipe-from-left{from{margin-left:100%}to{margin-left:0}}button{width:max-content;height:max-content;color:#fff;background:#019dff;font-size:1rem;padding:.4rem 1rem;border:0;border-radius:5px;cursor:pointer;box-shadow:inset -3px -3px 0 rgb(0,94.5826771654,154)}button:active{outline:none;box-shadow:inset 3px 3px 0 rgb(0,94.5826771654,154)}button{white-space:nowrap !important;flex-shrink:0 !important}button.red{width:max-content;height:max-content;color:#fff;background:#ff3a4a;font-size:1rem;padding:.4rem 1rem;border:0;border-radius:5px;cursor:pointer;box-shadow:inset -3px -3px 0 rgb(211,0,17.1370558376)}button.red:active{outline:none;box-shadow:inset 3px 3px 0 rgb(211,0,17.1370558376)}button.red{white-space:nowrap !important;flex-shrink:0 !important}button.close-btn,.close-btn,.modal-close,.history-modal .close-btn,.rightbar-close,button.icon-btn,button.btn-icon,button.mail-tool-btn,button.mail-filter-pill,button.mail-view-back-btn,button.mail-action-btn,button.spam-btn,button.mail-compose-btn,button[class*=close]{box-shadow:none !important}button.close-btn:active,.close-btn:active,.modal-close:active,.history-modal .close-btn:active,.rightbar-close:active,button.icon-btn:active,button.btn-icon:active,button.mail-tool-btn:active,button.mail-filter-pill:active,button.mail-view-back-btn:active,button.mail-action-btn:active,button.spam-btn:active,button.mail-compose-btn:active,button[class*=close]:active{box-shadow:none !important}.comments,.board-comments{margin-top:1.5rem;max-width:900px;color:#0f172a}.comments__heading,.board-comments__heading{display:flex;align-items:center;gap:1rem;margin-bottom:1.25rem}.comments__heading h3,.board-comments__heading h3{margin:0;font-size:1.15rem}.comments__heading span,.board-comments__heading span{color:#64748b;font-size:.8rem;font-weight:600}.comments__heading i,.board-comments__heading i{margin-right:.35rem}.comments__voter,.board-comments__voter{display:inline-flex;align-items:center;gap:.4rem;margin-left:auto;color:#64748b;font-size:.75rem;font-weight:600;white-space:nowrap}.comments__voter select,.board-comments__voter select{max-width:180px;padding:.25rem .4rem;font-size:.78rem}.comment-composer,.board-comment-composer,.comment,.board-comment{display:flex;gap:.8rem}.comment-avatar,.board-comment-avatar{display:flex;flex:0 0 38px;width:38px;height:38px;align-items:center;justify-content:center;border-radius:50%;background:linear-gradient(135deg, #2563eb, #7c3aed);color:#fff;font-size:.78rem;font-weight:700}.comment-avatar>.avatar,.comment-avatar>.jdenticon-avatar,.comment-avatar>.defaultAvatar,.board-comment-avatar>.avatar,.board-comment-avatar>.jdenticon-avatar,.board-comment-avatar>.defaultAvatar{width:100% !important;height:100% !important;min-width:100% !important;min-height:100% !important;margin-right:0 !important}.comment-composer__body,.board-comment-composer__body,.comment__content,.board-comment__content{min-width:0;flex:1}.comment-composer__identity,.board-comment-composer__identity{max-width:240px;margin-bottom:.45rem;font-size:.8rem}.comment-composer__input,.board-comment-composer__input{width:100%;min-height:36px;padding:.45rem 0;resize:vertical;border:0;border-bottom:1px solid #94a3b8;border-radius:0;background:rgba(0,0,0,0);color:#0f172a;font:inherit;line-height:1.4;box-sizing:border-box}.comment-composer__input:focus,.board-comment-composer__input:focus{outline:0;border-bottom:2px solid #2563eb}.comment-composer__input:disabled,.board-comment-composer__input:disabled{cursor:not-allowed;opacity:.6}.comment-composer__actions,.board-comment-composer__actions,.comment__actions,.board-comment__actions{display:flex;align-items:center;gap:.45rem;margin-top:.55rem}.comment-composer__actions,.board-comment-composer__actions{justify-content:flex-end}.comment-composer__actions button,.board-comment-composer__actions button{border:0;background:rgba(0,0,0,0);color:#475569;cursor:pointer;font-size:.78rem;font-weight:700}.comment__actions,.board-comment__actions{gap:.65rem;margin-top:.35rem}.comment__actions button,.board-comment__actions button{padding:.25rem .2rem;color:#0f172a;border:0;background:rgba(0,0,0,0);cursor:pointer;font-size:.78rem;font-weight:700}.comment__actions button:hover,.board-comment__actions button:hover{color:#2563eb}.comment__actions i,.board-comment__actions i{margin-right:.2rem}.comment-composer__submit,.board-comment-composer__submit{padding:.45rem .85rem !important;border-radius:999px !important;background:#2563eb !important;color:#fff !important}.comment-composer__submit:disabled,.board-comment-composer__submit:disabled{background:#dbe3ef !important;color:#94a3b8 !important;cursor:not-allowed}.comment-composer__cancel:hover,.board-comment-composer__cancel:hover{color:#2563eb}.comment-composer__replying,.board-comment-composer__replying{display:flex;align-items:center;gap:.25rem;margin-bottom:.35rem;color:#64748b;font-size:.8rem}.comment-composer__replying button,.board-comment-composer__replying button{margin-left:.3rem;border:0;background:rgba(0,0,0,0);color:#475569;cursor:pointer;font-size:.78rem;font-weight:700}.comment-composer__hint,.board-comment-composer__hint,.comment-composer__error,.board-comment-composer__error{margin:.4rem 0 0;font-size:.78rem}.comment-composer__hint,.board-comment-composer__hint{color:#64748b}.comment-composer__error,.board-comment-composer__error{color:#dc2626}.comment-composer__emoji,.board-comment-composer__emoji{position:relative;margin-right:auto}.comment-emoji-popover,.board-comment-emoji-popover{position:absolute;z-index:20;top:38px;left:0;width:250px;max-height:180px;overflow-y:auto;padding:.5rem;display:grid;grid-template-columns:repeat(8, 1fr);gap:.2rem;background:#fff;border:1px solid #cbd5e1;border-radius:8px;box-shadow:0 8px 20px rgba(0,0,0,.16)}.comments__list,.board-comments__list{margin-top:1.8rem}.comment,.board-comment{margin-top:1.35rem}.comment--reply,.board-comment--reply{margin-top:1rem}.comment__header,.board-comment__header{display:flex;align-items:center;justify-content:space-between;min-height:18px}.comment__meta,.board-comment__meta{display:flex;align-items:baseline;gap:.55rem;font-size:.8rem}.comment__meta b,.board-comment__meta b{color:#1e293b}.comment__meta span,.board-comment__meta span{color:#64748b;font-size:.75rem}.comment__text,.board-comment__text{margin:.2rem 0 0;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.45}.comment__replies-toggle,.board-comment__replies-toggle{position:relative;margin-top:.3rem;padding:.25rem .35rem;border:0;background:rgba(0,0,0,0);color:#2563eb;cursor:pointer;font-size:.78rem;font-weight:700}.comment__replies-toggle:hover,.board-comment__replies-toggle:hover{background:#eff6ff;border-radius:4px}.comment__replies-toggle i,.board-comment__replies-toggle i{margin-left:.15rem}.comment__replies-toggle::before,.board-comment__replies-toggle::before{content:"";position:absolute;left:-3.15rem;bottom:.8rem;width:1.5rem;height:2.2rem;border-left:1px solid #e2e8f0;border-bottom:1px solid #e2e8f0;border-radius:0 0 0 .75rem;pointer-events:none}.comment__replies,.board-comment__replies{margin-top:.2rem;padding-left:1rem;border-left:2px solid #e2e8f0}.comments__status,.board-comments__status,.comments__empty,.board-comments__empty{margin:2rem 0;color:#64748b;text-align:center}.comments__status i,.board-comments__status i,.comments__empty i,.board-comments__empty i{font-size:1.5rem}@media(max-width: 560px){.comments__heading,.board-comments__heading{flex-wrap:wrap;justify-content:space-between;gap:.5rem}.comments__voter,.board-comments__voter{width:100%;margin-left:0}.comments__voter select,.board-comments__voter select{flex:1;max-width:none}.comment-composer,.board-comment-composer,.comment,.board-comment{gap:.6rem}.comment-avatar,.board-comment-avatar{flex-basis:32px;width:32px;height:32px;font-size:.68rem}.comment__replies,.board-comment__replies{padding-left:.6rem}.comment__replies-toggle::before,.board-comment__replies-toggle::before{left:-2.65rem;width:1.2rem}}.media-item{display:flex;margin-top:.5rem;padding:1rem;border:1px solid rgba(20,20,27,.1);border-radius:4px}.media-item__details{flex-basis:40%;display:flex;align-items:start;gap:.5rem}.media-item__details img{width:6rem;object-fit:contain}.media-item__desc{flex-basis:60%}@media(max-width: 768px){.media-item__desc{display:none !important}.media-item__details{flex-basis:100% !important;width:100% !important}}.active-link{background:hsla(0,0%,100%,.1) !important}.nav-menu{background-color:#14141b;box-shadow:0 5px 5px #222;display:flex;flex-direction:column;align-items:center;height:100%;padding:.25rem;margin-right:0rem}.nav-menu__logo{padding:1.2rem 0;display:flex;align-items:center;gap:.3rem}.nav-menu__logo img{width:1.6rem}.nav-menu__logo h5{line-height:1;color:#fff}.nav-menu__box{padding:2rem .125rem;display:flex;flex-direction:column;gap:.5rem;position:relative}.nav-menu__box .item{position:relative;margin:0;padding:.675rem .5rem;width:10rem;display:flex;align-items:center;line-height:1;border-radius:.5rem;text-decoration:none;color:#ccc;text-transform:capitalize;transition:0ms}.nav-menu__box .item:hover{background-color:rgba(238,243,246,.15)}.nav-menu__box .item i.sidenav-icon{width:2.5rem;height:1.4rem;display:grid;place-items:center}.nav-menu__box .nav-unread-badge{margin-left:auto;min-width:1.25rem;height:1.25rem;padding:0 .35rem;border-radius:999px;display:grid;place-items:center;background:#ef4444;color:#fff;font-size:.7rem;line-height:1}.nav-menu__box .item.item-selected{color:#9bdaff;background-color:rgba(155,218,255,.15);font-weight:medium}.nav-menu__box button.toggle-nav{display:none;position:absolute;padding:0;top:0;right:-1rem;background:rgb(77.5,186.5157480315,255);width:1.5rem;height:1.5rem;aspect-ratio:1;justify-content:center;align-items:center;border-radius:50%;box-shadow:none}.nav-menu.collapsed .nav-menu__logo .logo-container{display:flex;flex-direction:column;align-items:center;gap:.5rem}.nav-menu.collapsed .nav-menu__logo .logo-container>*:not(img){display:block}.nav-menu.collapsed .nav-menu__logo .nav-menu__logo-text{display:none !important}.nav-menu.collapsed .nav-menu__box .item{padding:.675rem 0;width:2.5rem;justify-content:center;transition:300ms}.nav-menu.collapsed .nav-menu__box .item span,.nav-menu.collapsed .nav-menu__box .item p{display:none !important}.nav-menu.collapsed .nav-menu__box .item .nav-unread-badge{display:grid !important;position:absolute;top:.1rem;right:-0.15rem;min-width:1rem;height:1rem;padding:0 .2rem;font-size:.6rem}.nav-menu.collapsed button i{rotate:180deg}.nav-menu:hover button.toggle-nav{display:flex}.sidebar{width:13rem;background-color:#fff;display:flex;flex-direction:column}.sidebar a{text-decoration:none;text-transform:capitalize;padding:1rem;cursor:pointer;color:#999}.sidebar a:hover{color:#222}.sidebar .selected-sidebar-link{font-weight:bold;color:#222;border-left:5px solid #3ba4d7;animation:expand-left-border .1s}.sidebar-mobile-toggle,.sidebar-drawer__title,.sidebar-drawer__backdrop{display:none !important}.sidebarquickview>h6{padding:.5rem}.sidebarquickview a{text-decoration:none;text-transform:capitalize;padding:.5rem 1rem;display:block;color:#999}.sidebarquickview a a:hover{color:#222}.sidebarquickview .selected-sidebarquickview-link{font-weight:bold;color:#222;border-left:5px solid #3ba4d7;animation:expand-left-border .1s}.node-panel{width:100%;padding:.5rem;animation:fadein .5s}@keyframes expand-left-border{from{border-left:0}to{border-left:5px solid #3ba4d7}}@media(max-width: 700px){.tab-content{flex-direction:column}.sidebar{width:100% !important;flex-direction:row !important;overflow-x:auto !important;overflow-y:hidden !important;white-space:nowrap !important;border-bottom:1px solid rgba(20,20,27,.1) !important;background:#fff !important;z-index:50 !important;flex-shrink:0 !important;height:auto !important;padding:0 !important}.sidebar a{display:inline-block !important;padding:.8rem 1.2rem !important;border-bottom:3px solid rgba(0,0,0,0) !important;border-left:none !important}.sidebar .selected-sidebar-link{border-left:none !important;border-bottom:3px solid #3ba4d7 !important;animation:none !important}.sidebarquickview>h4,.sidebarquickview>h6{display:none !important}.sidebar-drawer{display:block;position:relative;width:100%;height:44px;z-index:1000}.sidebar-mobile-toggle{display:inline-flex !important;width:40px;height:40px;align-items:center;justify-content:center;border:0;border-radius:6px;background:#fff;color:#0f172a;cursor:pointer;font-size:1.15rem}.sidebar-mobile-toggle:hover{background:#f1f5f9}.sidebar-drawer .sidebar{position:fixed !important;top:0;left:0;display:flex !important;flex-direction:column !important;width:min(82vw,300px) !important;height:100dvh !important;padding:1rem 0 !important;overflow-y:auto !important;overflow-x:hidden !important;transform:translateX(-105%);transition:transform 180ms ease;border:0 !important;border-right:1px solid #e2e8f0 !important;background:#fff !important;opacity:1 !important;pointer-events:auto !important;box-shadow:8px 0 24px rgba(15,23,42,.16);white-space:normal !important;z-index:1002 !important}.sidebar-drawer .sidebar.sidebar--mobile-open{transform:translateX(0)}.sidebar-drawer .sidebar a{display:block !important;padding:.8rem 1.25rem !important;border:0 !important;border-left:4px solid rgba(0,0,0,0) !important;color:#334155 !important;font-size:.95rem}.sidebar-drawer .sidebar .selected-sidebar-link{border-left-color:#3ba4d7 !important;border-bottom:0 !important;background:#f0f9ff;color:#0f172a !important}.sidebar-drawer__title{display:block !important;margin:0 1.25rem .65rem;padding-bottom:.75rem;border-bottom:1px solid #e2e8f0;color:#64748b;font-size:.75rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase}.sidebar-drawer__backdrop{display:block !important;position:fixed;inset:0;background:rgba(15,23,42,.35);pointer-events:auto;z-index:1001}}@media(min-width: 701px){.sidebar-drawer{display:contents}.sidebar-mobile-toggle,.sidebar-drawer__title,.sidebar-drawer__backdrop{display:none !important}}.mobile-app-header,.mobile-bottom-nav,.mobile-more-overlay,.mobile-status-overlay{display:none}@media(max-width: 700px){.nav-menu{display:none !important}.mobile-app-header{display:flex;align-items:center;justify-content:space-between;min-height:calc(44px + env(safe-area-inset-top));padding:env(safe-area-inset-top) .7rem 0;box-sizing:border-box;color:#f8fafc;background:#14141b;flex-shrink:0}.mobile-app-header__brand{display:flex;align-items:center;gap:.55rem}.mobile-app-header__brand img{width:1.35rem;height:1.35rem}.mobile-app-header__brand strong{font-size:.85rem}.mobile-status-trigger{display:flex;align-items:center;gap:.4rem;padding:.3rem .5rem;color:#e2e8f0;background:hsla(0,0%,100%,.08);border:0;border-radius:999px;box-shadow:none;font-size:.75rem}.mobile-status-trigger__dot{display:inline-block;width:.65rem;height:.65rem;flex:0 0 auto;border:2px solid hsla(0,0%,100%,.7);border-radius:50%}.mobile-status-trigger i{font-size:.6rem}.mobile-bottom-nav{display:grid;grid-template-columns:repeat(6, minmax(0, 1fr));min-height:calc(50px + env(safe-area-inset-bottom));padding:0 0 env(safe-area-inset-bottom);box-sizing:border-box;background:#fff;border-top:1px solid #cbd5e1;box-shadow:0 -4px 14px rgba(15,23,42,.08);flex-shrink:0;z-index:900}.mobile-bottom-nav__item{position:relative;display:flex;width:100%;align-items:center;justify-content:center;flex-direction:column;min-width:0;margin:0;min-height:48px;padding:.2rem .1rem;gap:.1rem;color:#64748b;background:rgba(0,0,0,0);border:0;border-radius:0;box-shadow:none;text-decoration:none}.mobile-bottom-nav__item.active{color:#0284c7}.mobile-bottom-nav__item i{width:auto;height:auto;font-size:1.15rem}.mobile-bottom-nav__item span{overflow:hidden;max-width:100%;font-size:.6rem;text-overflow:ellipsis}.mobile-bottom-nav__item .nav-unread-badge{position:absolute;top:.1rem;left:calc(50% + .45rem);display:grid;place-items:center;min-width:1.05rem;height:1.05rem;padding:0 .22rem;box-sizing:border-box;border:2px solid #fff;border-radius:999px;background:#ef4444;color:#fff;font-size:.6rem;font-weight:700;line-height:1;box-shadow:0 1px 3px rgba(15,23,42,.25)}.mobile-more-overlay,.mobile-status-overlay{position:fixed;inset:0;display:flex;align-items:flex-end;background:rgba(15,23,42,.45);z-index:1100}.mobile-more-sheet,.mobile-status-sheet{width:100%;max-height:min(75dvh,38rem);padding:.55rem 1rem max(1rem,env(safe-area-inset-bottom));box-sizing:border-box;overflow-y:auto;color:#1e293b;background:#fff;border-radius:1rem 1rem 0 0;box-shadow:0 -12px 32px rgba(15,23,42,.2)}.mobile-more-sheet__handle,.mobile-status-sheet__handle{width:2.5rem;height:.25rem;margin:0 auto .75rem;background:#cbd5e1;border-radius:999px}.mobile-more-sheet h3,.mobile-status-sheet h3{margin:0 0 .75rem}.mobile-more-sheet__links{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:.4rem}.mobile-more-sheet__links a{display:flex;align-items:center;padding:.75rem;gap:.65rem;color:#334155;background:#f8fafc;border-radius:.55rem;text-decoration:none}.mobile-more-sheet__links a.active{color:#0369a1;background:#e0f2fe}.mobile-more-sheet__actions{display:flex;padding-top:.75rem;margin-top:.75rem;gap:.5rem;border-top:1px solid #e2e8f0}.mobile-more-sheet__actions button{flex:1}.mobile-status-sheet__heading,.mobile-status-sheet__heading>div{display:flex;align-items:center}.mobile-status-sheet__heading{justify-content:space-between;margin-bottom:1rem}.mobile-status-sheet__heading>div{gap:.55rem}.mobile-status-sheet__heading button{padding:.4rem;color:#64748b;background:rgba(0,0,0,0);border:0;box-shadow:none}.mobile-status-sheet__grid{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:.55rem}.mobile-status-sheet__item{display:flex;flex-direction:column;min-width:0;padding:.75rem;gap:.2rem;background:#f8fafc;border:1px solid #e2e8f0;border-radius:.55rem}.mobile-status-sheet__item span,.mobile-status-sheet__item small{color:#64748b;font-size:.72rem}.mobile-status-sheet__item strong{overflow-wrap:anywhere;font-size:.9rem}.mobile-status-sheet__version{margin-top:.75rem;color:#94a3b8;font-size:.7rem;text-align:center}}.mobile-app-header__version{margin-left:.4rem;font-size:.7rem;font-weight:600;color:#64748b;align-self:flex-end;padding-bottom:.15rem}.mobile-status-sheet__version{display:flex;align-items:center;justify-content:space-between;gap:.75rem}.mobile-status-sheet__version button{border:1px solid #cbd5e1;background:#f8fafc;color:#334155;border-radius:.375rem;padding:.3rem .6rem;font-size:.75rem;font-weight:600;cursor:pointer}dialog.accessible-dialog{margin:0;padding:0;border:0;width:100%;height:100%;max-width:none;max-height:none;box-sizing:border-box}dialog.accessible-dialog::backdrop{background:rgba(0,0,0,0)}.posts{height:100%;margin-top:1rem;flex-direction:column;overflow:auto}.posts__heading{display:flex;flex-direction:column;justify-content:space-between}.posts-container{height:100%;padding:1rem;display:grid;grid-template-columns:repeat(auto-fill, minmax(150px, 1fr));gap:2rem;border:1px solid rgba(20,20,27,.1);border-radius:4px;overflow:auto}.posts-container-card{min-height:240px;flex-direction:column;border:1px solid rgba(20,20,27,.5);border-radius:4px;cursor:pointer;text-align:center}.posts-container-card img{flex-basis:90%;object-fit:cover}.posts-container-card p{padding:0 .125rem;flex-basis:10%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.progress-bar{width:100%;height:2rem;position:relative;text-align:center;background-color:#eef3f6;border-radius:20px;overflow:hidden}.progress-bar__status{position:absolute;top:0;left:0;height:100%;color:#14141b;background-color:#019dff}.progress-bar__percent{position:absolute;inset:0;margin:auto;width:fit-content;height:fit-content}.progress-bar-chunks{position:relative;margin-top:.5rem;width:100%;height:2rem;display:flex;border-radius:.25rem;overflow:hidden;background-color:#eef3f6}.progress-bar-chunks .chunk{width:100%}.progress-bar-chunks .chunk[data-chunkVal="0"]{background-color:rgba(155,218,255,.2)}.progress-bar-chunks .chunk[data-chunkVal="1"]{background-color:#ff3a4a}.progress-bar-chunks .chunk[data-chunkVal="2"]{background-color:#019dff}.progress-bar-chunks .chunk[data-chunkVal="3"]{background-color:#fcba03}.progress-bar-chunks__percent{position:absolute;inset:0;margin:auto;width:fit-content;height:fit-content}.statusbar{display:flex;justify-content:space-between;align-items:center;height:28px;background-color:#14141b;border-top:1px solid #2e2e38;padding:0 1rem;font-size:.8rem;color:#94a3b8;z-index:100;box-sizing:border-box;user-select:none;flex-shrink:0}.statusbar-left{display:flex;align-items:center;gap:.75rem}.statusbar-right{display:flex;align-items:center;gap:.75rem}.statusbar-item{display:flex;align-items:center;gap:.3rem}.statusbar-divider{width:1px;height:14px;background-color:#2e2e38}.status-bullet{width:8px;height:8px;border-radius:50%;display:inline-block;box-shadow:0 0 4px rgba(0,0,0,.5)}@media(max-width: 700px){.statusbar{display:none !important}}.widget{height:100%;padding:1rem;display:flex;flex-direction:column;gap:.5rem;background-color:#fff;border-radius:.5rem;overflow:auto}.widget .top-heading{display:flex;justify-content:space-between}.widget__heading{display:flex;justify-content:space-between;align-items:center;border-bottom:2px solid #999}.widget__body{height:100%;display:flex;flex-direction:column;overflow:auto}.widget__body-heading{display:flex;justify-content:space-between;align-items:center}.widget__body-heading .action{display:flex;gap:.5rem}.widget__body-content{height:100%;overflow:auto}.widget__body-box{display:flex;flex-direction:column;gap:.5rem}.widget-half{max-width:50%}#modal-container{display:none;position:fixed;z-index:1300;height:100%;top:0;left:0;width:100%;background-color:rgba(0,0,0,.2)}.modal-content{position:absolute;color:#555;width:40%;min-height:10rem;height:max-content;padding:1.5rem;inset:0;margin:auto;background-color:#fff;border-radius:.5rem;animation:fadein .5s;display:flex;flex-direction:column}.modal-content button:last-child{margin-top:auto}.modal-content .close-btn{position:absolute;right:1.5rem}.modal-content .widget{padding:0}.modal-content.location-details-modal{width:min(760px,100% - 2rem);height:min(480px,100% - 2rem);max-height:calc(100% - 2rem);min-height:0;box-sizing:border-box;overflow:hidden}.location-details-dialog{display:flex;flex-direction:column;height:100%;min-width:0;min-height:0;overflow:hidden}.location-details-dialog h3{margin:0 2.5rem .75rem 0;color:#1e293b}.location-details-dialog .location-detail-tabs{overflow-x:auto;margin:0 -1.5rem;padding:0 1rem}.location-details-dialog .location-detail-tabs .tab-btn{flex:0 0 auto !important;margin:0 !important}.location-details-dialog .location-detail-content{flex:1 1 auto;min-height:0;padding:1rem .25rem .25rem 0;overflow-y:auto}.location-details-dialog .info-grid{display:grid;grid-template-columns:140px minmax(0, 1fr);gap:.7rem 1rem}.location-details-dialog .info-label{color:#64748b;font-weight:600}.location-details-dialog .info-value{min-width:0;color:#1e293b;overflow-wrap:anywhere}.location-details-dialog .retroshare-id-text{max-height:100%;box-sizing:border-box;overflow:auto;padding:.75rem;border:1px solid #cbd5e1;background:#f8fafc;white-space:pre-wrap;overflow-wrap:anywhere}.location-details-dialog .known-addresses-list{height:10rem;max-height:25vh;margin-bottom:0;overflow:auto;padding:.75rem;border:1px solid #cbd5e1;background:#f8fafc;white-space:pre}@media(max-width: 600px){.modal-content .close-btn{right:1rem}.modal-content.location-details-modal{width:calc(100% - 1rem);height:max-content;max-height:calc(100dvh - 5rem);padding:1rem}.location-details-dialog .location-detail-tabs{margin:0 -1rem;padding:0 .5rem}.location-details-dialog .location-detail-tabs .tab-btn{padding-right:.75rem;padding-left:.75rem;font-size:.85rem}.location-details-dialog .info-grid{grid-template-columns:1fr;gap:.2rem}.location-details-dialog .info-value{margin-bottom:.65rem}.location-details-dialog .known-addresses-list{height:8rem;max-height:20vh;font-size:.75rem}}#notification-container{position:absolute;bottom:0;right:0}.login-page{background-image:linear-gradient(-45deg, rgba(1, 157, 255, 0.75), rgba(17, 143, 204, 0.75));height:100%;animation:fadein .5s}.login-page .login-container{background-color:#fff;box-shadow:3px 3px 5px rgba(20,20,27,.4);margin:auto;position:relative;top:100px;max-width:400px;max-height:500px;border-radius:5px;display:flex;flex-direction:column;align-items:center}.login-page .login-container input{padding:.375rem .75rem;border-radius:.275rem}.login-page .login-container *{margin-bottom:1rem}.login-page .login-container>img{margin:1rem 0 2rem}.login-page .login-container extra{margin:0}.login-page .login-container>a{text-decoration:underline;cursor:pointer}.login-page .extra>label,.login-page .extra>br,.login-page .extra>input{margin-bottom:0}.homepage{margin:2rem auto 0;display:flex;flex-direction:column;gap:4rem}.homepage .logo{display:flex;justify-content:center;align-items:center}.homepage .logo img{width:90px}.homepage .logo .retroshareText{display:flex;flex-direction:column;align-items:center}.homepage .logo .retroshareText .retrotext{font-size:36px;font-weight:600;line-height:1.125}.homepage .logo .retroshareText .retrotext>span{color:#118fcc}.homepage .logo .retroshareText>b{font-size:14px;line-height:1}.homepage .certificate{display:flex;flex-direction:column;gap:4rem}.homepage .certificate__heading{text-align:center}.homepage .certificate__heading>h1{margin-bottom:1rem}.homepage .certificate__content{display:flex;flex-direction:column;gap:2rem;padding:2rem;text-align:center;border:1.5px solid rgba(17,143,204,.2);border-radius:6px;box-shadow:0px 0px 8px 2px rgba(20,20,27,.05)}.homepage .certificate__content .rsId>p{margin-bottom:.5rem;color:#118fcc}.homepage .certificate__content .retroshareID{padding:.25rem;display:flex;align-items:center;justify-self:start;font-size:1.25rem;border-radius:4px;background:rgba(20,20,27,.05)}.homepage .certificate__content .retroshareID .textArea{padding:0;width:100%;height:auto;font-size:1rem;font-family:monospace;background:rgba(0,0,0,0);border:none;resize:none;overflow:hidden;field-sizing:content}.homepage .certificate__content .retroshareID i{color:#118fcc}.homepage .certificate__content .retroshareID>i{margin:0 .5rem;cursor:pointer}.homepage .certificate__content .webhelp{padding:.5rem;background:#f5f5f5;display:flex;justify-content:center;align-items:center;gap:.5rem;border-radius:4px;border:1px solid rgba(20,20,27,.5);width:fit-content;cursor:pointer}.homepage .certificate__content .webhelp-container{display:grid;place-items:center}.homepage .certificate__content .webhelp:hover{background:#eef3f6;border:1px solid #14141b}.homepage .certificate__content .webhelp>i{font-size:1.2rem;color:green}.homepage .certificate__content .add-friend>h6,.homepage .certificate__content .webhelp-container>h6{font-weight:normal;margin-bottom:.5rem}@media(max-width: 768px){.homepage{margin:1rem auto !important;padding:0 1rem !important;gap:2rem !important;max-width:100% !important;box-sizing:border-box !important}.homepage .logo{flex-direction:column !important;gap:.5rem !important;text-align:center !important}.homepage .logo img{width:60px !important}.homepage .logo .retroshareText .retrotext{font-size:1.6rem !important}.homepage .logo .retroshareText>b{font-size:.75rem !important}.homepage .certificate{gap:2rem !important}.homepage .certificate__heading>h1{font-size:1.35rem !important;margin-bottom:.5rem !important}.homepage .certificate__heading{font-size:.85rem !important}.homepage .certificate__content{padding:1rem .75rem !important;gap:1.25rem !important}.homepage .certificate__content .retroshareID{padding:.5rem !important;font-size:.85rem !important;max-width:100% !important}.homepage .certificate__content .retroshareID .textArea{font-size:.8rem !important;word-break:break-all !important;overflow-wrap:anywhere !important}}.modal-content.web-help-modal{width:min(30rem,100% - 2rem);min-height:0;max-height:calc(100dvh - 2rem);box-sizing:border-box;overflow-y:auto}.web-help-confirmation{min-width:0}.web-help-confirmation h3{margin:0 3rem .75rem 0;font-size:1.5rem}.web-help-confirmation p{margin:.75rem 0;line-height:1.5}.web-help-confirmation__url{padding:.65rem .75rem;color:rgba(20,20,27,.8);background:rgba(17,143,204,.08);border-radius:4px;overflow-wrap:anywhere;word-break:break-word}@media(max-width: 600px){.modal-content.web-help-modal{width:calc(100% - 1rem);max-height:calc(100dvh - 1rem);padding:1rem}.web-help-confirmation h3{font-size:1.25rem}.web-help-confirmation button:last-child{width:100%}}.modal-content.copy-confirmation-modal{width:min(28rem,100% - 2rem);min-height:0;box-sizing:border-box}.modal-content.copy-confirmation-modal h3{margin:0 3rem .75rem 0;font-size:1.5rem}.modal-content.copy-confirmation-modal p{line-height:1.5}@media(max-width: 600px){.modal-content.copy-confirmation-modal{width:calc(100% - 1rem);max-height:calc(100% - 1rem);padding:1rem}.modal-content.copy-confirmation-modal h3{font-size:1.25rem}.modal-content.copy-confirmation-modal button:last-child{width:100%}}.add-friend-wizard{width:100%;min-width:0;box-sizing:border-box}.add-friend-wizard__heading{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem}.add-friend-wizard__heading>i{display:grid;place-items:center;width:3rem;height:3rem;flex:0 0 auto;color:#fff;background:#118fcc;border-radius:50%;font-size:1.25rem}.add-friend-wizard__heading h3,.add-friend-wizard__heading p{margin:0}.add-friend-wizard__heading p{margin-top:.25rem;color:rgba(20,20,27,.75)}.add-friend-wizard .cert-drop-zone{padding:1.25rem;border:2px dashed rgba(17,143,204,.45);border-radius:6px;transition:border-color 120ms ease,background-color 120ms ease}.add-friend-wizard .cert-drop-zone--active{border-color:#118fcc;background:rgba(17,143,204,.08)}.add-friend-wizard .cert-drop-zone>label:first-child{display:block;margin-bottom:.5rem;font-weight:600}.add-friend-wizard .cert-drop-zone textarea{display:block;width:100%;box-sizing:border-box;padding:.75rem;resize:vertical;font-family:monospace;overflow-wrap:anywhere}.add-friend-wizard__divider{display:flex;align-items:center;gap:.75rem;margin:1rem 0;color:rgba(20,20,27,.6)}.add-friend-wizard__divider::before,.add-friend-wizard__divider::after{content:"";height:1px;flex:1;background:rgba(20,20,27,.15)}.add-friend-wizard__file{display:flex;align-items:center;gap:.75rem;color:rgba(20,20,27,.7)}.add-friend-wizard__file input{display:none}.add-friend-wizard__file label{margin:0;white-space:nowrap;cursor:pointer}.add-friend-wizard__actions{display:flex;justify-content:flex-end;margin-top:1.25rem}.add-friend-wizard__actions button:disabled{cursor:not-allowed;opacity:.5}.modal-content.add-friend-modal{width:min(42rem,100% - 2rem);max-height:calc(100% - 2rem);min-height:0;box-sizing:border-box;overflow:auto}@media(max-width: 600px){.modal-content.add-friend-modal{width:calc(100% - 1rem);max-height:calc(100% - 1rem);padding:1rem}.add-friend-wizard__heading{align-items:flex-start;padding-right:2.25rem}.add-friend-wizard__heading>i{width:2.5rem;height:2.5rem;font-size:1rem}.add-friend-wizard__heading h3{font-size:1.5rem}.add-friend-wizard .cert-drop-zone{padding:.75rem}.add-friend-wizard .cert-drop-zone textarea{min-height:8rem}.add-friend-wizard__file{align-items:stretch;flex-direction:column}.add-friend-wizard__file label{width:100%;box-sizing:border-box;text-align:center}.add-friend-wizard__file span{overflow-wrap:anywhere}.add-friend-wizard__actions button{width:100%}}.modal-content.friend-confirmation-modal{width:min(34rem,100% - 2rem);max-height:calc(100% - 2rem);min-height:0;box-sizing:border-box;overflow:auto}.friend-confirmation{min-width:0}.friend-confirmation__heading{display:flex;align-items:center;gap:1rem;padding-right:2.5rem;margin-bottom:1.25rem}.friend-confirmation__heading>i{display:grid;place-items:center;width:3rem;height:3rem;flex:0 0 auto;color:#fff;background:#118fcc;border-radius:50%}.friend-confirmation__heading h3,.friend-confirmation__heading p{margin:0}.friend-confirmation__heading p{margin-top:.25rem;color:rgba(20,20,27,.7)}.friend-confirmation__details{overflow:hidden;border:1px solid rgba(20,20,27,.15);border-radius:6px}.friend-confirmation__row{display:grid;grid-template-columns:7rem minmax(0, 1fr);gap:1rem;padding:.75rem 1rem}.friend-confirmation__row+.friend-confirmation__row{border-top:1px solid rgba(20,20,27,.1)}.friend-confirmation__row code,.friend-confirmation__row span,.friend-confirmation__row strong{min-width:0;overflow-wrap:anywhere}.friend-confirmation__label{color:rgba(20,20,27,.65);font-weight:600}.friend-confirmation__actions{display:flex;justify-content:flex-end;margin-top:1.25rem}@media(max-width: 600px){.modal-content.friend-confirmation-modal{width:calc(100% - 1rem);max-height:calc(100% - 1rem);padding:1rem}.friend-confirmation__heading{align-items:flex-start}.friend-confirmation__heading>i{width:2.5rem;height:2.5rem}.friend-confirmation__heading h3{font-size:1.5rem}.friend-confirmation__row{grid-template-columns:1fr;gap:.25rem;padding:.65rem .75rem}.friend-confirmation__actions button{width:100%}}.network-container{display:flex;height:100%;width:100%;overflow:hidden;background-color:#f1f5f9}.network-left-pane{width:320px;min-width:300px;max-width:350px;border-right:1px solid #cbd5e1;display:flex;flex-direction:column;background:#fff;box-shadow:2px 0 5px rgba(0,0,0,.05)}.own-profile-card{padding:1.25rem;border-bottom:1px solid #e2e8f0;background:linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);display:flex;flex-direction:column;gap:.75rem}.own-profile-card .profile-header{display:flex;align-items:center;gap:1rem}.own-profile-card .profile-header .profile-avatar-wrapper{position:relative;flex-shrink:0}.own-profile-card .profile-header .profile-avatar-wrapper .status-dot{position:absolute;bottom:-1px;right:-1px;width:13px;height:13px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.25)}.own-profile-card .profile-header .profile-avatar-wrapper .profile-status-button{padding:0;cursor:pointer;transition:transform .15s ease,box-shadow .15s ease}.own-profile-card .profile-header .profile-avatar-wrapper .profile-status-button:hover,.own-profile-card .profile-header .profile-avatar-wrapper .profile-status-button:focus-visible{transform:scale(1.2);box-shadow:0 0 0 3px rgba(59,164,215,.25);outline:none}.own-profile-card .profile-header .profile-avatar-wrapper .profile-presence-menu{position:absolute;z-index:20;top:calc(100% + .5rem);left:0;width:9rem;padding:.3rem;background:#fff;border:1px solid #cbd5e1;border-radius:.5rem;box-shadow:0 8px 20px rgba(15,23,42,.18)}.own-profile-card .profile-header .profile-avatar-wrapper .profile-presence-option{display:grid;grid-template-columns:.65rem 1fr .75rem;align-items:center;width:100%;margin:0;padding:.45rem .55rem;gap:.5rem;color:#334155;background:rgba(0,0,0,0);border:0;border-radius:.35rem;text-align:left}.own-profile-card .profile-header .profile-avatar-wrapper .profile-presence-option:hover,.own-profile-card .profile-header .profile-avatar-wrapper .profile-presence-option.active{background:#f1f5f9}.own-profile-card .profile-header .profile-avatar-wrapper .profile-presence-option>span{width:.55rem;height:.55rem;border-radius:50%}.own-profile-card .profile-header .profile-avatar-wrapper .profile-presence-option>i{color:#3ba4d7;font-size:.7rem}.own-profile-card .profile-info{display:flex;flex-direction:column;flex:1;overflow:hidden}.own-profile-card .profile-info .profile-name{font-weight:700;color:#1e293b;font-size:1.1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.own-profile-card .profile-info .profile-status{font-size:.85rem;color:#10b981;font-weight:500;display:flex;align-items:center;gap:.35rem}.own-profile-card .profile-info .profile-status::before{content:"";display:inline-block;width:8px;height:8px;background-color:var(--profile-status-color, #10b981);border-radius:50%}.own-profile-card .own-identity-select-container{display:flex;flex-direction:column;gap:.25rem}.own-profile-card .own-identity-select-container label{font-size:.75rem;color:#64748b;font-weight:600;text-transform:uppercase;letter-spacing:.05em}.own-profile-card .own-identity-select-container select.own-identity-select{width:100%;padding:.375rem .5rem;font-size:.85rem;border:1px solid #cbd5e1;border-radius:.375rem;background-color:#fff;color:#334155;outline:none;cursor:pointer;transition:border-color .2s}.own-profile-card .own-identity-select-container select.own-identity-select:focus{border-color:#3ba4d7}.friends-list-container{flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative}.friends-list-container .searchbar-container{padding:.75rem 1rem;border-bottom:1px solid #e2e8f0}.friends-list-container .searchbar-container input.searchbar{width:100%;padding:.5rem .75rem;font-size:.9rem;border:1px solid #cbd5e1;border-radius:.375rem;background-color:#f8fafc;outline:none;transition:all .2s}.friends-list-container .searchbar-container input.searchbar:focus{background-color:#fff;border-color:#3ba4d7;box-shadow:0 0 0 3px rgba(59,164,215,.15)}.friends-list-container .friends-scroll{flex:1;overflow-y:auto;padding:.5rem 0}.friend-list-item{display:flex;align-items:center;gap:.75rem;padding:.75rem 1rem;margin:.125rem .5rem;border-radius:.5rem;cursor:pointer;transition:all .2s}.friend-list-item:hover{background-color:#f1f5f9}.friend-list-item.selected{background-color:#e0f2fe}.friend-list-item.selected .friend-name{color:#0369a1;font-weight:600}.friend-list-item .friend-avatar{position:relative;flex-shrink:0}.friend-list-item .friend-avatar .status-dot{position:absolute;bottom:-1px;right:-1px;width:13px;height:13px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.25)}.friend-list-item .friend-meta{flex:1;min-width:0}.friend-list-item .friend-meta .friend-name{font-size:.95rem;color:#334155;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:color .2s}.friend-list-item .friend-meta .friend-status{font-size:.8rem;color:#94a3b8}.friend-list-item .friend-meta .friend-status.online{color:#10b981;font-weight:500}.network-right-pane{flex:1;display:flex;flex-direction:column;overflow:hidden;background-color:#f8fafc}.network-pane-placeholder{flex:1;display:flex;flex-direction:column;justify-content:center;align-items:center;color:#94a3b8;gap:1rem;padding:2rem;text-align:center}.network-pane-placeholder i{font-size:4rem;color:#cbd5e1}.network-pane-placeholder p{font-size:1.1rem;max-width:400px}.network-tabs{display:flex;background-color:#fff;border-bottom:1px solid #cbd5e1;padding:.5rem 1rem 0;gap:.5rem}.network-tabs .tab-btn{padding:.625rem 1.25rem;font-size:.95rem;font-weight:600;color:#64748b;background:rgba(0,0,0,0);border:none;border-radius:.375rem .375rem 0 0;border-bottom:3px solid rgba(0,0,0,0);cursor:pointer;box-shadow:none;transition:all .2s}.network-tabs .tab-btn:hover{color:#334155;background-color:#f1f5f9}.network-tabs .tab-btn.active{color:#3ba4d7;border-bottom-color:#3ba4d7;background-color:rgba(0,0,0,0)}.chat-unread-badge{display:inline-flex;align-items:center;justify-content:center;min-width:1.25rem;height:1.25rem;margin-top:.2rem;padding:0 .35rem;border-radius:999px;background:#0284c7;color:#fff;font-size:.7rem;font-weight:700;line-height:1}.mobile-pane-header,.mobile-graph-shortcut{display:none !important}.network-tab-content.network-graph-tab{min-height:0;padding:0;overflow:hidden}.network-graph{display:flex;flex-direction:column;width:100%;height:100%;min-height:0;padding:1rem;box-sizing:border-box}.network-graph__toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:.75rem 1rem;margin-bottom:.75rem}.network-graph__toolbar label{display:flex;align-items:center;gap:.45rem;color:#475569;font-size:.8rem;font-weight:600}.network-graph__toolbar select{padding:.3rem .45rem}.network-graph__edge-control input{width:8rem}.network-graph__zoom-control{display:flex;align-items:center;gap:.3rem}.network-graph__zoom-control button{min-width:1.9rem;padding:.3rem .45rem}.network-graph__zoom-control label{flex-direction:column;align-items:flex-start;gap:.1rem}.network-graph__zoom-control input{width:7rem}.network-graph__search{display:flex;align-items:center;min-width:10rem;margin-left:auto;padding:.35rem .6rem;gap:.4rem;background:#fff;border:1px solid #cbd5e1;border-radius:.4rem}.network-graph__search input{min-width:0;padding:0;border:0;outline:0}.network-graph__canvas{flex:1;width:100%;min-height:20rem;background:#fff;border:1px solid #cbd5e1;border-radius:.5rem;touch-action:none}.network-graph__edges line{stroke:#94a3b8;stroke-width:1.25;opacity:.65}.network-graph__node{cursor:grab}.network-graph__node circle{stroke:#fff;stroke-width:2;filter:drop-shadow(0 1px 2px rgba(15, 23, 42, 0.35))}.network-graph__node text{fill:#1e293b;font-size:12px;paint-order:stroke;stroke:#fff;stroke-width:3px;stroke-linejoin:round}.network-graph__node.is-match circle{stroke:#f97316;stroke-width:5}.network-graph__message{display:grid;flex:1;place-items:center;color:#64748b}.network-graph__message--error{color:#b91c1c}.network-graph__legend{display:flex;align-items:center;flex-wrap:wrap;margin-top:.65rem;gap:.5rem 1rem;color:#64748b;font-size:.75rem}.network-graph__legend span:last-child{margin-left:auto}.network-graph__key{display:inline-block;width:.65rem;height:.65rem;margin-right:.3rem;border-radius:50%}.network-graph__key--own{background:#d6d91f}.network-graph__key--online{background:#16a34a}.network-graph__key--offline{background:#64748b}@media(max-width: 700px){.network-tabs .tab-btn{padding-inline:.65rem;font-size:.8rem}.network-graph{padding:.5rem}.network-graph__redraw{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;min-width:2.25rem;height:2.25rem;padding:0}.network-graph__redraw span{display:none}.network-graph__toolbar{align-items:stretch}.network-graph__search{width:100%;box-sizing:border-box}.network-graph__toolbar .network-graph__edge-control,.network-graph__zoom-control{display:none}.network-graph__search{margin-left:0}.network-graph__canvas{min-height:18rem}.network-graph__legend span:last-child{width:100%;margin-left:0}}.network-tab-content{flex:1;overflow-y:auto;padding:1.5rem}.network-detail-view{display:flex;flex-direction:column;gap:1.5rem}.network-detail-view .detail-header{display:flex;align-items:center;gap:1.5rem;padding-bottom:1.5rem;border-bottom:1px solid #e2e8f0}.network-detail-view .detail-header .detail-title{flex:1}.network-detail-view .detail-header .detail-title h2{font-size:1.75rem;font-weight:800;color:#1e293b;margin-bottom:.25rem}.network-detail-view .detail-header .detail-title .detail-subtitle{font-size:.9rem;color:#64748b;display:flex;align-items:center;gap:.5rem}.network-detail-view .detail-header .detail-actions{display:flex;gap:.75rem}.network-detail-view .detail-header .detail-actions button{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;font-size:.9rem}.network-detail-view .detail-section{background-color:#fff;border-radius:.5rem;border:1px solid #e2e8f0;padding:1.25rem;box-shadow:0 1px 3px rgba(0,0,0,.02)}.network-detail-view .detail-section h3{font-size:1.1rem;font-weight:700;color:#334155;margin-bottom:1rem;padding-bottom:.5rem;border-bottom:1px solid #f1f5f9}.network-detail-view .detail-section .info-grid{display:grid;grid-template-columns:120px 1fr;row-gap:.75rem;font-size:.9rem}.network-detail-view .detail-section .info-grid .info-label{font-weight:600;color:#64748b}.network-detail-view .detail-section .info-grid .info-value{color:#1e293b;word-break:break-all}.network-detail-view .locations-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:1rem}.network-detail-view .location-card{background-color:#fff;border:1px solid #e2e8f0;border-radius:.5rem;padding:1rem;display:flex;flex-direction:column;gap:.5rem;box-shadow:0 1px 3px rgba(0,0,0,.02)}.network-detail-view .location-card .loc-header{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #f1f5f9;padding-bottom:.5rem;margin-bottom:.25rem}.network-detail-view .location-card .loc-header .loc-name{font-weight:700;color:#334155;font-size:.95rem}.network-detail-view .location-card .loc-header .loc-status{font-size:.75rem;font-weight:600;padding:.125rem .5rem;border-radius:.25rem}.network-detail-view .location-card .loc-header .loc-status.online{background-color:#d1fae5;color:#065f46}.network-detail-view .location-card .loc-header .loc-status.offline{background-color:#f1f5f9;color:#475569}.network-detail-view .location-card .loc-body{font-size:.85rem;display:grid;grid-template-columns:80px 1fr;row-gap:.25rem}.network-detail-view .location-card .loc-body .loc-label{color:#64748b}.network-detail-view .location-card .loc-body .loc-val{color:#334155;word-break:break-all}.network-detail-view .location-card .loc-footer{margin-top:.5rem;display:flex;justify-content:flex-end}.network-detail-view .location-card .loc-footer button{font-size:.8rem;padding:.25rem .75rem}.network-chat-view{display:flex;flex-direction:column;height:100%;overflow:hidden;background-color:#f8fafc}.network-chat-view .chat-messages{flex:1;overflow-y:auto;padding:1.25rem;display:flex;flex-direction:column;gap:1rem}.network-chat-view .chat-bubble-container{display:flex;flex-direction:column;max-width:70%}.network-chat-view .chat-bubble-container.outgoing{align-self:flex-end;align-items:flex-end}.network-chat-view .chat-bubble-container.outgoing .chat-bubble{background-color:#3ba4d7;color:#fff;border-bottom-right-radius:.125rem}.network-chat-view .chat-bubble-container.incoming{align-self:flex-start;align-items:flex-start}.network-chat-view .chat-bubble-container.incoming .chat-bubble{background-color:#fff;color:#1e293b;border:1px solid #e2e8f0;border-bottom-left-radius:.125rem}.network-chat-view .chat-bubble-container .chat-sender{font-size:.75rem;color:#64748b;margin-bottom:.25rem;padding:0 .25rem}.network-chat-view .chat-bubble-container .chat-bubble{padding:.625rem .875rem;border-radius:.75rem;font-size:.925rem;line-height:1.4;white-space:break-spaces;word-break:break-word;box-shadow:0 1px 2px rgba(0,0,0,.05)}.network-chat-view .chat-bubble-container .chat-time{font-size:.7rem;color:#94a3b8;margin-top:.25rem;padding:0 .25rem}.network-chat-view .chat-input-area{padding:1rem;background-color:#fff;border-top:1px solid #cbd5e1;display:flex;gap:.75rem;align-items:flex-end}.network-chat-view .chat-input-area textarea.chat-textarea{flex:1;resize:none !important;min-height:40px;max-height:160px;height:40px;padding:.55rem .75rem;border:1px solid #cbd5e1;border-radius:.625rem;font-size:.9rem;line-height:1.45;outline:none;overflow-y:hidden;box-sizing:border-box;transition:all .2s}.network-chat-view .chat-input-area textarea.chat-textarea:focus{border-color:#3ba4d7;box-shadow:0 0 0 3px rgba(59,164,215,.15)}.network-chat-view .chat-input-area button.send-btn{padding:.5rem 1.25rem;font-size:.9rem;height:40px;display:flex;align-items:center;gap:.5rem}.network-chat-view .chat-warning{flex:1;display:flex;flex-direction:column;justify-content:center;align-items:center;color:#64748b;text-align:center;padding:2rem;gap:1rem}.network-chat-view .chat-warning i{font-size:3rem;color:#cbd5e1}.network-chat-view .chat-warning h4{font-weight:700;color:#334155}.network-chat-view .chat-warning p{max-width:350px;font-size:.9rem}@media(max-width: 768px){.network-tab-content.network-chat-tab-content{min-height:0;padding:0;overflow:hidden}.network-container{flex-direction:column !important}.network-left-pane{width:100% !important;max-width:none !important;height:45% !important;border-right:none !important;border-bottom:1px solid #cbd5e1 !important}.network-right-pane{height:55% !important;flex:1 !important}.detail-actions button .btn-text,.detail-header .detail-actions button .btn-text{display:none !important}.detail-actions button,.detail-header .detail-actions button{padding:.45rem .65rem !important;min-width:38px !important;height:38px !important;justify-content:center !important;align-items:center !important}.detail-actions button i,.detail-header .detail-actions button i{margin:0 !important;font-size:1.05rem !important}.network-detail-view .detail-header{flex-direction:column !important;align-items:flex-start !important;gap:1rem !important}.network-detail-view .detail-header .friend-avatar{margin-bottom:.25rem !important}.locations-grid{grid-template-columns:1fr !important}.network-chat-view .chat-input-area{align-items:flex-end !important;padding:.35rem .45rem !important;gap:.2rem !important;min-width:0}.network-chat-view .chat-input-area .mobile-chat-attachment{order:1}.network-chat-view .chat-input-area .chat-hub-action-btn,.network-chat-view .chat-input-area label.chat-hub-action-btn{width:32px !important;height:32px !important;min-width:32px !important;padding:.25rem !important;font-size:.95rem !important}.network-chat-view .chat-input-area .emoji-picker-wrapper{order:3;flex:0 0 32px}.network-chat-view .chat-input-area .emoji-picker-wrapper .emoji-picker{right:-2.7rem;left:auto;width:min(320px,100vw - 1rem);max-height:min(420px,100dvh - 8rem)}.network-chat-view .chat-input-area textarea.chat-textarea{order:2;min-width:0 !important;min-height:40px !important;height:40px !important;max-height:140px !important;padding:.55rem .7rem !important;border-color:#dbe2ea !important;border-radius:1.25rem !important;background:#fff !important;box-sizing:border-box;overflow-y:hidden;line-height:1.4}.network-chat-view .chat-input-area .send-btn{order:4;width:40px !important;min-width:40px !important;height:40px !important;padding:0 !important;font-size:0 !important;justify-content:center;border-radius:50% !important}.network-chat-view .chat-input-area .send-btn i{margin:0 !important;font-size:1rem !important}}@media(max-width: 700px){.network-container{display:block !important}.network-container .network-left-pane{width:100% !important;min-width:0 !important;height:100% !important;max-width:none !important;border:0 !important}.network-container .network-right-pane{display:none !important;width:100% !important;height:100% !important}.network-container.mobile-detail-open .network-left-pane{display:none !important}.network-container.mobile-detail-open .network-right-pane{display:flex !important}.mobile-pane-header{display:grid !important;grid-template-columns:minmax(5rem, auto) minmax(0, 1fr) minmax(5rem, auto);align-items:center;min-height:46px;padding:.35rem .65rem;border-bottom:1px solid #e2e8f0;background:#fff;flex:0 0 auto}.mobile-pane-header strong{grid-column:2;overflow:hidden;color:#1e293b;text-align:center;text-overflow:ellipsis;white-space:nowrap}.mobile-back-button{grid-column:1;justify-self:start;display:inline-flex !important;align-items:center;gap:.4rem;width:auto !important;min-width:0 !important;margin:0 !important;padding:.4rem .25rem !important;border:0 !important;box-shadow:none !important;background:rgba(0,0,0,0) !important;color:#0284c7 !important;font-weight:700}.network-container .network-tabs{flex:0 0 auto;overflow-x:auto}}.identity{color:#444;font-size:1.1em;margin:20px;padding:10px;border:1px solid #aaa;border-radius:20px}.identity>h4{margin:5px;font-size:1.3em}.identity button{font-size:.9em}.identity .details{display:grid;grid-template-columns:140px auto;grid-row-gap:5px;justify-content:left}.defaultAvatar{width:3rem;height:3rem;aspect-ratio:1;background:#b0c4de;border-radius:50%;display:grid;place-items:center}.defaultAvatar p{font-weight:900;color:#666f7f;transform:translateY(1px)}img.avatar{display:block;width:3rem;height:max-content;aspect-ratio:1;margin-right:.3em;border-radius:50%}.counter{margin-left:.5em}.counter:before{content:"("}.counter:after{content:")"}.chatInit{margin-left:.5em;color:green;cursor:pointer}.people-sidebar-header{display:flex;flex-direction:column;padding:.75rem 1rem .5rem 1rem;gap:.75rem;border-bottom:1px solid #e2e8f0;background-color:#fff}.people-sidebar-header .searchbar-wrapper{position:relative;display:flex;align-items:center}.people-sidebar-header .searchbar-wrapper i.fa-search{position:absolute;left:.85rem;color:#94a3b8;font-size:.9rem}.people-sidebar-header .searchbar-wrapper input.searchbar-input{width:100%;padding:.5rem .75rem .5rem 2.25rem;border:1px solid #e2e8f0;border-radius:.5rem;font-size:.9rem;background-color:#f8fafc;color:#1e293b;outline:none;transition:all .2s ease}.people-sidebar-header .searchbar-wrapper input.searchbar-input:focus{border-color:#3b82f6;background-color:#fff;box-shadow:0 0 0 3px rgba(59,130,246,.1)}.people-sidebar-header .segmented-control{display:flex;background-color:#f1f5f9;padding:3px;border-radius:.5rem;gap:4px}.people-sidebar-header .segmented-control button.segment-tab{flex:1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:.5rem .75rem;font-size:.9rem;font-weight:600;color:#64748b;background:rgba(0,0,0,0);border:none;border-radius:.375rem;cursor:pointer;box-shadow:none;transition:all .2s ease}.people-sidebar-header .segmented-control button.segment-tab:hover{color:#1e293b}.people-sidebar-header .segmented-control button.segment-tab.active{background-color:#fff;color:#0f172a;box-shadow:0 1px 3px rgba(0,0,0,.1),0 1px 2px rgba(0,0,0,.06)}.people-sidebar-header .segmented-control button.segment-tab.active .segment-badge{background-color:#019dff;color:#fff}.people-sidebar-header .segmented-control button.segment-tab .segment-badge{display:inline-flex;align-items:center;justify-content:center;background-color:#cbd5e1;color:#334155;font-size:.75rem;font-weight:700;min-width:1.25rem;height:1.25rem;padding:0 .35rem;border-radius:9999px;line-height:1;transition:all .2s ease}.people-sidebar-header .sub-filter-row{display:flex;align-items:center;justify-content:space-between;gap:.5rem;min-height:32px}.people-sidebar-header .sub-filter-row select.filter-select{padding:.35rem .6rem;border:1px solid #cbd5e1;border-radius:.375rem;font-size:.85rem;font-weight:600;color:#475569;background-color:#fff;cursor:pointer;outline:none}.people-sidebar-header .sub-filter-row .btn-add-id{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:.375rem;background-color:#3b82f6;color:#fff;border:none;cursor:pointer;font-size:.9rem;transition:background-color .2s}.people-sidebar-header .sub-filter-row .btn-add-id:hover{background-color:#2563eb}.friends-list-container .people-context-menu{position:absolute;left:2rem;width:210px;background-color:#fff;border:1px solid #e2e8f0;box-shadow:0 4px 10px rgba(0,0,0,.15);border-radius:.375rem;z-index:1010;padding:.25rem 0;display:flex;flex-direction:column}.friends-list-container .people-context-menu .menu-item{padding:.5rem 1rem;font-size:.85rem;color:#334155;cursor:pointer;display:flex;align-items:center;transition:background-color .2s}.friends-list-container .people-context-menu .menu-item:hover{background-color:#f1f5f9;color:#0f172a}.people-container{display:flex !important;flex-direction:row !important;height:100% !important;width:100% !important;overflow:hidden !important;background-color:#f1f5f9 !important}.people-left-pane{width:320px !important;min-width:300px !important;max-width:350px !important;height:100% !important;border-right:1px solid #cbd5e1 !important;display:flex !important;flex-direction:column !important;background:#fff !important;box-shadow:2px 0 5px rgba(0,0,0,.05) !important;flex-shrink:0 !important;overflow:hidden !important}.people-right-pane{flex:1 !important;min-width:0 !important;height:100% !important;display:flex !important;flex-direction:column !important;overflow:hidden !important;background-color:#f8fafc !important}.people-list-container{flex:1 !important;overflow-y:auto !important;padding:.5rem 0 !important}.chat-item{display:flex !important;align-items:center !important;gap:.75rem !important;padding:.65rem .85rem !important;margin:.2rem .5rem !important;border-radius:.5rem !important;cursor:pointer !important;transition:all .2s ease !important;position:relative !important}.chat-item:hover{background-color:#f1f5f9 !important}.chat-item.selected{background-color:#e0f2fe !important}.chat-item.selected .chat-name{color:#0369a1 !important;font-weight:700 !important}.chat-item .chat-avatar-wrapper{position:relative !important;flex-shrink:0 !important}.chat-item .chat-avatar-wrapper .status-dot{position:absolute !important;bottom:-1px !important;right:-1px !important;width:13px !important;height:13px !important;border-radius:50% !important;border:2px solid #fff !important}.chat-item .chat-info{flex:1 !important;min-width:0 !important;display:flex !important;flex-direction:column !important}.chat-item .chat-info .chat-name{font-size:.95rem !important;font-weight:600 !important;color:#1e293b !important;white-space:nowrap !important;overflow:hidden !important;text-overflow:ellipsis !important}.chat-item .chat-info .chat-last-msg{font-size:.825rem !important;color:#64748b !important;white-space:nowrap !important;overflow:hidden !important;text-overflow:ellipsis !important;margin-top:.1rem !important}.chat-item .chat-meta{display:flex !important;flex-direction:column !important;align-items:flex-end !important;flex-shrink:0 !important}.chat-item .chat-meta .chat-time{font-size:.75rem !important;color:#94a3b8 !important;font-weight:500 !important}@media(max-width: 768px){.people-container{flex-direction:column !important}.people-left-pane{width:100% !important;max-width:none !important;height:45% !important;border-right:none !important;border-bottom:1px solid #cbd5e1 !important}.people-right-pane{height:55% !important}.detail-actions button .btn-text,.detail-header .detail-actions button .btn-text{display:none !important}.chat-tunnel-status .tunnel-label,.select-own-profile .chatting-as-label{display:none !important}.detail-actions button,.detail-header .detail-actions button{padding:.45rem .65rem !important;min-width:38px !important;height:38px !important;justify-content:center !important;align-items:center !important}}.create-identity-form{display:grid !important;grid-template-columns:11rem minmax(0, 1fr);gap:1rem 1.5rem !important}.create-identity-form__heading,.create-identity-form__name,.create-identity-form__help,.create-identity-form__submit{grid-column:1/-1}.create-identity-form__heading{display:flex;align-items:center;gap:.75rem}.create-identity-form__heading>i{color:#3ba4d7;font-size:1.5rem}.create-identity-form__heading h3{margin:0;color:#1e293b}.create-identity-form__heading p{margin:.2rem 0 0;color:#64748b;font-size:.9rem}.create-identity-form__name{width:100%}.create-identity-form__avatar{grid-row:span 2;display:flex;flex-direction:column;align-items:center;gap:.5rem;padding:.75rem;border:1px solid #e2e8f0;border-radius:.65rem;background:#f8fafc}.create-identity-form__avatar-label,.create-identity-form__field label{color:#475569;font-size:.85rem;font-weight:700}.create-identity-form__file-input{position:absolute;width:1px;height:1px;overflow:hidden;opacity:0}.create-identity-form__file-button{display:inline-flex;align-items:center;gap:.35rem;padding:.4rem .65rem;border:1px solid #0284c7;border-radius:.35rem;background:#0284c7;color:#fff;font-size:.8rem;font-weight:600;cursor:pointer}.create-identity-form__remove-avatar{padding:.25rem .5rem;border:0;box-shadow:none;background:rgba(0,0,0,0);color:#64748b;font-size:.78rem}.create-identity-form__remove-avatar:hover{color:#dc2626;text-decoration:underline}.create-identity-form__avatar small{color:#64748b;text-align:center}.create-identity-form__field{display:flex;flex-direction:column;gap:.35rem;min-width:0}.create-identity-form__field .config-style-select{width:100%;max-width:320px;min-width:0;box-sizing:border-box;padding:.375rem .5rem;border:1px solid #cbd5e1;border-radius:.375rem;background:#fff;color:#334155;outline:none;font-weight:600}.create-identity-form__field .config-style-select:focus{border-color:#3ba4d7}.create-identity-form__help{color:#475569;line-height:1.5}.create-identity-form__submit{justify-self:end}@media(max-width: 700px){.people-container{display:block !important}.people-container .people-left-pane{width:100% !important;min-width:0 !important;height:100% !important;max-width:none !important;border:0 !important}.people-container .people-right-pane{display:none !important;width:100% !important;height:100% !important}.people-container.mobile-detail-open .people-left-pane{display:none !important}.people-container.mobile-detail-open .people-right-pane{display:flex !important}.people-container .network-tabs{flex:0 0 auto}.people-container .network-tab-content{min-height:0}}.create-identity-avatar-preview{width:8rem;height:8rem;overflow:hidden;border:1px solid #cbd5e1;border-radius:.5rem;background:#eef2ff;display:flex;align-items:center;justify-content:center}.create-identity-avatar-preview>img{width:100%;height:100%;object-fit:cover}.create-identity-avatar-preview>.jdenticon-avatar,.create-identity-avatar-preview>.defaultAvatar{margin:0 !important}.modal-content.create-identity-modal{width:min(720px,100% - 2rem);max-height:calc(100% - 2rem);box-sizing:border-box;overflow-x:hidden;overflow-y:auto}.modal-content.signed-identity-modal{width:min(440px,100% - 2rem);min-height:0;box-sizing:border-box}.modal-content.edit-identity-modal{width:min(440px,100% - 2rem);min-height:0;box-sizing:border-box}.edit-identity-form{display:flex;flex-direction:column;width:100%;min-width:0;gap:.65rem}.edit-identity-form__heading{display:flex;align-items:center;gap:.65rem;padding-right:2.5rem}.edit-identity-form__heading>i{color:#3ba4d7;font-size:1.35rem}.edit-identity-form__heading h3{margin:0;color:#1e293b}.edit-identity-form__name-label{color:#475569;font-size:.85rem;font-weight:700}.edit-identity-form__name{width:100%;min-width:0}.edit-identity-form__avatar{display:flex;align-items:center;min-width:0;gap:.75rem;padding:.75rem;border:1px solid #e2e8f0;border-radius:.65rem;background:#f8fafc}.edit-identity-form__avatar>.avatar,.edit-identity-form__avatar>.jdenticon-avatar,.edit-identity-form__avatar>.defaultAvatar{flex:0 0 auto;margin:0 !important}.edit-identity-form__avatar-button{display:inline-flex;align-items:center;gap:.4rem;min-width:0;padding:.45rem .7rem;border-radius:.35rem;background:#0284c7;color:#fff;font-size:.85rem;font-weight:600;cursor:pointer;white-space:nowrap}.edit-identity-form__keep{padding:.35rem .55rem;font-size:.8rem}.edit-identity-form__save{align-self:flex-end;margin-top:.25rem !important}.signed-identity-form{display:flex;flex-direction:column;gap:.75rem;width:100%}.signed-identity-form__heading{display:flex;align-items:flex-start;gap:.75rem;padding-right:2.5rem}.signed-identity-form__heading>i{color:#3ba4d7;font-size:1.5rem;margin-top:.15rem}.signed-identity-form__heading h3{margin:0;color:#1e293b}.signed-identity-form__heading p{margin:.25rem 0 0;color:#64748b;line-height:1.4}.signed-identity-form label{color:#475569;font-size:.85rem;font-weight:700}.signed-identity-form input{width:100%;box-sizing:border-box;min-width:0}.signed-identity-form__submit{align-self:flex-end;margin-top:.5rem !important}.signed-identity-result{padding-right:2.5rem}.signed-identity-result h3{margin:0;color:#1e293b}.signed-identity-result p{margin:.75rem 0 0;color:#475569;line-height:1.5}@media(max-width: 600px){.modal-content.create-identity-modal{width:calc(100% - 1rem) !important;max-width:none !important;max-height:calc(100% - 1rem);padding:1rem}.create-identity-form{width:100%;min-width:0;grid-template-columns:minmax(0, 1fr);gap:.75rem !important}.create-identity-form__heading,.create-identity-form__name,.create-identity-form__avatar,.create-identity-form__field,.create-identity-form__help,.create-identity-form__submit{grid-column:1}.create-identity-form__avatar{grid-row:auto}.create-identity-form__field .config-style-select{max-width:none}.create-identity-form__heading{padding-right:2.5rem}.create-identity-form__heading h3{font-size:1.35rem;white-space:nowrap}.create-identity-form__heading p{font-size:.82rem}.create-identity-form__avatar{padding:.6rem}.create-identity-form__help{font-size:.85rem}.create-identity-form__submit{width:100%}.create-identity-avatar-preview{width:7rem;height:7rem}.modal-content.signed-identity-modal{width:calc(100% - 1rem) !important;max-width:none !important;padding:1rem}.modal-content.edit-identity-modal{width:calc(100% - 1rem) !important;max-width:none !important;max-height:calc(100dvh - 1rem);padding:1rem;overflow-y:auto}.edit-identity-form__heading h3{font-size:1.35rem;white-space:nowrap}.edit-identity-form__avatar{flex-direction:column;padding:.85rem}.edit-identity-form__avatar-button{justify-content:center;width:100%;box-sizing:border-box}.edit-identity-form__keep,.edit-identity-form__save{width:100%}.signed-identity-form__submit{width:100%}}.people-context-menu{position:absolute !important;z-index:9999 !important;background-color:#fff !important;border:1px solid #cbd5e1 !important;border-radius:8px !important;box-shadow:0 10px 25px -5px rgba(0,0,0,.15),0 8px 10px -6px rgba(0,0,0,.1) !important;padding:.35rem 0 !important;min-width:180px !important;font-family:inherit !important;overflow:hidden !important}.people-context-menu .menu-item{display:flex !important;align-items:center !important;padding:.6rem .9rem !important;font-size:.875rem !important;font-weight:500 !important;color:#1e293b !important;cursor:pointer !important;transition:background-color .15s ease,color .15s ease !important;user-select:none !important}.people-context-menu .menu-item:hover{background-color:#f1f5f9 !important;color:#0284c7 !important}.people-context-menu .menu-item i{font-size:1rem !important;width:1.25rem !important;text-align:center !important}.lobby{margin:10px;border:1px solid #aaa;border-radius:20px}.lobby .mainname{margin:20px;font-weight:100;font-size:1.2em}.topic{color:#666}.lobby>.topic{font-size:.95em;margin-left:25px;margin-bottom:5px}.lefttitle{margin-top:15px;margin-bottom:0;font-weight:100;font-size:1.2em}.leftname{margin-top:5px;margin-bottom:5px;padding:5px;font-weight:100;font-size:1em}.leftlobby>.topic{font-size:.75em;margin-left:15px;margin-bottom:5px}.subscribed,.public{cursor:pointer}.leftlobby{border:1px solid #aaa;border-radius:10px;margin-top:5px;background-color:#fff}.leftlobby.selected-lobby,.selectedidentity{color:#fff;background-color:#3ba4d7}.rightbar{position:absolute;width:185px;background-color:#fff;overflow:auto;top:130px;bottom:15px;right:15px}.user{padding:5px}.lobbyName{padding:15px;margin-top:2rem}.lobbies{position:absolute;width:185px;left:165px;bottom:15px;top:130px;overflow:auto}.messages,.setup{position:absolute;background-color:#fff;top:130px;left:360px;right:215px;overflow:auto}.messages{bottom:115px}.messagetext{white-space:break-spaces;margin-right:5px}.message>*{margin-left:5px}.username{color:#006400;font-weight:bolder}.chatMessage{position:absolute;background-color:#fff;height:85px;bottom:15px;right:215px;left:360px}textarea.chatMsg{height:100%;width:100%}.chatatchar{margin-left:.2em;margin-right:.2em;color:silver}.setupicon{margin-left:1em;cursor:pointer}.leaveicon{margin-left:1em;cursor:pointer;color:#d40000}.selectidentity{margin:15px;font-size:1.2em}.setup>.identity{cursor:pointer}.setup{bottom:15px}.createDistantChat{margin-top:1em}.no-lobbies .messages,.no-lobbies .chatMessage,.no-lobbies .setup{left:165px}@media(min-width: 900px){.node-panel.chat-room{display:grid !important;grid-template-columns:250px 1fr 200px !important;grid-template-rows:auto 1fr auto !important;grid-template-areas:"lobbies header rightbar" "lobbies messages rightbar" "lobbies input rightbar" !important;padding:0 !important;height:100% !important}.node-panel.chat-room .lobbyName{grid-area:header;padding:10px;border-bottom:1px solid #eee;margin:0;z-index:10;background:#fff}.node-panel.chat-room .lobbies{grid-area:lobbies;position:static !important;width:auto !important;height:auto !important;border-right:1px solid #ccc;overflow-y:auto;display:block !important;top:auto !important;bottom:auto !important;left:auto !important}.node-panel.chat-room .messages{grid-area:messages;position:static !important;width:auto !important;height:auto !important;overflow-y:auto;padding:10px;left:auto !important;right:auto !important;top:auto !important;bottom:auto !important;margin:0 !important}.node-panel.chat-room .rightbar{grid-area:rightbar;position:static !important;width:auto !important;border-left:1px solid #ccc;overflow-y:auto;display:block !important}.node-panel.chat-room .chatMessage{grid-area:input;position:static !important;width:auto !important;height:auto !important;border-top:1px solid #eee;left:auto !important;right:auto !important;bottom:auto !important;flex:0 0 auto;padding:10px !important;background:#fff;z-index:10}}@media(max-width: 899px){.node-panel.chat-room{display:flex !important;flex-direction:column !important;height:100% !important;position:relative !important}.node-panel.chat-room .lobbyName{flex:0 0 auto}.node-panel.chat-room .messages{flex:1 !important;overflow-y:auto !important;position:relative !important;top:0 !important;bottom:0 !important;left:0 !important;right:0 !important;width:100% !important;height:auto !important;margin:0 !important}.node-panel.chat-room .chatMessage{flex:0 0 auto !important;position:relative !important;bottom:0 !important;left:0 !important;right:0 !important;width:100% !important;height:auto !important;z-index:100}.node-panel.chat-room .rightbar,.node-panel.chat-room .lobbies{display:none !important;position:fixed !important;top:60px !important;bottom:0 !important;width:80% !important;background:#fff !important;z-index:200 !important;box-shadow:2px 0 10px rgba(0,0,0,.2) !important}.node-panel.chat-room.show-lobbies .lobbies{display:block !important;left:0 !important}.node-panel.chat-room.show-users .rightbar{display:block !important;right:0 !important}.chat-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:150}.show-lobbies .chat-overlay,.show-users .chat-overlay{display:block}.mobile-menu-icons{display:flex;gap:15px;font-size:1.2rem}.mobile-menu-icons i{cursor:pointer;padding:5px}}@media(max-width: 700px){.chat-hub-container{display:block}.chat-hub-container .chat-hub-left-pane{width:100%;min-width:0;height:100%;max-width:none;max-height:none;border:0}.chat-hub-container .chat-hub-right-pane{display:none;width:100%;height:100%}.chat-hub-container.mobile-detail-open .chat-hub-left-pane{display:none}.chat-hub-container.mobile-detail-open .chat-hub-right-pane{display:flex}.chat-hub-container .chat-hub-tab-content{min-height:0}}@media(min-width: 900px){.mobile-menu-icons{display:none}}.chat-hub-container{display:flex;height:100%;width:100%;overflow:hidden;background-color:#f1f5f9}.chat-hub-left-pane{width:320px;min-width:300px;max-width:350px;border-right:1px solid #cbd5e1;display:flex;flex-direction:column;background:#fff;box-shadow:2px 0 5px rgba(0,0,0,.05)}.chat-own-profile-card{padding:.85rem 1.25rem !important;border-bottom:1px solid #e2e8f0 !important;background:linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%) !important;display:flex !important;align-items:center !important;justify-content:space-between !important;gap:.75rem !important;position:relative}.chat-own-profile-card .profile-header{display:flex !important;align-items:center !important;gap:.75rem !important;flex:1 !important;min-width:0 !important}.chat-own-profile-card .chat-create-room-btn{display:inline-flex !important;align-items:center !important;justify-content:center !important}.chat-own-profile-card .chat-create-room-btn i{display:none}@media(max-width: 700px),(max-width: 899px)and (max-height: 500px){.chat-own-profile-card .chat-create-room-btn{width:32px !important;min-width:32px !important;height:32px !important;padding:0 !important}.chat-own-profile-card .chat-create-room-btn i{display:inline-block !important;margin:0 !important;font-size:.95rem !important}.chat-own-profile-card .chat-create-room-btn .btn-text{display:none !important}}.chat-own-profile-card .profile-info{display:flex;flex-direction:column;flex:1;overflow:hidden}.chat-own-profile-card .profile-info .profile-name{font-weight:700;color:#1e293b;font-size:1.1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.chat-own-profile-card .profile-info .profile-status{font-size:.85rem;color:#10b981;font-weight:500;display:flex;align-items:center;gap:.35rem}.chat-own-profile-card .profile-info .profile-status::before{content:"";display:inline-block;width:8px;height:8px;background-color:#10b981;border-radius:50%}.chat-rooms-list-container{flex:1;display:flex;flex-direction:column;overflow:hidden}.chat-rooms-list-container .searchbar-container{padding:.75rem 1rem;border-bottom:1px solid #e2e8f0}.chat-rooms-list-container .searchbar-container input.searchbar{width:100%;padding:.5rem .75rem;font-size:.9rem;border:1px solid #cbd5e1;border-radius:.375rem;background-color:#f8fafc;outline:none;transition:all .2s}.chat-rooms-list-container .searchbar-container input.searchbar:focus{background-color:#fff;border-color:#3ba4d7;box-shadow:0 0 0 3px rgba(59,164,215,.15)}.chat-rooms-list-container .rooms-scroll{flex:1;overflow-y:auto;padding:.5rem 0}.rooms-section-title{display:flex;align-items:center;gap:.5rem;padding:.75rem 1rem .375rem;font-size:.75rem;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em}.rooms-section-title i{font-size:.7rem;color:#94a3b8}.chat-room-list-item{display:flex;align-items:center;gap:.75rem;padding:.75rem 1rem;margin:.125rem .5rem;border-radius:.5rem;cursor:pointer;transition:all .2s}.chat-room-list-item:hover{background-color:#f1f5f9}.chat-room-list-item.selected{background-color:#e0f2fe}.chat-room-list-item.selected .room-name{color:#0369a1;font-weight:600}.chat-room-list-item .room-icon{flex-shrink:0;width:36px;height:36px;border-radius:.5rem;background:linear-gradient(135deg, #3ba4d7, #0ea5e9);display:flex;align-items:center;justify-content:center;color:#fff;font-size:1.35rem}.chat-room-list-item.public-room .room-icon{background:linear-gradient(135deg, #10b981, #059669)}.chat-room-list-item .room-meta{flex:1;min-width:0}.chat-room-list-item .room-meta .room-name{font-size:.95rem;color:#334155;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:color .2s}.chat-room-list-item .room-meta .room-topic{font-size:.8rem;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.chat-room-list-item .room-badge{flex-shrink:0;min-width:24px;height:24px;border-radius:12px;background-color:#e2e8f0;color:#475569;font-size:.75rem;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 .375rem}.chat-hub-right-pane{flex:1;display:flex;flex-direction:column;overflow:hidden;background-color:#f8fafc}.chat-pane-placeholder{flex:1;display:flex;flex-direction:column;justify-content:center;align-items:center;color:#94a3b8;gap:1rem;padding:2rem;text-align:center}.chat-pane-placeholder i{font-size:4rem;color:#cbd5e1}.chat-pane-placeholder p{font-size:1.1rem;max-width:400px}.chat-hub-tab-content{flex:1;overflow-y:auto;padding:1.5rem}.chat-room-detail-view{display:flex;flex-direction:column;gap:1.5rem}.chat-room-detail-view .detail-header{display:flex;align-items:flex-start;gap:1.5rem;padding-bottom:1.5rem;border-bottom:1px solid #e2e8f0;flex-wrap:wrap}.chat-room-detail-view .detail-header .detail-title{flex:1;min-width:200px}.chat-room-detail-view .detail-header .detail-title h2{font-size:1.75rem;font-weight:800;color:#1e293b;margin-bottom:.25rem}.chat-room-detail-view .detail-header .detail-title .detail-subtitle{font-size:.9rem;color:#64748b;display:flex;align-items:center;gap:.5rem}.chat-room-detail-view .detail-header .detail-actions{display:flex;gap:.75rem;flex-wrap:wrap}.chat-room-detail-view .detail-header .detail-actions button{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;font-size:.9rem}.chat-room-detail-view .detail-section{background-color:#fff;border-radius:.5rem;border:1px solid #e2e8f0;padding:1.25rem;box-shadow:0 1px 3px rgba(0,0,0,.02)}.chat-room-detail-view .detail-section h3{font-size:1.1rem;font-weight:700;color:#334155;margin-bottom:1rem;padding-bottom:.5rem;border-bottom:1px solid #f1f5f9}.chat-room-detail-view .detail-section .info-grid{display:grid;grid-template-columns:130px 1fr;row-gap:.75rem;font-size:.9rem}.chat-room-detail-view .detail-section .info-grid .info-label{font-weight:600;color:#64748b}.chat-room-detail-view .detail-section .info-grid .info-value{color:#1e293b;word-break:break-all}.participants-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:.5rem}.participant-card{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;background-color:#f8fafc;border:1px solid #e2e8f0;border-radius:.375rem}.participant-card>.jdenticon-avatar,.participant-card>.defaultAvatar,.participant-card>img.avatar{margin-right:0 !important}.participant-card .participant-name{flex:1;min-width:0;font-size:.875rem;color:#334155;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.participant-actions{display:none}.participant-more{display:none}@media(max-width: 700px){.chat-hub-tab-content.details-content{min-height:0;overflow-y:auto;overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch}.chat-hub-tab-content.details-content .participants-grid{grid-template-columns:1fr}.chat-hub-tab-content.details-content .participant-card{min-height:3rem;flex-wrap:wrap}.chat-hub-tab-content.details-content .participant-card.has-actions{cursor:pointer}.chat-hub-tab-content.details-content .participant-card.has-actions:focus-visible{outline:2px solid #0284c7;outline-offset:1px}.chat-hub-tab-content.details-content .participant-more{display:block;color:#94a3b8;font-size:.75rem;transition:transform .15s ease}.chat-hub-tab-content.details-content .participant-card.actions-open .participant-more{transform:rotate(180deg)}.chat-hub-tab-content.details-content .participant-actions{display:flex;width:100%;gap:.4rem;padding-top:.25rem}.chat-hub-tab-content.details-content .participant-action{display:inline-flex;flex:1;align-items:center;justify-content:center;min-width:0;gap:.3rem;padding:.4rem .35rem;border:1px solid #cbd5e1;border-radius:.375rem;background:#fff;color:#0369a1;box-shadow:none;font-size:.75rem;font-weight:600}.chat-hub-tab-content.details-content .participant-action:active{background:#e0f2fe;box-shadow:none}}.no-participants{color:#94a3b8;font-size:.9rem;font-style:italic}.detail-actions-footer{display:flex;gap:.75rem}.detail-actions-footer button{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;font-size:.9rem}.join-description{color:#64748b;font-size:.9rem;margin-bottom:1rem}.identities-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(200px, 1fr));gap:.75rem}.chat-invite-decline{display:inline-flex;align-items:center;gap:.5rem;margin-top:1rem;padding:.5rem 1rem;border:1px solid #fca5a5;border-radius:.5rem;background:#fff;color:#b91c1c;font-weight:600;cursor:pointer}.chat-invite-decline:hover{background:#fef2f2}.chat-invite-decline:disabled{opacity:.5;cursor:not-allowed}@media(max-width: 700px){.chat-invite-decline{width:100%;justify-content:center}}.identity-card{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;background-color:#f8fafc;border:1px solid #e2e8f0;border-radius:.5rem;cursor:pointer;transition:all .2s}.identity-card:hover{background-color:#e0f2fe;border-color:#3ba4d7}.identity-card__identity{display:flex;align-items:center;min-width:0;gap:.65rem}.identity-card__identity>.avatar,.identity-card__identity>.jdenticon-avatar,.identity-card__identity>.defaultAvatar{margin-right:0 !important}.identity-card .identity-name{overflow:hidden;font-size:.95rem;font-weight:600;color:#334155;text-overflow:ellipsis;white-space:nowrap}.identity-card i{color:#3ba4d7;font-size:.9rem}.no-rooms{padding:1rem;color:#94a3b8;text-align:center;font-style:italic}@media(max-width: 899px){.chat-hub-container{flex-direction:column}.chat-hub-left-pane{width:100%;min-width:0;max-width:none;max-height:45%;border-right:none;border-bottom:1px solid #cbd5e1}.chat-hub-right-pane{flex:1;min-height:0}}.chat-hub-header-bar{padding:.75rem 1.5rem;background-color:#fff;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;justify-content:space-between;height:65px;flex-shrink:0}.chat-hub-header-bar .chat-header-info{display:flex;flex-direction:column;overflow:hidden}.chat-hub-header-bar .chat-header-info .chat-header-name{font-size:1.15rem;font-weight:800;color:#1e293b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.chat-hub-header-bar .chat-header-info .chat-header-topic{font-size:.85rem;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:.125rem}.chat-hub-header-bar .chat-header-actions{display:flex;gap:.5rem}.chat-hub-header-bar .chat-header-actions button{display:flex;align-items:center;gap:.35rem;padding:.375rem .75rem;font-size:.85rem}@media(max-width: 700px){.chat-hub-header-bar{height:auto;min-height:48px;padding:.45rem .55rem;gap:.45rem}.chat-hub-header-bar .chat-header-info{min-width:0;flex:1}.chat-hub-header-bar .chat-header-info .chat-header-name{font-size:.95rem}.chat-hub-header-bar .chat-header-actions{flex:0 0 auto;gap:.25rem}.chat-hub-header-bar .chat-header-actions button{width:32px;min-width:32px;height:32px;padding:0;justify-content:center;font-size:0}.chat-hub-header-bar .chat-header-actions button i{margin:0;font-size:.95rem}}.chat-hub-tabs-container{background-color:#fff;border-bottom:1px solid #cbd5e1;padding:.5rem 1.5rem 0}.chat-hub-tabs{display:flex;gap:.5rem}.chat-hub-tabs .tab-btn{padding:.625rem 1.25rem;font-size:.95rem;font-weight:600;color:#64748b;background:rgba(0,0,0,0);border:none;border-radius:.375rem .375rem 0 0;border-bottom:3px solid rgba(0,0,0,0);cursor:pointer;box-shadow:none;transition:all .2s;display:flex;align-items:center;gap:.5rem}.chat-hub-tabs .tab-btn:hover{color:#334155;background-color:#f1f5f9}.chat-hub-tabs .tab-btn.active{color:#3ba4d7;border-bottom-color:#3ba4d7;background-color:rgba(0,0,0,0)}.chat-hub-tab-content{flex:1;display:flex;flex-direction:column;overflow:hidden;background-color:#f8fafc}.chat-hub-conversation-layout{display:flex;flex-direction:row;height:100%;width:100%;overflow:hidden}.chat-hub-conversation-main{display:flex;flex-direction:column;flex:1;height:100%;overflow:hidden}.chat-hub-rightbar{width:200px;border-left:1px solid #cbd5e1;background-color:#fff;display:flex;flex-direction:column;flex-shrink:0;position:relative}.chat-hub-rightbar .rightbar-title{padding:.75rem 1rem;font-size:.85rem;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid #e2e8f0}.chat-hub-rightbar .rightbar-users-list{flex:1;overflow-y:auto;padding:.5rem}.chat-hub-rightbar .user{padding:.5rem .75rem;font-size:.9rem;color:#334155;border-radius:.375rem;transition:all .2s;display:flex;align-items:center;gap:.5rem;position:relative}.chat-hub-rightbar .user .user-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chat-hub-rightbar .user:hover{background-color:#f1f5f9;color:#0f172a}.chat-hub-rightbar .user .defaultAvatar{width:2rem;height:2rem;font-size:.9rem;flex-shrink:0}.chat-hub-rightbar .user img.avatar{width:2rem;height:2rem;flex-shrink:0}.chat-hub-rightbar .rightbar-title{display:flex;align-items:center;justify-content:space-between}.chat-hub-rightbar .rightbar-close{display:none;border:none;background:rgba(0,0,0,0);color:#64748b;font-size:1rem;padding:.25rem .5rem;cursor:pointer}@media(min-width: 900px){.chat-hub-header-bar .chat-header-actions .participants-toggle{display:none}}@media(max-width: 899px){.chat-hub-conversation-layout{position:relative}.chat-hub-rightbar{display:none}.chat-hub-conversation-layout.show-participants .chat-hub-rightbar{display:flex;position:absolute;top:0;right:0;bottom:0;width:min(40vw,260px);z-index:60;box-shadow:-4px 0 16px rgba(0,0,0,.15)}.chat-hub-conversation-layout.show-participants .rightbar-close{display:inline-flex}}@media(max-width: 700px),(max-width: 899px)and (max-height: 500px){.chat-hub-conversation-layout.show-participants .chat-hub-rightbar{width:min(50vw,200px)}.chat-hub-conversation-layout.show-participants .chat-hub-rightbar .rightbar-title{padding:.6rem .75rem;font-size:.8rem}.chat-hub-conversation-layout.show-participants .chat-hub-rightbar .rightbar-users-list{padding:.25rem}.chat-hub-conversation-layout.show-participants .chat-hub-rightbar .user{padding:.35rem .5rem;gap:.4rem;font-size:.85rem}.chat-hub-conversation-layout.show-participants .chat-hub-rightbar .user .defaultAvatar,.chat-hub-conversation-layout.show-participants .chat-hub-rightbar .user img.avatar{width:1.75rem;height:1.75rem}}.chat-hub-messages{flex:1;overflow-y:auto;padding:1.25rem 1.5rem;display:flex;flex-direction:column;gap:1rem}.chat-hub-messages .message{display:flex;flex-direction:column;max-width:70%;padding:.625rem .875rem;border-radius:.75rem;font-size:.925rem;line-height:1.4;word-break:break-word;box-shadow:0 1px 2px rgba(0,0,0,.05)}.chat-hub-messages .message.incoming{align-self:flex-start;align-items:flex-start;background-color:#fff;color:#1e293b;border:1px solid #e2e8f0;border-bottom-left-radius:.125rem}.chat-hub-messages .message.outgoing{align-self:flex-end;align-items:flex-end;background-color:#3ba4d7;color:#fff;border-bottom-right-radius:.125rem}.chat-hub-messages .message .username{font-size:.75rem;margin-bottom:.25rem;padding:0 .125rem;font-weight:700}.chat-hub-messages .message.incoming .username{color:#0369a1}.chat-hub-messages .message.outgoing .username{color:#e0f2fe}.chat-hub-messages .message .messagetext{white-space:break-spaces;margin:0}.chat-hub-messages .message .datetime{font-size:.7rem;margin-top:.25rem;padding:0 .125rem;opacity:.8}.chat-hub-messages .message.incoming .datetime{color:#64748b}.chat-hub-messages .message.outgoing .datetime{color:#f1f5f9}.chat-attachment-preview{display:flex;align-items:center;gap:1rem;padding:.75rem 1.25rem;background-color:#f8fafc;border-top:1px solid #e2e8f0;flex-shrink:0}.chat-attachment-preview__item{position:relative;display:inline-flex;flex-shrink:0}.chat-attachment-preview__thumb{width:auto;max-width:220px;height:120px;min-width:90px;object-fit:cover;border-radius:.625rem;border:1px solid #cbd5e1;background-color:#fff;box-shadow:0 2px 8px rgba(0,0,0,.12);cursor:pointer;transition:transform .15s ease,box-shadow .15s ease}.chat-attachment-preview__thumb:hover{transform:scale(1.02);box-shadow:0 4px 14px rgba(0,0,0,.18)}.chat-attachment-preview__remove{position:absolute;top:-8px;right:-8px;width:24px;height:24px;border-radius:50%;background-color:#ef4444;color:#fff;border:2px solid #fff;display:inline-flex;align-items:center;justify-content:center;font-size:.75rem;cursor:pointer;box-shadow:0 2px 4px rgba(0,0,0,.25);padding:0;line-height:1;transition:background-color .15s,transform .15s}.chat-attachment-preview__remove:hover{background-color:#dc2626;transform:scale(1.1)}.chat-attachment-preview__info{display:flex;flex-direction:column;gap:.15rem;min-width:0;overflow:hidden}.chat-attachment-preview__name{font-size:.85rem;font-weight:600;color:#1e293b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.chat-attachment-preview__hint{font-size:.75rem;color:#64748b}.chat-hub-input-area{padding:.75rem 1.5rem;background-color:#fff;border-top:1px solid #cbd5e1;display:flex;gap:.75rem;align-items:flex-end;flex-shrink:0}.chat-hub-input-area textarea.chat-hub-textarea{flex:1;resize:none !important;min-height:40px;max-height:160px;height:40px;padding:.55rem .75rem;border:1px solid #cbd5e1;border-radius:.625rem;font-size:.9rem;line-height:1.45;outline:none;overflow-y:hidden;box-sizing:border-box;transition:border-color .2s,box-shadow .2s;background-color:#f8fafc}.chat-hub-input-area textarea.chat-hub-textarea:focus{background-color:#fff;border-color:#3ba4d7;box-shadow:0 0 0 3px rgba(59,164,215,.15)}.chat-hub-input-area button.chat-hub-send-btn{padding:.5rem 1.25rem;font-size:.9rem;height:40px;display:flex;align-items:center;gap:.5rem;border-radius:.375rem}button.chat-hub-action-btn,label.chat-hub-action-btn,.chat-hub-action-btn{display:inline-flex !important;align-items:center !important;justify-content:center !important;width:36px !important;height:36px !important;min-width:36px !important;padding:0 !important;margin:0 !important;background:rgba(0,0,0,0) !important;border:none !important;box-shadow:none !important;color:#64748b !important;font-size:1.15rem !important;border-radius:.375rem !important;cursor:pointer !important;transition:all .15s ease !important;outline:none !important}button.chat-hub-action-btn:hover,label.chat-hub-action-btn:hover,.chat-hub-action-btn:hover{background-color:#e2e8f0 !important;color:#3b82f6 !important;box-shadow:none !important}button.chat-hub-action-btn i,label.chat-hub-action-btn i,.chat-hub-action-btn i{font-size:1.15rem !important;color:inherit !important}.mobile-chat-attachment{display:none;position:relative;flex:0 0 auto}.mobile-chat-attachment__menu{position:absolute;z-index:20;bottom:calc(100% + .5rem);left:0;display:flex;min-width:9rem;flex-direction:column;padding:.25rem;border:1px solid #cbd5e1;border-radius:.5rem;background:#fff;box-shadow:0 8px 24px rgba(15,23,42,.18)}button.mobile-chat-attachment__option,label.mobile-chat-attachment__option{display:flex;width:100%;align-items:center;gap:.6rem;padding:.55rem .7rem;border:0;border-radius:.35rem;background:rgba(0,0,0,0);box-shadow:none;color:#334155;cursor:pointer;font-size:.9rem;text-align:left}button.mobile-chat-attachment__option:hover,label.mobile-chat-attachment__option:hover{background:#f1f5f9}@media(max-width: 700px){.chat-attachment-preview{padding:.35rem .6rem;gap:.5rem}.chat-attachment-preview__thumb{height:80px;max-width:130px}.chat-hub-input-area .desktop-chat-attachment,.network-chat-view .chat-input-area .desktop-chat-attachment{display:none !important}.chat-hub-input-area .mobile-chat-attachment,.network-chat-view .chat-input-area .mobile-chat-attachment{display:block}.chat-hub-input-area{gap:.2rem;align-items:center;padding:.35rem .45rem}.chat-hub-input-area .mobile-chat-attachment{order:1}.chat-hub-input-area textarea.chat-hub-textarea{order:2;min-width:0;min-height:40px;height:40px;max-height:140px;padding:.55rem .7rem;resize:none !important;border-color:#dbe2ea;border-radius:1.25rem;background:#fff;box-sizing:border-box;overflow-y:hidden;line-height:1.4}.chat-hub-input-area .emoji-picker-wrapper{order:3}.chat-hub-input-area .emoji-picker-wrapper .emoji-picker{right:-2.7rem;left:auto;width:min(320px,100vw - 1rem);max-height:min(420px,100dvh - 8rem)}.chat-hub-input-area .mobile-chat-attachment .chat-hub-action-btn,.chat-hub-input-area .emoji-picker-wrapper .chat-hub-action-btn{width:32px !important;height:32px !important;padding:.25rem !important}.chat-hub-input-area button.chat-hub-send-btn{order:4;width:40px;min-width:40px;height:40px;padding:0;justify-content:center;border-radius:50%}.chat-hub-input-area button.chat-hub-send-btn i{margin:0}}.chat-hub-messages.compact-container,.messages.compact-container{gap:0 !important;padding:.75rem 1rem !important;background-color:#fff !important;display:flex !important;flex-direction:column !important}.chat-hub-messages.compact-container .message.compact,.messages.compact-container .message.compact{display:block !important;max-width:100% !important;padding:.1rem 0 !important;border-radius:0 !important;background-color:rgba(0,0,0,0) !important;border:none !important;box-shadow:none !important;align-self:flex-start !important;font-size:.875rem !important;line-height:1.45 !important;margin:0 !important;white-space:nowrap !important}.chat-hub-messages.compact-container .message.compact:hover,.messages.compact-container .message.compact:hover{background-color:#f8fafc !important;overflow:visible !important;white-space:normal !important}.chat-hub-messages.compact-container .message.compact .datetime,.messages.compact-container .message.compact .datetime{color:#a0a0a0 !important;margin-right:.4rem !important;font-size:.78rem !important;font-family:monospace !important;opacity:1 !important;display:inline !important}.chat-hub-messages.compact-container .message.compact .username,.messages.compact-container .message.compact .username{font-weight:bold !important;margin-right:.2rem !important;font-size:.875rem !important;display:inline !important}.chat-hub-messages.compact-container .message.compact .messagetext,.messages.compact-container .message.compact .messagetext{color:#1e293b !important;white-space:normal !important;word-break:break-word !important;display:inline !important;margin:0 !important}.chat-hub-rightbar .user .user-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}.chat-hub-rightbar .rightbar-context-menu{position:absolute;right:1rem;width:210px;background-color:#fff;border:1px solid #e2e8f0;box-shadow:0 4px 10px rgba(0,0,0,.15);border-radius:.375rem;z-index:1010;padding:.25rem 0;display:flex;flex-direction:column}.chat-hub-rightbar .rightbar-context-menu .menu-item{padding:.5rem 1rem;font-size:.85rem;color:#334155;cursor:pointer;display:flex;align-items:center;transition:background-color .2s}.chat-hub-rightbar .rightbar-context-menu .menu-item:hover{background-color:#f1f5f9;color:#0f172a}.chat-emoji{font-size:1.45em;line-height:1;vertical-align:-0.15em;display:inline-block}.chat-hub-attach-btn,.chat-hub-action-btn{background-color:rgba(0,0,0,0) !important;border:none !important;font-size:1.15rem !important;color:#64748b !important;cursor:pointer !important;padding:.4rem .5rem !important;border-radius:.375rem !important;flex-shrink:0 !important;display:inline-flex !important;align-items:center !important;justify-content:center !important;transition:all .2s !important;box-shadow:none !important;margin:0 !important;line-height:1 !important;height:36px !important;width:36px !important}.chat-hub-attach-btn:hover,.chat-hub-action-btn:hover{background-color:#f1f5f9 !important;color:#3b82f6 !important;transform:none !important}.attach-modal-overlay{position:fixed;top:0;left:0;width:100vw;height:100vh;height:100dvh;background-color:rgba(15,23,42,.4);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:2000}.attach-modal{background-color:#fff;border-radius:.5rem;width:450px;max-width:90%;padding:1.5rem;box-shadow:0 10px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);display:flex;flex-direction:column;gap:1rem}.attach-modal .attach-modal-header{display:flex;align-items:center;gap:.6rem;margin-bottom:.25rem}.attach-modal .attach-modal-icon{font-size:1.2rem;color:#3b82f6}.attach-modal h4{margin:0;font-size:1.2rem;color:#0f172a}.attach-modal p{margin:0;font-size:.9rem;color:#475569}.attach-modal .attach-path-row{display:flex;gap:.5rem;align-items:center}.attach-modal .attach-path-row input[type=text]{flex:1;padding:.75rem;border:1px solid #cbd5e1;border-radius:.375rem;font-size:.9rem;outline:none;transition:border-color .2s;min-width:0}.attach-modal .attach-path-row input[type=text]:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.15)}.attach-browse-btn{flex-shrink:0;display:flex;align-items:center;gap:.35rem;padding:.625rem .9rem;font-size:.875rem;background-color:#f1f5f9;color:#334155;border:1px solid #cbd5e1;border-radius:.375rem;cursor:pointer;box-shadow:none;transition:background-color .2s,border-color .2s;white-space:nowrap}.attach-browse-btn:hover{background-color:#e2e8f0;border-color:#94a3b8}.attach-path-hint{display:flex;align-items:flex-start;gap:.5rem;padding:.6rem .75rem;background-color:#fffbeb;border:1px solid #fcd34d;border-left:3px solid #f59e0b;border-radius:.375rem;font-size:.825rem;color:#92400e;line-height:1.45}.attach-path-hint i{color:#f59e0b;margin-top:.1rem;flex-shrink:0}.attach-path-hint code{font-family:monospace;background-color:rgba(245,158,11,.15);padding:.05rem .25rem;border-radius:.2rem}.attach-modal .hashing-spinner{display:flex;align-items:center;gap:.5rem;font-size:.9rem;color:#3b82f6}.attach-modal .error-text{color:#ef4444;font-size:.85rem;margin:0}.attach-modal .modal-buttons{display:flex;justify-content:flex-end;gap:.75rem;margin-top:.5rem}.attach-modal .modal-buttons button{padding:.5rem 1rem;font-size:.9rem;border-radius:.25rem;border:none;cursor:pointer;transition:opacity .2s}.attach-modal .modal-buttons button:hover{opacity:.9}.chat-hub-emoji-btn{background-color:rgba(0,0,0,0);border:none;font-size:1.3rem;cursor:pointer;padding:.35rem .4rem;margin-right:.25rem;flex-shrink:0;display:flex;align-items:center;justify-content:center;border-radius:.375rem;line-height:1;transition:background-color .15s,transform .15s;box-shadow:none}.chat-hub-emoji-btn:hover{background-color:#f1f5f9;transform:scale(1.1)}.chat-hub-messages.compact-container .message.compact,.messages.compact-container .message.compact{display:block !important;max-width:100% !important;padding:.1rem 0 !important;border-radius:0 !important;background-color:rgba(0,0,0,0) !important;border:none !important;box-shadow:none !important;align-self:flex-start !important;font-size:.875rem !important;line-height:1.45 !important;margin:0 !important;white-space:nowrap !important}.chat-hub-messages.compact-container .message.compact:hover,.messages.compact-container .message.compact:hover{background-color:#f8fafc !important;overflow:visible !important;white-space:normal !important}.chat-hub-messages.compact-container .message.compact .datetime,.messages.compact-container .message.compact .datetime{color:#a0a0a0 !important;margin-right:.4rem !important;font-size:.78rem !important;font-family:monospace !important;opacity:1 !important;display:inline !important}.chat-hub-messages.compact-container .message.compact .username,.messages.compact-container .message.compact .username{font-weight:bold !important;margin-right:.2rem !important;font-size:.875rem !important;display:inline !important}.chat-hub-messages.compact-container .message.compact .messagetext,.messages.compact-container .message.compact .messagetext{color:#1e293b !important;white-space:normal !important;word-break:break-word !important;display:inline !important;margin:0 !important}.user-tooltip{position:fixed !important;width:280px !important;background-color:#ffffe1 !important;border:1px solid #7f7f7f !important;box-shadow:2px 2px 6px rgba(0,0,0,.25) !important;padding:.5rem !important;border-radius:.25rem !important;z-index:10000 !important;white-space:normal !important;display:flex !important;gap:.5rem !important;align-items:flex-start !important;color:#000 !important;font-size:.8rem !important;text-align:left !important;pointer-events:none !important}.user-tooltip .tooltip-avatar{flex-shrink:0 !important}.user-tooltip .tooltip-avatar .jdenticon-avatar,.user-tooltip .tooltip-avatar .defaultAvatar,.user-tooltip .tooltip-avatar img.avatar{width:56px !important;height:56px !important;min-width:56px !important;min-height:56px !important;border-radius:2px !important;border:1px solid #999 !important;box-shadow:none !important;object-fit:cover !important}.user-tooltip .tooltip-details{display:flex !important;flex-direction:column !important;gap:.2rem !important;min-width:0 !important;flex:1 !important}.user-tooltip .tooltip-details .tooltip-row{line-height:1.2 !important;display:flex !important;flex-direction:row !important;align-items:baseline !important;gap:.35rem !important;white-space:normal !important;word-break:break-all !important}.user-tooltip .tooltip-details .tooltip-row .tooltip-label{font-weight:bold !important;color:#000 !important;font-size:.8rem !important;flex-shrink:0 !important}.user-tooltip .tooltip-details .tooltip-row .tooltip-value{font-weight:normal !important;color:#000 !important;font-size:.8rem !important;overflow:hidden !important;text-overflow:ellipsis !important}.user-tooltip .tooltip-details .tooltip-row .tooltip-value.tooltip-id{font-family:monospace !important;font-size:.75rem !important;color:#00b !important}.attach-modal-overlay{position:fixed;top:0;left:0;width:100vw;height:100vh;height:100dvh;background-color:rgba(15,23,42,.5);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:9999}.attach-modal{background:#fff;border-radius:.5rem;width:480px;max-width:92vw;box-shadow:0 20px 25px -5px rgba(0,0,0,.15),0 8px 10px -6px rgba(0,0,0,.1);padding:1.5rem;display:flex;flex-direction:column;color:#1e293b;box-sizing:border-box}.attach-modal h4{margin:0 0 1rem 0;font-size:1.15rem;font-weight:700;color:#0f172a}.attach-modal .attach-modal-header{display:flex;align-items:center;gap:.5rem;margin-bottom:1rem}.attach-modal .attach-modal-header h4{margin:0}.attach-modal .attach-modal-header .attach-modal-icon{font-size:1.25rem;color:#3b82f6}.emoji-picker-wrapper{position:relative;flex-shrink:0;display:flex;align-items:center}.emoji-picker{position:absolute;bottom:calc(100% + .5rem);left:0;width:320px;background-color:#fff;border:1px solid #e2e8f0;border-radius:.625rem;box-shadow:0 8px 30px -4px rgba(0,0,0,.18),0 4px 12px -2px rgba(0,0,0,.1);z-index:9999;display:flex;flex-direction:column;overflow:hidden;animation:emoji-pop .15s ease-out}.emoji-search-row{display:flex;align-items:center;gap:.4rem;padding:.6rem .75rem .4rem;border-bottom:1px solid #f1f5f9}.emoji-search-icon{color:#94a3b8;font-size:.8rem;flex-shrink:0}input.emoji-search-input{flex:1;border:1px solid #e2e8f0;border-radius:.375rem;padding:.3rem .5rem;font-size:.85rem;outline:none;background-color:#f8fafc;transition:border-color .15s}input.emoji-search-input:focus{border-color:#3ba4d7;background-color:#fff;box-shadow:none}.emoji-search-clear{background:none !important;border:none !important;cursor:pointer;color:#94a3b8;padding:.2rem;font-size:.8rem;box-shadow:none !important;display:flex;align-items:center}.emoji-search-clear:hover{color:#475569}.emoji-categories{display:flex;gap:.1rem;padding:.35rem .5rem;border-bottom:1px solid #f1f5f9;overflow-x:auto;scrollbar-width:none}.emoji-categories::-webkit-scrollbar{display:none}.emoji-cat-btn{background:rgba(0,0,0,0) !important;border:none !important;cursor:pointer;font-size:1.15rem !important;padding:.25rem .35rem !important;border-radius:6px !important;line-height:1 !important;box-shadow:none !important;transition:background-color .15s ease,transform .1s ease !important;flex-shrink:0;width:auto !important;height:auto !important;min-width:unset !important}.emoji-cat-btn:hover{background-color:#f1f5f9 !important;transform:scale(1.15)}.emoji-cat-btn.active{background-color:#e0f2fe !important;border-radius:6px !important;box-shadow:none !important}.emoji-grid{display:grid !important;grid-template-columns:repeat(8, 1fr) !important;gap:2px !important;padding:.4rem .35rem !important;max-height:220px !important;overflow-y:auto !important;overflow-x:hidden !important;scrollbar-width:thin;scrollbar-color:#cbd5e1 rgba(0,0,0,0)}.emoji-grid::-webkit-scrollbar{width:5px}.emoji-grid::-webkit-scrollbar-track{background:rgba(0,0,0,0)}.emoji-grid::-webkit-scrollbar-thumb{background-color:#cbd5e1;border-radius:3px}.emoji-btn{background:rgba(0,0,0,0) !important;border:none !important;cursor:pointer;font-size:1.25rem !important;padding:.35rem 0 !important;border-radius:6px !important;line-height:1 !important;box-shadow:none !important;text-align:center;transition:background-color .1s ease,transform .1s ease !important;display:flex !important;align-items:center !important;justify-content:center !important;width:auto !important;height:auto !important;min-width:unset !important}.emoji-btn:hover{background-color:#e2e8f0 !important;transform:scale(1.2)}@keyframes emoji-pop{from{opacity:0;transform:scale(0.92) translateY(6px)}to{opacity:1;transform:scale(1) translateY(0)}}.rightbar-context-menu,.chat-msg-context-menu{z-index:9999 !important;background-color:#fff !important;border:1px solid #cbd5e1 !important;border-radius:8px !important;box-shadow:0 10px 25px -5px rgba(0,0,0,.15),0 8px 10px -6px rgba(0,0,0,.1) !important;padding:.35rem 0 !important;font-family:inherit !important;overflow:hidden !important}.rightbar-context-menu .menu-item,.rightbar-context-menu .context-menu-item,.chat-msg-context-menu .menu-item,.chat-msg-context-menu .context-menu-item{display:flex !important;align-items:center !important;padding:.55rem .85rem !important;font-size:.875rem !important;font-weight:500 !important;color:#1e293b !important;cursor:pointer !important;transition:background-color .15s ease,color .15s ease !important;user-select:none !important}.rightbar-context-menu .menu-item:hover,.rightbar-context-menu .context-menu-item:hover,.chat-msg-context-menu .menu-item:hover,.chat-msg-context-menu .context-menu-item:hover{background-color:#f1f5f9 !important;color:#0284c7 !important}.rightbar-context-menu .menu-item i,.rightbar-context-menu .context-menu-item i,.chat-msg-context-menu .menu-item i,.chat-msg-context-menu .context-menu-item i{font-size:.95rem !important;width:1.25rem !important;text-align:center !important}.rightbar-context-menu{position:absolute !important;right:10px !important;min-width:220px !important;z-index:9999 !important}.chat-msg-context-menu{position:fixed !important;right:auto !important;width:max-content !important;min-width:180px !important;max-width:calc(100vw - 16px) !important;box-sizing:border-box !important}.chat-msg-context-menu .context-menu-item{white-space:nowrap !important}.chat-image-viewer{position:fixed;inset:0;z-index:1000000;display:flex;align-items:center;justify-content:center;box-sizing:border-box;padding:3.5rem 1rem 1rem;background:rgba(15,23,42,.98)}.chat-image-viewer__image{display:block;max-width:100%;max-height:100%;object-fit:contain}.chat-image-viewer__close{position:absolute;top:max(.5rem,env(safe-area-inset-top));right:max(.5rem,env(safe-area-inset-right));width:2.75rem;height:2.75rem;padding:0;border:1px solid hsla(0,0%,100%,.5);border-radius:50%;background:rgba(0,0,0,.45);color:#fff;font-size:2rem;line-height:1}.mail-outlook-container{display:flex;width:100%;height:100%;overflow:hidden;background-color:#f1f5f9;position:relative}.mail-drawer-backdrop{position:fixed;inset:0;background:rgba(15,23,42,.45);backdrop-filter:blur(2px);z-index:1150;animation:fadeIn .2s ease}@keyframes fadeIn{from{opacity:0}to{opacity:1}}.mail-folders-pane{width:250px;min-width:230px;max-width:280px;height:100%;background:#fff;border-right:1px solid #e2e8f0;display:flex;flex-direction:column;flex-shrink:0;z-index:10}.mail-folders-header{padding:1rem;border-bottom:1px solid #e2e8f0;background:linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)}.mail-compose-btn{display:flex;align-items:center;justify-content:center;gap:.55rem;width:100%;padding:.65rem 1.15rem;border-radius:4px;background-color:#0284c7;color:#fff;font-weight:600;font-size:.95rem;border:none;cursor:pointer;box-shadow:0 1px 3px rgba(2,132,199,.25);transition:background-color .15s ease,box-shadow .15s ease,transform .12s ease}.mail-compose-btn i{font-size:1.05rem;line-height:1}.mail-compose-btn span{line-height:1}.mail-compose-btn:hover{background-color:#0369a1;box-shadow:0 2px 6px rgba(2,132,199,.35)}.mail-compose-btn:active{background-color:#075985;transform:translateY(1px);box-shadow:none}.mail-nav-scroll{flex:1;overflow-y:auto;padding:.5rem .75rem 1.5rem;display:flex;flex-direction:column;gap:.25rem}.mail-nav-section-title{font-size:.725rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;padding:.75rem .6rem .25rem}.mail-nav-list{display:flex;flex-direction:column;gap:.15rem}.mail-nav-item{display:flex;align-items:center;gap:.75rem;padding:.55rem .75rem;border-radius:.5rem;color:#334155;font-size:.9rem;font-weight:500;text-decoration:none;transition:all .15s ease;user-select:none;border-left:3px solid rgba(0,0,0,0)}.mail-nav-item i{font-size:1.05rem;width:20px;text-align:center;flex-shrink:0;color:#1e293b;transition:color .15s ease}.mail-nav-item .mail-category-dot{width:18px;height:18px;min-width:18px;border-radius:50%;margin:0 1px;flex-shrink:0;box-shadow:0 1px 2px rgba(0,0,0,.08)}.mail-nav-item .mail-nav-label{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.mail-nav-item .mail-nav-badge{font-size:.725rem;font-weight:600;padding:.15rem .45rem;border-radius:999px;background:#f1f5f9;color:#64748b;margin-left:auto}.mail-nav-item .mail-nav-badge--unread{background:#0284c7;color:#fff;font-weight:700}.mail-nav-item:hover{background:#f8fafc;color:#0f172a}.mail-nav-item:hover i{color:#0f172a}.mail-nav-item.active{background:#e0f2fe;color:#0369a1;font-weight:600;border-left-color:#0284c7}.mail-nav-item.active i{color:#0284c7}.mail-list-pane{width:400px;min-width:350px;max-width:460px;height:100%;background:#fff;border-right:1px solid #e2e8f0;display:flex;flex-direction:column;flex-shrink:0}.mail-list-pane--table-view{width:auto;min-width:0;max-width:none;flex:1;border-right:none}.mail-list-pane--table-selected-hidden{display:none !important}.mail-list-header{padding:.85rem 1rem .75rem;border-bottom:1px solid #e2e8f0;background:#fff;display:flex;flex-direction:column;gap:.65rem}.mail-list-header-top{display:flex;align-items:center;justify-content:space-between;gap:.5rem}.mail-mobile-nav-toggle{display:none;align-items:center;justify-content:center;width:36px;height:36px;border:1px solid #e2e8f0;border-radius:.5rem;background:#f8fafc;color:#334155;cursor:pointer;font-size:1.1rem}.mail-mobile-nav-toggle:hover{background:#f1f5f9;color:#0284c7}.mail-folder-title-row{display:flex;align-items:center;gap:.5rem;flex:1;min-width:0}.mail-folder-title-row i{font-size:1.15rem;color:#1e293b}.mail-folder-title-row .mail-folder-heading{margin:0;font-size:1.2rem;font-weight:700;color:#0f172a;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.mail-folder-title-row .mail-folder-count{font-size:.85rem;color:#64748b;font-weight:500}.mail-view-toggle{display:inline-flex;background:#f1f5f9;padding:2px;border-radius:.45rem;gap:2px;border:1px solid #e2e8f0}.mail-toggle-btn{display:inline-flex;align-items:center;justify-content:center;min-width:30px;height:28px;padding:0 .45rem;border:none;border-radius:.35rem;background:rgba(0,0,0,0);color:#64748b;font-size:.85rem;cursor:pointer;transition:all .15s ease}.mail-toggle-btn i{font-size:.85rem}.mail-toggle-btn:hover{color:#0f172a}.mail-toggle-btn.active{background-color:#0284c7;color:#fff;box-shadow:0 1px 3px rgba(2,132,199,.35)}.mail-toggle-btn.active i{color:#fff}.mail-toggle-btn.active:hover{background-color:#0369a1;color:#fff}.mail-toggle-btn.active:hover i{color:#fff}.mail-search-wrapper{position:relative;display:flex;align-items:center}.mail-search-wrapper .mail-search-icon{position:absolute;left:.75rem;color:#94a3b8;font-size:.85rem;pointer-events:none}.mail-search-wrapper .mail-search-input{width:100%;padding:.45rem 1.85rem .45rem 2.15rem;border:1px solid #cbd5e1;border-radius:.5rem;background:#f8fafc;font-size:.85rem;color:#1e293b;outline:none;transition:all .15s ease}.mail-search-wrapper .mail-search-input:focus{background:#fff;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12)}.mail-search-wrapper .mail-search-clear{position:absolute;right:.5rem;background:rgba(0,0,0,0);border:none;color:#94a3b8;cursor:pointer;padding:.25rem;font-size:.75rem}.mail-search-wrapper .mail-search-clear:hover{color:#334155}.mail-filter-row{display:flex;align-items:center;justify-content:space-between;gap:.5rem}.mail-filter-tabs{display:flex;gap:.35rem}.mail-filter-pill{border:none !important;background:rgba(0,0,0,0);box-shadow:none !important;outline:none !important;padding:.25rem .65rem;border-radius:999px;font-size:.8rem;font-weight:600;color:#64748b;cursor:pointer;transition:all .15s ease;display:inline-flex;align-items:center;gap:.35rem}.mail-filter-pill:hover{background:#f1f5f9 !important;color:#1e293b;box-shadow:none !important}.mail-filter-pill.active{background:#e0f2fe !important;color:#0284c7;box-shadow:none !important}.mail-filter-pill.active:hover{background:#bae6fd !important;color:#0284c7;box-shadow:none !important}.mail-filter-pill:active{box-shadow:none !important;transform:scale(0.96)}.mail-filter-pill .mail-unread-pill-count{background:#0284c7;color:#fff;font-size:.7rem;padding:.05rem .35rem;border-radius:999px;font-weight:700;box-shadow:none !important}.mail-tag-select{border:1px solid #e2e8f0;background:#f8fafc;font-size:.775rem;font-weight:500;color:#475569;padding:.25rem .5rem;border-radius:.375rem;outline:none;cursor:pointer}.mail-tag-select:focus{border-color:#3b82f6}.mail-list-body{flex:1;overflow-y:auto;overflow-x:hidden;background:#f8fafc}.mail-empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:3.5rem 1.5rem;text-align:center;color:#94a3b8}.mail-empty-state .mail-empty-icon{font-size:3rem;color:#cbd5e1;margin-bottom:.75rem}.mail-empty-state h4{margin:0 0 .35rem;font-size:1.1rem;font-weight:600;color:#475569}.mail-empty-state p{margin:0;font-size:.85rem;max-width:250px}.mail-cards-container{display:flex;flex-direction:column;gap:1px;background:#e2e8f0}.mail-card-item{position:relative;display:flex;gap:.75rem;padding:.85rem 1rem;background:#fff;cursor:pointer;transition:all .15s ease;border-left:3.5px solid rgba(0,0,0,0)}.mail-card-item:hover{background:#f8fafc}.mail-card-item.selected{background:#e0f2fe !important;border-left-color:#0284c7 !important}.mail-card-item.selected:hover{background:#bae6fd !important}.mail-card-item.selected .mail-card-sender{color:#0f172a;font-weight:600}.mail-card-item.selected .mail-card-subject{color:#0369a1;font-weight:600}.mail-card-item.selected .mail-card-date{color:#64748b}.mail-card-item.selected .mail-card-snippet{color:#334155}.mail-card-item.selected .mail-card-clip{color:#64748b}.mail-card-item.selected .mail-card-star-btn{color:#94a3b8}.mail-card-item.selected .mail-card-star-btn.starred,.mail-card-item.selected .mail-card-star-btn:hover{color:#f59e0b}.mail-card-item.selected .mail-card-spam-btn{color:#94a3b8}.mail-card-item.selected .mail-card-spam-btn.spammed,.mail-card-item.selected .mail-card-spam-btn:hover{color:#f97316}.mail-card-item.unread{background:#f0f9ff}.mail-card-item.unread .mail-card-subject{font-weight:700;color:#0f172a}.mail-card-item.unread .mail-card-sender{font-weight:700;color:#0f172a}.mail-card-item .mail-card-unread-dot{position:absolute;top:1.15rem;left:.35rem;width:7px;height:7px;border-radius:50%;background:#0284c7}.mail-card-item .mail-card-avatar-col{flex-shrink:0;padding-top:.1rem}.mail-card-item .mail-card-content-col{flex:1;min-width:0;display:flex;flex-direction:column;gap:.2rem}.mail-card-item .mail-card-row-top{display:flex;align-items:baseline;justify-content:space-between;gap:.5rem}.mail-card-item .mail-card-sender{font-size:.9rem;font-weight:600;color:#1e293b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.mail-card-item .mail-card-date{font-size:.75rem;color:#94a3b8;flex-shrink:0;font-weight:500}.mail-card-item .mail-card-row-subject{display:flex;align-items:center;justify-content:space-between;gap:.5rem}.mail-card-item .mail-card-subject{font-size:.875rem;color:#334155;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500;flex:1;min-width:0}.mail-card-item .mail-card-indicators{display:flex;align-items:center;gap:.4rem;flex-shrink:0}.mail-card-item .mail-card-clip{font-size:.8rem;color:#94a3b8}.mail-card-item .mail-card-spam-btn{background:rgba(0,0,0,0) !important;background-color:rgba(0,0,0,0) !important;border:none !important;box-shadow:none !important;outline:none !important;padding:.15rem !important;width:auto !important;height:auto !important;font-size:.85rem;color:#cbd5e1;cursor:pointer;line-height:1;display:inline-flex;align-items:center;justify-content:center;border-radius:0 !important;transition:color .15s ease,transform .12s ease}.mail-card-item .mail-card-spam-btn:hover,.mail-card-item .mail-card-spam-btn:active,.mail-card-item .mail-card-spam-btn:focus{background:rgba(0,0,0,0) !important;background-color:rgba(0,0,0,0) !important;box-shadow:none !important;outline:none !important}.mail-card-item .mail-card-spam-btn.spammed{color:#f97316 !important}.mail-card-item .mail-card-spam-btn:hover{color:#f97316 !important;transform:scale(1.15)}.mail-card-item .mail-card-star-btn{background:rgba(0,0,0,0) !important;background-color:rgba(0,0,0,0) !important;border:none !important;box-shadow:none !important;outline:none !important;padding:.15rem !important;width:auto !important;height:auto !important;font-size:.9rem;color:#cbd5e1;cursor:pointer;line-height:1;display:inline-flex;align-items:center;justify-content:center;border-radius:0 !important;transition:color .15s ease,transform .12s ease}.mail-card-item .mail-card-star-btn:hover,.mail-card-item .mail-card-star-btn:active,.mail-card-item .mail-card-star-btn:focus{background:rgba(0,0,0,0) !important;background-color:rgba(0,0,0,0) !important;box-shadow:none !important;outline:none !important}.mail-card-item .mail-card-star-btn.starred{color:#f59e0b !important}.mail-card-item .mail-card-star-btn:hover{color:#f59e0b !important;transform:scale(1.15)}.mail-card-item .mail-card-snippet{font-size:.8rem;color:#64748b;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.mail-card-item .mail-card-tags{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.2rem}.mail-card-item .mail-card-tag-badge{display:inline-flex;align-items:center;gap:.3rem;font-size:.7rem;font-weight:600;padding:.1rem .45rem;border-radius:4px}.mail-card-item .mail-card-tag-dot{width:6px;height:6px;border-radius:50%}.table-pagination-container{display:flex;flex-direction:column;height:100%;background:#fff;overflow-x:hidden}.table-pagination-container table.mails{width:100%;table-layout:fixed;border-collapse:collapse}.table-pagination-container table.mails .mobile-subject-clip{display:none}.table-pagination-container table.mails col.col-starred,.table-pagination-container table.mails th.col-starred,.table-pagination-container table.mails td.cell-star{width:44px;min-width:44px;max-width:44px;text-align:center;padding-left:.25rem;padding-right:.25rem}.table-pagination-container table.mails col.col-attachments,.table-pagination-container table.mails th.col-attachments,.table-pagination-container table.mails td.cell-attachment{width:38px;min-width:38px;max-width:38px;text-align:center;padding-left:.25rem;padding-right:.25rem}.table-pagination-container table.mails col.col-subject,.table-pagination-container table.mails th.col-subject,.table-pagination-container table.mails td.cell-subject{width:430px;min-width:320px;max-width:430px;text-align:left;overflow:hidden}.table-pagination-container table.mails col.col-subject div,.table-pagination-container table.mails th.col-subject div,.table-pagination-container table.mails td.cell-subject div{display:flex;align-items:center;gap:.5rem;min-width:0;overflow:hidden}.table-pagination-container table.mails col.col-subject span,.table-pagination-container table.mails th.col-subject span,.table-pagination-container table.mails td.cell-subject span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.table-pagination-container table.mails col.col-from,.table-pagination-container table.mails th.col-from,.table-pagination-container table.mails td.cell-from{width:210px;min-width:170px;max-width:250px;text-align:left;overflow:hidden}.table-pagination-container table.mails col.col-from div,.table-pagination-container table.mails th.col-from div,.table-pagination-container table.mails td.cell-from div{display:flex;align-items:center;gap:.5rem;min-width:0;overflow:hidden}.table-pagination-container table.mails col.col-from span,.table-pagination-container table.mails th.col-from span,.table-pagination-container table.mails td.cell-from span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.table-pagination-container table.mails col.col-spam,.table-pagination-container table.mails th.col-spam,.table-pagination-container table.mails td.cell-spam{width:38px;min-width:38px;max-width:38px;text-align:center;padding-left:.25rem;padding-right:.25rem}.table-pagination-container table.mails col.col-date,.table-pagination-container table.mails th.col-date,.table-pagination-container table.mails td.cell-date{width:105px;min-width:95px;max-width:120px;text-align:right;padding-right:.75rem;white-space:nowrap}.table-pagination-container table.mails col.col-spacer,.table-pagination-container table.mails th.col-spacer,.table-pagination-container table.mails td.cell-spacer{width:auto;padding:0}.table-pagination-container table.mails tr{border-bottom:1px solid #f1f5f9;transition:background-color .15s ease;height:42px}.table-pagination-container table.mails tr:hover{background-color:#f8fafc;cursor:pointer}.table-pagination-container table.mails tr.selected{background-color:#e0f2fe !important}.table-pagination-container table.mails tr.selected:hover{background-color:#bae6fd !important}.table-pagination-container table.mails tr.selected td{color:#1e293b}.table-pagination-container table.mails tr.selected td.cell-subject span{color:#0369a1;font-weight:600}.table-pagination-container table.mails tr.selected td.cell-from span{color:#0f172a;font-weight:600}.table-pagination-container table.mails tr.selected td.cell-date{color:#64748b}.table-pagination-container table.mails tr.selected td.cell-attachment i{color:#64748b}.table-pagination-container table.mails tr.selected td.cell-star label.star-check{color:#cbd5e1}.table-pagination-container table.mails tr.selected td.cell-star label.star-check.starred{color:#f59e0b !important}.table-pagination-container table.mails tr.selected td.cell-star label.star-check:hover{color:#f59e0b !important}.table-pagination-container table.mails tr.selected td.cell-spam button.spam-btn{color:#94a3b8}.table-pagination-container table.mails tr.selected td.cell-spam button.spam-btn.spammed{color:#f97316 !important}.table-pagination-container table.mails tr.selected td.cell-spam button.spam-btn:hover{color:#f97316 !important}.table-pagination-container table.mails tr.unread{background-color:#f0f9ff}.table-pagination-container table.mails tr.unread td.cell-subject span{font-weight:700;color:#0f172a}.table-pagination-container table.mails tr.unread td.cell-from span{font-weight:700;color:#0f172a}.table-pagination-container table.mails th{padding:.65rem .75rem;background:#f8fafc;border-bottom:1px solid #e2e8f0;color:#64748b;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;position:sticky;top:0;z-index:2;box-sizing:border-box}.table-pagination-container table.mails th.sortable-th:hover{background:#f1f5f9;color:#0f172a}.table-pagination-container table.mails td{padding:.55rem .75rem;font-size:.85rem;color:#334155;vertical-align:middle;box-sizing:border-box}.table-pagination-container table.mails td.cell-star label.star-check{cursor:pointer;color:#cbd5e1;transition:color .15s ease}.table-pagination-container table.mails td.cell-star label.star-check.starred{color:#eab308}.table-pagination-container table.mails td.cell-star label.star-check:hover{color:#eab308}.table-pagination-container table.mails td.cell-spam button.spam-btn{background:rgba(0,0,0,0) !important;background-color:rgba(0,0,0,0) !important;border:none !important;box-shadow:none !important;outline:none !important;padding:0 !important;margin:0 !important;width:auto !important;height:auto !important;font-size:.85rem;color:#cbd5e1;cursor:pointer;line-height:1;display:inline-flex;align-items:center;justify-content:center;border-radius:0 !important;transition:color .15s ease,transform .12s ease}.table-pagination-container table.mails td.cell-spam button.spam-btn:hover,.table-pagination-container table.mails td.cell-spam button.spam-btn:active,.table-pagination-container table.mails td.cell-spam button.spam-btn:focus{background:rgba(0,0,0,0) !important;background-color:rgba(0,0,0,0) !important;box-shadow:none !important;outline:none !important}.table-pagination-container table.mails td.cell-spam button.spam-btn.spammed{color:#f97316 !important}.table-pagination-container table.mails td.cell-spam button.spam-btn:hover{color:#f97316 !important;transform:scale(1.15)}.table-pagination-container table.mails td.cell-attachment{color:#94a3b8}.table-pagination-container table.mails td.cell-date{color:#94a3b8;font-size:.775rem}.table-pagination-container .pagination{margin-top:auto;display:flex;justify-content:center;align-items:center;gap:1rem;padding:.75rem 1rem;border-top:1px solid #e2e8f0;background:#fff;font-size:.85rem;color:#64748b}input.star-check{display:none}.mail-reading-pane{flex:1;height:100%;overflow-y:auto;background:#f8fafc;display:flex;flex-direction:column}.mail-reading-pane--table-view{flex:1;width:auto;background:#fff}.mail-reading-pane--table-view .mail-view-back-btn{display:inline-flex !important}.mail-reading-pane--table-view .msg-view.mail-reading-card{width:100%;max-width:none}.mail-reading-placeholder{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:3rem;text-align:center;color:#94a3b8}.mail-reading-placeholder .mail-reading-placeholder__icon{font-size:4.5rem;color:#cbd5e1;margin-bottom:1.25rem}.mail-reading-placeholder .mail-reading-placeholder__title{font-size:1.35rem;font-weight:700;color:#475569;margin:0 0 .5rem}.mail-reading-placeholder .mail-reading-placeholder__subtitle{font-size:.95rem;color:#94a3b8;max-width:320px;margin:0;line-height:1.5}.msg-view.mail-reading-card{flex:1;display:flex;flex-direction:column;height:100%;overflow-y:auto}.msg-view-nav{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1.5rem;background:#fff;border-bottom:1px solid #e2e8f0;position:sticky;top:0;z-index:5}.mail-view-back-btn{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;min-width:36px;padding:0;flex-shrink:0;border-radius:50%;background-color:#e8f4fc;color:#0788cb;border:none;font-size:1.15rem;cursor:pointer;box-shadow:none;text-decoration:none;transition:background-color .15s ease,color .15s ease,transform .12s ease}.mail-view-back-btn i{font-size:1.05rem;line-height:1;transition:transform .15s ease}.mail-view-back-btn .fa-arrow-left::before{content:""}.mail-view-back-btn:hover{background-color:#d5ebfa;color:#0788cb}.mail-view-back-btn:hover i{transform:translateX(-2px)}.mail-view-back-btn:active{transform:scale(0.92)}.msg-view-nav__action{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}.mail-action-btn{display:inline-flex;align-items:center;gap:.35rem;padding:.4rem .75rem;border-radius:.375rem;border:1px solid #e2e8f0;background:#fff;color:#334155;font-size:.85rem;font-weight:600;cursor:pointer;transition:all .15s ease}.mail-action-btn i{font-size:.85rem}.mail-action-btn:hover{background:#f1f5f9;border-color:#cbd5e1;color:#0f172a}.mail-action-btn.mail-action-btn--starred{color:#eab308;border-color:#fef08a;background:#fefce8}.mail-action-btn.mail-action-btn--spam{color:#ea580c;border-color:#fed7aa;background:#fff7ed}.mail-action-btn.mail-action-btn--spam:hover{background:#ffedd5;border-color:#fdba74}.mail-action-btn.mail-action-btn--delete{color:#ef4444;border-color:#fecaca}.mail-action-btn.mail-action-btn--delete:hover{background:#fef2f2;border-color:#fca5a5}.msg-view__header{padding:1.5rem 1.5rem 1rem;background:#fff;border-bottom:1px solid #f1f5f9}.mail-reading-title-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:1rem}.mail-reading-title-row h2.msg-view__title{margin:0;font-size:1.45rem;font-weight:700;color:#0f172a;line-height:1.3}.mail-reading-title-row .mail-reading-tags{display:flex;gap:.35rem;flex-wrap:wrap}.msg-details{display:flex;gap:1rem;align-items:flex-start}.msg-details__info{flex:1;min-width:0;display:flex;flex-direction:column;gap:.35rem}.msg-details__info-row{display:flex;align-items:baseline;justify-content:space-between;gap:1rem}.msg-sender-name{font-size:1.05rem;font-weight:700;color:#1e293b;cursor:pointer;transition:color .15s ease}.msg-sender-name:hover{color:#0284c7}.msg-timestamp{font-size:.825rem;color:#64748b;font-weight:500;flex-shrink:0}.msg-recipients-row{display:flex;align-items:center;flex-wrap:wrap;gap:.35rem;font-size:.825rem;color:#64748b}.msg-recipients-row .recipient-label{font-weight:600;color:#475569;margin-right:.2rem}.msg-recipients-row .recipient-chip{background:#f1f5f9;padding:.15rem .5rem;border-radius:999px;font-size:.775rem;color:#334155;font-weight:500}.msg-view__attachment{padding:1rem 1.5rem;background:#f8fafc;border-bottom:1px solid #e2e8f0}.msg-view__attachment .attachments-title{margin:0 0 .5rem;font-size:.9rem;font-weight:600;color:#475569;display:flex;align-items:center;gap:.4rem}.msg-view__body{flex:1;padding:1.5rem;background:#f8fafc;overflow-y:auto}.mail-body-container{background:#fff;border:1px solid #e2e8f0;border-radius:.75rem;padding:1.5rem;box-shadow:0 1px 3px rgba(0,0,0,.03);font-size:.95rem;line-height:1.65;color:#1e293b;min-height:220px;word-break:break-word}.mail-body-container a{color:#0284c7;text-decoration:underline}.mail-body-container a:hover{color:#0369a1}.mail-body-container blockquote{border-left:3px solid #cbd5e1;padding-left:1rem;margin-left:0;color:#64748b}.attachments-wrapper{display:flex;flex-direction:column;gap:.5rem;margin-top:.5rem}.attachment-card{background:#fff;border:1px solid #e2e8f0;border-radius:.5rem;padding:.75rem 1rem;display:flex;align-items:center;gap:.75rem;box-shadow:0 1px 3px rgba(0,0,0,.02)}.attachment-card .attachment-icon{width:38px;height:38px;border-radius:.5rem;background:#eff6ff;color:#0284c7;display:flex;align-items:center;justify-content:center;font-size:1.15rem;flex-shrink:0}.attachment-card .attachment-info{flex:1;min-width:0}.attachment-card .attachment-info .attachment-name{font-weight:600;font-size:.875rem;color:#1e293b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.attachment-card .attachment-info .attachment-size{font-size:.75rem;color:#64748b}.attachment-card .btn-attachment-download{padding:.4rem .75rem;font-size:.8rem;border-radius:.375rem;background:#0284c7;color:#fff;border:none;cursor:pointer;font-weight:600;display:inline-flex;align-items:center;gap:.35rem}.attachment-card .btn-attachment-download:hover{background:#0369a1}.composePopupOverlay{position:fixed;width:100%;height:100%;top:0;left:0;z-index:1;background-color:rgba(0,0,0,.2);z-index:1200}.composePopupOverlay .composePopup{position:absolute;inset:0;margin:auto;width:min(850px,92vw);height:min(750px,90vh);background:#fff;border-radius:.75rem;box-shadow:0 20px 40px rgba(15,23,42,.25);display:flex;flex-direction:column;overflow:hidden}.composePopupOverlay .composePopup>.widget{padding:1.5rem;height:100%;display:flex;flex-direction:column;overflow:hidden}.composePopupOverlay .composePopup .close-btn{position:absolute;top:1rem;right:1rem;z-index:10;width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#64748b;border:none;cursor:pointer}.composePopupOverlay .composePopup .close-btn:hover{background:#fee2e2;color:#ef4444}.compose-mail{display:flex;flex-direction:column;height:100%;overflow:hidden}.compose-mail__from{display:flex;justify-content:flex-start;align-items:center;gap:.5rem;padding-bottom:.5rem;border-bottom:1px solid #e2e8f0}.compose-mail__recipients{padding:.5rem 0;display:flex;flex-direction:column;gap:.5rem;border-bottom:1px solid #e2e8f0}.compose-mail__recipients__container{display:flex;gap:.5rem}.compose-mail__recipients__container>label{text-transform:capitalize;font-weight:600;color:#475569;font-size:.85rem;width:35px}.compose-mail__recipients__container .recipients{width:100%;display:flex;gap:.5rem;flex-wrap:wrap}.compose-mail__recipients__container .recipients__selected{padding:.2rem .6rem;display:flex;align-items:center;gap:.4rem;border:1px solid #cbd5e1;border-radius:999px;background:#f1f5f9;font-size:.8rem;color:#334155}.compose-mail__recipients__container .recipients__selected i{cursor:pointer;color:#94a3b8}.compose-mail__recipients__container .recipients__selected i:hover{color:#ef4444}.compose-mail__recipients__container .recipients__input{display:flex;position:relative;flex-grow:1}.compose-mail__recipients__container .recipients__input-field{flex-grow:1;min-width:180px;padding:.25rem .5rem;border:none;box-shadow:none;font-size:.85rem;outline:none}.compose-mail__recipients__container .recipients__input-field:focus+.recipients__input-list{display:flex}.compose-mail__recipients__container .recipients__input-list{z-index:10;position:absolute;top:2rem;padding:0;width:min(24rem,100vw - 3rem);max-width:100%;max-height:15rem;flex-direction:column;overflow:auto;display:none;background:#fff;border:1px solid #cbd5e1;border-radius:.5rem;box-shadow:0 10px 25px rgba(0,0,0,.1)}.compose-mail__recipients__container .recipients__input-list:hover{display:flex}.compose-mail__recipients__container .recipients__input-list li{list-style:none;padding:.5rem .75rem;cursor:pointer;background:#fff;border-bottom:1px solid #f1f5f9;font-size:.85rem}.compose-mail__recipients__container .recipients__input-list li:hover{background:#eff6ff;color:#0284c7}.compose-mail__recipients__container .recipients__input-list li:last-child{border-bottom:0px}.compose-mail input[type=text].compose-mail__subject{padding:.65rem .25rem;border:none;box-shadow:none;border-bottom:1px solid #e2e8f0;border-radius:0;font-size:.95rem;font-weight:600;outline:none}.compose-mail input[type=text].compose-mail__subject:focus{border-bottom-color:#0284c7}.compose-mail__message{margin:.5rem 0;flex:1;display:flex;flex-direction:column;overflow:auto}.compose-mail__message-body{flex:1;min-height:180px;outline:rgba(0,0,0,0);padding:.5rem;font-size:.95rem;line-height:1.5}.compose-mail .mail-compose-toolbar{display:flex;align-items:center;justify-content:space-between;padding:.5rem .75rem;background:#fff;border:1px solid #cbd5e1;border-top:1px solid #e2e8f0;border-radius:0 0 .375rem .375rem;position:relative}.compose-mail .mail-compose-toolbar .toolbar-left{display:flex;align-items:center;gap:.5rem}.compose-mail .mail-compose-toolbar .toolbar-divider{width:1px;height:22px;background:#cbd5e1;margin:0 .25rem}.compose-mail .mail-compose-toolbar button.mail-compose-send-btn{display:inline-flex !important;align-items:center !important;justify-content:center !important;gap:.5rem !important;padding:.5rem 1.25rem !important;border-radius:999px !important;background:#0284c7 !important;background-image:none !important;color:#fff !important;font-weight:600 !important;font-size:.9rem !important;border:none !important;cursor:pointer !important;box-shadow:0 2px 8px rgba(2,132,199,.28) !important;transition:all .2s ease !important}.compose-mail .mail-compose-toolbar button.mail-compose-send-btn i{font-size:.85rem}.compose-mail .mail-compose-toolbar button.mail-compose-send-btn:hover{background:#0369a1 !important;background-image:none !important;transform:translateY(-1px);box-shadow:0 4px 14px rgba(2,132,199,.38) !important}.compose-mail .mail-compose-toolbar button.mail-compose-send-btn:active{transform:translateY(0);box-shadow:0 2px 4px rgba(2,132,199,.2) !important}.compose-mail .mail-compose-toolbar button.mail-tool-btn{width:34px !important;height:34px !important;min-width:34px !important;max-width:34px !important;border-radius:50% !important;border:none !important;background:rgba(0,0,0,0) !important;background-image:none !important;box-shadow:none !important;outline:none !important;padding:0 !important;margin:0 !important;color:#64748b !important;display:inline-flex !important;align-items:center !important;justify-content:center !important;cursor:pointer !important;transition:background .15s ease,color .15s ease,transform .1s ease !important}.compose-mail .mail-compose-toolbar button.mail-tool-btn i{font-size:1.05rem;color:inherit}.compose-mail .mail-compose-toolbar button.mail-tool-btn:hover{background:#f1f5f9 !important;background-image:none !important;box-shadow:none !important;color:#0284c7 !important}.compose-mail .mail-compose-toolbar button.mail-tool-btn:active,.compose-mail .mail-compose-toolbar button.mail-tool-btn:focus{background:#f1f5f9 !important;background-image:none !important;box-shadow:none !important;outline:none !important}.compose-mail .mail-compose-toolbar button.mail-tool-btn.active{background:#e0f2fe !important;background-image:none !important;box-shadow:none !important;color:#0284c7 !important}.mobile-fab-compose{display:none}@media(max-width: 1024px){.mail-folders-pane{width:210px;min-width:200px}.mail-list-pane{width:340px;min-width:310px}}@media(max-width: 768px){.mail-mobile-nav-toggle{display:inline-flex !important}.mail-folders-pane{position:fixed;top:0;left:0;bottom:0;width:min(82vw,290px);max-width:85vw;z-index:1200;background:#fff;box-shadow:8px 0 28px rgba(15,23,42,.2);transform:translateX(-105%);transition:transform .25s cubic-bezier(0.16, 1, 0.3, 1)}.mail-folders-pane--open{transform:translateX(0)}.mail-list-pane{width:100% !important;min-width:0 !important;max-width:100% !important;border-right:none !important}.mail-list-pane--mobile-hidden{display:none !important}.table-pagination-container{overflow-x:hidden !important;width:100% !important;max-width:100% !important}.table-pagination-container table.mails{width:100% !important;max-width:100% !important;table-layout:fixed !important}.table-pagination-container table.mails col.col-attachments,.table-pagination-container table.mails th.col-attachments,.table-pagination-container table.mails td.cell-attachment,.table-pagination-container table.mails col.col-spacer,.table-pagination-container table.mails th.col-spacer,.table-pagination-container table.mails td.cell-spacer{display:none !important;width:0 !important;padding:0 !important}.table-pagination-container table.mails col.col-starred,.table-pagination-container table.mails th.col-starred,.table-pagination-container table.mails td.cell-star{width:30px !important;min-width:30px !important;max-width:30px !important;padding:.35rem .1rem !important;text-align:center !important}.table-pagination-container table.mails col.col-subject,.table-pagination-container table.mails th.col-subject,.table-pagination-container table.mails td.cell-subject{width:auto !important;min-width:0 !important;max-width:none !important;padding:.35rem .35rem .35rem .2rem !important}.table-pagination-container table.mails col.col-subject div,.table-pagination-container table.mails th.col-subject div,.table-pagination-container table.mails td.cell-subject div{gap:.3rem !important}.table-pagination-container table.mails col.col-subject .mobile-subject-clip,.table-pagination-container table.mails th.col-subject .mobile-subject-clip,.table-pagination-container table.mails td.cell-subject .mobile-subject-clip{display:inline-flex !important;align-items:center;font-size:.72rem;color:#94a3b8;flex-shrink:0}.table-pagination-container table.mails col.col-subject span,.table-pagination-container table.mails th.col-subject span,.table-pagination-container table.mails td.cell-subject span{font-size:.8rem !important}.table-pagination-container table.mails col.col-from,.table-pagination-container table.mails th.col-from,.table-pagination-container table.mails td.cell-from{width:78px !important;min-width:68px !important;max-width:88px !important;padding:.35rem .2rem !important}.table-pagination-container table.mails col.col-from .user-avatar,.table-pagination-container table.mails th.col-from .user-avatar,.table-pagination-container table.mails td.cell-from .user-avatar{display:none !important}.table-pagination-container table.mails col.col-from div,.table-pagination-container table.mails th.col-from div,.table-pagination-container table.mails td.cell-from div{gap:0 !important}.table-pagination-container table.mails col.col-from span,.table-pagination-container table.mails th.col-from span,.table-pagination-container table.mails td.cell-from span{font-size:.75rem !important}.table-pagination-container table.mails col.col-spam,.table-pagination-container table.mails th.col-spam,.table-pagination-container table.mails td.cell-spam{width:26px !important;min-width:26px !important;max-width:26px !important;padding:.35rem .1rem !important;text-align:center !important}.table-pagination-container table.mails col.col-spam button.spam-btn,.table-pagination-container table.mails th.col-spam button.spam-btn,.table-pagination-container table.mails td.cell-spam button.spam-btn{font-size:.75rem !important}.table-pagination-container table.mails col.col-date,.table-pagination-container table.mails th.col-date,.table-pagination-container table.mails td.cell-date{width:56px !important;min-width:50px !important;max-width:64px !important;padding:.35rem .35rem .35rem .1rem !important;font-size:.72rem !important;text-align:right !important}.table-pagination-container table.mails th{font-size:.68rem !important;padding-top:.45rem !important;padding-bottom:.45rem !important;letter-spacing:.02em !important;white-space:nowrap !important}.table-pagination-container table.mails th i.fas.fa-sort,.table-pagination-container table.mails th i.fas.fa-sort-up,.table-pagination-container table.mails th i.fas.fa-sort-down{font-size:.62rem !important;margin-left:.15rem !important}.table-pagination-container table.mails tr{height:38px !important}.table-pagination-container .pagination{padding:.5rem .75rem !important;gap:.5rem !important;font-size:.8rem !important}.mail-reading-pane{width:100% !important}.mail-reading-pane--mobile-hidden{display:none !important}.mail-reading-pane .mail-view-back-btn{display:inline-flex !important}.mobile-fab-compose{display:flex !important;position:fixed;right:1.25rem;bottom:calc(56px + env(safe-area-inset-bottom) + 1rem);width:52px;height:52px;border-radius:50%;background:#0284c7;color:#fff;align-items:center;justify-content:center;font-size:1.2rem;box-shadow:0 6px 18px rgba(2,132,199,.45);z-index:1050;border:none;cursor:pointer;transition:transform .15s ease}.mobile-fab-compose:active{transform:scale(0.92)}.composePopupOverlay .composePopup{top:calc(44px + env(safe-area-inset-top) + .5rem) !important;right:0 !important;bottom:calc(50px + env(safe-area-inset-bottom) + .5rem) !important;left:0 !important;width:calc(100% - 1rem) !important;height:auto !important;margin:0 auto !important}.composePopupOverlay .composePopup>.widget{padding:1rem}.composePopupOverlay .composePopup .close-btn{top:.75rem;right:.75rem}.msg-view-nav{display:flex;align-items:center;justify-content:space-between;padding:.5rem .75rem !important;gap:.35rem;position:sticky;top:0;background:#fff;border-bottom:1px solid #f1f5f9;z-index:10}.msg-view-nav__action{display:flex;align-items:center;gap:.25rem;flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch}.msg-view-nav__action::-webkit-scrollbar{display:none}.mail-action-btn{display:inline-flex !important;align-items:center !important;justify-content:center !important;width:36px !important;height:36px !important;min-width:36px !important;padding:0 !important;border:none !important;background:rgba(0,0,0,0) !important;box-shadow:none !important;border-radius:50% !important;color:#475569 !important;transition:all .15s ease}.mail-action-btn i{font-size:1.05rem !important;line-height:1 !important}.mail-action-btn .btn-text{display:none !important}.mail-action-btn:hover,.mail-action-btn:active{background:#f1f5f9 !important;color:#0f172a !important}.mail-action-btn.mail-action-btn--starred{color:#f59e0b !important;background:rgba(0,0,0,0) !important}.mail-action-btn.mail-action-btn--starred:hover,.mail-action-btn.mail-action-btn--starred:active{background:#fefce8 !important;color:#d97706 !important}.mail-action-btn.mail-action-btn--spam{color:#f97316 !important;background:rgba(0,0,0,0) !important}.mail-action-btn.mail-action-btn--spam:hover,.mail-action-btn.mail-action-btn--spam:active{background:#fff7ed !important;color:#ea580c !important}.mail-action-btn.mail-action-btn--delete{color:#ef4444 !important;background:rgba(0,0,0,0) !important}.mail-action-btn.mail-action-btn--delete:hover,.mail-action-btn.mail-action-btn--delete:active{background:#fef2f2 !important;color:#dc2626 !important}}@media(max-width: 480px){.mail-list-header{padding:.65rem .75rem}.msg-view__header{padding:1rem .75rem}.mail-reading-title-row h2.msg-view__title{font-size:1.2rem}.msg-view__body{padding:.75rem}.mail-body-container{padding:1rem}}.mail-cards-pagination{display:flex;align-items:center;justify-content:center;gap:1rem;padding:.75rem;border-top:1px solid #e2e8f0;color:#475569;font-size:.9rem}.mail-cards-pagination button{padding:.3rem .7rem;border:1px solid #cbd5e1;background:#f8fafc;color:#334155;border-radius:.375rem;box-shadow:none}.mail-cards-pagination button:disabled{opacity:.45;cursor:default}.file-view{width:100%;padding:1rem;margin-top:1.5rem;border-radius:8px;border:1px solid #ccc;animation:fadein .5s}.file-view__heading{display:flex;justify-content:space-between;margin-bottom:.5rem}.file-view__heading-chunk{display:flex;gap:1rem}.file-view__body{display:flex;flex-direction:column;gap:1rem}.file-view__body-details{display:flex;align-items:center}.file-view__body-details-stat{width:100%;display:grid;grid-template-columns:repeat(5, 1fr)}.file-view__body-details-stat span>i{margin-right:.5rem}.file-view__body-details-action{display:flex;gap:1rem;height:100%}.file-view__body-details-action button,.file-view__body-details-action button.red{padding:.25rem .75rem}table.myfiles td{word-wrap:break-word}table.myfiles th:nth-child(1){width:2%}table.myfiles th:nth-child(2){width:50%}table.myfiles td:nth-child(2){text-align:start}table.friendsfiles td{word-wrap:break-word}table.friendsfiles th:nth-child(1){width:1.5rem;padding-left:.25rem;padding-right:0}table.friendsfiles th:nth-child(2){width:50%;text-align:left;padding-left:.25rem}table.friendsfiles th:nth-child(4){width:40%}table.friendsfiles td:nth-child(2){text-align:start;padding-left:.25rem}table.friendsfiles td:nth-child(1){width:1.5rem;padding-left:.25rem;padding-right:0}.file-search-container{margin-top:1rem;padding:8px;display:flex;gap:8px;border:1px solid rgba(20,20,27,.2);border-radius:6px;height:100%;overflow:auto}.file-search-container__keywords{flex-basis:15%;padding-right:.25rem;border-right:1px solid rgba(20,20,27,.1)}.file-search-container__keywords .keywords-container{display:flex;flex-direction:column;border-top:2.5px solid rgba(20,20,27,.08);margin-top:.125rem;padding-top:.25rem}.file-search-container__keywords .keywords-container a{font-size:1.2rem;text-decoration:none;color:#14141b}.file-search-container__keywords .keywords-container a.selected{color:#019dff}.file-search-container__results{flex-basis:85%;height:100%;overflow:auto}.file-search-container__results .results-container .results-header tr{display:flex}.file-search-container__results .results-container .results-header tr th{font-size:1.25rem;font-weight:bold;text-align:left}.file-search-container__results .results-container .results-header tr th:nth-child(1){flex-basis:40%}.file-search-container__results .results-container .results-header tr th:nth-child(2){flex-basis:10%;text-align:center}.file-search-container__results .results-container .results-header tr th:nth-child(3){flex-basis:40%}.file-search-container__results .results-container .results-header tr th:nth-child(4){flex-basis:10%}.file-search-container__results .results-container .results{height:100%;overflow:auto}.file-search-container__results .results-container .results tr{display:flex}.file-search-container__results .results-container .results tr .results__hash,.file-search-container__results .results-container .results tr .results__name{text-align:left;flex-basis:40%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.file-search-container__results .results-container .results tr .results__hash span,.file-search-container__results .results-container .results tr .results__name span{margin-left:8px}.file-search-container__results .results-container .results tr .results__size{flex-basis:10%}.file-search-container__results .results-container .results tr .results__download{flex-basis:10%;display:flex;justify-content:start;align-items:center}.search-form{display:flex;width:40%}.search-form input{width:100%}.search-form button{margin-left:.5rem}.file-search-container{align-items:stretch;min-height:16rem;padding:1rem;background:#fff;box-shadow:0 1px 3px rgba(15,23,42,.06)}.file-search-container__keywords{flex:0 0 13rem;padding:0 1rem 0 0}.file-search-container__keywords .keywords-header{display:flex;align-items:center;justify-content:space-between;gap:.75rem}.file-search-container__keywords .keywords-header h5{margin:0;font-size:1rem}.file-search-container__keywords .clear-btn{padding:.35rem .7rem}.file-search-container__keywords .keywords-container a{padding:.45rem .55rem;border-radius:.35rem;font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-search-container__keywords .keywords-container a:hover,.file-search-container__keywords .keywords-container a.selected{background:rgba(0,154,235,.1);color:#019dff}.file-search-container__results{flex:1 1 auto;min-width:0;overflow:visible}.file-search-container__results>h5{margin:0;color:#64748b}.results-container{width:100%;border:1px solid #dbe3ec;border-radius:.5rem;overflow:hidden}.results-row{display:grid;grid-template-columns:minmax(12rem, 2fr) minmax(5.5rem, 0.6fr) minmax(12rem, 1.6fr) auto;gap:1rem;align-items:center}.results-header{background:#f1f5f9;border-bottom:1px solid #dbe3ec;color:#475569;font-size:.78rem;font-weight:700;letter-spacing:.02em;text-transform:uppercase}.results-header .results-row{padding:.65rem .85rem}.results-list .file-item{padding:.75rem .85rem;border-bottom:1px solid #edf2f7;transition:background-color .15s ease}.results-list .file-item:last-child{border-bottom:0}.results-list .file-item:hover{background:#f8fafc}.results-cell{min-width:0}.results-cell.name-col{display:flex;align-items:center;gap:.55rem;color:#0f172a;font-weight:600}.results-cell.name-col i{color:#0284c7;font-size:1.1rem}.results-cell.name-col span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.results-cell.size-col{color:#475569;white-space:nowrap}.results-cell.hash-col{overflow:hidden;color:#64748b;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.78rem;text-overflow:ellipsis;white-space:nowrap}.download-btn-v65{padding:.45rem .75rem;white-space:nowrap}.modal-content.add-file-modal{width:min(32rem,100% - 2rem);box-sizing:border-box}.add-file-dialog{display:flex;flex-direction:column;min-width:0;gap:.75rem}.add-file-dialog__heading{display:flex;align-items:center;padding-right:3rem;gap:.6rem}.add-file-dialog__heading h3{margin:0;white-space:nowrap}.add-file-dialog hr{width:100%;margin:0}.add-file-dialog label{color:#334155;font-weight:600}.add-file-dialog input[type=text]{width:100%;min-width:0;box-sizing:border-box}.add-file-dialog button[type=submit]{align-self:flex-end}.shareManagerPopupOverlay{position:fixed;width:100%;height:100%;top:0;left:0;z-index:1;background-color:rgba(0,0,0,.2);z-index:1200}.shareManagerPopupOverlay .shareManagerPopup{position:absolute;inset:0;margin:auto;width:80%;height:90%;max-width:72rem;box-sizing:border-box}.shareManagerPopupOverlay .shareManagerPopup>.widget{padding:1.5rem}.shareManagerPopupOverlay .shareManagerPopup .close-btn{position:absolute;top:1.5rem;right:1.5rem}.share-manager{display:flex;flex-direction:column;justify-content:space-between;min-height:0}.share-manager__table{margin:1rem 0 auto}.share-manager__table thead{font-weight:bold;text-align:left}.share-manager__table thead td:nth-child(1),.share-manager__table thead td:nth-child(2){padding-left:.5rem}.share-manager__table thead td:nth-child(3) .tooltip,.share-manager__table thead td:nth-child(4) .tooltip{font-weight:normal;font-size:1rem}.share-manager__table tbody{text-align:left}.share-manager__table tbody td:nth-child(4){font-size:1rem}.share-manager__table td input{border:0 !important}.share-manager__table td input[type=text]{width:100%}.share-manager__table td:nth-child(1){width:45%}.share-manager__table td:nth-child(2){width:20%}.share-manager__table td:nth-child(3){width:10%}.share-manager__table td:nth-child(4){width:25%}.share-manager__actions{display:flex;justify-content:space-between}.share-manager__empty td{padding:2rem 1rem;color:#64748b;text-align:center}.share-manager__form{display:flex;flex-direction:column;gap:.5rem}.share-manager__form_input{display:flex;flex-direction:column;gap:.5rem}.share-manager__form_input input{flex-grow:1}.share-manager .share-flags input.share-flags-check{display:none}.share-manager .share-flags input.share-flags-check+label.share-flags-label{color:gray;margin-right:.25rem;padding:.25rem .25rem .125rem;border:1px solid #6d6d6d;border-radius:.5rem}.share-manager .share-flags input.share-flags-check:checked+label.share-flags-label{color:#118fcc}.share-manager label span{display:inline-block;width:1.125rem}.manage-visibility label{width:100%;cursor:pointer}.manage-visibility{display:flex;justify-content:space-between}@media(max-width: 700px){.modal-content.add-file-modal{width:calc(100% - 1rem);min-height:0;padding:1rem}.modal-content.add-file-modal>.close-btn{top:.75rem;right:.75rem;padding:.55rem .75rem}.add-file-dialog{gap:.85rem}.add-file-dialog__heading{min-height:2.5rem}.add-file-dialog__heading h3{font-size:1.4rem}.add-file-dialog button[type=submit]{width:100%;min-height:2.75rem}.shareManagerPopupOverlay{height:100dvh;padding:.5rem;box-sizing:border-box;background-color:rgba(15,23,42,.55)}.shareManagerPopupOverlay .shareManagerPopup{position:relative;width:100%;height:100%;min-width:0;overflow:hidden;border-radius:.75rem;background:#fff}.shareManagerPopupOverlay .shareManagerPopup>.widget{min-width:0;padding:1rem;overflow:hidden}.shareManagerPopupOverlay .shareManagerPopup .close-btn{top:.85rem;right:.85rem;padding:.55rem .75rem}.shareManagerPopupOverlay .shareManagerPopup .widget__heading{min-height:2.5rem;padding-right:3rem}.shareManagerPopupOverlay .shareManagerPopup .widget__heading h3{overflow:hidden;font-size:1.45rem;text-overflow:ellipsis;white-space:nowrap}.share-manager{flex:1 1 auto;height:auto;overflow:hidden}.share-manager>blockquote.info{flex:0 0 auto;margin:.75rem 0 0;padding:.75rem .75rem .75rem 2rem;font-size:.9rem;line-height:1.35}.share-manager__table{flex:1 1 auto;min-height:0;margin:.75rem 0;padding:0;overflow-y:auto}.share-manager__actions{flex:0 0 auto;gap:.75rem;padding-top:.75rem;border-top:1px solid #e2e8f0}.share-manager__actions button{flex:1 1 0 !important;width:auto;min-height:2.75rem}.file-view__body-details{flex-direction:column;align-items:flex-start;gap:1rem}.file-view__body-details-stat{grid-template-columns:1fr;gap:.5rem}.file-view__body-details-stat span{display:flex;align-items:center}.share-manager__table,.share-manager__table thead,.share-manager__table tbody,.share-manager__table tr,.share-manager__table td{display:block;width:100% !important}.share-manager__table thead{display:none}.share-manager__table tr{border:1px solid #ccc;border-radius:8px;margin-bottom:1rem;padding:.5rem;background:#fff}.share-manager__table td{margin-bottom:.5rem;border:none !important;padding-left:0 !important}.share-manager__table tr:not(.share-manager__empty) td::before{display:block;margin-bottom:.2rem;color:#64748b;font-size:.72rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase}.share-manager__table tr:not(.share-manager__empty) td:nth-child(1)::before{content:"Shared directory"}.share-manager__table tr:not(.share-manager__empty) td:nth-child(2)::before{content:"Visible name"}.share-manager__table tr:not(.share-manager__empty) td:nth-child(3)::before{content:"Access"}.share-manager__table tr:not(.share-manager__empty) td:nth-child(4)::before{content:"Visibility"}.share-manager__table .share-manager__empty{display:table-row;margin:0;padding:0;border:0}.share-manager__table .share-manager__empty td{display:table-cell;padding:2rem 1rem !important}table.myfiles,table.myfiles tr,table.myfiles td,table.friendsfiles,table.friendsfiles tr,table.friendsfiles td{display:block;width:100% !important}table.myfiles th,table.friendsfiles th{display:none}table.myfiles tr,table.friendsfiles tr{border:1px solid #ccc;border-radius:8px;margin-bottom:1rem;padding:.5rem;background:#fff}table.friendsfiles tr{display:grid;grid-template-columns:1.25rem minmax(0, 1fr) auto;align-items:center;column-gap:.35rem;margin-bottom:.5rem;padding:.65rem .5rem}table.friendsfiles td{display:block;width:auto !important;margin:0;padding:0 !important;border:0 !important}table.friendsfiles td:nth-child(1){grid-column:1}table.friendsfiles td:nth-child(2){grid-column:2;left:0 !important;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}table.friendsfiles td:nth-child(3){grid-column:3;white-space:nowrap}table.friendsfiles td:nth-child(4){grid-column:2/-1;margin-top:.4rem}table.myfiles tr{display:grid;grid-template-columns:1.25rem minmax(0, 1fr) auto;align-items:center;column-gap:.35rem;margin-bottom:.5rem;padding:.65rem .5rem}table.myfiles td{display:block;width:auto !important;margin:0;padding:0 !important;border:0 !important}table.myfiles td:nth-child(1){grid-column:1}table.myfiles td:nth-child(2){grid-column:2;left:0 !important;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}table.myfiles td:nth-child(3){grid-column:3;white-space:nowrap}.my-files__configure-shares{width:2.4rem;min-width:2.4rem;padding:.55rem !important}.my-files__configure-shares span{display:none}.file-search-container{flex-direction:column}.file-search-container__keywords{flex-basis:auto;width:100%;border-right:none;border-bottom:1px solid rgba(20,20,27,.1);padding-bottom:1rem;margin-bottom:1rem}.results-container,.results-container thead,.results-container tbody,.results-container tr,.results-container td{display:block;width:100% !important}.results-container thead{display:none}.results-container tr{border-bottom:1px solid #eee;padding:1rem 0}.results-container td{margin-bottom:.5rem;word-break:break-all}.search-form{width:auto;flex:1;max-width:18rem}.file-search-container{gap:.75rem;padding:.75rem}.file-search-container__keywords{padding:0 0 .75rem;margin:0}.file-search-container__results{width:100%}.results-header{display:none}.results-list .file-item{display:grid;grid-template-columns:minmax(0, 1fr) auto;grid-template-areas:"name action" "size hash";gap:.45rem .75rem;padding:.8rem}.results-cell.name-col{grid-area:name}.results-cell.size-col{grid-area:size;font-size:.82rem}.results-cell.hash-col{grid-area:hash;max-width:10rem;text-align:right}.results-cell.action-col{grid-area:action}.download-btn-v65{padding:.4rem .6rem}}.file-section{margin-top:2rem;display:flex;flex-direction:column}.comments-section{margin-top:2rem;display:flex;justify-content:space-between}.comments-section__menu{display:flex;gap:1rem}.comments-section__menu-id{display:flex;align-items:center;gap:.25rem}#toggleunsub{position:relative;background:gray}table.channels th:nth-child(1){width:50%;text-align:start}table.channels td:nth-child(1){text-align:start;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}table.channels tr:hover{background-color:#eef3f6;cursor:pointer}table.channels tr.hidden{display:none}table{padding:.5rem}table.comments{border:1px solid #eee}table.comments th{height:40px}table.comments th:nth-child(1){width:2%}table.comments th:nth-child(2){width:40%}table.comments td{word-wrap:break-word}table.comments td:nth-child(2){text-align:start}table.files th:first-child{text-align:start;width:60%}table.files tr td:first-child{text-align:start}table.files td{word-wrap:break-word}.posts-container-card .channel-post__placeholder{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;color:#64748b;background:linear-gradient(135deg, #f8fafc, #dbe5f1)}.channel-post__placeholder i{font-size:1.35rem;color:#64748b}.channel-post__placeholder span{font-size:2rem;font-weight:700;color:#2563eb}.channel-post__placeholder small{font-size:.72rem;font-weight:600}.post-description{margin:1rem 0;padding:.85rem 1rem;border-radius:10px;background:#f1f5f9;color:#1e293b}.post-description__text{overflow-wrap:anywhere;line-height:1.5}.post-description__text>:first-child{margin-top:0}.post-description__text>:last-child{margin-bottom:0}.post-description__text--collapsed{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:3}.post-description__toggle{margin-top:.35rem;padding:0 !important;border:0 !important;box-shadow:none !important;background:rgba(0,0,0,0) !important;color:#0f172a !important;font-size:.85rem !important;font-weight:700 !important}.post-description__toggle:hover{color:#2563eb !important;text-decoration:underline}.create-channel-form{display:grid !important;grid-template-columns:11rem minmax(0, 1fr);gap:1rem 1.5rem !important}.create-channel-form__heading,.create-channel-form__title,.create-channel-form__description,.create-channel-form__submit{grid-column:1/-1}.create-channel-form__heading h3{margin:0;color:#1e293b}.create-channel-form__heading p{margin:.25rem 0 0;color:#64748b;font-size:.9rem}.create-channel-form__title{width:100%}.create-channel-form__thumbnail{grid-row:span 2;display:flex;flex-direction:column;align-items:center;gap:.5rem;padding:.75rem;border:1px solid #e2e8f0;border-radius:.65rem;background:#f8fafc}.create-channel-form__thumbnail-label,.create-channel-form__field label{color:#475569;font-size:.85rem;font-weight:700}.create-channel-form__file-input{position:absolute;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none}.create-channel-form__file-button{padding:.4rem .65rem;border:1px solid #0284c7;border-radius:.35rem;background:#0284c7;color:#fff;font-size:.8rem;font-weight:600;cursor:pointer}.create-channel-form__thumbnail small{color:#64748b;text-align:center}.create-channel-form__field{display:flex;flex-direction:column;gap:.35rem;min-width:0}.create-channel-form__field .config-style-select{width:100%;max-width:320px;min-width:0;box-sizing:border-box;padding:.4rem;border:1px solid #cbd5e1;border-radius:4px;background-color:#fff;color:#1e293b;font-size:.95rem}.create-channel-form__description{width:100%;min-height:7rem;resize:vertical}.create-channel-form__submit{justify-self:end}.modal-content.create-channel-modal{width:min(760px,100% - 2rem);max-height:calc(100% - 2rem);box-sizing:border-box;overflow-x:hidden;overflow-y:auto}.channel-thumbnail-preview{width:9rem;height:9rem;overflow:hidden;border:1px solid #cbd5e1;border-radius:.5rem;background:#f8fafc}.channel-thumbnail-preview img{width:100%;height:100%;display:block;object-fit:cover}.channel-thumbnail-preview__placeholder{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;background:linear-gradient(135deg, #eef2ff, #dbeafe);color:#64748b}.channel-thumbnail-preview__placeholder i{color:#3ba4d7;font-size:2rem}.channel-thumbnail-preview__placeholder span{color:#334155;font-weight:700}.channel-thumbnail-preview__placeholder small{font-size:.72rem}.channel-detail-default-thumbnail{display:flex;flex:0 0 6rem;align-items:center;justify-content:center;width:6rem;height:6rem;border:1px solid #cbd5e1;border-radius:4px;background:linear-gradient(135deg, #eef6fb, #dbeafe);color:#3ba4d7;box-sizing:border-box}.channel-detail-default-thumbnail i{font-size:2.75rem}.create-channel-post-form{display:grid !important;grid-template-columns:11rem minmax(0, 1fr);gap:1rem 1.5rem !important}.create-channel-post-form__heading,.create-channel-post-form__title,.create-channel-post-form__description,.create-channel-post-form__submit{grid-column:1/-1}.create-channel-post-form__heading h3{margin:0;color:#1e293b}.create-channel-post-form__heading p{margin:.25rem 0 0;color:#64748b;font-size:.9rem}.create-channel-post-form__title,.create-channel-post-form__description{width:100%;box-sizing:border-box}.create-channel-post-form__thumbnail{grid-row:span 2;display:flex;flex-direction:column;align-items:center;gap:.5rem;padding:.75rem;border:1px solid #e2e8f0;border-radius:.65rem;background:#f8fafc}.create-channel-post-form__thumbnail-label,.create-channel-post-form__attachments>label:first-child{color:#475569;font-size:.85rem;font-weight:700}.create-channel-post-form__file-input{position:absolute;width:1px;height:1px;overflow:hidden;opacity:0}.create-channel-post-form__file-button,.create-channel-post-form__attachment-button{display:inline-flex;align-items:center;justify-content:center;gap:.35rem;padding:.4rem .65rem;border:1px solid #0284c7;border-radius:.35rem;background:#0284c7;color:#fff;font-size:.8rem;font-weight:600;cursor:pointer}.create-channel-post-form__thumbnail small,.create-channel-post-form__attachments small{color:#64748b;text-align:center}.create-channel-post-form__attachments{display:flex;flex-direction:column;align-items:flex-start;align-self:start;gap:.5rem;min-width:0;padding:.8rem;border:1px solid #e2e8f0;border-radius:.5rem;background:#f8fafc}.create-channel-post-form__attachment-list{width:100%;max-height:9rem;overflow-y:auto;box-sizing:border-box;border:1px solid #cbd5e1;border-radius:.35rem;background:#fff}.create-channel-post-form__attachment-item{display:flex;align-items:center;gap:.55rem;padding:.45rem .55rem;border-bottom:1px solid #e2e8f0}.create-channel-post-form__attachment-item:last-child{border-bottom:0}.create-channel-post-form__attachment-item>i{flex:0 0 auto;color:#64748b}.create-channel-post-form__attachment-info{display:flex;flex-direction:column;min-width:0;flex:1}.create-channel-post-form__attachment-info span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#334155}.create-channel-post-form__attachment-info small{text-align:left}.create-channel-post-form__attachment-remove{flex:0 0 auto;padding:.25rem .4rem;border:0;box-shadow:none;background:rgba(0,0,0,0);color:#dc2626}.create-channel-post-form__attachment-remove:hover{background:#fee2e2}.create-channel-post-form__description{min-height:9rem;resize:vertical}.create-channel-post-form__submit{justify-self:end}.channel-post-thumbnail-preview{width:9rem;height:9rem;overflow:hidden;border:1px solid #cbd5e1;border-radius:.5rem;background:#f8fafc}.channel-post-thumbnail-preview img{width:100%;height:100%;display:block;object-fit:cover}.channel-post-thumbnail-preview__placeholder{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;background:linear-gradient(135deg, #eef2ff, #dbeafe);color:#64748b}.channel-post-thumbnail-preview__placeholder i{color:#3ba4d7;font-size:2rem}.channel-post-thumbnail-preview__placeholder span{color:#334155;font-weight:700;text-align:center}.channel-post-thumbnail-preview__placeholder small{font-size:.72rem}.modal-content.create-channel-post-modal{width:min(760px,100% - 2rem);max-height:calc(100% - 2rem);box-sizing:border-box;overflow-x:hidden;overflow-y:auto}.posts>.posts__heading.channel-posts-heading{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;width:100%;gap:1rem;flex-wrap:wrap;text-align:left}.posts>.posts__heading.channel-posts-heading h3{margin:0;text-align:left}.channel-posts-heading__create{display:inline-flex;align-items:center;gap:.35rem}@media(max-width: 600px){.modal-content.create-channel-modal{width:calc(100% - 1rem);max-height:calc(100% - 1rem);padding:1rem}.create-channel-form{width:100%;min-width:0;padding:0;overflow:visible;grid-template-columns:1fr}.create-channel-form__heading,.create-channel-form__title,.create-channel-form__thumbnail,.create-channel-form__field,.create-channel-form__description,.create-channel-form__submit{grid-column:1}.create-channel-form__thumbnail{grid-row:auto}.create-channel-form__thumbnail input[type=file]{max-width:100%}.create-channel-form__field .config-style-select{max-width:100%}.create-channel-form__description{box-sizing:border-box}.create-channel-form__submit{width:100%}.channel-thumbnail-preview{width:8rem;height:8rem}.modal-content.create-channel-post-modal{width:calc(100% - 1rem);max-height:calc(100% - 1rem);padding:1rem}.create-channel-post-form{width:100%;min-width:0;padding:0;grid-template-columns:minmax(0, 1fr)}.create-channel-post-form__heading,.create-channel-post-form__title,.create-channel-post-form__thumbnail,.create-channel-post-form__attachments,.create-channel-post-form__description,.create-channel-post-form__submit{grid-column:1}.create-channel-post-form__thumbnail{grid-row:auto}.create-channel-post-form__attachments{align-items:stretch;box-sizing:border-box}.create-channel-post-form__submit{width:100%}.channel-post-thumbnail-preview{width:8rem;height:8rem}.channel-posts-heading__create span{display:none}.channel-posts-heading__create{min-width:2.25rem;justify-content:center;padding-inline:.55rem}}.posts-container{align-content:start;grid-auto-rows:240px}.posts-container-card{position:relative;display:flex;align-self:start;height:240px;min-height:0;overflow:hidden}.posts-container-card>img,.posts-container-card>.channel-post__placeholder{min-height:0}.channel-post-comment-badge{position:absolute;z-index:2;top:.5rem;right:.5rem;display:inline-flex;align-items:center;gap:.3rem;min-width:1.75rem;min-height:1.75rem;padding:.25rem .45rem;box-sizing:border-box;color:#0f172a;background:hsla(0,0%,100%,.94);border:1px solid rgba(148,163,184,.7);border-radius:999px;box-shadow:0 2px 6px rgba(15,23,42,.22);font-size:.75rem;font-weight:700;pointer-events:none}.channel-post-comment-badge i{color:#3ba4d7}@media(max-width: 700px){.posts-container{grid-auto-rows:210px;gap:1rem}.posts-container-card{height:210px}}@media(max-width: 700px){.file-section{margin-top:1.25rem}table.channel-files,table.channel-files tbody,table.channel-files tr,table.channel-files td{display:block;width:100% !important;box-sizing:border-box}table.channel-files{padding:0;table-layout:auto}table.channel-files thead{display:none}table.channel-files tr{margin:0 0 .7rem;padding:.7rem;border:1px solid #dbe3ef;border-radius:8px;background:#fff}table.channel-files td{min-width:0;padding:.25rem 0;border:0;text-align:left}table.channel-files td::before{display:block;margin-bottom:.1rem;color:#64748b;content:attr(data-label);font-size:.72rem;font-weight:700;text-transform:uppercase}table.channel-files .channel-file__name{overflow-wrap:anywhere;color:#0f172a;font-weight:600;line-height:1.35}table.channel-files .channel-file__size{color:#475569}table.channel-files .channel-file__action{padding-top:.5rem}table.channel-files .channel-file__action>button{min-width:116px}table.channel-files .channel-file__action .file-view{margin-top:.6rem}}#mtags{width:160px;text-align:center;font-size:medium;margin-left:10px;height:40px}.channel-detail-navigation{display:flex;align-items:center}.channel-mobile-search,.channel-mobile-actions,.channel-mobile-create{display:none}@media(max-width: 700px){.tab-content:has(.channels-detail-widget){position:relative}.widget.channels-detail-widget{display:grid;grid-template-columns:minmax(0, 1fr);grid-template-rows:36px auto minmax(0, 1fr);row-gap:4px}.widget.channels-detail-widget>.top-heading{display:none}.widget.channels-detail-widget>.widget__body{min-height:0}.widget.channels-detail-widget .channel-subscription--subscribed,.widget.channels-detail-widget .posts>.channel-posts-heading{display:none}.widget.channels-detail-widget .posts{margin-top:9px;min-height:0}.widget.channels-detail-widget .posts-container{padding:.5rem}.channel-detail-navigation{justify-content:space-between;gap:.5rem}a.channel-back[title=Back]{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;flex-shrink:0;border-radius:50%;background:#e8f4fc;color:#0788cb;font-size:1.15rem}a.channel-back[title=Back]:hover{background:#d5ebfa}.channel-back .fa-arrow-left::before{content:""}.channel-mobile-search{display:flex;align-items:center;justify-content:flex-end;gap:.5rem;min-width:0;flex:1}.channel-mobile-search input{min-width:0;max-width:100%}.channel-mobile-create{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;font-size:.85rem}.channel-mobile-actions{display:block;position:absolute;top:0;right:.5rem;z-index:1001}.channel-mobile-actions summary{display:flex;align-items:center;justify-content:center;width:44px;height:44px;cursor:pointer;list-style:none}.channel-mobile-actions summary::-webkit-details-marker{display:none}.channel-mobile-actions summary:focus-visible{outline:2px solid #0788cb}.channel-mobile-actions__items{position:absolute;right:0;top:100%;min-width:10rem;padding:.35rem;background:#fff;border:1px solid #cbd5e1;border-radius:.375rem;box-shadow:0 4px 12px rgba(15,23,42,.15)}.channel-mobile-actions__items button{width:100%;min-height:44px;text-align:left;background:#fff;color:#334155;box-shadow:none}.channel-mobile-actions__items button:hover{background:#f1f5f9}}.channels-heading-create{display:none}@media(max-width: 700px){.channels-create-button{display:none}.channels-heading-create{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;font-size:.85rem}}.forums-node-panel{position:relative;bottom:200px;margin-left:200px;animation:fadein .5s}.forum-thread-view{width:100%;min-width:0;box-sizing:border-box}.forum-post-content{max-width:100%;box-sizing:border-box;overflow-wrap:anywhere}.forum-post-content img,.forum-post-content video,.forum-post-content iframe{max-width:100%;height:auto}.forum-post-content pre{max-width:100%;overflow:auto;white-space:pre-wrap}.forum-post-content table{display:block;max-width:100%;overflow-x:auto}table.forums th:nth-child(1){width:50%;text-align:start}table.forums td:nth-child(1){text-align:start;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}table.forums tr:hover{background-color:#eef3f6;cursor:pointer}table.forums tr.hidden{display:none}#searchforum{position:relative;margin-left:250px}#forumdetails{position:relative;padding:10px}.forum-detail-navigation{display:flex;align-items:center}.forum-mobile-search,.forum-mobile-actions,.forum-mobile-create,.forums-heading-create{display:none}.forum-detail-heading{margin-top:.75rem}.forum-detail-heading h3{margin:0}.forum-detail-default-thumbnail{display:flex;flex:0 0 6rem;align-items:center;justify-content:center;width:6rem;height:6rem;border:1px solid #cbd5e1;border-radius:4px;background:linear-gradient(135deg, #eef6fb, #dbeafe);color:#3ba4d7;box-sizing:border-box}.forum-detail-default-thumbnail i{font-size:2.75rem}.forum-threads{margin-top:1rem}.forum-threads__heading{display:flex;align-items:center;justify-content:flex-start;gap:1rem;padding-bottom:.35rem;border-bottom:1px solid #d7dee8}.forum-threads__heading h3{margin:0}.forum-threads__create{display:inline-flex;align-items:center;gap:.35rem}.p{margin:0}#toggleunsub{position:relative;background:gray}table.threads{width:100%;border-collapse:collapse}table.threads tr.forum-thread-row{border-bottom:1px solid #f1f5f9;cursor:pointer;transition:background-color .15s ease}table.threads tr.forum-thread-row:hover{background-color:#f8fafc}table.threads tr.forum-thread-row.forum-thread-row--unread{background-color:#f0f9ff}table.threads tr.forum-thread-row.forum-thread-row--unread .forum-thread-row__title{font-weight:700;color:#0369a1}table.threads .forum-thread-row__cell{padding:.65rem .5rem;text-align:left !important}table.threads .forum-thread-row__title{font-size:1.05rem;font-weight:600;color:#1e293b;margin-bottom:.25rem;word-break:break-word;line-height:1.3}table.threads .forum-thread-row__meta{display:flex;align-items:center;flex-wrap:wrap;gap:.35rem;font-size:.82rem;color:#64748b}table.threads .forum-thread-row__author{font-style:normal;color:#475569;font-weight:500}table.threads .forum-thread-row__bullet{color:#cbd5e1;font-size:.75rem}table.threads .forum-thread-row__date{color:#94a3b8}table.threads td{word-wrap:break-word}@media(max-width: 700px){.tab-content:has(.forums-detail-widget),.tab-content:has(.forums-thread-widget){position:relative}.widget.forums-detail-widget{display:flex;flex-direction:column;row-gap:4px}.widget.forums-detail-widget>.top-heading{display:none}.widget.forums-detail-widget .forum-subscription--subscribed,.widget.forums-detail-widget .forum-threads>.forum-threads__heading{display:none}.widget.forums-detail-widget .forum-threads{margin-top:6px;min-height:0}.widget.forums-thread-widget>.top-heading{display:none}.forum-detail-navigation{justify-content:space-between;gap:.5rem}a.forum-back[title=Back]{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;flex-shrink:0;border-radius:50%;background:#e8f4fc;color:#0788cb;font-size:1.15rem;text-decoration:none;cursor:pointer}a.forum-back[title=Back]:hover{background:#d5ebfa}a.forum-back[title=Back] .fa-arrow-left::before{content:""}.forum-mobile-search{display:flex;align-items:center;min-width:0;flex:1;margin-right:44px}.forum-mobile-search input{min-width:0;width:100%;height:36px;padding:0 .75rem;border:1px solid #cbd5e1;border-radius:.375rem;font-size:.85rem;background:#fff;box-sizing:border-box}.forum-mobile-search input:focus{border-color:#0788cb;outline:none}.forum-mobile-create{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;font-size:.85rem;border-radius:.375rem;border:0;background:#0788cb;color:#fff;cursor:pointer}.forum-mobile-create:hover{background:#0672aa}.forum-mobile-actions{display:block;position:absolute;top:0;right:.5rem;z-index:1001}.forum-mobile-actions summary{display:flex;align-items:center;justify-content:center;width:44px;height:44px;cursor:pointer;list-style:none}.forum-mobile-actions summary::-webkit-details-marker{display:none}.forum-mobile-actions summary:focus-visible{outline:2px solid #0788cb}.forum-mobile-actions__items{position:absolute;right:0;top:100%;min-width:10rem;padding:.35rem;background:#fff;border:1px solid #cbd5e1;border-radius:.375rem;box-shadow:0 4px 12px rgba(15,23,42,.15)}.forum-mobile-actions__items button{width:100%;min-height:44px;text-align:left;background:#fff;color:#334155;box-shadow:none;border:0;padding:0 .75rem;border-radius:.25rem;cursor:pointer}.forum-mobile-actions__items button:hover{background:#f1f5f9}.forum-detail-heading{display:flex;align-items:center;justify-content:space-between;gap:.5rem;margin-top:.25rem;margin-bottom:.25rem;padding-bottom:.25rem;border-bottom:1px solid #e2e8f0}.forum-detail-heading h3{font-size:1.2rem;margin:0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}table.threads .forum-thread-row__cell{padding:.4rem .25rem;text-align:left !important}table.threads .forum-thread-row__title{font-size:.92rem;margin-bottom:.15rem}table.threads .forum-thread-row__meta{font-size:.76rem;gap:.25rem}#searchforum{margin-left:0;width:100%}.forums-create-button{display:none}.forums-heading-create{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;font-size:.85rem;border-radius:.375rem;border:0;background:#0788cb;color:#fff;cursor:pointer}}table.threadreply th:nth-child(2){width:50%}table.threadreply th:nth-child(1){width:2%}table.threadreply td:nth-child(2){width:50%;text-align:start}table.threadreply td{word-wrap:break-word}table.threadreply tr:hover{background-color:#eef3f6;cursor:pointer}.create-forum-form{display:grid !important;grid-template-columns:repeat(2, minmax(0, 1fr));gap:1rem 1.5rem !important}.create-forum-form__heading,.create-forum-form__title,.create-forum-form__moderators,.create-forum-form__description,.create-forum-form__submit{grid-column:1/-1}.create-forum-form__heading h3{margin:0;color:#1e293b}.create-forum-form__heading p{margin:.25rem 0 0;color:#64748b;font-size:.9rem}.create-forum-form__title,.create-forum-form__description{width:100%;box-sizing:border-box}.create-forum-form__description{min-height:7rem;resize:vertical}.create-forum-form__field{display:flex;flex-direction:column;gap:.35rem;min-width:0}.create-forum-form__field label,.create-forum-form__moderators-heading label{color:#475569;font-size:.85rem;font-weight:700}.create-forum-form__field .config-style-select{width:100%;min-width:0;box-sizing:border-box;padding:.4rem;border:1px solid #cbd5e1;border-radius:4px;background:#fff;color:#1e293b;font-size:.95rem}.create-forum-form__moderators{min-width:0}.create-forum-form__moderators-heading{display:flex;justify-content:space-between;margin-bottom:.4rem}.create-forum-form__moderators-heading span{color:#64748b;font-size:.8rem}.create-forum-form__moderators-toggle{display:inline-flex;align-items:center;gap:.45rem;cursor:pointer}.create-forum-form__moderators-toggle input{margin:0}.create-forum-form__moderator-controls{display:flex;flex-direction:column;gap:.4rem}.create-forum-form__moderator-controls>.config-style-select{width:100%;box-sizing:border-box;padding:.4rem;border:1px solid #cbd5e1;border-radius:4px;background:#fff}.create-forum-form__search{position:relative}.create-forum-form__search i{position:absolute;left:.65rem;top:50%;transform:translateY(-50%);color:#94a3b8}.create-forum-form__search input{width:100%;box-sizing:border-box;padding-left:2rem}.create-forum-form__moderator-list{max-height:13rem;overflow-y:auto;border:1px solid #cbd5e1;border-radius:4px;background:#fff}.create-forum-form__moderator{display:flex;align-items:center;gap:.65rem;padding:.45rem .65rem;border-bottom:1px solid #e2e8f0;cursor:pointer}.create-forum-form__moderator:last-child{border-bottom:0}.create-forum-form__moderator:hover{background:#f1f5f9}.create-forum-form__moderator>span{display:flex;flex-direction:column;min-width:0}.create-forum-form__moderator>.jdenticon-avatar,.create-forum-form__moderator>.defaultAvatar,.create-forum-form__moderator>img.avatar{flex:0 0 30px;margin-right:0}.create-forum-form__moderator b{color:#1e293b;font-size:.9rem}.create-forum-form__moderator small{color:#64748b;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.create-forum-form__empty{padding:1rem;color:#64748b;text-align:center}.create-forum-form__submit{justify-self:end}.modal-content.create-forum-modal{width:min(720px,100% - 2rem);max-height:calc(100% - 2rem);box-sizing:border-box;overflow-x:hidden;overflow-y:auto}@media(max-width: 600px){.modal-content.create-forum-modal{width:calc(100% - 1rem);max-height:calc(100% - 1rem);padding:1rem}.create-forum-form{grid-template-columns:minmax(0, 1fr)}.create-forum-form__heading,.create-forum-form__title,.create-forum-form__field,.create-forum-form__moderators,.create-forum-form__description,.create-forum-form__submit{grid-column:1}.create-forum-form__moderator-list{max-height:10rem}.create-forum-form__submit{width:100%}}.forum-thread-composer{width:min(720px,100%);height:auto;box-sizing:border-box;gap:.85rem}.forum-thread-composer__heading{padding-right:5.5rem}.forum-thread-composer__heading-copy{min-width:0}.forum-thread-composer__heading h3{margin:0;color:#1e293b}.forum-thread-composer__heading p{margin:.25rem 0 0;color:#64748b;font-size:.9rem}.forum-thread-composer__fullscreen{position:absolute;z-index:2;top:1.25rem;right:4.25rem;display:flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;margin:0;padding:0;border:0;border-radius:4px;background:#f1f5f9;color:#475569;box-shadow:none}.forum-thread-composer__fullscreen:hover{background:#e0f2fe;color:#0284c7}.forum-thread-composer__reply{padding:.6rem .75rem;border-radius:4px;background:#f1f5f9;color:#475569}.forum-thread-composer__title{width:100%;box-sizing:border-box}.forum-thread-composer__field{display:flex;flex-direction:column;gap:.35rem}.forum-thread-composer__field label{color:#475569;font-size:.85rem;font-weight:700}.forum-thread-composer__field select{width:100%;box-sizing:border-box;padding:.45rem;border:1px solid #cbd5e1;border-radius:4px;background:#fff}.forum-thread-composer__editor{position:relative}.forum-thread-composer__editor textarea{display:block;width:100%;min-height:8rem;padding:.75rem;border-radius:4px 4px 0 0;box-sizing:border-box;resize:vertical}.forum-thread-composer__toolbar{position:relative;display:flex;gap:.25rem;padding:.4rem .5rem;border:1px solid #cbd5e1;border-top:0;border-radius:0 0 4px 4px;background:#f8fafc}.forum-thread-composer__toolbar>input[type=file]{position:absolute;width:1px;height:1px;opacity:0;overflow:hidden}.forum-thread-composer__tool{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;padding:0;border:0;border-radius:50%;background:rgba(0,0,0,0);color:#0284c7;cursor:pointer;box-shadow:none;box-sizing:border-box}.forum-thread-composer__tool:hover,.forum-thread-composer__tool.active{background:#e0f2fe}.forum-thread-composer__emoji-picker{position:absolute;left:2.75rem;bottom:3rem;z-index:10;width:min(22rem,100vw - 4rem);padding:.5rem;border:1px solid #cbd5e1;border-radius:8px;background:#fff;box-shadow:0 10px 25px rgba(15,23,42,.18)}.forum-thread-composer__emoji-categories{display:flex;gap:.15rem;overflow-x:auto;padding-bottom:.35rem;border-bottom:1px solid #e2e8f0}.forum-thread-composer__emoji-categories button,.forum-thread-composer__emoji-grid button{border:0;background:rgba(0,0,0,0);box-shadow:none;cursor:pointer;color:#0f172a}.forum-thread-composer__emoji-categories button{flex:0 0 2rem;padding:.3rem;border-radius:4px}.forum-thread-composer__emoji-categories button.active{background:#e0f2fe}.forum-thread-composer__emoji-grid{display:grid;grid-template-columns:repeat(8, 1fr);max-height:12rem;overflow-y:auto;padding-top:.4rem}.forum-thread-composer__emoji-grid button{padding:.3rem;font-size:1.15rem}.forum-thread-composer__file-panel{display:flex;flex-direction:column;gap:.35rem;padding:.65rem;border:1px solid #cbd5e1;border-top:0;background:#f8fafc}.forum-thread-composer__file-panel>div{display:flex;gap:.4rem;min-width:0}.forum-thread-composer__file-panel input{flex:1 1 auto;min-width:0;box-sizing:border-box}.forum-thread-composer__file-panel label{display:flex;flex:0 0 2.4rem;align-items:center;justify-content:center;border:1px solid #cbd5e1;border-radius:4px;background:#fff;color:#0284c7;cursor:pointer}.forum-thread-composer__file-panel button{flex:0 0 auto}.forum-thread-composer__file-panel small{color:#64748b}.forum-thread-composer__file-panel small.error-text{color:#dc2626}.forum-thread-composer__inline-images{display:flex;flex-direction:column;gap:.65rem;padding:.75rem;border:1px solid #cbd5e1;border-top:0;background:#fff}.forum-thread-composer__inline-image{position:relative;align-self:flex-start;max-width:100%}.forum-thread-composer__inline-image img{display:block;max-width:100%;max-height:12rem;border-radius:6px;object-fit:contain}.forum-thread-composer__inline-image button{position:absolute;top:.4rem;right:.4rem;display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;padding:0;border:0;border-radius:50%;background:rgba(15,23,42,.78);color:#fff;box-shadow:none}.forum-thread-composer__attachments{padding:.65rem;border:1px solid #d7dee8;border-radius:6px;background:#f8fafc}.forum-thread-composer__attachments-heading{display:flex;align-items:center;gap:.4rem;margin-bottom:.5rem;color:#475569;font-size:.85rem;font-weight:700}.forum-thread-composer__attachment-list{display:flex;flex-wrap:wrap;gap:.5rem;max-height:6rem;overflow-y:auto}.forum-thread-composer__attachment{display:flex;align-items:center;gap:.5rem;max-width:18rem;padding:.35rem .5rem;border:1px solid #cbd5e1;border-radius:6px;background:#fff}.forum-thread-composer__attachment>img{width:2rem;height:2rem;border-radius:4px;object-fit:cover}.forum-thread-composer__attachment>i{width:2rem;color:#3b82f6;text-align:center}.forum-thread-composer__attachment>span{display:flex;flex-direction:column;min-width:0}.forum-thread-composer__attachment b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.82rem}.forum-thread-composer__attachment small{color:#64748b}.forum-thread-composer__attachment button{margin-left:auto;padding:.25rem;border:0;background:rgba(0,0,0,0);color:#ef4444;box-shadow:none}.forum-thread-composer__capacity{color:#64748b;font-size:.78rem;text-align:right}.forum-thread-composer__capacity.is-over-limit{color:#dc2626;font-weight:700}.forum-thread-composer__actions{display:flex;justify-content:flex-end}.modal-content.create-forum-thread-modal{width:min(720px,100% - 2rem);height:fit-content;max-height:calc(100% - 2rem);min-height:0;padding:1.25rem;box-sizing:border-box;overflow:hidden}.modal-content.create-forum-thread-modal>.forum-thread-composer{flex:0 1 auto;height:auto;max-height:calc(100vh - 4.5rem);min-height:0;padding-right:.25rem;overflow-x:hidden;overflow-y:auto}.modal-content.create-forum-thread-modal:not(.is-fullscreen){height:fit-content !important}.modal-content.create-forum-thread-modal:not(.is-fullscreen)>.forum-thread-composer{flex-grow:0;flex-shrink:1;align-self:stretch}.modal-content.create-forum-thread-modal.is-fullscreen{width:min(1100px,100% - 3rem);height:min(760px,100vh - 3rem);max-width:1100px;max-height:calc(100vh - 3rem);min-height:0;overflow:hidden}.modal-content.create-forum-thread-modal.is-fullscreen>.forum-thread-composer{width:100%;height:auto;max-height:100%;min-height:0;box-sizing:border-box;overflow-x:hidden;overflow-y:auto}.modal-content.create-forum-thread-modal.is-fullscreen .forum-thread-composer__title,.modal-content.create-forum-thread-modal.is-fullscreen .forum-thread-composer__field,.modal-content.create-forum-thread-modal.is-fullscreen .forum-thread-composer__editor,.modal-content.create-forum-thread-modal.is-fullscreen .forum-thread-composer__toolbar,.modal-content.create-forum-thread-modal.is-fullscreen .forum-thread-composer__attachments,.modal-content.create-forum-thread-modal.is-fullscreen .forum-thread-composer__inline-images{width:100%;box-sizing:border-box}.modal-content.create-forum-thread-modal.is-fullscreen .forum-thread-composer__editor textarea{width:100%;height:clamp(14rem,100vh - 25rem,28rem);min-height:14rem;max-height:28rem;box-sizing:border-box}@media(max-width: 600px){.modal-content.create-forum-thread-modal{width:calc(100% - 1rem);height:auto;max-height:calc(100% - 1rem);padding:1rem;overflow-y:auto}.modal-content.create-forum-thread-modal>.forum-thread-composer{width:100%;height:auto;max-height:calc(100vh - 3rem);padding:0;overflow-y:visible}.forum-thread-composer__fullscreen{display:none}.forum-thread-composer__editor textarea{min-height:7rem}.forum-thread-composer__emoji-picker{left:0;width:calc(100vw - 3rem);box-sizing:border-box}.forum-thread-composer__emoji-grid{grid-template-columns:repeat(6, 1fr)}.forum-thread-composer__file-panel>div{flex-wrap:wrap}.forum-thread-composer__file-panel input{flex-basis:calc(100% - 3rem)}.forum-thread-composer__file-panel button{flex:1 1 100%}.forum-thread-composer__attachment{max-width:100%;flex:1 1 100%}.forum-thread-composer__actions button{width:100%}}@media(max-width: 768px){.forum-detail-heading+.media-item .media-item__desc{display:block !important}}#popupmessage{position:fixed;top:0;left:0;right:0;bottom:0;width:100vw;height:100vh;height:100dvh;background-color:rgba(15,23,42,.75);backdrop-filter:blur(4px);z-index:999999;display:none;align-items:center;justify-content:center;box-sizing:border-box}.popup{position:fixed;top:50%;left:50%;transform:translate(-50%, -50%);z-index:1000000;max-width:90vw;max-height:90vh;display:flex;flex-direction:column;box-sizing:border-box}.popup-content{position:relative;background:#fff;border-radius:12px;padding:1rem;box-shadow:0 20px 25px -5px rgba(0,0,0,.4),0 10px 10px -5px rgba(0,0,0,.2);max-width:90vw;max-height:90vh;overflow:auto;box-sizing:border-box}.popup-content span.close{position:absolute;top:.5rem;right:.75rem;font-size:1.75rem;font-weight:700;color:#64748b;cursor:pointer;line-height:1;z-index:10;transition:color .15s ease}.popup-content span.close:hover{color:#ef4444}.board-view-container{display:flex;flex-direction:column;gap:1rem;width:100%;max-width:100%;overflow-x:hidden;padding:.5rem 0;box-sizing:border-box}.board-table{width:100%;border-collapse:collapse}.board-table th,.board-table td{text-align:left;padding:.65rem .85rem}.board-toolbar{display:flex;flex-direction:row;align-items:center;justify-content:space-between;gap:1rem;width:100%;padding:.5rem .85rem;background-color:#fff;border:1px solid #cbd5e1;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.05);box-sizing:border-box}.board-toolbar__left{display:flex;flex-direction:row;align-items:center;gap:.75rem;flex:1;min-width:0}.board-toolbar__right{display:flex;flex-direction:row;align-items:center;gap:.65rem;flex-shrink:0}.board-toolbar__count-badge{display:inline-flex;align-items:center;gap:.4rem;padding:.3rem .65rem;background-color:#f1f5f9;color:#475569;font-size:.825rem;font-weight:600;border-radius:16px;border:1px solid #e2e8f0;white-space:nowrap}.board-toolbar__count-badge i{color:#007bff}.board-toolbar__search{position:relative;display:flex;align-items:center;flex:1;max-width:380px;min-width:160px}.board-toolbar__search-icon{position:absolute;left:.75rem;color:#94a3b8;font-size:.85rem;pointer-events:none}input.board-toolbar__search-input{width:100%;padding:.35rem .65rem .35rem 2.1rem;border:1px solid #cbd5e1;border-radius:6px;font-size:.85rem;background-color:#fff;color:#1e293b}input.board-toolbar__search-input:focus{outline:none;border-color:#007bff;box-shadow:0 0 0 3px rgba(0,123,255,.15)}.board-toolbar__voter{display:inline-flex;align-items:center;gap:.4rem;color:#475569;font-size:.8rem;font-weight:600;white-space:nowrap}.board-toolbar__voter select{max-width:180px;min-width:110px;padding:.3rem .45rem;border:1px solid #cbd5e1;border-radius:6px;background:#fff;color:#1e293b;font-size:.8rem}.board-toolbar__voter select:focus{outline:none;border-color:#007bff;box-shadow:0 0 0 3px rgba(0,123,255,.15)}.board-toolbar__view-toggle{display:inline-flex;flex-direction:row;align-items:center;background-color:#f1f5f9;padding:2px;border-radius:6px;border:1px solid #cbd5e1}.board-toolbar__toggle-btn{display:inline-flex;align-items:center;justify-content:center;gap:.35rem;padding:.3rem .65rem;border:none;background:rgba(0,0,0,0);color:#64748b;font-size:.825rem;font-weight:500;border-radius:4px;cursor:pointer;white-space:nowrap;transition:all .2s ease}.board-toolbar__toggle-btn i{font-size:.85rem}.board-toolbar__toggle-btn:hover{color:#1e293b;background-color:hsla(0,0%,100%,.6)}.board-toolbar__toggle-btn--active,.board-toolbar__toggle-btn--active:hover{background-color:#007bff;color:#fff;font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.1)}.board-toolbar__toggle-btn--active i{color:#fff}@media(max-width: 768px){.board-toolbar{flex-wrap:wrap}.board-toolbar__left{flex-basis:100%}.board-toolbar__toggle-btn span{display:none}.board-toolbar__toggle-btn{padding:.35rem .55rem}}.board-post-voting{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin:.85rem 0;padding:.65rem .75rem;background:#f8fafc;border:1px solid #e2e8f0;border-radius:.5rem}.board-post-voting__identity{display:flex;align-items:center;gap:.5rem;color:#64748b;font-size:.8rem;font-weight:600}.board-post-voting__identity select{max-width:220px;padding:.3rem .45rem;font-size:.8rem}.board-post-voting__buttons{display:flex;align-items:center;gap:.35rem}.board-post-voting__buttons button{min-width:52px;padding:.3rem .55rem;box-shadow:none}.board-post-voting__score{min-width:2rem;color:#334155;font-weight:700;text-align:center}@media(max-width: 560px){.board-post-voting{align-items:stretch;flex-direction:column}.board-post-voting__identity select{flex:1;min-width:0;max-width:none}.board-post-voting__buttons{justify-content:center}.board-card__notes-btn span{display:none}}.board-pagination{display:inline-flex;flex-direction:row;align-items:center;gap:.25rem;background-color:#fff;padding:2px 4px;border-radius:6px;border:1px solid #cbd5e1}.board-pagination__btn{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;min-width:26px;min-height:26px;padding:0;margin:0;border:1px solid #cbd5e1;border-radius:4px;background:#fff;color:#007bff;font-size:.825rem;cursor:pointer;transition:all .15s ease}.board-pagination__btn i{font-size:.825rem;color:#007bff}.board-pagination__btn:hover:not(:disabled){background-color:#007bff;color:#fff;border-color:#007bff}.board-pagination__btn:hover:not(:disabled) i{color:#fff}.board-pagination__btn:disabled{opacity:.4;cursor:not-allowed;color:#94a3b8;border-color:#e2e8f0;background-color:#f1f5f9}.board-pagination__btn:disabled i{color:#94a3b8}.board-pagination__label{font-size:.825rem;font-weight:700;color:#334155;padding:0 .35rem;white-space:nowrap;user-select:none}.board-view-footer{display:flex;justify-content:center;align-items:center;padding:1rem 0 .5rem 0;width:100%}.board-grid{display:flex;flex-direction:column;width:100%;box-sizing:border-box}.board-grid--compact{display:flex;flex-direction:column;gap:.5rem;width:100%}.board-grid--card{display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:1.25rem;width:100%}.board-grid__empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:4rem 2rem;text-align:center;background:#fff;border:2px dashed #cbd5e1;border-radius:12px;width:100%;box-sizing:border-box}.board-grid__empty-icon{font-size:3rem;color:#cbd5e1;margin-bottom:1rem}.board-grid__empty-title{font-size:1.15rem;font-weight:600;color:#475569;margin:0 0 .5rem 0}.board-grid__empty-desc{font-size:.9rem;color:#94a3b8;margin:0}.board-card{box-sizing:border-box}.board-card--compact{display:flex;flex-direction:row;align-items:center;width:100%;min-height:70px;padding:.45rem .75rem;background-color:#eef2f5;border:1px solid #d1d5db;border-radius:4px;gap:.75rem;box-sizing:border-box;margin-bottom:.35rem}.board-card--compact:hover{background-color:#e2e8f0;border-color:#9ca3af}.board-card__vote-pill{display:inline-flex;align-items:center;gap:.4rem;padding:.2rem .55rem;background-color:#f1f5f9;border:1px solid #cbd5e1;border-radius:20px;box-sizing:border-box}button.board-card__vote-btn,.board-card__vote-pill .board-card__vote-btn{background:rgba(0,0,0,0);border:none;box-shadow:none;padding:.1rem .2rem;margin:0;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;border-radius:4px;transition:background-color .15s ease;outline:none}.board-card__vote-pill .board-card__vote-btn:hover{background-color:#e2e8f0;box-shadow:none}.board-card__vote-pill .board-card__vote-btn--up i{color:#16a34a;font-size:1.15rem}.board-card__vote-pill .board-card__vote-btn--down i{color:#dc2626;font-size:1.15rem}.board-card__vote-pill .board-card__vote-score{font-size:.9rem;font-weight:700;color:#1e293b;padding:0 .15rem;line-height:1;min-width:1rem;text-align:center}.board-card--compact .board-card__image-container{width:110px;height:62px;flex-shrink:0;border-radius:4px;overflow:hidden;background-color:#cbd5e1}.board-card--compact .board-card__image{width:100%;height:100%;object-fit:cover;display:block}.board-card--compact .board-card__placeholder-wrapper{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%)}.board-card__placeholder-content{display:flex;flex-direction:column;align-items:center;gap:.3rem;color:#64748b;font-size:.72rem;font-weight:600}.board-card__placeholder-content i{font-size:1.35rem}.board-card--compact .board-card__placeholder-img{width:24px;height:24px;color:#64748b}.board-card--compact .board-card__content{display:flex;flex-direction:column;justify-content:center;flex:1;min-width:0;padding:0}.board-card__title-button,.board-card__title-button:active{all:unset;box-sizing:border-box;display:block;width:100%;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.board-card__title-button:focus-visible{outline:2px solid currentColor;outline-offset:-2px}.board-card--compact .board-card__title{font-size:1.05rem;font-weight:700;color:#25a;text-decoration:underline;font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0 0 .15rem 0;cursor:pointer}.board-card--compact .board-card__title:hover{color:#1d4ed8}.board-card--compact .board-card__meta{font-size:.8rem;color:#475569;margin-bottom:.2rem}.board-card--compact .board-card__meta b{color:#1e293b}.board-card--compact .board-card__footer{display:flex;align-items:center;gap:.5rem;padding:0;border:none;margin:0}.board-card__comments-btn{display:inline-flex;align-items:center;gap:.35rem;padding:.2rem .5rem;background:rgba(0,0,0,0);border:none;box-shadow:none;color:#64748b;font-size:.85rem;font-weight:500;cursor:pointer;outline:none}.board-card__comments-btn:hover{color:#007bff;text-decoration:underline;box-shadow:none}.board-card__notes-btn{display:inline-flex;align-items:center;gap:.35rem;padding:.2rem .5rem;background:rgba(0,0,0,0);border:none;box-shadow:none;color:#64748b;font-size:.85rem;font-weight:500;cursor:pointer}.board-card__notes-btn:hover{color:#007bff;text-decoration:underline}.board-card--card{display:flex;flex-direction:column;background-color:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden;transition:transform .2s ease,box-shadow .2s ease;box-shadow:0 2px 4px rgba(0,0,0,.04)}.board-card--card:hover{transform:translateY(-3px);box-shadow:0 8px 16px rgba(0,0,0,.08);border-color:#cbd5e1}.board-card--card .board-card__vote-col{display:none}.board-card--card .board-card__image-container{width:100%;height:170px;overflow:hidden;background-color:#f1f5f9}.board-card--card .board-card__image{width:100%;height:100%;object-fit:cover;display:block}.board-card--card .board-card__placeholder-wrapper{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)}.board-card--card .board-card__placeholder-img{width:36px;height:36px;color:#94a3b8}.board-card--card .board-card__content{display:flex;flex-direction:column;flex:1;padding:1rem}.board-card--card .board-card__title{font-size:1.05rem;font-weight:700;color:#0f172a;margin:0 0 .4rem 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.board-card--card .board-card__title:hover{color:#007bff}.board-card--card .board-card__meta{font-size:.8rem;color:#64748b;margin-bottom:.5rem}.board-card--card .board-card__notes-wrapper{display:flex;flex-direction:column;gap:.35rem;margin-bottom:.75rem}.board-card--card .board-card__notes{font-size:.85rem;line-height:1.45;color:#475569;background-color:#f8fafc;border-left:3px solid #cbd5e1;padding:.4rem .6rem;word-break:break-word}.board-card--card .board-card__notes--clamped{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;white-space:pre-line}.board-card--card .board-card__notes--expanded{display:block;white-space:pre-line}.board-card--card .board-card__notes-toggle{align-self:flex-start;background:none;border:none;padding:.1rem .3rem;color:#007bff;font-size:.775rem;font-weight:600;cursor:pointer}.board-card--card .board-card__notes-toggle:hover{text-decoration:underline}.board-card--card .board-card__footer{display:flex;align-items:center;justify-content:flex-end;margin-top:auto;padding-top:.65rem;border-top:1px solid #f1f5f9}.board-card--card .board-card__comments-btn{display:inline-flex;align-items:center;gap:.4rem;padding:.3rem .6rem;background-color:#f1f5f9;color:#475569;border:1px solid #e2e8f0;border-radius:6px;font-size:.8rem;font-weight:500;cursor:pointer}.board-card--card .board-card__comments-btn:hover{background-color:#007bff;color:#fff;border-color:#007bff}.board-card--card .board-card__comments-btn:hover i{color:#fff}.board-card--card .board-card__notes-btn{display:inline-flex;align-items:center;gap:.4rem;padding:.3rem .6rem;background-color:#f8fafc;color:#475569;border:1px solid #e2e8f0;border-radius:6px;font-size:.8rem;font-weight:500}.board-card--card .board-card__notes-btn:hover{background-color:#e0f2fe;color:#0369a1;border-color:#7dd3fc;text-decoration:none}.board-notes-dialog{min-width:min(560px,75vw);max-width:75vw}.board-notes-dialog h3{margin:0 2rem .8rem 0;color:#0f172a}.board-notes-dialog__label{margin:0 0 .35rem;color:#64748b;font-size:.78rem;font-weight:700;text-transform:uppercase}.board-notes-dialog__content{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;color:#1e293b;line-height:1.55}#photo-view-overlay{display:none;position:fixed;inset:0;z-index:999999;background-color:rgba(0,0,0,.85);align-items:center;justify-content:center}.photo-view-dialog{background-color:#f8fafc;border-radius:8px;box-shadow:0 20px 25px -5px rgba(0,0,0,.5);display:flex;flex-direction:column;max-width:90vw;max-height:90vh;width:820px;overflow:hidden;position:relative;z-index:1}.photo-view-header{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;background-color:#fff;border-bottom:1px solid #e2e8f0}.photo-view-title{font-size:1.05rem;font-style:italic;font-weight:700;color:#1e293b;margin:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.photo-view-close-btn{background:none;border:none;font-size:1.6rem;line-height:1;color:#64748b;cursor:pointer;padding:0 .4rem}.photo-view-close-btn:hover{color:#ef4444}.photo-view-body{display:flex;flex-direction:row;align-items:stretch;background-color:#0f172a;flex:1;min-height:380px;max-height:68vh;overflow:hidden}.photo-view-nav-col{width:56px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background-color:rgba(0,0,0,.25)}.photo-view-img-wrap{flex:1;display:flex;align-items:center;justify-content:center;min-width:0;padding:.75rem}.photo-view-img{max-width:100%;max-height:65vh;object-fit:contain;display:block;border-radius:4px}.photo-view-no-img{color:#94a3b8;font-size:.95rem}.photo-view-nav-btn{width:38px;height:38px;background-color:#fff;border:1px solid #cbd5e1;border-radius:6px;color:#007bff;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 4px 10px rgba(0,0,0,.3);transition:all .15s ease;flex-shrink:0}.photo-view-nav-btn i{font-size:1rem;color:#007bff}.photo-view-nav-btn:hover{background-color:#007bff;color:#fff;border-color:#007bff}.photo-view-nav-btn:hover i{color:#fff}.photo-view-footer{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;background-color:#fff;border-top:1px solid #e2e8f0}.photo-view-meta{font-size:.875rem;color:#475569}.photo-view-meta b{color:#0f172a}.board-detail-default-thumbnail{display:flex;flex:0 0 6rem;align-items:center;justify-content:center;width:6rem;height:6rem;border:1px solid #cbd5e1;border-radius:4px;background:linear-gradient(135deg, #eef6fb, #dbeafe);color:#3ba4d7;box-sizing:border-box}.board-detail-default-thumbnail i{font-size:2.75rem}.create-board-form{display:grid;grid-template-columns:11rem minmax(0, 1fr);gap:1rem 1.5rem}.create-board-form__heading,.create-board-form__title,.create-board-form__description,.create-board-form__submit{grid-column:1/-1}.create-board-form__heading h3{margin:0;color:#1e293b}.create-board-form__heading p{margin:.25rem 0 0;color:#64748b;font-size:.9rem}.create-board-form__title{width:100%}.create-board-form__visual{grid-row:span 2;display:flex;flex-direction:column;align-items:center;gap:.6rem;padding:.75rem;border:1px solid #e2e8f0;border-radius:.65rem;background:#f8fafc}.create-board-form .board-thumbnail-preview{width:9rem;height:9rem;overflow:hidden;border:1px solid #cbd5e1;border-radius:.5rem;background:linear-gradient(135deg, #eef2ff, #dbeafe)}.create-board-form .board-thumbnail-preview img{width:100%;height:100%;display:block;object-fit:cover}.create-board-form .board-thumbnail-preview__placeholder{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5rem}.create-board-form .board-thumbnail-preview__placeholder i{color:#3ba4d7;font-size:2rem}.create-board-form .board-thumbnail-preview__placeholder span,.create-board-form__visual-label{color:#334155;font-weight:700}.create-board-form__file-input{position:absolute;width:1px;height:1px;opacity:0;overflow:hidden}.create-board-form__file-button{display:inline-flex;align-items:center;gap:.35rem;cursor:pointer;padding:.35rem .65rem;border:1px solid #cbd5e1;border-radius:4px;background:#fff;color:#334155;font-size:.85rem}.create-board-form__image-error{color:#b91c1c;font-size:.85rem;text-align:center}.create-board-form__file-button:hover{border-color:#3ba4d7;color:#0879b2}.create-board-form__visual small{color:#64748b;text-align:center}.create-board-form__field{display:flex;flex-direction:column;gap:.35rem;min-width:0}.create-board-form__field label{color:#475569;font-size:.85rem;font-weight:700}.create-board-form__field .config-style-select{width:100%;max-width:320px;min-width:0;box-sizing:border-box;padding:.4rem;border:1px solid #cbd5e1;border-radius:4px;background-color:#fff;color:#1e293b;font-size:.95rem}.create-board-form__description{width:100%;min-height:7rem;resize:vertical}.create-board-form__submit{justify-self:end}.modal-content.create-board-modal{width:min(760px,100% - 2rem);max-height:calc(100% - 2rem);box-sizing:border-box;overflow-x:hidden;overflow-y:auto}.create-board-post{display:flex;flex-direction:column;gap:.9rem}.create-board-post__heading h3{margin:0;color:#1e293b}.create-board-post__heading p{margin:.25rem 0 0;color:#64748b;font-size:.9rem}.create-board-post__modes{display:flex;gap:.4rem;border-bottom:1px solid #cbd5e1}.create-board-post__modes button{border:0;border-bottom:3px solid rgba(0,0,0,0);border-radius:0;background:rgba(0,0,0,0);color:#475569;box-shadow:none;padding:.55rem .8rem}.create-board-post__modes button.active{color:#0788cb;border-bottom-color:#0788cb}.create-board-post__title,.create-board-post__link,.create-board-post__notes{width:100%;box-sizing:border-box}.create-board-post__notes{resize:vertical;min-height:10rem}.create-board-post__image{display:flex;flex-direction:column;align-items:center;gap:.65rem}.create-board-post__preview{width:min(100%,30rem);height:16rem;overflow:hidden;border:1px solid #cbd5e1;border-radius:.5rem;background:#f8fafc}.create-board-post__preview img{width:100%;height:100%;object-fit:contain;display:block}.create-board-post__placeholder{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.6rem;color:#64748b}.create-board-post__placeholder i{color:#3ba4d7;font-size:2.5rem}.create-board-post__file{position:absolute;width:1px;height:1px;opacity:0;overflow:hidden}.create-board-post__file-button{display:inline-flex;align-items:center;gap:.35rem;cursor:pointer;padding:.4rem .7rem;border:1px solid #cbd5e1;border-radius:4px;background:#fff}.create-board-post__author{display:flex;align-items:center;gap:.65rem}.create-board-post__author label{color:#475569;font-weight:700}.create-board-post__author select.network-style-select{flex:1;width:100%;min-width:0;box-sizing:border-box;padding:.375rem .5rem;border:1px solid #cbd5e1;border-radius:.375rem;background-color:#fff;color:#334155;outline:none;cursor:pointer;font-size:.85rem;font-weight:600;transition:border-color .2s}.create-board-post__author select.network-style-select:focus{border-color:#3ba4d7}.create-board-post__submit{align-self:flex-end;min-width:6rem}.posts>.posts__heading.board-posts-heading{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;width:100%;gap:1rem;flex-wrap:wrap;text-align:left}.posts>.posts__heading.board-posts-heading h3{margin:0;text-align:left}.board-posts-heading__create{display:inline-flex;align-items:center;gap:.3rem}.modal-content.create-board-post-modal{width:min(760px,100% - 2rem);max-height:calc(100% - 2rem);box-sizing:border-box;overflow-x:hidden;overflow-y:auto}@media(max-width: 600px){.modal-content.create-board-modal{width:calc(100% - 1rem);max-height:calc(100% - 1rem);padding:1rem}.create-board-form{width:100%;min-width:0;padding:0;overflow:visible;grid-template-columns:1fr}.create-board-form__heading,.create-board-form__title,.create-board-form__visual,.create-board-form__field,.create-board-form__description,.create-board-form__submit{grid-column:1}.create-board-form__visual{grid-row:auto}.create-board-form__field .config-style-select{max-width:100%}.create-board-form__description{box-sizing:border-box}.create-board-form__submit{width:100%}.create-board-form .board-thumbnail-preview{width:8rem;height:8rem}.modal-content.create-board-post-modal{width:calc(100% - 1rem);max-height:calc(100% - 1rem);padding:1rem}.create-board-post__modes button{flex:1;padding:.5rem .25rem}.create-board-post__preview{height:12rem}.create-board-post__author{align-items:stretch;flex-direction:column;gap:.35rem}.create-board-post__submit{width:100%}.board-posts-heading__create span{display:none}.board-posts-heading__create{min-width:2.25rem;justify-content:center;padding-inline:.55rem}}.board-toolbar__toggle-btn:active,.board-card__comments-btn:active,.board-card__notes-btn:active,.board-card__vote-pill .board-card__vote-btn:active{box-shadow:none}@media(max-width: 560px){.board-card .board-card__notes-btn{width:30px;height:30px;justify-content:center;padding:0}.board-card__notes-btn i{margin:0}}.board-detail-navigation{display:flex;align-items:center;justify-content:space-between}.board-mobile-actions{display:none}@media(max-width: 700px){.widget>.top-heading.boards-subscribed-list-toolbar{justify-content:flex-end}}.my-boards-create,.other-boards-create,.popular-boards-create{display:none}@media(max-width: 700px){.my-boards-create,.other-boards-create,.popular-boards-create{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;font-size:.85rem}}.board-toolbar__create-post{display:none}@media(max-width: 700px){.posts>.posts__heading.board-posts-heading{display:none}.board-toolbar__create-post{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;font-size:.85rem;padding:0}.board-toolbar__left .board-toolbar__search{min-width:0;max-width:none}}@media(max-width: 700px){.tab-content:has(.boards-detail-widget){position:relative}.widget.boards-detail-widget{display:grid;grid-template-columns:minmax(0, 1fr);grid-template-rows:36px auto minmax(0, 1fr);row-gap:4px}.widget.boards-detail-widget>.top-heading{grid-area:1/1;margin-left:44px;justify-content:flex-end;min-width:0}.widget.boards-detail-widget>.board-detail-navigation{grid-area:1/1;width:44px}.widget.boards-detail-widget>.widget__heading{grid-area:2/1}.widget.boards-detail-widget>.widget__body{grid-area:3/1;min-height:0}.widget.boards-detail-widget .posts{margin-top:1px}.widget.boards-detail-widget .board-mobile-actions{position:absolute;top:0;right:.5rem;z-index:1001}}@media(max-width: 700px){.boards-create-button--mobile-hidden,.board-subscription-button--subscribed{display:none}.board-mobile-actions{display:block;position:relative}.board-mobile-actions summary{display:flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:.375rem;cursor:pointer;list-style:none}.board-mobile-actions summary::-webkit-details-marker{display:none}.board-mobile-actions summary:focus-visible{outline:2px solid #0788cb}.board-mobile-actions__items{position:absolute;right:0;top:100%;z-index:10;min-width:10rem;padding:.35rem;background:#fff;border:1px solid #cbd5e1;border-radius:.375rem;box-shadow:0 4px 12px rgba(15,23,42,.15)}.board-mobile-actions__items button{width:100%;min-height:44px;text-align:left;background:#fff;color:#334155;box-shadow:none}.board-mobile-actions__items button:hover{background:#f1f5f9}}@media(max-width: 700px){a.board-back[title=Back]{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;flex-shrink:0;border-radius:50%;background-color:#e8f4fc;color:#0788cb;font-size:1.15rem;text-decoration:none}a.board-back[title=Back]:hover{background-color:#d5ebfa}.board-back .fa-arrow-left::before{content:""}}.mail .permission-flag{margin-bottom:1rem;display:flex;gap:1rem}.mail-tags{padding:.5rem;border:1px solid rgba(20,20,27,.2);border-radius:6px}.mail-tags__container{display:flex;flex-direction:column}.mail-tags__container .tag-item{display:flex;align-items:center;gap:4px;border-bottom:1px solid rgba(20,20,27,.1);padding:2px 0}.mail-tags__container .tag-item:last-child{border:none}.mail-tags__container .tag-item__color{width:18px;height:18px;aspect-ratio:1;border-radius:50%}.mail-tags__container .tag-item__name{font-size:1.125rem}.mail-tags__container .tag-item__modify{margin-left:auto;font-size:.75rem;display:flex;gap:4px}.mail-tags__container .tag-item:hover{background-color:#eef3f6}.mail-tags__container .tag-item button,.mail-tags__container .tag-item button.red{padding:.25rem .6rem}.mail-tags-form .input-field{margin-bottom:.5rem}.mail-tags-form .input-field label{margin-right:.5rem}.external-address{margin:0;padding-left:1rem;height:100px;overflow:hidden auto}.external-address::-webkit-scrollbar{display:none}.proxy-server{display:flex;flex-direction:column;gap:4px}.proxy-server__tor>h4,.proxy-server__i2p>h4{margin-bottom:.25rem}.proxy-server__tor>input,.proxy-server__i2p>input{margin-right:.5rem}.proxy-server__tor .proxy-outgoing,.proxy-server__i2p .proxy-outgoing{display:inline-flex;align-items:center;gap:.5rem}.proxy-server__tor .proxy-outgoing__status,.proxy-server__i2p .proxy-outgoing__status{width:1rem;height:1rem;aspect-ratio:1;border:1px solid #000;border-radius:50%}.config-files{display:flex;flex-direction:column;gap:1rem}.proxy-server-container{width:100%;display:flex;flex-direction:column;gap:1rem}.proxy-description{color:#334155;font-size:.95rem;margin-bottom:.5rem}.proxy-rows-container{display:flex;flex-direction:column;gap:.75rem;width:100%}.proxy-row{display:grid;grid-template-columns:160px 220px 220px auto;gap:.75rem;align-items:center;width:100%}.proxy-label{font-size:.95rem;font-weight:500;color:#1e293b}.proxy-addr-input,.proxy-port-input{width:100% !important;max-width:none !important}.proxy-status-container{display:flex;align-items:center;gap:.5rem}.proxy-status-bullet{width:14px;height:14px;border-radius:50%;display:inline-block;border:1px solid #475569}.proxy-status-text{font-size:.95rem;color:#1e293b}@media(max-width: 700px){.config-network{min-width:0;overflow-x:hidden}.config-network .widget{min-width:0;padding:.8rem}.config-network .nw-config-row{display:flex !important;flex-direction:column !important;align-items:stretch !important;gap:.35rem !important;min-width:0}.config-network .nw-config-row>label,.config-network .nw-config-row>p{margin:0 !important}.config-network .nw-mode-group,.config-network .nat-control-group,.config-network .addr-control-group,.config-network .proxy-control-group,.config-network .addr-port-group{width:100%;min-width:0;gap:.5rem !important}.config-network input[type=text],.config-network input[type=number],.config-network select{width:100% !important;max-width:none !important;min-width:0 !important;box-sizing:border-box}.config-network .port-group,.config-network .status-indicator{margin-left:0 !important}.config-network .port-group input[type=number]{width:90px !important}.config-network .external-address{width:100%;height:auto;max-height:9rem;padding-left:1.25rem;overflow:auto;overflow-wrap:anywhere;word-break:break-word;box-sizing:border-box}}@media(max-width: 700px){.node-config .config-grid{display:flex !important;flex-direction:column !important;align-items:stretch !important;gap:.6rem !important;min-width:0;box-sizing:border-box}.node-config .default-id-selector{width:100%;min-width:0}.node-config .default-id-selector select,.node-config .config-grid>select{width:100% !important;min-width:0 !important;max-width:none !important;box-sizing:border-box}.node-config .storage-input-group{justify-content:flex-start}.node-config .table-container{overflow:visible !important}.node-config .history-config-table,.node-config .history-config-table tbody,.node-config .history-config-table tr,.node-config .history-config-table td{display:block;width:100% !important;box-sizing:border-box}.node-config .history-config-table{table-layout:auto}.node-config .history-config-table thead{display:none}.node-config .history-config-table tr{margin:0;padding:.75rem;border-bottom:1px solid #e2e8f0 !important}.node-config .history-config-table tr:last-child{border-bottom:0 !important}.node-config .history-config-table td{padding:.25rem 0 !important;text-align:left !important}.node-config .history-config-table td:nth-child(2),.node-config .history-config-table td:nth-child(3){display:flex;align-items:center;justify-content:space-between;gap:.75rem}.node-config .history-config-table td:nth-child(2)::before{content:"Enable history";color:#64748b;font-size:.8rem;font-weight:600}.node-config .history-config-table td:nth-child(3)::before{content:"Max saved messages";color:#64748b;font-size:.8rem;font-weight:600}}.statistics-container{display:flex;height:100%;width:100%;overflow:hidden;background-color:#f1f5f9}.statistics-left-pane{width:280px;min-width:260px;max-width:320px;border-right:1px solid #cbd5e1;display:flex;flex-direction:column;background:#fff;box-shadow:2px 0 5px rgba(0,0,0,.05);overflow-y:auto}.statistics-header-card{padding:1rem 1.25rem;border-bottom:1px solid #e2e8f0;background:linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)}.statistics-header-card__title{display:flex;align-items:center;gap:.75rem}.statistics-header-card__title>i{font-size:1.4rem;color:#0788cb}.statistics-header-card__title h1{font-size:1.15rem;font-weight:700;color:#1e293b;margin:0}.statistics-header-card__title p{margin:0;font-size:.8rem;color:#64748b}.statistics-nav{display:flex;flex-direction:column;padding:.5rem 0;flex:1}.statistics-nav-item{display:flex;align-items:center;gap:.75rem;padding:.7rem 1.25rem;cursor:pointer;color:#334155;border-left:3px solid rgba(0,0,0,0);background:none;box-shadow:none;border-radius:0;font-size:.9rem;font-weight:500;text-align:left;width:100%;white-space:nowrap;transition:background-color .15s ease,color .15s ease}.statistics-nav-item:active{box-shadow:none}.statistics-nav-item i{width:1.1rem;text-align:center;color:#64748b;font-size:.95rem;flex-shrink:0}.statistics-nav-item:hover:not(.active){background-color:#f1f5f9}.statistics-nav-item.active{background-color:#e0f2fe;color:#0369a1;font-weight:600;border-left-color:#0284c7}.statistics-nav-item.active i{color:#0284c7}.statistics-mobile-tabs{display:none}.statistics-right-pane{flex:1;display:flex;flex-direction:column;overflow-y:auto;padding:1.5rem;background-color:#f8fafc}.statistics-content-header{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:1.25rem}.statistics-content-header__title h2{font-size:1.5rem;font-weight:800;color:#1e293b;margin:0 0 .25rem}.statistics-content-header__title p{margin:0;font-size:.9rem;color:#64748b}.statistics-content-header .statistics-refresh-btn{display:inline-flex;align-items:center;justify-content:center}.statistics-content-header .statistics-refresh-btn i{display:none}.statistics-content-header .statistics-refresh-btn .btn-text{display:inline}.statistics-grid{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.25rem;align-items:start}.traffic-panel{min-width:0;padding:1.25rem;border:1px solid #e2e8f0;border-radius:.5rem;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.02);overflow:hidden}.traffic-panel__heading{display:flex;align-items:flex-start;gap:.6rem;margin-bottom:1rem;padding-bottom:.75rem;border-bottom:1px solid #f1f5f9}.traffic-panel__heading>i{color:#0788cb;font-size:1.05rem;margin-top:.15rem;flex-shrink:0}.traffic-panel__heading h3{margin:0 0 .2rem;font-size:1.1rem;font-weight:700;color:#334155}.traffic-panel__heading p{margin:0;font-size:.82rem;color:#64748b}.traffic-pie{display:grid;grid-template-columns:minmax(10rem, 42%) 1fr;align-items:center;gap:1.25rem;margin:1rem 0}.traffic-pie svg{width:100%;max-height:16rem;transform:rotate(-90deg)}.traffic-pie__track,.traffic-pie__segment{fill:none;stroke-width:18}.traffic-pie__track{stroke:#e8eef3}.traffic-pie__segment{transition:stroke-dasharray .25s ease}.traffic-pie__value,.traffic-pie__caption{transform:rotate(90deg);transform-origin:60px 60px;fill:#1e293b}.traffic-pie__value{font-size:9px;font-weight:700}.traffic-pie__caption{font-size:5px;fill:#64748b}.traffic-legend{min-width:0;max-height:16rem;overflow-y:auto}.traffic-legend__item{display:grid;grid-template-columns:.7rem minmax(0, 1fr) auto;align-items:center;gap:.5rem;padding:.35rem 0;font-size:.84rem}.traffic-legend__swatch{width:.7rem;height:.7rem;border-radius:50%}.traffic-legend__name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.traffic-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;width:100%}.traffic-table{width:100%;font-size:.85rem;border-collapse:collapse}.traffic-table th,.traffic-table td{padding:.55rem .45rem;text-align:right;border-bottom:1px solid #f1f5f9;white-space:nowrap}.traffic-table th{font-weight:600;color:#475569;border-bottom:2px solid #e2e8f0}.traffic-table th:first-child,.traffic-table td:first-child{text-align:left;white-space:normal}.traffic-table tbody tr:hover{background-color:#f8fafc}.traffic-empty{display:grid;place-items:center;gap:.5rem;min-height:14rem;color:#94a3b8;text-align:center}.traffic-empty i{font-size:3rem}.statistics-error{display:flex;gap:.5rem;align-items:center;margin-bottom:1rem;padding:.8rem 1rem;color:#991b1b;background:#fef2f2;border:1px solid #fecaca;border-radius:.5rem}.statistics-note{margin:1rem 0 0;color:#64748b;font-size:.8rem;text-align:right}.statistics-placeholder{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.75rem;min-height:50vh;text-align:center;color:#94a3b8}.statistics-placeholder i{font-size:4rem;color:#cbd5e1}.statistics-placeholder h3{font-size:1.25rem;font-weight:700;color:#64748b;margin:0}.statistics-placeholder p{font-size:.95rem;margin:0}.bandwidth-view{display:flex;flex-direction:column;gap:1.25rem}.bandwidth-summary-grid{display:grid;grid-template-columns:repeat(4, minmax(0, 1fr));gap:1rem}.bandwidth-stat-card{background:#fff;border:1px solid #e2e8f0;border-radius:.5rem;padding:1rem 1.25rem;display:flex;align-items:center;gap:1rem;box-shadow:0 1px 3px rgba(0,0,0,.02)}.bandwidth-stat-card__icon{width:44px;height:44px;border-radius:.5rem;display:flex;align-items:center;justify-content:center;font-size:1.2rem;flex-shrink:0}.bandwidth-stat-card__icon--in{background:#e0f2fe;color:#0284c7}.bandwidth-stat-card__icon--out{background:#dcfce7;color:#16a34a}.bandwidth-stat-card__icon--queue{background:#fef3c7;color:#d97706}.bandwidth-stat-card__icon--session,.bandwidth-stat-card__icon--drain{background:#f3e8ff;color:#7e22ce}.bandwidth-stat-card__body{min-width:0}.bandwidth-stat-card__value{font-size:1.15rem;font-weight:700;color:#1e293b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.bandwidth-stat-card__label{font-size:.8rem;color:#64748b;margin-top:.15rem;white-space:nowrap}.bandwidth-panel{width:100%}.bandwidth-table{font-size:.82rem;min-width:1050px;width:100%;border-collapse:separate;border-spacing:0}.bandwidth-table th,.bandwidth-table td{padding:.6rem .65rem;white-space:nowrap !important}.bandwidth-table th:first-child,.bandwidth-table td:first-child{text-align:left;white-space:nowrap !important;position:sticky;left:0;z-index:2;background:#fff;box-shadow:2px 0 5px rgba(0,0,0,.06)}.bandwidth-table th:first-child{background:#fff;z-index:3}.bandwidth-table tbody tr:hover td:first-child{background:#f8fafc}.bandwidth-table .bandwidth-peer-name{font-weight:600;color:#1e293b;display:inline-block;max-width:150px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.bandwidth-table .bandwidth-peerid-cell{font-family:monospace;font-size:.78rem;color:#64748b}.bandwidth-table .bandwidth-totals-row{background-color:#f1f5f9;font-weight:600}.bandwidth-table .bandwidth-totals-row td{border-bottom:2px solid #cbd5e1}.bandwidth-table .bandwidth-totals-row td:first-child{background:#f1f5f9}.bandwidth-table .bandwidth-totals-row:hover{background-color:#e8eef3}.bandwidth-table .bandwidth-totals-row:hover td:first-child{background:#e8eef3}.bandwidth-drain-badge{display:inline-block;padding:.15rem .5rem;border-radius:4px;font-size:.75rem;font-weight:600;text-align:center;white-space:nowrap;background:#f1f5f9;color:#475569}.bandwidth-drain-badge--warning{background:#fef3c7;color:#b45309;font-weight:700}.bandwidth-drain-badge--critical{background:#fee2e2;color:#dc2626;font-weight:700}@media(max-width: 1200px){.bandwidth-summary-grid{grid-template-columns:repeat(2, minmax(0, 1fr))}}@media(max-width: 1100px){.statistics-grid{grid-template-columns:1fr}}@media(max-width: 700px),(max-width: 899px)and (max-height: 500px){.statistics-left-pane{display:none}.statistics-mobile-tabs{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;background:#fff;border-bottom:1px solid #e2e8f0;overflow:hidden;flex-shrink:0}.statistics-mobile-tabs__list{display:flex;gap:.25rem;overflow-x:auto;flex:1;-webkit-overflow-scrolling:touch}.statistics-mobile-tabs__list::-webkit-scrollbar{display:none}.statistics-mobile-tab{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .75rem;border-radius:9999px;font-size:.8rem;font-weight:500;white-space:nowrap;color:#64748b;background:none;border:1px solid rgba(0,0,0,0);box-shadow:none;cursor:pointer;flex-shrink:0}.statistics-mobile-tab:active{box-shadow:none}.statistics-mobile-tab i{font-size:.75rem}.statistics-mobile-tab.active{background-color:#e0f2fe;color:#0369a1;font-weight:600;border-color:#bae6fd}.statistics-mobile-tab:hover:not(.active){background-color:#f1f5f9}.statistics-mobile-refresh{width:32px;height:32px;padding:0;display:flex;align-items:center;justify-content:center;border-radius:50%;flex-shrink:0;font-size:.85rem}.statistics-container{flex-direction:column}.statistics-right-pane{padding:.75rem}.statistics-content-header{margin-bottom:.75rem}.statistics-content-header__title h2{font-size:1.25rem}.statistics-content-header .statistics-refresh-btn{display:none}.statistics-grid{grid-template-columns:1fr}.traffic-panel{padding:.8rem}.traffic-pie{grid-template-columns:1fr}.traffic-pie svg{max-height:14rem}.bandwidth-summary-grid{grid-template-columns:1fr;gap:.65rem}.bandwidth-stat-card{padding:.75rem 1rem}.bandwidth-panel{padding:.8rem .5rem}}.debug-page{max-width:1280px;margin:0 auto;padding:1.5rem;color:#1e293b}.debug-header{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap}.debug-header__title{display:flex;align-items:center;gap:1rem}.debug-header__icon{width:48px;height:48px;border-radius:.75rem;background:#e0f2fe;color:#0284c7;display:flex;align-items:center;justify-content:center;font-size:1.4rem;flex-shrink:0}.debug-header__text h1{font-size:1.6rem;font-weight:800;color:#1e293b;margin:0 0 .25rem;line-height:1.2}.debug-header__text p{font-size:.9rem;color:#64748b;margin:0}.debug-header__actions{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}.debug-btn{display:inline-flex;align-items:center;gap:.45rem;padding:.55rem 1rem;border-radius:.5rem;font-size:.85rem;font-weight:600;cursor:pointer;border:1px solid #cbd5e1;background:#fff;color:#334155;transition:all .15s ease;box-shadow:0 1px 2px rgba(0,0,0,.05)}.debug-btn:hover{background:#f8fafc;border-color:#94a3b8;color:#1e293b}.debug-btn--danger{color:#dc2626;border-color:#fecaca;background:#fff}.debug-btn--danger:hover{background:#fef2f2;border-color:#fca5a5;color:#b91c1c}.debug-kpi-grid{display:grid;grid-template-columns:repeat(4, minmax(0, 1fr));gap:1rem;margin-bottom:1.5rem}.debug-kpi-card{background:#fff;border:1px solid #e2e8f0;border-radius:.75rem;padding:1.15rem 1.25rem;display:flex;align-items:center;gap:1rem;box-shadow:0 1px 3px rgba(15,23,42,.04);transition:transform .15s ease,box-shadow .15s ease}.debug-kpi-card:hover{box-shadow:0 4px 12px rgba(15,23,42,.08)}.debug-kpi-card__icon{width:46px;height:46px;border-radius:.6rem;display:flex;align-items:center;justify-content:center;font-size:1.25rem;flex-shrink:0}.debug-kpi-card__icon--blue{background:#e0f2fe;color:#0284c7}.debug-kpi-card__icon--purple{background:#f3e8ff;color:#7e22ce}.debug-kpi-card__icon--green{background:#dcfce7;color:#16a34a}.debug-kpi-card__icon--amber{background:#fef3c7;color:#d97706}.debug-kpi-card__body{min-width:0;flex:1}.debug-kpi-card__label{font-size:.78rem;font-weight:600;color:#64748b;margin-bottom:.2rem;text-transform:uppercase;letter-spacing:.03em}.debug-kpi-card__value{font-size:1.35rem;font-weight:800;color:#1e293b;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.debug-kpi-card__subtext{display:flex;align-items:center;gap:.4rem;font-size:.78rem;color:#64748b;margin-top:.25rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.debug-status-dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0}.debug-status-dot.online{background:#10b981;box-shadow:0 0 0 2px rgba(16,185,129,.25)}.debug-status-dot.offline{background:#ef4444;box-shadow:0 0 0 2px rgba(239,68,68,.25)}.debug-grid-2col{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.25rem;margin-bottom:1.25rem}.debug-section{background:#fff;border:1px solid #e2e8f0;border-radius:.75rem;padding:1.25rem 1.5rem;box-shadow:0 1px 3px rgba(15,23,42,.04);margin-bottom:1.25rem}.debug-section__header{display:flex;align-items:center;gap:.6rem;padding-bottom:.85rem;border-bottom:1px solid #f1f5f9;margin-bottom:1rem}.debug-section__header i{color:#0788cb;font-size:1.15rem}.debug-section__header h3{font-size:1.05rem;font-weight:700;color:#1e293b;margin:0}.debug-info-list{display:flex;flex-direction:column}.debug-info-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.55rem 0;border-bottom:1px solid #f8fafc}.debug-info-row:last-child{border-bottom:0}.debug-info-label{font-size:.88rem;font-weight:600;color:#64748b}.debug-info-value{font-size:.9rem;font-weight:600;color:#1e293b;display:flex;align-items:center;gap:.45rem;flex-wrap:wrap;justify-content:flex-end}.debug-sublabel{color:#94a3b8;font-size:.78rem;font-weight:400}.debug-badge{display:inline-flex;align-items:center;padding:.18rem .55rem;border-radius:.35rem;font-size:.78rem;font-weight:700}.debug-badge--blue{background:#e0f2fe;color:#0369a1}.debug-badge--slate{background:#f1f5f9;color:#334155}.debug-badge--green{background:#dcfce7;color:#15803d}.debug-callout{display:flex;gap:.75rem;align-items:flex-start;margin-top:1rem;padding:.85rem 1rem;background:#f0f9ff;border:1px solid #bae6fd;border-radius:.5rem}.debug-callout i{color:#0284c7;font-size:1.1rem;margin-top:.15rem;flex-shrink:0}.debug-callout p{margin:0;font-size:.82rem;color:#0369a1;line-height:1.45}.debug-tables-grid{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.25rem}.debug-table-panel{background:#f8fafc;border:1px solid #e2e8f0;border-radius:.5rem;padding:1rem}.debug-table-panel__header{display:flex;align-items:center;justify-content:space-between;margin-bottom:.75rem}.debug-table-panel__header h4{margin:0;font-size:.95rem;font-weight:700;color:#334155}.debug-table-panel__header .debug-count-badge{font-size:.75rem;color:#64748b;background:#e2e8f0;padding:.15rem .5rem;border-radius:.3rem;font-weight:600}.debug-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border-radius:.375rem;border:1px solid #e2e8f0}.debug-table{width:100%;border-collapse:collapse;font-size:.84rem}.debug-table th{text-align:left;padding:.55rem .65rem;font-size:.74rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#64748b;border-bottom:2px solid #e2e8f0;background:#fff;white-space:nowrap}.debug-table td{padding:.55rem .65rem;border-bottom:1px solid #f1f5f9;vertical-align:middle;background:#fff;white-space:nowrap}.debug-table tr:last-child td{border-bottom:0}.debug-table tbody tr:hover td{background:#f8fafc}.debug-table code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:.82rem;color:#0284c7;background:#f0f9ff;padding:.15rem .35rem;border-radius:.25rem;border:1px solid #e0f2fe;display:inline-block;max-width:280px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.debug-table .debug-latency-badge{display:inline-flex;align-items:center;padding:.15rem .5rem;border-radius:.3rem;font-size:.78rem;font-weight:700;white-space:nowrap}.debug-table .debug-latency-badge.debug-latency--fast{background:#dcfce7;color:#166534}.debug-table .debug-latency-badge.debug-latency--moderate{background:#fef3c7;color:#92400e}.debug-table .debug-latency-badge.debug-latency--slow{background:#fee2e2;color:#991b1b}.debug-table .debug-table__when{color:#94a3b8;font-size:.8rem;white-space:nowrap}.debug-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.4rem;padding:2rem 1rem;color:#94a3b8;text-align:center}.debug-empty i{font-size:2rem;color:#cbd5e1}.debug-empty p{margin:0;font-size:.85rem}@media(max-width: 900px){.debug-kpi-grid{grid-template-columns:repeat(2, minmax(0, 1fr))}.debug-grid-2col{grid-template-columns:1fr}.debug-tables-grid{grid-template-columns:1fr}}@media(max-width: 700px){.debug-page{padding:.75rem}.debug-header{margin-bottom:1rem;gap:.75rem}.debug-header__title{gap:.75rem}.debug-header__icon{width:40px;height:40px;font-size:1.2rem;border-radius:.5rem}.debug-header__text h1{font-size:1.3rem}.debug-header__text p{font-size:.82rem}.debug-header__actions{width:100%;display:grid;grid-template-columns:repeat(3, 1fr);gap:.4rem}.debug-btn{padding:.5rem .25rem;justify-content:center;font-size:.78rem;min-height:40px}.debug-btn span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.debug-kpi-grid{grid-template-columns:repeat(2, minmax(0, 1fr));gap:.5rem;margin-bottom:1rem}.debug-kpi-card{padding:.75rem .65rem;gap:.6rem;border-radius:.5rem}.debug-kpi-card__icon{width:36px;height:36px;font-size:1rem;border-radius:.45rem}.debug-kpi-card__label{font-size:.7rem}.debug-kpi-card__value{font-size:1.1rem}.debug-kpi-card__subtext{font-size:.72rem}.debug-section{padding:.85rem;border-radius:.5rem;margin-bottom:.75rem}.debug-section__header{padding-bottom:.6rem;margin-bottom:.65rem}.debug-section__header h3{font-size:.95rem}.debug-info-row{padding:.45rem 0}.debug-info-label{font-size:.82rem}.debug-info-value{font-size:.82rem}.debug-table-panel{padding:.65rem}.debug-table{font-size:.8rem}.debug-table th,.debug-table td{padding:.45rem .5rem}.debug-table code{max-width:150px}}@media(max-width: 480px){.debug-header__actions{grid-template-columns:1fr}.debug-kpi-grid{grid-template-columns:1fr}.debug-info-row{flex-direction:column;align-items:flex-start;gap:.2rem}.debug-info-row .debug-info-value{justify-content:flex-start;width:100%}}@media(max-width: 700px),(max-width: 899px)and (max-height: 500px){.network-detail-view{gap:1rem}.network-detail-view .detail-header{flex-direction:column;align-items:flex-start;padding-bottom:1rem}.network-detail-view .detail-header .detail-title h2{font-size:1.35rem}.network-detail-view .detail-header .detail-actions{flex-wrap:wrap;width:100%}.network-detail-view .detail-section{padding:.875rem}.network-detail-view .detail-section .info-grid{grid-template-columns:1fr;row-gap:.25rem}.network-detail-view .detail-section .info-grid .info-label{margin-top:.5rem}.network-detail-view .locations-grid{grid-template-columns:1fr}.network-detail-view .location-card .loc-body{grid-template-columns:1fr}.proxy-row{grid-template-columns:1fr;gap:.35rem;padding-bottom:.75rem;border-bottom:1px solid #e2e8f0}.grid-2col{grid-template-columns:1fr}.grid-2col input[type=checkbox]{margin-top:0}.identity{margin:.5rem;padding:.75rem}.identity .details{grid-template-columns:1fr;grid-row-gap:.125rem}.widget{padding:.5rem}.widget-half{max-width:100%}}@media(max-width: 700px),(max-width: 899px)and (max-height: 500px){table{table-layout:auto;font-size:1rem}table td,table th{word-break:break-word}table.myfiles th:nth-child(2),table.friendsfiles th:nth-child(2){width:auto}table.comments{display:block}table.comments tbody{display:block}table.comments tr.comments-head{display:none}table.comments tr:not(.comments-head){display:block;padding:.5rem 0}table.comments tr:not(.comments-head)>td{display:block;text-align:start;padding:.125rem 0}table.comments tr:not(.comments-head)>td:nth-child(n+3){display:inline-block;margin-right:.75rem;font-size:.8rem;color:#64748b}table.comments tr:not(.comments-head)>td:nth-child(5)::before{content:"Score: "}table.comments tr:not(.comments-head)>td:nth-child(6)::before{content:"⬆"}table.comments tr:not(.comments-head)>td:nth-child(7)::before{content:"⬇"}}@media(max-width: 700px),(max-width: 899px)and (max-height: 500px){.statusbar{gap:.5rem;overflow-x:auto;white-space:nowrap;scrollbar-width:none}.statusbar::-webkit-scrollbar{display:none}.statusbar-left,.statusbar-right{flex-shrink:0;gap:.5rem !important}.statusbar-divider{display:none}}@media(max-width: 700px),(max-width: 899px)and (max-height: 500px){input[type=text],input[type=password],input[type=number],input[type=search],input[type=email],select,textarea{font-size:16px}input.stretched,input.searchbar{width:100%}input.small{max-width:100%}.sidebar a{min-height:2.75rem;display:inline-flex !important;align-items:center}.tooltiptext{left:0;margin-left:0;min-width:0;width:max-content;max-width:80vw}}@media(hover: none),(pointer: coarse){.tooltip .tooltiptext:focus,.tooltip .tooltiptext:focus-within{visibility:visible}.tooltip:focus-within .tooltiptext{visibility:visible}.compose-mail__recipients .recipients__input:focus-within .recipients__input-list{display:flex}}@media(hover: none),(pointer: coarse){html,body{overscroll-behavior-y:none}.chat-messages,.chat-hub-messages,.tab-content,.main-container{overscroll-behavior-y:contain}}