connection info and context menus

This commit is contained in:
Michael Peters 2021-12-25 12:00:20 -06:00
parent a47804d980
commit 80c2a352da
20 changed files with 256 additions and 152 deletions

View File

@ -1,45 +0,0 @@
import React from 'react';
import ElementsUtil from './require/elements-util.js';
import BaseElements from './require/base-elements.js';
import Q from '../q-module.js';
import UI from '../ui.js';
import CombinedGuild from '../guild-combined.js';
import PersonalizeOverlay from './overlays/overlay-personalize.js';
export default function createConnectionContextMenu(document: Document, q: Q, ui: UI, guild: CombinedGuild) {
const statuses = [ 'online', 'away', 'busy', 'invisible' ];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let content: JSX.Element[] = [
<div key="personalize" className="item personalize">
<div className="icon"><img src="./img/pencil-icon.png"></img></div>
<div>Personalize</div>
</div>,
<div key="spacer-1" className="item-spacer"></div>
];
content = content.concat(statuses.map(status => (
<div key={status} className={'item ' + status}>
<div className="status-circle"></div>
<div className="status-text">{status}</div>
</div>
)));
const element = BaseElements.createContextMenu(document, <div className="member-context">{content}</div>);
q.$$$(element, '.personalize').addEventListener('click', async () => {
element.removeSelf();
if (ui.activeConnection === null) return;
ElementsUtil.presentReactOverlay(document, <PersonalizeOverlay document={document} guild={guild} connection={ui.activeConnection} />);
});
for (const status of statuses) {
q.$$$(element, '.' + status).addEventListener('click', async () => {
element.removeSelf();
const currentConnection = await guild.fetchConnectionInfo();
if (status != currentConnection.status) {
await guild.requestSetStatus(status);
}
});
}
return element;
}

View File

@ -0,0 +1,38 @@
import React, { FC, ReactNode, RefObject, useEffect, useMemo, useRef, useState } from 'react';
import ElementsUtil, { IAlignment } from '../../require/elements-util';
import ReactHelper from '../../require/react-helper';
export interface ContextMenuProps {
relativeToRef: RefObject<HTMLElement | null>;
alignment: IAlignment;
children: ReactNode;
close: () => void;
}
const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => {
const { relativeToRef, alignment, children, close } = props;
const rootRef = useRef<HTMLDivElement>(null);
const [ aligned, setAligned ] = useState<boolean>(false);
ReactHelper.useCloseWhenClickedOutsideEffect(rootRef, close);
useEffect(() => {
if (!rootRef.current || !relativeToRef.current) return;
ElementsUtil.alignContextElement(rootRef.current, relativeToRef.current, alignment);
setAligned(true);
}, [ rootRef, relativeToRef ]);
const contextClass = useMemo(() => {
return 'context react' + (aligned ? ' aligned' : '');
}, [ aligned ]);
return (
<div ref={rootRef} className={contextClass}>
<div className="menu">{children}</div>
</div>
);
}
export default ContextMenu;

View File

