Compare commits

...

26 commits

Author SHA1 Message Date
Ajay Bura
253e3eb855 v1.4.0: SSO login and profile viewer
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
54dd5a4edb Fix reaction selector doesn't focus msg input (#62)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
0bd22bee13 Fix message menu placement on large screen (#113)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
6703614c78 Fix wildcard matching in emojisearch (#121)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
32923a1c34 Fix profile picture inconsistency (#104, #147)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
1dc0f9288a UI improvement in SSO
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
2e62f103f2 SegmentedControl bug fixed
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
d0e9aea788 Add option to filter PeopleDrawer
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
e12a75e431 Enhanced people search UX
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
5ece37fffe Added button reset type.
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
e6f228b63e Fixed inconsistent search in emojiboard.
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
5018df11f1 Add search in People drawer
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
5f6667debf SSO login bug fixed
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
5b1d5d0326 Fix redirect on SSO login (#142), #27, #94
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
James Julich
012e933e43 Address 301 redirect issue and Safari regex issue. (#143)
* Address 301 redirect issue and Safari regex issue.

* Restored login redirect

as this doesn't not fix the sso redirect #143.

Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2021-10-25 17:59:57 +05:30
Gero Gerke
825db633a0 Implement Profile Viewer (#130)
* Implement Profile Viewer

Fixes #111

* Make user avatar in chat clickable

* design progress

* Refactored code

* progress

* Updated chip comp

Signed-off-by: Ajay Bura <ajbura@gmail.com>

* Refactored ProfileViewer comp

Signed-off-by: Ajay Bura <ajbura@gmail.com>

* Added msg functionality in ProfileViewer

Signed-off-by: Ajay Bura <ajbura@gmail.com>

* Added Ignore functionality in ProfileViewer

Signed-off-by: Ajay Bura <ajbura@gmail.com>

* Fixed Ignore btn bug

Signed-off-by: Ajay Bura <ajbura@gmail.com>

* Refectored ProfileViewer comp

Signed-off-by: Ajay Bura <ajbura@gmail.com>

Co-authored-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
4dfe40333e Update pull-request.yml 2021-10-25 17:59:57 +05:30
Ajay Bura
a34c35fbe3 Update pull-request.yml 2021-10-25 17:59:57 +05:30
Ajay Bura
8b4beccf82 Fixed deploy on PR 2021-10-25 17:59:57 +05:30
Ajay Bura
d067fe776d Update pull-request.yml 2021-10-25 17:59:57 +05:30
Ajay Bura
9fad8a1098 added action for pull request previews 2021-10-25 17:59:57 +05:30
jamesjulich
8a1b749667 Add support for SSO login. 2021-10-25 17:59:57 +05:30
kfiven
7b67e4a6e6 Fix unable to send msg in DM from IRC users (#135) 2021-10-25 17:59:57 +05:30
Ajay Bura
824a7f2095 Fix make both user admin on DM create
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
b4d4cefe23 Updated support link
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Krishan
bdb94d7145
Merge pull request #131 from ajbura/dev
v1.3.2
2021-10-06 13:22:18 +05:30
36 changed files with 924 additions and 97 deletions

30
.github/workflows/pull-request.yml vendored Normal file
View 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
View file

@ -2,3 +2,5 @@ experiment
dist dist
node_modules node_modules
devAssets devAssets
.DS_Store

2
package-lock.json generated
View file

@ -1,6 +1,6 @@
{ {
"name": "cinny", "name": "cinny",
"version": "1.3.2", "version": "1.4.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

View file

@ -1,6 +1,6 @@
{ {
"name": "cinny", "name": "cinny",
"version": "1.3.2", "version": "1.4.0",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
"engines": { "engines": {

View 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

View file

@ -24,7 +24,6 @@
height: var(--av-extra-small); height: var(--av-extra-small);
} }
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;

View file

@ -17,7 +17,8 @@ function Button({
className={`${className ? `${className} ` : ''}btn-${variant} ${iconClass} noselect`} className={`${className ? `${className} ` : ''}btn-${variant} ${iconClass} noselect`}
onMouseUp={(e) => blurOnBubbling(e, `.btn-${variant}`)} onMouseUp={(e) => blurOnBubbling(e, `.btn-${variant}`)}
onClick={onClick} onClick={onClick}
type={type === 'button' ? 'button' : 'submit'} // eslint-disable-next-line react/button-has-type
type={type}
disabled={disabled} disabled={disabled}
> >
{iconSrc !== null && <RawIcon size="small" src={iconSrc} />} {iconSrc !== null && <RawIcon size="small" src={iconSrc} />}
@ -42,7 +43,7 @@ Button.propTypes = {
className: PropTypes.string, className: PropTypes.string,
variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']), variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']),
iconSrc: PropTypes.string, iconSrc: PropTypes.string,
type: PropTypes.oneOf(['button', 'submit']), type: PropTypes.oneOf(['button', 'submit', 'reset']),
onClick: PropTypes.func, onClick: PropTypes.func,
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
disabled: PropTypes.bool, disabled: PropTypes.bool,

View file

@ -17,7 +17,8 @@ const IconButton = React.forwardRef(({
className={`ic-btn ic-btn-${variant}`} className={`ic-btn ic-btn-${variant}`}
onMouseUp={(e) => blurOnBubbling(e, `.ic-btn-${variant}`)} onMouseUp={(e) => blurOnBubbling(e, `.ic-btn-${variant}`)}
onClick={onClick} onClick={onClick}
type={type === 'button' ? 'button' : 'submit'} // eslint-disable-next-line react/button-has-type
type={type}
> >
<RawIcon size={size} src={src} /> <RawIcon size={size} src={src} />
</button> </button>
@ -45,7 +46,7 @@ IconButton.defaultProps = {
IconButton.propTypes = { IconButton.propTypes = {
variant: PropTypes.oneOf(['surface', 'positive', 'caution', 'danger']), variant: PropTypes.oneOf(['surface', 'positive', 'caution', 'danger']),
size: PropTypes.oneOf(['normal', 'small', 'extra-small']), size: PropTypes.oneOf(['normal', 'small', 'extra-small']),
type: PropTypes.oneOf(['button', 'submit']), type: PropTypes.oneOf(['button', 'submit', 'reset']),
tooltip: PropTypes.string, tooltip: PropTypes.string,
tooltipPlacement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']), tooltipPlacement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
src: PropTypes.string.isRequired, src: PropTypes.string.isRequired,

View file

@ -7,13 +7,14 @@ import RawIcon from '../system-icons/RawIcon';
function Chip({ function Chip({
iconSrc, iconColor, text, children, iconSrc, iconColor, text, children,
onClick,
}) { }) {
return ( return (
<div className="chip"> <button className="chip" type="button" onClick={onClick}>
{iconSrc != null && <RawIcon src={iconSrc} color={iconColor} size="small" />} {iconSrc != null && <RawIcon src={iconSrc} color={iconColor} size="extra-small" />}
{(text != null && text !== '') && <Text variant="b2">{text}</Text>} {(text != null && text !== '') && <Text variant="b3">{text}</Text>}
{children} {children}
</div> </button>
); );
} }
@ -22,6 +23,7 @@ Chip.propTypes = {
iconColor: PropTypes.string, iconColor: PropTypes.string,
text: PropTypes.string, text: PropTypes.string,
children: PropTypes.element, children: PropTypes.element,
onClick: PropTypes.func,
}; };
Chip.defaultProps = { Chip.defaultProps = {
@ -29,6 +31,7 @@ Chip.defaultProps = {
iconColor: null, iconColor: null,
text: null, text: null,
children: null, children: null,
onClick: null,
}; };
export default Chip; export default Chip;

View file

@ -7,13 +7,27 @@
background: var(--bg-surface-low); background: var(--bg-surface-low);
border-radius: var(--bo-radius); 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 { & > .ic-raw {
margin-right: var(--sp-extra-tight); width: 16px;
height: 16px;
margin-right: var(--sp-ultra-tight);
[dir=rtl] & { [dir=rtl] & {
margin-right: 0; margin-right: 0;
margin-left: var(--sp-extra-tight); margin-left: var(--sp-ultra-tight);
} }
} }
} }

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './SegmentedControls.scss'; import './SegmentedControls.scss';
@ -17,6 +17,10 @@ function SegmentedControls({
onSelect(segmentIndex); onSelect(segmentIndex);
} }
useEffect(() => {
setSelect(selected);
}, [selected]);
return ( return (
<div className="segmented-controls"> <div className="segmented-controls">
{ {

View file

@ -22,11 +22,12 @@
&__avatar-container { &__avatar-container {
padding-top: 6px; padding-top: 6px;
}
&__avatar-container{
margin-right: var(--sp-tight); margin-right: var(--sp-tight);
& button {
cursor: pointer;
}
[dir=rtl] & { [dir=rtl] & {
margin: { margin: {
left: var(--sp-tight); left: var(--sp-tight);
@ -261,6 +262,16 @@
right: unset; right: unset;
} }
} }
@media (min-width: 1620px) {
.message__options {
right: unset;
left: 770px;
[dir=rtl] {
left: unset;
right: 770px
}
}
}
// markdown formating // markdown formating
.message__content { .message__content {

View file

@ -28,7 +28,7 @@ function RoomIntro({
} }
RoomIntro.defaultProps = { RoomIntro.defaultProps = {
avatarSrc: false, avatarSrc: null,
time: null, time: null,
}; };

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

View 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);
}
}
}

View file

@ -80,17 +80,17 @@ EmojiGroup.propTypes = {
}; };
const asyncSearch = new AsyncSearch(); const asyncSearch = new AsyncSearch();
asyncSearch.setup(emojis, { keys: ['shortcode'], limit: 30 }); asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 40 });
function SearchedEmoji() { function SearchedEmoji() {
const [searchedEmojis, setSearchedEmojis] = useState(null); const [searchedEmojis, setSearchedEmojis] = useState(null);
function handleSearchEmoji(resultEmojis, term) { function handleSearchEmoji(resultEmojis, term) {
if (term === '' || resultEmojis.length === 0) { if (term === '' || resultEmojis.length === 0) {
if (term === '') setSearchedEmojis(null); if (term === '') setSearchedEmojis(null);
else setSearchedEmojis([]); else setSearchedEmojis({ emojis: [] });
return; return;
} }
setSearchedEmojis(resultEmojis); setSearchedEmojis({ emojis: resultEmojis });
} }
useEffect(() => { useEffect(() => {
@ -102,7 +102,7 @@ function SearchedEmoji() {
if (searchedEmojis === null) return false; 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 }) { function EmojiBoard({ onSelect }) {

View file

@ -24,6 +24,28 @@ import PowerIC from '../../../../public/res/ic/outlined/power.svg';
function ProfileAvatarMenu() { function ProfileAvatarMenu() {
const mx = initMatrix.matrixClient; 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 ( return (
<ContextMenu <ContextMenu
@ -45,10 +67,10 @@ function ProfileAvatarMenu() {
render={(toggleMenu) => ( render={(toggleMenu) => (
<SidebarAvatar <SidebarAvatar
onClick={toggleMenu} onClick={toggleMenu}
tooltip={mx.getUser(mx.getUserId()).displayName} tooltip={profile.displayName}
imageSrc={mx.getUser(mx.getUserId()).avatarUrl !== null ? mx.mxcUrlToHttp(mx.getUser(mx.getUserId()).avatarUrl, 42, 42, 'crop') : null} imageSrc={profile.avatarUrl !== null ? mx.mxcUrlToHttp(profile.avatarUrl, 42, 42, 'crop') : null}
bgColor={colorMXID(mx.getUserId())} bgColor={colorMXID(mx.getUserId())}
text={mx.getUser(mx.getUserId()).displayName.slice(0, 1)} text={profile.displayName.slice(0, 1)}
/> />
)} )}
/> />

View file

@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
@ -17,11 +17,17 @@ function ProfileEditor({
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
const displayNameRef = useRef(null); const displayNameRef = useRef(null);
const bgColor = colorMXID(userId); 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); const [disabled, setDisabled] = useState(true);
let username = mx.getUser(mx.getUserId()).displayName; 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 // Sets avatar URL and updates the avatar component in profile editor to reflect new upload
function handleAvatarUpload(url) { function handleAvatarUpload(url) {
if (url === null) { if (url === null) {

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

View 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);
}
}
}
}

View file

@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import ReadReceipts from '../read-receipts/ReadReceipts'; import ReadReceipts from '../read-receipts/ReadReceipts';
import ProfileViewer from '../profile-viewer/ProfileViewer';
function Dialogs() { function Dialogs() {
return ( return (
<> <>
<ReadReceipts /> <ReadReceipts />
<ProfileViewer />
</> </>
); );
} }

View file

@ -11,6 +11,7 @@ import PeopleSelector from '../../molecules/people-selector/PeopleSelector';
import Dialog from '../../molecules/dialog/Dialog'; import Dialog from '../../molecules/dialog/Dialog';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import { openProfileViewer } from '../../../client/action/navigation';
function ReadReceipts() { function ReadReceipts() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -58,7 +59,10 @@ function ReadReceipts() {
return ( return (
<PeopleSelector <PeopleSelector
key={receipt.userId} 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')} avatarSrc={member?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')}
name={getUserDisplayName(receipt.userId)} name={getUserDisplayName(receipt.userId)}
color={colorMXID(receipt.userId)} color={colorMXID(receipt.userId)}

View file

@ -1,29 +1,29 @@
import React, { useState, useEffect } from 'react'; import React, {
useState, useEffect, useCallback, useRef,
} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './PeopleDrawer.scss'; import './PeopleDrawer.scss';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import { getUsernameOfRoomMember } from '../../../util/matrixUtil'; import { getPowerLabel, getUsernameOfRoomMember } from '../../../util/matrixUtil';
import colorMXID from '../../../util/colorMXID'; 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 Text from '../../atoms/text/Text';
import Header, { TitleWrapper } from '../../atoms/header/Header'; import Header, { TitleWrapper } from '../../atoms/header/Header';
import RawIcon from '../../atoms/system-icons/RawIcon';
import IconButton from '../../atoms/button/IconButton'; import IconButton from '../../atoms/button/IconButton';
import Button from '../../atoms/button/Button'; import Button from '../../atoms/button/Button';
import ScrollView from '../../atoms/scroll/ScrollView'; import ScrollView from '../../atoms/scroll/ScrollView';
import Input from '../../atoms/input/Input'; import Input from '../../atoms/input/Input';
import SegmentedControl from '../../atoms/segmented-controls/SegmentedControls';
import PeopleSelector from '../../molecules/people-selector/PeopleSelector'; import PeopleSelector from '../../molecules/people-selector/PeopleSelector';
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg'; 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) { function AtoZ(m1, m2) {
const aName = m1.name; const aName = m1.name;
const bName = m2.name; const bName = m2.name;
@ -44,31 +44,97 @@ function sortByPowerLevel(m1, m2) {
if (pl1 < pl2) return 1; if (pl1 < pl2) return 1;
return 0; 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 }) { function PeopleDrawer({ roomId }) {
const PER_PAGE_MEMBER = 50; const PER_PAGE_MEMBER = 50;
const room = initMatrix.matrixClient.getRoom(roomId); const mx = initMatrix.matrixClient;
const totalMemberList = room.getJoinedMembers().sort(AtoZ).sort(sortByPowerLevel); const room = mx.getRoom(roomId);
const [memberList, updateMemberList] = useState([]);
let isRoomChanged = false; 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() { 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(() => { 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(() => { room.loadMembersIfNeeded().then(() => {
if (isRoomChanged) return; if (isRoomChanged) return;
const newTotalMemberList = room.getJoinedMembers().sort(AtoZ).sort(sortByPowerLevel); setMemberList(
updateMemberList(newTotalMemberList.slice(0, PER_PAGE_MEMBER)); simplyfiMembers(
getMembersWithMembership(membership)
.sort(AtoZ).sort(sortByPowerLevel),
),
);
}); });
asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchData);
return () => { return () => {
isRoomChanged = true; isRoomChanged = true;
setMemberList([]);
setSearchedMembers(null);
setItemCount(PER_PAGE_MEMBER);
asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchData);
}; };
}, [roomId, membership]);
useEffect(() => {
setMembership('join');
}, [roomId]); }, [roomId]);
const mList = searchedMembers !== null ? searchedMembers.data : memberList.slice(0, itemCount);
return ( return (
<div className="people-drawer"> <div className="people-drawer">
<Header> <Header>
@ -84,21 +150,53 @@ function PeopleDrawer({ roomId }) {
<div className="people-drawer__scrollable"> <div className="people-drawer__scrollable">
<ScrollView autoHide> <ScrollView autoHide>
<div className="people-drawer__content"> <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 <PeopleSelector
key={member.userId} key={member.userId}
onClick={() => alert('Viewing profile is yet to be implemented')} onClick={() => openProfileViewer(member.userId, roomId)}
avatarSrc={member.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')} avatarSrc={member.avatarSrc}
name={getUsernameOfRoomMember(member)} name={member.name}
color={colorMXID(member.userId)} 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"> <div className="people-drawer__load-more">
{ {
memberList.length !== totalMemberList.length && ( mList.length !== 0
&& memberList.length > itemCount
&& searchedMembers === null
&& (
<Button onClick={loadMorePeople}>View more</Button> <Button onClick={loadMorePeople}>View more</Button>
) )
} }
@ -108,7 +206,12 @@ function PeopleDrawer({ roomId }) {
</div> </div>
<div className="people-drawer__sticky"> <div className="people-drawer__sticky">
<form onSubmit={(e) => e.preventDefault()} className="people-search"> <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> </form>
</div> </div>
</div> </div>

View file

@ -35,19 +35,48 @@
@extend .people-drawer-flexItem; @extend .people-drawer-flexItem;
} }
&__sticky { &__noresult {
display: none; padding: var(--sp-extra-tight) var(--sp-normal);
text-align: center;
}
&__sticky {
& .people-search { & .people-search {
min-height: 48px; --search-input-height: 40px;
min-height: var(--search-input-height);
margin: 0 var(--sp-normal); margin: 0 var(--sp-normal);
position: relative; position: relative;
bottom: var(--sp-normal); 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 { & .input {
height: 48px; padding: 0 calc(var(--sp-loose) + var(--sp-normal));
height: var(--search-input-height);
} }
} }
} }
@ -55,7 +84,21 @@
.people-drawer__content { .people-drawer__content {
padding-top: var(--sp-extra-tight); 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 { .people-drawer__load-more {
padding: var(--sp-normal); padding: var(--sp-normal);

View file

@ -319,6 +319,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
else if (searchTerm.match(/^[-]?(\()$/)) searchTerm = 'pleading_face'; else if (searchTerm.match(/^[-]?(\()$/)) searchTerm = 'pleading_face';
else if (searchTerm.match(/^[-]?(\$)$/)) searchTerm = 'money'; else if (searchTerm.match(/^[-]?(\$)$/)) searchTerm = 'money';
else if (searchTerm.match(/^(<3)$/)) searchTerm = 'heart'; 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.spaces]), { keys: ['name'], limit: 20 }),
'>#': () => asyncSearch.setup(getRooms([...roomList.rooms]), { keys: ['name'], limit: 20 }), '>#': () => asyncSearch.setup(getRooms([...roomList.rooms]), { keys: ['name'], limit: 20 }),
'>@': () => asyncSearch.setup(getRooms([...roomList.directs]), { 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) => ({ '@': () => asyncSearch.setup(matrixClient.getRoom(roomId).getJoinedMembers().map((member) => ({
name: member.name, name: member.name,
userId: member.userId.slice(1), userId: member.userId.slice(1),

View file

@ -11,7 +11,7 @@ import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
import { getUsername, getUsernameOfRoomMember, doesRoomHaveUnread } from '../../../util/matrixUtil'; import { getUsername, getUsernameOfRoomMember, doesRoomHaveUnread } from '../../../util/matrixUtil';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { diffMinutes, isNotInSameDay, getEventCords } from '../../../util/common'; 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 Divider from '../../atoms/divider/Divider';
import Avatar from '../../atoms/avatar/Avatar'; import Avatar from '../../atoms/avatar/Avatar';
@ -353,12 +353,14 @@ function RoomViewContent({
const senderMXIDColor = colorMXID(mEvent.sender.userId); const senderMXIDColor = colorMXID(mEvent.sender.userId);
const userAvatar = isContentOnly ? null : ( const userAvatar = isContentOnly ? null : (
<button type="button" onClick={() => openProfileViewer(mEvent.sender.userId, roomId)}>
<Avatar <Avatar
imageSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')} imageSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
text={getUsernameOfRoomMember(mEvent.sender).slice(0, 1)} text={getUsernameOfRoomMember(mEvent.sender).slice(0, 1)}
bgColor={senderMXIDColor} bgColor={senderMXIDColor}
size="small" size="small"
/> />
</button>
); );
const userHeader = isContentOnly ? null : ( const userHeader = isContentOnly ? null : (
<MessageHeader <MessageHeader

View file

@ -313,6 +313,7 @@ function RoomViewInput({
function addEmoji(emoji) { function addEmoji(emoji) {
textAreaRef.current.value += emoji.unicode; textAreaRef.current.value += emoji.unicode;
textAreaRef.current.focus();
} }
function handleUploadClick() { function handleUploadClick() {
@ -327,8 +328,7 @@ function RoomViewInput({
if (file !== null) roomsInput.setAttachment(roomId, file); if (file !== null) roomsInput.setAttachment(roomId, file);
} }
const myPowerlevel = roomTimeline.room.getMember(mx.getUserId()).powerLevel; const canISend = roomTimeline.room.currentState.maySendMessage(mx.getUserId());
const canISend = roomTimeline.room.currentState.hasSufficientPowerLevelFor('events_default', myPowerlevel);
function renderInputs() { function renderInputs() {
if (!canISend) { if (!canISend) {

View file

@ -104,13 +104,13 @@ function AboutSection() {
<div> <div>
<Text variant="h2"> <Text variant="h2">
Cinny 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>
<Text>Yet another matrix client</Text> <Text>Yet another matrix client</Text>
<div className="set-about__btns"> <div className="set-about__btns">
<Button onClick={() => window.open('https://github.com/ajbura/cinny')}>Source code</Button> <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> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { import {
BrowserRouter, Switch, Route, Redirect, BrowserRouter,
} from 'react-router-dom'; } from 'react-router-dom';
import { isAuthenticated } from '../../client/state/auth'; import { isAuthenticated } from '../../client/state/auth';
@ -11,17 +11,7 @@ import Client from '../templates/client/Client';
function App() { function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Switch> { isAuthenticated() ? <Client /> : <Auth />}
<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>
</BrowserRouter> </BrowserRouter>
); );
} }

View file

@ -3,8 +3,9 @@ import PropTypes from 'prop-types';
import './Auth.scss'; import './Auth.scss';
import ReCAPTCHA from 'react-google-recaptcha'; 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 * as auth from '../../../client/action/auth';
import cons from '../../../client/state/cons';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button'; 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 EyeIC from '../../../../public/res/ic/outlined/eye.svg';
import CinnySvg from '../../../../public/res/svg/cinny.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. // This regex validates historical usernames, which don't satisfy today's username requirements.
// See https://matrix.org/docs/spec/appendices#id13 for more info. // See https://matrix.org/docs/spec/appendices#id13 for more info.
@ -73,14 +75,39 @@ function normalizeUsername(rawUsername) {
return noLeadingAt.trim(); return noLeadingAt.trim();
} }
function Auth({ type }) { function Auth() {
const [type, setType] = useState('login');
const [process, changeProcess] = useState(null); const [process, changeProcess] = useState(null);
const [homeserver, changeHomeserver] = useState('matrix.org');
const usernameRef = useRef(null); const usernameRef = useRef(null);
const homeserverRef = useRef(null); const homeserverRef = useRef(null);
const passwordRef = useRef(null); const passwordRef = useRef(null);
const confirmPasswordRef = useRef(null); const confirmPasswordRef = useRef(null);
const emailRef = 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) { function register(recaptchaValue, terms, verified) {
auth.register( auth.register(
usernameRef.current.value, usernameRef.current.value,
@ -205,6 +232,7 @@ function Auth({ type }) {
/> />
<Input <Input
forwardRef={homeserverRef} forwardRef={homeserverRef}
onChange={(e) => changeHomeserver(e.target.value)}
id="auth_homeserver" id="auth_homeserver"
placeholder="Homeserver" placeholder="Homeserver"
value="matrix.org" value="matrix.org"
@ -281,26 +309,35 @@ function Auth({ type }) {
{type === 'login' ? 'Login' : 'Register' } {type === 'login' ? 'Login' : 'Register' }
</Button> </Button>
</div> </div>
{type === 'login' && (
<SSOButtons homeserver={homeserver} />
)}
</form> </form>
</div> </div>
<div className="flex--center"> <div style={{ flexDirection: 'column' }} className="flex--center">
<Text variant="b2"> <Text variant="b2">
{`${(type === 'login' ? 'Don\'t have' : 'Already have')} an account?`} {`${(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' } { type === 'login' ? ' Register' : ' Login' }
</Link> </button>
</Text> </Text>
<span style={{ marginTop: 'var(--sp-extra-tight)' }}>
<Text variant="b3">v1.4.0</Text>
</span>
</div> </div>
</StaticWrapper> </StaticWrapper>
</> </>
); );
} }
Auth.propTypes = {
type: PropTypes.string.isRequired,
};
function StaticWrapper({ children }) { function StaticWrapper({ children }) {
return ( return (
<ScrollView invisible> <ScrollView invisible>

View file

@ -2,7 +2,8 @@ import * as sdk from 'matrix-js-sdk';
import cons from '../state/cons'; import cons from '../state/cons';
import { getBaseUrl } from '../../util/matrixUtil'; 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; let baseUrl = null;
try { try {
baseUrl = await getBaseUrl(homeserver); baseUrl = await getBaseUrl(homeserver);
@ -12,7 +13,25 @@ async function login(username, homeserver, password) {
if (typeof baseUrl === 'undefined') throw new Error('Homeserver not found'); 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', { const response = await client.login('m.login.password', {
identifier: { identifier: {
@ -26,7 +45,21 @@ async function login(username, homeserver, password) {
localStorage.setItem(cons.secretKey.ACCESS_TOKEN, response.access_token); localStorage.setItem(cons.secretKey.ACCESS_TOKEN, response.access_token);
localStorage.setItem(cons.secretKey.DEVICE_ID, response.device_id); localStorage.setItem(cons.secretKey.DEVICE_ID, response.device_id);
localStorage.setItem(cons.secretKey.USER_ID, response.user_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) { async function getAdditionalInfo(baseUrl, content) {
@ -45,6 +78,7 @@ async function getAdditionalInfo(baseUrl, content) {
throw new Error(e); throw new Error(e);
} }
} }
async function verifyEmail(baseUrl, content) { async function verifyEmail(baseUrl, content) {
try { try {
const res = await fetch(`${baseUrl}/_matrix/client/r0/register/email/requestToken`, { 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 {}; return {};
} }
export { login, register }; export {
createTemporaryClient, getLoginFlows, login,
loginWithToken, register, startSsoLogin,
};

View file

@ -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() { function openSettings() {
appDispatcher.dispatch({ appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_SETTINGS, type: cons.actions.navigation.OPEN_SETTINGS,
@ -94,6 +102,7 @@ export {
openPublicRooms, openPublicRooms,
openCreateRoom, openCreateRoom,
openInviteUser, openInviteUser,
openProfileViewer,
openSettings, openSettings,
openEmojiBoard, openEmojiBoard,
openReadReceipts, openReadReceipts,

View file

@ -143,6 +143,7 @@ async function create(opts) {
is_direct: opts.isDirect === true, is_direct: opts.isDirect === true,
invite: opts.invite || [], invite: opts.invite || [],
initial_state: [], initial_state: [],
preset: opts.isDirect === true ? 'trusted_private_chat' : undefined,
}; };
if (opts.isPublic !== true && opts.isEncrypted === true) { if (opts.isPublic !== true && opts.isEncrypted === true) {

View file

@ -27,6 +27,7 @@ const cons = {
OPEN_PUBLIC_ROOMS: 'OPEN_PUBLIC_ROOMS', OPEN_PUBLIC_ROOMS: 'OPEN_PUBLIC_ROOMS',
OPEN_CREATE_ROOM: 'OPEN_CREATE_ROOM', OPEN_CREATE_ROOM: 'OPEN_CREATE_ROOM',
OPEN_INVITE_USER: 'OPEN_INVITE_USER', OPEN_INVITE_USER: 'OPEN_INVITE_USER',
OPEN_PROFILE_VIEWER: 'OPEN_PROFILE_VIEWER',
OPEN_SETTINGS: 'OPEN_SETTINGS', OPEN_SETTINGS: 'OPEN_SETTINGS',
OPEN_EMOJIBOARD: 'OPEN_EMOJIBOARD', OPEN_EMOJIBOARD: 'OPEN_EMOJIBOARD',
OPEN_READRECEIPTS: 'OPEN_READRECEIPTS', OPEN_READRECEIPTS: 'OPEN_READRECEIPTS',
@ -57,6 +58,7 @@ const cons = {
CREATE_ROOM_OPENED: 'CREATE_ROOM_OPENED', CREATE_ROOM_OPENED: 'CREATE_ROOM_OPENED',
INVITE_USER_OPENED: 'INVITE_USER_OPENED', INVITE_USER_OPENED: 'INVITE_USER_OPENED',
SETTINGS_OPENED: 'SETTINGS_OPENED', SETTINGS_OPENED: 'SETTINGS_OPENED',
PROFILE_VIEWER_OPENED: 'PROFILE_VIEWER_OPENED',
EMOJIBOARD_OPENED: 'EMOJIBOARD_OPENED', EMOJIBOARD_OPENED: 'EMOJIBOARD_OPENED',
READRECEIPTS_OPENED: 'READRECEIPTS_OPENED', READRECEIPTS_OPENED: 'READRECEIPTS_OPENED',
ROOMOPTIONS_OPENED: 'ROOMOPTIONS_OPENED', ROOMOPTIONS_OPENED: 'ROOMOPTIONS_OPENED',

View file

@ -69,6 +69,9 @@ class Navigation extends EventEmitter {
[cons.actions.navigation.OPEN_INVITE_USER]: () => { [cons.actions.navigation.OPEN_INVITE_USER]: () => {
this.emit(cons.events.navigation.INVITE_USER_OPENED, action.roomId, action.searchTerm); 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]: () => { [cons.actions.navigation.OPEN_SETTINGS]: () => {
this.emit(cons.events.navigation.SETTINGS_OPENED); this.emit(cons.events.navigation.SETTINGS_OPENED);
}, },

View file

@ -69,7 +69,15 @@ function doesRoomHaveUnread(room) {
return true; 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 { export {
getBaseUrl, getUsername, getUsernameOfRoomMember, getBaseUrl, getUsername, getUsernameOfRoomMember,
isRoomAliasAvailable, doesRoomHaveUnread, isRoomAliasAvailable, doesRoomHaveUnread, getPowerLabel,
}; };