RECOIL IS SUPREME!
This commit is contained in:
parent
7ceceecbb1
commit
222feb1de1
@ -47,7 +47,7 @@ export function isFailed<T>(withLoadableValue: WithLoadableValue<T>): withLoadab
|
||||
return withLoadableValue.hasValueError;
|
||||
}
|
||||
|
||||
// WARNING: This should NOT be used in a place that could prevent hooks from running
|
||||
// WARNING: This must NOT be used in a place that could prevent hooks from running
|
||||
// NOTE: This is useful to skip rendering elements that require an atom if its value is not available yet. Using this in the parent element will prevent the child eleemnt from
|
||||
// needing to implement the complexity of loading without a value
|
||||
export function isDevoidPendedOrFailed<T>(withNullableLoadableValue: WithLoadableValue<T> | null): withNullableLoadableValue is null | WithUnloadedValue | WithFailedValue {
|
@ -5,8 +5,8 @@ const LOG = Logger.create(__filename, electronConsole);
|
||||
|
||||
import React, { FC, ReactNode, RefObject, useCallback, useEffect } from "react";
|
||||
import { useActionWhenEscapeOrClickedOrContextOutsideEffect } from '../require/react-helper';
|
||||
import { overlayState } from '../require/atoms';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { overlayState } from '../require/atoms-2';
|
||||
|
||||
interface OverlayProps {
|
||||
childRootRef?: RefObject<HTMLElement>; // clicks outside this ref will close the overlay
|
||||
|
@ -1,9 +1,11 @@
|
||||
import moment from 'moment';
|
||||
import React, { FC } from 'react';
|
||||
import { GuildMetadata, Member, Token } from '../../data-types';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Member, Token } from '../../data-types';
|
||||
import CombinedGuild from '../../guild-combined';
|
||||
import Util from '../../util';
|
||||
import { IAddGuildData } from '../overlays/overlay-add-guild';
|
||||
import { GuildWithValue } from '../require/atoms';
|
||||
import { guildMetaState, isLoaded } from '../require/atoms-2';
|
||||
import BaseElements from '../require/base-elements';
|
||||
import { useSoftImageSrcResourceSubscription } from '../require/guild-subscriptions';
|
||||
import { useAsyncVoidCallback, useDownloadButton, useOneTimeAsyncAction } from '../require/react-helper';
|
||||
@ -12,33 +14,35 @@ import Button, { ButtonColorType } from './button';
|
||||
export interface TokenRowProps {
|
||||
url: string;
|
||||
token: Token;
|
||||
guildWithMeta: GuildWithValue<GuildMetadata>
|
||||
guild: CombinedGuild;
|
||||
}
|
||||
|
||||
const TokenRow: FC<TokenRowProps> = (props: TokenRowProps) => {
|
||||
const { token, guildWithMeta } = props;
|
||||
const { token, guild } = props;
|
||||
|
||||
const guildMeta = useRecoilValue(guildMetaState(guild.id));
|
||||
|
||||
const [ guildSocketConfigs ] = useOneTimeAsyncAction(
|
||||
async () => {
|
||||
return await guildWithMeta.guild.fetchSocketConfigs()
|
||||
return await guild.fetchSocketConfigs()
|
||||
},
|
||||
null,
|
||||
[ guildWithMeta ]
|
||||
[ guild ]
|
||||
);
|
||||
const [ iconSrc ] = useSoftImageSrcResourceSubscription(guildWithMeta.guild, guildWithMeta.value.iconResourceId, guildWithMeta.guild);
|
||||
const [ iconSrc ] = useSoftImageSrcResourceSubscription(guild, isLoaded(guildMeta) ? guildMeta.value.iconResourceId : null, guild);
|
||||
|
||||
const [ revoke ] = useAsyncVoidCallback(async () => {
|
||||
await guildWithMeta.guild.requestDoRevokeToken(token.token);
|
||||
}, [ guildWithMeta.guild, token ]);
|
||||
await guild.requestDoRevokeToken(token.token);
|
||||
}, [ guild, token ]);
|
||||
|
||||
const [ downloadFunc, downloadText, downloadShaking ] = useDownloadButton(
|
||||
guildWithMeta.value.name + '.cordis',
|
||||
isLoaded(guildMeta) ? guildMeta.value.name + '.cordis' : 'unknown-guild.cordis',
|
||||
async () => {
|
||||
if (guildSocketConfigs === null) return null;
|
||||
if (guildWithMeta === null || guildWithMeta.value === null) return null;
|
||||
if (!isLoaded(guildMeta)) return null; // TODO: Use placeholders / send error message?
|
||||
const guildSocketConfig = Util.randomChoice(guildSocketConfigs);
|
||||
const addGuildData: IAddGuildData = {
|
||||
name: guildWithMeta.value.name,
|
||||
name: guildMeta.value.name,
|
||||
url: guildSocketConfig.url,
|
||||
cert: guildSocketConfig.cert,
|
||||
token: token.token,
|
||||
@ -48,12 +52,12 @@ const TokenRow: FC<TokenRowProps> = (props: TokenRowProps) => {
|
||||
const json = JSON.stringify(addGuildData);
|
||||
return Buffer.from(json);
|
||||
},
|
||||
[ guildSocketConfigs, guildWithMeta, token, iconSrc ]
|
||||
[ guildSocketConfigs, guildMeta, token, iconSrc ]
|
||||
);
|
||||
|
||||
const userText = (token.member instanceof Member ? 'Used by ' + token.member.displayName : token.member?.id) ?? 'Unused Token';
|
||||
return (
|
||||
<div key={guildWithMeta.guild.id + token.token} className="token-row">
|
||||
<div key={guild.id + token.token} className="token-row">
|
||||
<div className="user-token">
|
||||
<div className="user">{userText}</div>
|
||||
<div className="token">{token.token}</div>
|
||||
|
@ -1,26 +1,28 @@
|
||||
import React, { FC, ReactNode, RefObject, useCallback, useMemo } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { GuildMetadata, Member } from '../../data-types';
|
||||
import { GuildWithValue, overlayState } from '../require/atoms';
|
||||
import { Member } from '../../data-types';
|
||||
import PersonalizeOverlay from '../overlays/overlay-personalize';
|
||||
import ContextMenu from './components/context-menu';
|
||||
import CombinedGuild from '../../guild-combined';
|
||||
import { overlayState } from '../require/atoms-2';
|
||||
|
||||
export interface ConnectionInfoContextMenuProps {
|
||||
guildWithSelfMember: GuildWithValue<Member>;
|
||||
guild: CombinedGuild;
|
||||
selfMember: Member;
|
||||
relativeToRef: RefObject<HTMLElement>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const ConnectionInfoContextMenu: FC<ConnectionInfoContextMenuProps> = (props: ConnectionInfoContextMenuProps) => {
|
||||
const { guildWithSelfMember, relativeToRef, close } = props;
|
||||
const { guild, selfMember, relativeToRef, close } = props;
|
||||
|
||||
const setOverlay = useSetRecoilState<ReactNode>(overlayState)
|
||||
|
||||
const setSelfStatus = useCallback(async (status: string) => {
|
||||
if (guildWithSelfMember.value.status !== status) {
|
||||
await guildWithSelfMember.guild.requestSetStatus(status);
|
||||
if (selfMember.status !== status) {
|
||||
await guild.requestSetStatus(status);
|
||||
}
|
||||
}, [ guildWithSelfMember ]);
|
||||
}, [ guild, selfMember ]);
|
||||
|
||||
const statusElements = useMemo(() => {
|
||||
return [ 'online', 'away', 'busy', 'invisible' ].map(status => {
|
||||
@ -36,7 +38,7 @@ const ConnectionInfoContextMenu: FC<ConnectionInfoContextMenuProps> = (props: Co
|
||||
|
||||
const openPersonalize = useCallback(() => {
|
||||
close();
|
||||
setOverlay(<PersonalizeOverlay guildWithSelfMember={guildWithSelfMember} />);
|
||||
setOverlay(<PersonalizeOverlay guild={guild} selfMember={selfMember} />);
|
||||
}, [ close ]);
|
||||
|
||||
const alignment = useMemo(() => {
|
||||
|
@ -5,33 +5,32 @@ const LOG = Logger.create(__filename, electronConsole);
|
||||
|
||||
import React, { FC, ReactNode, RefObject, useCallback, useMemo } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { Member } from '../../data-types';
|
||||
import { isDevoidPendedOrFailed, overlayState, selectedGuildWithMetaState } from '../require/atoms';
|
||||
import ChannelOverlay from '../overlays/overlay-channel';
|
||||
import GuildSettingsOverlay from '../overlays/overlay-guild-settings';
|
||||
import BaseElements from '../require/base-elements';
|
||||
import ContextMenu from './components/context-menu';
|
||||
import { currGuildSelfMemberState, currGuildState, isLoaded, overlayState } from '../require/atoms-2';
|
||||
|
||||
export interface GuildTitleContextMenuProps {
|
||||
close: () => void;
|
||||
relativeToRef: RefObject<HTMLElement>;
|
||||
selfMember: Member;
|
||||
}
|
||||
|
||||
const GuildTitleContextMenu: FC<GuildTitleContextMenuProps> = (props: GuildTitleContextMenuProps) => {
|
||||
const { close, relativeToRef, selfMember } = props;
|
||||
const { close, relativeToRef } = props;
|
||||
|
||||
const guildWithMeta = useRecoilValue(selectedGuildWithMetaState);
|
||||
const guild = useRecoilValue(currGuildState);
|
||||
const selfMember = useRecoilValue(currGuildSelfMemberState);
|
||||
|
||||
const setOverlay = useSetRecoilState<ReactNode>(overlayState);
|
||||
|
||||
const openGuildSettings = useCallback(() => {
|
||||
close();
|
||||
if (isDevoidPendedOrFailed(guildWithMeta)) {
|
||||
LOG.warn('not opening guild settings context menu due to devoid/pended/failed guildWithMeta');
|
||||
if (guild === null) {
|
||||
LOG.warn('not launching settings for a null guild');
|
||||
return;
|
||||
}
|
||||
setOverlay(<GuildSettingsOverlay guildWithMeta={guildWithMeta} />);
|
||||
setOverlay(<GuildSettingsOverlay guild={guild} />);
|
||||
}, [ close ]);
|
||||
|
||||
const openCreateChannel = useCallback(() => {
|
||||
@ -40,7 +39,8 @@ const GuildTitleContextMenu: FC<GuildTitleContextMenuProps> = (props: GuildTitle
|
||||
}, [ close ]);
|
||||
|
||||
const guildSettingsElement = useMemo(() => {
|
||||
if (!selfMember.privileges.includes('modify_profile')) return null;
|
||||
if (!isLoaded(selfMember)) return null;
|
||||
if (!selfMember.value.privileges.includes('modify_profile')) return null;
|
||||
return (
|
||||
<div className="item guild-settings" onClick={openGuildSettings}>
|
||||
<div className="icon">{BaseElements.COG}</div>
|
||||
@ -50,7 +50,8 @@ const GuildTitleContextMenu: FC<GuildTitleContextMenuProps> = (props: GuildTitle
|
||||
}, [ selfMember, openGuildSettings ]);
|
||||
|
||||
const createChannelElement = useMemo(() => {
|
||||
if (!selfMember.privileges.includes('modify_channels')) return null;
|
||||
if (!isLoaded(selfMember)) return null;
|
||||
if (!selfMember.value.privileges.includes('modify_channels')) return null;
|
||||
return (
|
||||
<div className="item create-channel" onClick={openCreateChannel}>
|
||||
<div className="icon">{BaseElements.CREATE}</div>
|
||||
|
@ -6,7 +6,7 @@ const LOG = Logger.create(__filename, electronConsole);
|
||||
import React, { FC, useEffect, useMemo, useState } from 'react';
|
||||
import Display from '../components/display';
|
||||
import InvitePreview from '../components/invite-preview';
|
||||
import { GuildMetadata, Token } from '../../data-types';
|
||||
import { Token } from '../../data-types';
|
||||
import { useAsyncSubmitButton } from '../require/react-helper';
|
||||
import { Duration } from 'moment';
|
||||
import moment from 'moment';
|
||||
@ -14,22 +14,27 @@ import DropdownInput from '../components/input-dropdown';
|
||||
import Button from '../components/button';
|
||||
import { useTokensSubscription, useSoftImageSrcResourceSubscription } from '../require/guild-subscriptions';
|
||||
import TokenRow from '../components/token-row';
|
||||
import { GuildWithValue } from '../require/atoms';
|
||||
import CombinedGuild from '../../guild-combined';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { guildMetaState, isLoaded } from '../require/atoms-2';
|
||||
|
||||
export interface GuildInvitesDisplayProps {
|
||||
guildWithMeta: GuildWithValue<GuildMetadata>;
|
||||
guild: CombinedGuild;
|
||||
}
|
||||
const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDisplayProps) => {
|
||||
const { guildWithMeta } = props;
|
||||
const { guild } = props;
|
||||
|
||||
const guildMeta = useRecoilValue(guildMetaState(guild.id));
|
||||
|
||||
const url = 'https://localhost:3030'; // TODO: this will likely be a dropdown list at some point
|
||||
|
||||
const [ _fetchRetryCallable, tokensResult, tokensError ] = useTokensSubscription(guildWithMeta.guild);
|
||||
// TODO: Recoilize tokens :)
|
||||
const [ _fetchRetryCallable, tokensResult, tokensError ] = useTokensSubscription(guild);
|
||||
|
||||
const [ expiresFromNow, setExpiresFromNow ] = useState<Duration | null>(moment.duration(1, 'day'));
|
||||
const [ expiresFromNowText, setExpiresFromNowText ] = useState<string>('1 day');
|
||||
|
||||
const [ iconSrc ] = useSoftImageSrcResourceSubscription(guildWithMeta.guild, guildWithMeta.value.iconResourceId ?? null, guildWithMeta.guild);
|
||||
const [ iconSrc ] = useSoftImageSrcResourceSubscription(guild, isLoaded(guildMeta) ? guildMeta.value.iconResourceId : null, guild);
|
||||
|
||||
useEffect(() => {
|
||||
if (expiresFromNowText === 'never') {
|
||||
@ -44,14 +49,14 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
|
||||
const [ createTokenFunc, tokenButtonText, tokenButtonShaking, _, createTokenFailMessage ] = useAsyncSubmitButton(
|
||||
async () => {
|
||||
try {
|
||||
const createdToken = await guildWithMeta.guild.requestDoCreateToken(expiresFromNowText === 'never' ? null : expiresFromNowText)
|
||||
const createdToken = await guild.requestDoCreateToken(expiresFromNowText === 'never' ? null : expiresFromNowText)
|
||||
return { result: createdToken, errorMessage: null };
|
||||
} catch (e: unknown) {
|
||||
LOG.error('error creating token', e);
|
||||
return { result: null, errorMessage: 'Error creating token' };
|
||||
}
|
||||
},
|
||||
[ guildWithMeta, expiresFromNowText ],
|
||||
[ guild, expiresFromNowText ],
|
||||
{ start: 'Create Token', done: 'Create Another Token' }
|
||||
);
|
||||
|
||||
@ -65,11 +70,9 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
|
||||
// TODO: Try Again
|
||||
return <div className="tokens-failed">Unable to load tokens</div>;
|
||||
}
|
||||
if (!guildWithMeta?.value) {
|
||||
return <div className="no-guild-meta">No Guild Metadata</div>;
|
||||
}
|
||||
return tokensResult?.value?.map((token: Token) => <TokenRow key={guildWithMeta.guild.id + token.token} url={url} token={token} guildWithMeta={guildWithMeta} />);
|
||||
}, [ url, guildWithMeta, tokensResult, tokensError ]);
|
||||
// TODO: Try again?
|
||||
return tokensResult?.value?.map((token: Token) => <TokenRow key={guild.id + token.token} url={url} token={token} guild={guild} />);
|
||||
}, [ url, guild, tokensResult, tokensError ]);
|
||||
|
||||
return (
|
||||
<Display
|
||||
@ -89,7 +92,7 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
|
||||
<div><Button shaking={tokenButtonShaking} onClick={createTokenFunc}>{tokenButtonText}</Button></div>
|
||||
</div>
|
||||
<InvitePreview
|
||||
name={guildWithMeta.value.name} iconSrc={iconSrc}
|
||||
name={isLoaded(guildMeta) ? guildMeta.value.name : 'Your Guild'} iconSrc={iconSrc}
|
||||
url={url} expiresFromNow={expiresFromNow}
|
||||
/>
|
||||
</div>
|
||||
@ -101,7 +104,8 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
|
||||
</div>
|
||||
</div>
|
||||
</Display>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default GuildInvitesDisplay;
|
||||
|
||||
|
@ -10,17 +10,20 @@ import Display from '../components/display';
|
||||
import TextInput from '../components/input-text';
|
||||
import ImageEditInput from '../components/input-image-edit';
|
||||
import { useResourceSubscription } from '../require/guild-subscriptions';
|
||||
import { GuildWithValue } from '../require/atoms';
|
||||
import { GuildMetadata } from '../../data-types';
|
||||
import CombinedGuild from '../../guild-combined';
|
||||
import { guildMetaState, isLoaded } from '../require/atoms-2';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
interface GuildOverviewDisplayProps {
|
||||
guildWithMeta: GuildWithValue<GuildMetadata>;
|
||||
guild: CombinedGuild;
|
||||
}
|
||||
const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOverviewDisplayProps) => {
|
||||
const { guildWithMeta } = props;
|
||||
const { guild } = props;
|
||||
|
||||
const guildMeta = useRecoilValue(guildMetaState(guild.id));
|
||||
|
||||
// TODO: Use the one from guild.tsx (for both of these?)
|
||||
const [ iconResourceResult, iconResourceError ] = useResourceSubscription(guildWithMeta.guild, guildWithMeta.value.iconResourceId, guildWithMeta.guild);
|
||||
const [ iconResourceResult, iconResourceError ] = useResourceSubscription(guild, isLoaded(guildMeta) ? guildMeta.value.iconResourceId : null, guild);
|
||||
|
||||
const [ savedName, setSavedName ] = useState<string | null>(null);
|
||||
const [ savedIconBuff, setSavedIconBuff ] = useState<Buffer | null>(null);
|
||||
@ -38,10 +41,10 @@ const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOvervie
|
||||
const [ iconInputMessage, setIconInputMessage ] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (guildWithMeta === null || guildWithMeta.value === null) return;
|
||||
if (name === savedName) setName(guildWithMeta.value.name);
|
||||
setSavedName(guildWithMeta.value.name);
|
||||
}, [ guildWithMeta ]);
|
||||
if (!isLoaded(guildMeta)) return;
|
||||
if (name === savedName) setName(guildMeta.value.name);
|
||||
setSavedName(guildMeta.value.name);
|
||||
}, [ guildMeta ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (iconResourceResult && iconResourceResult.value) {
|
||||
@ -82,7 +85,7 @@ const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOvervie
|
||||
if (name !== savedName) {
|
||||
// Save name
|
||||
try {
|
||||
await guildWithMeta.guild.requestSetGuildName(name);
|
||||
await guild.requestSetGuildName(name);
|
||||
setSavedName(name);
|
||||
} catch (e: unknown) {
|
||||
LOG.error('error setting guild name', e);
|
||||
@ -96,7 +99,7 @@ const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOvervie
|
||||
// Save icon
|
||||
try {
|
||||
LOG.debug('saving icon');
|
||||
await guildWithMeta.guild.requestSetGuildIcon(iconBuff);
|
||||
await guild.requestSetGuildIcon(iconBuff);
|
||||
setSavedIconBuff(iconBuff);
|
||||
} catch (e: unknown) {
|
||||
LOG.error('error setting guild icon', e);
|
||||
@ -107,7 +110,7 @@ const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOvervie
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
}, [ errorMessage, saving, name, savedName, iconBuff ]);
|
||||
}, [ name, iconBuff, errorMessage, saving, guild, iconBuff, savedIconBuff ]);
|
||||
|
||||
return (
|
||||
<Display
|
||||
|
@ -1,42 +1,29 @@
|
||||
import React, { Dispatch, FC, ReactNode, SetStateAction, useMemo } from 'react'
|
||||
import { Channel, Member } from '../../data-types';
|
||||
import CombinedGuild from '../../guild-combined';
|
||||
import React, { FC, useMemo } from 'react'
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Channel } from '../../data-types';
|
||||
import { currGuildChannelsState, currGuildSelfMemberState, currGuildState, isFailed, isLoaded } from '../require/atoms-2';
|
||||
import ChannelElement from './components/channel-element';
|
||||
|
||||
export interface ChannelListProps {
|
||||
guild: CombinedGuild;
|
||||
selfMember: Member | null;
|
||||
channels: Channel[] | null;
|
||||
channelsFetchError: unknown | null;
|
||||
activeChannel: Channel | null;
|
||||
setActiveChannel: Dispatch<SetStateAction<Channel | null>>;
|
||||
}
|
||||
const ChannelList: FC = () => {
|
||||
const guild = useRecoilValue(currGuildState);
|
||||
const selfMember = useRecoilValue(currGuildSelfMemberState);
|
||||
const channels = useRecoilValue(currGuildChannelsState);
|
||||
|
||||
const ChannelList: FC<ChannelListProps> = (props: ChannelListProps) => {
|
||||
const { guild, selfMember, channels, channelsFetchError, activeChannel, setActiveChannel } = props;
|
||||
|
||||
const hasModifyPrivilege = selfMember && selfMember.privileges.includes('modify_channels');
|
||||
const hasModifyPrivilege = useMemo(() => {
|
||||
if (!isLoaded(selfMember)) return false;
|
||||
return selfMember.value.privileges.includes('modify_channels');
|
||||
}, [ selfMember ]);
|
||||
|
||||
const baseClassName = hasModifyPrivilege ? 'channel-list modify_channels' : 'channel-list'
|
||||
|
||||
const channelElements = useMemo(() => {
|
||||
if (!selfMember) return null;
|
||||
if (channelsFetchError) {
|
||||
// TODO: Retry button on error (pass in retryChannels)
|
||||
return <div className="channels-failed">Unable to load channels</div>
|
||||
}
|
||||
if (!channels) {
|
||||
return <div className="channels-loading">Loading channels...</div>
|
||||
}
|
||||
return channels.map((channel: Channel) => (
|
||||
<ChannelElement
|
||||
key={guild.id + channel.id} guild={guild}
|
||||
selfMember={selfMember}
|
||||
channel={channel} activeChannel={activeChannel}
|
||||
setActiveChannel={() => { setActiveChannel(channel); }}
|
||||
/>
|
||||
// TODO: Retry button on error
|
||||
if (isFailed(channels)) return <div className="channels-failed">Unable to load channels</div>;
|
||||
if (guild === null || !isLoaded(channels)) return <div className="channels-loading">Loading channels...</div>; // Unloaded/Pending
|
||||
return channels.value.map((channel: Channel) => (
|
||||
<ChannelElement key={guild.id + channel.id} guild={guild} channel={channel} />
|
||||
));
|
||||
}, [ selfMember, channelsFetchError, channels, guild, selfMember, activeChannel ]);
|
||||
}, [ channels, guild ]);
|
||||
|
||||
return (
|
||||
<div className={baseClassName}>
|
||||
|
@ -4,31 +4,31 @@ import Logger from '../../../../../logger/logger';
|
||||
const LOG = Logger.create(__filename, electronConsole);
|
||||
|
||||
import React, { Dispatch, FC, MouseEvent, ReactNode, SetStateAction, useCallback, useRef } from 'react'
|
||||
import { Channel, Member } from '../../../data-types';
|
||||
import { Channel } from '../../../data-types';
|
||||
import CombinedGuild from '../../../guild-combined';
|
||||
import ChannelOverlay from '../../overlays/overlay-channel';
|
||||
import BaseElements from '../../require/base-elements';
|
||||
import { useContextHover } from '../../require/react-helper';
|
||||
import BasicHover, { BasicHoverSide } from '../../contexts/context-hover-basic';
|
||||
import { overlayState } from '../../require/atoms';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { currGuildActiveChannelState, guildActiveChannelIdState, isLoaded, overlayState } from '../../require/atoms-2';
|
||||
|
||||
export interface ChannelElementProps {
|
||||
guild: CombinedGuild;
|
||||
channel: Channel;
|
||||
selfMember: Member; // Note: Expected to use this later since it may not be best to have css-based hiding
|
||||
activeChannel: Channel | null;
|
||||
setActiveChannel: Dispatch<SetStateAction<Channel | null>>;
|
||||
}
|
||||
|
||||
const ChannelElement: FC<ChannelElementProps> = (props: ChannelElementProps) => {
|
||||
const { guild, channel, selfMember, activeChannel, setActiveChannel } = props;
|
||||
const { guild, channel } = props;
|
||||
|
||||
const modifyRef = useRef<HTMLDivElement>(null);
|
||||
const activeChannel = useRecoilValue(currGuildActiveChannelState);
|
||||
const setActiveChannelId = useSetRecoilState(guildActiveChannelIdState(guild.id));
|
||||
|
||||
const setOverlay = useSetRecoilState<ReactNode>(overlayState);
|
||||
|
||||
const baseClassName = activeChannel?.id === channel.id ? 'channel text active' : 'channel text';
|
||||
const modifyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const baseClassName = (isLoaded(activeChannel) && activeChannel.value.id === channel.id) ? 'channel text active' : 'channel-text';
|
||||
|
||||
const [ modifyContextHover, modifyMouseEnterCallable, modifyMouseLeaveCallable ] = useContextHover(
|
||||
() => {
|
||||
@ -45,8 +45,8 @@ const ChannelElement: FC<ChannelElementProps> = (props: ChannelElementProps) =>
|
||||
|
||||
const setSelfActiveChannel = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
if (modifyRef.current?.contains(event.target as Node)) return; // ignore "modify" button clicks
|
||||
setActiveChannel(channel);
|
||||
}, [ modifyRef, channel ]);
|
||||
setActiveChannelId(channel.id);
|
||||
}, [ modifyRef, channel, setActiveChannelId ]);
|
||||
|
||||
const launchModify = useCallback(() => {
|
||||
setOverlay(<ChannelOverlay channel={channel} />);
|
||||
|
@ -1,51 +1,51 @@
|
||||
import React, { FC, useCallback, useMemo, useRef } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { GuildMetadata } from '../../../data-types';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import CombinedGuild from '../../../guild-combined';
|
||||
import ContextMenu from '../../contexts/components/context-menu';
|
||||
import BasicHover, { BasicHoverSide } from '../../contexts/context-hover-basic';
|
||||
import { guildsManagerState, GuildWithErrorableValue, selectedGuildIdState, selectedGuildState } from '../../require/atoms';
|
||||
import { currGuildIdState, guildMetaState, guildSelfMemberState, guildsManagerState, isLoaded } from '../../require/atoms-2';
|
||||
import BaseElements from '../../require/base-elements';
|
||||
import { IAlignment } from '../../require/elements-util';
|
||||
import { useSelfMemberSubscription, useSoftImageSrcResourceSubscription } from '../../require/guild-subscriptions';
|
||||
import { useSoftImageSrcResourceSubscription } from '../../require/guild-subscriptions';
|
||||
import { useContextClickContextMenu, useContextHover } from '../../require/react-helper';
|
||||
|
||||
export interface GuildListElementProps {
|
||||
guildWithMeta: GuildWithErrorableValue<GuildMetadata>,
|
||||
guild: CombinedGuild;
|
||||
}
|
||||
|
||||
const GuildListElement: FC<GuildListElementProps> = (props: GuildListElementProps) => {
|
||||
const { guildWithMeta: { guild, value: guildMeta, valueError: _guildMetaError } } = props;
|
||||
const { guild } = props;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const guildsManager = useRecoilValue(guildsManagerState);
|
||||
const selectedGuild = useRecoilValue(selectedGuildState);
|
||||
const setSelectedGuildId = useSetRecoilState(selectedGuildIdState);
|
||||
const [ currGuildId, setCurrGuildId ] = useRecoilState(currGuildIdState);
|
||||
const guildMeta = useRecoilValue(guildMetaState(guild.id));
|
||||
const selfMember = useRecoilValue(guildSelfMemberState(guild.id));
|
||||
|
||||
// TODO: more state higher up
|
||||
// TODO: handle metadata error
|
||||
const [ selfMemberResult ] = useSelfMemberSubscription(guild);
|
||||
const [ iconSrc ] = useSoftImageSrcResourceSubscription(guild, guildMeta?.iconResourceId ?? null, guild);
|
||||
const [ iconSrc ] = useSoftImageSrcResourceSubscription(guild, isLoaded(guildMeta) ? guildMeta.value.iconResourceId : null, guild);
|
||||
|
||||
const [ contextHover, mouseEnterCallable, mouseLeaveCallable ] = useContextHover(() => {
|
||||
if (!guildMeta) return null;
|
||||
if (!selfMemberResult || !selfMemberResult.value) return null;
|
||||
const nameStyle = selfMemberResult.value.roleColor ? { color: selfMemberResult.value.roleColor } : {};
|
||||
if (!isLoaded(guildMeta)) return null; // TODO: Loading message here?
|
||||
if (!isLoaded(selfMember)) return null; // TODO: Loading message here?
|
||||
const nameStyle = selfMember.value.roleColor ? { color: selfMember.value.roleColor } : {};
|
||||
return (
|
||||
<BasicHover relativeToRef={rootRef} side={BasicHoverSide.RIGHT} realignDeps={[ guildMeta, selfMemberResult ]}>
|
||||
<BasicHover relativeToRef={rootRef} side={BasicHoverSide.RIGHT} realignDeps={[ guildMeta, selfMember ]}>
|
||||
<div className="guild-hover">
|
||||
<div className="tab">{BaseElements.TAB_LEFT}</div>
|
||||
<div className="info">
|
||||
<div className="guild-name">{guildMeta.name}</div>
|
||||
<div className={'connection ' + selfMemberResult.value.status}>
|
||||
<div className="guild-name">{guildMeta.value.name}</div>
|
||||
<div className={'connection ' + selfMember.value.status}>
|
||||
<div className="status-circle" />
|
||||
<div className="display-name" style={nameStyle}>{selfMemberResult.value.displayName}</div>
|
||||
<div className="display-name" style={nameStyle}>{selfMember.value.displayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicHover>
|
||||
)
|
||||
}, [ guildMeta, selfMemberResult ]);
|
||||
}, [ guildMeta, selfMember ]);
|
||||
|
||||
const leaveGuildCallable = useCallback(async () => {
|
||||
if (!guildsManager) return;
|
||||
@ -67,12 +67,12 @@ const GuildListElement: FC<GuildListElementProps> = (props: GuildListElementProp
|
||||
|
||||
|
||||
const setSelfActiveGuild = useCallback(() => {
|
||||
setSelectedGuildId(guild.id);
|
||||
setCurrGuildId(guild.id);
|
||||
}, [ guild ]);
|
||||
|
||||
const className = useMemo(() => {
|
||||
return selectedGuild && guild.id === selectedGuild.id ? 'guild active' : 'guild';
|
||||
}, [ guild, selectedGuild ]);
|
||||
return currGuildId && guild.id === currGuildId ? 'guild active' : 'guild';
|
||||
}, [ guild, currGuildId ]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -3,9 +3,9 @@ import React, { FC, ReactNode, useCallback, useMemo } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { Member, Message } from '../../../data-types';
|
||||
import CombinedGuild from '../../../guild-combined';
|
||||
import { overlayState } from '../../require/atoms';
|
||||
import ImageContextMenu from '../../contexts/context-menu-image';
|
||||
import ImageOverlay from '../../overlays/overlay-image';
|
||||
import { overlayState } from '../../require/atoms-2';
|
||||
import ElementsUtil, { IAlignment } from '../../require/elements-util';
|
||||
import { useSoftImageSrcResourceSubscription } from '../../require/guild-subscriptions';
|
||||
import { useContextClickContextMenu, useDownloadButton, useOneTimeAsyncAction } from '../../require/react-helper';
|
||||
|
@ -1,20 +1,15 @@
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { GuildMetadata } from '../../data-types';
|
||||
import { guildsState, GuildWithErrorableValue } from '../require/atoms';
|
||||
import { allGuildsState } from '../require/atoms-2';
|
||||
import GuildListElement from './components/guild-list-element';
|
||||
|
||||
const GuildList: FC = () => {
|
||||
const guildsWithMeta = useRecoilValue(guildsState);
|
||||
const allGuilds = useRecoilValue(allGuildsState);
|
||||
|
||||
const guildElements = useMemo(() => {
|
||||
if (!guildsWithMeta) return null;
|
||||
return guildsWithMeta.map((guildWithMeta: GuildWithErrorableValue<GuildMetadata>) => (
|
||||
<GuildListElement
|
||||
guildWithMeta={guildWithMeta} key={guildWithMeta.guild.id}
|
||||
/>
|
||||
));
|
||||
}, [ guildsWithMeta ]);
|
||||
if (!allGuilds) return null;
|
||||
return allGuilds.map(guild => <GuildListElement key={guild.id} guild={guild} />)
|
||||
}, [ allGuilds ]);
|
||||
|
||||
return (
|
||||
<div className="guild-list">
|
||||
|
@ -7,18 +7,19 @@ import React, { FC, useMemo } from 'react';
|
||||
import { Member } from '../../data-types';
|
||||
import MemberElement from './components/member-element';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDevoid, isPended, isFailed, selectedGuildWithMembersState } from '../require/atoms';
|
||||
import { currGuildMembersState, currGuildState, isFailed, isPended, isUnload } from '../require/atoms-2';
|
||||
|
||||
const MemberList: FC = () => {
|
||||
const guildWithMembers = useRecoilValue(selectedGuildWithMembersState);
|
||||
const guild = useRecoilValue(currGuildState);
|
||||
const members = useRecoilValue(currGuildMembersState);
|
||||
|
||||
const memberElements = useMemo(() => {
|
||||
if (isDevoid(guildWithMembers)) return null;
|
||||
if (isPended(guildWithMembers)) return <div className="members-loading">Loading Members...</div>;
|
||||
if (isFailed(guildWithMembers)) return <div className="members-failed">Unable to load members</div>;
|
||||
if (!guild || isUnload(members)) return null
|
||||
if (isPended(members)) return <div className="members-loading">Loading Members...</div>;
|
||||
if (isFailed(members)) return <div className="members-failed">Unable to load members</div>;
|
||||
//LOG.debug(`drawing ${membersResult.value.length} members`);
|
||||
return guildWithMembers.value.map((member: Member) => <MemberElement key={guildWithMembers.guild.id + member.id} guild={guildWithMembers.guild} member={member} />);
|
||||
}, [ guildWithMembers ]);
|
||||
return members.value.map((member: Member) => <MemberElement key={guild.id + member.id} guild={guild} member={member} />);
|
||||
}, [ guild, members ]);
|
||||
|
||||
return (
|
||||
<div className="member-list">
|
||||
|
@ -17,7 +17,7 @@ import * as fs from 'fs/promises';
|
||||
import Button from '../components/button';
|
||||
import Overlay from '../components/overlay';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { guildsManagerState, overlayState, selectedGuildIdState } from '../require/atoms';
|
||||
import { currGuildIdState, guildsManagerState, overlayState } from '../require/atoms-2';
|
||||
|
||||
export interface IAddGuildData {
|
||||
name: string,
|
||||
@ -58,7 +58,7 @@ const AddGuildOverlay: FC<AddGuildOverlayProps> = (props: AddGuildOverlayProps)
|
||||
const { addGuildData } = props;
|
||||
|
||||
const guildsManager = useRecoilValue(guildsManagerState);
|
||||
const setSelectedGuildId = useSetRecoilState(selectedGuildIdState);
|
||||
const setCurrGuildId = useSetRecoilState(currGuildIdState);
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const setOverlay = useSetRecoilState<ReactNode>(overlayState);
|
||||
@ -109,7 +109,7 @@ const AddGuildOverlay: FC<AddGuildOverlayProps> = (props: AddGuildOverlayProps)
|
||||
return { result: null, errorMessage: (e as Error).message ?? 'Error adding new guild' };
|
||||
}
|
||||
|
||||
setSelectedGuildId(newGuild.id);
|
||||
setCurrGuildId(newGuild.id);
|
||||
|
||||
setOverlay(null);
|
||||
return { result: newGuild, errorMessage: null };
|
||||
|
@ -13,7 +13,7 @@ import { useAsyncSubmitButton } from '../require/react-helper';
|
||||
import Button from '../components/button';
|
||||
import Overlay from '../components/overlay';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { overlayState, selectedGuildState } from '../require/atoms';
|
||||
import { currGuildState, overlayState } from '../require/atoms-2';
|
||||
|
||||
export interface ChannelOverlayProps {
|
||||
channel?: Channel; // If this is undefined, this is an add-channel overlay
|
||||
@ -21,8 +21,7 @@ export interface ChannelOverlayProps {
|
||||
const ChannelOverlay: FC<ChannelOverlayProps> = (props: ChannelOverlayProps) => {
|
||||
const { channel } = props;
|
||||
|
||||
const guild = useRecoilValue(selectedGuildState);
|
||||
if (guild === null) return null;
|
||||
const guild = useRecoilValue(currGuildState);
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
@ -67,6 +66,7 @@ const ChannelOverlay: FC<ChannelOverlayProps> = (props: ChannelOverlayProps) =>
|
||||
|
||||
const [ submitFunc, submitButtonText, submitButtonShaking, submitFailMessage ] = useAsyncSubmitButton(
|
||||
async () => {
|
||||
if (guild === null) return { result: null, errorMessage: 'no guild' };
|
||||
if (validationErrorMessage) return { result: null, errorMessage: 'Invalid input' };
|
||||
|
||||
if (!edited) {
|
||||
@ -89,7 +89,7 @@ const ChannelOverlay: FC<ChannelOverlayProps> = (props: ChannelOverlayProps) =>
|
||||
setOverlay(null);
|
||||
return { result: null, errorMessage: null };
|
||||
},
|
||||
[ edited, validationErrorMessage, name, flavorText, setOverlay ],
|
||||
[ guild, edited, validationErrorMessage, name, flavorText, setOverlay ],
|
||||
{ start: channel ? 'Modify Channel' : 'Create Channel' }
|
||||
);
|
||||
|
||||
|
@ -3,14 +3,17 @@ import ChoicesControl from "../components/control-choices";
|
||||
import GuildInvitesDisplay from "../displays/display-guild-invites";
|
||||
import GuildOverviewDisplay from "../displays/display-guild-overview";
|
||||
import Overlay from '../components/overlay';
|
||||
import { GuildWithValue } from "../require/atoms";
|
||||
import { GuildMetadata } from "../../data-types";
|
||||
import CombinedGuild from "../../guild-combined";
|
||||
import { guildMetaState, isLoaded } from "../require/atoms-2";
|
||||
import { useRecoilValue } from "recoil";
|
||||
|
||||
interface GuildSettingsOverlayProps {
|
||||
guildWithMeta: GuildWithValue<GuildMetadata>;
|
||||
guild: CombinedGuild;
|
||||
}
|
||||
const GuildSettingsOverlay: FC<GuildSettingsOverlayProps> = (props: GuildSettingsOverlayProps) => {
|
||||
const { guildWithMeta } = props;
|
||||
const { guild } = props;
|
||||
|
||||
const guildMeta = useRecoilValue(guildMetaState(guild.id));
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -18,15 +21,15 @@ const GuildSettingsOverlay: FC<GuildSettingsOverlayProps> = (props: GuildSetting
|
||||
const [ display, setDisplay ] = useState<JSX.Element>();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === 'overview') setDisplay(<GuildOverviewDisplay guildWithMeta={guildWithMeta} />);
|
||||
if (selectedId === 'overview') setDisplay(<GuildOverviewDisplay guild={guild} />);
|
||||
//if (selectedId === 'roles' ) setDisplay(<GuildOverviewDisplay guild={guild} guildMeta={guildMeta} />);
|
||||
if (selectedId === 'invites' ) setDisplay(<GuildInvitesDisplay guildWithMeta={guildWithMeta} />);
|
||||
}, [ selectedId, guildWithMeta, setDisplay ]);
|
||||
if (selectedId === 'invites' ) setDisplay(<GuildInvitesDisplay guild={guild} />);
|
||||
}, [ guild, selectedId, setDisplay ]);
|
||||
|
||||
return (
|
||||
<Overlay childRootRef={rootRef}>
|
||||
<div ref={rootRef} className="content display-swapper guild-settings">
|
||||
<ChoicesControl title={guildWithMeta.value.name ?? 'unknown guild'} selectedId={selectedId} setSelectedId={setSelectedId} choices={[
|
||||
<ChoicesControl title={isLoaded(guildMeta) ? guildMeta.value.name : 'loading...'} selectedId={selectedId} setSelectedId={setSelectedId} choices={[
|
||||
{ id: 'overview', display: 'Overview' },
|
||||
{ id: 'roles', display: 'Roles' },
|
||||
{ id: 'invites', display: 'Invites' },
|
||||
|
@ -13,19 +13,19 @@ import Button from '../components/button';
|
||||
import Overlay from '../components/overlay';
|
||||
import { useResourceSubscription } from '../require/guild-subscriptions';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { overlayState, GuildWithValue } from '../require/atoms';
|
||||
import { Member } from '../../data-types';
|
||||
import CombinedGuild from '../../guild-combined';
|
||||
import { overlayState } from '../require/atoms-2';
|
||||
|
||||
interface PersonalizeOverlayProps {
|
||||
guildWithSelfMember: GuildWithValue<Member>;
|
||||
guild: CombinedGuild;
|
||||
selfMember: Member;
|
||||
}
|
||||
const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverlayProps) => {
|
||||
const { guildWithSelfMember } = props;
|
||||
const { guild, selfMember } = props;
|
||||
|
||||
const setOverlay = useSetRecoilState<ReactNode>(overlayState);
|
||||
|
||||
const { guild, value: selfMember } = guildWithSelfMember;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [ avatarResourceResult, avatarResourceError ] = useResourceSubscription(guild, selfMember.avatarResourceId, guild);
|
||||
|
586
src/client/webapp/elements/require/atoms-2.ts
Normal file
586
src/client/webapp/elements/require/atoms-2.ts
Normal file
@ -0,0 +1,586 @@
|
||||
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 { ReactNode, useEffect } from "react";
|
||||
import { atom, atomFamily, GetCallback, GetRecoilValue, RecoilState, RecoilValue, RecoilValueReadOnly, selector, selectorFamily, useSetRecoilState } from "recoil";
|
||||
import { Changes, Channel, GuildMetadata, Member } from "../../data-types";
|
||||
import CombinedGuild from "../../guild-combined";
|
||||
import GuildsManager from "../../guilds-manager";
|
||||
import { AutoVerifierChangesType } from '../../auto-verifier';
|
||||
import { Conflictable, Connectable } from '../../guild-types';
|
||||
|
||||
// General typescript type that infers the arguments of a function
|
||||
type Arguments<T> = T extends (...args: infer A) => unknown ? A : never;
|
||||
type Defined<T> = T extends undefined ? never : T | Awaited<T>;
|
||||
|
||||
export type UnloadedValue = {
|
||||
value: undefined;
|
||||
error: undefined;
|
||||
retry: undefined;
|
||||
hasError: false;
|
||||
loading: false;
|
||||
};
|
||||
export type LoadingValue = {
|
||||
value: undefined;
|
||||
error: undefined;
|
||||
retry: undefined;
|
||||
hasError: false;
|
||||
loading: true;
|
||||
};
|
||||
export type LoadedValue<T> = {
|
||||
value: Defined<T>;
|
||||
error: undefined;
|
||||
retry: () => Promise<void>;
|
||||
hasError: false;
|
||||
loading: false;
|
||||
};
|
||||
export type FailedValue = {
|
||||
value: undefined;
|
||||
error: unknown;
|
||||
retry: () => Promise<void>;
|
||||
hasError: true;
|
||||
loading: false;
|
||||
};
|
||||
export type LoadableValue<T> = UnloadedValue | LoadingValue | LoadedValue<T> | FailedValue;
|
||||
export type QueriedValue<T> = LoadingValue | LoadedValue<T> | FailedValue;
|
||||
|
||||
const DEF_UNLOADED_VALUE: UnloadedValue = { value: undefined, error: undefined, retry: undefined, hasError: false, loading: false };
|
||||
const DEF_PENDED_VALUE: LoadingValue = { value: undefined, error: undefined, retry: undefined, hasError: false, loading: true };
|
||||
function createLoadedValue<T>(value: Defined<T>, retry: () => Promise<void>): LoadedValue<T> {
|
||||
return {
|
||||
value,
|
||||
error: undefined,
|
||||
retry,
|
||||
hasError: false,
|
||||
loading: false
|
||||
};
|
||||
}
|
||||
function createFailedValue(error: unknown, retry: () => Promise<void>): FailedValue {
|
||||
return {
|
||||
value: undefined,
|
||||
error,
|
||||
retry,
|
||||
hasError: true,
|
||||
loading: false
|
||||
};
|
||||
}
|
||||
|
||||
export function isUnload<T>(loadableValue: LoadableValue<T>): loadableValue is UnloadedValue {
|
||||
return loadableValue.value === undefined && loadableValue.hasError === false && loadableValue.loading === false;
|
||||
}
|
||||
export function isPended<T>(loadableValue: LoadableValue<T>): loadableValue is LoadingValue {
|
||||
return loadableValue.loading === true;
|
||||
}
|
||||
export function isFailed<T>(loadableValue: LoadableValue<T>): loadableValue is FailedValue {
|
||||
return loadableValue.hasError === true;
|
||||
}
|
||||
export function isLoaded<T>(loadableValue: LoadableValue<T>): loadableValue is LoadedValue<T> {
|
||||
return loadableValue.value !== undefined;
|
||||
}
|
||||
|
||||
export const overlayState = atom<ReactNode>({
|
||||
key: 'overlayState',
|
||||
default: null
|
||||
});
|
||||
|
||||
export const guildsManagerState = atom<GuildsManager | null>({
|
||||
key: 'guildsManager',
|
||||
default: null,
|
||||
// Allow mutability so that we can have changes to internal guild stuff
|
||||
// We will still listen to new/update/delete/conflict events to maintain the UI's state
|
||||
dangerouslyAllowMutability: true
|
||||
});
|
||||
|
||||
// This will be a sliced copy of guildsManager.guilds
|
||||
export const allGuildsState = atom<CombinedGuild[] | null>({
|
||||
key: 'allGuildsListState',
|
||||
default: null,
|
||||
dangerouslyAllowMutability: true
|
||||
});
|
||||
|
||||
interface RecoilLoadableAtomEffectParams<T> {
|
||||
trigger: 'get' | 'set';
|
||||
setSelf: (loadableValue: LoadableValue<T>) => void;
|
||||
getPromise: <S>(recoilValue: RecoilValue<S>) => Promise<S>;
|
||||
}
|
||||
|
||||
function createFetchValueFunc<T>(
|
||||
getPromise: <S>(recoilValue: RecoilValue<S>) => Promise<S>,
|
||||
guildId: number,
|
||||
setSelf: (loadableValue: LoadableValue<T>) => void,
|
||||
stateAtomFamily: (guildId: number) => RecoilState<LoadableValue<T>>,
|
||||
fetchFunc: (guild: CombinedGuild) => Promise<Defined<T>>,
|
||||
): () => Promise<void> {
|
||||
const fetchValueFunc = async () => {
|
||||
const guild = await getPromise(guildState(guildId));
|
||||
if (guild === null) return; // Can't send a request without an associated guild
|
||||
|
||||
const selfState = await getPromise(stateAtomFamily(guildId));
|
||||
if (isPended(selfState)) return; // Don't send another request if we're already loading
|
||||
|
||||
setSelf(DEF_PENDED_VALUE);
|
||||
try {
|
||||
const value = await fetchFunc(guild);
|
||||
setSelf(createLoadedValue(value, fetchValueFunc));
|
||||
} catch (e: unknown) {
|
||||
LOG.error('unable to fetch initial guild metadata', e);
|
||||
setSelf(createFailedValue(e, fetchValueFunc));
|
||||
}
|
||||
}
|
||||
return fetchValueFunc;
|
||||
}
|
||||
|
||||
// Creates an event handler that directly applies the result of the eventArgsMap as a loadedValue into self
|
||||
function createDirectMappedEventHandler<T, XE extends keyof (Connectable | Conflictable)>(
|
||||
getPromise: <S>(recoilValue: RecoilValue<S>) => Promise<S>,
|
||||
guildId: number,
|
||||
setSelf: (loadableValue: LoadableValue<T>) => void,
|
||||
stateAtomFamily: (guildId: number) => RecoilState<LoadableValue<T>>,
|
||||
fetchValueFunc: () => Promise<void>,
|
||||
eventArgsMap: (...args: Arguments<(Connectable & Conflictable)[XE]>) => Defined<T>
|
||||
): (Connectable & Conflictable)[XE] {
|
||||
return ((...args: Arguments<(Connectable & Conflictable)[XE]>) => {
|
||||
(async () => {
|
||||
const selfState = await getPromise(stateAtomFamily(guildId));
|
||||
if (isLoaded(selfState)) {
|
||||
const value = eventArgsMap(...args);
|
||||
setSelf(createLoadedValue(value, fetchValueFunc));
|
||||
}
|
||||
})();
|
||||
}) as (Connectable & Conflictable)[XE];
|
||||
}
|
||||
|
||||
function applyNew<T>(value: T[], newElements: T[], sortFunc: (a: T, b: T) => number): T[] {
|
||||
return value.concat(newElements).sort(sortFunc);
|
||||
}
|
||||
function applyUpdated<T extends { id: string }>(value: T[], updatedElements: T[], sortFunc: (a: T, b: T) => number): T[] {
|
||||
return value.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc);
|
||||
}
|
||||
function applyRemoved<T extends { id: string }>(value: T[], removedElements: T[]): T[] {
|
||||
const removedIds = new Set<string>(removedElements.map(removedElement => removedElement.id));
|
||||
return value.filter(element => !removedIds.has(element.id));
|
||||
}
|
||||
|
||||
// Useful for new-xxx, update-xxx, remove-xxx list events
|
||||
function createListConnectableMappedEventHandler<T, XE extends keyof (Connectable | Conflictable)>(
|
||||
getPromise: <S>(recoilValue: RecoilValue<S>) => Promise<S>,
|
||||
guildId: number,
|
||||
setSelf: (loadableValue: LoadableValue<T[]>) => void,
|
||||
stateAtomFamily: (guildId: number) => RecoilState<LoadableValue<T[]>>,
|
||||
fetchValueFunc: () => Promise<void>,
|
||||
eventArgsMap: (...args: Arguments<(Connectable & Conflictable)[XE]>) => Defined<T[]>,
|
||||
sortFunc: (a: T, b: T) => number,
|
||||
applyFunc: (value: T[], eventArgsResult: T[], sortFunc: (a: T, b: T) => number) => T[],
|
||||
): (Connectable & Conflictable)[XE] {
|
||||
// I think the typed EventEmitter class isn't ready for this level of insane type safety
|
||||
// otherwise, I may have done this wrong. Forcing it to work with these calls
|
||||
return ((...args: Arguments<(Connectable & Conflictable)[XE]>) => {
|
||||
(async () => {
|
||||
const selfState = await getPromise(stateAtomFamily(guildId));
|
||||
if (isLoaded(selfState)) {
|
||||
const eventArgsResult = eventArgsMap(...args);
|
||||
const value = applyFunc(selfState.value, eventArgsResult, sortFunc);
|
||||
setSelf(createLoadedValue(value, fetchValueFunc));
|
||||
}
|
||||
})();
|
||||
}) as (Connectable & Conflictable)[XE];
|
||||
}
|
||||
|
||||
function applyChanges<T extends { id: string }>(value: T[], changes: Changes<T>, sortFunc: (a: T, b: T) => number): T[] {
|
||||
const removedIds = new Set<string>(changes.deleted.map(deletedElement => deletedElement.id));
|
||||
return value
|
||||
.concat(changes.added)
|
||||
.map(element => changes.updated.find(updatedPair => updatedPair.newDataPoint.id === element.id)?.newDataPoint ?? element)
|
||||
.filter(element => !removedIds.has(element.id))
|
||||
.sort(sortFunc);
|
||||
}
|
||||
|
||||
// Useful for conflict-xxx list events
|
||||
function createListConflictableMappedEventHandler<T, XE extends keyof (Connectable | Conflictable)>(
|
||||
getPromise: <S>(recoilValue: RecoilValue<S>) => Promise<S>,
|
||||
guildId: number,
|
||||
setSelf: (loadableValue: LoadableValue<T[]>) => void,
|
||||
stateAtomFamily: (guildId: number) => RecoilState<LoadableValue<T[]>>,
|
||||
fetchValueFunc: () => Promise<void>,
|
||||
eventArgsMap: (...args: Arguments<(Connectable & Conflictable)[XE]>) => Changes<T>,
|
||||
sortFunc: (a: T, b: T) => number,
|
||||
applyFunc: (value: T[], eventArgsResult: Changes<T>, sortFunc: (a: T, b: T) => number) => T[],
|
||||
): (Connectable & Conflictable)[XE] {
|
||||
return ((...args: Arguments<(Connectable & Conflictable)[XE]>) => {
|
||||
(async () => {
|
||||
const selfState = await getPromise(stateAtomFamily(guildId));
|
||||
if (isLoaded(selfState)) {
|
||||
const eventArgsResult = eventArgsMap(...args);
|
||||
const value = applyFunc(selfState.value, eventArgsResult, sortFunc);
|
||||
setSelf(createLoadedValue(value, fetchValueFunc));
|
||||
}
|
||||
})();
|
||||
}) as (Connectable & Conflictable)[XE];
|
||||
}
|
||||
|
||||
interface SingleEventMappingParams<T, UE extends keyof Connectable, CE extends keyof Conflictable> {
|
||||
updatedEventName: UE;
|
||||
updatedEventArgsMap: (...args: Arguments<(Connectable & Conflictable)[UE]>) => Defined<T>;
|
||||
conflictEventName: CE;
|
||||
conflictEventArgsMap: (...args: Arguments<(Connectable & Conflictable)[CE]>) => Defined<T>;
|
||||
}
|
||||
|
||||
function listenToSingle<
|
||||
T, // e.g. GuildMetadata
|
||||
UE extends keyof Connectable, // Update Event
|
||||
CE extends keyof Conflictable, // Conflict Event
|
||||
>(
|
||||
getPromise: <S>(recoilValue: RecoilValue<S>) => Promise<S>,
|
||||
guildId: number,
|
||||
setSelf: (loadableValue: LoadableValue<T>) => void,
|
||||
stateAtomFamily: (guildId: number) => RecoilState<LoadableValue<T>>,
|
||||
fetchValueFunc: () => Promise<void>,
|
||||
eventMapping: SingleEventMappingParams<T, UE, CE>
|
||||
) {
|
||||
// Listen for updates
|
||||
let guild: CombinedGuild | null = null;
|
||||
let onUpdateFunc: (Connectable & Conflictable)[UE] | null = null;
|
||||
let onConflictFunc: (Connectable & Conflictable)[CE] | null = null;
|
||||
let closed = false;
|
||||
(async () => {
|
||||
guild = await getPromise(guildState(guildId));
|
||||
if (guild === null) return;
|
||||
if (closed) return; // Make sure not to bind events if this closed while we were fetching the guild state
|
||||
|
||||
// I think the typed EventEmitter class isn't ready for this level of insane type safety
|
||||
// otherwise, I may have done this wrong. Forcing it to work with these calls
|
||||
onUpdateFunc = createDirectMappedEventHandler(getPromise, guildId, setSelf, stateAtomFamily, fetchValueFunc, eventMapping.updatedEventArgsMap);
|
||||
onConflictFunc = createDirectMappedEventHandler(getPromise, guildId, setSelf, stateAtomFamily, fetchValueFunc, eventMapping.conflictEventArgsMap);
|
||||
guild.on(eventMapping.updatedEventName, onUpdateFunc);
|
||||
guild.on(eventMapping.conflictEventName, onConflictFunc);
|
||||
})();
|
||||
const cleanup = () => {
|
||||
closed = true;
|
||||
if (guild && onUpdateFunc) guild.off(eventMapping.updatedEventName, onUpdateFunc);
|
||||
if (guild && onConflictFunc) guild.off(eventMapping.conflictEventName, onConflictFunc);
|
||||
}
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
interface MultipleEventMappingParams<
|
||||
T,
|
||||
NE extends keyof Connectable,
|
||||
UE extends keyof Connectable,
|
||||
RE extends keyof Connectable,
|
||||
CE extends keyof Conflictable
|
||||
> {
|
||||
newEventName: NE;
|
||||
newEventArgsMap: (...args: Arguments<(Connectable & Conflictable)[NE]>) => Defined<T[]>;
|
||||
updatedEventName: UE;
|
||||
updatedEventArgsMap: (...args: Arguments<(Connectable & Conflictable)[UE]>) => Defined<T[]>;
|
||||
removedEventName: RE;
|
||||
removedEventArgsMap: (...args: Arguments<(Connectable & Conflictable)[RE]>) => Defined<T[]>;
|
||||
conflictEventName: CE;
|
||||
conflictEventArgsMap: (...args: Arguments<(Connectable & Conflictable)[CE]>) => Changes<T>;
|
||||
}
|
||||
function listenToMultiple<
|
||||
T extends { id: string },
|
||||
NE extends keyof Connectable, // New Event
|
||||
UE extends keyof Connectable, // Update Event
|
||||
RE extends keyof Connectable, // Remove Event
|
||||
CE extends keyof Conflictable // Conflict Event
|
||||
>(
|
||||
getPromise: <S>(recoilValue: RecoilValue<S>) => Promise<S>,
|
||||
guildId: number,
|
||||
setSelf: (loadableValue: LoadableValue<T[]>) => void,
|
||||
stateAtomFamily: (guildId: number) => RecoilState<LoadableValue<T[]>>,
|
||||
fetchValueFunc: () => Promise<void>,
|
||||
sortFunc: (a: T, b: T) => number,
|
||||
eventMapping: MultipleEventMappingParams<T, NE, UE, RE, CE>
|
||||
) {
|
||||
// Listen for updates
|
||||
let guild: CombinedGuild | null = null;
|
||||
let onNewFunc: (Connectable & Conflictable)[NE] | null = null;
|
||||
let onUpdateFunc: (Connectable & Conflictable)[UE] | null = null;
|
||||
let onRemoveFunc: (Connectable & Conflictable)[RE] | null = null;
|
||||
let onConflictFunc: (Connectable & Conflictable)[CE] | null = null;
|
||||
let closed = false;
|
||||
(async () => {
|
||||
guild = await getPromise(guildState(guildId));
|
||||
if (guild === null) return;
|
||||
if (closed) return; // Make sure not to bind events if this closed while we were fetching the guild state
|
||||
|
||||
onNewFunc = createListConnectableMappedEventHandler(getPromise, guildId, setSelf, stateAtomFamily, fetchValueFunc, eventMapping.newEventArgsMap, sortFunc, applyNew);
|
||||
onUpdateFunc = createListConnectableMappedEventHandler(getPromise, guildId, setSelf, stateAtomFamily, fetchValueFunc, eventMapping.updatedEventArgsMap, sortFunc, applyUpdated);
|
||||
onRemoveFunc = createListConnectableMappedEventHandler(getPromise, guildId, setSelf, stateAtomFamily, fetchValueFunc, eventMapping.updatedEventArgsMap, sortFunc, applyRemoved);
|
||||
onConflictFunc = createListConflictableMappedEventHandler(getPromise, guildId, setSelf, stateAtomFamily, fetchValueFunc, eventMapping.conflictEventArgsMap, sortFunc, applyChanges);
|
||||
guild.on(eventMapping.updatedEventName, onUpdateFunc);
|
||||
guild.on(eventMapping.conflictEventName, onConflictFunc);
|
||||
})();
|
||||
const cleanup = () => {
|
||||
closed = true;
|
||||
if (guild && onNewFunc) guild.off(eventMapping.newEventName, onNewFunc);
|
||||
if (guild && onUpdateFunc) guild.off(eventMapping.updatedEventName, onUpdateFunc);
|
||||
if (guild && onRemoveFunc) guild.off(eventMapping.removedEventName, onRemoveFunc);
|
||||
if (guild && onConflictFunc) guild.off(eventMapping.conflictEventName, onConflictFunc);
|
||||
}
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
function singleGuildSubscriptionEffect<
|
||||
T, // e.g. GuildMetadata
|
||||
UE extends keyof Connectable, // Update Event
|
||||
CE extends keyof Conflictable, // Conflict Event
|
||||
>(
|
||||
guildId: number,
|
||||
stateAtomFamily: (guildId: number) => RecoilState<LoadableValue<T>>,
|
||||
fetchFunc: (guild: CombinedGuild) => Promise<Defined<T>>,
|
||||
eventMapping: SingleEventMappingParams<T, UE, CE>
|
||||
) {
|
||||
return (params: RecoilLoadableAtomEffectParams<T>) => {
|
||||
const { trigger, setSelf, getPromise } = params;
|
||||
const fetchValueFunc = createFetchValueFunc(getPromise, guildId, setSelf, stateAtomFamily, fetchFunc);
|
||||
|
||||
// Fetch initial value on first get
|
||||
if (trigger === 'get') {
|
||||
fetchValueFunc();
|
||||
}
|
||||
|
||||
// Listen to changes
|
||||
const cleanup = listenToSingle(getPromise, guildId, setSelf, stateAtomFamily, fetchValueFunc, eventMapping);
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function multipleGuildSubscriptionEffect<
|
||||
T extends { id: string },
|
||||
NE extends keyof Connectable,
|
||||
UE extends keyof Connectable,
|
||||
RE extends keyof Connectable,
|
||||
CE extends keyof Conflictable
|
||||
>(
|
||||
guildId: number,
|
||||
stateAtomFamily: (guildId: number) => RecoilState<LoadableValue<T[]>>,
|
||||
fetchFunc: (guild: CombinedGuild) => Promise<Defined<T[]>>,
|
||||
sortFunc: (a: T, b: T) => number,
|
||||
eventMapping: MultipleEventMappingParams<T, NE, UE, RE, CE>,
|
||||
) {
|
||||
return (params: RecoilLoadableAtomEffectParams<T[]>) => {
|
||||
const { trigger, setSelf, getPromise } = params;
|
||||
const fetchValueFunc = createFetchValueFunc(getPromise, guildId, setSelf, stateAtomFamily, fetchFunc);
|
||||
|
||||
// Fetch initial value on first get
|
||||
if (trigger === 'get') {
|
||||
fetchValueFunc();
|
||||
}
|
||||
|
||||
// Listen to changes
|
||||
const cleanup = listenToMultiple(getPromise, guildId, setSelf, stateAtomFamily, fetchValueFunc, sortFunc, eventMapping);
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// You probably want currGuildMetaState
|
||||
export const guildMetaState: (guildId: number) => RecoilState<LoadableValue<GuildMetadata>> = atomFamily<LoadableValue<GuildMetadata>, number>({
|
||||
key: 'guildMetaState',
|
||||
default: DEF_UNLOADED_VALUE,
|
||||
effects_UNSTABLE: (guildId: number) => [
|
||||
singleGuildSubscriptionEffect(
|
||||
guildId,
|
||||
guildMetaState,
|
||||
async (guild: CombinedGuild) => await guild.fetchMetadata(),
|
||||
{
|
||||
updatedEventName: 'update-metadata',
|
||||
updatedEventArgsMap: (newMeta: GuildMetadata) => newMeta,
|
||||
conflictEventName: 'conflict-metadata',
|
||||
conflictEventArgsMap: (_changesType: AutoVerifierChangesType, _oldMeta: GuildMetadata, newMeta: GuildMetadata) => newMeta
|
||||
}
|
||||
)
|
||||
],
|
||||
dangerouslyAllowMutability: true
|
||||
});
|
||||
|
||||
const guildMembersState: (guildId: number) => RecoilState<LoadableValue<Member[]>> = atomFamily<LoadableValue<Member[]>, number>({
|
||||
key: 'guildMembersState',
|
||||
default: DEF_UNLOADED_VALUE,
|
||||
effects_UNSTABLE: (guildId: number) => [
|
||||
multipleGuildSubscriptionEffect(
|
||||
guildId,
|
||||
guildMembersState,
|
||||
async (guild: CombinedGuild) => await guild.fetchMembers(),
|
||||
Member.sortForList,
|
||||
{
|
||||
newEventName: 'new-members',
|
||||
newEventArgsMap: (newMembers: Member[]) => newMembers,
|
||||
updatedEventName: 'update-members',
|
||||
updatedEventArgsMap: (updatedMembers: Member[]) => updatedMembers,
|
||||
removedEventName: 'remove-members',
|
||||
removedEventArgsMap: (removedMembers: Member[]) => removedMembers,
|
||||
conflictEventName: 'conflict-members',
|
||||
conflictEventArgsMap: (_changesType: AutoVerifierChangesType, changes: Changes<Member>) => changes,
|
||||
}
|
||||
)
|
||||
],
|
||||
dangerouslyAllowMutability: true
|
||||
});
|
||||
|
||||
// You probably want currGuildSelfMemberState
|
||||
export const guildSelfMemberState = selectorFamily<LoadableValue<Member>, number>({
|
||||
key: 'guildSelfMemberState',
|
||||
get: (guildId: number) => ({ get }) => {
|
||||
// Find the guild from the guilds manager list of guilds
|
||||
const guildsManager = get(guildsManagerState);
|
||||
if (guildsManager === null) return DEF_UNLOADED_VALUE;
|
||||
|
||||
// Make sure that the guild has a successfully loaded members list
|
||||
const members = get(guildMembersState(guildId));
|
||||
if (isUnload(members)) return DEF_UNLOADED_VALUE;
|
||||
if (isPended(members)) return DEF_PENDED_VALUE;
|
||||
if (isFailed(members)) return createFailedValue('failed to load members', members.retry);
|
||||
|
||||
// Find the guild's selfMember in the list
|
||||
const guild = guildsManager.guilds.find(guild => guild.id === guildId);
|
||||
if (!guild) return createFailedValue('unable to find self member guild', members.retry);
|
||||
const selfMember = members.value.find(member => member.id === guild.memberId);
|
||||
if (!selfMember) return createFailedValue('failed to find self in members', members.retry);
|
||||
return createLoadedValue(selfMember, members.retry);
|
||||
},
|
||||
dangerouslyAllowMutability: true
|
||||
});
|
||||
|
||||
const guildChannelsState: (guildId: number) => RecoilState<LoadableValue<Channel[]>> = atomFamily<LoadableValue<Channel[]>, number>({
|
||||
key: 'guildChannelsState',
|
||||
default: DEF_UNLOADED_VALUE,
|
||||
effects_UNSTABLE: (guildId: number) => [
|
||||
multipleGuildSubscriptionEffect(
|
||||
guildId,
|
||||
guildChannelsState,
|
||||
async (guild: CombinedGuild) => await guild.fetchChannels(),
|
||||
Channel.sortByIndex,
|
||||
{
|
||||
newEventName: 'new-channels',
|
||||
newEventArgsMap: (newChannels: Channel[]) => newChannels,
|
||||
updatedEventName: 'update-channels',
|
||||
updatedEventArgsMap: (updatedChannels: Channel[]) => updatedChannels,
|
||||
removedEventName: 'remove-channels',
|
||||
removedEventArgsMap: (removedChannels: Channel[]) => removedChannels,
|
||||
conflictEventName: 'conflict-channels',
|
||||
conflictEventArgsMap: (_changesType: AutoVerifierChangesType, changes: Changes<Channel>) => changes
|
||||
}
|
||||
)
|
||||
],
|
||||
dangerouslyAllowMutability: true
|
||||
});
|
||||
|
||||
// You probably want currGuildActiveChannel
|
||||
export const guildActiveChannelIdState: (guildId: number) => RecoilState<string | null> = atomFamily<string | null, number>({
|
||||
key: 'guildActiveChannelIdState',
|
||||
default: null,
|
||||
});
|
||||
const guildActiveChannelState = selectorFamily<LoadableValue<Channel>, number>({
|
||||
key: 'guildActiveChannelState',
|
||||
get: (guildId: number) => ({ get }) => {
|
||||
const activeChannelId = get(guildActiveChannelIdState(guildId));
|
||||
if (activeChannelId === null) return DEF_UNLOADED_VALUE;
|
||||
const channels = get(guildChannelsState(guildId));
|
||||
if (isUnload(channels)) return DEF_UNLOADED_VALUE;
|
||||
if (isPended(channels)) return DEF_PENDED_VALUE;
|
||||
if (isFailed(channels)) return createFailedValue('channels not loaded', channels.retry);
|
||||
const channel = channels.value.find(channel => channel.id === activeChannelId) ?? null;
|
||||
if (channel === null) {
|
||||
LOG.warn('active channel was not found in channels');
|
||||
return DEF_UNLOADED_VALUE;
|
||||
}
|
||||
return createLoadedValue(channel, channels.retry);
|
||||
},
|
||||
dangerouslyAllowMutability: true
|
||||
});
|
||||
|
||||
const guildState = selectorFamily<CombinedGuild | null, number>({
|
||||
key: 'guildState',
|
||||
get: (guildId: number) => ({ get }) => {
|
||||
const guildsManager = get(guildsManagerState);
|
||||
if (guildsManager === null) return null;
|
||||
const guild = guildsManager.guilds.find(guild => guild.id === guildId) ?? null;
|
||||
return guild;
|
||||
},
|
||||
dangerouslyAllowMutability: true
|
||||
});
|
||||
|
||||
export const currGuildIdState = atom<number | null>({
|
||||
key: 'currGuildIdState',
|
||||
default: null
|
||||
});
|
||||
|
||||
function createCurrentGuildStateGetter<T>(subSelectorFamily: (guildId: number) => RecoilValueReadOnly<T>) {
|
||||
return ({ get }: { get: GetRecoilValue }) => {
|
||||
const currGuildId = get(currGuildIdState);
|
||||
if (currGuildId === null) return null;
|
||||
const value = get(subSelectorFamily(currGuildId));
|
||||
return value;
|
||||
}
|
||||
}
|
||||
function createCurrentGuildLoadableStateGetter<T>(subSelectorFamily: (guildId: number) => RecoilValueReadOnly<LoadableValue<T>> | RecoilState<LoadableValue<T>>) {
|
||||
return ({ get }: { get: GetRecoilValue; getCallback: GetCallback }) => {
|
||||
// Use the unloaded value if the current guild hasn't been selected yet or doesn't exist
|
||||
const currGuildId = get(currGuildIdState);
|
||||
if (currGuildId === null) return DEF_UNLOADED_VALUE;
|
||||
const value = get(subSelectorFamily(currGuildId));
|
||||
if (value === null) return DEF_UNLOADED_VALUE;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: These will all update in parallel when the guild changes. They will always reference the same guild
|
||||
// There should not need to be a worry about them cauing extra renders
|
||||
export const currGuildState = selector<CombinedGuild | null>({
|
||||
key: 'currGuildState',
|
||||
get: createCurrentGuildStateGetter(guildState),
|
||||
dangerouslyAllowMutability: true
|
||||
});
|
||||
export const currGuildMetaState = selector<LoadableValue<GuildMetadata>>({
|
||||
key: 'currGuildMetaState',
|
||||
get: createCurrentGuildLoadableStateGetter(guildMetaState),
|
||||
dangerouslyAllowMutability: true
|
||||
});
|
||||
export const currGuildMembersState = selector<LoadableValue<Member[]>>({
|
||||
key: 'currGuildMembersState',
|
||||
get: createCurrentGuildLoadableStateGetter(guildMembersState),
|
||||
dangerouslyAllowMutability: true
|
||||
});
|
||||
export const currGuildSelfMemberState = selector<LoadableValue<Member>>({
|
||||
key: 'currGuildSelfMemberState',
|
||||
get: createCurrentGuildLoadableStateGetter(guildSelfMemberState),
|
||||
dangerouslyAllowMutability: true
|
||||
});
|
||||
export const currGuildChannelsState = selector<LoadableValue<Channel[]>>({
|
||||
key: 'currGuildChannelsState',
|
||||
get: createCurrentGuildLoadableStateGetter(guildChannelsState),
|
||||
dangerouslyAllowMutability: true
|
||||
});
|
||||
export const currGuildActiveChannelState = selector<LoadableValue<Channel>>({
|
||||
key: 'currGuildActiveChannelState',
|
||||
get: createCurrentGuildLoadableStateGetter(guildActiveChannelState),
|
||||
dangerouslyAllowMutability: true
|
||||
});
|
||||
|
||||
// Initialize with a guildsManager
|
||||
export function initRecoil(guildsManager: GuildsManager) {
|
||||
const setGuildsManager = useSetRecoilState(guildsManagerState);
|
||||
const setGuilds = useSetRecoilState(allGuildsState);
|
||||
useEffect(() => {
|
||||
setGuildsManager(guildsManager);
|
||||
}, [ guildsManager, setGuildsManager ]);
|
||||
useEffect(() => {
|
||||
const updateGuilds = () => { setGuilds(guildsManager.guilds.slice()); }
|
||||
updateGuilds();
|
||||
guildsManager.on('update-guilds', updateGuilds);
|
||||
return () => {
|
||||
guildsManager.off('update-guilds', updateGuilds);
|
||||
}
|
||||
}, [ guildsManager ]);
|
||||
}
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React, { FC, ReactNode } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import GuildsManager from '../guilds-manager';
|
||||
import { overlayState } from './require/atoms';
|
||||
import { useGuildsManagerWithRecoil } from './require/setup-guild-recoil';
|
||||
import { initRecoil, overlayState } from './require/atoms-2';
|
||||
import GuildsManagerElement from './sections/guilds-manager';
|
||||
import TitleBar from './sections/title-bar';
|
||||
|
||||
@ -13,9 +12,9 @@ export interface RootElementProps {
|
||||
const RootElement: FC<RootElementProps> = (props: RootElementProps) => {
|
||||
const { guildsManager } = props;
|
||||
|
||||
const overlay = useRecoilValue<ReactNode>(overlayState);
|
||||
initRecoil(guildsManager);
|
||||
|
||||
useGuildsManagerWithRecoil(guildsManager);
|
||||
const overlay = useRecoilValue<ReactNode>(overlayState);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -4,15 +4,16 @@ import MemberElement, { DummyMember } from '../lists/components/member-element';
|
||||
import ConnectionInfoContextMenu from '../contexts/context-menu-connection-info';
|
||||
import { useContextMenu } from '../require/react-helper';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDevoid, isPended, isFailed, selectedGuildWithSelfMemberState, isDevoidPendedOrFailed } from '../require/atoms';
|
||||
import { currGuildSelfMemberState, currGuildState, isFailed, isLoaded, isPended, isUnload } from '../require/atoms-2';
|
||||
|
||||
const ConnectionInfo: FC = () => {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const guildWithSelfMember = useRecoilValue(selectedGuildWithSelfMemberState);
|
||||
const guild = useRecoilValue(currGuildState);
|
||||
const selfMember = useRecoilValue(currGuildSelfMemberState);
|
||||
|
||||
const displayMember = useMemo((): Member | DummyMember => {
|
||||
if (isDevoid(guildWithSelfMember) || isPended(guildWithSelfMember)) {
|
||||
if (isUnload(selfMember) || isPended(selfMember)) {
|
||||
return {
|
||||
id: 'dummy',
|
||||
displayName: 'Connecting...',
|
||||
@ -21,7 +22,7 @@ const ConnectionInfo: FC = () => {
|
||||
avatarResourceId: null
|
||||
};
|
||||
}
|
||||
if (isFailed(guildWithSelfMember)) {
|
||||
if (isFailed(selfMember)) {
|
||||
return {
|
||||
id: 'dummy',
|
||||
displayName: 'UNKNOWN',
|
||||
@ -30,22 +31,27 @@ const ConnectionInfo: FC = () => {
|
||||
avatarResourceId: null
|
||||
};
|
||||
}
|
||||
return guildWithSelfMember.value;
|
||||
}, [ guildWithSelfMember ]);
|
||||
return selfMember.value;
|
||||
}, [ selfMember ]);
|
||||
|
||||
const [ contextMenu, toggleContextMenu ] = useContextMenu((close: () => void) => {
|
||||
if (isDevoidPendedOrFailed(guildWithSelfMember)) return null;
|
||||
if (guild === null) return null;
|
||||
if (!isLoaded(selfMember)) return null;
|
||||
return (
|
||||
<ConnectionInfoContextMenu guildWithSelfMember={guildWithSelfMember} relativeToRef={rootRef} close={close} />
|
||||
<ConnectionInfoContextMenu guild={guild} selfMember={selfMember.value} relativeToRef={rootRef} close={close} />
|
||||
);
|
||||
}, [ guildWithSelfMember ]);
|
||||
}, [ guild, selfMember ]);
|
||||
|
||||
return guildWithSelfMember?.guild ? (
|
||||
<div ref={rootRef} className="connection-info">
|
||||
<div onClick={toggleContextMenu}><MemberElement guild={guildWithSelfMember.guild} member={displayMember} /></div>
|
||||
{contextMenu}
|
||||
</div>
|
||||
) : null;
|
||||
if (guild) {
|
||||
return (
|
||||
<div ref={rootRef} className="connection-info">
|
||||
<div onClick={toggleContextMenu}><MemberElement guild={guild} member={displayMember} /></div>
|
||||
{contextMenu}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default ConnectionInfo;
|
||||
|
@ -11,8 +11,8 @@ import AddGuildOverlay from '../overlays/overlay-add-guild';
|
||||
import ErrorMessageOverlay from '../overlays/overlay-error-message';
|
||||
import BasicHover, { BasicHoverSide } from '../contexts/context-hover-basic';
|
||||
import BaseElements from '../require/base-elements';
|
||||
import { overlayState } from '../require/atoms';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { overlayState } from '../require/atoms-2';
|
||||
|
||||
const GuildListContainer: FC = () => {
|
||||
const addGuildRef = useRef<HTMLDivElement>(null);
|
||||
|
@ -1,40 +1,28 @@
|
||||
import React, { FC, useMemo, useRef } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Member } from '../../data-types';
|
||||
import GuildTitleContextMenu from '../contexts/context-menu-guild-title';
|
||||
import { selectedGuildWithMetaState } from '../require/atoms';
|
||||
import { isNonNullAndHasValue, SubscriptionResult } from '../require/guild-subscriptions';
|
||||
import { currGuildMetaState, currGuildSelfMemberState, isLoaded } from '../require/atoms-2';
|
||||
import { useContextMenu } from '../require/react-helper';
|
||||
|
||||
export interface GuildTitleProps {
|
||||
selfMemberResult: SubscriptionResult<Member | null> | null;
|
||||
}
|
||||
|
||||
const GuildTitle: FC<GuildTitleProps> = (props: GuildTitleProps) => {
|
||||
const { selfMemberResult } = props;
|
||||
|
||||
const guildWithMeta = useRecoilValue(selectedGuildWithMetaState)
|
||||
const GuildTitle: FC = () => {
|
||||
const guildMeta = useRecoilValue(currGuildMetaState);
|
||||
const selfMember = useRecoilValue(currGuildSelfMemberState);
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasContextMenu = useMemo(() => {
|
||||
if (!isNonNullAndHasValue(selfMemberResult)) return false;
|
||||
if (!isLoaded(selfMember)) return false;
|
||||
return (
|
||||
selfMemberResult.value.privileges.includes('modify_profile') ||
|
||||
selfMemberResult.value.privileges.includes('modify_channels')
|
||||
selfMember.value.privileges.includes('modify_profile') ||
|
||||
selfMember.value.privileges.includes('modify_channels')
|
||||
);
|
||||
}, [ selfMemberResult ]);
|
||||
}, [ selfMember ]);
|
||||
|
||||
const [ contextMenu, toggleContextMenu ] = useContextMenu((close: () => void) => {
|
||||
if (!isNonNullAndHasValue(selfMemberResult)) return null;
|
||||
|
||||
return (
|
||||
<GuildTitleContextMenu
|
||||
relativeToRef={rootRef} close={close}
|
||||
selfMember={selfMemberResult.value}
|
||||
/>
|
||||
<GuildTitleContextMenu relativeToRef={rootRef} close={close} />
|
||||
);
|
||||
}, [ selfMemberResult, rootRef ]);
|
||||
}, [ rootRef ]);
|
||||
|
||||
const nameStyle = useMemo(() => {
|
||||
if (hasContextMenu) {
|
||||
@ -47,7 +35,7 @@ const GuildTitle: FC<GuildTitleProps> = (props: GuildTitleProps) => {
|
||||
return (
|
||||
<div className="guild-title" ref={rootRef}>
|
||||
<div className="guild-name-container" style={nameStyle} onClick={hasContextMenu ? toggleContextMenu : undefined}>
|
||||
<span className="guild-name">{guildWithMeta?.value?.name ?? null}</span>
|
||||
<span className="guild-name">{isLoaded(guildMeta) ? guildMeta.value.name : null}</span>
|
||||
</div>
|
||||
{contextMenu}
|
||||
</div>
|
||||
|
@ -3,98 +3,70 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
|
||||
import Logger from '../../../../logger/logger';
|
||||
const LOG = Logger.create(__filename, electronConsole);
|
||||
|
||||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
import { Channel, Message } from '../../data-types';
|
||||
import CombinedGuild from '../../guild-combined';
|
||||
import ChannelList from '../lists/channel-list';
|
||||
import MemberList from '../lists/member-list';
|
||||
import MessageList from '../lists/message-list';
|
||||
import { useSelfMemberSubscription, useChannelsSubscription } from '../require/guild-subscriptions';
|
||||
import ChannelTitle from './channel-title';
|
||||
import ConnectionInfo from './connection-info';
|
||||
import GuildTitle from './guild-title';
|
||||
import SendMessage from './send-message';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { currGuildActiveChannelState, currGuildChannelsState, currGuildSelfMemberState, currGuildState, guildActiveChannelIdState, isLoaded, isUnload } from '../require/atoms-2';
|
||||
|
||||
export interface GuildElementProps {
|
||||
guild: CombinedGuild;
|
||||
}
|
||||
|
||||
const GuildElement: FC<GuildElementProps> = (props: GuildElementProps) => {
|
||||
const { guild } = props;
|
||||
|
||||
const GuildElement: FC = () => {
|
||||
// TODO: Handle fetch errors by allowing for retry
|
||||
// TODO: Handle fetch errors in message list
|
||||
// TODO: React set hasMessagesAbove and hasMessagesBelow when re-verified?
|
||||
// TODO: React jump messages to bottom when the current user sent a message
|
||||
|
||||
const [ selfMemberResult ] = useSelfMemberSubscription(guild);
|
||||
const [ channelsRetry, channelsResult, channelsFetchError ] = useChannelsSubscription(guild);
|
||||
|
||||
const [ activeChannel, setActiveChannel ] = useState<Channel | null>(null);
|
||||
const [ activeChannelGuild, setActiveChannelGuild ] = useState<CombinedGuild | null>(null);
|
||||
const guild = useRecoilValue(currGuildState);
|
||||
const selfMember = useRecoilValue(currGuildSelfMemberState);
|
||||
const channels = useRecoilValue(currGuildChannelsState);
|
||||
const activeChannel = useRecoilValue(currGuildActiveChannelState);
|
||||
const setActiveChannelId = useSetRecoilState(guildActiveChannelIdState(guild?.id ?? -1));
|
||||
|
||||
const [ fetchMessagesRetryCallable, setFetchMessagesRetryCallable ] = useState<(() => Promise<void>) | null>(null);
|
||||
|
||||
// If the active channel isn't set yet, set it to the first of the channels
|
||||
useEffect(() => {
|
||||
setActiveChannel(null);
|
||||
}, [ guild ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeChannel === null) {
|
||||
// initial active channel is the first one in the list
|
||||
if (channelsResult && channelsResult.value.length > 0) {
|
||||
setActiveChannel(channelsResult.value[0] as Channel);
|
||||
setActiveChannelGuild(channelsResult.guild);
|
||||
//LOG.debug('Active channel guild enabled', { channel: channels[0]?.id, channelsGuild: channelsGuild?.id });
|
||||
}
|
||||
} else if (channelsResult && activeChannel) {
|
||||
// in the active channel was updated
|
||||
const newActiveChannel = channelsResult.value.find(channel => channel.id === activeChannel.id) ?? null
|
||||
setActiveChannel(newActiveChannel);
|
||||
setActiveChannelGuild(channelsResult.guild);
|
||||
//LOG.debug('Active channel was updated...', { channel: newActiveChannel?.id, channelsGuild: channelsGuild?.id });
|
||||
if (isUnload(activeChannel) && isLoaded(channels) && channels.value.length > 0) {
|
||||
const activeChannel = channels.value[0] as Channel;
|
||||
setActiveChannelId(activeChannel.id);
|
||||
}
|
||||
}, [ channelsResult, activeChannel ]);
|
||||
}, [ guild, channels, activeChannel, setActiveChannelId ])
|
||||
|
||||
// When the self member sends a message to the active channel and we get it as a new message,
|
||||
// move the active channel's messages back to the bottom
|
||||
const onNewMessage = useCallback((messages: Message[]) => {
|
||||
if (messages.find(message =>
|
||||
message.member.id === selfMemberResult?.value?.id &&
|
||||
message.channel.id === activeChannel?.id)
|
||||
) {
|
||||
if (fetchMessagesRetryCallable) {
|
||||
// TODO: Only do the fetch if we're not at the bottom. If we're at the bottom, just
|
||||
// set the scroll height
|
||||
fetchMessagesRetryCallable(); // Re-load the messages to move them to the bottom.
|
||||
useEffect(() => {
|
||||
if (guild === null) return;
|
||||
const onNewMessage = (newMessages: Message[]) => {
|
||||
if (!isLoaded(selfMember)) return;
|
||||
if (!isLoaded(activeChannel)) return;
|
||||
if (fetchMessagesRetryCallable === null) return;
|
||||
if (newMessages.find(newMessage => newMessage.member.id === selfMember.value.id && newMessage.channel.id === activeChannel.value.id)) {
|
||||
fetchMessagesRetryCallable
|
||||
}
|
||||
}
|
||||
}, [ fetchMessagesRetryCallable/*, Note: Removing these to prevent re-sending fetch. This technique should probably be changed in the future with react/redux selfMemberResult, activeChannel */ ]);
|
||||
|
||||
useEffect(() => {
|
||||
guild.on('new-messages', onNewMessage);
|
||||
return () => {
|
||||
guild.off('new-messages', onNewMessage);
|
||||
}
|
||||
});
|
||||
}, [ guild, selfMember, activeChannel, fetchMessagesRetryCallable ]);
|
||||
|
||||
return (
|
||||
<div className="guild-react">
|
||||
<div className="guild-sidebar">
|
||||
<GuildTitle selfMemberResult={selfMemberResult} />
|
||||
<ChannelList
|
||||
guild={guild} selfMember={selfMemberResult?.value ?? null}
|
||||
channels={channelsResult?.value ?? null} channelsFetchError={channelsFetchError}
|
||||
activeChannel={activeChannel} setActiveChannel={setActiveChannel}
|
||||
/>
|
||||
<GuildTitle />
|
||||
<ChannelList />
|
||||
<ConnectionInfo />
|
||||
</div>
|
||||
<div className="guild-channel">
|
||||
<ChannelTitle channel={activeChannel} />
|
||||
<ChannelTitle channel={isLoaded(activeChannel) ? activeChannel.value : null} />
|
||||
<div className="guild-channel-content">
|
||||
<div className="guild-channel-feed-wrapper">
|
||||
{activeChannel && activeChannelGuild && <MessageList guild={guild} channel={activeChannel} channelGuild={activeChannelGuild} setFetchRetryCallable={setFetchMessagesRetryCallable} />}
|
||||
{activeChannel && activeChannelGuild && <SendMessage guild={guild} channel={activeChannel} />}
|
||||
{guild && isLoaded(activeChannel) && <MessageList guild={guild} channel={activeChannel.value} channelGuild={guild} setFetchRetryCallable={setFetchMessagesRetryCallable} />}
|
||||
{guild && isLoaded(activeChannel) && <SendMessage guild={guild} channel={activeChannel.value} />}
|
||||
</div>
|
||||
<div className="member-list-wrapper">
|
||||
<MemberList />
|
||||
|
@ -1,16 +1,12 @@
|
||||
import React, { FC } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { selectedGuildState } from '../require/atoms';
|
||||
import GuildElement from './guild';
|
||||
import GuildListContainer from './guild-list-container';
|
||||
|
||||
const GuildsManagerElement: FC = () => {
|
||||
const selectedGuild = useRecoilValue(selectedGuildState);
|
||||
|
||||
return (
|
||||
<div className="guilds-manager">
|
||||
<GuildListContainer />
|
||||
{selectedGuild && <GuildElement guild={selectedGuild} />}
|
||||
<GuildElement />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user