@ -0,0 +1,59 @@
import React, { FC, RefObject, useCallback, useMemo } from 'react';
import { Member } from '../../data-types';
import CombinedGuild from '../../guild-combined';
import PersonalizeOverlay from '../overlays/overlay-personalize';
import ElementsUtil from '../require/elements-util';
import ContextMenu from './components/context-menu';
export interface ConnectionInfoContextProps {
guild: CombinedGuild;
selfMember: Member;
relativeToRef: RefObject<HTMLElement | null>;
close: () => void;
}
const ConnectionInfoContext: FC<ConnectionInfoContextProps> = (props: ConnectionInfoContextProps) => {
const { guild, selfMember, relativeToRef, close } = props;
const setSelfStatus = useCallback(async (status: string) => {
await guild.requestSetStatus(status);
}, [ guild ]);
const statusElements = useMemo(() => {
return [ 'online', 'away', 'busy', 'invisible' ].map(status => {
// Note: throwing out setSelfStatus promise
return (
<div key={status} className={'item ' + status} onClick={() => { setSelfStatus(status); close(); }}>
<div className="status-circle"></div>
<div className="status-text">{status}</div>
</div>
);
});
}, [ setSelfStatus ]);
const openPersonalize = useCallback(() => {
close();
// Note: using global document, not very safe >:|
// TODO: Do this in full react (also fixes global document problem)
ElementsUtil.presentReactOverlay(document, <PersonalizeOverlay document={document} guild={guild} selfMember={selfMember} />);
}, [ guild, selfMember, close ]);
const alignment = useMemo(() => {
return { bottom: 'top', centerX: 'centerX' }
}, []);
return (
<ContextMenu relativeToRef={relativeToRef} alignment={alignment} close={close}>
<div className="member-context">
<div onClick={openPersonalize} className="item">
<div className="icon"><img src="./img/pencil-icon.png" /></div>
<div>Personalize</div>
</div>
<div className="item-spacer" />
{statusElements}
</div>
</ContextMenu>
);
}
export default ConnectionInfoContext;

View File

