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>
This commit is contained in:
Gero Gerke 2021-10-18 17:25:52 +02:00 committed by Krishan
parent 4dfe40333e
commit 825db633a0
17 changed files with 425 additions and 32 deletions

4
.gitignore vendored
View file

@ -1,4 +1,6 @@
experiment experiment
dist dist
node_modules node_modules
devAssets devAssets
.DS_Store

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

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

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

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

@ -3,9 +3,9 @@ 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 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';
@ -17,13 +17,6 @@ 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';
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;
@ -88,7 +81,7 @@ function PeopleDrawer({ roomId }) {
memberList.map((member) => ( memberList.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.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')}
name={getUsernameOfRoomMember(member)} name={getUsernameOfRoomMember(member)}
color={colorMXID(member.userId)} color={colorMXID(member.userId)}

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 : (
<Avatar <button type="button" onClick={() => openProfileViewer(mEvent.sender.userId, roomId)}>
imageSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')} <Avatar
text={getUsernameOfRoomMember(mEvent.sender).slice(0, 1)} imageSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
bgColor={senderMXIDColor} text={getUsernameOfRoomMember(mEvent.sender).slice(0, 1)}
size="small" bgColor={senderMXIDColor}
/> size="small"
/>
</button>
); );
const userHeader = isContentOnly ? null : ( const userHeader = isContentOnly ? null : (
<MessageHeader <MessageHeader

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

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