mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-17 20:50:29 +03:00
(chore) remove outdated code (#1765)
* optimize room typing members hook * remove unused code - WIP * remove old code from initMatrix * remove twemojify function * remove old sanitize util * delete old markdown util * delete Math atom component * uninstall unused dependencies * remove old notification system * decrypt message in inbox notification center and fix refresh in background * improve notification --------- Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
This commit is contained in:
parent
60e022035f
commit
4f09e6bbb5
147 changed files with 1164 additions and 15330 deletions
|
|
@ -2,11 +2,10 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import './CreateRoom.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { selectRoom, openReusableContextMenu } from '../../../client/action/navigation';
|
||||
import { openReusableContextMenu } from '../../../client/action/navigation';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
import { isRoomAliasAvailable, getIdServer } from '../../../util/matrixUtil';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
|
|
@ -32,12 +31,14 @@ import SpaceLockIC from '../../../../public/res/ic/outlined/space-lock.svg';
|
|||
import SpaceGlobeIC from '../../../../public/res/ic/outlined/space-globe.svg';
|
||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
|
||||
function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
||||
const [joinRule, setJoinRule] = useState(parentId ? 'restricted' : 'invite');
|
||||
const [isEncrypted, setIsEncrypted] = useState(true);
|
||||
const [isCreatingRoom, setIsCreatingRoom] = useState(false);
|
||||
const [creatingError, setCreatingError] = useState(null);
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
|
||||
const [isValidAddress, setIsValidAddress] = useState(null);
|
||||
const [addressValue, setAddressValue] = useState(undefined);
|
||||
|
|
@ -48,25 +49,6 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
|||
const mx = initMatrix.matrixClient;
|
||||
const userHs = getIdServer(mx.getUserId());
|
||||
|
||||
useEffect(() => {
|
||||
const { roomList } = initMatrix;
|
||||
const onCreated = (roomId) => {
|
||||
setIsCreatingRoom(false);
|
||||
setCreatingError(null);
|
||||
setIsValidAddress(null);
|
||||
setAddressValue(undefined);
|
||||
|
||||
if (!mx.getRoom(roomId)?.isSpaceRoom()) {
|
||||
selectRoom(roomId);
|
||||
}
|
||||
onRequestClose();
|
||||
};
|
||||
roomList.on(cons.events.roomList.ROOM_CREATED, onCreated);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.ROOM_CREATED, onCreated);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (evt) => {
|
||||
evt.preventDefault();
|
||||
const { target } = evt;
|
||||
|
|
@ -87,16 +69,26 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
|||
const powerLevel = roleIndex === 1 ? 101 : undefined;
|
||||
|
||||
try {
|
||||
await roomActions.createRoom({
|
||||
const data = await roomActions.createRoom({
|
||||
name,
|
||||
topic,
|
||||
joinRule,
|
||||
alias: roomAlias,
|
||||
isEncrypted: (isSpace || joinRule === 'public') ? false : isEncrypted,
|
||||
isEncrypted: isSpace || joinRule === 'public' ? false : isEncrypted,
|
||||
powerLevel,
|
||||
isSpace,
|
||||
parentId,
|
||||
});
|
||||
setIsCreatingRoom(false);
|
||||
setCreatingError(null);
|
||||
setIsValidAddress(null);
|
||||
setAddressValue(undefined);
|
||||
onRequestClose();
|
||||
if (isSpace) {
|
||||
navigateSpace(data.room_id);
|
||||
} else {
|
||||
navigateRoom(data.room_id);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message === 'M_UNKNOWN: Invalid characters in room alias') {
|
||||
setCreatingError('ERROR: Invalid characters in address');
|
||||
|
|
@ -131,36 +123,35 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
|||
|
||||
const joinRules = ['invite', 'restricted', 'public'];
|
||||
const joinRuleShortText = ['Private', 'Restricted', 'Public'];
|
||||
const joinRuleText = ['Private (invite only)', 'Restricted (space member can join)', 'Public (anyone can join)'];
|
||||
const joinRuleText = [
|
||||
'Private (invite only)',
|
||||
'Restricted (space member can join)',
|
||||
'Public (anyone can join)',
|
||||
];
|
||||
const jrRoomIC = [HashLockIC, HashIC, HashGlobeIC];
|
||||
const jrSpaceIC = [SpaceLockIC, SpaceIC, SpaceGlobeIC];
|
||||
const handleJoinRule = (evt) => {
|
||||
openReusableContextMenu(
|
||||
'bottom',
|
||||
getEventCords(evt, '.btn-surface'),
|
||||
(closeMenu) => (
|
||||
<>
|
||||
<MenuHeader>Visibility (who can join)</MenuHeader>
|
||||
{
|
||||
joinRules.map((rule) => (
|
||||
<MenuItem
|
||||
key={rule}
|
||||
variant={rule === joinRule ? 'positive' : 'surface'}
|
||||
iconSrc={
|
||||
isSpace
|
||||
? jrSpaceIC[joinRules.indexOf(rule)]
|
||||
: jrRoomIC[joinRules.indexOf(rule)]
|
||||
}
|
||||
onClick={() => { closeMenu(); setJoinRule(rule); }}
|
||||
disabled={!parentId && rule === 'restricted'}
|
||||
>
|
||||
{ joinRuleText[joinRules.indexOf(rule)] }
|
||||
</MenuItem>
|
||||
))
|
||||
}
|
||||
</>
|
||||
),
|
||||
);
|
||||
openReusableContextMenu('bottom', getEventCords(evt, '.btn-surface'), (closeMenu) => (
|
||||
<>
|
||||
<MenuHeader>Visibility (who can join)</MenuHeader>
|
||||
{joinRules.map((rule) => (
|
||||
<MenuItem
|
||||
key={rule}
|
||||
variant={rule === joinRule ? 'positive' : 'surface'}
|
||||
iconSrc={
|
||||
isSpace ? jrSpaceIC[joinRules.indexOf(rule)] : jrRoomIC[joinRules.indexOf(rule)]
|
||||
}
|
||||
onClick={() => {
|
||||
closeMenu();
|
||||
setJoinRule(rule);
|
||||
}}
|
||||
disabled={!parentId && rule === 'restricted'}
|
||||
>
|
||||
{joinRuleText[joinRules.indexOf(rule)]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -168,50 +159,64 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
|||
<form className="create-room__form" onSubmit={handleSubmit}>
|
||||
<SettingTile
|
||||
title="Visibility"
|
||||
options={(
|
||||
options={
|
||||
<Button onClick={handleJoinRule} iconSrc={ChevronBottomIC}>
|
||||
{joinRuleShortText[joinRules.indexOf(joinRule)]}
|
||||
</Button>
|
||||
)}
|
||||
content={<Text variant="b3">{`Select who can join this ${isSpace ? 'space' : 'room'}.`}</Text>}
|
||||
}
|
||||
content={
|
||||
<Text variant="b3">{`Select who can join this ${isSpace ? 'space' : 'room'}.`}</Text>
|
||||
}
|
||||
/>
|
||||
{joinRule === 'public' && (
|
||||
<div>
|
||||
<Text className="create-room__address__label" variant="b2">{isSpace ? 'Space address' : 'Room address'}</Text>
|
||||
<Text className="create-room__address__label" variant="b2">
|
||||
{isSpace ? 'Space address' : 'Room address'}
|
||||
</Text>
|
||||
<div className="create-room__address">
|
||||
<Text variant="b1">#</Text>
|
||||
<Input
|
||||
value={addressValue}
|
||||
onChange={validateAddress}
|
||||
state={(isValidAddress === false) ? 'error' : 'normal'}
|
||||
state={isValidAddress === false ? 'error' : 'normal'}
|
||||
forwardRef={addressRef}
|
||||
placeholder="my_address"
|
||||
required
|
||||
/>
|
||||
<Text variant="b1">{`:${userHs}`}</Text>
|
||||
</div>
|
||||
{isValidAddress === false && <Text className="create-room__address__tip" variant="b3"><span style={{ color: 'var(--bg-danger)' }}>{`#${addressValue}:${userHs} is already in use`}</span></Text>}
|
||||
{isValidAddress === false && (
|
||||
<Text className="create-room__address__tip" variant="b3">
|
||||
<span
|
||||
style={{ color: 'var(--bg-danger)' }}
|
||||
>{`#${addressValue}:${userHs} is already in use`}</span>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isSpace && joinRule !== 'public' && (
|
||||
<SettingTile
|
||||
title="Enable end-to-end encryption"
|
||||
options={<Toggle isActive={isEncrypted} onToggle={setIsEncrypted} />}
|
||||
content={<Text variant="b3">You can’t disable this later. Bridges & most bots won’t work yet.</Text>}
|
||||
content={
|
||||
<Text variant="b3">
|
||||
You can’t disable this later. Bridges & most bots won’t work yet.
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SettingTile
|
||||
title="Select your role"
|
||||
options={(
|
||||
options={
|
||||
<SegmentControl
|
||||
selected={roleIndex}
|
||||
segments={[{ text: 'Admin' }, { text: 'Founder' }]}
|
||||
onSelect={setRoleIndex}
|
||||
/>
|
||||
)}
|
||||
content={(
|
||||
}
|
||||
content={
|
||||
<Text variant="b3">Selecting Admin sets 100 power level whereas Founder sets 101.</Text>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
<Input name="topic" minHeight={174} resizable label="Topic (optional)" />
|
||||
<div className="create-room__name-wrapper">
|
||||
|
|
@ -231,7 +236,11 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
|||
<Text>{`Creating ${isSpace ? 'space' : 'room'}...`}</Text>
|
||||
</div>
|
||||
)}
|
||||
{typeof creatingError === 'string' && <Text className="create-room__error" variant="b3">{creatingError}</Text>}
|
||||
{typeof creatingError === 'string' && (
|
||||
<Text className="create-room__error" variant="b3">
|
||||
{creatingError}
|
||||
</Text>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -275,27 +284,22 @@ function CreateRoom() {
|
|||
return (
|
||||
<Dialog
|
||||
isOpen={create !== null}
|
||||
title={(
|
||||
title={
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
{parentId ? twemojify(room.name) : 'Home'}
|
||||
{parentId ? room.name : 'Home'}
|
||||
<span style={{ color: 'var(--tc-surface-low)' }}>
|
||||
{` — create ${isSpace ? 'space' : 'room'}`}
|
||||
</span>
|
||||
</Text>
|
||||
)}
|
||||
}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
|
||||
onRequestClose={onRequestClose}
|
||||
>
|
||||
{
|
||||
create
|
||||
? (
|
||||
<CreateRoomContent
|
||||
isSpace={isSpace}
|
||||
parentId={parentId}
|
||||
onRequestClose={onRequestClose}
|
||||
/>
|
||||
) : <div />
|
||||
}
|
||||
{create ? (
|
||||
<CreateRoomContent isSpace={isSpace} parentId={parentId} onRequestClose={onRequestClose} />
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,356 +0,0 @@
|
|||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './EmojiBoard.scss';
|
||||
|
||||
import parse from 'html-react-parser';
|
||||
import twemoji from 'twemoji';
|
||||
import { emojiGroups, emojis } from './emoji';
|
||||
import { getRelevantPacks } from './custom-emoji';
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import AsyncSearch from '../../../util/AsyncSearch';
|
||||
import { addRecentEmoji, getRecentEmojis } from './recent';
|
||||
import { TWEMOJI_BASE_URL } from '../../../util/twemojify';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
|
||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
import RecentClockIC from '../../../../public/res/ic/outlined/recent-clock.svg';
|
||||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
||||
import DogIC from '../../../../public/res/ic/outlined/dog.svg';
|
||||
import CupIC from '../../../../public/res/ic/outlined/cup.svg';
|
||||
import BallIC from '../../../../public/res/ic/outlined/ball.svg';
|
||||
import PhotoIC from '../../../../public/res/ic/outlined/photo.svg';
|
||||
import BulbIC from '../../../../public/res/ic/outlined/bulb.svg';
|
||||
import PeaceIC from '../../../../public/res/ic/outlined/peace.svg';
|
||||
import FlagIC from '../../../../public/res/ic/outlined/flag.svg';
|
||||
|
||||
const ROW_EMOJIS_COUNT = 7;
|
||||
|
||||
const EmojiGroup = React.memo(({ name, groupEmojis }) => {
|
||||
function getEmojiBoard() {
|
||||
const emojiBoard = [];
|
||||
const totalEmojis = groupEmojis.length;
|
||||
|
||||
for (let r = 0; r < totalEmojis; r += ROW_EMOJIS_COUNT) {
|
||||
const emojiRow = [];
|
||||
for (let c = r; c < r + ROW_EMOJIS_COUNT; c += 1) {
|
||||
const emojiIndex = c;
|
||||
if (emojiIndex >= totalEmojis) break;
|
||||
const emoji = groupEmojis[emojiIndex];
|
||||
emojiRow.push(
|
||||
<span key={emojiIndex}>
|
||||
{emoji.hexcode ? (
|
||||
// This is a unicode emoji, and should be rendered with twemoji
|
||||
parse(
|
||||
twemoji.parse(emoji.unicode, {
|
||||
attributes: () => ({
|
||||
unicode: emoji.unicode,
|
||||
shortcodes: emoji.shortcodes?.toString(),
|
||||
hexcode: emoji.hexcode,
|
||||
loading: 'lazy',
|
||||
}),
|
||||
base: TWEMOJI_BASE_URL,
|
||||
})
|
||||
)
|
||||
) : (
|
||||
// This is a custom emoji, and should be render as an mxc
|
||||
<img
|
||||
className="emoji"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
alt={emoji.shortcode}
|
||||
unicode={`:${emoji.shortcode}:`}
|
||||
shortcodes={emoji.shortcode}
|
||||
src={initMatrix.matrixClient.mxcUrlToHttp(emoji.mxc)}
|
||||
data-mx-emoticon={emoji.mxc}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
emojiBoard.push(
|
||||
<div key={r} className="emoji-row">
|
||||
{emojiRow}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return emojiBoard;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="emoji-group">
|
||||
<Text className="emoji-group__header" variant="b2" weight="bold">
|
||||
{name}
|
||||
</Text>
|
||||
{groupEmojis.length !== 0 && <div className="emoji-set noselect">{getEmojiBoard()}</div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
EmojiGroup.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
groupEmojis: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
length: PropTypes.number,
|
||||
unicode: PropTypes.string,
|
||||
hexcode: PropTypes.string,
|
||||
mxc: PropTypes.string,
|
||||
shortcode: PropTypes.string,
|
||||
shortcodes: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
|
||||
})
|
||||
).isRequired,
|
||||
};
|
||||
|
||||
const asyncSearch = new AsyncSearch();
|
||||
asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 40 });
|
||||
function SearchedEmoji() {
|
||||
const [searchedEmojis, setSearchedEmojis] = useState(null);
|
||||
|
||||
function handleSearchEmoji(resultEmojis, term) {
|
||||
if (term === '' || resultEmojis.length === 0) {
|
||||
if (term === '') setSearchedEmojis(null);
|
||||
else setSearchedEmojis({ emojis: [] });
|
||||
return;
|
||||
}
|
||||
setSearchedEmojis({ emojis: resultEmojis });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchEmoji);
|
||||
return () => {
|
||||
asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchEmoji);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (searchedEmojis === null) return false;
|
||||
|
||||
return (
|
||||
<EmojiGroup
|
||||
key="-1"
|
||||
name={searchedEmojis.emojis.length === 0 ? 'No search result found' : 'Search results'}
|
||||
groupEmojis={searchedEmojis.emojis}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmojiBoard({ onSelect, searchRef }) {
|
||||
const scrollEmojisRef = useRef(null);
|
||||
const emojiInfo = useRef(null);
|
||||
|
||||
function isTargetNotEmoji(target) {
|
||||
return target.classList.contains('emoji') === false;
|
||||
}
|
||||
function getEmojiDataFromTarget(target) {
|
||||
const unicode = target.getAttribute('unicode');
|
||||
const hexcode = target.getAttribute('hexcode');
|
||||
const mxc = target.getAttribute('data-mx-emoticon');
|
||||
let shortcodes = target.getAttribute('shortcodes');
|
||||
if (typeof shortcodes === 'undefined') shortcodes = undefined;
|
||||
else shortcodes = shortcodes.split(',');
|
||||
return {
|
||||
unicode,
|
||||
hexcode,
|
||||
shortcodes,
|
||||
mxc,
|
||||
};
|
||||
}
|
||||
|
||||
function selectEmoji(e) {
|
||||
if (isTargetNotEmoji(e.target)) return;
|
||||
|
||||
const emoji = getEmojiDataFromTarget(e.target);
|
||||
onSelect(emoji);
|
||||
if (emoji.hexcode) addRecentEmoji(emoji.unicode);
|
||||
}
|
||||
|
||||
function setEmojiInfo(emoji) {
|
||||
const infoEmoji = emojiInfo.current.firstElementChild.firstElementChild;
|
||||
const infoShortcode = emojiInfo.current.lastElementChild;
|
||||
|
||||
infoEmoji.src = emoji.src;
|
||||
infoEmoji.alt = emoji.unicode;
|
||||
infoShortcode.textContent = `:${emoji.shortcode}:`;
|
||||
}
|
||||
|
||||
function hoverEmoji(e) {
|
||||
if (isTargetNotEmoji(e.target)) return;
|
||||
|
||||
const emoji = e.target;
|
||||
const { shortcodes, unicode } = getEmojiDataFromTarget(emoji);
|
||||
const { src } = e.target;
|
||||
|
||||
if (typeof shortcodes === 'undefined') {
|
||||
searchRef.current.placeholder = 'Search';
|
||||
setEmojiInfo({
|
||||
unicode: '🙂',
|
||||
shortcode: 'slight_smile',
|
||||
src: 'https://twemoji.maxcdn.com/v/13.1.0/72x72/1f642.png',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (searchRef.current.placeholder === shortcodes[0]) return;
|
||||
searchRef.current.setAttribute('placeholder', shortcodes[0]);
|
||||
setEmojiInfo({ shortcode: shortcodes[0], src, unicode });
|
||||
}
|
||||
|
||||
function handleSearchChange() {
|
||||
const term = searchRef.current.value;
|
||||
asyncSearch.search(term);
|
||||
scrollEmojisRef.current.scrollTop = 0;
|
||||
}
|
||||
|
||||
const [availableEmojis, setAvailableEmojis] = useState([]);
|
||||
const [recentEmojis, setRecentEmojis] = useState([]);
|
||||
|
||||
const recentOffset = recentEmojis.length > 0 ? 1 : 0;
|
||||
|
||||
useEffect(() => {
|
||||
const updateAvailableEmoji = (selectedRoomId) => {
|
||||
if (!selectedRoomId) {
|
||||
setAvailableEmojis([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(selectedRoomId);
|
||||
const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
|
||||
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
||||
if (room) {
|
||||
const packs = getRelevantPacks(room.client, [room, ...parentRooms]).filter(
|
||||
(pack) => pack.getEmojis().length !== 0
|
||||
);
|
||||
|
||||
// Set an index for each pack so that we know where to jump when the user uses the nav
|
||||
for (let i = 0; i < packs.length; i += 1) {
|
||||
packs[i].packIndex = i;
|
||||
}
|
||||
setAvailableEmojis(packs);
|
||||
}
|
||||
};
|
||||
|
||||
const onOpen = () => {
|
||||
searchRef.current.value = '';
|
||||
handleSearchChange();
|
||||
|
||||
// only update when board is getting opened to prevent shifting UI
|
||||
setRecentEmojis(getRecentEmojis(3 * ROW_EMOJIS_COUNT));
|
||||
};
|
||||
|
||||
navigation.on(cons.events.navigation.ROOM_SELECTED, updateAvailableEmoji);
|
||||
navigation.on(cons.events.navigation.EMOJIBOARD_OPENED, onOpen);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, updateAvailableEmoji);
|
||||
navigation.removeListener(cons.events.navigation.EMOJIBOARD_OPENED, onOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function openGroup(groupOrder) {
|
||||
let tabIndex = groupOrder;
|
||||
const $emojiContent = scrollEmojisRef.current.firstElementChild;
|
||||
const groupCount = $emojiContent.childElementCount;
|
||||
if (groupCount > emojiGroups.length) {
|
||||
tabIndex += groupCount - emojiGroups.length - availableEmojis.length - recentOffset;
|
||||
}
|
||||
$emojiContent.children[tabIndex].scrollIntoView();
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="emoji-board" className="emoji-board">
|
||||
<ScrollView invisible>
|
||||
<div className="emoji-board__nav">
|
||||
{recentEmojis.length > 0 && (
|
||||
<IconButton
|
||||
onClick={() => openGroup(0)}
|
||||
src={RecentClockIC}
|
||||
tooltip="Recent"
|
||||
tooltipPlacement="left"
|
||||
/>
|
||||
)}
|
||||
<div className="emoji-board__nav-custom">
|
||||
{availableEmojis.map((pack) => {
|
||||
const src = initMatrix.matrixClient.mxcUrlToHttp(
|
||||
pack.avatarUrl ?? pack.getEmojis()[0].mxc
|
||||
);
|
||||
return (
|
||||
<IconButton
|
||||
onClick={() => openGroup(recentOffset + pack.packIndex)}
|
||||
src={src}
|
||||
key={pack.packIndex}
|
||||
tooltip={pack.displayName ?? 'Unknown'}
|
||||
tooltipPlacement="left"
|
||||
isImage
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="emoji-board__nav-twemoji">
|
||||
{[
|
||||
[0, EmojiIC, 'Smilies'],
|
||||
[1, DogIC, 'Animals'],
|
||||
[2, CupIC, 'Food'],
|
||||
[3, BallIC, 'Activities'],
|
||||
[4, PhotoIC, 'Travel'],
|
||||
[5, BulbIC, 'Objects'],
|
||||
[6, PeaceIC, 'Symbols'],
|
||||
[7, FlagIC, 'Flags'],
|
||||
].map(([indx, ico, name]) => (
|
||||
<IconButton
|
||||
onClick={() => openGroup(recentOffset + availableEmojis.length + indx)}
|
||||
key={indx}
|
||||
src={ico}
|
||||
tooltip={name}
|
||||
tooltipPlacement="left"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
<div className="emoji-board__content">
|
||||
<div className="emoji-board__content__search">
|
||||
<RawIcon size="small" src={SearchIC} />
|
||||
<Input onChange={handleSearchChange} forwardRef={searchRef} placeholder="Search" />
|
||||
</div>
|
||||
<div className="emoji-board__content__emojis">
|
||||
<ScrollView ref={scrollEmojisRef} autoHide>
|
||||
<div onMouseMove={hoverEmoji} onClick={selectEmoji}>
|
||||
<SearchedEmoji />
|
||||
{recentEmojis.length > 0 && (
|
||||
<EmojiGroup name="Recently used" groupEmojis={recentEmojis} />
|
||||
)}
|
||||
{availableEmojis.map((pack) => (
|
||||
<EmojiGroup
|
||||
name={pack.displayName ?? 'Unknown'}
|
||||
key={pack.packIndex}
|
||||
groupEmojis={pack.getEmojis()}
|
||||
className="custom-emoji-group"
|
||||
/>
|
||||
))}
|
||||
{emojiGroups.map((group) => (
|
||||
<EmojiGroup key={group.name} name={group.name} groupEmojis={group.emojis} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
<div ref={emojiInfo} className="emoji-board__content__info">
|
||||
<div>{parse(twemoji.parse('🙂', { base: TWEMOJI_BASE_URL }))}</div>
|
||||
<Text>:slight_smile:</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EmojiBoard.propTypes = {
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
searchRef: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default EmojiBoard;
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/text';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.emoji-board {
|
||||
--emoji-board-height: 390px;
|
||||
--emoji-board-width: 286px;
|
||||
display: flex;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
|
||||
&__content {
|
||||
@extend .cp-fx__item-one;
|
||||
@extend .cp-fx__column;
|
||||
height: var(--emoji-board-height);
|
||||
width: var(--emoji-board-width);
|
||||
}
|
||||
& > .scrollbar {
|
||||
width: initial;
|
||||
height: var(--emoji-board-height);
|
||||
}
|
||||
&__nav {
|
||||
@extend .cp-fx__column;
|
||||
justify-content: center;
|
||||
|
||||
min-height: 100%;
|
||||
padding: 4px 6px;
|
||||
@include dir.side(border, none, 1px solid var(--bg-surface-border));
|
||||
|
||||
position: relative;
|
||||
|
||||
& .ic-btn-surface {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
&__nav-custom,
|
||||
&__nav-twemoji {
|
||||
@extend .cp-fx__column;
|
||||
}
|
||||
&__nav-twemoji {
|
||||
background-color: var(--bg-surface);
|
||||
position: sticky;
|
||||
bottom: -70%;
|
||||
z-index: 999;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-board__content__search {
|
||||
padding: var(--sp-extra-tight);
|
||||
position: relative;
|
||||
|
||||
& .ic-raw {
|
||||
position: absolute;
|
||||
@include dir.prop(left, var(--sp-normal), unset);
|
||||
@include dir.prop(right, unset, var(--sp-normal));
|
||||
top: var(--sp-normal);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
& .input-container {
|
||||
& .input {
|
||||
min-width: 100%;
|
||||
width: 0;
|
||||
padding: var(--sp-extra-tight) 36px;
|
||||
border-radius: calc(var(--bo-radius) / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
.emoji-board__content__emojis {
|
||||
@extend .cp-fx__item-one;
|
||||
@extend .cp-fx__column;
|
||||
}
|
||||
.emoji-board__content__info {
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
padding: var(--sp-tight) var(--sp-extra-tight);
|
||||
border-top: 1px solid var(--bg-surface-border);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& > div:first-child {
|
||||
line-height: 0;
|
||||
.emoji {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
& > p:last-child {
|
||||
@extend .cp-fx__item-one;
|
||||
@extend .cp-txt__ellipsis;
|
||||
margin: 0 var(--sp-tight);
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.emoji-group {
|
||||
--emoji-padding: 6px;
|
||||
position: relative;
|
||||
margin-bottom: var(--sp-normal);
|
||||
|
||||
&__header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
background-color: var(--bg-surface);
|
||||
|
||||
@include dir.side(margin, var(--sp-extra-tight), 0);
|
||||
padding: var(--sp-extra-tight) var(--sp-ultra-tight);
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 -4px 0 0 var(--bg-surface);
|
||||
border-bottom: 1px solid var(--bg-surface-border);
|
||||
}
|
||||
& .emoji-set {
|
||||
--left-margin: calc(var(--sp-normal) - var(--emoji-padding));
|
||||
--right-margin: calc(var(--sp-extra-tight) - var(--emoji-padding));
|
||||
margin: var(--sp-extra-tight);
|
||||
@include dir.side(margin, var(--left-margin), var(--right-margin));
|
||||
}
|
||||
& .emoji {
|
||||
max-width: 38px;
|
||||
max-height: 38px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
object-fit: contain;
|
||||
padding: var(--emoji-padding);
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: var(--bg-surface-hover);
|
||||
border-radius: var(--bo-radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import settings from '../../../client/state/settings';
|
||||
|
||||
import ContextMenu from '../../atoms/context-menu/ContextMenu';
|
||||
import EmojiBoard from './EmojiBoard';
|
||||
|
||||
let requestCallback = null;
|
||||
let isEmojiBoardVisible = false;
|
||||
function EmojiBoardOpener() {
|
||||
const openerRef = useRef(null);
|
||||
const searchRef = useRef(null);
|
||||
|
||||
function openEmojiBoard(cords, requestEmojiCallback) {
|
||||
if (requestCallback !== null || isEmojiBoardVisible) {
|
||||
requestCallback = null;
|
||||
if (cords.detail === 0) openerRef.current.click();
|
||||
return;
|
||||
}
|
||||
|
||||
openerRef.current.style.transform = `translate(${cords.x}px, ${cords.y}px)`;
|
||||
requestCallback = requestEmojiCallback;
|
||||
openerRef.current.click();
|
||||
}
|
||||
|
||||
function afterEmojiBoardToggle(isVisible) {
|
||||
isEmojiBoardVisible = isVisible;
|
||||
if (isVisible) {
|
||||
if (!settings.isTouchScreenDevice) searchRef.current.focus();
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
if (!isEmojiBoardVisible) requestCallback = null;
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function addEmoji(emoji) {
|
||||
requestCallback(emoji);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
navigation.on(cons.events.navigation.EMOJIBOARD_OPENED, openEmojiBoard);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.EMOJIBOARD_OPENED, openEmojiBoard);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
content={(
|
||||
<EmojiBoard onSelect={addEmoji} searchRef={searchRef} />
|
||||
)}
|
||||
afterToggle={afterEmojiBoardToggle}
|
||||
render={(toggleMenu) => (
|
||||
<input
|
||||
ref={openerRef}
|
||||
onClick={toggleMenu}
|
||||
type="button"
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
backgroundColor: 'transparent',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
visibility: 'hidden',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmojiBoardOpener;
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import { emojis } from './emoji';
|
||||
|
||||
// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
|
||||
|
||||
class ImagePack {
|
||||
export class ImagePack {
|
||||
static parsePack(eventId, packContent) {
|
||||
if (!eventId || typeof packContent?.images !== 'object') {
|
||||
return null;
|
||||
|
|
@ -141,127 +139,4 @@ class ImagePack {
|
|||
}
|
||||
}
|
||||
|
||||
function getGlobalImagePacks(mx) {
|
||||
const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent();
|
||||
if (typeof globalContent !== 'object') return [];
|
||||
|
||||
const { rooms } = globalContent;
|
||||
if (typeof rooms !== 'object') return [];
|
||||
|
||||
const roomIds = Object.keys(rooms);
|
||||
|
||||
const packs = roomIds.flatMap((roomId) => {
|
||||
if (typeof rooms[roomId] !== 'object') return [];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return [];
|
||||
const stateKeys = Object.keys(rooms[roomId]);
|
||||
|
||||
return stateKeys.map((stateKey) => {
|
||||
const data = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
|
||||
const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
|
||||
if (pack) {
|
||||
pack.displayName ??= room.name;
|
||||
pack.avatarUrl ??= room.getMxcAvatarUrl();
|
||||
}
|
||||
return pack;
|
||||
}).filter((pack) => pack !== null);
|
||||
});
|
||||
|
||||
return packs;
|
||||
}
|
||||
|
||||
function getUserImagePack(mx) {
|
||||
const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes');
|
||||
if (!accountDataEmoji) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userImagePack = ImagePack.parsePack(mx.getUserId(), accountDataEmoji.event.content);
|
||||
if (userImagePack) userImagePack.displayName ??= 'Personal Emoji';
|
||||
return userImagePack;
|
||||
}
|
||||
|
||||
function getRoomImagePacks(room) {
|
||||
const dataEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
|
||||
|
||||
return dataEvents
|
||||
.map((data) => {
|
||||
const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
|
||||
if (pack) {
|
||||
pack.displayName ??= room.name;
|
||||
pack.avatarUrl ??= room.getMxcAvatarUrl();
|
||||
}
|
||||
return pack;
|
||||
})
|
||||
.filter((pack) => pack !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MatrixClient} mx Provide if you want to include user personal/global pack
|
||||
* @param {Room[]} rooms Provide rooms if you want to include rooms pack
|
||||
* @returns {ImagePack[]} packs
|
||||
*/
|
||||
function getRelevantPacks(mx, rooms) {
|
||||
const userPack = mx ? getUserImagePack(mx) : [];
|
||||
const globalPacks = mx ? getGlobalImagePacks(mx) : [];
|
||||
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
|
||||
const roomsPack = rooms?.flatMap(getRoomImagePacks) ?? [];
|
||||
|
||||
return [].concat(
|
||||
userPack ?? [],
|
||||
globalPacks,
|
||||
roomsPack.filter((pack) => !globalPackIds.has(pack.id)),
|
||||
);
|
||||
}
|
||||
|
||||
function getShortcodeToEmoji(mx, rooms) {
|
||||
const allEmoji = new Map();
|
||||
|
||||
emojis.forEach((emoji) => {
|
||||
if (Array.isArray(emoji.shortcodes)) {
|
||||
emoji.shortcodes.forEach((shortcode) => {
|
||||
allEmoji.set(shortcode, emoji);
|
||||
});
|
||||
} else {
|
||||
allEmoji.set(emoji.shortcodes, emoji);
|
||||
}
|
||||
});
|
||||
|
||||
getRelevantPacks(mx, rooms)
|
||||
.flatMap((pack) => pack.getEmojis())
|
||||
.forEach((emoji) => {
|
||||
allEmoji.set(emoji.shortcode, emoji);
|
||||
});
|
||||
|
||||
return allEmoji;
|
||||
}
|
||||
|
||||
function getShortcodeToCustomEmoji(room) {
|
||||
const allEmoji = new Map();
|
||||
|
||||
getRelevantPacks(room.client, [room])
|
||||
.flatMap((pack) => pack.getEmojis())
|
||||
.forEach((emoji) => {
|
||||
allEmoji.set(emoji.shortcode, emoji);
|
||||
});
|
||||
|
||||
return allEmoji;
|
||||
}
|
||||
|
||||
function getEmojiForCompletion(mx, rooms) {
|
||||
const allEmoji = new Map();
|
||||
getRelevantPacks(mx, rooms)
|
||||
.flatMap((pack) => pack.getEmojis())
|
||||
.forEach((emoji) => {
|
||||
allEmoji.set(emoji.shortcode, emoji);
|
||||
});
|
||||
|
||||
return Array.from(allEmoji.values()).concat(emojis.filter((e) => !allEmoji.has(e.shortcode)));
|
||||
}
|
||||
|
||||
export {
|
||||
ImagePack,
|
||||
getUserImagePack, getGlobalImagePacks, getRoomImagePacks,
|
||||
getShortcodeToEmoji, getShortcodeToCustomEmoji,
|
||||
getRelevantPacks, getEmojiForCompletion,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
import emojisData from 'emojibase-data/en/compact.json';
|
||||
import joypixels from 'emojibase-data/en/shortcodes/joypixels.json';
|
||||
import emojibase from 'emojibase-data/en/shortcodes/emojibase.json';
|
||||
|
||||
const emojiGroups = [{
|
||||
name: 'Smileys & people',
|
||||
order: 0,
|
||||
emojis: [],
|
||||
}, {
|
||||
name: 'Animals & nature',
|
||||
order: 1,
|
||||
emojis: [],
|
||||
}, {
|
||||
name: 'Food & drinks',
|
||||
order: 2,
|
||||
emojis: [],
|
||||
}, {
|
||||
name: 'Activity',
|
||||
order: 3,
|
||||
emojis: [],
|
||||
}, {
|
||||
name: 'Travel & places',
|
||||
order: 4,
|
||||
emojis: [],
|
||||
}, {
|
||||
name: 'Objects',
|
||||
order: 5,
|
||||
emojis: [],
|
||||
}, {
|
||||
name: 'Symbols',
|
||||
order: 6,
|
||||
emojis: [],
|
||||
}, {
|
||||
name: 'Flags',
|
||||
order: 7,
|
||||
emojis: [],
|
||||
}];
|
||||
Object.freeze(emojiGroups);
|
||||
|
||||
function addEmoji(emoji, order) {
|
||||
emojiGroups[order].emojis.push(emoji);
|
||||
}
|
||||
function addToGroup(emoji) {
|
||||
if (emoji.group === 0 || emoji.group === 1) addEmoji(emoji, 0);
|
||||
else if (emoji.group === 3) addEmoji(emoji, 1);
|
||||
else if (emoji.group === 4) addEmoji(emoji, 2);
|
||||
else if (emoji.group === 6) addEmoji(emoji, 3);
|
||||
else if (emoji.group === 5) addEmoji(emoji, 4);
|
||||
else if (emoji.group === 7) addEmoji(emoji, 5);
|
||||
else if (emoji.group === 8 || typeof emoji.group === 'undefined') addEmoji(emoji, 6);
|
||||
else if (emoji.group === 9) addEmoji(emoji, 7);
|
||||
}
|
||||
|
||||
const emojis = [];
|
||||
emojisData.forEach((emoji) => {
|
||||
const myShortCodes = joypixels[emoji.hexcode] || emojibase[emoji.hexcode];
|
||||
if (!myShortCodes) return;
|
||||
const em = {
|
||||
...emoji,
|
||||
shortcode: Array.isArray(myShortCodes) ? myShortCodes[0] : myShortCodes,
|
||||
shortcodes: myShortCodes,
|
||||
};
|
||||
addToGroup(em);
|
||||
emojis.push(em);
|
||||
});
|
||||
|
||||
export {
|
||||
emojis, emojiGroups,
|
||||
};
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import initMatrix from '../../../client/initMatrix';
|
||||
import { emojis } from './emoji';
|
||||
|
||||
const eventType = 'io.element.recent_emoji';
|
||||
|
||||
function getRecentEmojisRaw() {
|
||||
return initMatrix.matrixClient.getAccountData(eventType)?.getContent().recent_emoji ?? [];
|
||||
}
|
||||
|
||||
export function getRecentEmojis(limit) {
|
||||
const res = [];
|
||||
getRecentEmojisRaw()
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.find(([unicode]) => {
|
||||
const emoji = emojis.find((e) => e.unicode === unicode);
|
||||
if (emoji) return res.push(emoji) >= limit;
|
||||
return false;
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
export function addRecentEmoji(unicode) {
|
||||
const recent = getRecentEmojisRaw();
|
||||
const i = recent.findIndex(([u]) => u === unicode);
|
||||
let entry;
|
||||
if (i < 0) {
|
||||
entry = [unicode, 1];
|
||||
} else {
|
||||
[entry] = recent.splice(i, 1);
|
||||
entry[1] += 1;
|
||||
}
|
||||
recent.unshift(entry);
|
||||
initMatrix.matrixClient.setAccountData(eventType, {
|
||||
recent_emoji: recent.slice(0, 100),
|
||||
});
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './EmojiVerification.scss';
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
|
|
@ -30,8 +29,9 @@ function EmojiVerificationContent({ data, requestClose }) {
|
|||
|
||||
const beginVerification = async () => {
|
||||
if (
|
||||
isCrossVerified(mx.deviceId)
|
||||
&& (mx.getCrossSigningId() === null || await mx.crypto.crossSigningInfo.isStoredInKeyCache('self_signing') === false)
|
||||
isCrossVerified(mx.deviceId) &&
|
||||
(mx.getCrossSigningId() === null ||
|
||||
(await mx.crypto.crossSigningInfo.isStoredInKeyCache('self_signing')) === false)
|
||||
) {
|
||||
if (!hasPrivateKey(getDefaultSSKey())) {
|
||||
const keyData = await accessSecretStorage('Emoji verification');
|
||||
|
|
@ -106,16 +106,20 @@ function EmojiVerificationContent({ data, requestClose }) {
|
|||
{sas.sas.emoji.map((emoji, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div className="emoji-verification__emoji-block" key={`${emoji[1]}-${i}`}>
|
||||
<Text variant="h1">{twemojify(emoji[0])}</Text>
|
||||
<Text variant="h1">{emoji[0]}</Text>
|
||||
<Text>{emoji[1]}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="emoji-verification__buttons">
|
||||
{process ? renderWait() : (
|
||||
{process ? (
|
||||
renderWait()
|
||||
) : (
|
||||
<>
|
||||
<Button variant="primary" onClick={sasConfirm}>They match</Button>
|
||||
<Button onClick={sasMismatch}>{'They don\'t match'}</Button>
|
||||
<Button variant="primary" onClick={sasConfirm}>
|
||||
They match
|
||||
</Button>
|
||||
<Button onClick={sasMismatch}>No match</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -127,9 +131,7 @@ function EmojiVerificationContent({ data, requestClose }) {
|
|||
return (
|
||||
<div className="emoji-verification__content">
|
||||
<Text>Please accept the request from other device.</Text>
|
||||
<div className="emoji-verification__buttons">
|
||||
{renderWait()}
|
||||
</div>
|
||||
<div className="emoji-verification__buttons">{renderWait()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -138,11 +140,13 @@ function EmojiVerificationContent({ data, requestClose }) {
|
|||
<div className="emoji-verification__content">
|
||||
<Text>Click accept to start the verification process.</Text>
|
||||
<div className="emoji-verification__buttons">
|
||||
{
|
||||
process
|
||||
? renderWait()
|
||||
: <Button variant="primary" onClick={beginVerification}>Accept</Button>
|
||||
}
|
||||
{process ? (
|
||||
renderWait()
|
||||
) : (
|
||||
<Button variant="primary" onClick={beginVerification}>
|
||||
Accept
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -180,19 +184,19 @@ function EmojiVerification() {
|
|||
<Dialog
|
||||
isOpen={data !== null}
|
||||
className="emoji-verification"
|
||||
title={(
|
||||
title={
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
Emoji verification
|
||||
</Text>
|
||||
)}
|
||||
}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||
onRequestClose={requestClose}
|
||||
>
|
||||
{
|
||||
data !== null
|
||||
? <EmojiVerificationContent data={data} requestClose={requestClose} />
|
||||
: <div />
|
||||
}
|
||||
{data !== null ? (
|
||||
<EmojiVerificationContent data={data} requestClose={requestClose} />
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,145 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './InviteList.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
import { selectRoom, selectTab } from '../../../client/action/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import PopupWindow from '../../molecules/popup-window/PopupWindow';
|
||||
import RoomTile from '../../molecules/room-tile/RoomTile';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
function InviteList({ isOpen, onRequestClose }) {
|
||||
const [procInvite, changeProcInvite] = useState(new Set());
|
||||
|
||||
function acceptInvite(roomId, isDM) {
|
||||
procInvite.add(roomId);
|
||||
changeProcInvite(new Set(Array.from(procInvite)));
|
||||
roomActions.join(roomId, isDM);
|
||||
}
|
||||
function rejectInvite(roomId, isDM) {
|
||||
procInvite.add(roomId);
|
||||
changeProcInvite(new Set(Array.from(procInvite)));
|
||||
roomActions.leave(roomId, isDM);
|
||||
}
|
||||
function updateInviteList(roomId) {
|
||||
if (procInvite.has(roomId)) procInvite.delete(roomId);
|
||||
changeProcInvite(new Set(Array.from(procInvite)));
|
||||
|
||||
const rl = initMatrix.roomList;
|
||||
const totalInvites = rl.inviteDirects.size + rl.inviteRooms.size + rl.inviteSpaces.size;
|
||||
const room = initMatrix.matrixClient.getRoom(roomId);
|
||||
const isRejected = room === null || room?.getMyMembership() !== 'join';
|
||||
if (!isRejected) {
|
||||
if (room.isSpaceRoom()) selectTab(roomId);
|
||||
else selectRoom(roomId);
|
||||
onRequestClose();
|
||||
}
|
||||
if (totalInvites === 0) onRequestClose();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
initMatrix.roomList.on(cons.events.roomList.INVITELIST_UPDATED, updateInviteList);
|
||||
|
||||
return () => {
|
||||
initMatrix.roomList.removeListener(cons.events.roomList.INVITELIST_UPDATED, updateInviteList);
|
||||
};
|
||||
}, [procInvite]);
|
||||
|
||||
function renderRoomTile(roomId) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const myRoom = mx.getRoom(roomId);
|
||||
if (!myRoom) return null;
|
||||
const roomName = myRoom.name;
|
||||
let roomAlias = myRoom.getCanonicalAlias();
|
||||
if (!roomAlias) roomAlias = myRoom.roomId;
|
||||
const inviterName = myRoom.getMember(mx.getUserId())?.events?.member?.getSender?.() ?? '';
|
||||
return (
|
||||
<RoomTile
|
||||
key={myRoom.roomId}
|
||||
name={roomName}
|
||||
avatarSrc={initMatrix.matrixClient.getRoom(roomId).getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop')}
|
||||
id={roomAlias}
|
||||
inviterName={inviterName}
|
||||
options={
|
||||
procInvite.has(myRoom.roomId)
|
||||
? (<Spinner size="small" />)
|
||||
: (
|
||||
<div className="invite-btn__container">
|
||||
<Button onClick={() => rejectInvite(myRoom.roomId)}>Reject</Button>
|
||||
<Button onClick={() => acceptInvite(myRoom.roomId)} variant="primary">Accept</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PopupWindow
|
||||
isOpen={isOpen}
|
||||
title="Invites"
|
||||
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
|
||||
onRequestClose={onRequestClose}
|
||||
>
|
||||
<div className="invites-content">
|
||||
{ initMatrix.roomList.inviteDirects.size !== 0 && (
|
||||
<div className="invites-content__subheading">
|
||||
<Text variant="b3" weight="bold">Direct Messages</Text>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
Array.from(initMatrix.roomList.inviteDirects).map((roomId) => {
|
||||
const myRoom = initMatrix.matrixClient.getRoom(roomId);
|
||||
if (myRoom === null) return null;
|
||||
const roomName = myRoom.name;
|
||||
return (
|
||||
<RoomTile
|
||||
key={myRoom.roomId}
|
||||
name={roomName}
|
||||
id={myRoom.getDMInviter() || roomId}
|
||||
options={
|
||||
procInvite.has(myRoom.roomId)
|
||||
? (<Spinner size="small" />)
|
||||
: (
|
||||
<div className="invite-btn__container">
|
||||
<Button onClick={() => rejectInvite(myRoom.roomId, true)}>Reject</Button>
|
||||
<Button onClick={() => acceptInvite(myRoom.roomId, true)} variant="primary">Accept</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
{ initMatrix.roomList.inviteSpaces.size !== 0 && (
|
||||
<div className="invites-content__subheading">
|
||||
<Text variant="b3" weight="bold">Spaces</Text>
|
||||
</div>
|
||||
)}
|
||||
{ Array.from(initMatrix.roomList.inviteSpaces).map(renderRoomTile) }
|
||||
|
||||
{ initMatrix.roomList.inviteRooms.size !== 0 && (
|
||||
<div className="invites-content__subheading">
|
||||
<Text variant="b3" weight="bold">Rooms</Text>
|
||||
</div>
|
||||
)}
|
||||
{ Array.from(initMatrix.roomList.inviteRooms).map(renderRoomTile) }
|
||||
</div>
|
||||
</PopupWindow>
|
||||
);
|
||||
}
|
||||
|
||||
InviteList.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onRequestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default InviteList;
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
|
||||
.invites-content {
|
||||
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
||||
|
||||
&__subheading {
|
||||
margin-top: var(--sp-extra-loose);
|
||||
|
||||
& .text {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
&:first-child {
|
||||
margin-top: var(--sp-tight);
|
||||
}
|
||||
}
|
||||
|
||||
& .room-tile {
|
||||
margin-top: var(--sp-normal);
|
||||
&__options {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
& .invite-btn__container .btn-surface {
|
||||
@include dir.side(margin, 0, var(--sp-normal));
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,8 @@ import PropTypes from 'prop-types';
|
|||
import './InviteUser.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
import { selectRoom } from '../../../client/action/navigation';
|
||||
import { hasDMWith, hasDevices } from '../../../util/matrixUtil';
|
||||
import { hasDevices } from '../../../util/matrixUtil';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
|
|
@ -18,10 +16,10 @@ import RoomTile from '../../molecules/room-tile/RoomTile';
|
|||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { getDMRoomFor } from '../../utils/matrix';
|
||||
|
||||
function InviteUser({
|
||||
isOpen, roomId, searchTerm, onRequestClose,
|
||||
}) {
|
||||
function InviteUser({ isOpen, roomId, searchTerm, onRequestClose }) {
|
||||
const [isSearching, updateIsSearching] = useState(false);
|
||||
const [searchQuery, updateSearchQuery] = useState({});
|
||||
const [users, updateUsers] = useState([]);
|
||||
|
|
@ -37,6 +35,7 @@ function InviteUser({
|
|||
const usernameRef = useRef(null);
|
||||
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
function getMapCopy(myMap) {
|
||||
const newMap = new Map();
|
||||
|
|
@ -76,11 +75,13 @@ function InviteUser({
|
|||
if (isInputUserId) {
|
||||
try {
|
||||
const result = await mx.getProfileInfo(inputUsername);
|
||||
updateUsers([{
|
||||
user_id: inputUsername,
|
||||
display_name: result.displayname,
|
||||
avatar_url: result.avatar_url,
|
||||
}]);
|
||||
updateUsers([
|
||||
{
|
||||
user_id: inputUsername,
|
||||
display_name: result.displayname,
|
||||
avatar_url: result.avatar_url,
|
||||
},
|
||||
]);
|
||||
} catch (e) {
|
||||
updateSearchQuery({ error: `${inputUsername} not found!` });
|
||||
}
|
||||
|
|
@ -105,9 +106,9 @@ function InviteUser({
|
|||
|
||||
async function createDM(userId) {
|
||||
if (mx.getUserId() === userId) return;
|
||||
const dmRoomId = hasDMWith(userId);
|
||||
const dmRoomId = getDMRoomFor(mx, userId)?.roomId;
|
||||
if (dmRoomId) {
|
||||
selectRoom(dmRoomId);
|
||||
navigateRoom(dmRoomId);
|
||||
onRequestClose();
|
||||
return;
|
||||
}
|
||||
|
|
@ -120,6 +121,7 @@ function InviteUser({
|
|||
const result = await roomActions.createDM(userId, await hasDevices(userId));
|
||||
roomIdToUserId.set(result.room_id, userId);
|
||||
updateRoomIdToUserId(getMapCopy(roomIdToUserId));
|
||||
onDMCreated(result.room_id);
|
||||
} catch (e) {
|
||||
deleteUserFromProc(userId);
|
||||
if (typeof e.message === 'string') procUserError.set(userId, e.message);
|
||||
|
|
@ -150,7 +152,13 @@ function InviteUser({
|
|||
|
||||
function renderUserList() {
|
||||
const renderOptions = (userId) => {
|
||||
const messageJSX = (message, isPositive) => <Text variant="b2"><span style={{ color: isPositive ? 'var(--bg-positive)' : 'var(--bg-negative)' }}>{message}</span></Text>;
|
||||
const messageJSX = (message, isPositive) => (
|
||||
<Text variant="b2">
|
||||
<span style={{ color: isPositive ? 'var(--bg-positive)' : 'var(--bg-negative)' }}>
|
||||
{message}
|
||||
</span>
|
||||
</Text>
|
||||
);
|
||||
|
||||
if (mx.getUserId() === userId) return null;
|
||||
if (procUsers.has(userId)) {
|
||||
|
|
@ -158,7 +166,16 @@ function InviteUser({
|
|||
}
|
||||
if (createdDM.has(userId)) {
|
||||
// eslint-disable-next-line max-len
|
||||
return <Button onClick={() => { selectRoom(createdDM.get(userId)); onRequestClose(); }}>Open</Button>;
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigateRoom(createdDM.get(userId));
|
||||
onRequestClose();
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (invitedUserIds.has(userId)) {
|
||||
return messageJSX('Invited', true);
|
||||
|
|
@ -178,13 +195,23 @@ function InviteUser({
|
|||
}
|
||||
}
|
||||
}
|
||||
return (typeof roomId === 'string')
|
||||
? <Button onClick={() => inviteToRoom(userId)} variant="primary">Invite</Button>
|
||||
: <Button onClick={() => createDM(userId)} variant="primary">Message</Button>;
|
||||
return typeof roomId === 'string' ? (
|
||||
<Button onClick={() => inviteToRoom(userId)} variant="primary">
|
||||
Invite
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => createDM(userId)} variant="primary">
|
||||
Message
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
const renderError = (userId) => {
|
||||
if (!procUserError.has(userId)) return null;
|
||||
return <Text variant="b2"><span style={{ color: 'var(--bg-danger)' }}>{procUserError.get(userId)}</span></Text>;
|
||||
return (
|
||||
<Text variant="b2">
|
||||
<span style={{ color: 'var(--bg-danger)' }}>{procUserError.get(userId)}</span>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
return users.map((user) => {
|
||||
|
|
@ -193,7 +220,11 @@ function InviteUser({
|
|||
return (
|
||||
<RoomTile
|
||||
key={userId}
|
||||
avatarSrc={typeof user.avatar_url === 'string' ? mx.mxcUrlToHttp(user.avatar_url, 42, 42, 'crop') : null}
|
||||
avatarSrc={
|
||||
typeof user.avatar_url === 'string'
|
||||
? mx.mxcUrlToHttp(user.avatar_url, 42, 42, 'crop')
|
||||
: null
|
||||
}
|
||||
name={name}
|
||||
id={userId}
|
||||
options={renderOptions(userId)}
|
||||
|
|
@ -217,48 +248,43 @@ function InviteUser({
|
|||
};
|
||||
}, [isOpen, searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
initMatrix.roomList.on(cons.events.roomList.ROOM_CREATED, onDMCreated);
|
||||
return () => {
|
||||
initMatrix.roomList.removeListener(cons.events.roomList.ROOM_CREATED, onDMCreated);
|
||||
};
|
||||
}, [isOpen, procUsers, createdDM, roomIdToUserId]);
|
||||
|
||||
return (
|
||||
<PopupWindow
|
||||
isOpen={isOpen}
|
||||
title={(typeof roomId === 'string' ? `Invite to ${mx.getRoom(roomId).name}` : 'Direct message')}
|
||||
title={typeof roomId === 'string' ? `Invite to ${mx.getRoom(roomId).name}` : 'Direct message'}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
|
||||
onRequestClose={onRequestClose}
|
||||
>
|
||||
<div className="invite-user">
|
||||
<form className="invite-user__form" onSubmit={(e) => { e.preventDefault(); searchUser(usernameRef.current.value); }}>
|
||||
<form
|
||||
className="invite-user__form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
searchUser(usernameRef.current.value);
|
||||
}}
|
||||
>
|
||||
<Input value={searchTerm} forwardRef={usernameRef} label="Name or userId" />
|
||||
<Button disabled={isSearching} iconSrc={UserIC} variant="primary" type="submit">Search</Button>
|
||||
<Button disabled={isSearching} iconSrc={UserIC} variant="primary" type="submit">
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
<div className="invite-user__search-status">
|
||||
{
|
||||
typeof searchQuery.username !== 'undefined' && isSearching && (
|
||||
<div className="flex--center">
|
||||
<Spinner size="small" />
|
||||
<Text variant="b2">{`Searching for user "${searchQuery.username}"...`}</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
typeof searchQuery.username !== 'undefined' && !isSearching && (
|
||||
<Text variant="b2">{`Search result for user "${searchQuery.username}"`}</Text>
|
||||
)
|
||||
}
|
||||
{
|
||||
searchQuery.error && <Text className="invite-user__search-error" variant="b2">{searchQuery.error}</Text>
|
||||
}
|
||||
{typeof searchQuery.username !== 'undefined' && isSearching && (
|
||||
<div className="flex--center">
|
||||
<Spinner size="small" />
|
||||
<Text variant="b2">{`Searching for user "${searchQuery.username}"...`}</Text>
|
||||
</div>
|
||||
)}
|
||||
{typeof searchQuery.username !== 'undefined' && !isSearching && (
|
||||
<Text variant="b2">{`Search result for user "${searchQuery.username}"`}</Text>
|
||||
)}
|
||||
{searchQuery.error && (
|
||||
<Text className="invite-user__search-error" variant="b2">
|
||||
{searchQuery.error}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{ users.length !== 0 && (
|
||||
<div className="invite-user__content">
|
||||
{renderUserList()}
|
||||
</div>
|
||||
)}
|
||||
{users.length !== 0 && <div className="invite-user__content">{renderUserList()}</div>}
|
||||
</div>
|
||||
</PopupWindow>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import initMatrix from '../../../client/initMatrix';
|
|||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { join } from '../../../client/action/room';
|
||||
import { selectRoom, selectTab } from '../../../client/action/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
|
|
@ -18,36 +17,24 @@ import Dialog from '../../molecules/dialog/Dialog';
|
|||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
|
||||
const ALIAS_OR_ID_REG = /^[#|!].+:.+\..+$/;
|
||||
|
||||
function JoinAliasContent({ term, requestClose }) {
|
||||
const [process, setProcess] = useState(false);
|
||||
const [error, setError] = useState(undefined);
|
||||
const [lastJoinId, setLastJoinId] = useState(undefined);
|
||||
|
||||
const mx = initMatrix.matrixClient;
|
||||
const mountStore = useStore();
|
||||
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const openRoom = (roomId) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return;
|
||||
if (room.isSpaceRoom()) selectTab(roomId);
|
||||
else selectRoom(roomId);
|
||||
navigateRoom(roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleJoin = (roomId) => {
|
||||
if (lastJoinId !== roomId) return;
|
||||
openRoom(roomId);
|
||||
};
|
||||
initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleJoin);
|
||||
return () => {
|
||||
initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleJoin);
|
||||
};
|
||||
}, [lastJoinId]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
mountStore.setItem(true);
|
||||
|
|
@ -70,13 +57,14 @@ function JoinAliasContent({ term, requestClose }) {
|
|||
} catch (err) {
|
||||
if (!mountStore.getItem()) return;
|
||||
setProcess(false);
|
||||
setError(`Unable to find room/space with ${alias}. Either room/space is private or doesn't exist.`);
|
||||
setError(
|
||||
`Unable to find room/space with ${alias}. Either room/space is private or doesn't exist.`
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const roomId = await join(alias, false, via);
|
||||
if (!mountStore.getItem()) return;
|
||||
setLastJoinId(roomId);
|
||||
openRoom(roomId);
|
||||
} catch {
|
||||
if (!mountStore.getItem()) return;
|
||||
|
|
@ -87,24 +75,23 @@ function JoinAliasContent({ term, requestClose }) {
|
|||
|
||||
return (
|
||||
<form className="join-alias" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
label="Address"
|
||||
value={term}
|
||||
name="alias"
|
||||
required
|
||||
/>
|
||||
{error && <Text className="join-alias__error" variant="b3">{error}</Text>}
|
||||
<Input label="Address" value={term} name="alias" required />
|
||||
{error && (
|
||||
<Text className="join-alias__error" variant="b3">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<div className="join-alias__btn">
|
||||
{
|
||||
process
|
||||
? (
|
||||
<>
|
||||
<Spinner size="small" />
|
||||
<Text>{process}</Text>
|
||||
</>
|
||||
)
|
||||
: <Button variant="primary" type="submit">Join</Button>
|
||||
}
|
||||
{process ? (
|
||||
<>
|
||||
<Spinner size="small" />
|
||||
<Text>{process}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="primary" type="submit">
|
||||
Join
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
|
@ -141,13 +128,15 @@ function JoinAlias() {
|
|||
return (
|
||||
<Dialog
|
||||
isOpen={data !== null}
|
||||
title={(
|
||||
<Text variant="s1" weight="medium" primary>Join with address</Text>
|
||||
)}
|
||||
title={
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
Join with address
|
||||
</Text>
|
||||
}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||
onRequestClose={requestClose}
|
||||
>
|
||||
{ data ? <JoinAliasContent term={data.term} requestClose={requestClose} /> : <div /> }
|
||||
{data ? <JoinAliasContent term={data.term} requestClose={requestClose} /> : <div />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import Postie from '../../../util/Postie';
|
||||
import { roomIdByActivity } from '../../../util/sort';
|
||||
|
||||
import RoomsCategory from './RoomsCategory';
|
||||
|
||||
const drawerPostie = new Postie();
|
||||
function Directs({ size }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { roomList, notifications } = initMatrix;
|
||||
const [directIds, setDirectIds] = useState([]);
|
||||
|
||||
useEffect(() => setDirectIds([...roomList.directs].sort(roomIdByActivity)), [size]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTimeline = (event, room, toStartOfTimeline, removed, data) => {
|
||||
if (!roomList.directs.has(room.roomId)) return;
|
||||
if (!data.liveEvent) return;
|
||||
if (directIds[0] === room.roomId) return;
|
||||
const newDirectIds = [room.roomId];
|
||||
directIds.forEach((id) => {
|
||||
if (id === room.roomId) return;
|
||||
newDirectIds.push(id);
|
||||
});
|
||||
setDirectIds(newDirectIds);
|
||||
};
|
||||
mx.on('Room.timeline', handleTimeline);
|
||||
return () => {
|
||||
mx.removeListener('Room.timeline', handleTimeline);
|
||||
};
|
||||
}, [directIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectorChanged = (selectedRoomId, prevSelectedRoomId) => {
|
||||
if (!drawerPostie.hasTopic('selector-change')) return;
|
||||
const addresses = [];
|
||||
if (drawerPostie.hasSubscriber('selector-change', selectedRoomId)) addresses.push(selectedRoomId);
|
||||
if (drawerPostie.hasSubscriber('selector-change', prevSelectedRoomId)) addresses.push(prevSelectedRoomId);
|
||||
if (addresses.length === 0) return;
|
||||
drawerPostie.post('selector-change', addresses, selectedRoomId);
|
||||
};
|
||||
|
||||
const notiChanged = (roomId, total, prevTotal) => {
|
||||
if (total === prevTotal) return;
|
||||
if (drawerPostie.hasTopicAndSubscriber('unread-change', roomId)) {
|
||||
drawerPostie.post('unread-change', roomId);
|
||||
}
|
||||
};
|
||||
|
||||
navigation.on(cons.events.navigation.ROOM_SELECTED, selectorChanged);
|
||||
notifications.on(cons.events.notifications.NOTI_CHANGED, notiChanged);
|
||||
notifications.on(cons.events.notifications.MUTE_TOGGLED, notiChanged);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, selectorChanged);
|
||||
notifications.removeListener(cons.events.notifications.NOTI_CHANGED, notiChanged);
|
||||
notifications.removeListener(cons.events.notifications.MUTE_TOGGLED, notiChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <RoomsCategory name="People" hideHeader roomIds={directIds} drawerPostie={drawerPostie} />;
|
||||
}
|
||||
Directs.propTypes = {
|
||||
size: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default Directs;
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import './Drawer.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
|
||||
import DrawerHeader from './DrawerHeader';
|
||||
import DrawerBreadcrumb from './DrawerBreadcrumb';
|
||||
import Home from './Home';
|
||||
import Directs from './Directs';
|
||||
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
import { useSelectedTab } from '../../hooks/useSelectedTab';
|
||||
import { useSelectedSpace } from '../../hooks/useSelectedSpace';
|
||||
|
||||
function useSystemState() {
|
||||
const [systemState, setSystemState] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSystemState = (state) => {
|
||||
if (state === 'ERROR' || state === 'RECONNECTING' || state === 'STOPPED') {
|
||||
setSystemState({ status: 'Connection lost!' });
|
||||
}
|
||||
if (systemState !== null) setSystemState(null);
|
||||
};
|
||||
initMatrix.matrixClient.on('sync', handleSystemState);
|
||||
return () => {
|
||||
initMatrix.matrixClient.removeListener('sync', handleSystemState);
|
||||
};
|
||||
}, [systemState]);
|
||||
|
||||
return [systemState];
|
||||
}
|
||||
|
||||
function Drawer() {
|
||||
const [systemState] = useSystemState();
|
||||
const [selectedTab] = useSelectedTab();
|
||||
const [spaceId] = useSelectedSpace();
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
const scrollRef = useRef(null);
|
||||
const { roomList } = initMatrix;
|
||||
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => {
|
||||
forceUpdate();
|
||||
};
|
||||
roomList.on(cons.events.roomList.ROOMLIST_UPDATED, handleUpdate);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
}, [selectedTab]);
|
||||
|
||||
return (
|
||||
<div className="drawer">
|
||||
<DrawerHeader selectedTab={selectedTab} spaceId={spaceId} />
|
||||
<div className="drawer__content-wrapper">
|
||||
{navigation.selectedSpacePath.length > 1 && selectedTab !== cons.tabs.DIRECTS && (
|
||||
<DrawerBreadcrumb spaceId={spaceId} />
|
||||
)}
|
||||
<div className="rooms__wrapper">
|
||||
<ScrollView ref={scrollRef} autoHide>
|
||||
<div className="rooms-container">
|
||||
{selectedTab !== cons.tabs.DIRECTS ? (
|
||||
<Home spaceId={spaceId} />
|
||||
) : (
|
||||
<Directs size={roomList.directs.size} />
|
||||
)}
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</div>
|
||||
{systemState !== null && (
|
||||
<div className="drawer__state">
|
||||
<Text>{systemState.status}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Drawer;
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.drawer {
|
||||
@extend .cp-fx__column;
|
||||
@extend .cp-fx__item-one;
|
||||
min-width: 0;
|
||||
@include dir.side(border,
|
||||
none,
|
||||
1px solid var(--bg-surface-border),
|
||||
);
|
||||
|
||||
& .header {
|
||||
padding: var(--sp-extra-tight);
|
||||
& > .header__title-wrapper {
|
||||
@include dir.side(margin, var(--sp-ultra-tight), 0);
|
||||
}
|
||||
}
|
||||
|
||||
&__content-wrapper {
|
||||
@extend .cp-fx__item-one;
|
||||
@extend .cp-fx__column;
|
||||
}
|
||||
|
||||
&__state {
|
||||
padding: var(--sp-extra-tight);
|
||||
border-top: 1px solid var(--bg-surface-border);
|
||||
@extend .cp-fx__row--c-c;
|
||||
|
||||
& .text {
|
||||
color: var(--tc-danger-high);
|
||||
}
|
||||
}
|
||||
}
|
||||
.rooms__wrapper {
|
||||
@extend .cp-fx__item-one;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rooms-container {
|
||||
padding-bottom: var(--sp-extra-loose);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--bg-surface-low),
|
||||
var(--bg-surface-low-transparent));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './DrawerBreadcrumb.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import { selectTab, selectSpace } from '../../../client/action/navigation';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { abbreviateNumber } from '../../../util/common';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import NotificationBadge from '../../atoms/badge/NotificationBadge';
|
||||
|
||||
import ChevronRightIC from '../../../../public/res/ic/outlined/chevron-right.svg';
|
||||
|
||||
function DrawerBreadcrumb({ spaceId }) {
|
||||
const [, forceUpdate] = useState({});
|
||||
const scrollRef = useRef(null);
|
||||
const { roomList, notifications, accountData } = initMatrix;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const spacePath = navigation.selectedSpacePath;
|
||||
|
||||
function onNotiChanged(roomId, total, prevTotal) {
|
||||
if (total === prevTotal) return;
|
||||
if (navigation.selectedSpacePath.includes(roomId)) {
|
||||
forceUpdate({});
|
||||
}
|
||||
if (navigation.selectedSpacePath[0] === cons.tabs.HOME) {
|
||||
if (!roomList.isOrphan(roomId)) return;
|
||||
if (roomList.directs.has(roomId)) return;
|
||||
forceUpdate({});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollRef?.current === null) return;
|
||||
scrollRef.current.scrollLeft = scrollRef.current.scrollWidth;
|
||||
});
|
||||
notifications.on(cons.events.notifications.NOTI_CHANGED, onNotiChanged);
|
||||
return () => {
|
||||
notifications.removeListener(cons.events.notifications.NOTI_CHANGED, onNotiChanged);
|
||||
};
|
||||
}, [spaceId]);
|
||||
|
||||
function getHomeNotiExcept(childId) {
|
||||
const orphans = roomList.getOrphans()
|
||||
.filter((id) => (id !== childId))
|
||||
.filter((id) => !accountData.spaceShortcut.has(id));
|
||||
|
||||
let noti = null;
|
||||
|
||||
orphans.forEach((roomId) => {
|
||||
if (!notifications.hasNoti(roomId)) return;
|
||||
if (noti === null) noti = { total: 0, highlight: 0 };
|
||||
const childNoti = notifications.getNoti(roomId);
|
||||
noti.total += childNoti.total;
|
||||
noti.highlight += childNoti.highlight;
|
||||
});
|
||||
|
||||
return noti;
|
||||
}
|
||||
|
||||
function getNotiExcept(roomId, childId) {
|
||||
if (!notifications.hasNoti(roomId)) return null;
|
||||
|
||||
const noti = notifications.getNoti(roomId);
|
||||
if (!notifications.hasNoti(childId)) return noti;
|
||||
if (noti.from === null) return noti;
|
||||
|
||||
const childNoti = notifications.getNoti(childId);
|
||||
|
||||
let noOther = true;
|
||||
let total = 0;
|
||||
let highlight = 0;
|
||||
noti.from.forEach((fromId) => {
|
||||
if (childNoti.from.has(fromId)) return;
|
||||
noOther = false;
|
||||
const fromNoti = notifications.getNoti(fromId);
|
||||
total += fromNoti.total;
|
||||
highlight += fromNoti.highlight;
|
||||
});
|
||||
|
||||
if (noOther) return null;
|
||||
return { total, highlight };
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="drawer-breadcrumb__wrapper">
|
||||
<ScrollView ref={scrollRef} horizontal vertical={false} invisible>
|
||||
<div className="drawer-breadcrumb">
|
||||
{
|
||||
spacePath.map((id, index) => {
|
||||
const noti = (id !== cons.tabs.HOME && index < spacePath.length)
|
||||
? getNotiExcept(id, (index === spacePath.length - 1) ? null : spacePath[index + 1])
|
||||
: getHomeNotiExcept((index === spacePath.length - 1) ? null : spacePath[index + 1]);
|
||||
|
||||
return (
|
||||
<React.Fragment
|
||||
key={id}
|
||||
>
|
||||
{ index !== 0 && <RawIcon size="extra-small" src={ChevronRightIC} />}
|
||||
<Button
|
||||
className={index === spacePath.length - 1 ? 'drawer-breadcrumb__btn--selected' : ''}
|
||||
onClick={() => {
|
||||
if (id === cons.tabs.HOME) selectTab(id);
|
||||
else selectSpace(id);
|
||||
}}
|
||||
>
|
||||
<Text variant="b2">{id === cons.tabs.HOME ? 'Home' : twemojify(mx.getRoom(id).name)}</Text>
|
||||
{ noti !== null && (
|
||||
<NotificationBadge
|
||||
alert={noti.highlight !== 0}
|
||||
content={noti.total > 0 ? abbreviateNumber(noti.total) : null}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
}
|
||||
<div style={{ width: 'var(--sp-extra-tight)', height: '100%' }} />
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DrawerBreadcrumb.defaultProps = {
|
||||
spaceId: null,
|
||||
};
|
||||
|
||||
DrawerBreadcrumb.propTypes = {
|
||||
spaceId: PropTypes.string,
|
||||
};
|
||||
|
||||
export default DrawerBreadcrumb;
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
@use '../../partials/text';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.drawer-breadcrumb__wrapper {
|
||||
height: var(--header-height);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.drawer-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
flex-shrink: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 99;
|
||||
|
||||
content: '';
|
||||
display: inline-block;
|
||||
min-width: 8px;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--bg-surface-low-transparent),
|
||||
var(--bg-surface-low)
|
||||
);
|
||||
}
|
||||
&::before {
|
||||
left: 0;
|
||||
right: unset;
|
||||
background-image: linear-gradient(
|
||||
to left,
|
||||
var(--bg-surface-low-transparent),
|
||||
var(--bg-surface-low)
|
||||
);
|
||||
}
|
||||
|
||||
& > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
& > .btn-surface {
|
||||
min-width: 0;
|
||||
padding: var(--sp-extra-tight) 10px;
|
||||
white-space: nowrap;
|
||||
box-shadow: none;
|
||||
& p {
|
||||
@extend .cp-txt__ellipsis;
|
||||
max-width: 86px;
|
||||
}
|
||||
|
||||
& .notification-badge {
|
||||
@include dir.side(margin, var(--sp-extra-tight), 0);
|
||||
}
|
||||
}
|
||||
|
||||
&__btn--selected {
|
||||
box-shadow: var(--bs-surface-border) !important;
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './DrawerHeader.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import {
|
||||
openPublicRooms, openCreateRoom, openSpaceManage, openJoinAlias,
|
||||
openSpaceAddExisting, openInviteUser, openReusableContextMenu,
|
||||
} from '../../../client/action/navigation';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
|
||||
import { blurOnBubbling } from '../../atoms/button/script';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||
import SpaceOptions from '../../molecules/space-options/SpaceOptions';
|
||||
|
||||
import PlusIC from '../../../../public/res/ic/outlined/plus.svg';
|
||||
import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg';
|
||||
import HashGlobeIC from '../../../../public/res/ic/outlined/hash-globe.svg';
|
||||
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
|
||||
import SpacePlusIC from '../../../../public/res/ic/outlined/space-plus.svg';
|
||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||
|
||||
export function HomeSpaceOptions({ spaceId, afterOptionSelect }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(spaceId);
|
||||
const canManage = room
|
||||
? room.currentState.maySendStateEvent('m.space.child', mx.getUserId())
|
||||
: true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuHeader>Add rooms or spaces</MenuHeader>
|
||||
<MenuItem
|
||||
iconSrc={SpacePlusIC}
|
||||
onClick={() => { afterOptionSelect(); openCreateRoom(true, spaceId); }}
|
||||
disabled={!canManage}
|
||||
>
|
||||
Create new space
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
iconSrc={HashPlusIC}
|
||||
onClick={() => { afterOptionSelect(); openCreateRoom(false, spaceId); }}
|
||||
disabled={!canManage}
|
||||
>
|
||||
Create new room
|
||||
</MenuItem>
|
||||
{ !spaceId && (
|
||||
<MenuItem
|
||||
iconSrc={HashGlobeIC}
|
||||
onClick={() => { afterOptionSelect(); openPublicRooms(); }}
|
||||
>
|
||||
Explore public rooms
|
||||
</MenuItem>
|
||||
)}
|
||||
{ !spaceId && (
|
||||
<MenuItem
|
||||
iconSrc={PlusIC}
|
||||
onClick={() => { afterOptionSelect(); openJoinAlias(); }}
|
||||
>
|
||||
Join with address
|
||||
</MenuItem>
|
||||
)}
|
||||
{ spaceId && (
|
||||
<MenuItem
|
||||
iconSrc={PlusIC}
|
||||
onClick={() => { afterOptionSelect(); openSpaceAddExisting(spaceId); }}
|
||||
disabled={!canManage}
|
||||
>
|
||||
Add existing
|
||||
</MenuItem>
|
||||
)}
|
||||
{ spaceId && (
|
||||
<MenuItem
|
||||
onClick={() => { afterOptionSelect(); openSpaceManage(spaceId); }}
|
||||
iconSrc={HashSearchIC}
|
||||
>
|
||||
Manage rooms
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
HomeSpaceOptions.defaultProps = {
|
||||
spaceId: null,
|
||||
};
|
||||
HomeSpaceOptions.propTypes = {
|
||||
spaceId: PropTypes.string,
|
||||
afterOptionSelect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function DrawerHeader({ selectedTab, spaceId }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const tabName = selectedTab !== cons.tabs.DIRECTS ? 'Home' : 'Direct messages';
|
||||
|
||||
const isDMTab = selectedTab === cons.tabs.DIRECTS;
|
||||
const room = mx.getRoom(spaceId);
|
||||
const spaceName = isDMTab ? null : (room?.name || null);
|
||||
|
||||
const openSpaceOptions = (e) => {
|
||||
e.preventDefault();
|
||||
openReusableContextMenu(
|
||||
'bottom',
|
||||
getEventCords(e, '.header'),
|
||||
(closeMenu) => <SpaceOptions roomId={spaceId} afterOptionSelect={closeMenu} />,
|
||||
);
|
||||
};
|
||||
|
||||
const openHomeSpaceOptions = (e) => {
|
||||
e.preventDefault();
|
||||
openReusableContextMenu(
|
||||
'right',
|
||||
getEventCords(e, '.ic-btn'),
|
||||
(closeMenu) => <HomeSpaceOptions spaceId={spaceId} afterOptionSelect={closeMenu} />,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Header>
|
||||
{spaceName ? (
|
||||
<button
|
||||
className="drawer-header__btn"
|
||||
onClick={openSpaceOptions}
|
||||
type="button"
|
||||
onMouseUp={(e) => blurOnBubbling(e, '.drawer-header__btn')}
|
||||
>
|
||||
<TitleWrapper>
|
||||
<Text variant="s1" weight="medium" primary>{twemojify(spaceName)}</Text>
|
||||
</TitleWrapper>
|
||||
<RawIcon size="small" src={ChevronBottomIC} />
|
||||
</button>
|
||||
) : (
|
||||
<TitleWrapper>
|
||||
<Text variant="s1" weight="medium" primary>{tabName}</Text>
|
||||
</TitleWrapper>
|
||||
)}
|
||||
|
||||
{ isDMTab && <IconButton onClick={() => openInviteUser()} tooltip="Start DM" src={PlusIC} size="small" /> }
|
||||
{ !isDMTab && <IconButton onClick={openHomeSpaceOptions} tooltip="Add rooms/spaces" src={PlusIC} size="small" /> }
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
|
||||
DrawerHeader.defaultProps = {
|
||||
spaceId: null,
|
||||
};
|
||||
DrawerHeader.propTypes = {
|
||||
selectedTab: PropTypes.string.isRequired,
|
||||
spaceId: PropTypes.string,
|
||||
};
|
||||
|
||||
export default DrawerHeader;
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.drawer-header__btn {
|
||||
min-width: 0;
|
||||
@extend .cp-fx__row--s-c;
|
||||
@include dir.side(margin, 0, auto);
|
||||
padding: var(--sp-ultra-tight);
|
||||
border-radius: calc(var(--bo-radius) / 2);
|
||||
cursor: pointer;
|
||||
|
||||
& .header__title-wrapper {
|
||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
||||
}
|
||||
|
||||
@media (hover:hover) {
|
||||
&:hover {
|
||||
background-color: var(--bg-surface-hover);
|
||||
box-shadow: var(--bs-surface-outline);
|
||||
}
|
||||
}
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: var(--bg-surface-active);
|
||||
box-shadow: var(--bs-surface-outline);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import Postie from '../../../util/Postie';
|
||||
import { roomIdByActivity, roomIdByAtoZ } from '../../../util/sort';
|
||||
|
||||
import RoomsCategory from './RoomsCategory';
|
||||
|
||||
import { useCategorizedSpaces } from '../../hooks/useCategorizedSpaces';
|
||||
|
||||
const drawerPostie = new Postie();
|
||||
function Home({ spaceId }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { roomList, notifications, accountData } = initMatrix;
|
||||
const { spaces, rooms, directs } = roomList;
|
||||
useCategorizedSpaces();
|
||||
const isCategorized = accountData.categorizedSpaces.has(spaceId);
|
||||
|
||||
let categories = null;
|
||||
let spaceIds = [];
|
||||
let roomIds = [];
|
||||
let directIds = [];
|
||||
|
||||
if (spaceId) {
|
||||
const spaceChildIds = roomList.getSpaceChildren(spaceId) ?? [];
|
||||
spaceIds = spaceChildIds.filter((roomId) => spaces.has(roomId));
|
||||
roomIds = spaceChildIds.filter((roomId) => rooms.has(roomId));
|
||||
directIds = spaceChildIds.filter((roomId) => directs.has(roomId));
|
||||
} else {
|
||||
spaceIds = roomList.getOrphanSpaces().filter((id) => !accountData.spaceShortcut.has(id));
|
||||
roomIds = roomList.getOrphanRooms();
|
||||
}
|
||||
|
||||
if (isCategorized) {
|
||||
categories = roomList.getCategorizedSpaces(spaceIds);
|
||||
categories.delete(spaceId);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const selectorChanged = (selectedRoomId, prevSelectedRoomId) => {
|
||||
if (!drawerPostie.hasTopic('selector-change')) return;
|
||||
const addresses = [];
|
||||
if (drawerPostie.hasSubscriber('selector-change', selectedRoomId)) addresses.push(selectedRoomId);
|
||||
if (drawerPostie.hasSubscriber('selector-change', prevSelectedRoomId)) addresses.push(prevSelectedRoomId);
|
||||
if (addresses.length === 0) return;
|
||||
drawerPostie.post('selector-change', addresses, selectedRoomId);
|
||||
};
|
||||
|
||||
const notiChanged = (roomId, total, prevTotal) => {
|
||||
if (total === prevTotal) return;
|
||||
if (drawerPostie.hasTopicAndSubscriber('unread-change', roomId)) {
|
||||
drawerPostie.post('unread-change', roomId);
|
||||
}
|
||||
};
|
||||
|
||||
navigation.on(cons.events.navigation.ROOM_SELECTED, selectorChanged);
|
||||
notifications.on(cons.events.notifications.NOTI_CHANGED, notiChanged);
|
||||
notifications.on(cons.events.notifications.MUTE_TOGGLED, notiChanged);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, selectorChanged);
|
||||
notifications.removeListener(cons.events.notifications.NOTI_CHANGED, notiChanged);
|
||||
notifications.removeListener(cons.events.notifications.MUTE_TOGGLED, notiChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ !isCategorized && spaceIds.length !== 0 && (
|
||||
<RoomsCategory name="Spaces" roomIds={spaceIds.sort(roomIdByAtoZ)} drawerPostie={drawerPostie} />
|
||||
)}
|
||||
|
||||
{ roomIds.length !== 0 && (
|
||||
<RoomsCategory name="Rooms" roomIds={roomIds.sort(roomIdByAtoZ)} drawerPostie={drawerPostie} />
|
||||
)}
|
||||
|
||||
{ directIds.length !== 0 && (
|
||||
<RoomsCategory name="People" roomIds={directIds.sort(roomIdByActivity)} drawerPostie={drawerPostie} />
|
||||
)}
|
||||
|
||||
{ isCategorized && [...categories.keys()].sort(roomIdByAtoZ).map((catId) => {
|
||||
const rms = [];
|
||||
const dms = [];
|
||||
categories.get(catId).forEach((id) => {
|
||||
if (directs.has(id)) dms.push(id);
|
||||
else rms.push(id);
|
||||
});
|
||||
rms.sort(roomIdByAtoZ);
|
||||
dms.sort(roomIdByActivity);
|
||||
return (
|
||||
<RoomsCategory
|
||||
key={catId}
|
||||
spaceId={catId}
|
||||
name={mx.getRoom(catId).name}
|
||||
roomIds={rms.concat(dms)}
|
||||
drawerPostie={drawerPostie}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Home.defaultProps = {
|
||||
spaceId: null,
|
||||
};
|
||||
Home.propTypes = {
|
||||
spaceId: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import React from 'react';
|
||||
import './Navigation.scss';
|
||||
|
||||
import SideBar from './SideBar';
|
||||
import Drawer from './Drawer';
|
||||
|
||||
function Navigation() {
|
||||
return (
|
||||
<div className="navigation">
|
||||
<SideBar />
|
||||
<Drawer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Navigation;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
.navigation {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--bg-surface-low);
|
||||
|
||||
display: flex;
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomsCategory.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { selectSpace, selectRoom, openReusableContextMenu } from '../../../client/action/navigation';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Selector from './Selector';
|
||||
import SpaceOptions from '../../molecules/space-options/SpaceOptions';
|
||||
import { HomeSpaceOptions } from './DrawerHeader';
|
||||
|
||||
import PlusIC from '../../../../public/res/ic/outlined/plus.svg';
|
||||
import HorizontalMenuIC from '../../../../public/res/ic/outlined/horizontal-menu.svg';
|
||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||
import ChevronRightIC from '../../../../public/res/ic/outlined/chevron-right.svg';
|
||||
|
||||
function RoomsCategory({
|
||||
spaceId, name, hideHeader, roomIds, drawerPostie,
|
||||
}) {
|
||||
const { spaces, directs } = initMatrix.roomList;
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const openSpaceOptions = (e) => {
|
||||
e.preventDefault();
|
||||
openReusableContextMenu(
|
||||
'bottom',
|
||||
getEventCords(e, '.header'),
|
||||
(closeMenu) => <SpaceOptions roomId={spaceId} afterOptionSelect={closeMenu} />,
|
||||
);
|
||||
};
|
||||
|
||||
const openHomeSpaceOptions = (e) => {
|
||||
e.preventDefault();
|
||||
openReusableContextMenu(
|
||||
'right',
|
||||
getEventCords(e, '.ic-btn'),
|
||||
(closeMenu) => <HomeSpaceOptions spaceId={spaceId} afterOptionSelect={closeMenu} />,
|
||||
);
|
||||
};
|
||||
|
||||
const renderSelector = (roomId) => {
|
||||
const isSpace = spaces.has(roomId);
|
||||
const isDM = directs.has(roomId);
|
||||
|
||||
return (
|
||||
<Selector
|
||||
key={roomId}
|
||||
roomId={roomId}
|
||||
isDM={isDM}
|
||||
drawerPostie={drawerPostie}
|
||||
onClick={() => (isSpace ? selectSpace(roomId) : selectRoom(roomId))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="room-category">
|
||||
{!hideHeader && (
|
||||
<div className="room-category__header">
|
||||
<button className="room-category__toggle" onClick={() => setIsOpen(!isOpen)} type="button">
|
||||
<RawIcon src={isOpen ? ChevronBottomIC : ChevronRightIC} size="extra-small" />
|
||||
<Text className="cat-header" variant="b3" weight="medium">{name}</Text>
|
||||
</button>
|
||||
{spaceId && <IconButton onClick={openSpaceOptions} tooltip="Space options" src={HorizontalMenuIC} size="extra-small" />}
|
||||
{spaceId && <IconButton onClick={openHomeSpaceOptions} tooltip="Add rooms/spaces" src={PlusIC} size="extra-small" />}
|
||||
</div>
|
||||
)}
|
||||
{(isOpen || hideHeader) && (
|
||||
<div className="room-category__content">
|
||||
{roomIds.map(renderSelector)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
RoomsCategory.defaultProps = {
|
||||
spaceId: null,
|
||||
hideHeader: false,
|
||||
};
|
||||
RoomsCategory.propTypes = {
|
||||
spaceId: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
hideHeader: PropTypes.bool,
|
||||
roomIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
drawerPostie: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default RoomsCategory;
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
@use '../../partials/text';
|
||||
|
||||
.room-category {
|
||||
&__header,
|
||||
&__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
&__header {
|
||||
margin-top: var(--sp-extra-tight);
|
||||
|
||||
& .ic-btn {
|
||||
padding: var(--sp-ultra-tight);
|
||||
border-radius: 4px;
|
||||
@include dir.side(margin, 0, 5px);
|
||||
& .ic-raw {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: var(--ic-surface-low);
|
||||
}
|
||||
}
|
||||
}
|
||||
&__toggle {
|
||||
@extend .cp-fx__item-one;
|
||||
padding: var(--sp-extra-tight) var(--sp-tight);
|
||||
cursor: pointer;
|
||||
|
||||
& .ic-raw {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--ic-surface-low);
|
||||
@include dir.side(margin, 0, var(--sp-ultra-tight));
|
||||
}
|
||||
& .text {
|
||||
text-transform: uppercase;
|
||||
@extend .cp-txt__ellipsis;
|
||||
}
|
||||
&:hover .text {
|
||||
color: var(--tc-surface-normal);
|
||||
}
|
||||
}
|
||||
|
||||
&__content:first-child {
|
||||
margin-top: var(--sp-extra-tight);
|
||||
}
|
||||
|
||||
& .room-selector {
|
||||
width: calc(100% - var(--sp-extra-tight));
|
||||
@include dir.side(margin, auto, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { openReusableContextMenu } from '../../../client/action/navigation';
|
||||
import { getEventCords, abbreviateNumber } from '../../../util/common';
|
||||
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
||||
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import RoomSelector from '../../molecules/room-selector/RoomSelector';
|
||||
import RoomOptions from '../../molecules/room-options/RoomOptions';
|
||||
import SpaceOptions from '../../molecules/space-options/SpaceOptions';
|
||||
|
||||
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
|
||||
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
|
||||
function Selector({
|
||||
roomId, isDM, drawerPostie, onClick,
|
||||
}) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const noti = initMatrix.notifications;
|
||||
const room = mx.getRoom(roomId);
|
||||
|
||||
let imageSrc = room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
||||
if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
||||
|
||||
const isMuted = noti.getNotiType(roomId) === cons.notifs.MUTE;
|
||||
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
|
||||
useEffect(() => {
|
||||
const unSub1 = drawerPostie.subscribe('selector-change', roomId, forceUpdate);
|
||||
const unSub2 = drawerPostie.subscribe('unread-change', roomId, forceUpdate);
|
||||
return () => {
|
||||
unSub1();
|
||||
unSub2();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openOptions = (e) => {
|
||||
e.preventDefault();
|
||||
openReusableContextMenu(
|
||||
'right',
|
||||
getEventCords(e, '.room-selector'),
|
||||
room.isSpaceRoom()
|
||||
? (closeMenu) => <SpaceOptions roomId={roomId} afterOptionSelect={closeMenu} />
|
||||
: (closeMenu) => <RoomOptions roomId={roomId} afterOptionSelect={closeMenu} />,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<RoomSelector
|
||||
key={roomId}
|
||||
name={room.name}
|
||||
roomId={roomId}
|
||||
imageSrc={isDM ? imageSrc : null}
|
||||
iconSrc={isDM ? null : joinRuleToIconSrc(room.getJoinRule(), room.isSpaceRoom())}
|
||||
isSelected={navigation.selectedRoomId === roomId}
|
||||
isMuted={isMuted}
|
||||
isUnread={!isMuted && noti.hasNoti(roomId)}
|
||||
notificationCount={abbreviateNumber(noti.getTotalNoti(roomId))}
|
||||
isAlert={noti.getHighlightNoti(roomId) !== 0}
|
||||
onClick={onClick}
|
||||
onContextMenu={openOptions}
|
||||
options={(
|
||||
<IconButton
|
||||
size="extra-small"
|
||||
tooltip="Options"
|
||||
tooltipPlacement="right"
|
||||
src={VerticalMenuIC}
|
||||
onClick={openOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Selector.defaultProps = {
|
||||
isDM: true,
|
||||
};
|
||||
|
||||
Selector.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
isDM: PropTypes.bool,
|
||||
drawerPostie: PropTypes.shape({}).isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Selector;
|
||||
|
|
@ -1,390 +0,0 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './SideBar.scss';
|
||||
|
||||
import { DndProvider, useDrag, useDrop } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import {
|
||||
selectTab, openShortcutSpaces, openInviteList,
|
||||
openSearch, openSettings, openReusableContextMenu,
|
||||
} from '../../../client/action/navigation';
|
||||
import { moveSpaceShortcut } from '../../../client/action/accountData';
|
||||
import { abbreviateNumber, getEventCords } from '../../../util/common';
|
||||
import { isCrossVerified } from '../../../util/matrixUtil';
|
||||
|
||||
import Avatar from '../../atoms/avatar/Avatar';
|
||||
import NotificationBadge from '../../atoms/badge/NotificationBadge';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import SidebarAvatar from '../../molecules/sidebar-avatar/SidebarAvatar';
|
||||
import SpaceOptions from '../../molecules/space-options/SpaceOptions';
|
||||
|
||||
import HomeIC from '../../../../public/res/ic/outlined/home.svg';
|
||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
||||
import AddPinIC from '../../../../public/res/ic/outlined/add-pin.svg';
|
||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
import InviteIC from '../../../../public/res/ic/outlined/invite.svg';
|
||||
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
|
||||
|
||||
import { useSelectedTab } from '../../hooks/useSelectedTab';
|
||||
import { useDeviceList } from '../../hooks/useDeviceList';
|
||||
|
||||
import { tabText as settingTabText } from '../settings/Settings';
|
||||
|
||||
function useNotificationUpdate() {
|
||||
const { notifications } = initMatrix;
|
||||
const [, forceUpdate] = useState({});
|
||||
useEffect(() => {
|
||||
function onNotificationChanged(roomId, total, prevTotal) {
|
||||
if (total === prevTotal) return;
|
||||
forceUpdate({});
|
||||
}
|
||||
notifications.on(cons.events.notifications.NOTI_CHANGED, onNotificationChanged);
|
||||
return () => {
|
||||
notifications.removeListener(cons.events.notifications.NOTI_CHANGED, onNotificationChanged);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
function ProfileAvatarMenu() {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const [profile, setProfile] = useState({
|
||||
avatarUrl: null,
|
||||
displayName: mx.getUser(mx.getUserId()).displayName,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const user = mx.getUser(mx.getUserId());
|
||||
const setNewProfile = (avatarUrl, displayName) => setProfile({
|
||||
avatarUrl: avatarUrl || null,
|
||||
displayName: displayName || profile.displayName,
|
||||
});
|
||||
const onAvatarChange = (event, myUser) => {
|
||||
setNewProfile(myUser.avatarUrl, myUser.displayName);
|
||||
};
|
||||
mx.getProfileInfo(mx.getUserId()).then((info) => {
|
||||
setNewProfile(info.avatar_url, info.displayname);
|
||||
});
|
||||
user.on('User.avatarUrl', onAvatarChange);
|
||||
return () => {
|
||||
user.removeListener('User.avatarUrl', onAvatarChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarAvatar
|
||||
onClick={openSettings}
|
||||
tooltip="Settings"
|
||||
avatar={(
|
||||
<Avatar
|
||||
text={profile.displayName}
|
||||
bgColor={colorMXID(mx.getUserId())}
|
||||
size="normal"
|
||||
imageSrc={profile.avatarUrl !== null ? mx.mxcUrlToHttp(profile.avatarUrl, 42, 42, 'crop') : null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CrossSigninAlert() {
|
||||
const deviceList = useDeviceList();
|
||||
const unverified = deviceList?.filter((device) => isCrossVerified(device.device_id) === false);
|
||||
|
||||
if (!unverified?.length) return null;
|
||||
|
||||
return (
|
||||
<SidebarAvatar
|
||||
className="sidebar__cross-signin-alert"
|
||||
tooltip={`${unverified.length} unverified sessions`}
|
||||
onClick={() => openSettings(settingTabText.SECURITY)}
|
||||
avatar={<Avatar iconSrc={ShieldUserIC} iconColor="var(--ic-danger-normal)" size="normal" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedTab() {
|
||||
const { roomList, accountData, notifications } = initMatrix;
|
||||
const [selectedTab] = useSelectedTab();
|
||||
useNotificationUpdate();
|
||||
|
||||
function getHomeNoti() {
|
||||
const orphans = roomList.getOrphans();
|
||||
let noti = null;
|
||||
|
||||
orphans.forEach((roomId) => {
|
||||
if (accountData.spaceShortcut.has(roomId)) return;
|
||||
if (!notifications.hasNoti(roomId)) return;
|
||||
if (noti === null) noti = { total: 0, highlight: 0 };
|
||||
const childNoti = notifications.getNoti(roomId);
|
||||
noti.total += childNoti.total;
|
||||
noti.highlight += childNoti.highlight;
|
||||
});
|
||||
|
||||
return noti;
|
||||
}
|
||||
function getDMsNoti() {
|
||||
if (roomList.directs.size === 0) return null;
|
||||
let noti = null;
|
||||
|
||||
[...roomList.directs].forEach((roomId) => {
|
||||
if (!notifications.hasNoti(roomId)) return;
|
||||
if (noti === null) noti = { total: 0, highlight: 0 };
|
||||
const childNoti = notifications.getNoti(roomId);
|
||||
noti.total += childNoti.total;
|
||||
noti.highlight += childNoti.highlight;
|
||||
});
|
||||
|
||||
return noti;
|
||||
}
|
||||
|
||||
const dmsNoti = getDMsNoti();
|
||||
const homeNoti = getHomeNoti();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarAvatar
|
||||
tooltip="Home"
|
||||
active={selectedTab === cons.tabs.HOME}
|
||||
onClick={() => selectTab(cons.tabs.HOME)}
|
||||
avatar={<Avatar iconSrc={HomeIC} size="normal" />}
|
||||
notificationBadge={homeNoti ? (
|
||||
<NotificationBadge
|
||||
alert={homeNoti?.highlight > 0}
|
||||
content={abbreviateNumber(homeNoti.total) || null}
|
||||
/>
|
||||
) : null}
|
||||
/>
|
||||
<SidebarAvatar
|
||||
tooltip="People"
|
||||
active={selectedTab === cons.tabs.DIRECTS}
|
||||
onClick={() => selectTab(cons.tabs.DIRECTS)}
|
||||
avatar={<Avatar iconSrc={UserIC} size="normal" />}
|
||||
notificationBadge={dmsNoti ? (
|
||||
<NotificationBadge
|
||||
alert={dmsNoti?.highlight > 0}
|
||||
content={abbreviateNumber(dmsNoti.total) || null}
|
||||
/>
|
||||
) : null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DraggableSpaceShortcut({
|
||||
isActive, spaceId, index, moveShortcut, onDrop,
|
||||
}) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { notifications } = initMatrix;
|
||||
const room = mx.getRoom(spaceId);
|
||||
const shortcutRef = useRef(null);
|
||||
const avatarRef = useRef(null);
|
||||
|
||||
const openSpaceOptions = (e, sId) => {
|
||||
e.preventDefault();
|
||||
openReusableContextMenu(
|
||||
'right',
|
||||
getEventCords(e, '.sidebar-avatar'),
|
||||
(closeMenu) => <SpaceOptions roomId={sId} afterOptionSelect={closeMenu} />,
|
||||
);
|
||||
};
|
||||
|
||||
const [, drop] = useDrop({
|
||||
accept: 'SPACE_SHORTCUT',
|
||||
collect(monitor) {
|
||||
return {
|
||||
handlerId: monitor.getHandlerId(),
|
||||
};
|
||||
},
|
||||
drop(item) {
|
||||
onDrop(item.index, item.spaceId);
|
||||
},
|
||||
hover(item, monitor) {
|
||||
if (!shortcutRef.current) return;
|
||||
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = index;
|
||||
if (dragIndex === hoverIndex) return;
|
||||
|
||||
const hoverBoundingRect = shortcutRef.current?.getBoundingClientRect();
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
||||
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
moveShortcut(dragIndex, hoverIndex);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: 'SPACE_SHORTCUT',
|
||||
item: () => ({ spaceId, index }),
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
drag(avatarRef);
|
||||
drop(shortcutRef);
|
||||
|
||||
if (shortcutRef.current) {
|
||||
if (isDragging) shortcutRef.current.style.opacity = 0;
|
||||
else shortcutRef.current.style.opacity = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarAvatar
|
||||
ref={shortcutRef}
|
||||
active={isActive}
|
||||
tooltip={room.name}
|
||||
onClick={() => selectTab(spaceId)}
|
||||
onContextMenu={(e) => openSpaceOptions(e, spaceId)}
|
||||
avatar={(
|
||||
<Avatar
|
||||
ref={avatarRef}
|
||||
text={room.name}
|
||||
bgColor={colorMXID(room.roomId)}
|
||||
size="normal"
|
||||
imageSrc={room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop') || null}
|
||||
/>
|
||||
)}
|
||||
notificationBadge={notifications.hasNoti(spaceId) ? (
|
||||
<NotificationBadge
|
||||
alert={notifications.getHighlightNoti(spaceId) > 0}
|
||||
content={abbreviateNumber(notifications.getTotalNoti(spaceId)) || null}
|
||||
/>
|
||||
) : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
DraggableSpaceShortcut.propTypes = {
|
||||
spaceId: PropTypes.string.isRequired,
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
moveShortcut: PropTypes.func.isRequired,
|
||||
onDrop: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function SpaceShortcut() {
|
||||
const { accountData } = initMatrix;
|
||||
const [selectedTab] = useSelectedTab();
|
||||
useNotificationUpdate();
|
||||
const [spaceShortcut, setSpaceShortcut] = useState([...accountData.spaceShortcut]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleShortcut = () => setSpaceShortcut([...accountData.spaceShortcut]);
|
||||
accountData.on(cons.events.accountData.SPACE_SHORTCUT_UPDATED, handleShortcut);
|
||||
return () => {
|
||||
accountData.removeListener(cons.events.accountData.SPACE_SHORTCUT_UPDATED, handleShortcut);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const moveShortcut = (dragIndex, hoverIndex) => {
|
||||
const dragSpaceId = spaceShortcut[dragIndex];
|
||||
const newShortcuts = [...spaceShortcut];
|
||||
newShortcuts.splice(dragIndex, 1);
|
||||
newShortcuts.splice(hoverIndex, 0, dragSpaceId);
|
||||
setSpaceShortcut(newShortcuts);
|
||||
};
|
||||
|
||||
const handleDrop = (dragIndex, dragSpaceId) => {
|
||||
if ([...accountData.spaceShortcut][dragIndex] === dragSpaceId) return;
|
||||
moveSpaceShortcut(dragSpaceId, dragIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
{
|
||||
spaceShortcut.map((shortcut, index) => (
|
||||
<DraggableSpaceShortcut
|
||||
key={shortcut}
|
||||
index={index}
|
||||
spaceId={shortcut}
|
||||
isActive={selectedTab === shortcut}
|
||||
moveShortcut={moveShortcut}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function useTotalInvites() {
|
||||
const { roomList } = initMatrix;
|
||||
const totalInviteCount = () => roomList.inviteRooms.size
|
||||
+ roomList.inviteSpaces.size
|
||||
+ roomList.inviteDirects.size;
|
||||
const [totalInvites, updateTotalInvites] = useState(totalInviteCount());
|
||||
|
||||
useEffect(() => {
|
||||
const onInviteListChange = () => {
|
||||
updateTotalInvites(totalInviteCount());
|
||||
};
|
||||
roomList.on(cons.events.roomList.INVITELIST_UPDATED, onInviteListChange);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.INVITELIST_UPDATED, onInviteListChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [totalInvites];
|
||||
}
|
||||
|
||||
function SideBar() {
|
||||
const [totalInvites] = useTotalInvites();
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="sidebar__scrollable">
|
||||
<ScrollView invisible>
|
||||
<div className="scrollable-content">
|
||||
<div className="featured-container">
|
||||
<FeaturedTab />
|
||||
</div>
|
||||
<div className="sidebar-divider" />
|
||||
<div className="space-container">
|
||||
<SpaceShortcut />
|
||||
<SidebarAvatar
|
||||
tooltip="Pin spaces"
|
||||
onClick={() => openShortcutSpaces()}
|
||||
avatar={<Avatar iconSrc={AddPinIC} size="normal" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
<div className="sidebar__sticky">
|
||||
<div className="sidebar-divider" />
|
||||
<div className="sticky-container">
|
||||
<SidebarAvatar
|
||||
tooltip="Search"
|
||||
onClick={() => openSearch()}
|
||||
avatar={<Avatar iconSrc={SearchIC} size="normal" />}
|
||||
/>
|
||||
{ totalInvites !== 0 && (
|
||||
<SidebarAvatar
|
||||
tooltip="Invites"
|
||||
onClick={() => openInviteList()}
|
||||
avatar={<Avatar iconSrc={InviteIC} size="normal" />}
|
||||
notificationBadge={<NotificationBadge alert content={totalInvites} />}
|
||||
/>
|
||||
)}
|
||||
<CrossSigninAlert />
|
||||
<ProfileAvatarMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SideBar;
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.sidebar {
|
||||
@extend .cp-fx__column;
|
||||
|
||||
width: var(--navigation-sidebar-width);
|
||||
height: 100%;
|
||||
background-color: var(--bg-surface-extra-low);
|
||||
@include dir.side(border, none, 1px solid var(--bg-surface-border));
|
||||
|
||||
&__scrollable,
|
||||
&__sticky {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__scrollable {
|
||||
@extend .cp-fx__item-one;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollable-content {
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
|
||||
background: transparent;
|
||||
background-image: linear-gradient(
|
||||
to top,
|
||||
var(--bg-surface-extra-low),
|
||||
var(--bg-surface-extra-low-transparent)
|
||||
);
|
||||
position: sticky;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.featured-container,
|
||||
.space-container,
|
||||
.sticky-container {
|
||||
@extend .cp-fx__column--c-c;
|
||||
|
||||
padding: var(--sp-ultra-tight) 0;
|
||||
|
||||
& > .sidebar-avatar,
|
||||
& > .avatar-container {
|
||||
margin: calc(var(--sp-tight) / 2) 0;
|
||||
}
|
||||
}
|
||||
.sidebar-divider {
|
||||
margin: auto;
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background-color: var(--bg-surface-border);
|
||||
}
|
||||
|
||||
.sidebar__cross-signin-alert .avatar-container {
|
||||
box-shadow: var(--bs-danger-border);
|
||||
animation-name: pushRight;
|
||||
animation-duration: 400ms;
|
||||
animation-iteration-count: 30;
|
||||
animation-direction: alternate;
|
||||
}
|
||||
|
||||
@keyframes pushRight {
|
||||
from {
|
||||
transform: translateX(4px) scale(1);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
|
@ -22,7 +21,9 @@ function ProfileEditor({ userId }) {
|
|||
const user = mx.getUser(mx.getUserId());
|
||||
|
||||
const displayNameRef = useRef(null);
|
||||
const [avatarSrc, setAvatarSrc] = useState(user.avatarUrl ? mx.mxcUrlToHttp(user.avatarUrl, 80, 80, 'crop') : null);
|
||||
const [avatarSrc, setAvatarSrc] = useState(
|
||||
user.avatarUrl ? mx.mxcUrlToHttp(user.avatarUrl, 80, 80, 'crop') : null
|
||||
);
|
||||
const [username, setUsername] = useState(user.displayName);
|
||||
const [disabled, setDisabled] = useState(true);
|
||||
|
||||
|
|
@ -44,7 +45,7 @@ function ProfileEditor({ userId }) {
|
|||
'Remove avatar',
|
||||
'Are you sure that you want to remove avatar?',
|
||||
'Remove',
|
||||
'caution',
|
||||
'caution'
|
||||
);
|
||||
if (isConfirmed) {
|
||||
mx.setAvatarUrl('');
|
||||
|
|
@ -79,7 +80,10 @@ function ProfileEditor({ userId }) {
|
|||
<form
|
||||
className="profile-editor__form"
|
||||
style={{ marginBottom: avatarSrc ? '24px' : '0' }}
|
||||
onSubmit={(e) => { e.preventDefault(); saveDisplayName(); }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
saveDisplayName();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label={`Display name of ${mx.getUserId()}`}
|
||||
|
|
@ -87,7 +91,9 @@ function ProfileEditor({ userId }) {
|
|||
value={mx.getUser(mx.getUserId()).displayName}
|
||||
forwardRef={displayNameRef}
|
||||
/>
|
||||
<Button variant="primary" type="submit" disabled={disabled}>Save</Button>
|
||||
<Button variant="primary" type="submit" disabled={disabled}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={cancelDisplayNameChanges}>Cancel</Button>
|
||||
</form>
|
||||
);
|
||||
|
|
@ -95,7 +101,9 @@ function ProfileEditor({ userId }) {
|
|||
const renderInfo = () => (
|
||||
<div className="profile-editor__info" style={{ marginBottom: avatarSrc ? '24px' : '0' }}>
|
||||
<div>
|
||||
<Text variant="h2" primary weight="medium">{twemojify(username) ?? userId}</Text>
|
||||
<Text variant="h2" primary weight="medium">
|
||||
{username ?? userId}
|
||||
</Text>
|
||||
<IconButton
|
||||
src={PencilIC}
|
||||
size="extra-small"
|
||||
|
|
@ -116,9 +124,7 @@ function ProfileEditor({ userId }) {
|
|||
onUpload={handleAvatarUpload}
|
||||
onRequestRemove={() => handleAvatarUpload(null)}
|
||||
/>
|
||||
{
|
||||
isEditing ? renderForm() : renderInfo()
|
||||
}
|
||||
{isEditing ? renderForm() : renderInfo()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,17 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import './ProfileViewer.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { selectRoom, openReusableContextMenu } from '../../../client/action/navigation';
|
||||
import { openReusableContextMenu } from '../../../client/action/navigation';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
|
||||
import {
|
||||
getUsername, getUsernameOfRoomMember, getPowerLabel, hasDMWith, hasDevices,
|
||||
getUsername,
|
||||
getUsernameOfRoomMember,
|
||||
getPowerLabel,
|
||||
hasDevices,
|
||||
} from '../../../util/matrixUtil';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
|
@ -33,26 +34,24 @@ import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
|||
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { getDMRoomFor } from '../../utils/matrix';
|
||||
|
||||
function ModerationTools({
|
||||
roomId, userId,
|
||||
}) {
|
||||
function ModerationTools({ roomId, userId }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(roomId);
|
||||
const roomMember = room.getMember(userId);
|
||||
|
||||
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||
const powerLevel = roomMember?.powerLevel || 0;
|
||||
const canIKick = (
|
||||
roomMember?.membership === 'join'
|
||||
&& room.currentState.hasSufficientPowerLevelFor('kick', myPowerLevel)
|
||||
&& powerLevel < myPowerLevel
|
||||
);
|
||||
const canIBan = (
|
||||
['join', 'leave'].includes(roomMember?.membership)
|
||||
&& room.currentState.hasSufficientPowerLevelFor('ban', myPowerLevel)
|
||||
&& powerLevel < myPowerLevel
|
||||
);
|
||||
const canIKick =
|
||||
roomMember?.membership === 'join' &&
|
||||
room.currentState.hasSufficientPowerLevelFor('kick', myPowerLevel) &&
|
||||
powerLevel < myPowerLevel;
|
||||
const canIBan =
|
||||
['join', 'leave'].includes(roomMember?.membership) &&
|
||||
room.currentState.hasSufficientPowerLevelFor('ban', myPowerLevel) &&
|
||||
powerLevel < myPowerLevel;
|
||||
|
||||
const handleKick = (e) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -120,13 +119,14 @@ function SessionInfo({ userId }) {
|
|||
<div className="session-info__chips">
|
||||
{devices === null && <Text variant="b2">Loading sessions...</Text>}
|
||||
{devices?.length === 0 && <Text variant="b2">No session found.</Text>}
|
||||
{devices !== null && (devices.map((device) => (
|
||||
<Chip
|
||||
key={device.deviceId}
|
||||
iconSrc={ShieldEmptyIC}
|
||||
text={device.getDisplayName() || device.deviceId}
|
||||
/>
|
||||
)))}
|
||||
{devices !== null &&
|
||||
devices.map((device) => (
|
||||
<Chip
|
||||
key={device.deviceId}
|
||||
iconSrc={ShieldEmptyIC}
|
||||
text={device.getDisplayName() || device.deviceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -137,7 +137,11 @@ function SessionInfo({ userId }) {
|
|||
onClick={() => setIsVisible(!isVisible)}
|
||||
iconSrc={isVisible ? ChevronBottomIC : ChevronRightIC}
|
||||
>
|
||||
<Text variant="b2">{`View ${devices?.length > 0 ? `${devices.length} ${devices.length == 1 ? 'session' : 'sessions'}` : 'sessions'}`}</Text>
|
||||
<Text variant="b2">{`View ${
|
||||
devices?.length > 0
|
||||
? `${devices.length} ${devices.length == 1 ? 'session' : 'sessions'}`
|
||||
: 'sessions'
|
||||
}`}</Text>
|
||||
</MenuItem>
|
||||
{renderSessionChips()}
|
||||
</div>
|
||||
|
|
@ -155,6 +159,7 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
|||
|
||||
const isMountedRef = useRef(true);
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const room = mx.getRoom(roomId);
|
||||
const member = room.getMember(userId);
|
||||
const isInvitable = member?.membership !== 'join' && member?.membership !== 'ban';
|
||||
|
|
@ -164,25 +169,18 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
|||
|
||||
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||
const userPL = room.getMember(userId)?.powerLevel || 0;
|
||||
const canIKick = room.currentState.hasSufficientPowerLevelFor('kick', myPowerlevel) && userPL < myPowerlevel;
|
||||
const canIKick =
|
||||
room.currentState.hasSufficientPowerLevelFor('kick', myPowerlevel) && userPL < myPowerlevel;
|
||||
|
||||
const isBanned = member?.membership === 'ban';
|
||||
|
||||
const onCreated = (dmRoomId) => {
|
||||
if (isMountedRef.current === false) return;
|
||||
setIsCreatingDM(false);
|
||||
selectRoom(dmRoomId);
|
||||
navigateRoom(dmRoomId);
|
||||
onRequestClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const { roomList } = initMatrix;
|
||||
roomList.on(cons.events.roomList.ROOM_CREATED, onCreated);
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
roomList.removeListener(cons.events.roomList.ROOM_CREATED, onCreated);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setIsUserIgnored(initMatrix.matrixClient.isUserIgnored(userId));
|
||||
setIsIgnoring(false);
|
||||
|
|
@ -191,9 +189,9 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
|||
|
||||
const openDM = async () => {
|
||||
// Check and open if user already have a DM with userId.
|
||||
const dmRoomId = hasDMWith(userId);
|
||||
const dmRoomId = getDMRoomFor(mx, userId)?.roomId;
|
||||
if (dmRoomId) {
|
||||
selectRoom(dmRoomId);
|
||||
navigateRoom(dmRoomId);
|
||||
onRequestClose();
|
||||
return;
|
||||
}
|
||||
|
|
@ -201,7 +199,8 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
|||
// Create new DM
|
||||
try {
|
||||
setIsCreatingDM(true);
|
||||
await roomActions.createDM(userId, await hasDevices(userId));
|
||||
const result = await roomActions.createDM(userId, await hasDevices(userId));
|
||||
onCreated(result.room_id);
|
||||
} catch {
|
||||
if (isMountedRef.current === false) return;
|
||||
setIsCreatingDM(false);
|
||||
|
|
@ -246,31 +245,19 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
|||
|
||||
return (
|
||||
<div className="profile-viewer__buttons">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={openDM}
|
||||
disabled={isCreatingDM}
|
||||
>
|
||||
<Button variant="primary" onClick={openDM} disabled={isCreatingDM}>
|
||||
{isCreatingDM ? 'Creating room...' : 'Message'}
|
||||
</Button>
|
||||
{ isBanned && canIKick && (
|
||||
<Button
|
||||
variant="positive"
|
||||
onClick={() => roomActions.unban(roomId, userId)}
|
||||
>
|
||||
{isBanned && canIKick && (
|
||||
<Button variant="positive" onClick={() => roomActions.unban(roomId, userId)}>
|
||||
Unban
|
||||
</Button>
|
||||
)}
|
||||
{ (isInvited ? canIKick : room.canInvite(mx.getUserId())) && isInvitable && (
|
||||
<Button
|
||||
onClick={toggleInvite}
|
||||
disabled={isInviting}
|
||||
>
|
||||
{
|
||||
isInvited
|
||||
? `${isInviting ? 'Disinviting...' : 'Disinvite'}`
|
||||
: `${isInviting ? 'Inviting...' : 'Invite'}`
|
||||
}
|
||||
{(isInvited ? canIKick : room.canInvite(mx.getUserId())) && isInvitable && (
|
||||
<Button onClick={toggleInvite} disabled={isInviting}>
|
||||
{isInvited
|
||||
? `${isInviting ? 'Disinviting...' : 'Disinvite'}`
|
||||
: `${isInviting ? 'Inviting...' : 'Invite'}`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
|
|
@ -278,11 +265,9 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
|||
onClick={toggleIgnore}
|
||||
disabled={isIgnoring}
|
||||
>
|
||||
{
|
||||
isUserIgnored
|
||||
? `${isIgnoring ? 'Unignoring...' : 'Unignore'}`
|
||||
: `${isIgnoring ? 'Ignoring...' : 'Ignore'}`
|
||||
}
|
||||
{isUserIgnored
|
||||
? `${isIgnoring ? 'Unignoring...' : 'Unignore'}`
|
||||
: `${isIgnoring ? 'Ignoring...' : 'Ignore'}`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -326,8 +311,8 @@ function useRerenderOnProfileChange(roomId, userId) {
|
|||
useEffect(() => {
|
||||
const handleProfileChange = (mEvent, member) => {
|
||||
if (
|
||||
mEvent.getRoomId() === roomId
|
||||
&& (member.userId === userId || member.userId === mx.getUserId())
|
||||
mEvent.getRoomId() === roomId &&
|
||||
(member.userId === userId || member.userId === mx.getUserId())
|
||||
) {
|
||||
forceUpdate();
|
||||
}
|
||||
|
|
@ -352,20 +337,22 @@ function ProfileViewer() {
|
|||
const roomMember = room.getMember(userId);
|
||||
const username = roomMember ? getUsernameOfRoomMember(roomMember) : getUsername(userId);
|
||||
const avatarMxc = roomMember?.getMxcAvatarUrl?.() || mx.getUser(userId)?.avatarUrl;
|
||||
const avatarUrl = (avatarMxc && avatarMxc !== 'null') ? mx.mxcUrlToHttp(avatarMxc, 80, 80, 'crop') : null;
|
||||
const avatarUrl =
|
||||
avatarMxc && avatarMxc !== 'null' ? mx.mxcUrlToHttp(avatarMxc, 80, 80, 'crop') : null;
|
||||
|
||||
const powerLevel = roomMember?.powerLevel || 0;
|
||||
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||
|
||||
const canChangeRole = (
|
||||
room.currentState.maySendEvent('m.room.power_levels', mx.getUserId())
|
||||
&& (powerLevel < myPowerLevel || userId === mx.getUserId())
|
||||
);
|
||||
const canChangeRole =
|
||||
room.currentState.maySendEvent('m.room.power_levels', mx.getUserId()) &&
|
||||
(powerLevel < myPowerLevel || userId === mx.getUserId());
|
||||
|
||||
const handleChangePowerLevel = async (newPowerLevel) => {
|
||||
if (newPowerLevel === powerLevel) return;
|
||||
const SHARED_POWER_MSG = 'You will not be able to undo this change as you are promoting the user to have the same power level as yourself. Are you sure?';
|
||||
const DEMOTING_MYSELF_MSG = 'You will not be able to undo this change as you are demoting yourself. Are you sure?';
|
||||
const SHARED_POWER_MSG =
|
||||
'You will not be able to undo this change as you are promoting the user to have the same power level as yourself. Are you sure?';
|
||||
const DEMOTING_MYSELF_MSG =
|
||||
'You will not be able to undo this change as you are demoting yourself. Are you sure?';
|
||||
|
||||
const isSharedPower = newPowerLevel === myPowerLevel;
|
||||
const isDemotingMyself = userId === mx.getUserId();
|
||||
|
|
@ -374,7 +361,7 @@ function ProfileViewer() {
|
|||
'Change power level',
|
||||
isSharedPower ? SHARED_POWER_MSG : DEMOTING_MYSELF_MSG,
|
||||
'Change',
|
||||
'caution',
|
||||
'caution'
|
||||
);
|
||||
if (!isConfirmed) return;
|
||||
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
|
||||
|
|
@ -384,20 +371,16 @@ function ProfileViewer() {
|
|||
};
|
||||
|
||||
const handlePowerSelector = (e) => {
|
||||
openReusableContextMenu(
|
||||
'bottom',
|
||||
getEventCords(e, '.btn-surface'),
|
||||
(closeMenu) => (
|
||||
<PowerLevelSelector
|
||||
value={powerLevel}
|
||||
max={myPowerLevel}
|
||||
onSelect={(pl) => {
|
||||
closeMenu();
|
||||
handleChangePowerLevel(pl);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
);
|
||||
openReusableContextMenu('bottom', getEventCords(e, '.btn-surface'), (closeMenu) => (
|
||||
<PowerLevelSelector
|
||||
value={powerLevel}
|
||||
max={myPowerLevel}
|
||||
onSelect={(pl) => {
|
||||
closeMenu();
|
||||
handleChangePowerLevel(pl);
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -405,8 +388,10 @@ function ProfileViewer() {
|
|||
<div className="profile-viewer__user">
|
||||
<Avatar imageSrc={avatarUrl} text={username} bgColor={colorMXID(userId)} size="large" />
|
||||
<div className="profile-viewer__user__info">
|
||||
<Text variant="s1" weight="medium">{twemojify(username)}</Text>
|
||||
<Text variant="b2">{twemojify(userId)}</Text>
|
||||
<Text variant="s1" weight="medium">
|
||||
{username}
|
||||
</Text>
|
||||
<Text variant="b2">{userId}</Text>
|
||||
</div>
|
||||
<div className="profile-viewer__user__role">
|
||||
<Text variant="b3">Role</Text>
|
||||
|
|
@ -420,7 +405,7 @@ function ProfileViewer() {
|
|||
</div>
|
||||
<ModerationTools roomId={roomId} userId={userId} />
|
||||
<SessionInfo userId={userId} />
|
||||
{ userId !== mx.getUserId() && (
|
||||
{userId !== mx.getUserId() && (
|
||||
<ProfileFooter roomId={roomId} userId={userId} onRequestClose={closeDialog} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,295 +0,0 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './PublicRooms.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import { selectRoom, selectTab } from '../../../client/action/navigation';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import PopupWindow from '../../molecules/popup-window/PopupWindow';
|
||||
import RoomTile from '../../molecules/room-tile/RoomTile';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
|
||||
|
||||
const SEARCH_LIMIT = 20;
|
||||
|
||||
function TryJoinWithAlias({ alias, onRequestClose }) {
|
||||
const [status, setStatus] = useState({
|
||||
isJoining: false,
|
||||
error: null,
|
||||
roomId: null,
|
||||
tempRoomId: null,
|
||||
});
|
||||
function handleOnRoomAdded(roomId) {
|
||||
if (status.tempRoomId !== null && status.tempRoomId !== roomId) return;
|
||||
setStatus({
|
||||
isJoining: false, error: null, roomId, tempRoomId: null,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
|
||||
return () => {
|
||||
initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
|
||||
};
|
||||
}, [status]);
|
||||
|
||||
async function joinWithAlias() {
|
||||
setStatus({
|
||||
isJoining: true, error: null, roomId: null, tempRoomId: null,
|
||||
});
|
||||
try {
|
||||
const roomId = await roomActions.join(alias, false);
|
||||
setStatus({
|
||||
isJoining: true, error: null, roomId: null, tempRoomId: roomId,
|
||||
});
|
||||
} catch (e) {
|
||||
setStatus({
|
||||
isJoining: false,
|
||||
error: `Unable to join ${alias}. Either room is private or doesn't exist.`,
|
||||
roomId: null,
|
||||
tempRoomId: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="try-join-with-alias">
|
||||
{status.roomId === null && !status.isJoining && status.error === null && (
|
||||
<Button onClick={() => joinWithAlias()}>{`Try joining ${alias}`}</Button>
|
||||
)}
|
||||
{status.isJoining && (
|
||||
<>
|
||||
<Spinner size="small" />
|
||||
<Text>{`Joining ${alias}...`}</Text>
|
||||
</>
|
||||
)}
|
||||
{status.roomId !== null && (
|
||||
<Button onClick={() => { onRequestClose(); selectRoom(status.roomId); }}>Open</Button>
|
||||
)}
|
||||
{status.error !== null && <Text variant="b2"><span style={{ color: 'var(--bg-danger)' }}>{status.error}</span></Text>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TryJoinWithAlias.propTypes = {
|
||||
alias: PropTypes.string.isRequired,
|
||||
onRequestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function PublicRooms({ isOpen, searchTerm, onRequestClose }) {
|
||||
const [isSearching, updateIsSearching] = useState(false);
|
||||
const [isViewMore, updateIsViewMore] = useState(false);
|
||||
const [publicRooms, updatePublicRooms] = useState([]);
|
||||
const [nextBatch, updateNextBatch] = useState(undefined);
|
||||
const [searchQuery, updateSearchQuery] = useState({});
|
||||
const [joiningRooms, updateJoiningRooms] = useState(new Set());
|
||||
|
||||
const roomNameRef = useRef(null);
|
||||
const hsRef = useRef(null);
|
||||
const userId = initMatrix.matrixClient.getUserId();
|
||||
|
||||
async function searchRooms(viewMore) {
|
||||
let inputRoomName = roomNameRef?.current?.value || searchTerm;
|
||||
let isInputAlias = false;
|
||||
if (typeof inputRoomName === 'string') {
|
||||
isInputAlias = inputRoomName[0] === '#' && inputRoomName.indexOf(':') > 1;
|
||||
}
|
||||
const hsFromAlias = (isInputAlias) ? inputRoomName.slice(inputRoomName.indexOf(':') + 1) : null;
|
||||
let inputHs = hsFromAlias || hsRef?.current?.value;
|
||||
|
||||
if (typeof inputHs !== 'string') inputHs = userId.slice(userId.indexOf(':') + 1);
|
||||
if (typeof inputRoomName !== 'string') inputRoomName = '';
|
||||
|
||||
if (isSearching) return;
|
||||
if (viewMore !== true
|
||||
&& inputRoomName === searchQuery.name
|
||||
&& inputHs === searchQuery.homeserver
|
||||
) return;
|
||||
|
||||
updateSearchQuery({
|
||||
name: inputRoomName,
|
||||
homeserver: inputHs,
|
||||
});
|
||||
if (isViewMore !== viewMore) updateIsViewMore(viewMore);
|
||||
updateIsSearching(true);
|
||||
|
||||
try {
|
||||
const result = await initMatrix.matrixClient.publicRooms({
|
||||
server: inputHs,
|
||||
limit: SEARCH_LIMIT,
|
||||
since: viewMore ? nextBatch : undefined,
|
||||
include_all_networks: true,
|
||||
filter: {
|
||||
generic_search_term: inputRoomName,
|
||||
},
|
||||
});
|
||||
|
||||
const totalRooms = viewMore ? publicRooms.concat(result.chunk) : result.chunk;
|
||||
updatePublicRooms(totalRooms);
|
||||
updateNextBatch(result.next_batch);
|
||||
updateIsSearching(false);
|
||||
updateIsViewMore(false);
|
||||
if (totalRooms.length === 0) {
|
||||
updateSearchQuery({
|
||||
error: inputRoomName === ''
|
||||
? `No public rooms on ${inputHs}`
|
||||
: `No result found for "${inputRoomName}" on ${inputHs}`,
|
||||
alias: isInputAlias ? inputRoomName : null,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
updatePublicRooms([]);
|
||||
let err = 'Something went wrong!';
|
||||
if (e?.httpStatus >= 400 && e?.httpStatus < 500) {
|
||||
err = e.message;
|
||||
}
|
||||
updateSearchQuery({
|
||||
error: err,
|
||||
alias: isInputAlias ? inputRoomName : null,
|
||||
});
|
||||
updateIsSearching(false);
|
||||
updateNextBatch(undefined);
|
||||
updateIsViewMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) searchRooms();
|
||||
}, [isOpen]);
|
||||
|
||||
function handleOnRoomAdded(roomId) {
|
||||
if (joiningRooms.has(roomId)) {
|
||||
joiningRooms.delete(roomId);
|
||||
updateJoiningRooms(new Set(Array.from(joiningRooms)));
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
|
||||
return () => {
|
||||
initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
|
||||
};
|
||||
}, [joiningRooms]);
|
||||
|
||||
function handleViewRoom(roomId) {
|
||||
const room = initMatrix.matrixClient.getRoom(roomId);
|
||||
if (room.isSpaceRoom()) selectTab(roomId);
|
||||
else selectRoom(roomId);
|
||||
onRequestClose();
|
||||
}
|
||||
|
||||
function joinRoom(roomIdOrAlias) {
|
||||
joiningRooms.add(roomIdOrAlias);
|
||||
updateJoiningRooms(new Set(Array.from(joiningRooms)));
|
||||
roomActions.join(roomIdOrAlias, false);
|
||||
}
|
||||
|
||||
function renderRoomList(rooms) {
|
||||
return rooms.map((room) => {
|
||||
const alias = typeof room.canonical_alias === 'string' ? room.canonical_alias : room.room_id;
|
||||
const name = typeof room.name === 'string' ? room.name : alias;
|
||||
const isJoined = initMatrix.matrixClient.getRoom(room.room_id)?.getMyMembership() === 'join';
|
||||
return (
|
||||
<RoomTile
|
||||
key={room.room_id}
|
||||
avatarSrc={typeof room.avatar_url === 'string' ? initMatrix.matrixClient.mxcUrlToHttp(room.avatar_url, 42, 42, 'crop') : null}
|
||||
name={name}
|
||||
id={alias}
|
||||
memberCount={room.num_joined_members}
|
||||
desc={typeof room.topic === 'string' ? room.topic : null}
|
||||
options={(
|
||||
<>
|
||||
{isJoined && <Button onClick={() => handleViewRoom(room.room_id)}>Open</Button>}
|
||||
{!isJoined && (joiningRooms.has(room.room_id) ? <Spinner size="small" /> : <Button onClick={() => joinRoom(room.aliases?.[0] || room.room_id)} variant="primary">Join</Button>)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<PopupWindow
|
||||
isOpen={isOpen}
|
||||
title="Public rooms"
|
||||
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
|
||||
onRequestClose={onRequestClose}
|
||||
>
|
||||
<div className="public-rooms">
|
||||
<form className="public-rooms__form" onSubmit={(e) => { e.preventDefault(); searchRooms(); }}>
|
||||
<div className="public-rooms__input-wrapper">
|
||||
<Input value={searchTerm} forwardRef={roomNameRef} label="Room name or alias" />
|
||||
<Input forwardRef={hsRef} value={userId.slice(userId.indexOf(':') + 1)} label="Homeserver" required />
|
||||
</div>
|
||||
<Button disabled={isSearching} iconSrc={HashSearchIC} variant="primary" type="submit">Search</Button>
|
||||
</form>
|
||||
<div className="public-rooms__search-status">
|
||||
{
|
||||
typeof searchQuery.name !== 'undefined' && isSearching && (
|
||||
searchQuery.name === ''
|
||||
? (
|
||||
<div className="flex--center">
|
||||
<Spinner size="small" />
|
||||
<Text variant="b2">{`Loading public rooms from ${searchQuery.homeserver}...`}</Text>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex--center">
|
||||
<Spinner size="small" />
|
||||
<Text variant="b2">{`Searching for "${searchQuery.name}" on ${searchQuery.homeserver}...`}</Text>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
{
|
||||
typeof searchQuery.name !== 'undefined' && !isSearching && (
|
||||
searchQuery.name === ''
|
||||
? <Text variant="b2">{`Public rooms on ${searchQuery.homeserver}.`}</Text>
|
||||
: <Text variant="b2">{`Search result for "${searchQuery.name}" on ${searchQuery.homeserver}.`}</Text>
|
||||
)
|
||||
}
|
||||
{ searchQuery.error && (
|
||||
<>
|
||||
<Text className="public-rooms__search-error" variant="b2">{searchQuery.error}</Text>
|
||||
{typeof searchQuery.alias === 'string' && (
|
||||
<TryJoinWithAlias onRequestClose={onRequestClose} alias={searchQuery.alias} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{ publicRooms.length !== 0 && (
|
||||
<div className="public-rooms__content">
|
||||
{ renderRoomList(publicRooms) }
|
||||
</div>
|
||||
)}
|
||||
{ publicRooms.length !== 0 && publicRooms.length % SEARCH_LIMIT === 0 && (
|
||||
<div className="public-rooms__view-more">
|
||||
{ isViewMore !== true && (
|
||||
<Button onClick={() => searchRooms(true)}>View more</Button>
|
||||
)}
|
||||
{ isViewMore && <Spinner /> }
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopupWindow>
|
||||
);
|
||||
}
|
||||
|
||||
PublicRooms.defaultProps = {
|
||||
searchTerm: undefined,
|
||||
};
|
||||
|
||||
PublicRooms.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
searchTerm: PropTypes.string,
|
||||
onRequestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default PublicRooms;
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
|
||||
.public-rooms {
|
||||
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
||||
margin-top: var(--sp-extra-tight);
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
& .btn-primary {
|
||||
padding: {
|
||||
top: 11px;
|
||||
bottom: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&__input-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
display: flex;
|
||||
@include dir.side(margin, 0, var(--sp-normal));
|
||||
|
||||
& > div:first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
& .input {
|
||||
@include dir.prop(border-radius,
|
||||
var(--bo-radius) 0 0 var(--bo-radius),
|
||||
0 var(--bo-radius) var(--bo-radius) 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
& > div:last-child .input {
|
||||
width: 120px;
|
||||
@include dir.prop(border-left-width, 0, 1px);
|
||||
@include dir.prop(border-right-width, 1px, 0);
|
||||
@include dir.prop(border-radius,
|
||||
0 var(--bo-radius) var(--bo-radius) 0,
|
||||
var(--bo-radius) 0 0 var(--bo-radius),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&__search-status {
|
||||
margin-top: var(--sp-extra-loose);
|
||||
margin-bottom: var(--sp-tight);
|
||||
& .donut-spinner {
|
||||
margin: 0 var(--sp-tight);
|
||||
}
|
||||
|
||||
.try-join-with-alias {
|
||||
margin-top: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
&__search-error {
|
||||
color: var(--bg-danger);
|
||||
}
|
||||
&__content {
|
||||
border-top: 1px solid var(--bg-surface-border);
|
||||
}
|
||||
&__view-more {
|
||||
margin-top: var(--sp-loose);
|
||||
@include dir.side(margin, calc(var(--av-normal) + var(--sp-normal)), 0);
|
||||
}
|
||||
|
||||
& .room-tile {
|
||||
margin-top: var(--sp-normal);
|
||||
&__options {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.try-join-with-alias {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& >.text:nth-child(2) {
|
||||
margin: 0 var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,8 @@
|
|||
import React from 'react';
|
||||
|
||||
import ReadReceipts from '../read-receipts/ReadReceipts';
|
||||
import ProfileViewer from '../profile-viewer/ProfileViewer';
|
||||
import ShortcutSpaces from '../shortcut-spaces/ShortcutSpaces';
|
||||
import SpaceAddExisting from '../../molecules/space-add-existing/SpaceAddExisting';
|
||||
import Search from '../search/Search';
|
||||
import ViewSource from '../view-source/ViewSource';
|
||||
import CreateRoom from '../create-room/CreateRoom';
|
||||
import JoinAlias from '../join-alias/JoinAlias';
|
||||
import EmojiVerification from '../emoji-verification/EmojiVerification';
|
||||
|
|
@ -15,10 +12,7 @@ import ReusableDialog from '../../molecules/dialog/ReusableDialog';
|
|||
function Dialogs() {
|
||||
return (
|
||||
<>
|
||||
<ReadReceipts />
|
||||
<ViewSource />
|
||||
<ProfileViewer />
|
||||
<ShortcutSpaces />
|
||||
<CreateRoom />
|
||||
<JoinAlias />
|
||||
<SpaceAddExisting />
|
||||
|
|
|
|||
|
|
@ -3,35 +3,18 @@ import React, { useState, useEffect } from 'react';
|
|||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
|
||||
import InviteList from '../invite-list/InviteList';
|
||||
import PublicRooms from '../public-rooms/PublicRooms';
|
||||
import InviteUser from '../invite-user/InviteUser';
|
||||
import Settings from '../settings/Settings';
|
||||
import SpaceSettings from '../space-settings/SpaceSettings';
|
||||
import SpaceManage from '../space-manage/SpaceManage';
|
||||
import RoomSettings from '../room/RoomSettings';
|
||||
|
||||
function Windows() {
|
||||
const [isInviteList, changeInviteList] = useState(false);
|
||||
const [publicRooms, changePublicRooms] = useState({
|
||||
isOpen: false,
|
||||
searchTerm: undefined,
|
||||
});
|
||||
const [inviteUser, changeInviteUser] = useState({
|
||||
isOpen: false,
|
||||
roomId: undefined,
|
||||
term: undefined,
|
||||
});
|
||||
|
||||
function openInviteList() {
|
||||
changeInviteList(true);
|
||||
}
|
||||
function openPublicRooms(searchTerm) {
|
||||
changePublicRooms({
|
||||
isOpen: true,
|
||||
searchTerm,
|
||||
});
|
||||
}
|
||||
function openInviteUser(roomId, searchTerm) {
|
||||
changeInviteUser({
|
||||
isOpen: true,
|
||||
|
|
@ -41,24 +24,14 @@ function Windows() {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
navigation.on(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
|
||||
navigation.on(cons.events.navigation.PUBLIC_ROOMS_OPENED, openPublicRooms);
|
||||
navigation.on(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
|
||||
navigation.removeListener(cons.events.navigation.PUBLIC_ROOMS_OPENED, openPublicRooms);
|
||||
navigation.removeListener(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InviteList isOpen={isInviteList} onRequestClose={() => changeInviteList(false)} />
|
||||
<PublicRooms
|
||||
isOpen={publicRooms.isOpen}
|
||||
searchTerm={publicRooms.searchTerm}
|
||||
onRequestClose={() => changePublicRooms({ isOpen: false, searchTerm: undefined })}
|
||||
/>
|
||||
<InviteUser
|
||||
isOpen={inviteUser.isOpen}
|
||||
roomId={inviteUser.roomId}
|
||||
|
|
@ -68,7 +41,6 @@ function Windows() {
|
|||
<Settings />
|
||||
<SpaceSettings />
|
||||
<RoomSettings />
|
||||
<SpaceManage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { getUsername, getUsernameOfRoomMember } from '../../../util/matrixUtil';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import PeopleSelector from '../../molecules/people-selector/PeopleSelector';
|
||||
import Dialog from '../../molecules/dialog/Dialog';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import { openProfileViewer } from '../../../client/action/navigation';
|
||||
|
||||
function ReadReceipts() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [readers, setReaders] = useState([]);
|
||||
const [roomId, setRoomId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadReadReceipts = (rId, userIds) => {
|
||||
setReaders(userIds);
|
||||
setRoomId(rId);
|
||||
setIsOpen(true);
|
||||
};
|
||||
navigation.on(cons.events.navigation.READRECEIPTS_OPENED, loadReadReceipts);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.READRECEIPTS_OPENED, loadReadReceipts);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAfterClose = () => {
|
||||
setReaders([]);
|
||||
setRoomId(null);
|
||||
};
|
||||
|
||||
function renderPeople(userId) {
|
||||
const room = initMatrix.matrixClient.getRoom(roomId);
|
||||
const member = room.getMember(userId);
|
||||
const getUserDisplayName = () => {
|
||||
if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId));
|
||||
return getUsername(userId);
|
||||
};
|
||||
return (
|
||||
<PeopleSelector
|
||||
key={userId}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
openProfileViewer(userId, roomId);
|
||||
}}
|
||||
avatarSrc={member?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')}
|
||||
name={getUserDisplayName(userId)}
|
||||
color={colorMXID(userId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
title="Seen by"
|
||||
onAfterClose={handleAfterClose}
|
||||
onRequestClose={() => setIsOpen(false)}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={() => setIsOpen(false)} tooltip="Close" />}
|
||||
>
|
||||
<div style={{ marginTop: 'var(--sp-tight)', marginBottom: 'var(--sp-extra-loose)' }}>
|
||||
{
|
||||
readers.map(renderPeople)
|
||||
}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReadReceipts;
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
class EventLimit {
|
||||
constructor() {
|
||||
this._from = 0;
|
||||
|
||||
this.SMALLEST_EVT_HEIGHT = 32;
|
||||
this.PAGES_COUNT = 4;
|
||||
}
|
||||
|
||||
get maxEvents() {
|
||||
return Math.round(document.body.clientHeight / this.SMALLEST_EVT_HEIGHT) * this.PAGES_COUNT;
|
||||
}
|
||||
|
||||
get from() {
|
||||
return this._from;
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this._from + this.maxEvents;
|
||||
}
|
||||
|
||||
setFrom(from) {
|
||||
this._from = from < 0 ? 0 : from;
|
||||
}
|
||||
|
||||
paginate(backwards, limit, timelineLength) {
|
||||
this._from = backwards ? this._from - limit : this._from + limit;
|
||||
|
||||
if (!backwards && this.length > timelineLength) {
|
||||
this._from = timelineLength - this.maxEvents;
|
||||
}
|
||||
if (this._from < 0) this._from = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default EventLimit;
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
import React, {
|
||||
useState, useEffect, useCallback, useRef,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './PeopleDrawer.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { getPowerLabel, getUsernameOfRoomMember } from '../../../util/matrixUtil';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
|
||||
import AsyncSearch from '../../../util/AsyncSearch';
|
||||
import { memberByAtoZ, memberByPowerLevel } from '../../../util/sort';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import SegmentedControl from '../../atoms/segmented-controls/SegmentedControls';
|
||||
import PeopleSelector from '../../molecules/people-selector/PeopleSelector';
|
||||
|
||||
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
function simplyfiMembers(members) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
return members.map((member) => ({
|
||||
userId: member.userId,
|
||||
name: getUsernameOfRoomMember(member),
|
||||
username: member.userId.slice(1, member.userId.indexOf(':')),
|
||||
avatarSrc: member.getAvatarUrl(mx.baseUrl, 24, 24, 'crop'),
|
||||
peopleRole: getPowerLabel(member.powerLevel),
|
||||
powerLevel: members.powerLevel,
|
||||
}));
|
||||
}
|
||||
|
||||
const asyncSearch = new AsyncSearch();
|
||||
function PeopleDrawer({ roomId }) {
|
||||
const PER_PAGE_MEMBER = 50;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(roomId);
|
||||
const canInvite = room?.canInvite(mx.getUserId());
|
||||
|
||||
const [itemCount, setItemCount] = useState(PER_PAGE_MEMBER);
|
||||
const [membership, setMembership] = useState('join');
|
||||
const [memberList, setMemberList] = useState([]);
|
||||
const [searchedMembers, setSearchedMembers] = useState(null);
|
||||
const searchRef = useRef(null);
|
||||
|
||||
const getMembersWithMembership = useCallback(
|
||||
(mship) => room.getMembersWithMembership(mship),
|
||||
[roomId, membership],
|
||||
);
|
||||
|
||||
function loadMorePeople() {
|
||||
setItemCount(itemCount + PER_PAGE_MEMBER);
|
||||
}
|
||||
|
||||
function handleSearchData(data) {
|
||||
// NOTICE: data is passed as object property
|
||||
// because react sucks at handling state update with array.
|
||||
setSearchedMembers({ data });
|
||||
setItemCount(PER_PAGE_MEMBER);
|
||||
}
|
||||
|
||||
function handleSearch(e) {
|
||||
const term = e.target.value;
|
||||
if (term === '' || term === undefined) {
|
||||
searchRef.current.value = '';
|
||||
searchRef.current.focus();
|
||||
setSearchedMembers(null);
|
||||
setItemCount(PER_PAGE_MEMBER);
|
||||
} else asyncSearch.search(term);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
asyncSearch.setup(memberList, {
|
||||
keys: ['name', 'username', 'userId'],
|
||||
limit: PER_PAGE_MEMBER,
|
||||
});
|
||||
}, [memberList]);
|
||||
|
||||
useEffect(() => {
|
||||
let isLoadingMembers = false;
|
||||
let isRoomChanged = false;
|
||||
const updateMemberList = (event) => {
|
||||
if (isLoadingMembers) return;
|
||||
if (event && event?.getRoomId() !== roomId) return;
|
||||
setMemberList(
|
||||
simplyfiMembers(
|
||||
getMembersWithMembership(membership)
|
||||
.sort(memberByAtoZ).sort(memberByPowerLevel),
|
||||
),
|
||||
);
|
||||
};
|
||||
searchRef.current.value = '';
|
||||
updateMemberList();
|
||||
isLoadingMembers = true;
|
||||
room.loadMembersIfNeeded().then(() => {
|
||||
isLoadingMembers = false;
|
||||
if (isRoomChanged) return;
|
||||
updateMemberList();
|
||||
});
|
||||
|
||||
asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchData);
|
||||
mx.on('RoomMember.membership', updateMemberList);
|
||||
mx.on('RoomMember.powerLevel', updateMemberList);
|
||||
return () => {
|
||||
isRoomChanged = true;
|
||||
setMemberList([]);
|
||||
setSearchedMembers(null);
|
||||
setItemCount(PER_PAGE_MEMBER);
|
||||
asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchData);
|
||||
mx.removeListener('RoomMember.membership', updateMemberList);
|
||||
mx.removeListener('RoomMember.powerLevel', updateMemberList);
|
||||
};
|
||||
}, [roomId, membership]);
|
||||
|
||||
useEffect(() => {
|
||||
setMembership('join');
|
||||
}, [roomId]);
|
||||
|
||||
const mList = searchedMembers !== null ? searchedMembers.data : memberList.slice(0, itemCount);
|
||||
return (
|
||||
<div className="people-drawer">
|
||||
<Header>
|
||||
<TitleWrapper>
|
||||
<Text variant="s1" primary>
|
||||
People
|
||||
<Text className="people-drawer__member-count" variant="b3">{`${room.getJoinedMemberCount()} members`}</Text>
|
||||
</Text>
|
||||
</TitleWrapper>
|
||||
<IconButton onClick={() => openInviteUser(roomId)} tooltip="Invite" src={AddUserIC} disabled={!canInvite} />
|
||||
</Header>
|
||||
<div className="people-drawer__content-wrapper">
|
||||
<div className="people-drawer__scrollable">
|
||||
<ScrollView autoHide>
|
||||
<div className="people-drawer__content">
|
||||
<SegmentedControl
|
||||
selected={
|
||||
(() => {
|
||||
const getSegmentIndex = {
|
||||
join: 0,
|
||||
invite: 1,
|
||||
ban: 2,
|
||||
};
|
||||
return getSegmentIndex[membership];
|
||||
})()
|
||||
}
|
||||
segments={[{ text: 'Joined' }, { text: 'Invited' }, { text: 'Banned' }]}
|
||||
onSelect={(index) => {
|
||||
const selectSegment = [
|
||||
() => setMembership('join'),
|
||||
() => setMembership('invite'),
|
||||
() => setMembership('ban'),
|
||||
];
|
||||
selectSegment[index]?.();
|
||||
}}
|
||||
/>
|
||||
{
|
||||
mList.map((member) => (
|
||||
<PeopleSelector
|
||||
key={member.userId}
|
||||
onClick={() => openProfileViewer(member.userId, roomId)}
|
||||
avatarSrc={member.avatarSrc}
|
||||
name={member.name}
|
||||
color={colorMXID(member.userId)}
|
||||
peopleRole={member.peopleRole}
|
||||
/>
|
||||
))
|
||||
}
|
||||
{
|
||||
(searchedMembers?.data.length === 0 || memberList.length === 0)
|
||||
&& (
|
||||
<div className="people-drawer__noresult">
|
||||
<Text variant="b2">No results found!</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="people-drawer__load-more">
|
||||
{
|
||||
mList.length !== 0
|
||||
&& memberList.length > itemCount
|
||||
&& searchedMembers === null
|
||||
&& (
|
||||
<Button onClick={loadMorePeople}>View more</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
<div className="people-drawer__sticky">
|
||||
<form onSubmit={(e) => e.preventDefault()} className="people-search">
|
||||
<RawIcon size="small" src={SearchIC} />
|
||||
<Input forwardRef={searchRef} type="text" onChange={handleSearch} placeholder="Search" required />
|
||||
{
|
||||
searchedMembers !== null
|
||||
&& <IconButton onClick={handleSearch} size="small" src={CrossIC} />
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PeopleDrawer.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default PeopleDrawer;
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.people-drawer {
|
||||
@extend .cp-fx__column;
|
||||
width: var(--people-drawer-width);
|
||||
background-color: var(--bg-surface-low);
|
||||
@include dir.side(border, 1px solid var(--bg-surface-border), none);
|
||||
|
||||
&__member-count {
|
||||
color: var(--tc-surface-low);
|
||||
}
|
||||
|
||||
&__content-wrapper {
|
||||
@extend .cp-fx__item-one;
|
||||
@extend .cp-fx__column;
|
||||
}
|
||||
|
||||
&__scrollable {
|
||||
@extend .cp-fx__item-one;
|
||||
}
|
||||
|
||||
&__noresult {
|
||||
padding: var(--sp-extra-tight) var(--sp-normal);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__sticky {
|
||||
& .people-search {
|
||||
--search-input-height: 40px;
|
||||
min-height: var(--search-input-height);
|
||||
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
|
||||
position: relative;
|
||||
bottom: var(--sp-normal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& > .ic-raw,
|
||||
& > .ic-btn {
|
||||
position: absolute;
|
||||
z-index: 99;
|
||||
}
|
||||
& > .ic-raw {
|
||||
@include dir.prop(left, var(--sp-tight), unset);
|
||||
@include dir.prop(right, unset, var(--sp-tight));
|
||||
}
|
||||
& > .ic-btn {
|
||||
@include dir.prop(right, 2px, unset);
|
||||
@include dir.prop(left, unset, 2px);
|
||||
}
|
||||
& .input-container {
|
||||
flex: 1;
|
||||
}
|
||||
& .input {
|
||||
padding: 0 44px;
|
||||
height: var(--search-input-height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.people-drawer__content {
|
||||
padding-top: var(--sp-extra-tight);
|
||||
padding-bottom: calc(2 * var(--sp-normal));
|
||||
|
||||
& .people-selector {
|
||||
padding: var(--sp-extra-tight);
|
||||
border-radius: var(--bo-radius);
|
||||
&__container {
|
||||
@include dir.side(margin, var(--sp-extra-tight), 0);
|
||||
}
|
||||
}
|
||||
|
||||
& .segmented-controls {
|
||||
display: flex;
|
||||
margin-bottom: var(--sp-extra-tight);
|
||||
@include dir.side(margin, var(--sp-extra-tight), 0);
|
||||
}
|
||||
& .segment-btn {
|
||||
flex: 1;
|
||||
padding: var(--sp-ultra-tight) 0;
|
||||
}
|
||||
}
|
||||
.people-drawer__load-more {
|
||||
padding: var(--sp-normal) 0 0;
|
||||
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
||||
|
||||
& .btn-surface {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/screen';
|
||||
|
||||
.room {
|
||||
@extend .cp-fx__row;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
|
||||
&__content {
|
||||
@extend .cp-fx__item-one;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.room .people-drawer {
|
||||
@include screen.smallerThan(tabletBreakpoint) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import './RoomSettings.scss';
|
|||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Tabs from '../../atoms/tabs/Tabs';
|
||||
|
|
@ -86,7 +85,7 @@ function GeneralSettings({ roomId }) {
|
|||
'danger'
|
||||
);
|
||||
if (!isConfirmed) return;
|
||||
roomActions.leave(roomId);
|
||||
mx.leave(roomId);
|
||||
}}
|
||||
iconSrc={LeaveArrowIC}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/screen';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.room-view {
|
||||
@extend .cp-fx__column;
|
||||
background-color: var(--bg-surface);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
box-shadow: none;
|
||||
|
||||
transition: transform 200ms var(--fluid-slide-down);
|
||||
|
||||
&--dropped {
|
||||
transform: translateY(calc(100% - var(--header-height)));
|
||||
border-radius: var(--bo-radius) var(--bo-radius) 0 0;
|
||||
box-shadow: var(--bs-popup);
|
||||
}
|
||||
|
||||
& .header {
|
||||
@include screen.smallerThan(mobileBreakpoint) {
|
||||
padding: 0 var(--sp-tight);
|
||||
}
|
||||
}
|
||||
|
||||
&__content-wrapper {
|
||||
@extend .cp-fx__item-one;
|
||||
@extend .cp-fx__column;
|
||||
}
|
||||
|
||||
&__scrollable {
|
||||
@extend .cp-fx__item-one;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__sticky {
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
&__editor {
|
||||
padding: 0 var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomViewCmdBar.scss';
|
||||
import parse from 'html-react-parser';
|
||||
import twemoji from 'twemoji';
|
||||
|
||||
import { twemojify, TWEMOJI_BASE_URL } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { getEmojiForCompletion } from '../emoji-board/custom-emoji';
|
||||
import AsyncSearch from '../../../util/AsyncSearch';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import FollowingMembers from '../../molecules/following-members/FollowingMembers';
|
||||
import { addRecentEmoji, getRecentEmojis } from '../emoji-board/recent';
|
||||
import commands from './commands';
|
||||
|
||||
function CmdItem({ onClick, children }) {
|
||||
return (
|
||||
<button className="cmd-item" onClick={onClick} type="button">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
CmdItem.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
function renderSuggestions({ prefix, option, suggestions }, fireCmd) {
|
||||
function renderCmdSuggestions(cmdPrefix, cmds) {
|
||||
const cmdOptString = typeof option === 'string' ? `/${option}` : '/?';
|
||||
return cmds.map((cmd) => (
|
||||
<CmdItem
|
||||
key={cmd}
|
||||
onClick={() => {
|
||||
fireCmd({
|
||||
prefix: cmdPrefix,
|
||||
option,
|
||||
result: commands[cmd],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text variant="b2">{`${cmd}${cmd.isOptions ? cmdOptString : ''}`}</Text>
|
||||
</CmdItem>
|
||||
));
|
||||
}
|
||||
|
||||
function renderEmojiSuggestion(emPrefix, emos) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
|
||||
// Renders a small Twemoji
|
||||
function renderTwemoji(emoji) {
|
||||
return parse(
|
||||
twemoji.parse(emoji.unicode, {
|
||||
attributes: () => ({
|
||||
unicode: emoji.unicode,
|
||||
shortcodes: emoji.shortcodes?.toString(),
|
||||
}),
|
||||
base: TWEMOJI_BASE_URL,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Render a custom emoji
|
||||
function renderCustomEmoji(emoji) {
|
||||
return (
|
||||
<img
|
||||
className="emoji"
|
||||
src={mx.mxcUrlToHttp(emoji.mxc)}
|
||||
data-mx-emoticon=""
|
||||
alt={`:${emoji.shortcode}:`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Dynamically render either a custom emoji or twemoji based on what the input is
|
||||
function renderEmoji(emoji) {
|
||||
if (emoji.mxc) {
|
||||
return renderCustomEmoji(emoji);
|
||||
}
|
||||
return renderTwemoji(emoji);
|
||||
}
|
||||
|
||||
return emos.map((emoji) => (
|
||||
<CmdItem
|
||||
key={emoji.shortcode}
|
||||
onClick={() =>
|
||||
fireCmd({
|
||||
prefix: emPrefix,
|
||||
result: emoji,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text variant="b1">{renderEmoji(emoji)}</Text>
|
||||
<Text variant="b2">{`:${emoji.shortcode}:`}</Text>
|
||||
</CmdItem>
|
||||
));
|
||||
}
|
||||
|
||||
function renderNameSuggestion(namePrefix, members) {
|
||||
return members.map((member) => (
|
||||
<CmdItem
|
||||
key={member.userId}
|
||||
onClick={() => {
|
||||
fireCmd({
|
||||
prefix: namePrefix,
|
||||
result: member,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text variant="b2">{twemojify(member.name)}</Text>
|
||||
</CmdItem>
|
||||
));
|
||||
}
|
||||
|
||||
const cmd = {
|
||||
'/': (cmds) => renderCmdSuggestions(prefix, cmds),
|
||||
':': (emos) => renderEmojiSuggestion(prefix, emos),
|
||||
'@': (members) => renderNameSuggestion(prefix, members),
|
||||
};
|
||||
return cmd[prefix]?.(suggestions);
|
||||
}
|
||||
|
||||
const asyncSearch = new AsyncSearch();
|
||||
let cmdPrefix;
|
||||
let cmdOption;
|
||||
function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
|
||||
const [cmd, setCmd] = useState(null);
|
||||
|
||||
function displaySuggestions(suggestions) {
|
||||
if (suggestions.length === 0) {
|
||||
setCmd({ prefix: cmd?.prefix || cmdPrefix, error: 'No suggestion found.' });
|
||||
viewEvent.emit('cmd_error');
|
||||
return;
|
||||
}
|
||||
setCmd({ prefix: cmd?.prefix || cmdPrefix, suggestions, option: cmdOption });
|
||||
}
|
||||
|
||||
function processCmd(prefix, slug) {
|
||||
let searchTerm = slug;
|
||||
cmdOption = undefined;
|
||||
cmdPrefix = prefix;
|
||||
if (prefix === '/') {
|
||||
const cmdSlugParts = slug.split('/');
|
||||
[searchTerm, cmdOption] = cmdSlugParts;
|
||||
}
|
||||
if (prefix === ':') {
|
||||
if (searchTerm.length <= 3) {
|
||||
if (searchTerm.match(/^[-]?(\))$/)) searchTerm = 'smile';
|
||||
else if (searchTerm.match(/^[-]?(s|S)$/)) searchTerm = 'confused';
|
||||
else if (searchTerm.match(/^[-]?(o|O|0)$/)) searchTerm = 'astonished';
|
||||
else if (searchTerm.match(/^[-]?(\|)$/)) searchTerm = 'neutral_face';
|
||||
else if (searchTerm.match(/^[-]?(d|D)$/)) searchTerm = 'grin';
|
||||
else if (searchTerm.match(/^[-]?(\/)$/)) searchTerm = 'frown';
|
||||
else if (searchTerm.match(/^[-]?(p|P)$/)) searchTerm = 'stuck_out_tongue';
|
||||
else if (searchTerm.match(/^'[-]?(\()$/)) searchTerm = 'cry';
|
||||
else if (searchTerm.match(/^[-]?(x|X)$/)) searchTerm = 'dizzy_face';
|
||||
else if (searchTerm.match(/^[-]?(\()$/)) searchTerm = 'pleading_face';
|
||||
else if (searchTerm.match(/^[-]?(\$)$/)) searchTerm = 'money';
|
||||
else if (searchTerm.match(/^(<3)$/)) searchTerm = 'heart';
|
||||
else if (searchTerm.match(/^(c|ca|cat)$/)) searchTerm = '_cat';
|
||||
}
|
||||
}
|
||||
|
||||
asyncSearch.search(searchTerm);
|
||||
}
|
||||
function activateCmd(prefix) {
|
||||
cmdPrefix = prefix;
|
||||
cmdPrefix = undefined;
|
||||
|
||||
const mx = initMatrix.matrixClient;
|
||||
const setupSearch = {
|
||||
'/': () => {
|
||||
asyncSearch.setup(Object.keys(commands), { isContain: true });
|
||||
setCmd({ prefix, suggestions: Object.keys(commands) });
|
||||
},
|
||||
':': () => {
|
||||
const parentIds = initMatrix.roomList.getAllParentSpaces(roomId);
|
||||
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
||||
const emojis = getEmojiForCompletion(mx, [mx.getRoom(roomId), ...parentRooms]);
|
||||
const recentEmoji = getRecentEmojis(20);
|
||||
asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 });
|
||||
setCmd({
|
||||
prefix,
|
||||
suggestions: recentEmoji.length > 0 ? recentEmoji : emojis.slice(26, 46),
|
||||
});
|
||||
},
|
||||
'@': () => {
|
||||
const members = mx
|
||||
.getRoom(roomId)
|
||||
.getJoinedMembers()
|
||||
.map((member) => ({
|
||||
name: member.name,
|
||||
userId: member.userId.slice(1),
|
||||
}));
|
||||
asyncSearch.setup(members, { keys: ['name', 'userId'], limit: 20 });
|
||||
const endIndex = members.length > 20 ? 20 : members.length;
|
||||
setCmd({ prefix, suggestions: members.slice(0, endIndex) });
|
||||
},
|
||||
};
|
||||
setupSearch[prefix]?.();
|
||||
}
|
||||
function deactivateCmd() {
|
||||
setCmd(null);
|
||||
cmdOption = undefined;
|
||||
cmdPrefix = undefined;
|
||||
}
|
||||
function fireCmd(myCmd) {
|
||||
if (myCmd.prefix === '/') {
|
||||
viewEvent.emit('cmd_fired', {
|
||||
replace: `/${myCmd.result.name}`,
|
||||
});
|
||||
}
|
||||
if (myCmd.prefix === ':') {
|
||||
if (!myCmd.result.mxc) addRecentEmoji(myCmd.result.unicode);
|
||||
viewEvent.emit('cmd_fired', {
|
||||
replace: myCmd.result.mxc ? `:${myCmd.result.shortcode}: ` : myCmd.result.unicode,
|
||||
});
|
||||
}
|
||||
if (myCmd.prefix === '@') {
|
||||
viewEvent.emit('cmd_fired', {
|
||||
replace: `@${myCmd.result.userId}`,
|
||||
});
|
||||
}
|
||||
deactivateCmd();
|
||||
}
|
||||
|
||||
function listenKeyboard(event) {
|
||||
const { activeElement } = document;
|
||||
const lastCmdItem = document.activeElement.parentNode.lastElementChild;
|
||||
if (event.key === 'Escape') {
|
||||
if (activeElement.className !== 'cmd-item') return;
|
||||
viewEvent.emit('focus_msg_input');
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
if (lastCmdItem.className !== 'cmd-item') return;
|
||||
if (lastCmdItem !== activeElement) return;
|
||||
if (event.shiftKey) return;
|
||||
viewEvent.emit('focus_msg_input');
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
viewEvent.on('cmd_activate', activateCmd);
|
||||
viewEvent.on('cmd_deactivate', deactivateCmd);
|
||||
return () => {
|
||||
deactivateCmd();
|
||||
viewEvent.removeListener('cmd_activate', activateCmd);
|
||||
viewEvent.removeListener('cmd_deactivate', deactivateCmd);
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cmd !== null) document.body.addEventListener('keydown', listenKeyboard);
|
||||
viewEvent.on('cmd_process', processCmd);
|
||||
asyncSearch.on(asyncSearch.RESULT_SENT, displaySuggestions);
|
||||
return () => {
|
||||
if (cmd !== null) document.body.removeEventListener('keydown', listenKeyboard);
|
||||
|
||||
viewEvent.removeListener('cmd_process', processCmd);
|
||||
asyncSearch.removeListener(asyncSearch.RESULT_SENT, displaySuggestions);
|
||||
};
|
||||
}, [cmd]);
|
||||
|
||||
const isError = typeof cmd?.error === 'string';
|
||||
if (cmd === null || isError) {
|
||||
return (
|
||||
<div className="cmd-bar">
|
||||
<FollowingMembers roomTimeline={roomTimeline} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cmd-bar">
|
||||
<div className="cmd-bar__info">
|
||||
<Text variant="b3">TAB</Text>
|
||||
</div>
|
||||
<div className="cmd-bar__content">
|
||||
<ScrollView horizontal vertical={false} invisible>
|
||||
<div className="cmd-bar__content-suggestions">{renderSuggestions(cmd, fireCmd)}</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
RoomViewCmdBar.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
viewEvent: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default RoomViewCmdBar;
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/text';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.cmd-bar {
|
||||
--cmd-bar-height: 28px;
|
||||
min-height: var(--cmd-bar-height);
|
||||
display: flex;
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
width: 40px;
|
||||
@include dir.side(margin, 14px, 10px);
|
||||
|
||||
& > * {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
@extend .cp-fx__item-one;
|
||||
display: flex;
|
||||
|
||||
&-suggestions {
|
||||
height: 100%;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& > .text {
|
||||
@extend .cp-txt__ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cmd-item {
|
||||
--cmd-item-bar: inset 0 -2px 0 0 var(--bg-caution);
|
||||
height: 100%;
|
||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
||||
padding: 0 var(--sp-extra-tight);
|
||||
border-radius: var(--bo-radius) var(--bo-radius) 0 0;
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-caution-hover);
|
||||
}
|
||||
&:focus {
|
||||
background-color: var(--bg-caution-active);
|
||||
box-shadow: var(--cmd-item-bar);
|
||||
border-bottom: 2px solid transparent;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,644 +0,0 @@
|
|||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
/* eslint-disable react/prop-types */
|
||||
import React, {
|
||||
useState, useEffect, useLayoutEffect, useCallback, useRef,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomViewContent.scss';
|
||||
|
||||
import dateFormat from 'dateformat';
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { openProfileViewer } from '../../../client/action/navigation';
|
||||
import { diffMinutes, isInSameDay, Throttle } from '../../../util/common';
|
||||
import { markAsRead } from '../../../client/action/notifications';
|
||||
|
||||
import Divider from '../../atoms/divider/Divider';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import { Message, PlaceholderMessage } from '../../molecules/message/Message';
|
||||
import RoomIntro from '../../molecules/room-intro/RoomIntro';
|
||||
import TimelineChange from '../../molecules/message/TimelineChange';
|
||||
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
import { parseTimelineChange } from './common';
|
||||
import TimelineScroll from './TimelineScroll';
|
||||
import EventLimit from './EventLimit';
|
||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
|
||||
const PAG_LIMIT = 30;
|
||||
const MAX_MSG_DIFF_MINUTES = 5;
|
||||
const PLACEHOLDER_COUNT = 2;
|
||||
const PLACEHOLDERS_HEIGHT = 96 * PLACEHOLDER_COUNT;
|
||||
const SCROLL_TRIGGER_POS = PLACEHOLDERS_HEIGHT * 4;
|
||||
|
||||
function loadingMsgPlaceholders(key, count = 2) {
|
||||
const pl = [];
|
||||
const genPlaceholders = () => {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
pl.push(<PlaceholderMessage key={`placeholder-${i}${key}`} />);
|
||||
}
|
||||
return pl;
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment key={`placeholder-container${key}`}>
|
||||
{genPlaceholders()}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomIntroContainer({ event, timeline }) {
|
||||
const [, nameForceUpdate] = useForceUpdate();
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { roomList } = initMatrix;
|
||||
const { room } = timeline;
|
||||
const roomTopic = room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
|
||||
const isDM = roomList.directs.has(timeline.roomId);
|
||||
let avatarSrc = room.getAvatarUrl(mx.baseUrl, 80, 80, 'crop');
|
||||
avatarSrc = isDM ? room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 80, 80, 'crop') : avatarSrc;
|
||||
|
||||
const heading = isDM ? room.name : `Welcome to ${room.name}`;
|
||||
const topic = twemojify(roomTopic || '', undefined, true);
|
||||
const nameJsx = twemojify(room.name);
|
||||
const desc = isDM
|
||||
? (
|
||||
<>
|
||||
This is the beginning of your direct message history with @
|
||||
<b>{nameJsx}</b>
|
||||
{'. '}
|
||||
{topic}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{'This is the beginning of the '}
|
||||
<b>{nameJsx}</b>
|
||||
{' room. '}
|
||||
{topic}
|
||||
</>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => nameForceUpdate();
|
||||
|
||||
roomList.on(cons.events.roomList.ROOM_PROFILE_UPDATED, handleUpdate);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.ROOM_PROFILE_UPDATED, handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RoomIntro
|
||||
roomId={timeline.roomId}
|
||||
avatarSrc={avatarSrc}
|
||||
name={room.name}
|
||||
heading={twemojify(heading)}
|
||||
desc={desc}
|
||||
time={event ? `Created at ${dateFormat(event.getDate(), 'dd mmmm yyyy, hh:MM TT')}` : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function handleOnClickCapture(e) {
|
||||
const { target, nativeEvent } = e;
|
||||
|
||||
const userId = target.getAttribute('data-mx-pill');
|
||||
if (userId) {
|
||||
const roomId = navigation.selectedRoomId;
|
||||
openProfileViewer(userId, roomId);
|
||||
}
|
||||
|
||||
const spoiler = nativeEvent.composedPath().find((el) => el?.hasAttribute?.('data-mx-spoiler'));
|
||||
if (spoiler) {
|
||||
if (!spoiler.classList.contains('data-mx-spoiler--visible')) e.preventDefault();
|
||||
spoiler.classList.toggle('data-mx-spoiler--visible');
|
||||
}
|
||||
}
|
||||
|
||||
function renderEvent(
|
||||
roomTimeline,
|
||||
mEvent,
|
||||
prevMEvent,
|
||||
isFocus,
|
||||
isEdit,
|
||||
setEdit,
|
||||
cancelEdit,
|
||||
) {
|
||||
const isBodyOnly = (prevMEvent !== null
|
||||
&& prevMEvent.getSender() === mEvent.getSender()
|
||||
&& prevMEvent.getType() !== 'm.room.member'
|
||||
&& prevMEvent.getType() !== 'm.room.create'
|
||||
&& diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
|
||||
);
|
||||
const timestamp = mEvent.getTs();
|
||||
|
||||
if (mEvent.getType() === 'm.room.member') {
|
||||
const timelineChange = parseTimelineChange(mEvent);
|
||||
if (timelineChange === null) return <div key={mEvent.getId()} />;
|
||||
return (
|
||||
<TimelineChange
|
||||
key={mEvent.getId()}
|
||||
variant={timelineChange.variant}
|
||||
content={timelineChange.content}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Message
|
||||
key={mEvent.getId()}
|
||||
mEvent={mEvent}
|
||||
isBodyOnly={isBodyOnly}
|
||||
roomTimeline={roomTimeline}
|
||||
focus={isFocus}
|
||||
fullTime={false}
|
||||
isEdit={isEdit}
|
||||
setEdit={setEdit}
|
||||
cancelEdit={cancelEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useTimeline(roomTimeline, eventId, readUptoEvtStore, eventLimitRef) {
|
||||
const [timelineInfo, setTimelineInfo] = useState(null);
|
||||
|
||||
const setEventTimeline = async (eId) => {
|
||||
if (typeof eId === 'string') {
|
||||
const isLoaded = await roomTimeline.loadEventTimeline(eId);
|
||||
if (isLoaded) return;
|
||||
// if eventTimeline failed to load,
|
||||
// we will load live timeline as fallback.
|
||||
}
|
||||
roomTimeline.loadLiveTimeline();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const limit = eventLimitRef.current;
|
||||
const initTimeline = (eId) => {
|
||||
// NOTICE: eId can be id of readUpto, reply or specific event.
|
||||
// readUpTo: when user click jump to unread message button.
|
||||
// reply: when user click reply from timeline.
|
||||
// specific event when user open a link of event. behave same as ^^^^
|
||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
||||
let focusEventIndex = -1;
|
||||
const isSpecificEvent = eId && eId !== readUpToId;
|
||||
|
||||
if (isSpecificEvent) {
|
||||
focusEventIndex = roomTimeline.getEventIndex(eId);
|
||||
}
|
||||
if (!readUptoEvtStore.getItem() && roomTimeline.hasEventInTimeline(readUpToId)) {
|
||||
// either opening live timeline or jump to unread.
|
||||
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
||||
}
|
||||
if (readUptoEvtStore.getItem() && !isSpecificEvent) {
|
||||
focusEventIndex = roomTimeline.getUnreadEventIndex(readUptoEvtStore.getItem().getId());
|
||||
}
|
||||
|
||||
if (focusEventIndex > -1) {
|
||||
limit.setFrom(focusEventIndex - Math.round(limit.maxEvents / 2));
|
||||
} else {
|
||||
limit.setFrom(roomTimeline.timeline.length - limit.maxEvents);
|
||||
}
|
||||
setTimelineInfo({ focusEventId: isSpecificEvent ? eId : null });
|
||||
};
|
||||
|
||||
roomTimeline.on(cons.events.roomTimeline.READY, initTimeline);
|
||||
setEventTimeline(eventId);
|
||||
return () => {
|
||||
roomTimeline.removeListener(cons.events.roomTimeline.READY, initTimeline);
|
||||
limit.setFrom(0);
|
||||
};
|
||||
}, [roomTimeline, eventId]);
|
||||
|
||||
return timelineInfo;
|
||||
}
|
||||
|
||||
function usePaginate(
|
||||
roomTimeline,
|
||||
readUptoEvtStore,
|
||||
forceUpdateLimit,
|
||||
timelineScrollRef,
|
||||
eventLimitRef,
|
||||
) {
|
||||
const [info, setInfo] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaginatedFromServer = (backwards, loaded) => {
|
||||
const limit = eventLimitRef.current;
|
||||
if (loaded === 0) return;
|
||||
if (!readUptoEvtStore.getItem()) {
|
||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
||||
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
||||
}
|
||||
limit.paginate(backwards, PAG_LIMIT, roomTimeline.timeline.length);
|
||||
setTimeout(() => setInfo({
|
||||
backwards,
|
||||
loaded,
|
||||
}));
|
||||
};
|
||||
roomTimeline.on(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
|
||||
return () => {
|
||||
roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
|
||||
};
|
||||
}, [roomTimeline]);
|
||||
|
||||
const autoPaginate = useCallback(async () => {
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
const limit = eventLimitRef.current;
|
||||
if (roomTimeline.isOngoingPagination) return;
|
||||
const tLength = roomTimeline.timeline.length;
|
||||
|
||||
if (timelineScroll.bottom < SCROLL_TRIGGER_POS) {
|
||||
if (limit.length < tLength) {
|
||||
// paginate from memory
|
||||
limit.paginate(false, PAG_LIMIT, tLength);
|
||||
forceUpdateLimit();
|
||||
} else if (roomTimeline.canPaginateForward()) {
|
||||
// paginate from server.
|
||||
await roomTimeline.paginateTimeline(false, PAG_LIMIT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (timelineScroll.top < SCROLL_TRIGGER_POS) {
|
||||
if (limit.from > 0) {
|
||||
// paginate from memory
|
||||
limit.paginate(true, PAG_LIMIT, tLength);
|
||||
forceUpdateLimit();
|
||||
} else if (roomTimeline.canPaginateBackward()) {
|
||||
// paginate from server.
|
||||
await roomTimeline.paginateTimeline(true, PAG_LIMIT);
|
||||
}
|
||||
}
|
||||
}, [roomTimeline]);
|
||||
|
||||
return [info, autoPaginate];
|
||||
}
|
||||
|
||||
function useHandleScroll(
|
||||
roomTimeline,
|
||||
autoPaginate,
|
||||
readUptoEvtStore,
|
||||
forceUpdateLimit,
|
||||
timelineScrollRef,
|
||||
eventLimitRef,
|
||||
) {
|
||||
const handleScroll = useCallback(() => {
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
const limit = eventLimitRef.current;
|
||||
requestAnimationFrame(() => {
|
||||
// emit event to toggle scrollToBottom button visibility
|
||||
const isAtBottom = (
|
||||
timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()
|
||||
&& limit.length >= roomTimeline.timeline.length
|
||||
);
|
||||
roomTimeline.emit(cons.events.roomTimeline.AT_BOTTOM, isAtBottom);
|
||||
if (isAtBottom && readUptoEvtStore.getItem()) {
|
||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
||||
}
|
||||
});
|
||||
autoPaginate();
|
||||
}, [roomTimeline]);
|
||||
|
||||
const handleScrollToLive = useCallback(() => {
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
const limit = eventLimitRef.current;
|
||||
if (readUptoEvtStore.getItem()) {
|
||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
||||
}
|
||||
if (roomTimeline.isServingLiveTimeline()) {
|
||||
limit.setFrom(roomTimeline.timeline.length - limit.maxEvents);
|
||||
timelineScroll.scrollToBottom();
|
||||
forceUpdateLimit();
|
||||
return;
|
||||
}
|
||||
roomTimeline.loadLiveTimeline();
|
||||
}, [roomTimeline]);
|
||||
|
||||
return [handleScroll, handleScrollToLive];
|
||||
}
|
||||
|
||||
function useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, eventLimitRef) {
|
||||
const myUserId = initMatrix.matrixClient.getUserId();
|
||||
const [newEvent, setEvent] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
const limit = eventLimitRef.current;
|
||||
const trySendReadReceipt = (event) => {
|
||||
if (myUserId === event.getSender()) {
|
||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
||||
return;
|
||||
}
|
||||
const readUpToEvent = readUptoEvtStore.getItem();
|
||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
||||
const isUnread = readUpToEvent ? readUpToEvent?.getId() === readUpToId : true;
|
||||
|
||||
if (isUnread === false) {
|
||||
if (document.visibilityState === 'visible' && timelineScroll.bottom < 16) {
|
||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
||||
} else {
|
||||
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { timeline } = roomTimeline;
|
||||
const unreadMsgIsLast = timeline[timeline.length - 2].getId() === readUpToId;
|
||||
if (unreadMsgIsLast) {
|
||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEvent = (event) => {
|
||||
const tLength = roomTimeline.timeline.length;
|
||||
const isViewingLive = roomTimeline.isServingLiveTimeline() && limit.length >= tLength - 1;
|
||||
const isAttached = timelineScroll.bottom < SCROLL_TRIGGER_POS;
|
||||
|
||||
if (isViewingLive && isAttached && document.hasFocus()) {
|
||||
limit.setFrom(tLength - limit.maxEvents);
|
||||
trySendReadReceipt(event);
|
||||
setEvent(event);
|
||||
return;
|
||||
}
|
||||
const isRelates = (event.getType() === 'm.reaction' || event.getRelation()?.rel_type === 'm.replace');
|
||||
if (isRelates) {
|
||||
setEvent(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isViewingLive) {
|
||||
// This stateUpdate will help to put the
|
||||
// loading msg placeholder at bottom
|
||||
setEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEventRedact = (event) => setEvent(event);
|
||||
|
||||
roomTimeline.on(cons.events.roomTimeline.EVENT, handleEvent);
|
||||
roomTimeline.on(cons.events.roomTimeline.EVENT_REDACTED, handleEventRedact);
|
||||
return () => {
|
||||
roomTimeline.removeListener(cons.events.roomTimeline.EVENT, handleEvent);
|
||||
roomTimeline.removeListener(cons.events.roomTimeline.EVENT_REDACTED, handleEventRedact);
|
||||
};
|
||||
}, [roomTimeline]);
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
let jumpToItemIndex = -1;
|
||||
|
||||
function RoomViewContent({ roomInputRef, eventId, roomTimeline }) {
|
||||
const [throttle] = useState(new Throttle());
|
||||
|
||||
const timelineSVRef = useRef(null);
|
||||
const timelineScrollRef = useRef(null);
|
||||
const eventLimitRef = useRef(null);
|
||||
const [editEventId, setEditEventId] = useState(null);
|
||||
const cancelEdit = () => setEditEventId(null);
|
||||
|
||||
const readUptoEvtStore = useStore(roomTimeline);
|
||||
const [onLimitUpdate, forceUpdateLimit] = useForceUpdate();
|
||||
|
||||
const timelineInfo = useTimeline(roomTimeline, eventId, readUptoEvtStore, eventLimitRef);
|
||||
const [paginateInfo, autoPaginate] = usePaginate(
|
||||
roomTimeline,
|
||||
readUptoEvtStore,
|
||||
forceUpdateLimit,
|
||||
timelineScrollRef,
|
||||
eventLimitRef,
|
||||
);
|
||||
const [handleScroll, handleScrollToLive] = useHandleScroll(
|
||||
roomTimeline,
|
||||
autoPaginate,
|
||||
readUptoEvtStore,
|
||||
forceUpdateLimit,
|
||||
timelineScrollRef,
|
||||
eventLimitRef,
|
||||
);
|
||||
const newEvent = useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, eventLimitRef);
|
||||
|
||||
const { timeline } = roomTimeline;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!roomTimeline.initialized) {
|
||||
timelineScrollRef.current = new TimelineScroll(timelineSVRef.current);
|
||||
eventLimitRef.current = new EventLimit();
|
||||
}
|
||||
});
|
||||
|
||||
// when active timeline changes
|
||||
useEffect(() => {
|
||||
if (!roomTimeline.initialized) return undefined;
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
|
||||
if (timeline.length > 0) {
|
||||
if (jumpToItemIndex === -1) {
|
||||
timelineScroll.scrollToBottom();
|
||||
} else {
|
||||
timelineScroll.scrollToIndex(jumpToItemIndex, 80);
|
||||
}
|
||||
if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()) {
|
||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
||||
if (readUptoEvtStore.getItem()?.getId() === readUpToId || readUpToId === null) {
|
||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
||||
}
|
||||
}
|
||||
jumpToItemIndex = -1;
|
||||
}
|
||||
autoPaginate();
|
||||
|
||||
roomTimeline.on(cons.events.roomTimeline.SCROLL_TO_LIVE, handleScrollToLive);
|
||||
return () => {
|
||||
if (timelineSVRef.current === null) return;
|
||||
roomTimeline.removeListener(cons.events.roomTimeline.SCROLL_TO_LIVE, handleScrollToLive);
|
||||
};
|
||||
}, [timelineInfo]);
|
||||
|
||||
// when paginating from server
|
||||
useEffect(() => {
|
||||
if (!roomTimeline.initialized) return;
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
timelineScroll.tryRestoringScroll();
|
||||
autoPaginate();
|
||||
}, [paginateInfo]);
|
||||
|
||||
// when paginating locally
|
||||
useEffect(() => {
|
||||
if (!roomTimeline.initialized) return;
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
timelineScroll.tryRestoringScroll();
|
||||
}, [onLimitUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
if (!roomTimeline.initialized) return;
|
||||
if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward() && document.visibilityState === 'visible') {
|
||||
timelineScroll.scrollToBottom();
|
||||
} else {
|
||||
timelineScroll.tryRestoringScroll();
|
||||
}
|
||||
}, [newEvent]);
|
||||
|
||||
useResizeObserver(
|
||||
useCallback((entries) => {
|
||||
if (!roomInputRef.current) return;
|
||||
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
|
||||
if (!editorBaseEntry) return;
|
||||
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
if (!roomTimeline.initialized) return;
|
||||
if (timelineScroll.bottom < 40 && !roomTimeline.canPaginateForward() && document.visibilityState === 'visible') {
|
||||
timelineScroll.scrollToBottom();
|
||||
}
|
||||
}, [roomInputRef]),
|
||||
useCallback(() => roomInputRef.current, [roomInputRef]),
|
||||
);
|
||||
|
||||
const listenKeyboard = useCallback((event) => {
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) return;
|
||||
if (event.key !== 'ArrowUp') return;
|
||||
if (navigation.isRawModalVisible) return;
|
||||
|
||||
if (document.activeElement.id !== 'message-textarea') return;
|
||||
if (document.activeElement.value !== '') return;
|
||||
|
||||
const {
|
||||
timeline: tl, activeTimeline, liveTimeline, matrixClient: mx,
|
||||
} = roomTimeline;
|
||||
const limit = eventLimitRef.current;
|
||||
if (activeTimeline !== liveTimeline) return;
|
||||
if (tl.length > limit.length) return;
|
||||
|
||||
const mTypes = ['m.text'];
|
||||
for (let i = tl.length - 1; i >= 0; i -= 1) {
|
||||
const mE = tl[i];
|
||||
if (
|
||||
mE.getSender() === mx.getUserId()
|
||||
&& mE.getType() === 'm.room.message'
|
||||
&& mTypes.includes(mE.getContent()?.msgtype)
|
||||
) {
|
||||
setEditEventId(mE.getId());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [roomTimeline]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.addEventListener('keydown', listenKeyboard);
|
||||
return () => {
|
||||
document.body.removeEventListener('keydown', listenKeyboard);
|
||||
};
|
||||
}, [listenKeyboard]);
|
||||
|
||||
const handleTimelineScroll = (event) => {
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
if (!event.target) return;
|
||||
|
||||
throttle._(() => {
|
||||
const backwards = timelineScroll?.calcScroll();
|
||||
if (typeof backwards !== 'boolean') return;
|
||||
handleScroll(backwards);
|
||||
}, 200)();
|
||||
};
|
||||
|
||||
const renderTimeline = () => {
|
||||
const tl = [];
|
||||
const limit = eventLimitRef.current;
|
||||
|
||||
let itemCountIndex = 0;
|
||||
jumpToItemIndex = -1;
|
||||
const readUptoEvent = readUptoEvtStore.getItem();
|
||||
let unreadDivider = false;
|
||||
|
||||
if (roomTimeline.canPaginateBackward() || limit.from > 0) {
|
||||
tl.push(loadingMsgPlaceholders(1, PLACEHOLDER_COUNT));
|
||||
itemCountIndex += PLACEHOLDER_COUNT;
|
||||
}
|
||||
for (let i = limit.from; i < limit.length; i += 1) {
|
||||
if (i >= timeline.length) break;
|
||||
const mEvent = timeline[i];
|
||||
const prevMEvent = timeline[i - 1] ?? null;
|
||||
|
||||
if (i === 0 && !roomTimeline.canPaginateBackward()) {
|
||||
if (mEvent.getType() === 'm.room.create') {
|
||||
tl.push(
|
||||
<RoomIntroContainer key={mEvent.getId()} event={mEvent} timeline={roomTimeline} />,
|
||||
);
|
||||
itemCountIndex += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
} else {
|
||||
tl.push(<RoomIntroContainer key="room-intro" event={null} timeline={roomTimeline} />);
|
||||
itemCountIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let isNewEvent = false;
|
||||
if (!unreadDivider) {
|
||||
unreadDivider = (readUptoEvent
|
||||
&& prevMEvent?.getTs() <= readUptoEvent.getTs()
|
||||
&& readUptoEvent.getTs() < mEvent.getTs());
|
||||
if (unreadDivider) {
|
||||
isNewEvent = true;
|
||||
tl.push(<Divider key={`new-${mEvent.getId()}`} variant="positive" text="New messages" />);
|
||||
itemCountIndex += 1;
|
||||
if (jumpToItemIndex === -1) jumpToItemIndex = itemCountIndex;
|
||||
}
|
||||
}
|
||||
const dayDivider = prevMEvent && !isInSameDay(mEvent.getDate(), prevMEvent.getDate());
|
||||
if (dayDivider) {
|
||||
tl.push(<Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />);
|
||||
itemCountIndex += 1;
|
||||
}
|
||||
|
||||
const focusId = timelineInfo.focusEventId;
|
||||
const isFocus = focusId === mEvent.getId();
|
||||
if (isFocus) jumpToItemIndex = itemCountIndex;
|
||||
|
||||
tl.push(renderEvent(
|
||||
roomTimeline,
|
||||
mEvent,
|
||||
isNewEvent ? null : prevMEvent,
|
||||
isFocus,
|
||||
editEventId === mEvent.getId(),
|
||||
setEditEventId,
|
||||
cancelEdit,
|
||||
));
|
||||
itemCountIndex += 1;
|
||||
}
|
||||
if (roomTimeline.canPaginateForward() || limit.length < timeline.length) {
|
||||
tl.push(loadingMsgPlaceholders(2, PLACEHOLDER_COUNT));
|
||||
}
|
||||
|
||||
return tl;
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView onScroll={handleTimelineScroll} ref={timelineSVRef} autoHide>
|
||||
<div className="room-view__content" onClick={handleOnClickCapture}>
|
||||
<div className="timeline__wrapper">
|
||||
{ roomTimeline.initialized ? renderTimeline() : loadingMsgPlaceholders('loading', 3) }
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
RoomViewContent.defaultProps = {
|
||||
eventId: null,
|
||||
};
|
||||
RoomViewContent.propTypes = {
|
||||
eventId: PropTypes.string,
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
roomInputRef: PropTypes.shape({
|
||||
current: PropTypes.shape({})
|
||||
}).isRequired
|
||||
};
|
||||
|
||||
export default RoomViewContent;
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
|
||||
.room-view__content {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
|
||||
& .timeline__wrapper {
|
||||
--typing-noti-height: 28px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding-bottom: var(--typing-noti-height);
|
||||
|
||||
& .message,
|
||||
& .ph-msg,
|
||||
& .timeline-change {
|
||||
@include dir.prop(border-radius,
|
||||
0 var(--bo-radius) var(--bo-radius) 0,
|
||||
var(--bo-radius) 0 0 var(--bo-radius),
|
||||
);
|
||||
}
|
||||
|
||||
& > .divider {
|
||||
margin: var(--sp-extra-tight);
|
||||
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
||||
@include dir.side(padding, calc(var(--av-small) + var(--sp-tight)), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomViewFloating.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import { markAsRead } from '../../../client/action/notifications';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
|
||||
import MessageIC from '../../../../public/res/ic/outlined/message.svg';
|
||||
import MessageUnreadIC from '../../../../public/res/ic/outlined/message-unread.svg';
|
||||
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
||||
|
||||
import { getUsersActionJsx } from './common';
|
||||
|
||||
function useJumpToEvent(roomTimeline) {
|
||||
const [eventId, setEventId] = useState(null);
|
||||
|
||||
const jumpToEvent = () => {
|
||||
roomTimeline.loadEventTimeline(eventId);
|
||||
};
|
||||
|
||||
const cancelJumpToEvent = () => {
|
||||
markAsRead(roomTimeline.roomId);
|
||||
setEventId(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const readEventId = roomTimeline.getReadUpToEventId();
|
||||
// we only show "Jump to unread" btn only if the event is not in timeline.
|
||||
// if event is in timeline
|
||||
// we will automatically open the timeline from that event position
|
||||
if (!readEventId?.startsWith('~') && !roomTimeline.hasEventInTimeline(readEventId)) {
|
||||
setEventId(readEventId);
|
||||
}
|
||||
|
||||
const { notifications } = initMatrix;
|
||||
const handleMarkAsRead = () => setEventId(null);
|
||||
notifications.on(cons.events.notifications.FULL_READ, handleMarkAsRead);
|
||||
|
||||
return () => {
|
||||
notifications.removeListener(cons.events.notifications.FULL_READ, handleMarkAsRead);
|
||||
setEventId(null);
|
||||
};
|
||||
}, [roomTimeline]);
|
||||
|
||||
return [!!eventId, jumpToEvent, cancelJumpToEvent];
|
||||
}
|
||||
|
||||
function useTypingMembers(roomTimeline) {
|
||||
const [typingMembers, setTypingMembers] = useState(new Set());
|
||||
|
||||
const updateTyping = (members) => {
|
||||
const mx = initMatrix.matrixClient;
|
||||
members.delete(mx.getUserId());
|
||||
setTypingMembers(members);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTypingMembers(new Set());
|
||||
roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
|
||||
return () => {
|
||||
roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
|
||||
};
|
||||
}, [roomTimeline]);
|
||||
|
||||
return [typingMembers];
|
||||
}
|
||||
|
||||
function useScrollToBottom(roomTimeline) {
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const handleAtBottom = (atBottom) => setIsAtBottom(atBottom);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAtBottom(true);
|
||||
roomTimeline.on(cons.events.roomTimeline.AT_BOTTOM, handleAtBottom);
|
||||
return () => roomTimeline.removeListener(cons.events.roomTimeline.AT_BOTTOM, handleAtBottom);
|
||||
}, [roomTimeline]);
|
||||
|
||||
return [isAtBottom, setIsAtBottom];
|
||||
}
|
||||
|
||||
function RoomViewFloating({
|
||||
roomId, roomTimeline,
|
||||
}) {
|
||||
const [isJumpToEvent, jumpToEvent, cancelJumpToEvent] = useJumpToEvent(roomTimeline);
|
||||
const [typingMembers] = useTypingMembers(roomTimeline);
|
||||
const [isAtBottom, setIsAtBottom] = useScrollToBottom(roomTimeline);
|
||||
|
||||
const handleScrollToBottom = () => {
|
||||
roomTimeline.emit(cons.events.roomTimeline.SCROLL_TO_LIVE);
|
||||
setIsAtBottom(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`room-view__unread ${isJumpToEvent ? 'room-view__unread--open' : ''}`}>
|
||||
<Button iconSrc={MessageUnreadIC} onClick={jumpToEvent} variant="primary">
|
||||
<Text variant="b3" weight="medium">Jump to unread messages</Text>
|
||||
</Button>
|
||||
<Button iconSrc={TickMarkIC} onClick={cancelJumpToEvent} variant="primary">
|
||||
<Text variant="b3" weight="bold">Mark as read</Text>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`room-view__typing${typingMembers.size > 0 ? ' room-view__typing--open' : ''}`}>
|
||||
<div className="bouncing-loader"><div /></div>
|
||||
<Text variant="b2">{getUsersActionJsx(roomId, [...typingMembers], 'typing...')}</Text>
|
||||
</div>
|
||||
<div className={`room-view__STB${isAtBottom ? '' : ' room-view__STB--open'}`}>
|
||||
<Button iconSrc={MessageIC} onClick={handleScrollToBottom}>
|
||||
<Text variant="b3" weight="medium">Jump to latest</Text>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
RoomViewFloating.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default RoomViewFloating;
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/text';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.room-view {
|
||||
&__typing {
|
||||
display: flex;
|
||||
padding: var(--sp-ultra-tight) var(--sp-normal);
|
||||
background: var(--bg-surface);
|
||||
transition: transform 200ms ease-in-out;
|
||||
|
||||
& b {
|
||||
color: var(--tc-surface-high);
|
||||
}
|
||||
|
||||
& .text {
|
||||
@extend .cp-txt__ellipsis;
|
||||
@extend .cp-fx__item-one;
|
||||
|
||||
margin: 0 var(--sp-tight);
|
||||
}
|
||||
|
||||
&--open {
|
||||
transform: translateY(-99%);
|
||||
box-shadow: 0 4px 0 0 var(--bg-surface);
|
||||
& .bouncing-loader {
|
||||
& > *,
|
||||
&::after,
|
||||
&::before {
|
||||
animation: bouncing-loader 0.6s infinite alternate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bouncing-loader {
|
||||
transform: translateY(2px);
|
||||
margin: 0 calc(var(--sp-ultra-tight) / 2);
|
||||
}
|
||||
.bouncing-loader > div,
|
||||
.bouncing-loader::before,
|
||||
.bouncing-loader::after {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--tc-surface-high);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
|
||||
.bouncing-loader::before,
|
||||
.bouncing-loader::after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.bouncing-loader > div {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.bouncing-loader > div {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.bouncing-loader::after {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes bouncing-loader {
|
||||
to {
|
||||
opacity: 0.1;
|
||||
transform: translate3d(0, -4px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
&__STB,
|
||||
&__unread {
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-surface-low);
|
||||
border-radius: var(--bo-radius);
|
||||
|
||||
& button {
|
||||
justify-content: flex-start;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 6px var(--sp-tight);
|
||||
& .ic-raw {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__STB {
|
||||
position: absolute;
|
||||
@include dir.prop(left, 50%, unset);
|
||||
@include dir.prop(right, unset, 50%);
|
||||
bottom: 0;
|
||||
box-shadow: var(--bs-surface-border);
|
||||
transition: transform 200ms ease-in-out;
|
||||
transform: translate(-50%, 100%);
|
||||
|
||||
&--open {
|
||||
transform: translate(-50%, -28px);
|
||||
}
|
||||
}
|
||||
|
||||
&__unread {
|
||||
position: absolute;
|
||||
top: var(--sp-extra-tight);
|
||||
@include dir.prop(left, var(--sp-normal), unset);
|
||||
@include dir.prop(right, unset, var(--sp-normal));
|
||||
z-index: 999;
|
||||
|
||||
display: none;
|
||||
width: calc(100% - var(--sp-extra-loose));
|
||||
box-shadow: 0 0 2px 0 rgba(0, 0, 0, 20%);
|
||||
|
||||
&--open {
|
||||
display: flex;
|
||||
}
|
||||
& button:first-child {
|
||||
@extend .cp-fx__item-one;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomViewHeader.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
import { blurOnBubbling } from '../../atoms/button/script';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import {
|
||||
toggleRoomSettings,
|
||||
openReusableContextMenu,
|
||||
openNavigation,
|
||||
} from '../../../client/action/navigation';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
|
||||
import { tabText } from './RoomSettings';
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
||||
import Avatar from '../../atoms/avatar/Avatar';
|
||||
import RoomOptions from '../../molecules/room-options/RoomOptions';
|
||||
|
||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
||||
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
|
||||
import BackArrowIC from '../../../../public/res/ic/outlined/chevron-left.svg';
|
||||
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
import { useSetSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
|
||||
function RoomViewHeader({ roomId }) {
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
const mx = initMatrix.matrixClient;
|
||||
const isDM = initMatrix.roomList.directs.has(roomId);
|
||||
const room = mx.getRoom(roomId);
|
||||
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
||||
let avatarSrc = room.getAvatarUrl(mx.baseUrl, 36, 36, 'crop');
|
||||
avatarSrc = isDM
|
||||
? room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 36, 36, 'crop')
|
||||
: avatarSrc;
|
||||
const roomName = room.name;
|
||||
|
||||
const roomHeaderBtnRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const settingsToggle = (isVisibile) => {
|
||||
const rawIcon = roomHeaderBtnRef.current.lastElementChild;
|
||||
rawIcon.style.transform = isVisibile ? 'rotateX(180deg)' : 'rotateX(0deg)';
|
||||
};
|
||||
navigation.on(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const { roomList } = initMatrix;
|
||||
const handleProfileUpdate = (rId) => {
|
||||
if (roomId !== rId) return;
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
roomList.on(cons.events.roomList.ROOM_PROFILE_UPDATED, handleProfileUpdate);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.ROOM_PROFILE_UPDATED, handleProfileUpdate);
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
const openRoomOptions = (e) => {
|
||||
openReusableContextMenu('bottom', getEventCords(e, '.ic-btn'), (closeMenu) => (
|
||||
<RoomOptions roomId={roomId} afterOptionSelect={closeMenu} />
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<IconButton
|
||||
src={BackArrowIC}
|
||||
className="room-header__back-btn"
|
||||
tooltip="Return to navigation"
|
||||
onClick={() => openNavigation()}
|
||||
/>
|
||||
<button
|
||||
ref={roomHeaderBtnRef}
|
||||
className="room-header__btn"
|
||||
onClick={() => toggleRoomSettings()}
|
||||
type="button"
|
||||
onMouseUp={(e) => blurOnBubbling(e, '.room-header__btn')}
|
||||
>
|
||||
<Avatar imageSrc={avatarSrc} text={roomName} bgColor={colorMXID(roomId)} size="small" />
|
||||
<TitleWrapper>
|
||||
<Text variant="h2" weight="medium" primary>
|
||||
{twemojify(roomName)}
|
||||
</Text>
|
||||
</TitleWrapper>
|
||||
<RawIcon src={ChevronBottomIC} />
|
||||
</button>
|
||||
{mx.isRoomEncrypted(roomId) === false && (
|
||||
<IconButton
|
||||
onClick={() => toggleRoomSettings(tabText.SEARCH)}
|
||||
tooltip="Search"
|
||||
src={SearchIC}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
className="room-header__drawer-btn"
|
||||
onClick={() => {
|
||||
setPeopleDrawer((t) => !t);
|
||||
}}
|
||||
tooltip="People"
|
||||
src={UserIC}
|
||||
/>
|
||||
<IconButton
|
||||
className="room-header__members-btn"
|
||||
onClick={() => toggleRoomSettings(tabText.MEMBERS)}
|
||||
tooltip="Members"
|
||||
src={UserIC}
|
||||
/>
|
||||
<IconButton onClick={openRoomOptions} tooltip="Options" src={VerticalMenuIC} />
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
RoomViewHeader.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default RoomViewHeader;
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
@use '../../partials/screen';
|
||||
|
||||
.room-header__btn {
|
||||
min-width: 0;
|
||||
@extend .cp-fx__row--s-c;
|
||||
@include dir.side(margin, 0, auto);
|
||||
border-radius: var(--bo-radius);
|
||||
cursor: pointer;
|
||||
|
||||
& .ic-raw {
|
||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
||||
transition: transform 200ms ease-in-out;
|
||||
}
|
||||
@media (hover:hover) {
|
||||
&:hover {
|
||||
background-color: var(--bg-surface-hover);
|
||||
box-shadow: var(--bs-surface-outline);
|
||||
}
|
||||
}
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: var(--bg-surface-active);
|
||||
box-shadow: var(--bs-surface-outline);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.room-header__drawer-btn {
|
||||
@include screen.smallerThan(tabletBreakpoint) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.room-header__members-btn {
|
||||
@include screen.biggerThan(tabletBreakpoint) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.room-header__back-btn {
|
||||
@include dir.side(margin, 0, var(--sp-tight));
|
||||
|
||||
@include screen.biggerThan(mobileBreakpoint) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,491 +0,0 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomViewInput.scss';
|
||||
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import settings from '../../../client/state/settings';
|
||||
import { openEmojiBoard, openReusableContextMenu } from '../../../client/action/navigation';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { bytesToSize, getEventCords } from '../../../util/common';
|
||||
import { getUsername } from '../../../util/matrixUtil';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import { MessageReply } from '../../molecules/message/Message';
|
||||
|
||||
import StickerBoard from '../sticker-board/StickerBoard';
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
|
||||
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
|
||||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
||||
import SendIC from '../../../../public/res/ic/outlined/send.svg';
|
||||
import StickerIC from '../../../../public/res/ic/outlined/sticker.svg';
|
||||
import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
|
||||
import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
|
||||
import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
|
||||
import FileIC from '../../../../public/res/ic/outlined/file.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
import commands from './commands';
|
||||
|
||||
const CMD_REGEX = /(^\/|:|@)(\S*)$/;
|
||||
let isTyping = false;
|
||||
let isCmdActivated = false;
|
||||
let cmdCursorPos = null;
|
||||
function RoomViewInput({
|
||||
roomId, roomTimeline, viewEvent,
|
||||
}) {
|
||||
const [attachment, setAttachment] = useState(null);
|
||||
const [replyTo, setReplyTo] = useState(null);
|
||||
|
||||
const textAreaRef = useRef(null);
|
||||
const inputBaseRef = useRef(null);
|
||||
const uploadInputRef = useRef(null);
|
||||
const uploadProgressRef = useRef(null);
|
||||
const rightOptionsRef = useRef(null);
|
||||
|
||||
const TYPING_TIMEOUT = 5000;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { roomsInput } = initMatrix;
|
||||
|
||||
function requestFocusInput() {
|
||||
if (textAreaRef === null) return;
|
||||
textAreaRef.current.focus();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
roomsInput.on(cons.events.roomsInput.ATTACHMENT_SET, setAttachment);
|
||||
viewEvent.on('focus_msg_input', requestFocusInput);
|
||||
return () => {
|
||||
roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_SET, setAttachment);
|
||||
viewEvent.removeListener('focus_msg_input', requestFocusInput);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendIsTyping = (isT) => {
|
||||
mx.sendTyping(roomId, isT, isT ? TYPING_TIMEOUT : undefined);
|
||||
isTyping = isT;
|
||||
|
||||
if (isT === true) {
|
||||
setTimeout(() => {
|
||||
if (isTyping) sendIsTyping(false);
|
||||
}, TYPING_TIMEOUT);
|
||||
}
|
||||
};
|
||||
|
||||
function uploadingProgress(myRoomId, { loaded, total }) {
|
||||
if (myRoomId !== roomId) return;
|
||||
const progressPer = Math.round((loaded * 100) / total);
|
||||
uploadProgressRef.current.textContent = `Uploading: ${bytesToSize(loaded)}/${bytesToSize(total)} (${progressPer}%)`;
|
||||
inputBaseRef.current.style.backgroundImage = `linear-gradient(90deg, var(--bg-surface-hover) ${progressPer}%, var(--bg-surface-low) ${progressPer}%)`;
|
||||
}
|
||||
function clearAttachment(myRoomId) {
|
||||
if (roomId !== myRoomId) return;
|
||||
setAttachment(null);
|
||||
inputBaseRef.current.style.backgroundImage = 'unset';
|
||||
uploadInputRef.current.value = null;
|
||||
}
|
||||
|
||||
function rightOptionsA11Y(A11Y) {
|
||||
const rightOptions = rightOptionsRef.current.children;
|
||||
for (let index = 0; index < rightOptions.length; index += 1) {
|
||||
rightOptions[index].tabIndex = A11Y ? 0 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
function activateCmd(prefix) {
|
||||
isCmdActivated = true;
|
||||
rightOptionsA11Y(false);
|
||||
viewEvent.emit('cmd_activate', prefix);
|
||||
}
|
||||
function deactivateCmd() {
|
||||
isCmdActivated = false;
|
||||
cmdCursorPos = null;
|
||||
rightOptionsA11Y(true);
|
||||
}
|
||||
function deactivateCmdAndEmit() {
|
||||
deactivateCmd();
|
||||
viewEvent.emit('cmd_deactivate');
|
||||
}
|
||||
function setCursorPosition(pos) {
|
||||
setTimeout(() => {
|
||||
textAreaRef.current.focus();
|
||||
textAreaRef.current.setSelectionRange(pos, pos);
|
||||
}, 0);
|
||||
}
|
||||
function replaceCmdWith(msg, cursor, replacement) {
|
||||
if (msg === null) return null;
|
||||
const targetInput = msg.slice(0, cursor);
|
||||
const cmdParts = targetInput.match(CMD_REGEX);
|
||||
const leadingInput = msg.slice(0, cmdParts.index);
|
||||
if (replacement.length > 0) setCursorPosition(leadingInput.length + replacement.length);
|
||||
return leadingInput + replacement + msg.slice(cursor);
|
||||
}
|
||||
function firedCmd(cmdData) {
|
||||
const msg = textAreaRef.current.value;
|
||||
textAreaRef.current.value = replaceCmdWith(
|
||||
msg,
|
||||
cmdCursorPos,
|
||||
typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '',
|
||||
);
|
||||
deactivateCmd();
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
if (settings.isTouchScreenDevice) return;
|
||||
textAreaRef.current.focus();
|
||||
}
|
||||
|
||||
function setUpReply(userId, eventId, body, formattedBody) {
|
||||
setReplyTo({ userId, eventId, body });
|
||||
roomsInput.setReplyTo(roomId, {
|
||||
userId, eventId, body, formattedBody,
|
||||
});
|
||||
focusInput();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
roomsInput.on(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
|
||||
roomsInput.on(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
|
||||
roomsInput.on(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
|
||||
viewEvent.on('cmd_fired', firedCmd);
|
||||
navigation.on(cons.events.navigation.REPLY_TO_CLICKED, setUpReply);
|
||||
if (textAreaRef?.current !== null) {
|
||||
isTyping = false;
|
||||
textAreaRef.current.value = roomsInput.getMessage(roomId);
|
||||
setAttachment(roomsInput.getAttachment(roomId));
|
||||
setReplyTo(roomsInput.getReplyTo(roomId));
|
||||
}
|
||||
return () => {
|
||||
roomsInput.removeListener(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
|
||||
roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
|
||||
roomsInput.removeListener(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
|
||||
viewEvent.removeListener('cmd_fired', firedCmd);
|
||||
navigation.removeListener(cons.events.navigation.REPLY_TO_CLICKED, setUpReply);
|
||||
if (isCmdActivated) deactivateCmd();
|
||||
if (textAreaRef?.current === null) return;
|
||||
|
||||
const msg = textAreaRef.current.value;
|
||||
textAreaRef.current.style.height = 'unset';
|
||||
inputBaseRef.current.style.backgroundImage = 'unset';
|
||||
if (msg.trim() === '') {
|
||||
roomsInput.setMessage(roomId, '');
|
||||
return;
|
||||
}
|
||||
roomsInput.setMessage(roomId, msg);
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
const sendBody = async (body, options) => {
|
||||
const opt = options ?? {};
|
||||
if (!opt.msgType) opt.msgType = 'm.text';
|
||||
if (typeof opt.autoMarkdown !== 'boolean') opt.autoMarkdown = true;
|
||||
if (roomsInput.isSending(roomId)) return;
|
||||
sendIsTyping(false);
|
||||
|
||||
roomsInput.setMessage(roomId, body);
|
||||
if (attachment !== null) {
|
||||
roomsInput.setAttachment(roomId, attachment);
|
||||
}
|
||||
textAreaRef.current.disabled = true;
|
||||
textAreaRef.current.style.cursor = 'not-allowed';
|
||||
await roomsInput.sendInput(roomId, opt);
|
||||
textAreaRef.current.disabled = false;
|
||||
textAreaRef.current.style.cursor = 'unset';
|
||||
focusInput();
|
||||
|
||||
textAreaRef.current.value = roomsInput.getMessage(roomId);
|
||||
textAreaRef.current.style.height = 'unset';
|
||||
if (replyTo !== null) setReplyTo(null);
|
||||
};
|
||||
|
||||
/** Return true if a command was executed. */
|
||||
const processCommand = async (cmdBody) => {
|
||||
const spaceIndex = cmdBody.indexOf(' ');
|
||||
const cmdName = cmdBody.slice(1, spaceIndex > -1 ? spaceIndex : undefined);
|
||||
const cmdData = spaceIndex > -1 ? cmdBody.slice(spaceIndex + 1) : '';
|
||||
if (!commands[cmdName]) {
|
||||
const sendAsMessage = await confirmDialog('Invalid Command', `"${cmdName}" is not a valid command. Did you mean to send this as a message?`, 'Send as message');
|
||||
if (sendAsMessage) {
|
||||
sendBody(cmdBody);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (['me', 'shrug', 'plain'].includes(cmdName)) {
|
||||
commands[cmdName].exe(roomId, cmdData, sendBody);
|
||||
return true;
|
||||
}
|
||||
commands[cmdName].exe(roomId, cmdData);
|
||||
return true;
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
requestAnimationFrame(() => deactivateCmdAndEmit());
|
||||
const msgBody = textAreaRef.current.value.trim();
|
||||
if (msgBody.startsWith('/')) {
|
||||
const executed = await processCommand(msgBody.trim());
|
||||
if (executed) {
|
||||
textAreaRef.current.value = '';
|
||||
textAreaRef.current.style.height = 'unset';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msgBody === '' && attachment === null) return;
|
||||
sendBody(msgBody);
|
||||
};
|
||||
|
||||
const handleSendSticker = async (data) => {
|
||||
roomsInput.sendSticker(roomId, data);
|
||||
};
|
||||
|
||||
function processTyping(msg) {
|
||||
const isEmptyMsg = msg === '';
|
||||
|
||||
if (isEmptyMsg && isTyping) {
|
||||
sendIsTyping(false);
|
||||
return;
|
||||
}
|
||||
if (!isEmptyMsg && !isTyping) {
|
||||
sendIsTyping(true);
|
||||
}
|
||||
}
|
||||
|
||||
function getCursorPosition() {
|
||||
return textAreaRef.current.selectionStart;
|
||||
}
|
||||
|
||||
function recognizeCmd(rawInput) {
|
||||
const cursor = getCursorPosition();
|
||||
const targetInput = rawInput.slice(0, cursor);
|
||||
|
||||
const cmdParts = targetInput.match(CMD_REGEX);
|
||||
if (cmdParts === null) {
|
||||
if (isCmdActivated) deactivateCmdAndEmit();
|
||||
return;
|
||||
}
|
||||
const cmdPrefix = cmdParts[1];
|
||||
const cmdSlug = cmdParts[2];
|
||||
|
||||
if (cmdPrefix === ':') {
|
||||
// skip emoji autofill command if link is suspected.
|
||||
const checkForLink = targetInput.slice(0, cmdParts.index);
|
||||
if (checkForLink.match(/(http|https|mailto|matrix|ircs|irc)$/)) {
|
||||
deactivateCmdAndEmit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
cmdCursorPos = cursor;
|
||||
if (cmdSlug === '') {
|
||||
activateCmd(cmdPrefix);
|
||||
return;
|
||||
}
|
||||
if (!isCmdActivated) activateCmd(cmdPrefix);
|
||||
viewEvent.emit('cmd_process', cmdPrefix, cmdSlug);
|
||||
}
|
||||
|
||||
const handleMsgTyping = (e) => {
|
||||
const msg = e.target.value;
|
||||
recognizeCmd(e.target.value);
|
||||
if (!isCmdActivated) processTyping(msg);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
roomsInput.cancelReplyTo(roomId);
|
||||
setReplyTo(null);
|
||||
}
|
||||
if (e.key === 'Enter' && e.shiftKey === false) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e) => {
|
||||
if (e.clipboardData === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.clipboardData.items === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < e.clipboardData.items.length; i += 1) {
|
||||
const item = e.clipboardData.items[i];
|
||||
if (item.type.indexOf('image') !== -1) {
|
||||
const image = item.getAsFile();
|
||||
if (attachment === null) {
|
||||
setAttachment(image);
|
||||
if (image !== null) {
|
||||
roomsInput.setAttachment(roomId, image);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function addEmoji(emoji) {
|
||||
textAreaRef.current.value += emoji.unicode;
|
||||
textAreaRef.current.focus();
|
||||
}
|
||||
|
||||
const handleUploadClick = () => {
|
||||
if (attachment === null) uploadInputRef.current.click();
|
||||
else {
|
||||
roomsInput.cancelAttachment(roomId);
|
||||
}
|
||||
};
|
||||
function uploadFileChange(e) {
|
||||
const file = e.target.files.item(0);
|
||||
setAttachment(file);
|
||||
if (file !== null) roomsInput.setAttachment(roomId, file);
|
||||
}
|
||||
|
||||
function renderInputs() {
|
||||
const canISend = roomTimeline.room.currentState.maySendMessage(mx.getUserId());
|
||||
const tombstoneEvent = roomTimeline.room.currentState.getStateEvents('m.room.tombstone')[0];
|
||||
if (!canISend || tombstoneEvent) {
|
||||
return (
|
||||
<Text className="room-input__alert">
|
||||
{
|
||||
tombstoneEvent
|
||||
? tombstoneEvent.getContent()?.body ?? 'This room has been replaced and is no longer active.'
|
||||
: 'You do not have permission to post to this room'
|
||||
}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className={`room-input__option-container${attachment === null ? '' : ' room-attachment__option'}`}>
|
||||
<input onChange={uploadFileChange} style={{ display: 'none' }} ref={uploadInputRef} type="file" />
|
||||
<IconButton onClick={handleUploadClick} tooltip={attachment === null ? 'Upload' : 'Cancel'} src={CirclePlusIC} />
|
||||
</div>
|
||||
<div ref={inputBaseRef} className="room-input__input-container">
|
||||
{roomTimeline.isEncrypted() && <RawIcon size="extra-small" src={ShieldIC} />}
|
||||
<ScrollView autoHide>
|
||||
<Text className="room-input__textarea-wrapper">
|
||||
<TextareaAutosize
|
||||
dir="auto"
|
||||
id="message-textarea"
|
||||
ref={textAreaRef}
|
||||
onChange={handleMsgTyping}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Send a message..."
|
||||
/>
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</div>
|
||||
<div ref={rightOptionsRef} className="room-input__option-container">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
openReusableContextMenu(
|
||||
'top',
|
||||
(() => {
|
||||
const cords = getEventCords(e);
|
||||
cords.y -= 20;
|
||||
return cords;
|
||||
})(),
|
||||
(closeMenu) => (
|
||||
<StickerBoard
|
||||
roomId={roomId}
|
||||
onSelect={(data) => {
|
||||
handleSendSticker(data);
|
||||
closeMenu();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
);
|
||||
}}
|
||||
tooltip="Sticker"
|
||||
src={StickerIC}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
const cords = getEventCords(e);
|
||||
cords.x += (document.dir === 'rtl' ? -80 : 80);
|
||||
cords.y -= 250;
|
||||
openEmojiBoard(cords, addEmoji);
|
||||
}}
|
||||
tooltip="Emoji"
|
||||
src={EmojiIC}
|
||||
/>
|
||||
<IconButton onClick={sendMessage} tooltip="Send" src={SendIC} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function attachFile() {
|
||||
const fileType = attachment.type.slice(0, attachment.type.indexOf('/'));
|
||||
return (
|
||||
<div className="room-attachment">
|
||||
<div className={`room-attachment__preview${fileType !== 'image' ? ' room-attachment__icon' : ''}`}>
|
||||
{fileType === 'image' && <img alt={attachment.name} src={URL.createObjectURL(attachment)} />}
|
||||
{fileType === 'video' && <RawIcon src={VLCIC} />}
|
||||
{fileType === 'audio' && <RawIcon src={VolumeFullIC} />}
|
||||
{fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && <RawIcon src={FileIC} />}
|
||||
</div>
|
||||
<div className="room-attachment__info">
|
||||
<Text variant="b1">{attachment.name}</Text>
|
||||
<Text variant="b3"><span ref={uploadProgressRef}>{`size: ${bytesToSize(attachment.size)}`}</span></Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function attachReply() {
|
||||
return (
|
||||
<div className="room-reply">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
roomsInput.cancelReplyTo(roomId);
|
||||
setReplyTo(null);
|
||||
}}
|
||||
src={CrossIC}
|
||||
tooltip="Cancel reply"
|
||||
size="extra-small"
|
||||
/>
|
||||
<MessageReply
|
||||
userId={replyTo.userId}
|
||||
onKeyDown={handleKeyDown}
|
||||
name={getUsername(replyTo.userId)}
|
||||
color={colorMXID(replyTo.userId)}
|
||||
body={replyTo.body}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ replyTo !== null && attachReply()}
|
||||
{ attachment !== null && attachFile() }
|
||||
<form className="room-input" onSubmit={(e) => { e.preventDefault(); }}>
|
||||
{
|
||||
renderInputs()
|
||||
}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
RoomViewInput.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
viewEvent: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default RoomViewInput;
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
|
||||
.room-input {
|
||||
padding: var(--sp-extra-tight) calc(var(--sp-normal) - 2px);
|
||||
display: flex;
|
||||
min-height: 56px;
|
||||
|
||||
&__alert {
|
||||
margin: auto;
|
||||
padding: 0 var(--sp-tight);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__input-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
margin: 0 calc(var(--sp-tight) - 2px);
|
||||
background-color: var(--bg-surface-low);
|
||||
box-shadow: var(--bs-surface-border);
|
||||
border-radius: var(--bo-radius);
|
||||
|
||||
& > .ic-raw {
|
||||
transform: scale(0.8);
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
}
|
||||
|
||||
& .scrollbar {
|
||||
max-height: 50vh;
|
||||
flex: 1;
|
||||
|
||||
&:first-child {
|
||||
@include dir.side(margin, var(--sp-tight), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__textarea-wrapper {
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& textarea {
|
||||
resize: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
padding: var(--sp-ultra-tight) 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--tc-surface-low);
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.room-attachment {
|
||||
--side-spacing: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@include dir.side(margin, var(--side-spacing), 0);
|
||||
margin-top: var(--sp-extra-tight);
|
||||
line-height: 0;
|
||||
|
||||
&__preview > img {
|
||||
max-height: 40px;
|
||||
border-radius: var(--bo-radius);
|
||||
max-width: 150px;
|
||||
}
|
||||
&__icon {
|
||||
padding: var(--sp-extra-tight);
|
||||
background-color: var(--bg-surface-low);
|
||||
box-shadow: var(--bs-surface-border);
|
||||
border-radius: var(--bo-radius);
|
||||
}
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0 var(--sp-tight);
|
||||
}
|
||||
|
||||
&__option button {
|
||||
transition: transform 200ms ease-in-out;
|
||||
transform: translateY(-48px);
|
||||
& .ic-raw {
|
||||
transition: transform 200ms ease-in-out;
|
||||
transform: rotate(45deg);
|
||||
background-color: var(--bg-caution);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.room-reply {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-surface-low);
|
||||
border-bottom: 1px solid var(--bg-surface-border);
|
||||
|
||||
& .ic-btn-surface {
|
||||
@include dir.side(margin, 17px, 13px);
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import { getScrollInfo } from '../../../util/common';
|
||||
|
||||
class TimelineScroll {
|
||||
constructor(target) {
|
||||
if (target === null) {
|
||||
throw new Error('Can not initialize TimelineScroll, target HTMLElement in null');
|
||||
}
|
||||
this.scroll = target;
|
||||
|
||||
this.backwards = false;
|
||||
this.inTopHalf = false;
|
||||
|
||||
this.isScrollable = false;
|
||||
this.top = 0;
|
||||
this.bottom = 0;
|
||||
this.height = 0;
|
||||
this.viewHeight = 0;
|
||||
|
||||
this.topMsg = null;
|
||||
this.bottomMsg = null;
|
||||
this.diff = 0;
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
const scrollInfo = getScrollInfo(this.scroll);
|
||||
const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight;
|
||||
|
||||
this._scrollTo(scrollInfo, maxScrollTop);
|
||||
}
|
||||
|
||||
// use previous calc by this._updateTopBottomMsg() & this._calcDiff.
|
||||
tryRestoringScroll() {
|
||||
const scrollInfo = getScrollInfo(this.scroll);
|
||||
|
||||
let scrollTop = 0;
|
||||
const ot = this.inTopHalf ? this.topMsg?.offsetTop : this.bottomMsg?.offsetTop;
|
||||
if (!ot) scrollTop = Math.round(this.height - this.viewHeight);
|
||||
else scrollTop = ot - this.diff;
|
||||
|
||||
this._scrollTo(scrollInfo, scrollTop);
|
||||
}
|
||||
|
||||
scrollToIndex(index, offset = 0) {
|
||||
const scrollInfo = getScrollInfo(this.scroll);
|
||||
const msgs = this.scroll.lastElementChild.lastElementChild.children;
|
||||
const offsetTop = msgs[index]?.offsetTop;
|
||||
|
||||
if (offsetTop === undefined) return;
|
||||
// if msg is already in visible are we don't need to scroll to that
|
||||
if (offsetTop > scrollInfo.top && offsetTop < (scrollInfo.top + scrollInfo.viewHeight)) return;
|
||||
const to = offsetTop - offset;
|
||||
|
||||
this._scrollTo(scrollInfo, to);
|
||||
}
|
||||
|
||||
_scrollTo(scrollInfo, scrollTop) {
|
||||
this.scroll.scrollTop = scrollTop;
|
||||
|
||||
// browser emit 'onscroll' event only if the 'element.scrollTop' value changes.
|
||||
// so here we flag that the upcoming 'onscroll' event is
|
||||
// emitted as side effect of assigning 'this.scroll.scrollTop' above
|
||||
// only if it's changes.
|
||||
// by doing so we prevent this._updateCalc() from calc again.
|
||||
if (scrollTop !== this.top) {
|
||||
this.scrolledByCode = true;
|
||||
}
|
||||
const sInfo = { ...scrollInfo };
|
||||
|
||||
const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight;
|
||||
|
||||
sInfo.top = (scrollTop > maxScrollTop) ? maxScrollTop : scrollTop;
|
||||
this._updateCalc(sInfo);
|
||||
}
|
||||
|
||||
// we maintain reference of top and bottom messages
|
||||
// to restore the scroll position when
|
||||
// messages gets removed from either end and added to other.
|
||||
_updateTopBottomMsg() {
|
||||
const msgs = this.scroll.lastElementChild.lastElementChild.children;
|
||||
const lMsgIndex = msgs.length - 1;
|
||||
|
||||
// TODO: classname 'ph-msg' prevent this class from being used
|
||||
const PLACEHOLDER_COUNT = 2;
|
||||
this.topMsg = msgs[0]?.className === 'ph-msg'
|
||||
? msgs[PLACEHOLDER_COUNT]
|
||||
: msgs[0];
|
||||
this.bottomMsg = msgs[lMsgIndex]?.className === 'ph-msg'
|
||||
? msgs[lMsgIndex - PLACEHOLDER_COUNT]
|
||||
: msgs[lMsgIndex];
|
||||
}
|
||||
|
||||
// we calculate the difference between first/last message and current scrollTop.
|
||||
// if we are going above we calc diff between first and scrollTop
|
||||
// else otherwise.
|
||||
// NOTE: This will help to restore the scroll when msgs get's removed
|
||||
// from one end and added to other end
|
||||
_calcDiff(scrollInfo) {
|
||||
if (!this.topMsg || !this.bottomMsg) return 0;
|
||||
if (this.inTopHalf) {
|
||||
return this.topMsg.offsetTop - scrollInfo.top;
|
||||
}
|
||||
return this.bottomMsg.offsetTop - scrollInfo.top;
|
||||
}
|
||||
|
||||
_updateCalc(scrollInfo) {
|
||||
const halfViewHeight = Math.round(scrollInfo.viewHeight / 2);
|
||||
const scrollMiddle = scrollInfo.top + halfViewHeight;
|
||||
const lastMiddle = this.top + halfViewHeight;
|
||||
|
||||
this.backwards = scrollMiddle < lastMiddle;
|
||||
this.inTopHalf = scrollMiddle < scrollInfo.height / 2;
|
||||
|
||||
this.isScrollable = scrollInfo.isScrollable;
|
||||
this.top = scrollInfo.top;
|
||||
this.bottom = scrollInfo.height - (scrollInfo.top + scrollInfo.viewHeight);
|
||||
this.height = scrollInfo.height;
|
||||
this.viewHeight = scrollInfo.viewHeight;
|
||||
|
||||
this._updateTopBottomMsg();
|
||||
this.diff = this._calcDiff(scrollInfo);
|
||||
}
|
||||
|
||||
calcScroll() {
|
||||
if (this.scrolledByCode) {
|
||||
this.scrolledByCode = false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scrollInfo = getScrollInfo(this.scroll);
|
||||
this._updateCalc(scrollInfo);
|
||||
|
||||
return this.backwards;
|
||||
}
|
||||
}
|
||||
|
||||
export default TimelineScroll;
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
import React from 'react';
|
||||
import './commands.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
import { hasDMWith, hasDevices } from '../../../util/matrixUtil';
|
||||
import { selectRoom, openReusableDialog } from '../../../client/action/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||
|
||||
const MXID_REG = /^@\S+:\S+$/;
|
||||
const ROOM_ID_ALIAS_REG = /^(#|!)\S+:\S+$/;
|
||||
const ROOM_ID_REG = /^!\S+:\S+$/;
|
||||
const MXC_REG = /^mxc:\/\/\S+$/;
|
||||
|
||||
export function processMxidAndReason(data) {
|
||||
let reason;
|
||||
let idData = data;
|
||||
const reasonMatch = data.match(/\s-r\s/);
|
||||
if (reasonMatch) {
|
||||
idData = data.slice(0, reasonMatch.index);
|
||||
reason = data.slice(reasonMatch.index + reasonMatch[0].length);
|
||||
if (reason.trim() === '') reason = undefined;
|
||||
}
|
||||
const rawIds = idData.split(' ');
|
||||
const userIds = rawIds.filter((id) => id.match(MXID_REG));
|
||||
return {
|
||||
userIds,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
const commands = {
|
||||
me: {
|
||||
name: 'me',
|
||||
description: 'Display action',
|
||||
exe: (roomId, data, onSuccess) => {
|
||||
const body = data.trim();
|
||||
if (body === '') return;
|
||||
onSuccess(body, { msgType: 'm.emote' });
|
||||
},
|
||||
},
|
||||
shrug: {
|
||||
name: 'shrug',
|
||||
description: 'Send ¯\\_(ツ)_/¯ as message',
|
||||
exe: (roomId, data, onSuccess) => onSuccess(
|
||||
`¯\\_(ツ)_/¯${data.trim() !== '' ? ` ${data}` : ''}`,
|
||||
{ msgType: 'm.text' },
|
||||
),
|
||||
},
|
||||
plain: {
|
||||
name: 'plain',
|
||||
description: 'Send plain text message',
|
||||
exe: (roomId, data, onSuccess) => {
|
||||
const body = data.trim();
|
||||
if (body === '') return;
|
||||
onSuccess(body, { msgType: 'm.text', autoMarkdown: false });
|
||||
},
|
||||
},
|
||||
help: {
|
||||
name: 'help',
|
||||
description: 'View all commands',
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
exe: () => openHelpDialog(),
|
||||
},
|
||||
startdm: {
|
||||
name: 'startdm',
|
||||
description: 'Start direct message with user. Example: /startdm userId1',
|
||||
exe: async (roomId, data) => {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const rawIds = data.split(' ');
|
||||
const userIds = rawIds.filter((id) => id.match(MXID_REG) && id !== mx.getUserId());
|
||||
if (userIds.length === 0) return;
|
||||
if (userIds.length === 1) {
|
||||
const dmRoomId = hasDMWith(userIds[0]);
|
||||
if (dmRoomId) {
|
||||
selectRoom(dmRoomId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const devices = await Promise.all(userIds.map(hasDevices));
|
||||
const isEncrypt = devices.every((hasDevice) => hasDevice);
|
||||
const result = await roomActions.createDM(userIds, isEncrypt);
|
||||
selectRoom(result.room_id);
|
||||
},
|
||||
},
|
||||
join: {
|
||||
name: 'join',
|
||||
description: 'Join room with address. Example: /join address1 address2',
|
||||
exe: (roomId, data) => {
|
||||
const rawIds = data.split(' ');
|
||||
const roomIds = rawIds.filter((id) => id.match(ROOM_ID_ALIAS_REG));
|
||||
roomIds.map((id) => roomActions.join(id));
|
||||
},
|
||||
},
|
||||
leave: {
|
||||
name: 'leave',
|
||||
description: 'Leave current room.',
|
||||
exe: (roomId, data) => {
|
||||
if (data.trim() === '') {
|
||||
roomActions.leave(roomId);
|
||||
return;
|
||||
}
|
||||
const rawIds = data.split(' ');
|
||||
const roomIds = rawIds.filter((id) => id.match(ROOM_ID_REG));
|
||||
roomIds.map((id) => roomActions.leave(id));
|
||||
},
|
||||
},
|
||||
invite: {
|
||||
name: 'invite',
|
||||
description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]',
|
||||
exe: (roomId, data) => {
|
||||
const { userIds, reason } = processMxidAndReason(data);
|
||||
userIds.map((id) => roomActions.invite(roomId, id, reason));
|
||||
},
|
||||
},
|
||||
disinvite: {
|
||||
name: 'disinvite',
|
||||
description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]',
|
||||
exe: (roomId, data) => {
|
||||
const { userIds, reason } = processMxidAndReason(data);
|
||||
userIds.map((id) => roomActions.kick(roomId, id, reason));
|
||||
},
|
||||
},
|
||||
kick: {
|
||||
name: 'kick',
|
||||
description: 'Kick user from room. Example: /kick userId1 userId2 [-r reason]',
|
||||
exe: (roomId, data) => {
|
||||
const { userIds, reason } = processMxidAndReason(data);
|
||||
userIds.map((id) => roomActions.kick(roomId, id, reason));
|
||||
},
|
||||
},
|
||||
ban: {
|
||||
name: 'ban',
|
||||
description: 'Ban user from room. Example: /ban userId1 userId2 [-r reason]',
|
||||
exe: (roomId, data) => {
|
||||
const { userIds, reason } = processMxidAndReason(data);
|
||||
userIds.map((id) => roomActions.ban(roomId, id, reason));
|
||||
},
|
||||
},
|
||||
unban: {
|
||||
name: 'unban',
|
||||
description: 'Unban user from room. Example: /unban userId1 userId2',
|
||||
exe: (roomId, data) => {
|
||||
const rawIds = data.split(' ');
|
||||
const userIds = rawIds.filter((id) => id.match(MXID_REG));
|
||||
userIds.map((id) => roomActions.unban(roomId, id));
|
||||
},
|
||||
},
|
||||
ignore: {
|
||||
name: 'ignore',
|
||||
description: 'Ignore user. Example: /ignore userId1 userId2',
|
||||
exe: (roomId, data) => {
|
||||
const rawIds = data.split(' ');
|
||||
const userIds = rawIds.filter((id) => id.match(MXID_REG));
|
||||
if (userIds.length > 0) roomActions.ignore(userIds);
|
||||
},
|
||||
},
|
||||
unignore: {
|
||||
name: 'unignore',
|
||||
description: 'Unignore user. Example: /unignore userId1 userId2',
|
||||
exe: (roomId, data) => {
|
||||
const rawIds = data.split(' ');
|
||||
const userIds = rawIds.filter((id) => id.match(MXID_REG));
|
||||
if (userIds.length > 0) roomActions.unignore(userIds);
|
||||
},
|
||||
},
|
||||
myroomnick: {
|
||||
name: 'myroomnick',
|
||||
description: 'Change nick in current room.',
|
||||
exe: (roomId, data) => {
|
||||
const nick = data.trim();
|
||||
if (nick === '') return;
|
||||
roomActions.setMyRoomNick(roomId, nick);
|
||||
},
|
||||
},
|
||||
myroomavatar: {
|
||||
name: 'myroomavatar',
|
||||
description: 'Change profile picture in current room. Example /myroomavatar mxc://xyzabc',
|
||||
exe: (roomId, data) => {
|
||||
if (data.match(MXC_REG)) {
|
||||
roomActions.setMyRoomAvatar(roomId, data);
|
||||
}
|
||||
},
|
||||
},
|
||||
converttodm: {
|
||||
name: 'converttodm',
|
||||
description: 'Convert room to direct message',
|
||||
exe: (roomId) => {
|
||||
roomActions.convertToDm(roomId);
|
||||
},
|
||||
},
|
||||
converttoroom: {
|
||||
name: 'converttoroom',
|
||||
description: 'Convert direct message to room',
|
||||
exe: (roomId) => {
|
||||
roomActions.convertToRoom(roomId);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function openHelpDialog() {
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Commands</Text>,
|
||||
() => (
|
||||
<div className="commands-dialog">
|
||||
{Object.keys(commands).map((cmdName) => (
|
||||
<SettingTile
|
||||
key={cmdName}
|
||||
title={cmdName}
|
||||
content={<Text variant="b3">{commands[cmdName].description}</Text>}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export default commands;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
.commands-dialog {
|
||||
& > * {
|
||||
padding: var(--sp-tight) var(--sp-normal);
|
||||
border-bottom: 1px solid var(--bg-surface-border);
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: var(--sp-extra-loose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { getUsername, getUsernameOfRoomMember } from '../../../util/matrixUtil';
|
||||
|
||||
function getTimelineJSXMessages() {
|
||||
return {
|
||||
join(user) {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' joined the room'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
leave(user, reason) {
|
||||
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' left the room'}
|
||||
{twemojify(reasonMsg)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
invite(inviter, user) {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(inviter)}</b>
|
||||
{' invited '}
|
||||
<b>{twemojify(user)}</b>
|
||||
</>
|
||||
);
|
||||
},
|
||||
cancelInvite(inviter, user) {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(inviter)}</b>
|
||||
{' canceled '}
|
||||
<b>{twemojify(user)}</b>
|
||||
{'\'s invite'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
rejectInvite(user) {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' rejected the invitation'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
kick(actor, user, reason) {
|
||||
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(actor)}</b>
|
||||
{' kicked '}
|
||||
<b>{twemojify(user)}</b>
|
||||
{twemojify(reasonMsg)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
ban(actor, user, reason) {
|
||||
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(actor)}</b>
|
||||
{' banned '}
|
||||
<b>{twemojify(user)}</b>
|
||||
{twemojify(reasonMsg)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
unban(actor, user) {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(actor)}</b>
|
||||
{' unbanned '}
|
||||
<b>{twemojify(user)}</b>
|
||||
</>
|
||||
);
|
||||
},
|
||||
avatarSets(user) {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' set a avatar'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
avatarChanged(user) {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' changed their avatar'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
avatarRemoved(user) {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' removed their avatar'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
nameSets(user, newName) {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' set display name to '}
|
||||
<b>{twemojify(newName)}</b>
|
||||
</>
|
||||
);
|
||||
},
|
||||
nameChanged(user, newName) {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' changed their display name to '}
|
||||
<b>{twemojify(newName)}</b>
|
||||
</>
|
||||
);
|
||||
},
|
||||
nameRemoved(user, lastName) {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' removed their display name '}
|
||||
<b>{twemojify(lastName)}</b>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getUsersActionJsx(roomId, userIds, actionStr) {
|
||||
const room = initMatrix.matrixClient.getRoom(roomId);
|
||||
const getUserDisplayName = (userId) => {
|
||||
if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId));
|
||||
return getUsername(userId);
|
||||
};
|
||||
const getUserJSX = (userId) => <b>{twemojify(getUserDisplayName(userId))}</b>;
|
||||
if (!Array.isArray(userIds)) return 'Idle';
|
||||
if (userIds.length === 0) return 'Idle';
|
||||
const MAX_VISIBLE_COUNT = 3;
|
||||
|
||||
const u1Jsx = getUserJSX(userIds[0]);
|
||||
// eslint-disable-next-line react/jsx-one-expression-per-line
|
||||
if (userIds.length === 1) return <>{u1Jsx} is {actionStr}</>;
|
||||
|
||||
const u2Jsx = getUserJSX(userIds[1]);
|
||||
// eslint-disable-next-line react/jsx-one-expression-per-line
|
||||
if (userIds.length === 2) return <>{u1Jsx} and {u2Jsx} are {actionStr}</>;
|
||||
|
||||
const u3Jsx = getUserJSX(userIds[2]);
|
||||
if (userIds.length === 3) {
|
||||
// eslint-disable-next-line react/jsx-one-expression-per-line
|
||||
return <>{u1Jsx}, {u2Jsx} and {u3Jsx} are {actionStr}</>;
|
||||
}
|
||||
|
||||
const othersCount = userIds.length - MAX_VISIBLE_COUNT;
|
||||
// eslint-disable-next-line react/jsx-one-expression-per-line
|
||||
return <>{u1Jsx}, {u2Jsx}, {u3Jsx} and {othersCount} others are {actionStr}</>;
|
||||
}
|
||||
|
||||
function parseTimelineChange(mEvent) {
|
||||
const tJSXMsgs = getTimelineJSXMessages();
|
||||
const makeReturnObj = (variant, content) => ({
|
||||
variant,
|
||||
content,
|
||||
});
|
||||
const content = mEvent.getContent();
|
||||
const prevContent = mEvent.getPrevContent();
|
||||
const sender = mEvent.getSender();
|
||||
const senderName = getUsername(sender);
|
||||
const userName = getUsername(mEvent.getStateKey());
|
||||
|
||||
switch (content.membership) {
|
||||
case 'invite': return makeReturnObj('invite', tJSXMsgs.invite(senderName, userName));
|
||||
case 'ban': return makeReturnObj('leave', tJSXMsgs.ban(senderName, userName, content.reason));
|
||||
case 'join':
|
||||
if (prevContent.membership === 'join') {
|
||||
if (content.displayname !== prevContent.displayname) {
|
||||
if (typeof content.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameRemoved(sender, prevContent.displayname));
|
||||
if (typeof prevContent.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameSets(sender, content.displayname));
|
||||
return makeReturnObj('avatar', tJSXMsgs.nameChanged(prevContent.displayname, content.displayname));
|
||||
}
|
||||
if (content.avatar_url !== prevContent.avatar_url) {
|
||||
if (typeof content.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarRemoved(content.displayname));
|
||||
if (typeof prevContent.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarSets(content.displayname));
|
||||
return makeReturnObj('avatar', tJSXMsgs.avatarChanged(content.displayname));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return makeReturnObj('join', tJSXMsgs.join(senderName));
|
||||
case 'leave':
|
||||
if (sender === mEvent.getStateKey()) {
|
||||
switch (prevContent.membership) {
|
||||
case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.rejectInvite(senderName));
|
||||
default: return makeReturnObj('leave', tJSXMsgs.leave(senderName, content.reason));
|
||||
}
|
||||
}
|
||||
switch (prevContent.membership) {
|
||||
case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.cancelInvite(senderName, userName));
|
||||
case 'ban': return makeReturnObj('other', tJSXMsgs.unban(senderName, userName));
|
||||
// sender is not target and made the target leave,
|
||||
// if not from invite/ban then this is a kick
|
||||
default: return makeReturnObj('leave', tJSXMsgs.kick(senderName, userName, content.reason));
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getTimelineJSXMessages,
|
||||
getUsersActionJsx,
|
||||
parseTimelineChange,
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import './Search.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
|
|
@ -19,6 +20,11 @@ import RoomSelector from '../../molecules/room-selector/RoomSelector';
|
|||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
|
||||
function useVisiblityToggle(setResult) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
|
@ -48,9 +54,8 @@ function useVisiblityToggle(setResult) {
|
|||
return [isOpen, requestClose];
|
||||
}
|
||||
|
||||
function mapRoomIds(roomIds) {
|
||||
function mapRoomIds(roomIds, directs, roomIdToParents) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { directs, roomIdToParents } = initMatrix.roomList;
|
||||
|
||||
return roomIds.map((roomId) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
|
|
@ -62,7 +67,7 @@ function mapRoomIds(roomIds) {
|
|||
|
||||
let type = 'room';
|
||||
if (room.isSpaceRoom()) type = 'space';
|
||||
else if (directs.has(roomId)) type = 'direct';
|
||||
else if (directs.includes(roomId)) type = 'direct';
|
||||
|
||||
return {
|
||||
type,
|
||||
|
|
@ -81,6 +86,12 @@ function Search() {
|
|||
const searchRef = useRef(null);
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const spaces = useSpaces(mx, allRoomsAtom);
|
||||
const rooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||
const directs = useDirects(mx, allRoomsAtom, mDirects);
|
||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
|
||||
const handleSearchResults = (chunk, term) => {
|
||||
setResult({
|
||||
|
|
@ -97,7 +108,6 @@ function Search() {
|
|||
return;
|
||||
}
|
||||
|
||||
const { spaces, rooms, directs } = initMatrix.roomList;
|
||||
let ids = null;
|
||||
|
||||
if (prefix) {
|
||||
|
|
@ -109,15 +119,15 @@ function Search() {
|
|||
}
|
||||
|
||||
ids.sort(roomIdByActivity);
|
||||
const mappedIds = mapRoomIds(ids);
|
||||
const mappedIds = mapRoomIds(ids, directs, roomToParents);
|
||||
asyncSearch.setup(mappedIds, { keys: 'name', isContain: true, limit: 20 });
|
||||
if (prefix) handleSearchResults(mappedIds, prefix);
|
||||
else asyncSearch.search(term);
|
||||
};
|
||||
|
||||
const loadRecentRooms = () => {
|
||||
const { recentRooms } = navigation;
|
||||
handleSearchResults(mapRoomIds(recentRooms).reverse());
|
||||
const recentRooms = [];
|
||||
handleSearchResults(mapRoomIds(recentRooms, directs, roomToParents).reverse());
|
||||
};
|
||||
|
||||
const handleAfterOpen = () => {
|
||||
|
|
@ -169,7 +179,6 @@ function Search() {
|
|||
}
|
||||
};
|
||||
|
||||
const noti = initMatrix.notifications;
|
||||
const renderRoomSelector = (item) => {
|
||||
let imageSrc = null;
|
||||
let iconSrc = null;
|
||||
|
|
@ -188,9 +197,9 @@ function Search() {
|
|||
roomId={item.roomId}
|
||||
imageSrc={imageSrc}
|
||||
iconSrc={iconSrc}
|
||||
isUnread={noti.hasNoti(item.roomId)}
|
||||
notificationCount={noti.getTotalNoti(item.roomId)}
|
||||
isAlert={noti.getHighlightNoti(item.roomId) > 0}
|
||||
isUnread={roomToUnread.has(item.roomId)}
|
||||
notificationCount={roomToUnread.get(item.roomId)?.total ?? 0}
|
||||
isAlert={roomToUnread.get(item.roomId)?.highlight > 0}
|
||||
onClick={() => openItem(item.roomId, item.type)}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import React, { useState } from 'react';
|
|||
import './CrossSigning.scss';
|
||||
import FileSaver from 'file-saver';
|
||||
import { Formik } from 'formik';
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { openReusableDialog } from '../../../client/action/navigation';
|
||||
|
|
@ -22,15 +21,17 @@ import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
|||
const failedDialog = () => {
|
||||
const renderFailure = (requestClose) => (
|
||||
<div className="cross-signing__failure">
|
||||
<Text variant="h1">{twemojify('❌')}</Text>
|
||||
<Text variant="h1">❌</Text>
|
||||
<Text weight="medium">Failed to setup cross signing. Please try again.</Text>
|
||||
<Button onClick={requestClose}>Close</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Setup cross signing</Text>,
|
||||
renderFailure,
|
||||
<Text variant="s1" weight="medium">
|
||||
Setup cross signing
|
||||
</Text>,
|
||||
renderFailure
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -48,11 +49,11 @@ const securityKeyDialog = (key) => {
|
|||
const renderSecurityKey = () => (
|
||||
<div className="cross-signing__key">
|
||||
<Text weight="medium">Please save this security key somewhere safe.</Text>
|
||||
<Text className="cross-signing__key-text">
|
||||
{key.encodedPrivateKey}
|
||||
</Text>
|
||||
<Text className="cross-signing__key-text">{key.encodedPrivateKey}</Text>
|
||||
<div className="cross-signing__key-btn">
|
||||
<Button variant="primary" onClick={() => copyKey(key)}>Copy</Button>
|
||||
<Button variant="primary" onClick={() => copyKey(key)}>
|
||||
Copy
|
||||
</Button>
|
||||
<Button onClick={() => downloadKey(key)}>Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -62,8 +63,10 @@ const securityKeyDialog = (key) => {
|
|||
downloadKey();
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Security Key</Text>,
|
||||
() => renderSecurityKey(),
|
||||
<Text variant="s1" weight="medium">
|
||||
Security Key
|
||||
</Text>,
|
||||
() => renderSecurityKey()
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -112,7 +115,7 @@ function CrossSigningSetup() {
|
|||
errors.phrase = 'Phrase must contain 8-127 characters with no space.';
|
||||
}
|
||||
if (values.confirmPhrase.length > 0 && values.confirmPhrase !== values.phrase) {
|
||||
errors.confirmPhrase = 'Phrase don\'t match.';
|
||||
errors.confirmPhrase = "Phrase don't match.";
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
|
@ -121,10 +124,14 @@ function CrossSigningSetup() {
|
|||
<div className="cross-signing__setup">
|
||||
<div className="cross-signing__setup-entry">
|
||||
<Text>
|
||||
We will generate a <b>Security Key</b>,
|
||||
which you can use to manage messages backup and session verification.
|
||||
We will generate a <b>Security Key</b>, which you can use to manage messages backup and
|
||||
session verification.
|
||||
</Text>
|
||||
{genWithPhrase !== false && <Button variant="primary" onClick={() => setup()} disabled={genWithPhrase !== undefined}>Generate Key</Button>}
|
||||
{genWithPhrase !== false && (
|
||||
<Button variant="primary" onClick={() => setup()} disabled={genWithPhrase !== undefined}>
|
||||
Generate Key
|
||||
</Button>
|
||||
)}
|
||||
{genWithPhrase === false && <Spinner size="small" />}
|
||||
</div>
|
||||
<Text className="cross-signing__setup-divider">OR</Text>
|
||||
|
|
@ -133,9 +140,7 @@ function CrossSigningSetup() {
|
|||
onSubmit={(values) => setup(values.phrase)}
|
||||
validate={validator}
|
||||
>
|
||||
{({
|
||||
values, errors, handleChange, handleSubmit,
|
||||
}) => (
|
||||
{({ values, errors, handleChange, handleSubmit }) => (
|
||||
<form
|
||||
className="cross-signing__setup-entry"
|
||||
onSubmit={handleSubmit}
|
||||
|
|
@ -143,8 +148,8 @@ function CrossSigningSetup() {
|
|||
>
|
||||
<Text>
|
||||
Alternatively you can also set a <b>Security Phrase </b>
|
||||
so you don't have to remember long Security Key,
|
||||
and optionally save the Key as backup.
|
||||
so you don't have to remember long Security Key, and optionally save the Key as
|
||||
backup.
|
||||
</Text>
|
||||
<Input
|
||||
name="phrase"
|
||||
|
|
@ -155,7 +160,11 @@ function CrossSigningSetup() {
|
|||
required
|
||||
disabled={genWithPhrase !== undefined}
|
||||
/>
|
||||
{errors.phrase && <Text variant="b3" className="cross-signing__error">{errors.phrase}</Text>}
|
||||
{errors.phrase && (
|
||||
<Text variant="b3" className="cross-signing__error">
|
||||
{errors.phrase}
|
||||
</Text>
|
||||
)}
|
||||
<Input
|
||||
name="confirmPhrase"
|
||||
value={values.confirmPhrase}
|
||||
|
|
@ -165,8 +174,16 @@ function CrossSigningSetup() {
|
|||
required
|
||||
disabled={genWithPhrase !== undefined}
|
||||
/>
|
||||
{errors.confirmPhrase && <Text variant="b3" className="cross-signing__error">{errors.confirmPhrase}</Text>}
|
||||
{genWithPhrase !== true && <Button variant="primary" type="submit" disabled={genWithPhrase !== undefined}>Set Phrase & Generate Key</Button>}
|
||||
{errors.confirmPhrase && (
|
||||
<Text variant="b3" className="cross-signing__error">
|
||||
{errors.confirmPhrase}
|
||||
</Text>
|
||||
)}
|
||||
{genWithPhrase !== true && (
|
||||
<Button variant="primary" type="submit" disabled={genWithPhrase !== undefined}>
|
||||
Set Phrase & Generate Key
|
||||
</Button>
|
||||
)}
|
||||
{genWithPhrase === true && <Spinner size="small" />}
|
||||
</form>
|
||||
)}
|
||||
|
|
@ -177,31 +194,36 @@ function CrossSigningSetup() {
|
|||
|
||||
const setupDialog = () => {
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Setup cross signing</Text>,
|
||||
() => <CrossSigningSetup />,
|
||||
<Text variant="s1" weight="medium">
|
||||
Setup cross signing
|
||||
</Text>,
|
||||
() => <CrossSigningSetup />
|
||||
);
|
||||
};
|
||||
|
||||
function CrossSigningReset() {
|
||||
return (
|
||||
<div className="cross-signing__reset">
|
||||
<Text variant="h1">{twemojify('✋🧑🚒🤚')}</Text>
|
||||
<Text variant="h1">✋🧑🚒🤚</Text>
|
||||
<Text weight="medium">Resetting cross-signing keys is permanent.</Text>
|
||||
<Text>
|
||||
Anyone you have verified with will see security alerts and your message backup will be lost.
|
||||
You almost certainly do not want to do this,
|
||||
unless you have lost <b>Security Key</b> or <b>Phrase</b> and
|
||||
every session you can cross-sign from.
|
||||
Anyone you have verified with will see security alerts and your message backup will be lost.
|
||||
You almost certainly do not want to do this, unless you have lost <b>Security Key</b> or{' '}
|
||||
<b>Phrase</b> and every session you can cross-sign from.
|
||||
</Text>
|
||||
<Button variant="danger" onClick={setupDialog}>Reset</Button>
|
||||
<Button variant="danger" onClick={setupDialog}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resetDialog = () => {
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Reset cross signing</Text>,
|
||||
() => <CrossSigningReset />,
|
||||
<Text variant="s1" weight="medium">
|
||||
Reset cross signing
|
||||
</Text>,
|
||||
() => <CrossSigningReset />
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -210,12 +232,23 @@ function CrossSignin() {
|
|||
return (
|
||||
<SettingTile
|
||||
title="Cross signing"
|
||||
content={<Text variant="b3">Setup to verify and keep track of all your sessions. Also required to backup encrypted message.</Text>}
|
||||
options={(
|
||||
isCSEnabled
|
||||
? <Button variant="danger" onClick={resetDialog}>Reset</Button>
|
||||
: <Button variant="primary" onClick={setupDialog}>Setup</Button>
|
||||
)}
|
||||
content={
|
||||
<Text variant="b3">
|
||||
Setup to verify and keep track of all your sessions. Also required to backup encrypted
|
||||
message.
|
||||
</Text>
|
||||
}
|
||||
options={
|
||||
isCSEnabled ? (
|
||||
<Button variant="danger" onClick={resetDialog}>
|
||||
Reset
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" onClick={setupDialog}>
|
||||
Setup
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './KeyBackup.scss';
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { openReusableDialog } from '../../../client/action/navigation';
|
||||
|
|
@ -34,10 +33,7 @@ function CreateKeyBackupDialog({ keyData }) {
|
|||
let info;
|
||||
|
||||
try {
|
||||
info = await mx.prepareKeyBackupVersion(
|
||||
null,
|
||||
{ secureSecretStorage: true },
|
||||
);
|
||||
info = await mx.prepareKeyBackupVersion(null, { secureSecretStorage: true });
|
||||
info = await mx.createKeyBackupVersion(info);
|
||||
await mx.scheduleAllGroupSessionsForBackup();
|
||||
if (!mountStore.getItem()) return;
|
||||
|
|
@ -65,7 +61,7 @@ function CreateKeyBackupDialog({ keyData }) {
|
|||
)}
|
||||
{done === true && (
|
||||
<>
|
||||
<Text variant="h1">{twemojify('✅')}</Text>
|
||||
<Text variant="h1">✅</Text>
|
||||
<Text>Successfully created backup</Text>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -104,12 +100,9 @@ function RestoreKeyBackupDialog({ keyData }) {
|
|||
|
||||
try {
|
||||
const backupInfo = await mx.getKeyBackupVersion();
|
||||
const info = await mx.restoreKeyBackupWithSecretStorage(
|
||||
backupInfo,
|
||||
undefined,
|
||||
undefined,
|
||||
{ progressCallback },
|
||||
);
|
||||
const info = await mx.restoreKeyBackupWithSecretStorage(backupInfo, undefined, undefined, {
|
||||
progressCallback,
|
||||
});
|
||||
if (!mountStore.getItem()) return;
|
||||
setStatus({ done: `Successfully restored backup keys (${info.imported}/${info.total}).` });
|
||||
} catch (e) {
|
||||
|
|
@ -138,7 +131,7 @@ function RestoreKeyBackupDialog({ keyData }) {
|
|||
)}
|
||||
{status.done && (
|
||||
<>
|
||||
<Text variant="h1">{twemojify('✅')}</Text>
|
||||
<Text variant="h1">✅</Text>
|
||||
<Text>{status.done}</Text>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -176,14 +169,16 @@ function DeleteKeyBackupDialog({ requestClose }) {
|
|||
|
||||
return (
|
||||
<div className="key-backup__delete">
|
||||
<Text variant="h1">{twemojify('🗑')}</Text>
|
||||
<Text variant="h1">🗑</Text>
|
||||
<Text weight="medium">Deleting key backup is permanent.</Text>
|
||||
<Text>All encrypted messages keys stored on server will be deleted.</Text>
|
||||
{
|
||||
isDeleting
|
||||
? <Spinner size="small" />
|
||||
: <Button variant="danger" onClick={deleteBackup}>Delete</Button>
|
||||
}
|
||||
{isDeleting ? (
|
||||
<Spinner size="small" />
|
||||
) : (
|
||||
<Button variant="danger" onClick={deleteBackup}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -224,9 +219,11 @@ function KeyBackup() {
|
|||
if (keyData === null) return;
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Create Key Backup</Text>,
|
||||
<Text variant="s1" weight="medium">
|
||||
Create Key Backup
|
||||
</Text>,
|
||||
() => <CreateKeyBackupDialog keyData={keyData} />,
|
||||
() => fetchKeyBackupVersion(),
|
||||
() => fetchKeyBackupVersion()
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -235,29 +232,44 @@ function KeyBackup() {
|
|||
if (keyData === null) return;
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Restore Key Backup</Text>,
|
||||
() => <RestoreKeyBackupDialog keyData={keyData} />,
|
||||
<Text variant="s1" weight="medium">
|
||||
Restore Key Backup
|
||||
</Text>,
|
||||
() => <RestoreKeyBackupDialog keyData={keyData} />
|
||||
);
|
||||
};
|
||||
|
||||
const openDeleteKeyBackup = () => openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Delete Key Backup</Text>,
|
||||
(requestClose) => (
|
||||
<DeleteKeyBackupDialog
|
||||
requestClose={(isDone) => {
|
||||
if (isDone) setKeyBackup(null);
|
||||
requestClose();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
);
|
||||
const openDeleteKeyBackup = () =>
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">
|
||||
Delete Key Backup
|
||||
</Text>,
|
||||
(requestClose) => (
|
||||
<DeleteKeyBackupDialog
|
||||
requestClose={(isDone) => {
|
||||
if (isDone) setKeyBackup(null);
|
||||
requestClose();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
const renderOptions = () => {
|
||||
if (keyBackup === undefined) return <Spinner size="small" />;
|
||||
if (keyBackup === null) return <Button variant="primary" onClick={openCreateKeyBackup}>Create Backup</Button>;
|
||||
if (keyBackup === null)
|
||||
return (
|
||||
<Button variant="primary" onClick={openCreateKeyBackup}>
|
||||
Create Backup
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<IconButton src={DownloadIC} variant="positive" onClick={openRestoreKeyBackup} tooltip="Restore backup" />
|
||||
<IconButton
|
||||
src={DownloadIC}
|
||||
variant="positive"
|
||||
onClick={openRestoreKeyBackup}
|
||||
tooltip="Restore backup"
|
||||
/>
|
||||
<IconButton src={BinIC} onClick={openDeleteKeyBackup} tooltip="Delete backup" />
|
||||
</>
|
||||
);
|
||||
|
|
@ -266,9 +278,12 @@ function KeyBackup() {
|
|||
return (
|
||||
<SettingTile
|
||||
title="Encrypted messages backup"
|
||||
content={(
|
||||
content={
|
||||
<>
|
||||
<Text variant="b3">Online backup your encrypted messages keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.</Text>
|
||||
<Text variant="b3">
|
||||
Online backup your encrypted messages keys with your account data in case you lose
|
||||
access to your sessions. Your keys will be secured with a unique Security Key.
|
||||
</Text>
|
||||
{!isCSEnabled && (
|
||||
<InfoCard
|
||||
style={{ marginTop: 'var(--sp-ultra-tight)' }}
|
||||
|
|
@ -279,7 +294,7 @@ function KeyBackup() {
|
|||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
}
|
||||
options={isCSEnabled ? renderOptions() : null}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import './ShortcutSpaces.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { createSpaceShortcut, deleteSpaceShortcut } from '../../../client/action/accountData';
|
||||
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
||||
import { roomIdByAtoZ } from '../../../util/sort';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Checkbox from '../../atoms/button/Checkbox';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import RoomSelector from '../../molecules/room-selector/RoomSelector';
|
||||
import Dialog from '../../molecules/dialog/Dialog';
|
||||
|
||||
import PinIC from '../../../../public/res/ic/outlined/pin.svg';
|
||||
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
import { useSpaceShortcut } from '../../hooks/useSpaceShortcut';
|
||||
|
||||
function ShortcutSpacesContent() {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { spaces, roomIdToParents } = initMatrix.roomList;
|
||||
|
||||
const [spaceShortcut] = useSpaceShortcut();
|
||||
const spaceWithoutShortcut = [...spaces].filter(
|
||||
(spaceId) => !spaceShortcut.includes(spaceId),
|
||||
).sort(roomIdByAtoZ);
|
||||
|
||||
const [process, setProcess] = useState(null);
|
||||
const [selected, setSelected] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (process !== null) {
|
||||
setProcess(null);
|
||||
setSelected([]);
|
||||
}
|
||||
}, [spaceShortcut]);
|
||||
|
||||
const toggleSelection = (sId) => {
|
||||
if (process !== null) return;
|
||||
const newSelected = [...selected];
|
||||
const selectedIndex = newSelected.indexOf(sId);
|
||||
|
||||
if (selectedIndex > -1) {
|
||||
newSelected.splice(selectedIndex, 1);
|
||||
setSelected(newSelected);
|
||||
return;
|
||||
}
|
||||
newSelected.push(sId);
|
||||
setSelected(newSelected);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setProcess(`Pinning ${selected.length} spaces...`);
|
||||
createSpaceShortcut(selected);
|
||||
};
|
||||
|
||||
const renderSpace = (spaceId, isShortcut) => {
|
||||
const room = mx.getRoom(spaceId);
|
||||
if (!room) return null;
|
||||
|
||||
const parentSet = roomIdToParents.get(spaceId);
|
||||
const parentNames = parentSet
|
||||
? [...parentSet].map((parentId) => mx.getRoom(parentId).name)
|
||||
: undefined;
|
||||
const parents = parentNames ? parentNames.join(', ') : null;
|
||||
|
||||
const toggleSelected = () => toggleSelection(spaceId);
|
||||
const deleteShortcut = () => deleteSpaceShortcut(spaceId);
|
||||
|
||||
return (
|
||||
<RoomSelector
|
||||
key={spaceId}
|
||||
name={room.name}
|
||||
parentName={parents}
|
||||
roomId={spaceId}
|
||||
imageSrc={null}
|
||||
iconSrc={joinRuleToIconSrc(room.getJoinRule(), true)}
|
||||
isUnread={false}
|
||||
notificationCount={0}
|
||||
isAlert={false}
|
||||
onClick={isShortcut ? deleteShortcut : toggleSelected}
|
||||
options={isShortcut ? (
|
||||
<IconButton
|
||||
src={isShortcut ? PinFilledIC : PinIC}
|
||||
size="small"
|
||||
onClick={deleteShortcut}
|
||||
disabled={process !== null}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
isActive={selected.includes(spaceId)}
|
||||
variant="positive"
|
||||
onToggle={toggleSelected}
|
||||
tabIndex={-1}
|
||||
disabled={process !== null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="shortcut-spaces__header" variant="b3" weight="bold">Pinned spaces</Text>
|
||||
{spaceShortcut.length === 0 && <Text>No pinned spaces</Text>}
|
||||
{spaceShortcut.map((spaceId) => renderSpace(spaceId, true))}
|
||||
<Text className="shortcut-spaces__header" variant="b3" weight="bold">Unpinned spaces</Text>
|
||||
{spaceWithoutShortcut.length === 0 && <Text>No unpinned spaces</Text>}
|
||||
{spaceWithoutShortcut.map((spaceId) => renderSpace(spaceId, false))}
|
||||
{selected.length !== 0 && (
|
||||
<div className="shortcut-spaces__footer">
|
||||
{process && <Spinner size="small" />}
|
||||
<Text weight="medium">{process || `${selected.length} spaces selected`}</Text>
|
||||
{ !process && (
|
||||
<Button onClick={handleAdd} variant="primary">Pin</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useVisibilityToggle() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = () => setIsOpen(true);
|
||||
navigation.on(cons.events.navigation.SHORTCUT_SPACES_OPENED, handleOpen);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.SHORTCUT_SPACES_OPENED, handleOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const requestClose = () => setIsOpen(false);
|
||||
|
||||
return [isOpen, requestClose];
|
||||
}
|
||||
|
||||
function ShortcutSpaces() {
|
||||
const [isOpen, requestClose] = useVisibilityToggle();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
className="shortcut-spaces"
|
||||
title={(
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
Pin spaces
|
||||
</Text>
|
||||
)}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||
onRequestClose={requestClose}
|
||||
>
|
||||
{
|
||||
isOpen
|
||||
? <ShortcutSpacesContent />
|
||||
: <div />
|
||||
}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShortcutSpaces;
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
@use '../../partials/flex';
|
||||
|
||||
.shortcut-spaces {
|
||||
height: 100%;
|
||||
.dialog__content-container {
|
||||
padding: 0;
|
||||
padding-bottom: 80px;
|
||||
@include dir.side(padding, var(--sp-extra-tight), 0);
|
||||
|
||||
& > .text-b1 {
|
||||
padding: 0 var(--sp-extra-tight);
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
margin-top: var(--sp-extra-tight);
|
||||
padding: var(--sp-extra-tight);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.room-selector {
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
}
|
||||
.room-selector__options {
|
||||
display: flex;
|
||||
.checkbox {
|
||||
margin: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: var(--sp-normal);
|
||||
background-color: var(--bg-surface);
|
||||
border-top: 1px solid var(--bg-surface-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& > .text {
|
||||
@extend .cp-fx__item-one;
|
||||
padding: 0 var(--sp-tight);
|
||||
}
|
||||
|
||||
& > button {
|
||||
@include dir.side(margin, var(--sp-normal), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,433 +0,0 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './SpaceManage.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { selectRoom, selectTab } from '../../../client/action/navigation';
|
||||
import RoomsHierarchy from '../../../client/state/RoomsHierarchy';
|
||||
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
||||
import { join } from '../../../client/action/room';
|
||||
import { Debounce } from '../../../util/common';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Checkbox from '../../atoms/button/Checkbox';
|
||||
import Avatar from '../../atoms/avatar/Avatar';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import PopupWindow from '../../molecules/popup-window/PopupWindow';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import ChevronRightIC from '../../../../public/res/ic/outlined/chevron-right.svg';
|
||||
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
|
||||
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
|
||||
function SpaceManageBreadcrumb({ path, onSelect }) {
|
||||
return (
|
||||
<div className="space-manage-breadcrumb__wrapper">
|
||||
<ScrollView horizontal vertical={false} invisible>
|
||||
<div className="space-manage-breadcrumb">
|
||||
{
|
||||
path.map((item, index) => (
|
||||
<React.Fragment key={item.roomId}>
|
||||
{index > 0 && <RawIcon size="extra-small" src={ChevronRightIC} />}
|
||||
<Button onClick={() => onSelect(item.roomId, item.name)}>
|
||||
<Text variant="b2">{twemojify(item.name)}</Text>
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
SpaceManageBreadcrumb.propTypes = {
|
||||
path: PropTypes.arrayOf(PropTypes.exact({
|
||||
roomId: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
})).isRequired,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function SpaceManageItem({
|
||||
parentId, roomInfo, onSpaceClick, requestClose,
|
||||
isSelected, onSelect, roomHierarchy,
|
||||
}) {
|
||||
const [isExpand, setIsExpand] = useState(false);
|
||||
const [isJoining, setIsJoining] = useState(false);
|
||||
|
||||
const { directs } = initMatrix.roomList;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const parentRoom = mx.getRoom(parentId);
|
||||
const isSpace = roomInfo.room_type === 'm.space';
|
||||
const roomId = roomInfo.room_id;
|
||||
const canManage = parentRoom?.currentState.maySendStateEvent('m.space.child', mx.getUserId()) || false;
|
||||
const isSuggested = parentRoom?.currentState.getStateEvents('m.space.child', roomId)?.getContent().suggested === true;
|
||||
|
||||
const room = mx.getRoom(roomId);
|
||||
const isJoined = !!(room?.getMyMembership() === 'join' || null);
|
||||
const name = room?.name || roomInfo.name || roomInfo.canonical_alias || roomId;
|
||||
let imageSrc = mx.mxcUrlToHttp(roomInfo.avatar_url, 24, 24, 'crop') || null;
|
||||
if (!imageSrc && room) {
|
||||
imageSrc = room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
||||
if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
||||
}
|
||||
const isDM = directs.has(roomId);
|
||||
|
||||
const handleOpen = () => {
|
||||
if (isSpace) selectTab(roomId);
|
||||
else selectRoom(roomId);
|
||||
requestClose();
|
||||
};
|
||||
const handleJoin = () => {
|
||||
const viaSet = roomHierarchy.viaMap.get(roomId);
|
||||
const via = viaSet ? [...viaSet] : undefined;
|
||||
join(roomId, false, via);
|
||||
setIsJoining(true);
|
||||
};
|
||||
|
||||
const roomAvatarJSX = (
|
||||
<Avatar
|
||||
text={name}
|
||||
bgColor={colorMXID(roomId)}
|
||||
imageSrc={isDM ? imageSrc : null}
|
||||
iconColor="var(--ic-surface-low)"
|
||||
iconSrc={
|
||||
isDM
|
||||
? null
|
||||
: joinRuleToIconSrc((roomInfo.join_rules || roomInfo.join_rule), isSpace)
|
||||
}
|
||||
size="extra-small"
|
||||
/>
|
||||
);
|
||||
const roomNameJSX = (
|
||||
<Text>
|
||||
{twemojify(name)}
|
||||
<Text variant="b3" span>{` • ${roomInfo.num_joined_members} members`}</Text>
|
||||
</Text>
|
||||
);
|
||||
|
||||
const expandBtnJsx = (
|
||||
<IconButton
|
||||
variant={isExpand ? 'primary' : 'surface'}
|
||||
size="extra-small"
|
||||
src={InfoIC}
|
||||
tooltip="Topic"
|
||||
tooltipPlacement="top"
|
||||
onClick={() => setIsExpand(!isExpand)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`space-manage-item${isSpace ? '--space' : ''}`}
|
||||
>
|
||||
<div>
|
||||
{canManage && <Checkbox isActive={isSelected} onToggle={() => onSelect(roomId)} variant="positive" />}
|
||||
<button
|
||||
className="space-manage-item__btn"
|
||||
onClick={isSpace ? () => onSpaceClick(roomId, name) : null}
|
||||
type="button"
|
||||
>
|
||||
{roomAvatarJSX}
|
||||
{roomNameJSX}
|
||||
{isSuggested && <Text variant="b2">Suggested</Text>}
|
||||
</button>
|
||||
{roomInfo.topic && expandBtnJsx}
|
||||
{
|
||||
isJoined
|
||||
? <Button onClick={handleOpen}>Open</Button>
|
||||
: <Button variant="primary" onClick={handleJoin} disabled={isJoining}>{isJoining ? 'Joining...' : 'Join'}</Button>
|
||||
}
|
||||
</div>
|
||||
{isExpand && roomInfo.topic && <Text variant="b2">{twemojify(roomInfo.topic, undefined, true)}</Text>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
SpaceManageItem.propTypes = {
|
||||
parentId: PropTypes.string.isRequired,
|
||||
roomHierarchy: PropTypes.shape({}).isRequired,
|
||||
roomInfo: PropTypes.shape({}).isRequired,
|
||||
onSpaceClick: PropTypes.func.isRequired,
|
||||
requestClose: PropTypes.func.isRequired,
|
||||
isSelected: PropTypes.bool.isRequired,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function SpaceManageFooter({ parentId, selected }) {
|
||||
const [process, setProcess] = useState(null);
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(parentId);
|
||||
const { currentState } = room;
|
||||
|
||||
const allSuggested = selected.every((roomId) => {
|
||||
const sEvent = currentState.getStateEvents('m.space.child', roomId);
|
||||
return !!sEvent?.getContent()?.suggested;
|
||||
});
|
||||
|
||||
const handleRemove = () => {
|
||||
setProcess(`Removing ${selected.length} items`);
|
||||
selected.forEach((roomId) => {
|
||||
mx.sendStateEvent(parentId, 'm.space.child', {}, roomId);
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleSuggested = (isMark) => {
|
||||
if (isMark) setProcess(`Marking as suggested ${selected.length} items`);
|
||||
else setProcess(`Marking as not suggested ${selected.length} items`);
|
||||
selected.forEach((roomId) => {
|
||||
const sEvent = room.currentState.getStateEvents('m.space.child', roomId);
|
||||
if (!sEvent) return;
|
||||
const content = { ...sEvent.getContent() };
|
||||
if (isMark && content.suggested) return;
|
||||
if (!isMark && !content.suggested) return;
|
||||
content.suggested = isMark;
|
||||
mx.sendStateEvent(parentId, 'm.space.child', content, roomId);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-manage__footer">
|
||||
{process && <Spinner size="small" />}
|
||||
<Text weight="medium">{process || `${selected.length} item selected`}</Text>
|
||||
{ !process && (
|
||||
<>
|
||||
<Button onClick={handleRemove} variant="danger">Remove</Button>
|
||||
<Button
|
||||
onClick={() => handleToggleSuggested(!allSuggested)}
|
||||
variant={allSuggested ? 'surface' : 'primary'}
|
||||
>
|
||||
{allSuggested ? 'Mark as not suggested' : 'Mark as suggested'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
SpaceManageFooter.propTypes = {
|
||||
parentId: PropTypes.string.isRequired,
|
||||
selected: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
};
|
||||
|
||||
function useSpacePath(roomId) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(roomId);
|
||||
const [spacePath, setSpacePath] = useState([{ roomId, name: room.name }]);
|
||||
|
||||
const addPathItem = (rId, name) => {
|
||||
const newPath = [...spacePath];
|
||||
const itemIndex = newPath.findIndex((item) => item.roomId === rId);
|
||||
if (itemIndex < 0) {
|
||||
newPath.push({ roomId: rId, name });
|
||||
setSpacePath(newPath);
|
||||
return;
|
||||
}
|
||||
newPath.splice(itemIndex + 1);
|
||||
setSpacePath(newPath);
|
||||
};
|
||||
|
||||
return [spacePath, addPathItem];
|
||||
}
|
||||
|
||||
function useUpdateOnJoin(roomId) {
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
const { roomList } = initMatrix;
|
||||
|
||||
useEffect(() => {
|
||||
const handleRoomList = () => forceUpdate();
|
||||
|
||||
roomList.on(cons.events.roomList.ROOM_JOINED, handleRoomList);
|
||||
roomList.on(cons.events.roomList.ROOM_LEAVED, handleRoomList);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleRoomList);
|
||||
roomList.removeListener(cons.events.roomList.ROOM_LEAVED, handleRoomList);
|
||||
};
|
||||
}, [roomId]);
|
||||
}
|
||||
|
||||
function useChildUpdate(roomId, roomsHierarchy) {
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
const [debounce] = useState(new Debounce());
|
||||
const mx = initMatrix.matrixClient;
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const handleStateEvent = (event) => {
|
||||
if (event.getRoomId() !== roomId) return;
|
||||
if (event.getType() !== 'm.space.child') return;
|
||||
|
||||
debounce._(() => {
|
||||
if (!isMounted) return;
|
||||
roomsHierarchy.removeHierarchy(roomId);
|
||||
forceUpdate();
|
||||
}, 500)();
|
||||
};
|
||||
mx.on('RoomState.events', handleStateEvent);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
mx.removeListener('RoomState.events', handleStateEvent);
|
||||
};
|
||||
}, [roomId, roomsHierarchy]);
|
||||
}
|
||||
|
||||
function SpaceManageContent({ roomId, requestClose }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
useUpdateOnJoin(roomId);
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
const [roomsHierarchy] = useState(new RoomsHierarchy(mx, 30));
|
||||
const [spacePath, addPathItem] = useSpacePath(roomId);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const mountStore = useStore();
|
||||
const currentPath = spacePath[spacePath.length - 1];
|
||||
useChildUpdate(currentPath.roomId, roomsHierarchy);
|
||||
|
||||
const currentHierarchy = roomsHierarchy.getHierarchy(currentPath.roomId);
|
||||
|
||||
useEffect(() => {
|
||||
mountStore.setItem(true);
|
||||
return () => {
|
||||
mountStore.setItem(false);
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected([]);
|
||||
}, [spacePath]);
|
||||
|
||||
const handleSelected = (selectedRoomId) => {
|
||||
const newSelected = [...selected];
|
||||
const selectedIndex = newSelected.indexOf(selectedRoomId);
|
||||
|
||||
if (selectedIndex > -1) {
|
||||
newSelected.splice(selectedIndex, 1);
|
||||
setSelected(newSelected);
|
||||
return;
|
||||
}
|
||||
newSelected.push(selectedRoomId);
|
||||
setSelected(newSelected);
|
||||
};
|
||||
|
||||
const loadRoomHierarchy = async () => {
|
||||
if (!roomsHierarchy.canLoadMore(currentPath.roomId)) return;
|
||||
if (!roomsHierarchy.getHierarchy(currentPath.roomId)) setSelected([]);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await roomsHierarchy.load(currentPath.roomId);
|
||||
if (!mountStore.getItem()) return;
|
||||
setIsLoading(false);
|
||||
forceUpdate();
|
||||
} catch {
|
||||
if (!mountStore.getItem()) return;
|
||||
setIsLoading(false);
|
||||
forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentHierarchy) loadRoomHierarchy();
|
||||
return (
|
||||
<div className="space-manage__content">
|
||||
{spacePath.length > 1 && (
|
||||
<SpaceManageBreadcrumb path={spacePath} onSelect={addPathItem} />
|
||||
)}
|
||||
<Text variant="b3" weight="bold">Rooms and spaces</Text>
|
||||
<div className="space-manage__content-items">
|
||||
{!isLoading && currentHierarchy?.rooms?.length === 1 && (
|
||||
<Text>
|
||||
Either the space contains private rooms or you need to join space to view it's rooms.
|
||||
</Text>
|
||||
)}
|
||||
{currentHierarchy && (currentHierarchy.rooms?.map((roomInfo) => (
|
||||
roomInfo.room_id === currentPath.roomId
|
||||
? null
|
||||
: (
|
||||
<SpaceManageItem
|
||||
key={roomInfo.room_id}
|
||||
isSelected={selected.includes(roomInfo.room_id)}
|
||||
roomHierarchy={currentHierarchy}
|
||||
parentId={currentPath.roomId}
|
||||
roomInfo={roomInfo}
|
||||
onSpaceClick={addPathItem}
|
||||
requestClose={requestClose}
|
||||
onSelect={handleSelected}
|
||||
/>
|
||||
)
|
||||
)))}
|
||||
{!currentHierarchy && <Text>loading...</Text>}
|
||||
</div>
|
||||
{currentHierarchy?.canLoadMore && !isLoading && (
|
||||
<Button onClick={loadRoomHierarchy}>Load more</Button>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="space-manage__content-loading">
|
||||
<Spinner size="small" />
|
||||
<Text>Loading rooms...</Text>
|
||||
</div>
|
||||
)}
|
||||
{selected.length > 0 && (
|
||||
<SpaceManageFooter parentId={currentPath.roomId} selected={selected} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
SpaceManageContent.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
requestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function useWindowToggle() {
|
||||
const [roomId, setRoomId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const openSpaceManage = (rId) => {
|
||||
setRoomId(rId);
|
||||
};
|
||||
navigation.on(cons.events.navigation.SPACE_MANAGE_OPENED, openSpaceManage);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.SPACE_MANAGE_OPENED, openSpaceManage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const requestClose = () => setRoomId(null);
|
||||
|
||||
return [roomId, requestClose];
|
||||
}
|
||||
function SpaceManage() {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const [roomId, requestClose] = useWindowToggle();
|
||||
const room = mx.getRoom(roomId);
|
||||
|
||||
return (
|
||||
<PopupWindow
|
||||
isOpen={roomId !== null}
|
||||
className="space-manage"
|
||||
title={(
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
{roomId && twemojify(room.name)}
|
||||
<span style={{ color: 'var(--tc-surface-low)' }}> — manage rooms</span>
|
||||
</Text>
|
||||
)}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||
onRequestClose={requestClose}
|
||||
>
|
||||
{
|
||||
roomId
|
||||
? <SpaceManageContent roomId={roomId} requestClose={requestClose} />
|
||||
: <div />
|
||||
}
|
||||
</PopupWindow>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpaceManage;
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
@use '../../partials/text';
|
||||
@use '../../partials/dir';
|
||||
@use '../../partials/flex';
|
||||
|
||||
.space-manage {
|
||||
& .pw__content-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
& .pw__content-container {
|
||||
padding-top: 0;
|
||||
padding-bottom: 73px;
|
||||
}
|
||||
}
|
||||
|
||||
.space-manage__content {
|
||||
margin-bottom: var(--sp-extra-loose);
|
||||
|
||||
& > .text {
|
||||
margin-top: var(--sp-extra-tight);
|
||||
padding: var(--sp-extra-tight) var(--sp-normal);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&-items {
|
||||
@include dir.side(padding, var(--sp-extra-tight), 0);
|
||||
& > .text:first-child {
|
||||
padding: var(--sp-extra-tight);
|
||||
}
|
||||
}
|
||||
|
||||
& > button {
|
||||
margin: var(--sp-normal);
|
||||
}
|
||||
|
||||
&-loading {
|
||||
padding: var(--sp-loose);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
& .text {
|
||||
margin: 0 var(--sp-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
.space-manage-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
|
||||
&__wrapper {
|
||||
height: var(--header-height);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
& > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
& > .btn-surface {
|
||||
min-width: 0;
|
||||
padding: var(--sp-extra-tight) 10px;
|
||||
white-space: nowrap;
|
||||
box-shadow: none;
|
||||
& p {
|
||||
@extend .cp-txt__ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
&:last-child {
|
||||
box-shadow: var(--bs-surface-border) !important;
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.space-manage-item {
|
||||
margin: var(--sp-ultra-tight) var(--sp-extra-tight);
|
||||
padding: 0 var(--sp-extra-tight);
|
||||
border-radius: var(--bo-radius);
|
||||
|
||||
& > div {
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&--space {
|
||||
@extend .space-manage-item;
|
||||
& .space-manage-item__btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
& .checkbox {
|
||||
@include dir.side(margin, 0, var(--sp-tight));
|
||||
}
|
||||
|
||||
|
||||
&__btn {
|
||||
@extend .cp-fx__item-one;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& .avatar__border--active {
|
||||
box-shadow: none;
|
||||
}
|
||||
& > .text-b1 {
|
||||
@extend .cp-fx__item-one;
|
||||
@extend .cp-txt__ellipsis;
|
||||
min-width: 0;
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
}
|
||||
& > .text-b2 {
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
padding: 1px var(--sp-ultra-tight);
|
||||
color: var(--bg-positive);
|
||||
box-shadow: var(--bs-positive-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
& .ic-btn {
|
||||
padding: 7px;
|
||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
& .btn-surface,
|
||||
& .btn-primary {
|
||||
padding: var(--sp-ultra-tight) var(--sp-extra-tight);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
& > .text {
|
||||
padding: 32px;
|
||||
padding-top: 0;
|
||||
padding-bottom: var(--sp-normal);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.space-manage__footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: var(--sp-normal);
|
||||
background-color: var(--bg-surface);
|
||||
border-top: 1px solid var(--bg-surface-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& > .text {
|
||||
@extend .cp-fx__item-one;
|
||||
padding: 0 var(--sp-tight);
|
||||
}
|
||||
|
||||
& > button {
|
||||
@include dir.side(margin, var(--sp-normal), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,9 @@ import React, { useState, useEffect } from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import './SpaceSettings.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { leave } from '../../../client/action/room';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
|
|
@ -29,6 +26,7 @@ import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
|||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
||||
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
const tabText = {
|
||||
GENERAL: 'General',
|
||||
|
|
@ -62,6 +60,7 @@ const tabItems = [
|
|||
|
||||
function GeneralSettings({ roomId }) {
|
||||
const roomName = initMatrix.matrixClient.getRoom(roomId)?.name;
|
||||
const mx = useMatrixClient();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -76,7 +75,7 @@ function GeneralSettings({ roomId }) {
|
|||
'Leave',
|
||||
'danger'
|
||||
);
|
||||
if (isConfirmed) leave(roomId);
|
||||
if (isConfirmed) mx.leave(roomId);
|
||||
}}
|
||||
iconSrc={LeaveArrowIC}
|
||||
>
|
||||
|
|
@ -138,7 +137,7 @@ function SpaceSettings() {
|
|||
className="space-settings"
|
||||
title={
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
{isOpen && twemojify(room.name)}
|
||||
{isOpen && room.name}
|
||||
<span style={{ color: 'var(--tc-surface-low)' }}> — space settings</span>
|
||||
</Text>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './StickerBoard.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { getRelevantPacks } from '../emoji-board/custom-emoji';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
|
||||
function StickerBoard({ roomId, onSelect }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(roomId);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
|
||||
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
||||
|
||||
const packs = getRelevantPacks(
|
||||
mx,
|
||||
[room, ...parentRooms],
|
||||
).filter((pack) => pack.getStickers().length !== 0);
|
||||
|
||||
function isTargetNotSticker(target) {
|
||||
return target.classList.contains('sticker-board__sticker') === false;
|
||||
}
|
||||
function getStickerData(target) {
|
||||
const mxc = target.getAttribute('data-mx-sticker');
|
||||
const body = target.getAttribute('title');
|
||||
const httpUrl = target.getAttribute('src');
|
||||
return { mxc, body, httpUrl };
|
||||
}
|
||||
const handleOnSelect = (e) => {
|
||||
if (isTargetNotSticker(e.target)) return;
|
||||
|
||||
const stickerData = getStickerData(e.target);
|
||||
onSelect(stickerData);
|
||||
};
|
||||
|
||||
const openGroup = (groupIndex) => {
|
||||
const scrollContent = scrollRef.current.firstElementChild;
|
||||
scrollContent.children[groupIndex].scrollIntoView();
|
||||
};
|
||||
|
||||
const renderPack = (pack) => (
|
||||
<div className="sticker-board__pack" key={pack.id}>
|
||||
<Text className="sticker-board__pack-header" variant="b2" weight="bold">{pack.displayName ?? 'Unknown'}</Text>
|
||||
<div className="sticker-board__pack-items">
|
||||
{pack.getStickers().map((sticker) => (
|
||||
<img
|
||||
key={sticker.shortcode}
|
||||
className="sticker-board__sticker"
|
||||
src={mx.mxcUrlToHttp(sticker.mxc)}
|
||||
alt={sticker.shortcode}
|
||||
title={sticker.body ?? sticker.shortcode}
|
||||
data-mx-sticker={sticker.mxc}
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="sticker-board">
|
||||
{packs.length > 0 && (
|
||||
<ScrollView invisible>
|
||||
<div className="sticker-board__sidebar">
|
||||
{packs.map((pack, index) => {
|
||||
const src = mx.mxcUrlToHttp(pack.avatarUrl ?? pack.getStickers()[0].mxc);
|
||||
return (
|
||||
<IconButton
|
||||
key={pack.id}
|
||||
onClick={() => openGroup(index)}
|
||||
src={src}
|
||||
tooltip={pack.displayName || 'Unknown'}
|
||||
tooltipPlacement="left"
|
||||
isImage
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollView>
|
||||
)}
|
||||
<div className="sticker-board__container">
|
||||
<ScrollView autoHide ref={scrollRef}>
|
||||
<div
|
||||
onClick={handleOnSelect}
|
||||
className="sticker-board__content"
|
||||
>
|
||||
{
|
||||
packs.length > 0
|
||||
? packs.map(renderPack)
|
||||
: (
|
||||
<div className="sticker-board__empty">
|
||||
<Text>There is no sticker pack.</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
StickerBoard.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default StickerBoard;
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
|
||||
.sticker-board {
|
||||
--sticker-board-height: 390px;
|
||||
--sticker-board-width: 286px;
|
||||
display: flex;
|
||||
height: var(--sticker-board-height);
|
||||
display: flex;
|
||||
|
||||
& > .scrollbar {
|
||||
width: initial;
|
||||
height: var(--sticker-board-height);
|
||||
}
|
||||
|
||||
&__sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
padding: 4px 6px;
|
||||
@include dir.side(border, none, 1px solid var(--bg-surface-border));
|
||||
}
|
||||
|
||||
&__container {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
width: var(--sticker-board-width);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__content {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
&__pack {
|
||||
margin-bottom: var(--sp-normal);
|
||||
position: relative;
|
||||
|
||||
&-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
background-color: var(--bg-surface);
|
||||
|
||||
@include dir.side(margin, var(--sp-extra-tight), 0);
|
||||
padding: var(--sp-extra-tight) var(--sp-ultra-tight);
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 -4px 0 0 var(--bg-surface);
|
||||
border-bottom: 1px solid var(--bg-surface-border);
|
||||
}
|
||||
&-items {
|
||||
margin: var(--sp-tight);
|
||||
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-normal) var(--sp-tight);
|
||||
|
||||
img {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
width: 100%;
|
||||
height: var(--sticker-board-height);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './ViewSource.scss';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import PopupWindow from '../../molecules/popup-window/PopupWindow';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
function ViewSourceBlock({ title, json }) {
|
||||
return (
|
||||
<div className="view-source__card">
|
||||
<MenuHeader>{title}</MenuHeader>
|
||||
<ScrollView horizontal vertical={false} autoHide>
|
||||
<pre className="text text-b1">
|
||||
<code className="language-json">
|
||||
{JSON.stringify(json, null, 2)}
|
||||
</code>
|
||||
</pre>
|
||||
</ScrollView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ViewSourceBlock.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
json: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
function ViewSource() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [event, setEvent] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadViewSource = (e) => {
|
||||
setEvent(e);
|
||||
setIsOpen(true);
|
||||
};
|
||||
navigation.on(cons.events.navigation.VIEWSOURCE_OPENED, loadViewSource);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.VIEWSOURCE_OPENED, loadViewSource);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAfterClose = () => {
|
||||
setEvent(null);
|
||||
};
|
||||
|
||||
const renderViewSource = () => (
|
||||
<div className="view-source">
|
||||
{event.isEncrypted() && <ViewSourceBlock title="Decrypted source" json={event.getEffectiveEvent()} />}
|
||||
<ViewSourceBlock title="Original source" json={event.event} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PopupWindow
|
||||
isOpen={isOpen}
|
||||
title="View source"
|
||||
onAfterClose={handleAfterClose}
|
||||
onRequestClose={() => setIsOpen(false)}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={() => setIsOpen(false)} tooltip="Close" />}
|
||||
>
|
||||
{event ? renderViewSource() : <div />}
|
||||
</PopupWindow>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewSource;
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
|
||||
.view-source {
|
||||
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
||||
|
||||
& pre {
|
||||
padding: var(--sp-extra-tight);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__card {
|
||||
margin: var(--sp-normal) 0;
|
||||
background-color: var(--bg-surface-hover);
|
||||
border-radius: var(--bo-radius);
|
||||
box-shadow: var(--bs-surface-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import React from 'react';
|
||||
import './Welcome.scss';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
|
||||
import CinnySvg from '../../../../public/res/svg/cinny.svg';
|
||||
|
||||
function Welcome() {
|
||||
return (
|
||||
<div className="app-welcome flex--center">
|
||||
<div>
|
||||
<img className="app-welcome__logo noselect" src={CinnySvg} alt="Cinny logo" />
|
||||
<Text className="app-welcome__heading" variant="h1" weight="medium" primary>Welcome to Cinny</Text>
|
||||
<Text className="app-welcome__subheading" variant="s1">Yet another matrix client</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Welcome;
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
|
||||
.app-welcome {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--bg-surface);
|
||||
|
||||
& > div {
|
||||
@extend .cp-fx__column--c-c;
|
||||
max-width: 600px;
|
||||
}
|
||||
&__logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
&__heading {
|
||||
margin: var(--sp-extra-loose) 0 var(--sp-tight);
|
||||
color: var(--tc-surface-high);
|
||||
}
|
||||
&__subheading {
|
||||
color: var(--tc-surface-normal);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue