split off context-menu into sub-functional component

This commit is contained in:
Michael Peters 2021-12-26 13:41:17 -06:00
parent 6f26181b43
commit f7433c23be
17 changed files with 94 additions and 284 deletions

View File

@ -20,27 +20,4 @@ export default class Actions {
ui.setActiveConnection(guild, { id: null, avatarResourceId: null, displayName: 'Error', status: 'Error', privileges: [], roleName: null, roleColor: null, rolePriority: null }); ui.setActiveConnection(guild, { id: null, avatarResourceId: null, displayName: 'Error', status: 'Error', privileges: [], roleName: null, roleColor: null, rolePriority: null });
} }
} }
static async fetchAndUpdateChannels(q: Q, ui: UI, guild: CombinedGuild) {
await Util.withPotentialErrorWarnOnCancel(q, {
taskFunc: async () => {
if (ui.activeGuild === null || ui.activeGuild.id !== guild.id) return;
const channels = await guild.fetchChannels();
await ui.setChannels(guild, channels);
if (ui.activeGuild === null || ui.activeGuild.id !== guild.id) return;
if (ui.activeChannel === null) {
// click on the first channel in the list if no channel is active yet
const element = q.$_('#channel-list .channel');
if (element) {
element.click();
}
}
},
errorIndicatorAddFunc: async (errorIndicatorElement) => {
await ui.setChannelsErrorIndicator(guild, errorIndicatorElement);
},
errorContainer: q.$('#channel-list'),
errorMessage: 'Error fetching channels'
});
}
} }

View File

@ -1,54 +0,0 @@
import React from 'react';
import ReactHelper from './require/react-helper';
import ElementsUtil from './require/elements-util';
import BaseElements from './require/base-elements';
import { Channel } from '../data-types';
import UI from '../ui';
import Q from '../q-module';
import CombinedGuild from '../guild-combined';
import ChannelOverlay from './overlays/overlay-channel';
export default function createChannel(document: Document, q: Q, ui: UI, guild: CombinedGuild, channel: Channel) {
const element = ReactHelper.createElementFromJSX(
<div className="channel text" data-id={channel.id} data-guild-id={guild.id}>
<div className="icon">{BaseElements.TEXT_CHANNEL_ICON}</div>
<div className="name">{channel.name}</div>
<div className="modify">{BaseElements.COG}</div>
</div>
);
element.addEventListener('click', async () => {
if (element.classList.contains('active')) return;
await ui.setActiveChannel(guild, channel);
q.$('#text-input').focus();
});
const modifyContextElement = q.create({ class: 'context', content: {
class: 'above', content: [
{ class: 'content text', content: 'Modify Channel' },
{ class: 'tab', content: BaseElements.Q_TAB_BELOW }
]
}}) as HTMLElement;
q.$$$(element, '.modify').addEventListener('click', async (e) => {
e.stopPropagation();
if (modifyContextElement.parentElement) {
modifyContextElement.parentElement.removeChild(modifyContextElement);
}
ElementsUtil.presentReactOverlay(document, <ChannelOverlay guild={guild} channel={channel} />);
});
q.$$$(element, '.modify').addEventListener('mouseenter', () => {
document.body.appendChild(modifyContextElement);
ElementsUtil.alignContextElement(modifyContextElement, q.$$$(element, '.modify'), { bottom: 'top', centerX: 'centerX' });
});
q.$$$(element, '.modify').addEventListener('mouseleave', () => {
if (modifyContextElement.parentElement) {
modifyContextElement.parentElement.removeChild(modifyContextElement);
}
});
return element;
}

View File

@ -1,42 +0,0 @@
import React, { FC, ReactNode, RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { ShouldNeverHappenError } from '../../../data-types';
import ElementsUtil, { IAlignment } from '../../require/elements-util';
import ReactHelper from '../../require/react-helper';
export interface ContextMenuProps {
relativeToRef?: RefObject<HTMLElement | null>;
relativeToPos?: { x: number, y: number };
alignment: IAlignment;
children: ReactNode;
close: () => void;
}
const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => {
const { relativeToRef, relativeToPos, alignment, children, close } = props;
const rootRef = useRef<HTMLDivElement>(null);
const [ aligned, setAligned ] = useState<boolean>(false);
ReactHelper.useCloseWhenClickedOutsideEffect(rootRef, close);
useEffect(() => {
if (!rootRef.current) return;
const relativeTo = (relativeToRef && relativeToRef.current) ?? relativeToPos ?? null;
if (!relativeTo) throw new ShouldNeverHappenError('invalid context menu props');
ElementsUtil.alignContextElement(rootRef.current, relativeTo, alignment);
setAligned(true);
}, [ rootRef, relativeToRef, relativeToPos ]);
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,31 @@
import React, { FC, ReactNode, RefObject, useRef } from 'react'
import { IAlignment } from '../../require/elements-util';
import ReactHelper from '../../require/react-helper';
import Context from './context';
export interface ContextMenuProps {
relativeToRef?: RefObject<HTMLElement>;
relativeToPos?: { x: number, y: number };
alignment: IAlignment;
children: ReactNode;
close: () => void;
}
// Automatically closes when clicked outside of and includes a <div class="menu"> subelement
const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => {
const { relativeToRef, relativeToPos, alignment, children, close } = props;
const rootRef = useRef<HTMLDivElement>(null);
ReactHelper.useCloseWhenClickedOutsideEffect(rootRef, close);
return (
<Context rootRef={rootRef} relativeToRef={relativeToRef} relativeToPos={relativeToPos} alignment={alignment}>
<div className="menu">
{children}
</div>
</Context>
);
}
export default ContextMenu;

View File

@ -0,0 +1,28 @@
import React, { FC, ReactNode, RefObject } from 'react';
import { IAlignment } from '../../require/elements-util';
import ReactHelper from '../../require/react-helper';
export interface ContextProps {
rootRef: RefObject<HTMLDivElement>;
relativeToRef?: RefObject<HTMLElement>;
relativeToPos?: { x: number, y: number };
alignment: IAlignment;
children: ReactNode;
}
// You should create a component like context-menu.tsx instead of using this class directly.
const Context: FC<ContextProps> = (props: ContextProps) => {
const { rootRef, relativeToRef, relativeToPos, alignment, children } = props;
const [ className ] = ReactHelper.useAlignment(
rootRef, relativeToRef ?? null, relativeToPos ?? null, alignment, 'context react'
);
return (
<div ref={rootRef} className={className}>
{children}
</div>
);
}
export default Context;

View File

@ -8,7 +8,7 @@ import ContextMenu from './components/context-menu';
export interface ConnectionInfoContextMenuProps { export interface ConnectionInfoContextMenuProps {
guild: CombinedGuild; guild: CombinedGuild;
selfMember: Member; selfMember: Member;
relativeToRef: RefObject<HTMLElement | null>; relativeToRef: RefObject<HTMLElement>;
close: () => void; close: () => void;
} }

View File

@ -95,11 +95,6 @@ export default function createGuildListGuild(document: Document, q: Q, ui: UI, g
ui.updateGuildName(guild, 'ERROR'); ui.updateGuildName(guild, 'ERROR');
} }
})(); })();
// Guild Channel List
(async () => {
await Actions.fetchAndUpdateChannels(q, ui, guild);
})();
}); });
element.addEventListener('contextmenu', (e) => { element.addEventListener('contextmenu', (e) => {

View File

@ -2,7 +2,7 @@ import moment from 'moment';
import React, { FC, MouseEvent, useCallback, useMemo, useState } from 'react'; import React, { FC, MouseEvent, useCallback, useMemo, useState } from 'react';
import { Member, Message } from '../../../data-types'; import { Member, Message } from '../../../data-types';
import CombinedGuild from '../../../guild-combined'; import CombinedGuild from '../../../guild-combined';
import ImageContextMenu from '../../context-menus/context-menu-image'; import ImageContextMenu from '../../contexts/context-menu-image';
import ImageOverlay from '../../overlays/overlay-image'; import ImageOverlay from '../../overlays/overlay-image';
import ElementsUtil from '../../require/elements-util'; import ElementsUtil from '../../require/elements-util';
import GuildSubscriptions from '../../require/guild-subscriptions'; import GuildSubscriptions from '../../require/guild-subscriptions';

View File

@ -9,7 +9,7 @@ import ElementsUtil from '../require/elements-util';
import DownloadButton from '../components/button-download'; import DownloadButton from '../components/button-download';
import ReactHelper from '../require/react-helper'; import ReactHelper from '../require/react-helper';
import GuildSubscriptions from '../require/guild-subscriptions'; import GuildSubscriptions from '../require/guild-subscriptions';
import ImageContextMenu from '../context-menus/context-menu-image'; import ImageContextMenu from '../contexts/context-menu-image';
import Overlay from '../components/overlay'; import Overlay from '../components/overlay';
export interface ImageOverlayProps { export interface ImageOverlayProps {

View File

@ -12,6 +12,7 @@ import Globals from '../../globals';
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
import electron from 'electron'; import electron from 'electron';
import ElementsUtil, { IAlignment } from './elements-util';
// Helper function so we can use JSX before fully committing to React // Helper function so we can use JSX before fully committing to React
@ -396,6 +397,32 @@ export default class ReactHelper {
}, [ handleMouseUp ]); }, [ handleMouseUp ]);
} }
static useAlignment(
rootRef: RefObject<HTMLElement>,
relativeToRef: RefObject<HTMLElement | null> | null,
relativeToPos: { x: number, y: number } | null,
alignment: IAlignment,
baseClassName: string
): [
className: string
] {
const [ aligned, setAligned ] = useState<boolean>(false);
useEffect(() => {
if (!rootRef.current) return;
const relativeTo = (relativeToRef && relativeToRef.current) ?? relativeToPos ?? null;
if (!relativeTo) throw new ShouldNeverHappenError('invalid alignment props');
ElementsUtil.alignContextElement(rootRef.current, relativeTo, alignment);
setAligned(true);
}, [ rootRef, relativeToRef, relativeToPos ]);
const className = useMemo(() => {
return baseClassName + (aligned ? ' aligned' : '');
}, [ baseClassName, aligned ]);
return [ className ];
}
static useContextMenu( static useContextMenu(
createContextMenu: (close: () => void) => ReactNode, createContextMenu: (close: () => void) => ReactNode,
createContextMenuDeps: DependencyList createContextMenuDeps: DependencyList

View File

@ -8,7 +8,7 @@ import { Member } from '../../data-types';
import CombinedGuild from '../../guild-combined'; import CombinedGuild from '../../guild-combined';
import MemberElement, { DummyMember } from '../lists/components/member-element'; import MemberElement, { DummyMember } from '../lists/components/member-element';
import GuildSubscriptions from '../require/guild-subscriptions'; import GuildSubscriptions from '../require/guild-subscriptions';
import ConnectionInfoContextMenu from '../context-menus/context-menu-connection-info'; import ConnectionInfoContextMenu from '../contexts/context-menu-connection-info';
import ReactHelper from '../require/react-helper'; import ReactHelper from '../require/react-helper';
export interface ConnectionInfoProps { export interface ConnectionInfoProps {

View File

@ -1,6 +1,6 @@
import React, { FC, useRef } from 'react'; import React, { FC, useRef } from 'react';
import CombinedGuild from '../../guild-combined'; import CombinedGuild from '../../guild-combined';
import GuildTitleContextMenu from '../context-menus/context-menu-guild-title'; import GuildTitleContextMenu from '../contexts/context-menu-guild-title';
import GuildSubscriptions from '../require/guild-subscriptions'; import GuildSubscriptions from '../require/guild-subscriptions';
import ReactHelper from '../require/react-helper'; import ReactHelper from '../require/react-helper';

View File

@ -14,7 +14,7 @@ import Globals from './globals';
import UI from './ui'; import UI from './ui';
import Actions from './actions'; import Actions from './actions';
import { Changes, Channel, ConnectionInfo, GuildMetadata, Member, Resource, Token } from './data-types'; import { Changes, ConnectionInfo, GuildMetadata, Member, Resource, Token } from './data-types';
import Q from './q-module'; import Q from './q-module';
import bindWindowButtonEvents from './elements/events-window-buttons'; import bindWindowButtonEvents from './elements/events-window-buttons';
import bindTextInputEvents from './elements/events-text-input'; import bindTextInputEvents from './elements/events-text-input';
@ -90,10 +90,7 @@ window.addEventListener('DOMContentLoaded', () => {
(async () => { // update connection info (async () => { // update connection info
await Actions.fetchAndUpdateConnection(ui, guild); await Actions.fetchAndUpdateConnection(ui, guild);
})(); })();
(async () => { // refresh channels list // TODO: React set hasMessagesAbove and hasMessagesBelow when re-verified?
await Actions.fetchAndUpdateChannels(q, ui, guild);
})();
// TODO: React set hasMessagesAbove and hasMessagesBelow when re-verified
}); });
guildsManager.on('disconnect', (guild: CombinedGuild) => { guildsManager.on('disconnect', (guild: CombinedGuild) => {
@ -132,21 +129,6 @@ window.addEventListener('DOMContentLoaded', () => {
} }
}); });
guildsManager.on('remove-channels', async (guild: CombinedGuild, channels: Channel[]) => {
LOG.debug(channels.length + ' removed channels');
await ui.deleteChannels(guild, channels);
});
guildsManager.on('update-channels', async (guild: CombinedGuild, updatedChannels: Channel[]) => {
LOG.debug(updatedChannels.length + ' updated channels');
await ui.updateChannels(guild, updatedChannels);
});
guildsManager.on('new-channels', async (guild: CombinedGuild, channels: Channel[]) => {
LOG.debug(channels.length + ' added channels');
await ui.addChannels(guild, channels);
});
// TODO: React jump messages to bottom when the current user sent a message // TODO: React jump messages to bottom when the current user sent a message
// Conflict Events // Conflict Events
@ -160,13 +142,6 @@ window.addEventListener('DOMContentLoaded', () => {
})(); })();
}); });
guildsManager.on('conflict-channels', async (guild: CombinedGuild, changesType: AutoVerifierChangesType, changes: Changes<Channel>) => {
LOG.debug('channels conflict', { changes });
if (changes.deleted.length > 0) await ui.deleteChannels(guild, changes.deleted);
if (changes.added.length > 0) await ui.addChannels(guild, changes.added);
if (changes.updated.length > 0) await ui.updateChannels(guild, changes.updated.map(pair => pair.newDataPoint));
});
guildsManager.on('conflict-members', async (guild: CombinedGuild, changesType: AutoVerifierChangesType, changes: Changes<Member>) => { guildsManager.on('conflict-members', async (guild: CombinedGuild, changesType: AutoVerifierChangesType, changes: Changes<Member>) => {
//LOG.debug('members conflict', { changes }); //LOG.debug('members conflict', { changes });
if (changes.updated.length > 0) { if (changes.updated.length > 0) {

View File

@ -1,9 +1,5 @@
@import "theme.scss"; @import "theme.scss";
#channel-list {
display: none;
}
.channel-list { .channel-list {
box-sizing: border-box; box-sizing: border-box;
padding-top: 8px; padding-top: 8px;

View File

@ -11,7 +11,6 @@ import CombinedGuild from './guild-combined';
import { Message, Channel, ConnectionInfo, ShouldNeverHappenError } from './data-types'; import { Message, Channel, ConnectionInfo, ShouldNeverHappenError } from './data-types';
import Q from './q-module'; import Q from './q-module';
import createGuildListGuild from './elements/guild-list-guild'; import createGuildListGuild from './elements/guild-list-guild';
import createChannel from './elements/channel';
import GuildsManager from './guilds-manager'; import GuildsManager from './guilds-manager';
import { mountGuildChannelComponents, mountGuildComponents } from './elements/mounts'; import { mountGuildChannelComponents, mountGuildComponents } from './elements/mounts';
@ -114,22 +113,11 @@ export default class UI {
public async setActiveChannel(guild: CombinedGuild, channel: Channel): Promise<void> { public async setActiveChannel(guild: CombinedGuild, channel: Channel): Promise<void> {
await this.lockChannels(guild, () => { await this.lockChannels(guild, () => {
// Channel List Highlight
if (this.activeChannel !== null) {
const prev = this.q.$_('#channel-list .channel[data-id="' + this.activeChannel.id + '"]');
if (prev) {
prev.classList.remove('active');
}
}
const next = this.q.$('#channel-list .channel[data-id="' + channel.id + '"]');
next.classList.add('active');
// Channel Name + Flavor Text Header + Text Input Placeholder // Channel Name + Flavor Text Header + Text Input Placeholder
this.q.$('#text-input').setAttribute('data-placeholder', 'Message #' + channel.name); this.q.$('#text-input').setAttribute('data-placeholder', 'Message #' + channel.name);
mountGuildChannelComponents(this.q, guild, channel);
this.activeChannel = channel; this.activeChannel = channel;
mountGuildChannelComponents(this.q, guild, channel);
}); });
} }
@ -184,115 +172,4 @@ export default class UI {
baseElement.setAttribute('meta-name', name); baseElement.setAttribute('meta-name', name);
}); });
} }
private _updatePosition<T>(element: Element, guildCacheMap: Map<string | null, T>, getDirection: ((prevData: T, data: T) => number)) {
const data = guildCacheMap.get(element.getAttribute('data-id'));
if (!data) {
LOG.debug('cache map: ', { guildCacheMap, elementHtml: element.outerHTML })
throw new ShouldNeverHappenError('unable to get data from cache map');
}
// TODO: do-while may be a bit cleaner?
let prev = Q.previousElement(element);
while (prev != null) {
const prevData = guildCacheMap.get(prev.getAttribute('data-id'));
if (!prevData) throw new ShouldNeverHappenError('unable to get prevData from cache map');
if (getDirection(prevData, data) > 0) { // this element comes before previous element
prev.parentElement?.insertBefore(element, prev);
} else {
break;
}
prev = Q.previousElement(element);
}
let next = Q.nextElement(element);
while (next != null) {
const nextData = guildCacheMap.get(next.getAttribute('data-id'));
if (!nextData) throw new ShouldNeverHappenError('unable to get nextData from cache map');
if (getDirection(data, nextData) > 0) { // this element comes after next element
next.parentElement?.insertBefore(next, element);
} else {
break;
}
next = Q.nextElement(element);
}
}
public async updateChannelPosition(guild: CombinedGuild, channelElement: Element): Promise<void> {
this._updatePosition(channelElement, await guild.grabRAMChannelsMap(), (a, b) => {
return a.index - b.index;
});
}
public async addChannels(guild: CombinedGuild, channels: Channel[], options?: { clear: boolean }): Promise<void> {
await this.lockChannels(guild, async () => {
if (options?.clear) {
Q.clearChildren(this.q.$('#channel-list'));
}
for (const channel of channels) {
const element = createChannel(this.document, this.q, this, guild, channel);
this.q.$('#channel-list').appendChild(element);
await this.updateChannelPosition(guild, element);
}
});
}
public async deleteChannels(guild: CombinedGuild, channels: Channel[]): Promise<void> {
await this.lockChannels(guild, () => {
for (const channel of channels) {
const element = this.q.$_('#channel-list .channel[data-id="' + channel.id + '"]');
element?.parentElement?.removeChild(element);
if (this.activeChannel !== null && this.activeChannel.id == channel.id) {
this.activeChannel = null;
}
}
});
}
public async updateChannels(guild: CombinedGuild, updatedChannels: Channel[]): Promise<void> {
await this.lockChannels(guild, async () => {
for (const channel of updatedChannels) {
const oldElement = this.q.$('#channel-list .channel[data-id="' + channel.id + '"]');
const newElement = createChannel(this.document, this.q, this, guild, channel);
oldElement.parentElement?.replaceChild(newElement, oldElement);
await this.updateChannelPosition(guild, newElement);
if (this.activeChannel !== null && this.activeChannel.id === channel.id) {
newElement.classList.add('active');
// See also setActiveChannel
this.q.$('#text-input').setAttribute('placeholder', 'Message #' + channel.name);
}
}
});
}
public async setChannels(guild: CombinedGuild, channels: Channel[]): Promise<void> {
// check if an element with the same channel and guild exists before adding the new channels
// this is nescessary to make sure that if two guilds have channels with the same id, the channel list is still
// properly refreshed and the active channel is not improperly set.
let oldMatchingElement: HTMLElement | null = null;
if (this.activeGuild !== null && this.activeChannel !== null) {
oldMatchingElement = this.q.$_('#channel-list .channel[data-id="' + this.activeChannel.id + '"][data-guild-id="' + this.activeGuild.id + '"]');
}
await this.addChannels(guild, channels, { clear: true });
if (this.activeGuild !== null && this.activeGuild.id === guild.id && this.activeChannel !== null) {
const newActiveElement = this.q.$_('#channel-list .channel[data-id="' + this.activeChannel.id + '"][data-guild-id="' + this.activeGuild.id + '"]');
if (newActiveElement && oldMatchingElement) {
const activeChannelId = this.activeChannel.id;
const channel = channels.find(channel => channel.id === activeChannelId);
if (channel === undefined) throw new ShouldNeverHappenError('current channel does not exist in channels list')
this.setActiveChannel(guild, channel);
} else {
this.activeChannel = null; // the active channel was removed
}
}
}
public async setChannelsErrorIndicator(guild: CombinedGuild, errorIndicatorElement: Element): Promise<void> {
await this.lockChannels(guild, () => {
Q.clearChildren(this.q.$('#channel-list'));
this.q.$('#channel-list').appendChild(errorIndicatorElement);
});
}
} }