@ -14,7 +14,6 @@ import moment from 'moment';
import DropdownInput from '../components/input-dropdown';
import Button, { ButtonColorType } from '../components/button';
import GuildSubscriptions from '../require/guild-subscriptions';
import ElementsUtil from '../require/elements-util';
import BaseElements from '../require/base-elements';
@ -33,11 +32,7 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
const [ expiresFromNow, setExpiresFromNow ] = useState<Duration | null>(moment.duration(1, 'day'));
const [ expiresFromNowText, setExpiresFromNowText ] = useState<string>('1 day');
const [ iconSrc ] = ReactHelper.useOneTimeAsyncAction(
async () => await ElementsUtil.getImageSrcFromResourceFailSoftly(guild, guildMeta?.iconResourceId ?? null),
'./img/loading.svg',
[ guild, guildMeta?.iconResourceId ]
);
const [ iconSrc ] = GuildSubscriptions.useSoftImageSrcResourceSubscription(guild, guildMeta?.iconResourceId ?? null);
useEffect(() => {
if (expiresFromNowText === 'never') {

View File

@ -1,16 +0,0 @@
import ElementsUtil from './require/elements-util.js';
import Q from '../q-module.js';
import UI from '../ui.js';
import createConnectionContextMenu from './context-menu-conn';
export default function bindConnectionEvents(document: Document, q: Q, ui: UI): void {
q.$('#connection').addEventListener('click', () => {
if (ui.activeGuild === null) return;
if (!ui.activeGuild.isSocketVerified()) return;
const contextMenu = createConnectionContextMenu(document, q, ui, ui.activeGuild);
document.body.appendChild(contextMenu);
ElementsUtil.alignContextElement(contextMenu, q.$('#connection'), { bottom: 'top', centerX: 'centerX' });
});
}

View File

@ -1,22 +1,25 @@
import React, { FC, useMemo } from 'react';
import { Member } from '../../../data-types';
import CombinedGuild from '../../../guild-combined';
import ElementsUtil from '../../require/elements-util';
import ReactHelper from '../../require/react-helper';
import GuildSubscriptions from '../../require/guild-subscriptions';
export interface DummyMember {
id: 'dummy';
displayName: string;
status: string;
roleColor: null;
avatarResourceId: null;
}
export interface MemberProps {
guild: CombinedGuild;
member: Member;
member: Member | DummyMember;
}
const MemberElement: FC<MemberProps> = (props: MemberProps) => {
const { guild, member } = props;
const [ avatarSrc ] = ReactHelper.useOneTimeAsyncAction(
async () => ElementsUtil.getImageSrcFromResourceFailSoftly(guild, member.avatarResourceId),
'./img/loading.svg',
[ guild, member.avatarResourceId ]
);
const [ avatarSrc ] = GuildSubscriptions.useSoftImageSrcResourceSubscription(guild, member.avatarResourceId);
const nameStyle = useMemo(() => member.roleColor ? { color: member.roleColor } : {}, [ member.roleColor ]);

View File

@ -4,6 +4,7 @@ import { Member, Message } from '../../../data-types';
import CombinedGuild from '../../../guild-combined';
import ImageOverlay from '../../overlays/overlay-image';
import ElementsUtil from '../../require/elements-util';
import GuildSubscriptions from '../../require/guild-subscriptions';
import ReactHelper from '../../require/react-helper';
interface ResourceElementProps {
@ -49,11 +50,7 @@ interface PreviewImageElementProps {
const PreviewImageElement: FC<PreviewImageElementProps> = (props: PreviewImageElementProps) => {
const { guild, previewWidth, previewHeight, resourcePreviewId, resourceId, resourceName } = props;
const [ imgSrc ] = ReactHelper.useOneTimeAsyncAction(
async () => await ElementsUtil.getImageSrcFromResourceFailSoftly(guild, resourcePreviewId),
'./img/loading.svg',
[ guild, resourcePreviewId ]
);
const [ imgSrc ] = GuildSubscriptions.useSoftImageSrcResourceSubscription(guild, resourcePreviewId);
const clickCallback = useCallback(() => {
// Note: document here isn't 100% guaranteed but we should be getting rid of this eventually anyway

View File

@ -6,6 +6,7 @@ import ElementsUtil from "./require/elements-util";
import MemberList from "./lists/member-list";
import MessageList from './lists/message-list';
import ChannelTitle from './sections/channel-title';
import ConnectionInfo from './sections/connection-info';
export function mountBaseComponents() {
// guild-list
@ -13,6 +14,10 @@ export function mountBaseComponents() {
}
export function mountGuildComponents(q: Q, guild: CombinedGuild) {
// connection info
ElementsUtil.unmountReactComponent(q.$('.connection-anchor'));
ElementsUtil.mountReactComponent(q.$('.connection-anchor'), <ConnectionInfo guild={guild} />);
// member-list
ElementsUtil.unmountReactComponent(q.$('.member-list-anchor'));
ElementsUtil.mountReactComponent(q.$('.member-list-anchor'), <MemberList guild={guild} />);

View File

@ -26,6 +26,7 @@ const ImageOverlay: FC<ImageOverlayProps> = (props: ImageOverlayProps) => {
// TODO: Handle errors
const [ resource, resourceError ] = GuildSubscriptions.useResourceSubscription(guild, resourceId);
// Note: We want this customization here since we need the resource buffer.
const [ resourceImgSrc, resourceImgSrcError ] = ReactHelper.useOneTimeAsyncAction(
async () => await ElementsUtil.getImageSrcFromBufferFailSoftly(resource?.data ?? null),
'./img/loading.svg',

View File

@ -4,7 +4,7 @@ import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import React, { createRef, FC, MutableRefObject, useEffect, useMemo, useState } from 'react';
import { ConnectionInfo } from '../../data-types';
import { ConnectionInfo, Member } from '../../data-types';
import Globals from '../../globals';
import CombinedGuild from '../../guild-combined';
import ImageEditInput from '../components/input-image-edit';
@ -18,23 +18,23 @@ import Button from '../components/button';
export interface PersonalizeOverlayProps {
document: Document;
guild: CombinedGuild;
connection: ConnectionInfo;
selfMember: ConnectionInfo | Member;
}
const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverlayProps) => {
const { document, guild, connection } = props;
const { document, guild, selfMember } = props;
if (connection.avatarResourceId === null) {
if (selfMember.avatarResourceId === null) {
throw new Error('bad avatar');
}
const [ avatarResource, avatarResourceError ] = GuildSubscriptions.useResourceSubscription(guild, connection.avatarResourceId)
const [ avatarResource, avatarResourceError ] = GuildSubscriptions.useResourceSubscription(guild, selfMember.avatarResourceId)
const displayNameInputRef = createRef<HTMLInputElement>();
const [ savedDisplayName, setSavedDisplayName ] = useState<string>(connection.displayName);
const [ savedDisplayName, setSavedDisplayName ] = useState<string>(selfMember.displayName);
const [ savedAvatarBuff, setSavedAvatarBuff ] = useState<Buffer | null>(null);
const [ displayName, setDisplayName ] = useState<string>(connection.displayName);
const [ displayName, setDisplayName ] = useState<string>(selfMember.displayName);
const [ avatarBuff, setAvatarBuff ] = useState<Buffer | null>(null);
const [ displayNameInputValid, setDisplayNameInputValid ] = useState<boolean>(false);

View File

@ -14,7 +14,7 @@ import { Channel } from '../../data-types';
import CombinedGuild from '../../guild-combined';
import Q from '../../q-module';
import ReactHelper from './react-helper';
export interface HTMLElementWithRemoveSelf extends HTMLElement {
removeSelf: (() => void);
}
@ -240,9 +240,7 @@ export default class BaseElements {
Z` }
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
static createContextMenu(document: Document, content: JSX.Element): HTMLElementWithRemoveSelf {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const element = ReactHelper.createElementFromJSX(
<div className="context">
<div className="menu">{content}</div>
@ -252,15 +250,12 @@ export default class BaseElements {
element.addEventListener('mousedown', (e: Event) => {
e.stopPropagation(); // stop the bubble
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
element.removeSelf = () => {
if (element.parentElement) {
element.parentElement.removeChild(element);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
document.body.removeEventListener('mousedown', element.removeSelf);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
document.body.addEventListener('mousedown', element.removeSelf);
return element as HTMLElementWithRemoveSelf;
}

View File

@ -20,7 +20,7 @@ import React from 'react';
import Overlay from '../components/overlay';
import ReactDOM from 'react-dom';
interface IAlignment {
export interface IAlignment {
left?: string;
centerX?: string;
right?: string;
@ -96,6 +96,7 @@ export default class ElementsUtil {
element.classList.remove('shaking-horizontal');
}
// TODO: Remove this in favor of useSubmitButton style stuff from ReactHelper
// Calls a function with the start parameter and then the inverse of the start parameter after a determined number of ms
// There is no way to cancel this function
// Useful for enabling a "shake" element for a pre-determined amount of time
@ -105,20 +106,6 @@ export default class ElementsUtil {
setState(old => !start);
}
static createShakingOnSubmit(props: ShakingOnSubmitProps): () => Promise<void> {
const { doSubmit, setSubmitting, setSubmitFailed, setShaking } = props;
return async () => {
setSubmitting(true);
const succeeded = await doSubmit();
setSubmitting(false);
setSubmitFailed(!succeeded);
if (!succeeded) {
await ElementsUtil.delayToggleState(setShaking, 400);
}
}
}
static async getImageBufferSrc(buffer: Buffer): Promise<string> {
const result = await FileType.fromBuffer(buffer);
switch (result && result.mime) {
@ -148,6 +135,7 @@ export default class ElementsUtil {
}
}
// Avoid this function. Use GuildSubscriptions.useSoftImgSrcResourceSubscription instead
static async getImageSrcFromResourceFailSoftly(guild: CombinedGuild, resourceId: string | null): Promise<string> {
if (resourceId === null) {
return './img/loading.svg';

View File

@ -14,6 +14,7 @@ import { IDQuery, PartialMessageListQuery } from '../../auto-verifier-with-args'
import { Token, Channel } from '../../data-types';
import ReactHelper from './react-helper';
import Globals from '../../globals';
import ElementsUtil from './elements-util';
export type SingleSubscriptionEvents = {
'fetch': () => void;
@ -573,6 +574,22 @@ export default class GuildSubscriptions {
}, fetchResourceFunc);
}
static useSoftImageSrcResourceSubscription(guild: CombinedGuild, resourceId: string | null): [ imgSrc: string ] {
const [ value, fetchError ] = GuildSubscriptions.useResourceSubscription(guild, resourceId);
const [ imgSrc ] = ReactHelper.useOneTimeAsyncAction(
async () => {
if (fetchError) return './img/error.png';
if (!value) return './img/loading.svg';
return await ElementsUtil.getImageSrcFromBufferFailSoftly(value.data);
},
'./img/loading.svg',
[ value, fetchError ]
);
return [ imgSrc ];
}
static useChannelsSubscription(guild: CombinedGuild) {
const fetchChannelsFunc = useCallback(async () => {
return await guild.fetchChannels();
@ -607,6 +624,26 @@ export default class GuildSubscriptions {
}, fetchMembersFunc);
}
static useSelfMemberSubscription(guild: CombinedGuild): [ selfMember: Member | null ] {
const [ fetchRetryCallable, members, fetchError ] = GuildSubscriptions.useMembersSubscription(guild);
// TODO: Show an error if we can't fetch and allow retry
const selfMember = useMemo(() => {
if (members) {
const member = members.find(m => m.id === guild.memberId);
if (!member) {
LOG.warn('Unable to find self in members');
return null;
}
return member;
}
return null;
}, [ guild.memberId, members ]);
return [ selfMember ];
}
static useTokensSubscription(guild: CombinedGuild) {
const fetchTokensFunc = useCallback(async () => {
//LOG.silly('fetching tokens for subscription');

View File

@ -3,7 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import { DependencyList, Dispatch, MutableRefObject, SetStateAction, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DependencyList, Dispatch, MutableRefObject, RefObject, SetStateAction, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactDOMServer from "react-dom/server";
import { ShouldNeverHappenError } from "../../data-types";
import Util from '../../util';
@ -337,4 +337,21 @@ export default class ReactHelper {
return [ onScrollCallable, onLoadCallable, loadAbove, loadBelow ];
}
static useCloseWhenClickedOutsideEffect(ref: RefObject<HTMLElement>, close: () => void) {
const handleClickOutside = useCallback((event: MouseEvent) => {
if (!ref.current) return;
// Casting here is OK. https://stackoverflow.com/q/61164018
if (ref.current.contains(event.target as Node)) return;
close();
}, [ ref, close ]);
useEffect(() => {
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, [ handleClickOutside ]);
}
}

View File

@ -0,0 +1,58 @@
import * as electronRemote from '@electron/remote';
const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import React, { FC, useCallback, useMemo, useRef, useState } from 'react';
import { Member } from '../../data-types';
import CombinedGuild from '../../guild-combined';
import ConnectionInfoContext from '../context-menus/context-connection-info';
import MemberElement, { DummyMember } from '../lists/components/member-element';
import GuildSubscriptions from '../require/guild-subscriptions';
export interface ConnectionInfoProps {
guild: CombinedGuild;
}
const ConnectionInfo: FC<ConnectionInfoProps> = (props: ConnectionInfoProps) => {
const { guild } = props;
const rootRef = useRef<HTMLDivElement>(null);
// TODO: Respond to and emit global context menu events to prevent multiple
// context menus from being open at once. (maybe this isn't very reacty though)
const [ contextMenuOpen, setContextMenuOpen ] = useState<boolean>(false);
const [ selfMember ] = GuildSubscriptions.useSelfMemberSubscription(guild);
const displayMember = useMemo((): Member | DummyMember => {
if (!selfMember) {
return {
id: 'dummy',
displayName: 'Connecting...',
status: 'unknown',
roleColor: null,
avatarResourceId: null
};
}
return selfMember;
}, [ selfMember ]);
const contextMenu = useMemo(() => {
if (!selfMember) return null;
return <ConnectionInfoContext guild={guild} selfMember={selfMember} relativeToRef={rootRef} close={() => { console.log('close'); setContextMenuOpen(false); }} />
}, [ guild, selfMember, rootRef ]);
const toggleContextMenu = useCallback(() => {
setContextMenuOpen(oldContextMenuOpen => !!contextMenu && !oldContextMenuOpen);
}, [ contextMenu ]);
return (
<div ref={rootRef} className="connection-info">
<div onClick={toggleContextMenu}><MemberElement guild={guild} member={displayMember} /></div>
{contextMenuOpen ? contextMenu : null}
</div>
);
}
export default ConnectionInfo;

View File

@ -46,16 +46,7 @@
<div id="guild-sidebar">
<div id="guild-name-container"><span id="guild-name"></span></div>
<div id="channel-list"></div>
<div id="connection" class="hidden member">
<div class="icon">
<img id="member-avatar" class="avatar" src="./img/loading.svg" alt="Loading...">
<div id="member-status-circle" class="status-circle offline"></div>
</div>
<div class="text">
<div id="member-name" class="name">Connecting...</div>
<div id="member-status-text" class="status-text">Connecting...</div>
</div>
</div>
<div class="connection-anchor"></div>
</div>
<div id="channel">
<div class="channel-title-anchor"></div>

View File

@ -18,7 +18,6 @@ import { Changes, Channel, ConnectionInfo, GuildMetadata, Member, Resource, Toke
import Q from './q-module';
import bindWindowButtonEvents from './elements/events-window-buttons';
import bindTextInputEvents from './elements/events-text-input';
import bindConnectionEvents from './elements/events-connection';
import bindAddGuildTitleEvents from './elements/events-guild-title';
import bindAddGuildEvents from './elements/events-add-guild';
import PersonalDB from './personal-db';
@ -74,7 +73,6 @@ window.addEventListener('DOMContentLoaded', () => {
bindWindowButtonEvents(q);
bindTextInputEvents(document, q, ui);
bindConnectionEvents(document, q, ui);
bindAddGuildTitleEvents(document, q, ui);
bindAddGuildEvents(document, q, ui, guildsManager);

View File

@ -1,23 +1,16 @@
@import "theme.scss";
#connection.hidden {
visibility: hidden;
}
#connection.member {
.connection-info {
background-color: $background-secondary-alt;
padding: 8px;
cursor: pointer; /* clicking on this brings up the select-status context menu */
}
#connection.member .name {
width: calc(224px - 40px);
}
.name {
width: calc(224px - 40px);
font-weight: 600;
}
#member-name.name {
font-weight: 600;
}
#member-status-circle.status-circle {
border-color: $background-secondary-alt;
.status-circle {
border-color: $background-secondary-alt;
}
}

View File

@ -5,6 +5,12 @@
.context {
position: fixed;
// Since useEffect gets called after the element is rendered, hide it until
// it gets aligned
&.react:not(.aligned) {
visibility: hidden;
}
.menu {
min-width: 180px;
box-sizing: border-box;

View File

@ -137,26 +137,10 @@ export default class UI {
await this.lockConnection(guild, () => {
this.activeConnection = connection;
this.q.$('#connection').className = 'member ' + connection.status;
this.q.$('#member-name').innerText = connection.displayName;
this.q.$('#member-status-text').innerText = connection.status;
this.q.$('#guild').className = '';
for (const privilege of connection.privileges) {
this.q.$('#guild').classList.add('privilege-' + privilege);
}
if (connection.avatarResourceId) {
(async () => {
// Update avatar
if (this.activeGuild === null || this.activeGuild.id !== guild.id) return;
const src = await ElementsUtil.getImageSrcFromResourceFailSoftly(guild, connection.avatarResourceId);
if (this.activeGuild === null || this.activeGuild.id !== guild.id) return;
(this.q.$('#member-avatar') as HTMLImageElement).src = src;
})();
} else {
(this.q.$('#member-avatar') as HTMLImageElement).src = './img/loading.svg';
}
});
}