Compare commits
26 commits
dev
...
master-(ab
Author | SHA1 | Date | |
---|---|---|---|
|
253e3eb855 | ||
|
54dd5a4edb | ||
|
0bd22bee13 | ||
|
6703614c78 | ||
|
32923a1c34 | ||
|
1dc0f9288a | ||
|
2e62f103f2 | ||
|
d0e9aea788 | ||
|
e12a75e431 | ||
|
5ece37fffe | ||
|
e6f228b63e | ||
|
5018df11f1 | ||
|
5f6667debf | ||
|
5b1d5d0326 | ||
|
012e933e43 | ||
|
825db633a0 | ||
|
4dfe40333e | ||
|
a34c35fbe3 | ||
|
8b4beccf82 | ||
|
d067fe776d | ||
|
9fad8a1098 | ||
|
8a1b749667 | ||
|
7b67e4a6e6 | ||
|
824a7f2095 | ||
|
b4d4cefe23 | ||
|
bdb94d7145 |
36 changed files with 924 additions and 97 deletions
30
.github/workflows/pull-request.yml
vendored
Normal file
30
.github/workflows/pull-request.yml
vendored
Normal file
|
@ -0,0 +1,30 @@
|
|||
name: 'Netlify Preview Deploy'
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: ['opened', 'synchronize']
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: "Deploy to Netlify"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: jsmrcaga/action-netlify-deploy@master
|
||||
with:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE3_ID }}
|
||||
BUILD_DIRECTORY: "dist"
|
||||
- name: Post on PR
|
||||
uses: nwtgck/actions-netlify@v1.1
|
||||
with:
|
||||
publish-dir: "dist"
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
deploy-message: "Deploy from GitHub Actions"
|
||||
enable-pull-request-comment: true
|
||||
enable-commit-comment: false
|
||||
overwrites-pull-request-comment: true
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE3_ID }}
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -2,3 +2,5 @@ experiment
|
|||
dist
|
||||
node_modules
|
||||
devAssets
|
||||
|
||||
.DS_Store
|
||||
|
|
2
package-lock.json
generated
2
package-lock.json
generated
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "cinny",
|
||||
"version": "1.3.2",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "cinny",
|
||||
"version": "1.3.2",
|
||||
"version": "1.4.0",
|
||||
"description": "Yet another matrix client",
|
||||
"main": "index.js",
|
||||
"engines": {
|
||||
|
|
7
public/res/ic/outlined/shield-empty.svg
Normal file
7
public/res/ic/outlined/shield-empty.svg
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
||||
<path d="M12,2L3,6v7c0,5,4,9,9,9c5,0,9-4,9-9V6L12,2z M19,13c0,3.9-3.1,7-7,7s-7-3.1-7-7V7.3l7-3.1l7,3.1V13z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 554 B |
|
@ -24,7 +24,6 @@
|
|||
height: var(--av-extra-small);
|
||||
}
|
||||
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
|
|
@ -17,7 +17,8 @@ function Button({
|
|||
className={`${className ? `${className} ` : ''}btn-${variant} ${iconClass} noselect`}
|
||||
onMouseUp={(e) => blurOnBubbling(e, `.btn-${variant}`)}
|
||||
onClick={onClick}
|
||||
type={type === 'button' ? 'button' : 'submit'}
|
||||
// eslint-disable-next-line react/button-has-type
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
>
|
||||
{iconSrc !== null && <RawIcon size="small" src={iconSrc} />}
|
||||
|
@ -42,7 +43,7 @@ Button.propTypes = {
|
|||
className: PropTypes.string,
|
||||
variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']),
|
||||
iconSrc: PropTypes.string,
|
||||
type: PropTypes.oneOf(['button', 'submit']),
|
||||
type: PropTypes.oneOf(['button', 'submit', 'reset']),
|
||||
onClick: PropTypes.func,
|
||||
children: PropTypes.node.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
|
|
|
@ -17,7 +17,8 @@ const IconButton = React.forwardRef(({
|
|||
className={`ic-btn ic-btn-${variant}`}
|
||||
onMouseUp={(e) => blurOnBubbling(e, `.ic-btn-${variant}`)}
|
||||
onClick={onClick}
|
||||
type={type === 'button' ? 'button' : 'submit'}
|
||||
// eslint-disable-next-line react/button-has-type
|
||||
type={type}
|
||||
>
|
||||
<RawIcon size={size} src={src} />
|
||||
</button>
|
||||
|
@ -45,7 +46,7 @@ IconButton.defaultProps = {
|
|||
IconButton.propTypes = {
|
||||
variant: PropTypes.oneOf(['surface', 'positive', 'caution', 'danger']),
|
||||
size: PropTypes.oneOf(['normal', 'small', 'extra-small']),
|
||||
type: PropTypes.oneOf(['button', 'submit']),
|
||||
type: PropTypes.oneOf(['button', 'submit', 'reset']),
|
||||
tooltip: PropTypes.string,
|
||||
tooltipPlacement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
|
||||
src: PropTypes.string.isRequired,
|
||||
|
|
|
@ -7,13 +7,14 @@ import RawIcon from '../system-icons/RawIcon';
|
|||
|
||||
function Chip({
|
||||
iconSrc, iconColor, text, children,
|
||||
onClick,
|
||||
}) {
|
||||
return (
|
||||
<div className="chip">
|
||||
{iconSrc != null && <RawIcon src={iconSrc} color={iconColor} size="small" />}
|
||||
{(text != null && text !== '') && <Text variant="b2">{text}</Text>}
|
||||
<button className="chip" type="button" onClick={onClick}>
|
||||
{iconSrc != null && <RawIcon src={iconSrc} color={iconColor} size="extra-small" />}
|
||||
{(text != null && text !== '') && <Text variant="b3">{text}</Text>}
|
||||
{children}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -22,6 +23,7 @@ Chip.propTypes = {
|
|||
iconColor: PropTypes.string,
|
||||
text: PropTypes.string,
|
||||
children: PropTypes.element,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
Chip.defaultProps = {
|
||||
|
@ -29,6 +31,7 @@ Chip.defaultProps = {
|
|||
iconColor: null,
|
||||
text: null,
|
||||
children: null,
|
||||
onClick: null,
|
||||
};
|
||||
|
||||
export default Chip;
|
||||
|
|
|
@ -7,13 +7,27 @@
|
|||
|
||||
background: var(--bg-surface-low);
|
||||
border-radius: var(--bo-radius);
|
||||
border: 1px solid var(--bg-surface-border);
|
||||
box-shadow: var(--bs-surface-border);
|
||||
cursor: pointer;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--bg-surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
& > .text {
|
||||
flex: 1;
|
||||
color: var(--tc-surface-high);
|
||||
}
|
||||
|
||||
& > .ic-raw {
|
||||
margin-right: var(--sp-extra-tight);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: var(--sp-ultra-tight);
|
||||
[dir=rtl] & {
|
||||
margin-right: 0;
|
||||
margin-left: var(--sp-extra-tight);
|
||||
margin-left: var(--sp-ultra-tight);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './SegmentedControls.scss';
|
||||
|
||||
|
@ -17,6 +17,10 @@ function SegmentedControls({
|
|||
onSelect(segmentIndex);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSelect(selected);
|
||||
}, [selected]);
|
||||
|
||||
return (
|
||||
<div className="segmented-controls">
|
||||
{
|
||||
|
|
|
@ -22,11 +22,12 @@
|
|||
|
||||
&__avatar-container {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
&__avatar-container{
|
||||
margin-right: var(--sp-tight);
|
||||
|
||||
& button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[dir=rtl] & {
|
||||
margin: {
|
||||
left: var(--sp-tight);
|
||||
|
@ -261,6 +262,16 @@
|
|||
right: unset;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1620px) {
|
||||
.message__options {
|
||||
right: unset;
|
||||
left: 770px;
|
||||
[dir=rtl] {
|
||||
left: unset;
|
||||
right: 770px
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// markdown formating
|
||||
.message__content {
|
||||
|
|
|
@ -28,7 +28,7 @@ function RoomIntro({
|
|||
}
|
||||
|
||||
RoomIntro.defaultProps = {
|
||||
avatarSrc: false,
|
||||
avatarSrc: null,
|
||||
time: null,
|
||||
};
|
||||
|
||||
|
|
98
src/app/molecules/sso-buttons/SSOButtons.jsx
Normal file
98
src/app/molecules/sso-buttons/SSOButtons.jsx
Normal file
|
@ -0,0 +1,98 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './SSOButtons.scss';
|
||||
|
||||
import { createTemporaryClient, getLoginFlows, startSsoLogin } from '../../../client/action/auth';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
|
||||
function SSOButtons({ homeserver }) {
|
||||
const [identityProviders, setIdentityProviders] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// If the homeserver passed in is not a fully-qualified domain name, do not update.
|
||||
if (!homeserver.match('^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\\.[a-zA-Z]{2,})+$')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO Check that there is a Matrix server at homename before making requests.
|
||||
// This will prevent the CORS errors that happen when a user changes their homeserver.
|
||||
createTemporaryClient(homeserver).then((client) => {
|
||||
const providers = [];
|
||||
getLoginFlows(client).then((flows) => {
|
||||
if (flows.flows !== undefined) {
|
||||
const ssoFlows = flows.flows.filter((flow) => flow.type === 'm.login.sso' || flow.type === 'm.login.cas');
|
||||
ssoFlows.forEach((flow) => {
|
||||
if (flow.identity_providers !== undefined) {
|
||||
const type = flow.type.substring(8);
|
||||
flow.identity_providers.forEach((idp) => {
|
||||
const imageSrc = client.mxcUrlToHttp(idp.icon);
|
||||
providers.push({
|
||||
homeserver, id: idp.id, name: idp.name, type, imageSrc,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
setIdentityProviders(providers);
|
||||
}).catch(() => {});
|
||||
}).catch(() => {
|
||||
setIdentityProviders([]);
|
||||
});
|
||||
}, [homeserver]);
|
||||
|
||||
if (identityProviders.length === 0) return <></>;
|
||||
|
||||
return (
|
||||
<div className="sso-buttons">
|
||||
<div className="sso-buttons__divider">
|
||||
<Text>OR</Text>
|
||||
</div>
|
||||
<div className="sso-buttons__container">
|
||||
{identityProviders.sort((idp) => !!idp.imageSrc).map((idp) => (
|
||||
<SSOButton
|
||||
key={idp.id}
|
||||
homeserver={idp.homeserver}
|
||||
id={idp.id}
|
||||
name={idp.name}
|
||||
type={idp.type}
|
||||
imageSrc={idp.imageSrc}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SSOButton({
|
||||
homeserver, id, name, type, imageSrc,
|
||||
}) {
|
||||
const isImageAvail = !!imageSrc;
|
||||
function handleClick() {
|
||||
startSsoLogin(homeserver, type, id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`sso-btn${!isImageAvail ? ' sso-btn__text-only' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{isImageAvail && <img className="sso-btn__img" src={imageSrc} alt={name} />}
|
||||
{!isImageAvail && <Text>{`Login with ${name}`}</Text>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
SSOButtons.propTypes = {
|
||||
homeserver: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
SSOButton.propTypes = {
|
||||
homeserver: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
imageSrc: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default SSOButtons;
|
43
src/app/molecules/sso-buttons/SSOButtons.scss
Normal file
43
src/app/molecules/sso-buttons/SSOButtons.scss
Normal file
|
@ -0,0 +1,43 @@
|
|||
.sso-buttons {
|
||||
&__divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
flex: 1;
|
||||
content: '';
|
||||
margin: var(--sp-tight);
|
||||
border-bottom: 1px solid var(--bg-surface-border);
|
||||
}
|
||||
}
|
||||
&__container {
|
||||
margin-bottom: var(--sp-extra-loose);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sso-btn {
|
||||
margin: var(--sp-tight);
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&__img {
|
||||
height: var(--av-small);
|
||||
width: var(--av-small);
|
||||
}
|
||||
&__text-only {
|
||||
flex-basis: 100%;
|
||||
text-align: center;
|
||||
|
||||
margin: var(--sp-tight) 0px;
|
||||
cursor: pointer;
|
||||
& .text {
|
||||
color: var(--tc-link);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -80,17 +80,17 @@ EmojiGroup.propTypes = {
|
|||
};
|
||||
|
||||
const asyncSearch = new AsyncSearch();
|
||||
asyncSearch.setup(emojis, { keys: ['shortcode'], limit: 30 });
|
||||
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([]);
|
||||
else setSearchedEmojis({ emojis: [] });
|
||||
return;
|
||||
}
|
||||
setSearchedEmojis(resultEmojis);
|
||||
setSearchedEmojis({ emojis: resultEmojis });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -102,7 +102,7 @@ function SearchedEmoji() {
|
|||
|
||||
if (searchedEmojis === null) return false;
|
||||
|
||||
return <EmojiGroup key="-1" name={searchedEmojis.length === 0 ? 'No search result found' : 'Search results'} groupEmojis={searchedEmojis} />;
|
||||
return <EmojiGroup key="-1" name={searchedEmojis.emojis.length === 0 ? 'No search result found' : 'Search results'} groupEmojis={searchedEmojis.emojis} />;
|
||||
}
|
||||
|
||||
function EmojiBoard({ onSelect }) {
|
||||
|
|
|
@ -24,6 +24,28 @@ import PowerIC from '../../../../public/res/ic/outlined/power.svg';
|
|||
|
||||
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 (
|
||||
<ContextMenu
|
||||
|
@ -45,10 +67,10 @@ function ProfileAvatarMenu() {
|
|||
render={(toggleMenu) => (
|
||||
<SidebarAvatar
|
||||
onClick={toggleMenu}
|
||||
tooltip={mx.getUser(mx.getUserId()).displayName}
|
||||
imageSrc={mx.getUser(mx.getUserId()).avatarUrl !== null ? mx.mxcUrlToHttp(mx.getUser(mx.getUserId()).avatarUrl, 42, 42, 'crop') : null}
|
||||
tooltip={profile.displayName}
|
||||
imageSrc={profile.avatarUrl !== null ? mx.mxcUrlToHttp(profile.avatarUrl, 42, 42, 'crop') : null}
|
||||
bgColor={colorMXID(mx.getUserId())}
|
||||
text={mx.getUser(mx.getUserId()).displayName.slice(0, 1)}
|
||||
text={profile.displayName.slice(0, 1)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
|
@ -17,11 +17,17 @@ function ProfileEditor({
|
|||
const mx = initMatrix.matrixClient;
|
||||
const displayNameRef = useRef(null);
|
||||
const bgColor = colorMXID(userId);
|
||||
const [avatarSrc, setAvatarSrc] = useState(mx.mxcUrlToHttp(mx.getUser(mx.getUserId()).avatarUrl, 80, 80, 'crop') || null);
|
||||
const [avatarSrc, setAvatarSrc] = useState(null);
|
||||
const [disabled, setDisabled] = useState(true);
|
||||
|
||||
let username = mx.getUser(mx.getUserId()).displayName;
|
||||
|
||||
useEffect(() => {
|
||||
mx.getProfileInfo(mx.getUserId()).then((info) => {
|
||||
setAvatarSrc(info.avatar_url ? mx.mxcUrlToHttp(info.avatar_url, 80, 80, 'crop') : null);
|
||||
});
|
||||
}, [userId]);
|
||||
|
||||
// Sets avatar URL and updates the avatar component in profile editor to reflect new upload
|
||||
function handleAvatarUpload(url) {
|
||||
if (url === null) {
|
||||
|
|
255
src/app/organisms/profile-viewer/ProfileViewer.jsx
Normal file
255
src/app/organisms/profile-viewer/ProfileViewer.jsx
Normal file
|
@ -0,0 +1,255 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './ProfileViewer.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { selectRoom } from '../../../client/action/navigation';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
|
||||
import { getUsername, getUsernameOfRoomMember, getPowerLabel } from '../../../util/matrixUtil';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Chip from '../../atoms/chip/Chip';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Avatar from '../../atoms/avatar/Avatar';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Dialog from '../../molecules/dialog/Dialog';
|
||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||
|
||||
import ShieldEmptyIC from '../../../../public/res/ic/outlined/shield-empty.svg';
|
||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
function SessionInfo({ userId }) {
|
||||
const [devices, setDevices] = useState(null);
|
||||
const mx = initMatrix.matrixClient;
|
||||
|
||||
useEffect(() => {
|
||||
let isUnmounted = false;
|
||||
|
||||
async function loadDevices() {
|
||||
try {
|
||||
await mx.downloadKeys([userId], true);
|
||||
const myDevices = mx.getStoredDevicesForUser(userId);
|
||||
|
||||
if (isUnmounted) return;
|
||||
setDevices(myDevices);
|
||||
} catch {
|
||||
setDevices([]);
|
||||
}
|
||||
}
|
||||
loadDevices();
|
||||
|
||||
return () => {
|
||||
isUnmounted = true;
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
function renderSessionChips() {
|
||||
return (
|
||||
<div className="session-info__chips">
|
||||
{devices === null && <Text variant="b3">Loading sessions...</Text>}
|
||||
{devices?.length === 0 && <Text variant="b3">No session found.</Text>}
|
||||
{devices !== null && (devices.map((device) => (
|
||||
<Chip
|
||||
key={device.deviceId}
|
||||
iconSrc={ShieldEmptyIC}
|
||||
text={device.getDisplayName() || device.deviceId}
|
||||
/>
|
||||
)))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="session-info">
|
||||
<SettingTile
|
||||
title="Sessions"
|
||||
content={renderSessionChips()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SessionInfo.propTypes = {
|
||||
userId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function ProfileFooter({ userId, onRequestClose }) {
|
||||
const [isCreatingDM, setIsCreatingDM] = useState(false);
|
||||
const [isIgnoring, setIsIgnoring] = useState(false);
|
||||
const [isUserIgnored, setIsUserIgnored] = useState(initMatrix.matrixClient.isUserIgnored(userId));
|
||||
|
||||
const mx = initMatrix.matrixClient;
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => () => {
|
||||
isMountedRef.current = false;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setIsUserIgnored(initMatrix.matrixClient.isUserIgnored(userId));
|
||||
}, [userId]);
|
||||
|
||||
async function openDM() {
|
||||
const directIds = [...initMatrix.roomList.directs];
|
||||
|
||||
// Check and open if user already have a DM with userId.
|
||||
for (let i = 0; i < directIds.length; i += 1) {
|
||||
const dRoom = mx.getRoom(directIds[i]);
|
||||
const roomMembers = dRoom.getMembers();
|
||||
if (roomMembers.length <= 2 && dRoom.currentState.members[userId]) {
|
||||
selectRoom(directIds[i]);
|
||||
onRequestClose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new DM
|
||||
try {
|
||||
setIsCreatingDM(true);
|
||||
const result = await roomActions.create({
|
||||
isEncrypted: true,
|
||||
isDirect: true,
|
||||
invite: [userId],
|
||||
});
|
||||
|
||||
if (isMountedRef.current === false) return;
|
||||
setIsCreatingDM(false);
|
||||
selectRoom(result.room_id);
|
||||
onRequestClose();
|
||||
} catch {
|
||||
setIsCreatingDM(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleIgnore() {
|
||||
const ignoredUsers = mx.getIgnoredUsers();
|
||||
const uIndex = ignoredUsers.indexOf(userId);
|
||||
if (uIndex >= 0) {
|
||||
if (uIndex === -1) return;
|
||||
ignoredUsers.splice(uIndex, 1);
|
||||
} else ignoredUsers.push(userId);
|
||||
|
||||
try {
|
||||
setIsIgnoring(true);
|
||||
await mx.setIgnoredUsers(ignoredUsers);
|
||||
|
||||
if (isMountedRef.current === false) return;
|
||||
setIsUserIgnored(uIndex < 0);
|
||||
setIsIgnoring(false);
|
||||
} catch {
|
||||
setIsIgnoring(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="profile-viewer__buttons">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={openDM}
|
||||
disabled={isCreatingDM}
|
||||
>
|
||||
{isCreatingDM ? 'Creating room...' : 'Message'}
|
||||
</Button>
|
||||
<Button>Mention</Button>
|
||||
<Button
|
||||
variant={isUserIgnored ? 'positive' : 'danger'}
|
||||
onClick={toggleIgnore}
|
||||
disabled={isIgnoring}
|
||||
>
|
||||
{
|
||||
isUserIgnored
|
||||
? `${isIgnoring ? 'Unignoring...' : 'Unignore'}`
|
||||
: `${isIgnoring ? 'Ignoring...' : 'Ignore'}`
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ProfileFooter.propTypes = {
|
||||
userId: PropTypes.string.isRequired,
|
||||
onRequestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function ProfileViewer() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [roomId, setRoomId] = useState(null);
|
||||
const [userId, setUserId] = useState(null);
|
||||
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = roomId ? mx.getRoom(roomId) : null;
|
||||
let username = '';
|
||||
if (room !== null) {
|
||||
const roomMember = room.getMember(userId);
|
||||
if (roomMember) username = getUsernameOfRoomMember(roomMember);
|
||||
else username = getUsername(userId);
|
||||
}
|
||||
|
||||
function loadProfile(uId, rId) {
|
||||
setIsOpen(true);
|
||||
setUserId(uId);
|
||||
setRoomId(rId);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
navigation.on(cons.events.navigation.PROFILE_VIEWER_OPENED, loadProfile);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.PROFILE_VIEWER_OPENED, loadProfile);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) return;
|
||||
setUserId(null);
|
||||
setRoomId(null);
|
||||
}, [isOpen]);
|
||||
|
||||
function renderProfile() {
|
||||
const member = room.getMember(userId) || mx.getUser(userId);
|
||||
const avatarMxc = member.getMxcAvatarUrl() || member.avatarUrl;
|
||||
|
||||
return (
|
||||
<div className="profile-viewer">
|
||||
<div className="profile-viewer__user">
|
||||
<Avatar
|
||||
imageSrc={!avatarMxc ? null : mx.mxcUrlToHttp(avatarMxc, 80, 80, 'crop')}
|
||||
text={username.slice(0, 1)}
|
||||
bgColor={colorMXID(userId)}
|
||||
size="large"
|
||||
/>
|
||||
<div className="profile-viewer__user__info">
|
||||
<Text variant="s1">{username}</Text>
|
||||
<Text variant="b2">{userId}</Text>
|
||||
</div>
|
||||
<div className="profile-viewer__user__role">
|
||||
<Text variant="b3">Role</Text>
|
||||
<Button iconSrc={ChevronBottomIC}>{getPowerLabel(member.powerLevel) || 'Member'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SessionInfo userId={userId} />
|
||||
{ userId !== mx.getUserId() && (
|
||||
<ProfileFooter
|
||||
userId={userId}
|
||||
onRequestClose={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="profile-viewer__dialog"
|
||||
isOpen={isOpen}
|
||||
title={`${username} in ${room?.name ?? ''}`}
|
||||
onRequestClose={() => setIsOpen(false)}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={() => setIsOpen(false)} tooltip="Close" />}
|
||||
>
|
||||
{isOpen && renderProfile()}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileViewer;
|
89
src/app/organisms/profile-viewer/ProfileViewer.scss
Normal file
89
src/app/organisms/profile-viewer/ProfileViewer.scss
Normal file
|
@ -0,0 +1,89 @@
|
|||
.profile-viewer__dialog {
|
||||
& .dialog__content__wrapper {
|
||||
position: relative;
|
||||
}
|
||||
& .dialog__content-container {
|
||||
padding: var(--sp-normal);
|
||||
padding-bottom: 89px;
|
||||
padding-right: var(--sp-extra-tight);
|
||||
[dir=rtl] & {
|
||||
padding-right: var(--sp-normal);
|
||||
padding-left: var(--sp-extra-tight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-viewer {
|
||||
&__user {
|
||||
display: flex;
|
||||
padding-bottom: var(--sp-normal);
|
||||
border-bottom: 1px solid var(--bg-surface-border);
|
||||
|
||||
&__info {
|
||||
align-self: end;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
margin: 0 var(--sp-normal);
|
||||
|
||||
& .text-s1 {
|
||||
font-weight: 500;
|
||||
}
|
||||
& .text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
&__role {
|
||||
align-self: end;
|
||||
& > .text {
|
||||
margin-bottom: var(--sp-ultra-tight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .session-info {
|
||||
margin-top: var(--sp-normal);
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
|
||||
width: 100%;
|
||||
padding: var(--sp-normal);
|
||||
background-color: var(--bg-surface);
|
||||
border-top: 1px solid var(--bg-surface-border);
|
||||
display: flex;
|
||||
|
||||
& > *:nth-child(2n) {
|
||||
margin: 0 var(--sp-normal)
|
||||
}
|
||||
& > *:last-child {
|
||||
margin-left: auto;
|
||||
[dir=rtl] & {
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-info {
|
||||
& .setting-tile__title .text {
|
||||
color: var(--tc-surface-high);
|
||||
}
|
||||
&__chips {
|
||||
padding-top: var(--sp-ultra-tight);
|
||||
& .chip {
|
||||
margin: {
|
||||
top: var(--sp-extra-tight);
|
||||
right: var(--sp-extra-tight);
|
||||
}
|
||||
[dir=rtl] & {
|
||||
margin: 0 0 var(--sp-extra-tight) var(--sp-extra-tight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,11 +1,13 @@
|
|||
import React from 'react';
|
||||
|
||||
import ReadReceipts from '../read-receipts/ReadReceipts';
|
||||
import ProfileViewer from '../profile-viewer/ProfileViewer';
|
||||
|
||||
function Dialogs() {
|
||||
return (
|
||||
<>
|
||||
<ReadReceipts />
|
||||
<ProfileViewer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ 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);
|
||||
|
@ -58,7 +59,10 @@ function ReadReceipts() {
|
|||
return (
|
||||
<PeopleSelector
|
||||
key={receipt.userId}
|
||||
onClick={() => alert('Viewing profile is yet to be implemented')}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
openProfileViewer(receipt.userId, roomId);
|
||||
}}
|
||||
avatarSrc={member?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')}
|
||||
name={getUserDisplayName(receipt.userId)}
|
||||
color={colorMXID(receipt.userId)}
|
||||
|
|
|
@ -1,29 +1,29 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, {
|
||||
useState, useEffect, useCallback, useRef,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './PeopleDrawer.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { getUsernameOfRoomMember } from '../../../util/matrixUtil';
|
||||
import { getPowerLabel, getUsernameOfRoomMember } from '../../../util/matrixUtil';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { openInviteUser } from '../../../client/action/navigation';
|
||||
import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
|
||||
import AsyncSearch from '../../../util/AsyncSearch';
|
||||
|
||||
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 getPowerLabel(powerLevel) {
|
||||
if (powerLevel > 9000) return 'Goku';
|
||||
if (powerLevel > 100) return 'Founder';
|
||||
if (powerLevel === 100) return 'Admin';
|
||||
if (powerLevel >= 50) return 'Mod';
|
||||
return null;
|
||||
}
|
||||
function AtoZ(m1, m2) {
|
||||
const aName = m1.name;
|
||||
const bName = m2.name;
|
||||
|
@ -44,31 +44,97 @@ function sortByPowerLevel(m1, m2) {
|
|||
if (pl1 < pl2) return 1;
|
||||
return 0;
|
||||
}
|
||||
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 room = initMatrix.matrixClient.getRoom(roomId);
|
||||
const totalMemberList = room.getJoinedMembers().sort(AtoZ).sort(sortByPowerLevel);
|
||||
const [memberList, updateMemberList] = useState([]);
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(roomId);
|
||||
let isRoomChanged = false;
|
||||
|
||||
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() {
|
||||
updateMemberList(totalMemberList.slice(0, memberList.length + PER_PAGE_MEMBER));
|
||||
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(() => {
|
||||
updateMemberList(totalMemberList.slice(0, PER_PAGE_MEMBER));
|
||||
asyncSearch.setup(memberList, {
|
||||
keys: ['name', 'username', 'userId'],
|
||||
limit: PER_PAGE_MEMBER,
|
||||
});
|
||||
}, [memberList]);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current.value = '';
|
||||
setMemberList(
|
||||
simplyfiMembers(
|
||||
getMembersWithMembership(membership)
|
||||
.sort(AtoZ).sort(sortByPowerLevel),
|
||||
),
|
||||
);
|
||||
room.loadMembersIfNeeded().then(() => {
|
||||
if (isRoomChanged) return;
|
||||
const newTotalMemberList = room.getJoinedMembers().sort(AtoZ).sort(sortByPowerLevel);
|
||||
updateMemberList(newTotalMemberList.slice(0, PER_PAGE_MEMBER));
|
||||
setMemberList(
|
||||
simplyfiMembers(
|
||||
getMembersWithMembership(membership)
|
||||
.sort(AtoZ).sort(sortByPowerLevel),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchData);
|
||||
return () => {
|
||||
isRoomChanged = true;
|
||||
setMemberList([]);
|
||||
setSearchedMembers(null);
|
||||
setItemCount(PER_PAGE_MEMBER);
|
||||
asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchData);
|
||||
};
|
||||
}, [roomId, membership]);
|
||||
|
||||
useEffect(() => {
|
||||
setMembership('join');
|
||||
}, [roomId]);
|
||||
|
||||
const mList = searchedMembers !== null ? searchedMembers.data : memberList.slice(0, itemCount);
|
||||
return (
|
||||
<div className="people-drawer">
|
||||
<Header>
|
||||
|
@ -84,21 +150,53 @@ function PeopleDrawer({ roomId }) {
|
|||
<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]?.();
|
||||
}}
|
||||
/>
|
||||
{
|
||||
memberList.map((member) => (
|
||||
mList.map((member) => (
|
||||
<PeopleSelector
|
||||
key={member.userId}
|
||||
onClick={() => alert('Viewing profile is yet to be implemented')}
|
||||
avatarSrc={member.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')}
|
||||
name={getUsernameOfRoomMember(member)}
|
||||
onClick={() => openProfileViewer(member.userId, roomId)}
|
||||
avatarSrc={member.avatarSrc}
|
||||
name={member.name}
|
||||
color={colorMXID(member.userId)}
|
||||
peopleRole={getPowerLabel(member.powerLevel)}
|
||||
peopleRole={member.peopleRole}
|
||||
/>
|
||||
))
|
||||
}
|
||||
{
|
||||
(searchedMembers?.data.length === 0 || memberList.length === 0)
|
||||
&& (
|
||||
<div className="people-drawer__noresult">
|
||||
<Text variant="b2">No result found!</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="people-drawer__load-more">
|
||||
{
|
||||
memberList.length !== totalMemberList.length && (
|
||||
mList.length !== 0
|
||||
&& memberList.length > itemCount
|
||||
&& searchedMembers === null
|
||||
&& (
|
||||
<Button onClick={loadMorePeople}>View more</Button>
|
||||
)
|
||||
}
|
||||
|
@ -108,7 +206,12 @@ function PeopleDrawer({ roomId }) {
|
|||
</div>
|
||||
<div className="people-drawer__sticky">
|
||||
<form onSubmit={(e) => e.preventDefault()} className="people-search">
|
||||
<Input type="text" placeholder="Search" required />
|
||||
<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>
|
||||
|
|
|
@ -35,19 +35,48 @@
|
|||
@extend .people-drawer-flexItem;
|
||||
}
|
||||
|
||||
&__sticky {
|
||||
display: none;
|
||||
&__noresult {
|
||||
padding: var(--sp-extra-tight) var(--sp-normal);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__sticky {
|
||||
& .people-search {
|
||||
min-height: 48px;
|
||||
--search-input-height: 40px;
|
||||
min-height: var(--search-input-height);
|
||||
|
||||
margin: 0 var(--sp-normal);
|
||||
|
||||
position: relative;
|
||||
bottom: var(--sp-normal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& > .ic-raw,
|
||||
& > .ic-btn {
|
||||
position: absolute;
|
||||
z-index: 99;
|
||||
}
|
||||
& > .ic-raw {
|
||||
left: var(--sp-tight);
|
||||
[dir=rtl] & {
|
||||
right: var(--sp-tight);
|
||||
left: unset;
|
||||
}
|
||||
}
|
||||
& > .ic-btn {
|
||||
right: 2px;
|
||||
[dir=rtl] & {
|
||||
left: 2px;
|
||||
right: unset;
|
||||
}
|
||||
}
|
||||
& .input-container {
|
||||
flex: 1;
|
||||
}
|
||||
& .input {
|
||||
height: 48px;
|
||||
padding: 0 calc(var(--sp-loose) + var(--sp-normal));
|
||||
height: var(--search-input-height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -55,7 +84,21 @@
|
|||
|
||||
.people-drawer__content {
|
||||
padding-top: var(--sp-extra-tight);
|
||||
padding-bottom: calc( var(--sp-extra-tight) + var(--sp-normal));
|
||||
padding-bottom: calc(2 * var(--sp-normal));
|
||||
|
||||
& .segmented-controls {
|
||||
display: flex;
|
||||
margin-bottom: var(--sp-extra-tight);
|
||||
margin-left: var(--sp-extra-tight);
|
||||
[dir=rtl] & {
|
||||
margin-left: unset;
|
||||
margin-right: var(--sp-extra-tight);
|
||||
}
|
||||
}
|
||||
& .segment-btn {
|
||||
flex: 1;
|
||||
padding: var(--sp-ultra-tight) 0;
|
||||
}
|
||||
}
|
||||
.people-drawer__load-more {
|
||||
padding: var(--sp-normal);
|
||||
|
|
|
@ -319,6 +319,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
|
|||
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';
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -343,7 +344,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
|
|||
'>*': () => asyncSearch.setup(getRooms([...roomList.spaces]), { keys: ['name'], limit: 20 }),
|
||||
'>#': () => asyncSearch.setup(getRooms([...roomList.rooms]), { keys: ['name'], limit: 20 }),
|
||||
'>@': () => asyncSearch.setup(getRooms([...roomList.directs]), { keys: ['name'], limit: 20 }),
|
||||
':': () => asyncSearch.setup(emojis, { keys: ['shortcode'], limit: 20 }),
|
||||
':': () => asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 }),
|
||||
'@': () => asyncSearch.setup(matrixClient.getRoom(roomId).getJoinedMembers().map((member) => ({
|
||||
name: member.name,
|
||||
userId: member.userId.slice(1),
|
||||
|
|
|
@ -11,7 +11,7 @@ import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
|
|||
import { getUsername, getUsernameOfRoomMember, doesRoomHaveUnread } from '../../../util/matrixUtil';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { diffMinutes, isNotInSameDay, getEventCords } from '../../../util/common';
|
||||
import { openEmojiBoard, openReadReceipts } from '../../../client/action/navigation';
|
||||
import { openEmojiBoard, openProfileViewer, openReadReceipts } from '../../../client/action/navigation';
|
||||
|
||||
import Divider from '../../atoms/divider/Divider';
|
||||
import Avatar from '../../atoms/avatar/Avatar';
|
||||
|
@ -353,12 +353,14 @@ function RoomViewContent({
|
|||
|
||||
const senderMXIDColor = colorMXID(mEvent.sender.userId);
|
||||
const userAvatar = isContentOnly ? null : (
|
||||
<Avatar
|
||||
imageSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
|
||||
text={getUsernameOfRoomMember(mEvent.sender).slice(0, 1)}
|
||||
bgColor={senderMXIDColor}
|
||||
size="small"
|
||||
/>
|
||||
<button type="button" onClick={() => openProfileViewer(mEvent.sender.userId, roomId)}>
|
||||
<Avatar
|
||||
imageSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
|
||||
text={getUsernameOfRoomMember(mEvent.sender).slice(0, 1)}
|
||||
bgColor={senderMXIDColor}
|
||||
size="small"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
const userHeader = isContentOnly ? null : (
|
||||
<MessageHeader
|
||||
|
|
|
@ -313,6 +313,7 @@ function RoomViewInput({
|
|||
|
||||
function addEmoji(emoji) {
|
||||
textAreaRef.current.value += emoji.unicode;
|
||||
textAreaRef.current.focus();
|
||||
}
|
||||
|
||||
function handleUploadClick() {
|
||||
|
@ -327,8 +328,7 @@ function RoomViewInput({
|
|||
if (file !== null) roomsInput.setAttachment(roomId, file);
|
||||
}
|
||||
|
||||
const myPowerlevel = roomTimeline.room.getMember(mx.getUserId()).powerLevel;
|
||||
const canISend = roomTimeline.room.currentState.hasSufficientPowerLevelFor('events_default', myPowerlevel);
|
||||
const canISend = roomTimeline.room.currentState.maySendMessage(mx.getUserId());
|
||||
|
||||
function renderInputs() {
|
||||
if (!canISend) {
|
||||
|
|
|
@ -104,13 +104,13 @@ function AboutSection() {
|
|||
<div>
|
||||
<Text variant="h2">
|
||||
Cinny
|
||||
<span className="text text-b3" style={{ margin: '0 var(--sp-extra-tight)' }}>v1.3.2</span>
|
||||
<span className="text text-b3" style={{ margin: '0 var(--sp-extra-tight)' }}>v1.4.0</span>
|
||||
</Text>
|
||||
<Text>Yet another matrix client</Text>
|
||||
|
||||
<div className="set-about__btns">
|
||||
<Button onClick={() => window.open('https://github.com/ajbura/cinny')}>Source code</Button>
|
||||
<Button onClick={() => window.open('https://liberapay.com/ajbura/donate')}>Support</Button>
|
||||
<Button onClick={() => window.open('https://cinny.in/#sponsor')}>Support</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
BrowserRouter, Switch, Route, Redirect,
|
||||
BrowserRouter,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { isAuthenticated } from '../../client/state/auth';
|
||||
|
@ -11,17 +11,7 @@ import Client from '../templates/client/Client';
|
|||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
{ isAuthenticated() ? <Client /> : <Redirect to="/login" />}
|
||||
</Route>
|
||||
<Route path="/login">
|
||||
{ isAuthenticated() ? <Redirect to="/" /> : <Auth type="login" />}
|
||||
</Route>
|
||||
<Route path="/register">
|
||||
{ isAuthenticated() ? <Redirect to="/" /> : <Auth type="register" />}
|
||||
</Route>
|
||||
</Switch>
|
||||
{ isAuthenticated() ? <Client /> : <Auth />}
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,8 +3,9 @@ import PropTypes from 'prop-types';
|
|||
import './Auth.scss';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import * as auth from '../../../client/action/auth';
|
||||
import cons from '../../../client/state/cons';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
|
@ -15,6 +16,7 @@ import ScrollView from '../../atoms/scroll/ScrollView';
|
|||
|
||||
import EyeIC from '../../../../public/res/ic/outlined/eye.svg';
|
||||
import CinnySvg from '../../../../public/res/svg/cinny.svg';
|
||||
import SSOButtons from '../../molecules/sso-buttons/SSOButtons';
|
||||
|
||||
// This regex validates historical usernames, which don't satisfy today's username requirements.
|
||||
// See https://matrix.org/docs/spec/appendices#id13 for more info.
|
||||
|
@ -73,14 +75,39 @@ function normalizeUsername(rawUsername) {
|
|||
return noLeadingAt.trim();
|
||||
}
|
||||
|
||||
function Auth({ type }) {
|
||||
function Auth() {
|
||||
const [type, setType] = useState('login');
|
||||
const [process, changeProcess] = useState(null);
|
||||
const [homeserver, changeHomeserver] = useState('matrix.org');
|
||||
|
||||
const usernameRef = useRef(null);
|
||||
const homeserverRef = useRef(null);
|
||||
const passwordRef = useRef(null);
|
||||
const confirmPasswordRef = useRef(null);
|
||||
const emailRef = useRef(null);
|
||||
|
||||
const { search } = useLocation();
|
||||
const searchParams = new URLSearchParams(search);
|
||||
if (searchParams.has('loginToken')) {
|
||||
const loginToken = searchParams.get('loginToken');
|
||||
if (loginToken !== undefined) {
|
||||
if (localStorage.getItem(cons.secretKey.BASE_URL) !== undefined) {
|
||||
const baseUrl = localStorage.getItem(cons.secretKey.BASE_URL);
|
||||
auth.loginWithToken(baseUrl, loginToken)
|
||||
.then(() => {
|
||||
const { href } = window.location;
|
||||
window.location.replace(href.slice(0, href.indexOf('?')));
|
||||
})
|
||||
.catch((error) => {
|
||||
changeProcess(null);
|
||||
if (!error.contains('CORS request rejected')) {
|
||||
renderErrorMessage(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function register(recaptchaValue, terms, verified) {
|
||||
auth.register(
|
||||
usernameRef.current.value,
|
||||
|
@ -205,6 +232,7 @@ function Auth({ type }) {
|
|||
/>
|
||||
<Input
|
||||
forwardRef={homeserverRef}
|
||||
onChange={(e) => changeHomeserver(e.target.value)}
|
||||
id="auth_homeserver"
|
||||
placeholder="Homeserver"
|
||||
value="matrix.org"
|
||||
|
@ -281,26 +309,35 @@ function Auth({ type }) {
|
|||
{type === 'login' ? 'Login' : 'Register' }
|
||||
</Button>
|
||||
</div>
|
||||
{type === 'login' && (
|
||||
<SSOButtons homeserver={homeserver} />
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex--center">
|
||||
<div style={{ flexDirection: 'column' }} className="flex--center">
|
||||
<Text variant="b2">
|
||||
{`${(type === 'login' ? 'Don\'t have' : 'Already have')} an account?`}
|
||||
<Link to={type === 'login' ? '/register' : '/login'}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ color: 'var(--tc-link)', cursor: 'pointer', margin: '0 var(--sp-ultra-tight)' }}
|
||||
onClick={() => {
|
||||
if (type === 'login') setType('register');
|
||||
else setType('login');
|
||||
}}
|
||||
>
|
||||
{ type === 'login' ? ' Register' : ' Login' }
|
||||
</Link>
|
||||
</button>
|
||||
</Text>
|
||||
<span style={{ marginTop: 'var(--sp-extra-tight)' }}>
|
||||
<Text variant="b3">v1.4.0</Text>
|
||||
</span>
|
||||
</div>
|
||||
</StaticWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Auth.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function StaticWrapper({ children }) {
|
||||
return (
|
||||
<ScrollView invisible>
|
||||
|
|
|
@ -2,7 +2,8 @@ import * as sdk from 'matrix-js-sdk';
|
|||
import cons from '../state/cons';
|
||||
import { getBaseUrl } from '../../util/matrixUtil';
|
||||
|
||||
async function login(username, homeserver, password) {
|
||||
// This method inspired by a similar one in matrix-react-sdk
|
||||
async function createTemporaryClient(homeserver) {
|
||||
let baseUrl = null;
|
||||
try {
|
||||
baseUrl = await getBaseUrl(homeserver);
|
||||
|
@ -12,7 +13,25 @@ async function login(username, homeserver, password) {
|
|||
|
||||
if (typeof baseUrl === 'undefined') throw new Error('Homeserver not found');
|
||||
|
||||
const client = sdk.createClient({ baseUrl });
|
||||
return sdk.createClient({ baseUrl });
|
||||
}
|
||||
|
||||
async function getLoginFlows(client) {
|
||||
const flows = await client.loginFlows();
|
||||
if (flows !== undefined) {
|
||||
return flows;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function startSsoLogin(homeserver, type, idpId) {
|
||||
const client = await createTemporaryClient(homeserver);
|
||||
localStorage.setItem(cons.secretKey.BASE_URL, client.baseUrl);
|
||||
window.location.href = client.getSsoLoginUrl(window.location.href, type, idpId);
|
||||
}
|
||||
|
||||
async function login(username, homeserver, password) {
|
||||
const client = await createTemporaryClient(homeserver);
|
||||
|
||||
const response = await client.login('m.login.password', {
|
||||
identifier: {
|
||||
|
@ -26,7 +45,21 @@ async function login(username, homeserver, password) {
|
|||
localStorage.setItem(cons.secretKey.ACCESS_TOKEN, response.access_token);
|
||||
localStorage.setItem(cons.secretKey.DEVICE_ID, response.device_id);
|
||||
localStorage.setItem(cons.secretKey.USER_ID, response.user_id);
|
||||
localStorage.setItem(cons.secretKey.BASE_URL, response?.well_known?.['m.homeserver']?.base_url || baseUrl);
|
||||
localStorage.setItem(cons.secretKey.BASE_URL, response?.well_known?.['m.homeserver']?.base_url || client.baseUrl);
|
||||
}
|
||||
|
||||
async function loginWithToken(baseUrl, token) {
|
||||
const client = sdk.createClient(baseUrl);
|
||||
|
||||
const response = await client.login('m.login.token', {
|
||||
token,
|
||||
initial_device_display_name: cons.DEVICE_DISPLAY_NAME,
|
||||
});
|
||||
|
||||
localStorage.setItem(cons.secretKey.ACCESS_TOKEN, response.access_token);
|
||||
localStorage.setItem(cons.secretKey.DEVICE_ID, response.device_id);
|
||||
localStorage.setItem(cons.secretKey.USER_ID, response.user_id);
|
||||
localStorage.setItem(cons.secretKey.BASE_URL, response?.well_known?.['m.homeserver']?.base_url || client.baseUrl);
|
||||
}
|
||||
|
||||
async function getAdditionalInfo(baseUrl, content) {
|
||||
|
@ -45,6 +78,7 @@ async function getAdditionalInfo(baseUrl, content) {
|
|||
throw new Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyEmail(baseUrl, content) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/_matrix/client/r0/register/email/requestToken`, {
|
||||
|
@ -149,4 +183,7 @@ async function register(username, homeserver, password, email, recaptchaValue, t
|
|||
return {};
|
||||
}
|
||||
|
||||
export { login, register };
|
||||
export {
|
||||
createTemporaryClient, getLoginFlows, login,
|
||||
loginWithToken, register, startSsoLogin,
|
||||
};
|
||||
|
|
|
@ -55,6 +55,14 @@ function openInviteUser(roomId, searchTerm) {
|
|||
});
|
||||
}
|
||||
|
||||
function openProfileViewer(userId, roomId) {
|
||||
appDispatcher.dispatch({
|
||||
type: cons.actions.navigation.OPEN_PROFILE_VIEWER,
|
||||
userId,
|
||||
roomId,
|
||||
});
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
appDispatcher.dispatch({
|
||||
type: cons.actions.navigation.OPEN_SETTINGS,
|
||||
|
@ -94,6 +102,7 @@ export {
|
|||
openPublicRooms,
|
||||
openCreateRoom,
|
||||
openInviteUser,
|
||||
openProfileViewer,
|
||||
openSettings,
|
||||
openEmojiBoard,
|
||||
openReadReceipts,
|
||||
|
|
|
@ -143,6 +143,7 @@ async function create(opts) {
|
|||
is_direct: opts.isDirect === true,
|
||||
invite: opts.invite || [],
|
||||
initial_state: [],
|
||||
preset: opts.isDirect === true ? 'trusted_private_chat' : undefined,
|
||||
};
|
||||
|
||||
if (opts.isPublic !== true && opts.isEncrypted === true) {
|
||||
|
|
|
@ -27,6 +27,7 @@ const cons = {
|
|||
OPEN_PUBLIC_ROOMS: 'OPEN_PUBLIC_ROOMS',
|
||||
OPEN_CREATE_ROOM: 'OPEN_CREATE_ROOM',
|
||||
OPEN_INVITE_USER: 'OPEN_INVITE_USER',
|
||||
OPEN_PROFILE_VIEWER: 'OPEN_PROFILE_VIEWER',
|
||||
OPEN_SETTINGS: 'OPEN_SETTINGS',
|
||||
OPEN_EMOJIBOARD: 'OPEN_EMOJIBOARD',
|
||||
OPEN_READRECEIPTS: 'OPEN_READRECEIPTS',
|
||||
|
@ -57,6 +58,7 @@ const cons = {
|
|||
CREATE_ROOM_OPENED: 'CREATE_ROOM_OPENED',
|
||||
INVITE_USER_OPENED: 'INVITE_USER_OPENED',
|
||||
SETTINGS_OPENED: 'SETTINGS_OPENED',
|
||||
PROFILE_VIEWER_OPENED: 'PROFILE_VIEWER_OPENED',
|
||||
EMOJIBOARD_OPENED: 'EMOJIBOARD_OPENED',
|
||||
READRECEIPTS_OPENED: 'READRECEIPTS_OPENED',
|
||||
ROOMOPTIONS_OPENED: 'ROOMOPTIONS_OPENED',
|
||||
|
|
|
@ -69,6 +69,9 @@ class Navigation extends EventEmitter {
|
|||
[cons.actions.navigation.OPEN_INVITE_USER]: () => {
|
||||
this.emit(cons.events.navigation.INVITE_USER_OPENED, action.roomId, action.searchTerm);
|
||||
},
|
||||
[cons.actions.navigation.OPEN_PROFILE_VIEWER]: () => {
|
||||
this.emit(cons.events.navigation.PROFILE_VIEWER_OPENED, action.userId, action.roomId);
|
||||
},
|
||||
[cons.actions.navigation.OPEN_SETTINGS]: () => {
|
||||
this.emit(cons.events.navigation.SETTINGS_OPENED);
|
||||
},
|
||||
|
|
|
@ -69,7 +69,15 @@ function doesRoomHaveUnread(room) {
|
|||
return true;
|
||||
}
|
||||
|
||||
function getPowerLabel(powerLevel) {
|
||||
if (powerLevel > 9000) return 'Goku';
|
||||
if (powerLevel > 100) return 'Founder';
|
||||
if (powerLevel === 100) return 'Admin';
|
||||
if (powerLevel >= 50) return 'Mod';
|
||||
return null;
|
||||
}
|
||||
|
||||
export {
|
||||
getBaseUrl, getUsername, getUsernameOfRoomMember,
|
||||
isRoomAliasAvailable, doesRoomHaveUnread,
|
||||
isRoomAliasAvailable, doesRoomHaveUnread, getPowerLabel,
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue