mirror of
https://github.com/RetroShare/RSNewWebUI.git
synced 2026-09-14 11:05:47 +05:00
Merge cf08a7953f into fb80060595
This commit is contained in:
commit
7efbc4c8ee
695
webui-src/app/boards/board_kanban.js
Normal file
695
webui-src/app/boards/board_kanban.js
Normal 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,
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 })),
|
||||
];
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
const m = require('mithril');
|
||||
const rs = require('rswebui');
|
||||
const peopleUtil = require('people/people_util');
|
||||
const widget = require('widgets');
|
||||
|
||||
const GROUP_SUBSCRIBE_ADMIN = 0x01; // means: you have the admin key for this group
|
||||
const GROUP_SUBSCRIBE_PUBLISH = 0x02; // means: you have the publish key for thiss group. Typical use: publish key in channels are shared with specific friends.
|
||||
@ -20,137 +22,365 @@ const Data = {
|
||||
Comments: {}, // threadID, msgID -> {Comment, showReplies}
|
||||
};
|
||||
|
||||
async function updateDisplayBoards(keyid, details) {
|
||||
const res1 = await rs.rsJsonApiRequest('/rsPosted/getBoardsInfo', {
|
||||
boardsIds: [keyid],
|
||||
});
|
||||
details = res1.body.boardsInfo[0];
|
||||
Data.DisplayBoards[keyid] = {
|
||||
name: details.mMeta.mGroupName,
|
||||
isSearched: true,
|
||||
description: details.mDescription,
|
||||
image: details.mGroupImage,
|
||||
author: details.mMeta.mAuthorId,
|
||||
isSubscribed:
|
||||
details.mMeta.mSubscribeFlags === GROUP_SUBSCRIBE_SUBSCRIBED ||
|
||||
details.mMeta.mSubscribeFlags === GROUP_MY_BOARD,
|
||||
posts: details.mMeta.mVisibleMsgCount,
|
||||
activity: details.mMeta.mLastPost,
|
||||
created: details.mMeta.mPublishTs,
|
||||
all: details,
|
||||
};
|
||||
|
||||
if (Data.Posts[keyid] === undefined) {
|
||||
Data.Posts[keyid] = {};
|
||||
}
|
||||
|
||||
/* const res2 = await rs.rsJsonApiRequest('/rsPosted/getContentSummaries', {
|
||||
boardId: keyid,
|
||||
});
|
||||
|
||||
if (res2.body.retval) {
|
||||
res2.body.summaries.map((content) => {
|
||||
updateContent(content, keyid);
|
||||
});
|
||||
}*/
|
||||
// Older Qt clients store board notes as rich HTML. Render them as readable,
|
||||
// inert text in the web UI instead of exposing the markup and embedded CSS.
|
||||
function plainText(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
const text = String(value);
|
||||
if (!/<\/?[a-z][^>]*>/i.test(text)) return text.trim();
|
||||
return text
|
||||
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
|
||||
.replace(/<(br|\/p|\/div|\/li|\/h[1-6])\b[^>]*>/gi, '\n')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/&(nbsp|#160);/gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, '\'')
|
||||
.replace(/\n\s*\n+/g, '\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const DisplayBoardsFromList = () => {
|
||||
const BOARD_POST_BATCH_SIZE = 25;
|
||||
|
||||
async function updateContent(content, boardid) {
|
||||
const requested = Array.isArray(content) ? content : [content];
|
||||
const msgIds = requested
|
||||
.map((item) => item.mMsgId || item.msgId || item)
|
||||
.filter(Boolean);
|
||||
if (msgIds.length === 0) return false;
|
||||
try {
|
||||
const res = await rs.rsJsonApiRequest('/rsPosted/getBoardContent', {
|
||||
boardId: boardid,
|
||||
contentsIds: msgIds,
|
||||
});
|
||||
if (res && res.body && res.body.retval) {
|
||||
const posts = res.body.posts || res.body.postList || [];
|
||||
const comments = res.body.comments || res.body.commentList || [];
|
||||
const votes = res.body.votes || res.body.voteList || [];
|
||||
|
||||
if (posts.length > 0) {
|
||||
if (!Data.Posts[boardid]) Data.Posts[boardid] = {};
|
||||
posts.forEach((post) => {
|
||||
const msgId = post.mMeta && post.mMeta.mMsgId;
|
||||
if (msgId) Data.Posts[boardid][msgId] = { post, isSearched: true };
|
||||
});
|
||||
m.redraw();
|
||||
} else if (comments.length > 0 && requested.length === 1) {
|
||||
const threadId = requested[0].mThreadId || comments[0].mMeta.mThreadId;
|
||||
if (Data.Comments[threadId] === undefined) {
|
||||
Data.Comments[threadId] = {};
|
||||
}
|
||||
Data.Comments[threadId][msgIds[0]] = comments[0];
|
||||
m.redraw();
|
||||
} else if (votes.length > 0) {
|
||||
const vote = votes[0];
|
||||
if (
|
||||
Data.Comments[vote.mMeta.mThreadId] &&
|
||||
Data.Comments[vote.mMeta.mThreadId][vote.mMeta.mParentId]
|
||||
) {
|
||||
if (vote.mVoteType === GXS_VOTE_UP) {
|
||||
Data.Comments[vote.mMeta.mThreadId][vote.mMeta.mParentId].mUpVotes += 1;
|
||||
}
|
||||
if (vote.mVoteType === GXS_VOTE_DOWN) {
|
||||
Data.Comments[vote.mMeta.mThreadId][vote.mMeta.mParentId].mDownVotes += 1;
|
||||
}
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('updateContent error:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same as the channel loader: a post can carry base64 media, and the JSON API
|
||||
// cuts a response it cannot flush in time. Retry a failed batch as two smaller
|
||||
// ones until the offending post is isolated, instead of dropping the 25 of them
|
||||
// on a single console.warn. Do not split when the core stopped answering, or
|
||||
// one batch would turn into 2N-1 doomed requests.
|
||||
async function updateContentBatch(contentIds, boardid) {
|
||||
const loaded = await updateContent(contentIds, boardid);
|
||||
if (loaded || contentIds.length <= 1 || !rs.connectionState.status) {
|
||||
if (!loaded) {
|
||||
console.warn('Unable to load board content item', contentIds[0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const middle = Math.ceil(contentIds.length / 2);
|
||||
await updateContentBatch(contentIds.slice(0, middle), boardid);
|
||||
await updateContentBatch(contentIds.slice(middle), boardid);
|
||||
}
|
||||
|
||||
const inFlightBoards = {};
|
||||
|
||||
async function updateDisplayBoards(keyid, details) {
|
||||
if (!keyid) return Promise.resolve();
|
||||
|
||||
// 1. Fast path: if posts for this board are already loaded in memory, render instantly and do not re-fetch
|
||||
if (Data.DisplayBoards[keyid] && Data.Posts[keyid] && Object.keys(Data.Posts[keyid]).length > 0) {
|
||||
m.redraw();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 2. Prevent duplicate concurrent HTTP requests for the same board ID
|
||||
if (inFlightBoards[keyid]) {
|
||||
return inFlightBoards[keyid];
|
||||
}
|
||||
|
||||
inFlightBoards[keyid] = (async () => {
|
||||
try {
|
||||
// Fetch board info metadata if missing
|
||||
if (!Data.DisplayBoards[keyid]) {
|
||||
const res1 = await rs.rsJsonApiRequest('/rsPosted/getBoardsInfo', {
|
||||
boardsIds: [keyid],
|
||||
});
|
||||
if (res1 && res1.body && res1.body.boardsInfo && res1.body.boardsInfo.length > 0) {
|
||||
details = res1.body.boardsInfo[0];
|
||||
Data.DisplayBoards[keyid] = {
|
||||
name: details.mMeta.mGroupName,
|
||||
isSearched: true,
|
||||
description: details.mDescription,
|
||||
image: details.mGroupImage,
|
||||
author: details.mMeta.mAuthorId,
|
||||
subscribeFlags: details.mMeta.mSubscribeFlags,
|
||||
isSubscribed:
|
||||
details.mMeta.mSubscribeFlags === GROUP_SUBSCRIBE_SUBSCRIBED ||
|
||||
details.mMeta.mSubscribeFlags === GROUP_MY_BOARD,
|
||||
posts: details.mMeta.mVisibleMsgCount,
|
||||
activity: details.mMeta.mLastPost,
|
||||
created: details.mMeta.mPublishTs,
|
||||
all: details,
|
||||
};
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
if (!Data.Posts[keyid]) {
|
||||
Data.Posts[keyid] = {};
|
||||
}
|
||||
|
||||
// Load lightweight post metadata first, then fetch complete posts newest
|
||||
// first in page-sized batches. The first page can render without waiting
|
||||
// for every image and older post in a large board.
|
||||
const summariesRes = await rs.rsJsonApiRequest('/rsPosted/getBoardPostSummaries', {
|
||||
boardId: keyid,
|
||||
});
|
||||
const summaries = summariesRes && summariesRes.body && summariesRes.body.retval
|
||||
&& Array.isArray(summariesRes.body.summaries)
|
||||
? summariesRes.body.summaries
|
||||
: null;
|
||||
|
||||
if (summaries) {
|
||||
summaries.sort((a, b) => Number((b.mPublishTs && b.mPublishTs.xint64) || b.mPublishTs || 0)
|
||||
- Number((a.mPublishTs && a.mPublishTs.xint64) || a.mPublishTs || 0));
|
||||
for (let i = 0; i < summaries.length; i += BOARD_POST_BATCH_SIZE) {
|
||||
await updateContentBatch(summaries.slice(i, i + BOARD_POST_BATCH_SIZE), keyid);
|
||||
}
|
||||
} else {
|
||||
// Compatibility fallback for RetroShare cores which do not yet expose
|
||||
// getBoardPostSummaries.
|
||||
const resAll = await rs.rsJsonApiRequest('/rsPosted/getBoardAllContent', {
|
||||
boardId: keyid,
|
||||
});
|
||||
const posts = resAll && resAll.body && resAll.body.retval
|
||||
? (resAll.body.posts || resAll.body.postList || [])
|
||||
: [];
|
||||
posts.forEach((post) => {
|
||||
const msgId = (post.mMeta && post.mMeta.mMsgId) || post.mMsgId;
|
||||
if (msgId) Data.Posts[keyid][msgId] = { post, isSearched: true };
|
||||
});
|
||||
m.redraw();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('updateDisplayBoards network error for board:', keyid, err);
|
||||
} finally {
|
||||
delete inFlightBoards[keyid];
|
||||
}
|
||||
})();
|
||||
|
||||
return inFlightBoards[keyid];
|
||||
}
|
||||
|
||||
const BoardSummary = () => {
|
||||
return {
|
||||
oninit: (v) => {},
|
||||
view: (v) =>
|
||||
m(
|
||||
view: (vnode) => {
|
||||
const details = vnode.attrs.details;
|
||||
const bname = details.mGroupName || details.name || '';
|
||||
|
||||
return m(
|
||||
'tr',
|
||||
{
|
||||
key: v.attrs.id,
|
||||
class:
|
||||
Data.DisplayBoards[v.attrs.id] && Data.DisplayBoards[v.attrs.id].isSearched
|
||||
? ''
|
||||
: 'hidden',
|
||||
key: details.mGroupId,
|
||||
onclick: () => {
|
||||
m.route.set('/boards/:tab/:mGroupId', {
|
||||
tab: v.attrs.category,
|
||||
mGroupId: v.attrs.id,
|
||||
tab: vnode.attrs.category,
|
||||
mGroupId: details.mGroupId,
|
||||
});
|
||||
},
|
||||
},
|
||||
[m('td', Data.DisplayBoards[v.attrs.id] ? Data.DisplayBoards[v.attrs.id].name : '')]
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const BoardSummary = () => {
|
||||
let keyid = {};
|
||||
return {
|
||||
oninit: (v) => {
|
||||
keyid = v.attrs.details.mGroupId;
|
||||
updateDisplayBoards(keyid);
|
||||
[
|
||||
m('td', bname),
|
||||
]
|
||||
);
|
||||
},
|
||||
|
||||
view: (v) => {},
|
||||
};
|
||||
};
|
||||
|
||||
const BoardTable = () => {
|
||||
return {
|
||||
oninit: (v) => {},
|
||||
view: (v) => m('table.boards', [m('tr', [m('th', 'Board Name')]), v.children]),
|
||||
view: (vnode) =>
|
||||
m('table.board-table', [
|
||||
m('thead', [
|
||||
m('tr', [
|
||||
m('th', 'Board Name'),
|
||||
]),
|
||||
]),
|
||||
vnode.children,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
function popupmessage(message) {
|
||||
const container = document.getElementById('modal-container');
|
||||
container.style.display = 'block';
|
||||
m.render(
|
||||
container,
|
||||
m('.modal-content[id=composepopup]', [
|
||||
m(
|
||||
'button.red',
|
||||
{
|
||||
onclick: () => (container.style.display = 'none'),
|
||||
},
|
||||
m('i.fas.fa-times')
|
||||
),
|
||||
message,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
const SearchBar = () => {
|
||||
let searchString = '';
|
||||
return {
|
||||
view: (v) =>
|
||||
m('input[type=text][id=searchboard][placeholder=Search Subject].searchbar', {
|
||||
value: searchString,
|
||||
oninput: (e) => {
|
||||
searchString = e.target.value.toLowerCase();
|
||||
for (const hash in Data.DisplayBoards) {
|
||||
if (Data.DisplayBoards[hash].name.toLowerCase().indexOf(searchString) > -1) {
|
||||
Data.DisplayBoards[hash].isSearched = true;
|
||||
} else {
|
||||
Data.DisplayBoards[hash].isSearched = false;
|
||||
view: (vnode) =>
|
||||
m('.search-bar', [
|
||||
m('input[type=text][placeholder=Search Boards...]', {
|
||||
value: searchString,
|
||||
oninput: (e) => {
|
||||
searchString = e.target.value;
|
||||
const query = searchString.toLowerCase();
|
||||
if (vnode.attrs.list) {
|
||||
vnode.attrs.list.forEach((board) => {
|
||||
const name = (board.mGroupName || board.name || '').toLowerCase();
|
||||
board.isSearched = name.includes(query);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
function popupmessage(message, modalClass = '') {
|
||||
widget.popupMessage(message, modalClass);
|
||||
}
|
||||
|
||||
// Replace the cached post only once the core's background tally has moved
|
||||
// past the pre-vote counts: a blind fixed-delay refetch could land BEFORE
|
||||
// the retally (one board per tick, asynchronous write), revert the
|
||||
// optimistic bump on screen, and stick -- updateDisplayBoards never
|
||||
// refreshes cached content. Bounded retries; on give-up the bump stays.
|
||||
function refetchPostAfterRetally(postGrpId, postMsgId, baseline, attempt = 0) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const res = await rs.rsJsonApiRequest('/rsPosted/getBoardContent', {
|
||||
boardId: postGrpId,
|
||||
contentsIds: [postMsgId],
|
||||
});
|
||||
const post = res && res.body && res.body.retval
|
||||
&& ((res.body.posts || res.body.postList || [])[0]);
|
||||
if (!post) return;
|
||||
const up = Number(post.mUpVotes || 0);
|
||||
const down = Number(post.mDownVotes || 0);
|
||||
if (up === baseline.up && down === baseline.down) {
|
||||
if (attempt < 3) refetchPostAfterRetally(postGrpId, postMsgId, baseline, attempt + 1);
|
||||
return;
|
||||
}
|
||||
if (!Data.Posts[postGrpId]) Data.Posts[postGrpId] = {};
|
||||
Data.Posts[postGrpId][postMsgId] = { post, isSearched: true };
|
||||
m.redraw();
|
||||
} catch (e) { /* a failed refetch leaves the bump */ }
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
async function voteForPost(postGrpId, postMsgId, voteType, voterId = null) {
|
||||
try {
|
||||
let authorId = voterId;
|
||||
if (!authorId) {
|
||||
// Goes through people_util so the endpoints and their caching stay in
|
||||
// one place: /rsIdentity/getOwnIds is deprecated and answers 404.
|
||||
const ownIds = await peopleUtil.ownIds();
|
||||
if (ownIds.length === 0) {
|
||||
alert('No identity found to vote.');
|
||||
return false;
|
||||
}
|
||||
authorId = ownIds[0];
|
||||
}
|
||||
|
||||
const res = await rs.rsJsonApiRequest('/rsPosted/voteForPost', {
|
||||
postGrpId,
|
||||
postMsgId,
|
||||
authorId,
|
||||
vote: voteType,
|
||||
});
|
||||
|
||||
if (res && res.body && res.body.retval) {
|
||||
// No immediate refetch: a post's count lives in mMeta.mServiceString,
|
||||
// retallied by the core's background pass -- one board per ~15 s tick,
|
||||
// landing asynchronously -- so an immediate getBoardContent returns
|
||||
// the OLD count and would replace the cached post object out from
|
||||
// under the callers' optimistic +1. Callers bump the number they
|
||||
// render; the refetch below waits for the retally to actually show.
|
||||
const entry = Data.Posts[postGrpId] && Data.Posts[postGrpId][postMsgId];
|
||||
const baseline = entry && entry.post
|
||||
? { up: Number(entry.post.mUpVotes || 0), down: Number(entry.post.mDownVotes || 0) }
|
||||
: null;
|
||||
if (baseline) refetchPostAfterRetally(postGrpId, postMsgId, baseline);
|
||||
m.redraw();
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('voteForPost error:', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function voteForComment(boardId, postId, commentId, voteType, authorId) {
|
||||
if (!authorId) return false;
|
||||
try {
|
||||
const res = await rs.rsJsonApiRequest('/rsPosted/voteForComment', {
|
||||
boardId,
|
||||
postId,
|
||||
commentId,
|
||||
authorId,
|
||||
vote: voteType,
|
||||
});
|
||||
if (res && res.body && res.body.retval) {
|
||||
updateDisplayBoards(boardId);
|
||||
m.redraw();
|
||||
return true;
|
||||
}
|
||||
console.warn('voteForComment failed:', res && res.body && res.body.errorMessage);
|
||||
} catch (error) {
|
||||
console.error('voteForComment error:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Data,
|
||||
updateDisplayBoards,
|
||||
updateContent,
|
||||
BoardSummary,
|
||||
BoardTable,
|
||||
SearchBar,
|
||||
popupmessage,
|
||||
BoardSummary,
|
||||
DisplayBoardsFromList,
|
||||
updateDisplayBoards,
|
||||
BoardTable,
|
||||
voteForPost,
|
||||
voteForComment,
|
||||
plainText,
|
||||
GXS_VOTE_UP,
|
||||
GXS_VOTE_DOWN,
|
||||
GROUP_SUBSCRIBE_ADMIN,
|
||||
GROUP_SUBSCRIBE_NOT_SUBSCRIBED,
|
||||
GROUP_SUBSCRIBE_PUBLISH,
|
||||
GROUP_SUBSCRIBE_SUBSCRIBED,
|
||||
GROUP_SUBSCRIBE_NOT_SUBSCRIBED,
|
||||
GROUP_MY_BOARD,
|
||||
GXS_VOTE_DOWN,
|
||||
GXS_VOTE_UP,
|
||||
PUBLIC,
|
||||
EXTERNAL,
|
||||
NODES_GROUP,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
})
|
||||
),
|
||||
])
|
||||
),
|
||||
]),
|
||||
|
||||
@ -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
@ -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 })),
|
||||
];
|
||||
|
||||
@ -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,
|
||||
]),
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
})
|
||||
),
|
||||
])
|
||||
|
||||
@ -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',
|
||||
})
|
||||
),
|
||||
])
|
||||
|
||||
@ -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
@ -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 = () => ({
|
||||
|
||||
33
webui-src/app/chat/chat_preview.js
Normal file
33
webui-src/app/chat/chat_preview.js
Normal 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) || /<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;
|
||||
@ -89,9 +89,165 @@ function getStatusTooltip(status) {
|
||||
}
|
||||
}
|
||||
|
||||
// Chat messages travel as HTML. Stripping the tags is not enough: the entities
|
||||
// they leave behind are still raw text and end up displayed verbatim, the most
|
||||
// visible one being the that Qt emits for leading and repeated spaces.
|
||||
// A textarea decodes them without ever parsing markup, since its content model
|
||||
// is plain text and nothing in the string can become an element.
|
||||
function decodeHtmlEntities(text) {
|
||||
const el = document.createElement('textarea');
|
||||
el.innerHTML = text;
|
||||
return el.value;
|
||||
}
|
||||
|
||||
// Turn the HTML payload of a chat message into the text we display.
|
||||
function htmlToText(text) {
|
||||
return decodeHtmlEntities(
|
||||
text
|
||||
.replaceAll('<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 = '×';
|
||||
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
285
webui-src/app/comments.js
Normal 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,
|
||||
};
|
||||
@ -16,6 +16,7 @@ const Layout = {
|
||||
m(widget.Sidebar, {
|
||||
tabs: Object.keys(sections),
|
||||
baseRoute: '/config/',
|
||||
mobileDrawer: true,
|
||||
}),
|
||||
m('.node-panel', vnode.children),
|
||||
],
|
||||
|
||||
252
webui-src/app/debug/debug.js
Normal file
252
webui-src/app/debug/debug.js
Normal 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
79
webui-src/app/dialog.js
Normal 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;
|
||||
@ -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,
|
||||
|
||||
@ -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.'))
|
||||
),
|
||||
]);
|
||||
},
|
||||
|
||||
@ -35,6 +35,7 @@ const Layout = {
|
||||
m(widget.Sidebar, {
|
||||
tabs: Object.keys(sections),
|
||||
baseRoute: '/files/',
|
||||
mobileDrawer: true,
|
||||
}),
|
||||
m('.node-panel', m('.widget', vnode.children)),
|
||||
],
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
})
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -2,63 +2,149 @@ const m = require('mithril');
|
||||
const rs = require('rswebui');
|
||||
const util = require('forums/forums_util');
|
||||
const peopleUtil = require('people/people_util');
|
||||
const chatEmoji = require('chat/chat_emoji');
|
||||
const { loadPostContent, getTimestampValue, formatTimestamp } = require('./forums_util');
|
||||
const CIRCLE_PUBLIC = 1;
|
||||
const CIRCLE_EXTERNAL = 2;
|
||||
|
||||
function createforum() {
|
||||
let title;
|
||||
let body;
|
||||
let identity;
|
||||
let circle = CIRCLE_PUBLIC;
|
||||
let circles = [];
|
||||
let selectedCircle;
|
||||
let enableModerators = false;
|
||||
let moderatorFilter = 'all';
|
||||
let moderatorSearch = '';
|
||||
const moderators = new Set();
|
||||
return {
|
||||
oninit: (vnode) => {
|
||||
oninit: async (vnode) => {
|
||||
if (vnode.attrs.authorId) {
|
||||
identity = vnode.attrs.authorId[0];
|
||||
}
|
||||
const res = await rs.rsJsonApiRequest('/rsgxscircles/getCirclesSummaries');
|
||||
if (res.body.retval) {
|
||||
circles = res.body.circles || [];
|
||||
selectedCircle = circles[0];
|
||||
}
|
||||
},
|
||||
view: (vnode) =>
|
||||
m('.widget', [
|
||||
m('h3', 'Create Forum'),
|
||||
m('hr'),
|
||||
m('input[type=text][placeholder=Title]', {
|
||||
view: (vnode) => {
|
||||
const query = moderatorSearch.trim().toLowerCase();
|
||||
const identities = (rs.userList.users || [])
|
||||
.filter((item) => item && item.mGroupId)
|
||||
.filter((item) => moderatorFilter !== 'contacts' ||
|
||||
(rs.userList.userMap[item.mGroupId] && rs.userList.userMap[item.mGroupId].isContact))
|
||||
.filter((item) => !query || `${item.mGroupName} ${item.mGroupId}`.toLowerCase().includes(query))
|
||||
.sort((a, b) => (a.mGroupName || '').localeCompare(b.mGroupName || ''));
|
||||
return m('.widget.create-forum-form', [
|
||||
m('.create-forum-form__heading', [
|
||||
m('h3', 'Create Forum'),
|
||||
m('p', 'Set up the forum and choose its publishing permissions.'),
|
||||
]),
|
||||
m('input.create-forum-form__title[type=text][placeholder=Forum title]', {
|
||||
oninput: (e) => (title = e.target.value),
|
||||
}),
|
||||
m('label[for=tags]', 'Select identity'),
|
||||
m(
|
||||
'select[id=idtags]',
|
||||
{
|
||||
m('.create-forum-form__field', [
|
||||
m('label[for=forum-idtags]', 'Owner identity'),
|
||||
m('select.config-style-select[id=forum-idtags]', {
|
||||
value: identity,
|
||||
onchange: (e) => {
|
||||
identity = vnode.attrs.authorId[e.target.selectedIndex];
|
||||
},
|
||||
},
|
||||
[
|
||||
vnode.attrs.authorId &&
|
||||
vnode.attrs.authorId.map((o) =>
|
||||
m(
|
||||
'option',
|
||||
{ value: o },
|
||||
rs.userList.username(o)
|
||||
? rs.userList.username(o) + ' (' + o.slice(0, 8) + '...)'
|
||||
: 'No Signature'
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
m('textarea[rows=5][placeholder=Description]', {
|
||||
style: { width: '90%', display: 'block' },
|
||||
onchange: (e) => (identity = vnode.attrs.authorId[e.target.selectedIndex]),
|
||||
}, vnode.attrs.authorId && vnode.attrs.authorId.map((o) => m('option', { value: o },
|
||||
Number(o) === 0 ? 'No Signature' : `${rs.userList.username(o)} (${o.slice(0, 8)}...)`))),
|
||||
]),
|
||||
m('.create-forum-form__field', [
|
||||
m('label[for=forum-distribution]', 'Message distribution'),
|
||||
m('select.config-style-select[id=forum-distribution]', {
|
||||
value: circle,
|
||||
onchange: (e) => (circle = e.target.value),
|
||||
}, [
|
||||
m('option', { value: CIRCLE_PUBLIC }, '\u{1F310} Public'),
|
||||
m('option', { value: CIRCLE_EXTERNAL }, '\u25C9 Restricted to External Circle'),
|
||||
]),
|
||||
]),
|
||||
Number(circle) === CIRCLE_EXTERNAL && m('.create-forum-form__field', [
|
||||
m('label[for=forum-circle]', 'Circle'),
|
||||
m('select.config-style-select[id=forum-circle]', {
|
||||
value: selectedCircle && selectedCircle.mGroupId,
|
||||
onchange: (e) => (selectedCircle = circles.find((item) => item.mGroupId === e.target.value)),
|
||||
}, circles.length
|
||||
? circles.map((item) => m('option', { value: item.mGroupId }, item.mGroupName))
|
||||
: m('option[disabled]', 'No circles available')),
|
||||
]),
|
||||
m('.create-forum-form__moderators', [
|
||||
m('.create-forum-form__moderators-heading', [
|
||||
m('label.create-forum-form__moderators-toggle', [
|
||||
m('input[type=checkbox]', {
|
||||
checked: enableModerators,
|
||||
onchange: (e) => {
|
||||
enableModerators = e.target.checked;
|
||||
if (!enableModerators) {
|
||||
moderators.clear();
|
||||
}
|
||||
},
|
||||
}),
|
||||
m('span', 'Add moderators'),
|
||||
]),
|
||||
enableModerators && m('span', `${moderators.size} selected`),
|
||||
]),
|
||||
enableModerators && m('.create-forum-form__moderator-controls', [
|
||||
m('select.config-style-select[id=forum-moderator-filter]', {
|
||||
value: moderatorFilter,
|
||||
onchange: (e) => {
|
||||
moderatorFilter = e.target.value;
|
||||
},
|
||||
}, [
|
||||
m('option[value=all]', 'All identities'),
|
||||
m('option[value=contacts]', 'My contacts'),
|
||||
]),
|
||||
m('.create-forum-form__search', [
|
||||
m('i.fas.fa-search'),
|
||||
m('input[id=forum-moderator-search][type=search][placeholder=Search identities]', {
|
||||
value: moderatorSearch,
|
||||
oninput: (e) => (moderatorSearch = e.target.value),
|
||||
}),
|
||||
]),
|
||||
m('.create-forum-form__moderator-list', identities.length
|
||||
? identities.map((item) => m('label.create-forum-form__moderator', [
|
||||
m('input[type=checkbox]', {
|
||||
checked: moderators.has(item.mGroupId),
|
||||
onchange: (e) => e.target.checked
|
||||
? moderators.add(item.mGroupId)
|
||||
: moderators.delete(item.mGroupId),
|
||||
}),
|
||||
m(peopleUtil.UserAvatar, {
|
||||
firstLetter: (item.mGroupName || '?').slice(0, 1).toUpperCase(),
|
||||
identityId: item.mGroupId,
|
||||
size: 30,
|
||||
isSquare: true,
|
||||
}),
|
||||
m('span', [
|
||||
m('b', item.mGroupName || 'Unnamed identity'),
|
||||
m('small', item.mGroupId),
|
||||
]),
|
||||
]))
|
||||
: m('.create-forum-form__empty', query ? 'No matching identities' : 'No identities available')),
|
||||
]),
|
||||
]),
|
||||
m('textarea.create-forum-form__description[rows=5][placeholder=Describe your forum]', {
|
||||
oninput: (e) => (body = e.target.value),
|
||||
value: body,
|
||||
}),
|
||||
m(
|
||||
'button',
|
||||
m('button.create-forum-form__submit',
|
||||
{
|
||||
onclick: async () => {
|
||||
const res = await rs.rsJsonApiRequest('/rsgxsforums/createForumV2', {
|
||||
name: title,
|
||||
description: body,
|
||||
...(Number(identity) !== 0 && { authorId: identity }), // if id == '0', authorId is left empty
|
||||
...(Number(identity) !== 0 && { authorId: identity }),
|
||||
moderatorsIds: enableModerators ? Array.from(moderators) : [],
|
||||
circleType: Number(circle),
|
||||
...(Number(circle) === CIRCLE_EXTERNAL && selectedCircle && { circleId: selectedCircle.mGroupId }),
|
||||
});
|
||||
if (res.body.retval) {
|
||||
util.updatedisplayforums(res.body.forumId);
|
||||
await util.updatedisplayforums(res.body.forumId);
|
||||
if (vnode.attrs.onCreated) await vnode.attrs.onCreated();
|
||||
m.redraw();
|
||||
}
|
||||
res.body.retval === false
|
||||
@ -72,70 +158,335 @@ function createforum() {
|
||||
},
|
||||
'Create'
|
||||
),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
const AddThread = () => {
|
||||
const MAX_GXS_MESSAGE_SIZE = 199000;
|
||||
let title = '';
|
||||
let body = '';
|
||||
let identity;
|
||||
let showEmojiPicker = false;
|
||||
let emojiCategory = 'Smileys';
|
||||
let showFilePanel = false;
|
||||
let isFullscreen = false;
|
||||
let filePath = '';
|
||||
let filePathNeedsPrefix = false;
|
||||
let fileHashing = false;
|
||||
let fileError = '';
|
||||
let closed = false;
|
||||
const attachments = [];
|
||||
const inlineImages = [];
|
||||
|
||||
const escapeHtml = (value) => String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const pollFileHash = (localpath, attempt = 0) => {
|
||||
if (closed) return;
|
||||
rs.rsJsonApiRequest('/rsFiles/ExtraFileStatus', { localpath }, (data) => {
|
||||
if (closed) return;
|
||||
const info = data && data.retval && data.info;
|
||||
if (info && info.hash && info.hash !== '0000000000000000000000000000000000000000') {
|
||||
const size = Number(info.size && (info.size.xint64 || info.size.xstr64 || info.size)) || 0;
|
||||
if (!attachments.some((file) => file.hash === info.hash)) {
|
||||
attachments.push({ name: info.name, size, hash: info.hash });
|
||||
}
|
||||
fileHashing = false;
|
||||
filePath = '';
|
||||
showFilePanel = false;
|
||||
fileError = '';
|
||||
m.redraw();
|
||||
} else if (fileHashing && attempt < 120) {
|
||||
setTimeout(() => pollFileHash(localpath, attempt + 1), 500);
|
||||
} else {
|
||||
fileHashing = false;
|
||||
fileError = 'RetroShare could not hash this file. Check the full local path.';
|
||||
m.redraw();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const attachFile = () => {
|
||||
const localpath = filePath.trim();
|
||||
if (!localpath || filePathNeedsPrefix || fileHashing) return;
|
||||
fileHashing = true;
|
||||
fileError = '';
|
||||
rs.rsJsonApiRequest('/rsFiles/ExtraFileHash', {
|
||||
localpath,
|
||||
period: 86400 * 7,
|
||||
flags: 0,
|
||||
}, (data, success) => {
|
||||
if (success && data && data.retval) {
|
||||
pollFileHash(localpath);
|
||||
} else {
|
||||
fileHashing = false;
|
||||
fileError = 'Failed to start file hashing. Check the full local path.';
|
||||
m.redraw();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const addInlineImages = (files) => {
|
||||
Array.from(files || []).forEach((file) => {
|
||||
const image = new Image();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
image.onload = () => {
|
||||
let width = image.naturalWidth;
|
||||
let height = image.naturalHeight;
|
||||
const scale = Math.min(1, 640 / width, 480 / height);
|
||||
width = Math.max(1, Math.round(width * scale));
|
||||
height = Math.max(1, Math.round(height * scale));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d');
|
||||
context.fillStyle = '#ffffff';
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
|
||||
let quality = .84;
|
||||
let dataUrl = canvas.toDataURL('image/jpeg', quality);
|
||||
while (dataUrl.length > 175000 && (quality > .35 || width > 160 || height > 120)) {
|
||||
if (quality > .35) {
|
||||
quality = Math.max(.35, quality - .08);
|
||||
} else {
|
||||
width = Math.max(160, Math.round(width * .82));
|
||||
height = Math.max(120, Math.round(height * .82));
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
context.fillStyle = '#ffffff';
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
}
|
||||
dataUrl = canvas.toDataURL('image/jpeg', quality);
|
||||
}
|
||||
inlineImages.push({ name: file.name, dataUrl });
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
m.redraw();
|
||||
};
|
||||
image.onerror = () => URL.revokeObjectURL(objectUrl);
|
||||
image.src = objectUrl;
|
||||
});
|
||||
};
|
||||
|
||||
// The GXS limit is expressed in bytes, not in JS characters: an accent is two
|
||||
// bytes and an emoji four, while both count as one or two units of .length.
|
||||
// With an emoji picker one click away, counting characters lets the composer
|
||||
// accept a message the core then rejects.
|
||||
const byteLength = (value) => new TextEncoder().encode(value).length;
|
||||
|
||||
const postBody = () => {
|
||||
const message = escapeHtml(body).replace(/\r?\n/g, '<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)}&size=${file.size}&hash=${file.hash}">📎 ${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)),
|
||||
]),
|
||||
])
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
@ -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 })),
|
||||
];
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
})
|
||||
),
|
||||
])
|
||||
|
||||
@ -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',
|
||||
})
|
||||
),
|
||||
])
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 = () =>
|
||||
|
||||
@ -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)
|
||||
)
|
||||
),
|
||||
])
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
47
webui-src/app/mail/mail_identity_tooltip.js
Normal file
47
webui-src/app/mail/mail_identity_tooltip.js
Normal 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;
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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.'
|
||||
),
|
||||
]),
|
||||
]),
|
||||
|
||||
@ -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')
|
||||
])
|
||||
])
|
||||
]),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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'
|
||||
),
|
||||
]),
|
||||
])
|
||||
)
|
||||
]);
|
||||
})
|
||||
),
|
||||
]),
|
||||
]);
|
||||
|
||||
@ -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'),
|
||||
]),
|
||||
]
|
||||
);
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
|
||||
376
webui-src/app/network/network_graph.js
Normal file
376
webui-src/app/network/network_graph.js
Normal 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;
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
77
webui-src/app/people/people_attach.js
Normal file
77
webui-src/app/people/people_attach.js
Normal 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 };
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
@ -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 };
|
||||
|
||||
@ -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
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
308
webui-src/app/scss/_responsive.scss
Normal file
308
webui-src/app/scss/_responsive.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
385
webui-src/app/scss/components/_comments.scss
Normal file
385
webui-src/app/scss/components/_comments.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
@forward 'buttons';
|
||||
@forward 'comments';
|
||||
@forward 'media';
|
||||
@forward 'navbar';
|
||||
@forward 'posts';
|
||||
|
||||
@ -21,4 +21,15 @@
|
||||
&__desc {
|
||||
flex-basis: 60%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
&__desc {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&__details {
|
||||
flex-basis: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
@ -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
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
647
webui-src/app/scss/pages/_debug.scss
Normal file
647
webui-src/app/scss/pages/_debug.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,3 +9,5 @@
|
||||
@forward "forums";
|
||||
@forward "board";
|
||||
@forward "config";
|
||||
@forward "statistics";
|
||||
@forward "debug";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
707
webui-src/app/scss/pages/_statistics.scss
Normal file
707
webui-src/app/scss/pages/_statistics.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
3
webui-src/app/scss/vendors/_solid.scss
vendored
3
webui-src/app/scss/vendors/_solid.scss
vendored
@ -16,7 +16,8 @@
|
||||
}
|
||||
|
||||
.fa,
|
||||
.fas {
|
||||
.fas,
|
||||
.far {
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
368
webui-src/app/statistics/bandwidth.js
Normal file
368
webui-src/app/statistics/bandwidth.js
Normal 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;
|
||||
347
webui-src/app/statistics/statistics.js
Normal file
347
webui-src/app/statistics/statistics.js
Normal 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,
|
||||
]),
|
||||
]);
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user