This commit is contained in:
defnax 2026-09-12 09:41:59 +00:00 committed by GitHub
commit 7efbc4c8ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
91 changed files with 23308 additions and 5278 deletions

View File

@ -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 <author> <date>)
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,
};

View File

@ -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,
};

View File

@ -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 })),
];

View File

@ -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(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/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,

View File

@ -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;

View File

@ -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;

View File

@ -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',
})
),
])
),
]),

View File

@ -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',
})
),
])
),
]),

File diff suppressed because it is too large Load Diff

View File

@ -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 })),
];

View File

@ -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,
]),
};

View File

@ -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,

View File

@ -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',
})
),
])

View File

@ -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',
})
),
])

View File

@ -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',
})
),
])

File diff suppressed because it is too large Load Diff

View File

@ -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 = () => ({

View File

@ -0,0 +1,33 @@
function chatPreviewText(rawText) {
if (!rawText) return '';
const source = String(rawText);
if (!/[<&]/.test(source)) return source.trim();
const hasImage = /<img\b/i.test(source) || /&lt;img\b/i.test(source);
const decodeEntities = (text) => {
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(/<br\s*\/?>/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;

View File

@ -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 &nbsp; 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('<br/>', '\n')
.replaceAll('<br>', '\n')
.replace(new RegExp('<style[^<]*</style>|<[^>]*>', '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 <body> 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 = '&times;';
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 <body> 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 = /<a\b[^>]*>[\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 <img ... src="..."> HTML tags
const imgRegex = /<img\s+[^>]*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('<br/>', '\n')
.replaceAll('<br>', '\n')
.replace(new RegExp('<style[^<]*</style>|<[^>]*>', '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 <img> 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(`<body style="margin:0;background:#0f172a;display:flex;justify-content:center;align-items:center;min-height:100vh;"><img src="${src}" style="max-width:100%;max-height:100vh;object-fit:contain;"/></body>`);
}
}
onclick: () => openChatImageViewer(src),
})
);
}
@ -142,10 +297,7 @@ function renderChatMessage(rawText) {
if (lastIndex < rawText.length) {
const trailingText = rawText.substring(lastIndex);
const cleanText = trailingText
.replaceAll('<br/>', '\n')
.replaceAll('<br>', '\n')
.replace(new RegExp('<style[^<]*</style>|<[^>]*>', '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(`<body style="margin:0;background:#0f172a;display:flex;justify-content:center;align-items:center;min-height:100vh;"><img src="${src}" style="max-width:100%;max-height:100vh;object-fit:contain;"/></body>`);
}
}
onclick: () => openChatImageViewer(src),
});
}
// 3. Normal text message
const cleanText = rawText
.replace(/<blockquote[^>]*>/gi, '\n> ')
.replace(/<\/blockquote>/gi, '\n')
.replaceAll('<br/>', '\n')
.replaceAll('<br>', '\n')
.replace(new RegExp('<style[^<]*</style>|<[^>]*>', 'gm'), '');
const cleanText = htmlToText(
rawText
.replace(/<blockquote[^>]*>/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';
}

285
webui-src/app/comments.js Normal file
View File

@ -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,
};

View File

@ -16,6 +16,7 @@ const Layout = {
m(widget.Sidebar, {
tabs: Object.keys(sections),
baseRoute: '/config/',
mobileDrawer: true,
}),
m('.node-panel', vnode.children),
],

View File

@ -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;

79
webui-src/app/dialog.js Normal file
View File

@ -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;

View File

@ -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,

View File

@ -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.'))
),
]);
},

View File

@ -35,6 +35,7 @@ const Layout = {
m(widget.Sidebar, {
tabs: Object.keys(sections),
baseRoute: '/files/',
mobileDrawer: true,
}),
m('.node-panel', m('.widget', vnode.children)),
],

View File

@ -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(

View File

@ -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,
})
)
)
),
]),

View File

@ -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(

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
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, '<br>');
const images = inlineImages.map((file) =>
`<p><img src="${file.dataUrl}" alt="${escapeHtml(file.name)}" style="max-width:100%;height:auto;border-radius:6px;"></p>`
).join('');
const embedded = attachments.map((file) =>
`<p><a href="retroshare://file?name=${encodeURIComponent(file.name)}&amp;size=${file.size}&amp;hash=${file.hash}">&#128206; ${escapeHtml(file.name)}</a> (${formatSize(file.size)})</p>`
).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)),
]),
])
)
)
)
)
)
),

View File

@ -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 })),
];

View File

@ -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 = {

View File

@ -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,

View File

@ -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',
})
),
])

View File

@ -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',
})
),
])

View File

@ -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'

View File

@ -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 = () =>

View File

@ -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)
)
),
])
]),
]),
]),
]),

View File

@ -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;

View File

@ -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),
};

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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.'
),
]),
]),

View File

@ -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(`<img src="${dataUrl}" />`, dataUrl);
} else {
callback(`<img src="${evt.target.result}" />`, evt.target.result);
}
};
img.onerror = () => {
if (evt.target.result) {
callback(`<img src="${evt.target.result}" />`, 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 = `<a href="retroshare://file?name=${encodeURIComponent(info.name)}&size=${sizeNum}&hash=${info.hash}">${info.name}</a> (${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('<br/>', '\n')
.replace(new RegExp('<style[^<]*</style>|<[^>]*>', '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')
])
])
]),
]);
},
};

View File

@ -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;

View File

@ -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'
),
]),
])
)
]);
})
),
]),
]);

View File

@ -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'),
]),
]
);
}),
]),
]);
},

View File

@ -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;

View File

@ -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,
};

View File

@ -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;
}

View File

@ -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 = `<a href="retroshare://file?name=${encodeURIComponent(info.name)}`
+ `&size=${size}&hash=${info.hash}">${info.name}</a> (${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 };

View File

@ -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(`<img src="${dataUrl}" />`);
if (dataUrl.length <= MAX_IMAGE_CHARS) {
callback(`<img src="${dataUrl}" />`, 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);
}
}
},
}),

View File

@ -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`

View File

@ -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';
}

View File

@ -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);
},
};

View File

@ -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 };

View File

@ -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',
]),
]),
]);
];
})(),
]),
]);

File diff suppressed because it is too large Load Diff

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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 <tbody> 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 <tr> 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;
}
}

View File

@ -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) {

View File

@ -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;

View File

@ -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 */

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -1,4 +1,5 @@
@forward 'buttons';
@forward 'comments';
@forward 'media';
@forward 'navbar';
@forward 'posts';

View File

@ -21,4 +21,15 @@
&__desc {
flex-basis: 60%;
}
@media (max-width: 768px) {
&__desc {
display: none !important;
}
&__details {
flex-basis: 100% !important;
width: 100% !important;
}
}
}

View File

@ -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; }

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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';

File diff suppressed because it is too large Load Diff

View File

@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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;
}
}

View File

@ -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%;
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -119,4 +119,413 @@
}
}
}
}
}
/* 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%;
}
}
}

View File

@ -9,3 +9,5 @@
@forward "forums";
@forward "board";
@forward "config";
@forward "statistics";
@forward "debug";

File diff suppressed because it is too large Load Diff

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -16,7 +16,8 @@
}
.fa,
.fas {
.fas,
.far {
font-family: 'Font Awesome 5 Free';
font-weight: 900;
}

View File

@ -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;

View File

@ -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,
]),
]);
},
};

View File

@ -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;

View File

@ -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,
};

File diff suppressed because one or more lines are too long