Merge branch 'ajbura:dev' into profile-editor

This commit is contained in:
James Julich 2021-09-12 22:41:42 -05:00 committed by GitHub
commit 95bb0ac6d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 813 additions and 186 deletions

View file

@ -1,9 +1,8 @@
name: Publish Docker image name: Publish Docker image
on: on:
push: release:
branches: types: [published]
- master
jobs: jobs:
push_to_registry: push_to_registry:

View file

@ -1,4 +1,4 @@
name: 'Deploy to Netlify' name: 'Deploy to Netlify (dev)'
on: on:
push: push:

View file

@ -1,9 +1,8 @@
name: 'Deploy to Netlify' name: 'Deploy to Netlify (prod)'
on: on:
push: release:
branches: types: [published]
- master
jobs: jobs:
deploy: deploy:

View file

@ -10,6 +10,8 @@
Cinny is a [Matrix](https://matrix.org) client focusing primarily on simple, elegant and secure interface. Cinny is a [Matrix](https://matrix.org) client focusing primarily on simple, elegant and secure interface.
![preview](https://github.com/ajbura/cinny-site/blob/master/assets/preview-light.png)
## Building and Running ## Building and Running
### Building from source ### Building from source

View file

@ -0,0 +1,12 @@
<?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">
<g>
<path d="M12,22c1.1,0,2-0.9,2-2h-4C10,21.1,10.9,22,12,22z"/>
<path d="M20.1,18.1L20.1,18.1L16,14L9.2,7.2L7.8,5.8L5.9,3.9L4.5,5.3l2.1,2.1C6.2,8.2,6,9.1,6,10v6H4v2h13.2l1.5,1.5L20.1,18.1z
M8,16v-6c0-0.4,0.1-0.7,0.1-1l7,7H8z"/>
<path d="M12,6c2.2,0,4,1.8,4,4v1.2l2,2V10c0-3-2.2-5.4-5-5.9V3h-2v1.1c-0.6,0.1-1.1,0.3-1.6,0.5L11,6.1C11.3,6.1,11.6,6,12,6z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 810 B

View file

@ -0,0 +1,13 @@
<?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">
<g>
<circle cx="17" cy="8" r="3"/>
<path d="M12,22c1.1,0,2-0.9,2-2h-4C10,21.1,10.9,22,12,22z"/>
<path d="M18,12.9C17.7,13,17.3,13,17,13s-0.7,0-1-0.1V16H8v-6c0-2.2,1.8-4,4-4c0.1,0,0.3,0,0.4,0c0.3-0.7,0.7-1.3,1.3-1.8
c-0.2-0.1-0.5-0.1-0.7-0.2V3h-2v1.1C8.2,4.6,6,7,6,10v6H4v2h16v-2h-2V12.9z"/>
<path d="M6.3,4.3L4.9,2.9C3.1,4.7,2,7.2,2,10h2C4,7.8,4.9,5.8,6.3,4.3z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 819 B

View file

@ -0,0 +1,12 @@
<?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">
<g>
<path d="M12,22c1.1,0,2-0.9,2-2h-4C10,21.1,10.9,22,12,22z"/>
<path d="M18,10c0-3-2.2-5.4-5-5.9V3h-2v1.1C8.2,4.6,6,7,6,10v6H4v2h16v-2h-2V10z M16,16H8v-6c0-2.2,1.8-4,4-4s4,1.8,4,4V16z"/>
<path d="M6.3,4.3L4.9,2.9C3.1,4.7,2,7.2,2,10h2C4,7.8,4.9,5.8,6.3,4.3z"/>
<path d="M19.1,2.9l-1.4,1.4C19.1,5.8,20,7.8,20,10h2C22,7.2,20.9,4.7,19.1,2.9z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 796 B

View file

@ -4,8 +4,7 @@
<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" <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"> viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<g> <g>
<path d="M12,4c2.8,0,5,2.2,5,5v4v0.8l0.6,0.6l0.6,0.6H5.8l0.6-0.6L7,13.8V13V9C7,6.2,9.2,4,12,4 M12,2C8.1,2,5,5.1,5,9v4l-2,2v2h18 <path d="M12,22c1.1,0,2-0.9,2-2h-4C10,21.1,10.9,22,12,22z"/>
v-2l-2-2V9C19,5.1,15.9,2,12,2L12,2z"/> <path d="M18,16v-6c0-3-2.2-5.4-5-5.9V3h-2v1.1C8.2,4.6,6,7,6,10v6H4v2h16v-2H18z M16,16H8v-6c0-2.2,1.8-4,4-4s4,1.8,4,4V16z"/>
<path d="M9,19c0,1.7,1.3,3,3,3s3-1.3,3-3H9z"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 671 B

After

Width:  |  Height:  |  Size: 640 B

View file

@ -8,7 +8,7 @@ function NotificationBadge({ alert, content }) {
const notificationClass = alert ? ' notification-badge--alert' : ''; const notificationClass = alert ? ' notification-badge--alert' : '';
return ( return (
<div className={`notification-badge${notificationClass}`}> <div className={`notification-badge${notificationClass}`}>
{content && <Text variant="b3">{content}</Text>} {content !== null && <Text variant="b3">{content}</Text>}
</div> </div>
); );
} }

View file

@ -2,17 +2,18 @@
min-width: 16px; min-width: 16px;
min-height: 8px; min-height: 8px;
padding: 0 var(--sp-ultra-tight); padding: 0 var(--sp-ultra-tight);
background-color: var(--tc-surface-low); background-color: var(--bg-badge);
border-radius: var(--bo-radius); border-radius: var(--bo-radius);
.text { .text {
color: white; color: var(--tc-badge);
text-align: center; text-align: center;
font-weight: 700; font-weight: 700;
} }
&--alert { &--alert {
background-color: var(--bg-danger); background-color: var(--bg-positive);
& .text { color: white }
} }
&:empty { &:empty {

View file

@ -40,7 +40,7 @@ Button.defaultProps = {
Button.propTypes = { Button.propTypes = {
id: PropTypes.string, id: PropTypes.string,
className: PropTypes.string, className: PropTypes.string,
variant: PropTypes.oneOf(['surface', 'primary', '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']),
onClick: PropTypes.func, onClick: PropTypes.func,

View file

@ -2,6 +2,7 @@
.btn-surface, .btn-surface,
.btn-primary, .btn-primary,
.btn-positive,
.btn-caution, .btn-caution,
.btn-danger { .btn-danger {
display: inline-flex; display: inline-flex;
@ -67,6 +68,13 @@
@include state.focus(var(--bs-primary-outline)); @include state.focus(var(--bs-primary-outline));
@include state.active(var(--bg-primary-active)); @include state.active(var(--bg-primary-active));
} }
.btn-positive {
box-shadow: var(--bs-positive-border);
@include color(var(--tc-positive-high), var(--ic-positive-normal));
@include state.hover(var(--bg-positive-hover));
@include state.focus(var(--bs-positive-outline));
@include state.active(var(--bg-positive-active));
}
.btn-caution { .btn-caution {
box-shadow: var(--bs-caution-border); box-shadow: var(--bs-caution-border);
@include color(var(--tc-caution-high), var(--ic-caution-normal)); @include color(var(--tc-caution-high), var(--ic-caution-normal));

View file

@ -93,7 +93,7 @@ MenuItem.defaultProps = {
}; };
MenuItem.propTypes = { MenuItem.propTypes = {
variant: PropTypes.oneOf(['surface', 'caution', 'danger']), variant: PropTypes.oneOf(['surface', 'positive', 'caution', 'danger']),
iconSrc: PropTypes.string, iconSrc: PropTypes.string,
type: PropTypes.oneOf(['button', 'submit']), type: PropTypes.oneOf(['button', 'submit']),
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,

View file

@ -30,6 +30,9 @@
.text { .text {
color: var(--tc-surface-low); color: var(--tc-surface-low);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
&:not(:first-child) { &:not(:first-child) {

View file

@ -10,10 +10,12 @@ import NotificationBadge from '../../atoms/badge/NotificationBadge';
import { blurOnBubbling } from '../../atoms/button/script'; import { blurOnBubbling } from '../../atoms/button/script';
function RoomSelectorWrapper({ function RoomSelectorWrapper({
isSelected, onClick, content, options, isSelected, isUnread, onClick, content, options,
}) { }) {
let myClass = isUnread ? ' room-selector--unread' : '';
myClass += isSelected ? ' room-selector--selected' : '';
return ( return (
<div className={`room-selector${isSelected ? ' room-selector--selected' : ''}`}> <div className={`room-selector${myClass}`}>
<button <button
className="room-selector__content" className="room-selector__content"
type="button" type="button"
@ -31,6 +33,7 @@ RoomSelectorWrapper.defaultProps = {
}; };
RoomSelectorWrapper.propTypes = { RoomSelectorWrapper.propTypes = {
isSelected: PropTypes.bool.isRequired, isSelected: PropTypes.bool.isRequired,
isUnread: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
content: PropTypes.node.isRequired, content: PropTypes.node.isRequired,
options: PropTypes.node, options: PropTypes.node,
@ -44,6 +47,7 @@ function RoomSelector({
return ( return (
<RoomSelectorWrapper <RoomSelectorWrapper
isSelected={isSelected} isSelected={isSelected}
isUnread={isUnread}
content={( content={(
<> <>
<Avatar <Avatar
@ -68,6 +72,7 @@ function RoomSelector({
); );
} }
RoomSelector.defaultProps = { RoomSelector.defaultProps = {
isSelected: false,
imageSrc: null, imageSrc: null,
iconSrc: null, iconSrc: null,
options: null, options: null,
@ -77,9 +82,12 @@ RoomSelector.propTypes = {
roomId: PropTypes.string.isRequired, roomId: PropTypes.string.isRequired,
imageSrc: PropTypes.string, imageSrc: PropTypes.string,
iconSrc: PropTypes.string, iconSrc: PropTypes.string,
isSelected: PropTypes.bool.isRequired, isSelected: PropTypes.bool,
isUnread: PropTypes.bool.isRequired, isUnread: PropTypes.bool.isRequired,
notificationCount: PropTypes.number.isRequired, notificationCount: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]).isRequired,
isAlert: PropTypes.bool.isRequired, isAlert: PropTypes.bool.isRequired,
options: PropTypes.node, options: PropTypes.node,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,

View file

@ -14,6 +14,13 @@
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--bo-radius); border-radius: var(--bo-radius);
cursor: pointer; cursor: pointer;
&--unread {
.room-selector__content > .text {
font-weight: 500;
color: var(--tc-surface-high);
}
}
&--selected { &--selected {
background-color: var(--bg-surface); background-color: var(--bg-surface);

View file

@ -2,29 +2,22 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './SidebarAvatar.scss'; import './SidebarAvatar.scss';
import Tippy from '@tippyjs/react';
import Avatar from '../../atoms/avatar/Avatar'; import Avatar from '../../atoms/avatar/Avatar';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import Tooltip from '../../atoms/tooltip/Tooltip';
import NotificationBadge from '../../atoms/badge/NotificationBadge'; import NotificationBadge from '../../atoms/badge/NotificationBadge';
import { blurOnBubbling } from '../../atoms/button/script'; import { blurOnBubbling } from '../../atoms/button/script';
const SidebarAvatar = React.forwardRef(({ const SidebarAvatar = React.forwardRef(({
tooltip, text, bgColor, imageSrc, tooltip, text, bgColor, imageSrc,
iconSrc, active, onClick, notifyCount, iconSrc, active, onClick, isUnread, notificationCount, isAlert,
}, ref) => { }, ref) => {
let activeClass = ''; let activeClass = '';
if (active) activeClass = ' sidebar-avatar--active'; if (active) activeClass = ' sidebar-avatar--active';
return ( return (
<Tippy <Tooltip
content={<Text variant="b1">{tooltip}</Text>} content={<Text variant="b1">{tooltip}</Text>}
className="sidebar-avatar-tippy"
touch="hold"
arrow={false}
placement="right" placement="right"
maxWidth={200}
delay={[0, 0]}
duration={[100, 0]}
offset={[0, 0]}
> >
<button <button
ref={ref} ref={ref}
@ -40,9 +33,14 @@ const SidebarAvatar = React.forwardRef(({
iconSrc={iconSrc} iconSrc={iconSrc}
size="normal" size="normal"
/> />
{ notifyCount !== null && <NotificationBadge alert content={notifyCount} /> } { isUnread && (
<NotificationBadge
alert={isAlert}
content={notificationCount !== 0 ? notificationCount : null}
/>
)}
</button> </button>
</Tippy> </Tooltip>
); );
}); });
SidebarAvatar.defaultProps = { SidebarAvatar.defaultProps = {
@ -52,7 +50,9 @@ SidebarAvatar.defaultProps = {
imageSrc: null, imageSrc: null,
active: false, active: false,
onClick: null, onClick: null,
notifyCount: null, isUnread: false,
notificationCount: 0,
isAlert: false,
}; };
SidebarAvatar.propTypes = { SidebarAvatar.propTypes = {
@ -63,10 +63,12 @@ SidebarAvatar.propTypes = {
iconSrc: PropTypes.string, iconSrc: PropTypes.string,
active: PropTypes.bool, active: PropTypes.bool,
onClick: PropTypes.func, onClick: PropTypes.func,
notifyCount: PropTypes.oneOfType([ isUnread: PropTypes.bool,
notificationCount: PropTypes.oneOfType([
PropTypes.string, PropTypes.string,
PropTypes.number, PropTypes.number,
]), ]),
isAlert: PropTypes.bool,
}; };
export default SidebarAvatar; export default SidebarAvatar;

View file

@ -1,28 +1,18 @@
.sidebar-avatar-tippy {
padding: var(--sp-extra-tight) var(--sp-normal);
background-color: var(--bg-tooltip);
border-radius: var(--bo-radius);
box-shadow: var(--bs-popup);
.text {
color: var(--tc-tooltip);
}
}
.sidebar-avatar { .sidebar-avatar {
position: relative; position: relative;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 100%;
cursor: pointer; cursor: pointer;
& .notification-badge { & .notification-badge {
position: absolute; position: absolute;
right: var(--sp-extra-tight); right: 0;
top: calc(-1 * var(--sp-ultra-tight)); top: 0;
box-shadow: 0 0 0 2px var(--bg-surface-low); box-shadow: 0 0 0 2px var(--bg-surface-low);
transform: translate(20%, -20%);
margin: 0 !important;
} }
&:focus { &:focus {
outline: none; outline: none;
@ -37,7 +27,7 @@
content: ""; content: "";
display: block; display: block;
position: absolute; position: absolute;
left: 0; left: -11px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
@ -48,7 +38,8 @@
transition: height 200ms linear; transition: height 200ms linear;
[dir=rtl] & { [dir=rtl] & {
right: 0; left: unset;
right: -11px;
border-radius: 4px 0 0 4px; border-radius: 4px 0 0 4px;
} }
} }

View file

@ -12,7 +12,7 @@ import { AtoZ } from './common';
const drawerPostie = new Postie(); const drawerPostie = new Postie();
function Directs() { function Directs() {
const { roomList } = initMatrix; const { roomList, notifications } = initMatrix;
const directIds = [...roomList.directs].sort(AtoZ); const directIds = [...roomList.directs].sort(AtoZ);
const [, forceUpdate] = useState({}); const [, forceUpdate] = useState({});
@ -26,10 +26,11 @@ function Directs() {
drawerPostie.post('selector-change', addresses, selectedRoomId); drawerPostie.post('selector-change', addresses, selectedRoomId);
} }
function unreadChanged(roomId) { function notiChanged(roomId, total, prevTotal) {
if (!drawerPostie.hasTopic('unread-change')) return; if (total === prevTotal) return;
if (!drawerPostie.hasSubscriber('unread-change', roomId)) return; if (drawerPostie.hasTopicAndSubscriber('unread-change', roomId)) {
drawerPostie.post('unread-change', roomId); drawerPostie.post('unread-change', roomId);
}
} }
function roomListUpdated() { function roomListUpdated() {
@ -47,13 +48,11 @@ function Directs() {
useEffect(() => { useEffect(() => {
roomList.on(cons.events.roomList.ROOMLIST_UPDATED, roomListUpdated); roomList.on(cons.events.roomList.ROOMLIST_UPDATED, roomListUpdated);
navigation.on(cons.events.navigation.ROOM_SELECTED, selectorChanged); navigation.on(cons.events.navigation.ROOM_SELECTED, selectorChanged);
roomList.on(cons.events.roomList.MY_RECEIPT_ARRIVED, unreadChanged); notifications.on(cons.events.notifications.NOTI_CHANGED, notiChanged);
roomList.on(cons.events.roomList.EVENT_ARRIVED, unreadChanged);
return () => { return () => {
roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, roomListUpdated); roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, roomListUpdated);
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, selectorChanged); navigation.removeListener(cons.events.navigation.ROOM_SELECTED, selectorChanged);
roomList.removeListener(cons.events.roomList.MY_RECEIPT_ARRIVED, unreadChanged); notifications.removeListener(cons.events.notifications.NOTI_CHANGED, notiChanged);
roomList.removeListener(cons.events.roomList.EVENT_ARRIVED, unreadChanged);
}; };
}, []); }, []);

View file

@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './DrawerBreadcrumb.scss'; import './DrawerBreadcrumb.scss';
@ -6,51 +6,108 @@ import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import { selectSpace } from '../../../client/action/navigation'; import { selectSpace } from '../../../client/action/navigation';
import navigation from '../../../client/state/navigation'; import navigation from '../../../client/state/navigation';
import { abbreviateNumber } from '../../../util/common';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon'; import RawIcon from '../../atoms/system-icons/RawIcon';
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 NotificationBadge from '../../atoms/badge/NotificationBadge';
import ChevronRightIC from '../../../../public/res/ic/outlined/chevron-right.svg'; import ChevronRightIC from '../../../../public/res/ic/outlined/chevron-right.svg';
function DrawerBreadcrumb({ spaceId }) { function DrawerBreadcrumb({ spaceId }) {
const [, forceUpdate] = useState({});
const scrollRef = useRef(null); const scrollRef = useRef(null);
const { roomList, notifications } = initMatrix;
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
const spacePath = navigation.selectedSpacePath; const spacePath = navigation.selectedSpacePath;
function onNotiChanged(roomId, total, prevTotal) {
if (total === prevTotal) return;
if (navigation.selectedSpacePath.includes(roomId)) {
forceUpdate({});
}
if (navigation.selectedSpacePath[0] === cons.tabs.HOME) {
if (!roomList.isOrphan(roomId)) return;
if (roomList.directs.has(roomId)) return;
forceUpdate({});
}
}
useEffect(() => { useEffect(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (scrollRef?.current === null) return; if (scrollRef?.current === null) return;
scrollRef.current.scrollLeft = scrollRef.current.scrollWidth; scrollRef.current.scrollLeft = scrollRef.current.scrollWidth;
}); });
notifications.on(cons.events.notifications.NOTI_CHANGED, onNotiChanged);
return () => {
notifications.removeListener(cons.events.notifications.NOTI_CHANGED, onNotiChanged);
};
}, [spaceId]); }, [spaceId]);
if (spacePath.length === 1) return null; if (spacePath.length === 1) return null;
function getHomeNotiExcept(childId) {
const orphans = roomList.getOrphans();
const childIndex = orphans.indexOf(childId);
if (childId !== -1) orphans.splice(childIndex, 1);
let noti = null;
orphans.forEach((roomId) => {
if (!notifications.hasNoti(roomId)) return;
if (noti === null) noti = { total: 0, highlight: 0 };
const childNoti = notifications.getNoti(roomId);
noti.total += childNoti.total;
noti.highlight += childNoti.highlight;
});
return noti;
}
function getNotiExcept(roomId, childId) {
if (!notifications.hasNoti(roomId)) return null;
const noti = notifications.getNoti(roomId);
if (!notifications.hasNoti(childId)) return noti;
if (noti.from === null) return noti;
if (noti.from.has(childId) && noti.from.size === 1) return null;
const childNoti = notifications.getNoti(childId);
return {
total: noti.total - childNoti.total,
highlight: noti.highlight - childNoti.highlight,
};
}
return ( return (
<div className="breadcrumb__wrapper"> <div className="breadcrumb__wrapper">
<ScrollView ref={scrollRef} horizontal vertical={false} invisible> <ScrollView ref={scrollRef} horizontal vertical={false} invisible>
<div className="breadcrumb"> <div className="breadcrumb">
{ {
spacePath.map((id, index) => { spacePath.map((id, index) => {
if (index === 0) { const noti = (id !== cons.tabs.HOME && index < spacePath.length)
return ( ? getNotiExcept(id, (index === spacePath.length - 1) ? null : spacePath[index + 1])
<Button key={id} onClick={() => selectSpace(id)}> : getHomeNotiExcept((index === spacePath.length - 1) ? null : spacePath[index + 1]);
<Text variant="b2">{id === cons.tabs.HOME ? 'Home' : mx.getRoom(id).name}</Text>
</Button>
);
}
return ( return (
<React.Fragment <React.Fragment
key={id} key={id}
> >
<RawIcon size="extra-small" src={ChevronRightIC} /> { index !== 0 && <RawIcon size="extra-small" src={ChevronRightIC} />}
<Button <Button
className={index === spacePath.length - 1 ? 'breadcrumb__btn--selected' : ''} className={index === spacePath.length - 1 ? 'breadcrumb__btn--selected' : ''}
onClick={() => selectSpace(id)} onClick={() => selectSpace(id)}
> >
<Text variant="b2">{ mx.getRoom(id).name }</Text> <Text variant="b2">{id === cons.tabs.HOME ? 'Home' : mx.getRoom(id).name}</Text>
{ noti !== null && (
<NotificationBadge
alert={noti.highlight !== 0}
content={noti.total > 0 ? abbreviateNumber(noti.total) : null}
/>
)}
</Button> </Button>
</React.Fragment> </React.Fragment>
); );

View file

@ -51,6 +51,14 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
& .notification-badge {
margin-left: var(--sp-extra-tight);
[dir=rtl] & {
margin-left: 0;
margin-right: var(--sp-extra-tight);
}
}
} }
&__btn--selected { &__btn--selected {

View file

@ -15,7 +15,7 @@ import { AtoZ } from './common';
const drawerPostie = new Postie(); const drawerPostie = new Postie();
function Home({ spaceId }) { function Home({ spaceId }) {
const [, forceUpdate] = useState({}); const [, forceUpdate] = useState({});
const { roomList } = initMatrix; const { roomList, notifications } = initMatrix;
let spaceIds = []; let spaceIds = [];
let roomIds = []; let roomIds = [];
let directIds = []; let directIds = [];
@ -40,10 +40,11 @@ function Home({ spaceId }) {
if (addresses.length === 0) return; if (addresses.length === 0) return;
drawerPostie.post('selector-change', addresses, selectedRoomId); drawerPostie.post('selector-change', addresses, selectedRoomId);
} }
function unreadChanged(roomId) { function notiChanged(roomId, total, prevTotal) {
if (!drawerPostie.hasTopic('unread-change')) return; if (total === prevTotal) return;
if (!drawerPostie.hasSubscriber('unread-change', roomId)) return; if (drawerPostie.hasTopicAndSubscriber('unread-change', roomId)) {
drawerPostie.post('unread-change', roomId); drawerPostie.post('unread-change', roomId);
}
} }
function roomListUpdated() { function roomListUpdated() {
@ -61,13 +62,11 @@ function Home({ spaceId }) {
useEffect(() => { useEffect(() => {
roomList.on(cons.events.roomList.ROOMLIST_UPDATED, roomListUpdated); roomList.on(cons.events.roomList.ROOMLIST_UPDATED, roomListUpdated);
navigation.on(cons.events.navigation.ROOM_SELECTED, selectorChanged); navigation.on(cons.events.navigation.ROOM_SELECTED, selectorChanged);
roomList.on(cons.events.roomList.MY_RECEIPT_ARRIVED, unreadChanged); notifications.on(cons.events.notifications.NOTI_CHANGED, notiChanged);
roomList.on(cons.events.roomList.EVENT_ARRIVED, unreadChanged);
return () => { return () => {
roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, roomListUpdated); roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, roomListUpdated);
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, selectorChanged); navigation.removeListener(cons.events.navigation.ROOM_SELECTED, selectorChanged);
roomList.removeListener(cons.events.roomList.MY_RECEIPT_ARRIVED, unreadChanged); notifications.removeListener(cons.events.notifications.NOTI_CHANGED, notiChanged);
roomList.removeListener(cons.events.roomList.EVENT_ARRIVED, unreadChanged);
}; };
}, []); }, []);

View file

@ -3,9 +3,10 @@ import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import { doesRoomHaveUnread } from '../../../util/matrixUtil';
import navigation from '../../../client/state/navigation'; import navigation from '../../../client/state/navigation';
import { openRoomOptions } from '../../../client/action/navigation';
import { createSpaceShortcut, deleteSpaceShortcut } from '../../../client/action/room'; import { createSpaceShortcut, deleteSpaceShortcut } from '../../../client/action/room';
import { getEventCords, abbreviateNumber } from '../../../util/common';
import IconButton from '../../atoms/button/IconButton'; import IconButton from '../../atoms/button/IconButton';
import RoomSelector from '../../molecules/room-selector/RoomSelector'; import RoomSelector from '../../molecules/room-selector/RoomSelector';
@ -16,11 +17,13 @@ import SpaceIC from '../../../../public/res/ic/outlined/space.svg';
import SpaceLockIC from '../../../../public/res/ic/outlined/space-lock.svg'; import SpaceLockIC from '../../../../public/res/ic/outlined/space-lock.svg';
import StarIC from '../../../../public/res/ic/outlined/star.svg'; import StarIC from '../../../../public/res/ic/outlined/star.svg';
import FilledStarIC from '../../../../public/res/ic/filled/star.svg'; import FilledStarIC from '../../../../public/res/ic/filled/star.svg';
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
function Selector({ function Selector({
roomId, isDM, drawerPostie, onClick, roomId, isDM, drawerPostie, onClick,
}) { }) {
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
const noti = initMatrix.notifications;
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
let imageSrc = room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null; let imageSrc = room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null; if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
@ -44,43 +47,56 @@ function Selector({
}; };
}, []); }, []);
if (room.isSpaceRoom()) {
return (
<RoomSelector
key={roomId}
name={room.name}
roomId={roomId}
iconSrc={room.getJoinRule() === 'invite' ? SpaceLockIC : SpaceIC}
isUnread={noti.hasNoti(roomId)}
notificationCount={abbreviateNumber(noti.getTotalNoti(roomId))}
isAlert={noti.getHighlightNoti(roomId) !== 0}
onClick={onClick}
options={(
<IconButton
size="extra-small"
variant={initMatrix.roomList.spaceShortcut.has(roomId) ? 'positive' : 'surface'}
tooltip={initMatrix.roomList.spaceShortcut.has(roomId) ? 'Remove favourite' : 'Favourite'}
tooltipPlacement="right"
src={initMatrix.roomList.spaceShortcut.has(roomId) ? FilledStarIC : StarIC}
onClick={() => {
if (initMatrix.roomList.spaceShortcut.has(roomId)) deleteSpaceShortcut(roomId);
else createSpaceShortcut(roomId);
forceUpdate({});
}}
/>
)}
/>
);
}
return ( return (
<RoomSelector <RoomSelector
key={roomId} key={roomId}
name={room.name} name={room.name}
roomId={roomId} roomId={roomId}
imageSrc={isDM ? imageSrc : null} imageSrc={isDM ? imageSrc : null}
iconSrc={ // eslint-disable-next-line no-nested-ternary
isDM iconSrc={isDM ? null : room.getJoinRule() === 'invite' ? HashLockIC : HashIC}
? null
: (() => {
if (room.isSpaceRoom()) {
return (room.getJoinRule() === 'invite' ? SpaceLockIC : SpaceIC);
}
return (room.getJoinRule() === 'invite' ? HashLockIC : HashIC);
})()
}
isSelected={isSelected} isSelected={isSelected}
isUnread={doesRoomHaveUnread(room)} isUnread={noti.hasNoti(roomId)}
notificationCount={room.getUnreadNotificationCount('total') || 0} notificationCount={abbreviateNumber(noti.getTotalNoti(roomId))}
isAlert={room.getUnreadNotificationCount('highlight') !== 0} isAlert={noti.getHighlightNoti(roomId) !== 0}
onClick={onClick} onClick={onClick}
options={( options={(
!room.isSpaceRoom() <IconButton
? null size="extra-small"
: ( tooltip="Options"
<IconButton tooltipPlacement="right"
size="extra-small" src={VerticalMenuIC}
variant={initMatrix.roomList.spaceShortcut.has(roomId) ? 'positive' : 'surface'} onClick={(e) => openRoomOptions(getEventCords(e), roomId)}
tooltip={initMatrix.roomList.spaceShortcut.has(roomId) ? 'Remove favourite' : 'Favourite'} />
src={initMatrix.roomList.spaceShortcut.has(roomId) ? FilledStarIC : StarIC}
onClick={() => {
if (initMatrix.roomList.spaceShortcut.has(roomId)) deleteSpaceShortcut(roomId);
else createSpaceShortcut(roomId);
forceUpdate({});
}}
/>
)
)} )}
/> />
); );

View file

@ -9,6 +9,7 @@ import {
selectTab, openInviteList, openPublicRooms, openSettings, selectTab, openInviteList, openPublicRooms, openSettings,
} from '../../../client/action/navigation'; } from '../../../client/action/navigation';
import navigation from '../../../client/state/navigation'; import navigation from '../../../client/state/navigation';
import { abbreviateNumber } from '../../../util/common';
import ScrollView from '../../atoms/scroll/ScrollView'; import ScrollView from '../../atoms/scroll/ScrollView';
import SidebarAvatar from '../../molecules/sidebar-avatar/SidebarAvatar'; import SidebarAvatar from '../../molecules/sidebar-avatar/SidebarAvatar';
@ -55,7 +56,7 @@ function ProfileAvatarMenu() {
} }
function SideBar() { function SideBar() {
const { roomList } = initMatrix; const { roomList, notifications } = initMatrix;
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
const totalInviteCount = () => roomList.inviteRooms.size const totalInviteCount = () => roomList.inviteRooms.size
+ roomList.inviteSpaces.size + roomList.inviteSpaces.size
@ -74,33 +75,83 @@ function SideBar() {
function onSpaceShortcutUpdated() { function onSpaceShortcutUpdated() {
forceUpdate({}); forceUpdate({});
} }
function onNotificationChanged(roomId, total, prevTotal) {
if (total === prevTotal) return;
forceUpdate({});
}
useEffect(() => { useEffect(() => {
navigation.on(cons.events.navigation.TAB_SELECTED, onTabSelected); navigation.on(cons.events.navigation.TAB_SELECTED, onTabSelected);
roomList.on(cons.events.roomList.SPACE_SHORTCUT_UPDATED, onSpaceShortcutUpdated); roomList.on(cons.events.roomList.SPACE_SHORTCUT_UPDATED, onSpaceShortcutUpdated);
initMatrix.roomList.on( roomList.on(cons.events.roomList.INVITELIST_UPDATED, onInviteListChange);
cons.events.roomList.INVITELIST_UPDATED, notifications.on(cons.events.notifications.NOTI_CHANGED, onNotificationChanged);
onInviteListChange,
);
return () => { return () => {
navigation.removeListener(cons.events.navigation.TAB_SELECTED, onTabSelected); navigation.removeListener(cons.events.navigation.TAB_SELECTED, onTabSelected);
roomList.removeListener(cons.events.roomList.SPACE_SHORTCUT_UPDATED, onSpaceShortcutUpdated); roomList.removeListener(cons.events.roomList.SPACE_SHORTCUT_UPDATED, onSpaceShortcutUpdated);
initMatrix.roomList.removeListener( roomList.removeListener(cons.events.roomList.INVITELIST_UPDATED, onInviteListChange);
cons.events.roomList.INVITELIST_UPDATED, notifications.removeListener(cons.events.notifications.NOTI_CHANGED, onNotificationChanged);
onInviteListChange,
);
}; };
}, []); }, []);
function getHomeNoti() {
const orphans = roomList.getOrphans();
let noti = null;
orphans.forEach((roomId) => {
if (!notifications.hasNoti(roomId)) return;
if (noti === null) noti = { total: 0, highlight: 0 };
const childNoti = notifications.getNoti(roomId);
noti.total += childNoti.total;
noti.highlight += childNoti.highlight;
});
return noti;
}
function getDMsNoti() {
if (roomList.directs.size === 0) return null;
let noti = null;
[...roomList.directs].forEach((roomId) => {
if (!notifications.hasNoti(roomId)) return;
if (noti === null) noti = { total: 0, highlight: 0 };
const childNoti = notifications.getNoti(roomId);
noti.total += childNoti.total;
noti.highlight += childNoti.highlight;
});
return noti;
}
// TODO: bellow operations are heavy.
// refactor this component into more smaller components.
const dmsNoti = getDMsNoti();
const homeNoti = getHomeNoti();
return ( return (
<div className="sidebar"> <div className="sidebar">
<div className="sidebar__scrollable"> <div className="sidebar__scrollable">
<ScrollView invisible> <ScrollView invisible>
<div className="scrollable-content"> <div className="scrollable-content">
<div className="featured-container"> <div className="featured-container">
<SidebarAvatar active={selectedTab === cons.tabs.HOME} onClick={() => selectTab(cons.tabs.HOME)} tooltip="Home" iconSrc={HomeIC} /> <SidebarAvatar
<SidebarAvatar active={selectedTab === cons.tabs.DIRECTS} onClick={() => selectTab(cons.tabs.DIRECTS)} tooltip="People" iconSrc={UserIC} /> active={selectedTab === cons.tabs.HOME}
onClick={() => selectTab(cons.tabs.HOME)}
tooltip="Home"
iconSrc={HomeIC}
isUnread={homeNoti !== null}
notificationCount={homeNoti !== null ? abbreviateNumber(homeNoti.total) : 0}
isAlert={homeNoti?.highlight > 0}
/>
<SidebarAvatar
active={selectedTab === cons.tabs.DIRECTS}
onClick={() => selectTab(cons.tabs.DIRECTS)}
tooltip="People"
iconSrc={UserIC}
isUnread={dmsNoti !== null}
notificationCount={dmsNoti !== null ? abbreviateNumber(dmsNoti.total) : 0}
isAlert={dmsNoti?.highlight > 0}
/>
<SidebarAvatar onClick={() => openPublicRooms()} tooltip="Public rooms" iconSrc={HashSearchIC} /> <SidebarAvatar onClick={() => openPublicRooms()} tooltip="Public rooms" iconSrc={HashSearchIC} />
</div> </div>
<div className="sidebar-divider" /> <div className="sidebar-divider" />
@ -117,6 +168,9 @@ function SideBar() {
bgColor={colorMXID(room.roomId)} bgColor={colorMXID(room.roomId)}
imageSrc={room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop') || null} imageSrc={room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop') || null}
text={room.name.slice(0, 1)} text={room.name.slice(0, 1)}
isUnread={notifications.hasNoti(sRoomId)}
notificationCount={abbreviateNumber(notifications.getTotalNoti(sRoomId))}
isAlert={notifications.getHighlightNoti(sRoomId) !== 0}
onClick={() => selectTab(shortcut)} onClick={() => selectTab(shortcut)}
/> />
); );
@ -131,7 +185,9 @@ function SideBar() {
<div className="sticky-container"> <div className="sticky-container">
{ totalInvites !== 0 && ( { totalInvites !== 0 && (
<SidebarAvatar <SidebarAvatar
notifyCount={totalInvites} isUnread
notificationCount={totalInvites}
isAlert
onClick={() => openInviteList()} onClick={() => openInviteList()}
tooltip="Invites" tooltip="Invites"
iconSrc={InviteIC} iconSrc={InviteIC}

View file

@ -0,0 +1,229 @@
import React, { useState, useEffect, useRef } from 'react';
import './RoomOptions.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import { openInviteUser } from '../../../client/action/navigation';
import * as roomActions from '../../../client/action/room';
import ContextMenu, { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
import BellIC from '../../../../public/res/ic/outlined/bell.svg';
import BellRingIC from '../../../../public/res/ic/outlined/bell-ring.svg';
import BellPingIC from '../../../../public/res/ic/outlined/bell-ping.svg';
import BellOffIC from '../../../../public/res/ic/outlined/bell-off.svg';
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
function getNotifState(roomId) {
const mx = initMatrix.matrixClient;
const pushRule = mx.getRoomPushRule('global', roomId);
if (typeof pushRule === 'undefined') {
const overridePushRules = mx.getAccountData('m.push_rules')?.getContent()?.global?.override;
if (typeof overridePushRules === 'undefined') return 0;
const isMuteOverride = overridePushRules.find((rule) => (
rule.rule_id === roomId
&& rule.actions[0] === 'dont_notify'
&& rule.conditions[0].kind === 'event_match'
));
return isMuteOverride ? cons.notifs.MUTE : cons.notifs.DEFAULT;
}
if (pushRule.actions[0] === 'notify') return cons.notifs.ALL_MESSAGES;
return cons.notifs.MENTIONS_AND_KEYWORDS;
}
function setRoomNotifMute(roomId) {
const mx = initMatrix.matrixClient;
const roomPushRule = mx.getRoomPushRule('global', roomId);
const promises = [];
if (roomPushRule) {
promises.push(mx.deletePushRule('global', 'room', roomPushRule.rule_id));
}
promises.push(mx.addPushRule('global', 'override', roomId, {
conditions: [
{
kind: 'event_match',
key: 'room_id',
pattern: roomId,
},
],
actions: [
'dont_notify',
],
}));
return Promise.all(promises);
}
function setRoomNotifsState(newState, roomId) {
const mx = initMatrix.matrixClient;
const promises = [];
const oldState = getNotifState(roomId);
if (oldState === cons.notifs.MUTE) {
promises.push(mx.deletePushRule('global', 'override', roomId));
}
if (newState === cons.notifs.DEFAULT) {
const roomPushRule = mx.getRoomPushRule('global', roomId);
if (roomPushRule) {
promises.push(mx.deletePushRule('global', 'room', roomPushRule.rule_id));
}
return Promise.all(promises);
}
if (newState === cons.notifs.MENTIONS_AND_KEYWORDS) {
promises.push(mx.addPushRule('global', 'room', roomId, {
actions: [
'dont_notify',
],
}));
promises.push(mx.setPushRuleEnabled('global', 'room', roomId, true));
return Promise.all(promises);
}
// cons.notifs.ALL_MESSAGES
promises.push(mx.addPushRule('global', 'room', roomId, {
actions: [
'notify',
{
set_tweak: 'sound',
value: 'default',
},
],
}));
promises.push(mx.setPushRuleEnabled('global', 'room', roomId, true));
return Promise.all(promises);
}
function setRoomNotifPushRule(notifState, roomId) {
if (notifState === cons.notifs.MUTE) {
setRoomNotifMute(roomId);
return;
}
setRoomNotifsState(notifState, roomId);
}
let isRoomOptionVisible = false;
let roomId = null;
function RoomOptions() {
const openerRef = useRef(null);
const [notifState, setNotifState] = useState(cons.notifs.DEFAULT);
function openRoomOptions(cords, rId) {
if (roomId !== null || isRoomOptionVisible) {
roomId = null;
if (cords.detail === 0) openerRef.current.click();
return;
}
openerRef.current.style.transform = `translate(${cords.x}px, ${cords.y}px)`;
roomId = rId;
setNotifState(getNotifState(roomId));
openerRef.current.click();
}
function afterRoomOptionsToggle(isVisible) {
isRoomOptionVisible = isVisible;
if (!isVisible) {
setTimeout(() => {
if (!isRoomOptionVisible) roomId = null;
}, 500);
}
}
useEffect(() => {
navigation.on(cons.events.navigation.ROOMOPTIONS_OPENED, openRoomOptions);
return () => {
navigation.on(cons.events.navigation.ROOMOPTIONS_OPENED, openRoomOptions);
};
}, []);
const handleInviteClick = () => openInviteUser(roomId);
const handleLeaveClick = () => {
if (confirm('Are you really want to leave this room?')) roomActions.leave(roomId);
};
function setNotif(nState, currentNState) {
if (nState === currentNState) return;
setRoomNotifPushRule(nState, roomId);
setNotifState(nState);
}
return (
<ContextMenu
afterToggle={afterRoomOptionsToggle}
maxWidth={298}
content={(toggleMenu) => (
<>
<MenuHeader>{`Options for ${initMatrix.matrixClient.getRoom(roomId)?.name}`}</MenuHeader>
<MenuItem
iconSrc={AddUserIC}
onClick={() => {
handleInviteClick(); toggleMenu();
}}
>
Invite
</MenuItem>
<MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={handleLeaveClick}>Leave</MenuItem>
<MenuHeader>Notification</MenuHeader>
<MenuItem
variant={notifState === cons.notifs.DEFAULT ? 'positive' : 'surface'}
iconSrc={BellIC}
onClick={() => setNotif(cons.notifs.DEFAULT, notifState)}
>
Default
</MenuItem>
<MenuItem
variant={notifState === cons.notifs.ALL_MESSAGES ? 'positive' : 'surface'}
iconSrc={BellRingIC}
onClick={() => setNotif(cons.notifs.ALL_MESSAGES, notifState)}
>
All messages
</MenuItem>
<MenuItem
variant={notifState === cons.notifs.MENTIONS_AND_KEYWORDS ? 'positive' : 'surface'}
iconSrc={BellPingIC}
onClick={() => setNotif(cons.notifs.MENTIONS_AND_KEYWORDS, notifState)}
>
Mentions & Keywords
</MenuItem>
<MenuItem
variant={notifState === cons.notifs.MUTE ? 'positive' : 'surface'}
iconSrc={BellOffIC}
onClick={() => setNotif(cons.notifs.MUTE, notifState)}
>
Mute
</MenuItem>
</>
)}
render={(toggleMenu) => (
<input
ref={openerRef}
onClick={toggleMenu}
type="button"
style={{
width: '32px',
height: '32px',
backgroundColor: 'transparent',
position: 'absolute',
top: 0,
left: 0,
padding: 0,
border: 'none',
visibility: 'hidden',
}}
/>
)}
/>
);
}
export default RoomOptions;

View file

@ -0,0 +1,20 @@
.context-menu__item {
position: relative;
}
.context-menu__item .btn-positive::before {
content: '';
display: inline-block;
width: 3px;
height: 12px;
background: var(--bg-positive);
border-radius: 0 4px 4px 0;
position: absolute;
left: 0;
[dir=rtl] & {
left: unset;
right: 0;
border-radius: 4px 0 0 4px;
}
}

View file

@ -10,7 +10,7 @@ import cons from '../../../client/state/cons';
import { redactEvent, sendReaction } from '../../../client/action/roomTimeline'; 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 } from '../../../util/common'; import { diffMinutes, isNotInSameDay, getEventCords } from '../../../util/common';
import { openEmojiBoard, openReadReceipts } from '../../../client/action/navigation'; import { openEmojiBoard, openReadReceipts } from '../../../client/action/navigation';
import Divider from '../../atoms/divider/Divider'; import Divider from '../../atoms/divider/Divider';
@ -176,12 +176,7 @@ function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) {
} }
function pickEmoji(e, roomId, eventId, roomTimeline) { function pickEmoji(e, roomId, eventId, roomTimeline) {
const boxInfo = e.target.getBoundingClientRect(); openEmojiBoard(getEventCords(e), (emoji) => {
openEmojiBoard({
x: boxInfo.x,
y: boxInfo.y,
detail: e.detail,
}, (emoji) => {
toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline); toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline);
e.target.click(); e.target.click();
}); });

View file

@ -2,20 +2,17 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import { togglePeopleDrawer, openInviteUser } from '../../../client/action/navigation'; import { togglePeopleDrawer, openRoomOptions } from '../../../client/action/navigation';
import * as roomActions from '../../../client/action/room';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { getEventCords } from '../../../util/common';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton'; import IconButton from '../../atoms/button/IconButton';
import Header, { TitleWrapper } from '../../atoms/header/Header'; import Header, { TitleWrapper } from '../../atoms/header/Header';
import Avatar from '../../atoms/avatar/Avatar'; import Avatar from '../../atoms/avatar/Avatar';
import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
import UserIC from '../../../../public/res/ic/outlined/user.svg'; import UserIC from '../../../../public/res/ic/outlined/user.svg';
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg'; import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
function RoomViewHeader({ roomId }) { function RoomViewHeader({ roomId }) {
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
@ -33,24 +30,10 @@ function RoomViewHeader({ roomId }) {
{ typeof roomTopic !== 'undefined' && <p title={roomTopic} className="text text-b3">{roomTopic}</p>} { typeof roomTopic !== 'undefined' && <p title={roomTopic} className="text text-b3">{roomTopic}</p>}
</TitleWrapper> </TitleWrapper>
<IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} /> <IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
<ContextMenu <IconButton
placement="bottom" onClick={(e) => openRoomOptions(getEventCords(e), roomId)}
content={(toogleMenu) => ( tooltip="Options"
<> src={VerticalMenuIC}
<MenuHeader>Options</MenuHeader>
{/* <MenuBorder /> */}
<MenuItem
iconSrc={AddUserIC}
onClick={() => {
openInviteUser(roomId); toogleMenu();
}}
>
Invite
</MenuItem>
<MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={() => roomActions.leave(roomId)}>Leave</MenuItem>
</>
)}
render={(toggleMenu) => <IconButton onClick={toggleMenu} tooltip="Options" src={VerticalMenuIC} />}
/> />
</Header> </Header>
); );

View file

@ -9,7 +9,7 @@ import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import settings from '../../../client/state/settings'; import settings from '../../../client/state/settings';
import { openEmojiBoard } from '../../../client/action/navigation'; import { openEmojiBoard } from '../../../client/action/navigation';
import { bytesToSize } from '../../../util/common'; import { bytesToSize, getEventCords } from '../../../util/common';
import { getUsername } from '../../../util/matrixUtil'; import { getUsername } from '../../../util/matrixUtil';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
@ -327,12 +327,10 @@ function RoomViewInput({
<div ref={rightOptionsRef} className="room-input__option-container"> <div ref={rightOptionsRef} className="room-input__option-container">
<IconButton <IconButton
onClick={(e) => { onClick={(e) => {
const boxInfo = e.target.getBoundingClientRect(); const cords = getEventCords(e);
openEmojiBoard({ cords.x += (document.dir === 'rtl' ? -80 : 80);
x: boxInfo.x + (document.dir === 'rtl' ? -80 : 80), cords.y -= 250;
y: boxInfo.y - 250, openEmojiBoard(cords, addEmoji);
detail: e.detail,
}, addEmoji);
}} }}
tooltip="Emoji" tooltip="Emoji"
src={EmojiIC} src={EmojiIC}

View file

@ -8,6 +8,7 @@ import Room from '../../organisms/room/Room';
import Windows from '../../organisms/pw/Windows'; import Windows from '../../organisms/pw/Windows';
import Dialogs from '../../organisms/pw/Dialogs'; import Dialogs from '../../organisms/pw/Dialogs';
import EmojiBoardOpener from '../../organisms/emoji-board/EmojiBoardOpener'; import EmojiBoardOpener from '../../organisms/emoji-board/EmojiBoardOpener';
import RoomOptions from '../../organisms/room-optons/RoomOptions';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
@ -44,6 +45,7 @@ function Client() {
<Windows /> <Windows />
<Dialogs /> <Dialogs />
<EmojiBoardOpener /> <EmojiBoardOpener />
<RoomOptions />
</div> </div>
); );
} }

View file

@ -77,6 +77,14 @@ function openReadReceipts(roomId, eventId) {
}); });
} }
function openRoomOptions(cords, roomId) {
appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_ROOMOPTIONS,
cords,
roomId,
});
}
export { export {
selectTab, selectTab,
selectSpace, selectSpace,
@ -89,4 +97,5 @@ export {
openSettings, openSettings,
openEmojiBoard, openEmojiBoard,
openReadReceipts, openReadReceipts,
openRoomOptions,
}; };

View file

@ -4,6 +4,7 @@ import * as sdk from 'matrix-js-sdk';
import { secret } from './state/auth'; import { secret } from './state/auth';
import RoomList from './state/RoomList'; import RoomList from './state/RoomList';
import RoomsInput from './state/RoomsInput'; import RoomsInput from './state/RoomsInput';
import Notifications from './state/Notifications';
global.Olm = require('@matrix-org/olm'); global.Olm = require('@matrix-org/olm');
@ -56,6 +57,7 @@ class InitMatrix extends EventEmitter {
if (prevState === null) { if (prevState === null) {
this.roomList = new RoomList(this.matrixClient); this.roomList = new RoomList(this.matrixClient);
this.roomsInput = new RoomsInput(this.matrixClient); this.roomsInput = new RoomsInput(this.matrixClient);
this.notifications = new Notifications(this.roomList);
this.emit('init_loading_finished'); this.emit('init_loading_finished');
} }
}, },

View file

@ -0,0 +1,162 @@
import EventEmitter from 'events';
import cons from './cons';
class Notifications extends EventEmitter {
constructor(roomList) {
super();
this.matrixClient = roomList.matrixClient;
this.roomList = roomList;
this.roomIdToNoti = new Map();
this._initNoti();
this._listenEvents();
// TODO:
window.notifications = this;
}
_initNoti() {
const addNoti = (roomId) => {
const room = this.matrixClient.getRoom(roomId);
if (this.doesRoomHaveUnread(room) === false) return;
const total = room.getUnreadNotificationCount('total');
const highlight = room.getUnreadNotificationCount('highlight');
const noti = this.getNoti(room.roomId);
this._setNoti(room.roomId, total - noti.total, highlight - noti.highlight);
};
[...this.roomList.rooms].forEach(addNoti);
[...this.roomList.directs].forEach(addNoti);
}
doesRoomHaveUnread(room) {
const userId = this.matrixClient.getUserId();
const readUpToId = room.getEventReadUpTo(userId);
const supportEvents = ['m.room.message', 'm.room.encrypted', 'm.sticker'];
if (room.timeline.length
&& room.timeline[room.timeline.length - 1].sender
&& room.timeline[room.timeline.length - 1].sender.userId === userId
&& room.timeline[room.timeline.length - 1].getType() !== 'm.room.member') {
return false;
}
for (let i = room.timeline.length - 1; i >= 0; i -= 1) {
const event = room.timeline[i];
if (event.getId() === readUpToId) return false;
if (supportEvents.includes(event.getType())) {
return true;
}
}
return true;
}
getNoti(roomId) {
return this.roomIdToNoti.get(roomId) || { total: 0, highlight: 0, from: null };
}
getTotalNoti(roomId) {
const { total } = this.getNoti(roomId);
return total;
}
getHighlightNoti(roomId) {
const { highlight } = this.getNoti(roomId);
return highlight;
}
getFromNoti(roomId) {
const { from } = this.getNoti(roomId);
return from;
}
hasNoti(roomId) {
return this.roomIdToNoti.has(roomId);
}
_setNoti(roomId, total, highlight, childId) {
const prevTotal = this.roomIdToNoti.get(roomId)?.total ?? null;
const noti = this.getNoti(roomId);
noti.total += total;
noti.highlight += highlight;
if (childId) {
if (noti.from === null) noti.from = new Set();
noti.from.add(childId);
}
this.roomIdToNoti.set(roomId, noti);
this.emit(cons.events.notifications.NOTI_CHANGED, roomId, noti.total, prevTotal);
const parentIds = this.roomList.roomIdToParents.get(roomId);
if (typeof parentIds === 'undefined') return;
[...parentIds].forEach((parentId) => this._setNoti(parentId, total, highlight, roomId));
}
_deleteNoti(roomId, total, highlight, childId) {
if (this.roomIdToNoti.has(roomId) === false) return;
const noti = this.getNoti(roomId);
const prevTotal = noti.total;
noti.total -= total;
noti.highlight -= highlight;
if (childId && noti.from !== null) {
noti.from.delete(childId);
}
if (noti.from === null || noti.from.size === 0) {
this.roomIdToNoti.delete(roomId);
this.emit(cons.events.notifications.FULL_READ, roomId);
this.emit(cons.events.notifications.NOTI_CHANGED, roomId, null, prevTotal);
} else {
this.roomIdToNoti.set(roomId, noti);
this.emit(cons.events.notifications.NOTI_CHANGED, roomId, noti.total, prevTotal);
}
const parentIds = this.roomList.roomIdToParents.get(roomId);
if (typeof parentIds === 'undefined') return;
[...parentIds].forEach((parentId) => this._deleteNoti(parentId, total, highlight, roomId));
}
_listenEvents() {
this.matrixClient.on('Room.timeline', (mEvent, room) => {
const supportEvents = ['m.room.message', 'm.room.encrypted', 'm.sticker'];
if (!supportEvents.includes(mEvent.getType())) return;
const lastTimelineEvent = room.timeline[room.timeline.length - 1];
if (lastTimelineEvent.getId() !== mEvent.getId()) return;
if (mEvent.getSender() === this.matrixClient.getUserId()) return;
const total = room.getUnreadNotificationCount('total');
const highlight = room.getUnreadNotificationCount('highlight');
const noti = this.getNoti(room.roomId);
this._setNoti(room.roomId, total - noti.total, highlight - noti.highlight);
});
this.matrixClient.on('Room.receipt', (mEvent, room) => {
if (mEvent.getType() === 'm.receipt') {
const content = mEvent.getContent();
const readedEventId = Object.keys(content)[0];
const readerUserId = Object.keys(content[readedEventId]['m.read'])[0];
if (readerUserId !== this.matrixClient.getUserId()) return;
if (this.hasNoti(room.roomId)) {
const noti = this.getNoti(room.roomId);
this._deleteNoti(room.roomId, noti.total, noti.highlight);
}
}
});
this.matrixClient.on('Room.myMembership', (room, membership) => {
if (membership === 'leave' && this.hasNoti(room.roomId)) {
const noti = this.getNoti(room.roomId);
this._deleteNoti(room.roomId, noti.total, noti.highlight);
}
});
}
}
export default Notifications;

View file

@ -2,11 +2,18 @@ import EventEmitter from 'events';
import appDispatcher from '../dispatcher'; import appDispatcher from '../dispatcher';
import cons from './cons'; import cons from './cons';
function isMEventSpaceChild(mEvent) {
return mEvent.getType() === 'm.space.child' && Object.keys(mEvent.getContent()).length > 0;
}
class RoomList extends EventEmitter { class RoomList extends EventEmitter {
constructor(matrixClient) { constructor(matrixClient) {
super(); super();
this.matrixClient = matrixClient; this.matrixClient = matrixClient;
this.mDirects = this.getMDirects(); this.mDirects = this.getMDirects();
// Contains roomId to parent spaces roomId mapping of all spaces children.
// No matter if you have joined those children rooms or not.
this.roomIdToParents = new Map(); this.roomIdToParents = new Map();
this.spaceShortcut = new Set(); this.spaceShortcut = new Set();
@ -34,14 +41,24 @@ class RoomList extends EventEmitter {
this.matrixClient.setAccountData(cons['in.cinny.spaces'], spaceContent); this.matrixClient.setAccountData(cons['in.cinny.spaces'], spaceContent);
} }
isOrphan(roomId) {
return !this.roomIdToParents.has(roomId);
}
getOrphans() {
const rooms = [...this.spaces].concat([...this.rooms]);
return rooms.filter((roomId) => !this.roomIdToParents.has(roomId));
}
getSpaceChildren(roomId) { getSpaceChildren(roomId) {
const space = this.matrixClient.getRoom(roomId); const space = this.matrixClient.getRoom(roomId);
const mSpaceChild = space?.currentState.getStateEvents('m.space.child'); const mSpaceChild = space?.currentState.getStateEvents('m.space.child');
const children = mSpaceChild?.map((mEvent) => { const children = mSpaceChild?.map((mEvent) => {
if (Object.keys(mEvent.event.content).length === 0) return null; const childId = mEvent.event.state_key;
return mEvent.event.state_key; if (isMEventSpaceChild(mEvent)) return childId;
return null;
}); });
return children?.filter((child) => child !== null); return children?.filter((childId) => childId !== null);
} }
addToRoomIdToParents(roomId, parentRoomId) { addToRoomIdToParents(roomId, parentRoomId) {
@ -246,22 +263,13 @@ class RoomList extends EventEmitter {
this.matrixClient.on('Room.name', () => { this.matrixClient.on('Room.name', () => {
this.emit(cons.events.roomList.ROOMLIST_UPDATED); this.emit(cons.events.roomList.ROOMLIST_UPDATED);
}); });
this.matrixClient.on('Room.receipt', (event, room) => {
if (event.getType() === 'm.receipt') {
const content = event.getContent();
const userReadEventId = Object.keys(content)[0];
const eventReaderUserId = Object.keys(content[userReadEventId]['m.read'])[0];
if (eventReaderUserId !== this.matrixClient.getUserId()) return;
this.emit(cons.events.roomList.MY_RECEIPT_ARRIVED, room.roomId);
}
});
this.matrixClient.on('RoomState.events', (mEvent) => { this.matrixClient.on('RoomState.events', (mEvent) => {
if (mEvent.getType() === 'm.space.child') { if (mEvent.getType() === 'm.space.child') {
const { event } = mEvent; const { event } = mEvent;
const isRoomAdded = Object.keys(event.content).length > 0; if (isMEventSpaceChild(mEvent)) {
if (isRoomAdded) this.addToRoomIdToParents(event.state_key, event.room_id); this.addToRoomIdToParents(event.state_key, event.room_id);
else this.removeFromRoomIdToParents(event.state_key, event.room_id); } else this.removeFromRoomIdToParents(event.state_key, event.room_id);
this.emit(cons.events.roomList.ROOMLIST_UPDATED); this.emit(cons.events.roomList.ROOMLIST_UPDATED);
return; return;
} }
@ -379,15 +387,6 @@ class RoomList extends EventEmitter {
} }
this.emit(cons.events.roomList.ROOMLIST_UPDATED); this.emit(cons.events.roomList.ROOMLIST_UPDATED);
}); });
this.matrixClient.on('Room.timeline', (event, room) => {
const supportEvents = ['m.room.message', 'm.room.encrypted', 'm.sticker'];
if (!supportEvents.includes(event.getType())) return;
const lastTimelineEvent = room.timeline[room.timeline.length - 1];
if (lastTimelineEvent.getId() !== event.getId()) return;
this.emit(cons.events.roomList.EVENT_ARRIVED, room.roomId);
});
} }
} }
export default RoomList; export default RoomList;

View file

@ -11,6 +11,12 @@ const cons = {
HOME: 'home', HOME: 'home',
DIRECTS: 'dm', DIRECTS: 'dm',
}, },
notifs: {
DEFAULT: 'default',
ALL_MESSAGES: 'all_messages',
MENTIONS_AND_KEYWORDS: 'mentions_and_keywords',
MUTE: 'mute',
},
actions: { actions: {
navigation: { navigation: {
SELECT_TAB: 'SELECT_TAB', SELECT_TAB: 'SELECT_TAB',
@ -24,6 +30,7 @@ const cons = {
OPEN_SETTINGS: 'OPEN_SETTINGS', OPEN_SETTINGS: 'OPEN_SETTINGS',
OPEN_EMOJIBOARD: 'OPEN_EMOJIBOARD', OPEN_EMOJIBOARD: 'OPEN_EMOJIBOARD',
OPEN_READRECEIPTS: 'OPEN_READRECEIPTS', OPEN_READRECEIPTS: 'OPEN_READRECEIPTS',
OPEN_ROOMOPTIONS: 'OPEN_ROOMOPTIONS',
}, },
room: { room: {
JOIN: 'JOIN', JOIN: 'JOIN',
@ -52,6 +59,7 @@ const cons = {
SETTINGS_OPENED: 'SETTINGS_OPENED', SETTINGS_OPENED: 'SETTINGS_OPENED',
EMOJIBOARD_OPENED: 'EMOJIBOARD_OPENED', EMOJIBOARD_OPENED: 'EMOJIBOARD_OPENED',
READRECEIPTS_OPENED: 'READRECEIPTS_OPENED', READRECEIPTS_OPENED: 'READRECEIPTS_OPENED',
ROOMOPTIONS_OPENED: 'ROOMOPTIONS_OPENED',
}, },
roomList: { roomList: {
ROOMLIST_UPDATED: 'ROOMLIST_UPDATED', ROOMLIST_UPDATED: 'ROOMLIST_UPDATED',
@ -59,10 +67,12 @@ const cons = {
ROOM_JOINED: 'ROOM_JOINED', ROOM_JOINED: 'ROOM_JOINED',
ROOM_LEAVED: 'ROOM_LEAVED', ROOM_LEAVED: 'ROOM_LEAVED',
ROOM_CREATED: 'ROOM_CREATED', ROOM_CREATED: 'ROOM_CREATED',
MY_RECEIPT_ARRIVED: 'MY_RECEIPT_ARRIVED',
EVENT_ARRIVED: 'EVENT_ARRIVED',
SPACE_SHORTCUT_UPDATED: 'SPACE_SHORTCUT_UPDATED', SPACE_SHORTCUT_UPDATED: 'SPACE_SHORTCUT_UPDATED',
}, },
notifications: {
NOTI_CHANGED: 'NOTI_CHANGED',
FULL_READ: 'FULL_READ',
},
roomTimeline: { roomTimeline: {
EVENT: 'EVENT', EVENT: 'EVENT',
PAGINATED: 'PAGINATED', PAGINATED: 'PAGINATED',

View file

@ -85,6 +85,13 @@ class Navigation extends EventEmitter {
action.eventId, action.eventId,
); );
}, },
[cons.actions.navigation.OPEN_ROOMOPTIONS]: () => {
this.emit(
cons.events.navigation.ROOMOPTIONS_OPENED,
action.cords,
action.roomId,
);
},
}; };
actions[action.type]?.(); actions[action.type]?.();
} }

View file

@ -32,6 +32,7 @@
--bg-danger-border: rgba(240, 71, 71, 20%); --bg-danger-border: rgba(240, 71, 71, 20%);
--bg-tooltip: #353535; --bg-tooltip: #353535;
--bg-badge: #989898;
/* text color | --tc-[background type]-[priority]: value */ /* text color | --tc-[background type]-[priority]: value */
--tc-surface-high: #000000; --tc-surface-high: #000000;
@ -57,6 +58,7 @@
--tc-code: #e62498; --tc-code: #e62498;
--tc-tooltip: white; --tc-tooltip: white;
--tc-badge: white;
/* system icons | --ic-[background type]-[priority]: value */ /* system icons | --ic-[background type]-[priority]: value */
@ -179,6 +181,7 @@
--bg-primary-border: rgba(59, 119, 191, 38%); --bg-primary-border: rgba(59, 119, 191, 38%);
--bg-tooltip: #000; --bg-tooltip: #000;
--bg-badge: hsl(0, 0%, 75%);
/* text color | --tc-[background type]-[priority]: value */ /* text color | --tc-[background type]-[priority]: value */
--tc-surface-high: rgba(255, 255, 255, 94%); --tc-surface-high: rgba(255, 255, 255, 94%);
@ -190,6 +193,7 @@
--tc-primary-low: rgba(255, 255, 255, 0.4); --tc-primary-low: rgba(255, 255, 255, 0.4);
--tc-code: #e565b1; --tc-code: #e565b1;
--tc-badge: black;
/* system icons | --ic-[background type]-[priority]: value */ /* system icons | --ic-[background type]-[priority]: value */
--ic-surface-normal: rgba(255, 255, 255, 68%); --ic-surface-normal: rgba(255, 255, 255, 68%);
@ -216,6 +220,8 @@
--bg-surface-low: hsl(64, 6%, 10%); --bg-surface-low: hsl(64, 6%, 10%);
--bg-surface-low-transparent: hsla(64, 6%, 14%, 0); --bg-surface-low-transparent: hsla(64, 6%, 14%, 0);
--bg-badge: #c4c1ab;
/* text color | --tc-[background type]-[priority]: value */ /* text color | --tc-[background type]-[priority]: value */
--tc-surface-high: rgb(255, 251, 222, 94%); --tc-surface-high: rgb(255, 251, 222, 94%);

View file

@ -30,8 +30,8 @@ class Postie {
} }
hasTopicAndSubscriber(topic, address) { hasTopicAndSubscriber(topic, address) {
return (this.isTopicExist(topic)) return (this.hasTopic(topic))
? this.isSubscriberExist(topic, address) ? this.hasSubscriber(topic, address)
: false; : false;
} }

View file

@ -19,3 +19,17 @@ export function isNotInSameDay(dt2, dt1) {
|| dt2.getYear() !== dt1.getYear() || dt2.getYear() !== dt1.getYear()
); );
} }
export function getEventCords(ev) {
const boxInfo = ev.target.getBoundingClientRect();
return {
x: boxInfo.x,
y: boxInfo.y,
detail: ev.detail,
};
}
export function abbreviateNumber(number) {
if (number > 99) return '99+';
return number;
}