From 222feb1de13ed90fd1e75fc709f0c071d7576004 Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Wed, 2 Feb 2022 23:05:38 -0600 Subject: [PATCH] RECOIL IS SUPREME! --- .../require/atoms.ts => archive/atoms-old.ts | 2 +- .../require => archive}/setup-guild-recoil.ts | 0 .../webapp/elements/components/overlay.tsx | 2 +- .../webapp/elements/components/token-row.tsx | 32 +- .../contexts/context-menu-connection-info.tsx | 18 +- .../contexts/context-menu-guild-title.tsx | 21 +- .../displays/display-guild-invites.tsx | 34 +- .../displays/display-guild-overview.tsx | 27 +- .../webapp/elements/lists/channel-list.tsx | 49 +- .../lists/components/channel-element.tsx | 22 +- .../lists/components/guild-list-element.tsx | 42 +- .../lists/components/message-element.tsx | 2 +- .../webapp/elements/lists/guild-list.tsx | 15 +- .../webapp/elements/lists/member-list.tsx | 15 +- .../elements/overlays/overlay-add-guild.tsx | 6 +- .../elements/overlays/overlay-channel.tsx | 8 +- .../overlays/overlay-guild-settings.tsx | 19 +- .../elements/overlays/overlay-personalize.tsx | 10 +- src/client/webapp/elements/require/atoms-2.ts | 586 ++++++++++++++++++ src/client/webapp/elements/root.tsx | 7 +- .../elements/sections/connection-info.tsx | 36 +- .../sections/guild-list-container.tsx | 2 +- .../webapp/elements/sections/guild-title.tsx | 34 +- src/client/webapp/elements/sections/guild.tsx | 84 +-- .../elements/sections/guilds-manager.tsx | 6 +- 25 files changed, 813 insertions(+), 266 deletions(-) rename src/client/webapp/elements/require/atoms.ts => archive/atoms-old.ts (98%) rename {src/client/webapp/elements/require => archive}/setup-guild-recoil.ts (100%) create mode 100644 src/client/webapp/elements/require/atoms-2.ts diff --git a/src/client/webapp/elements/require/atoms.ts b/archive/atoms-old.ts similarity index 98% rename from src/client/webapp/elements/require/atoms.ts rename to archive/atoms-old.ts index f34d9a3..021fb06 100644 --- a/src/client/webapp/elements/require/atoms.ts +++ b/archive/atoms-old.ts @@ -47,7 +47,7 @@ export function isFailed(withLoadableValue: WithLoadableValue): 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(withNullableLoadableValue: WithLoadableValue | null): withNullableLoadableValue is null | WithUnloadedValue | WithFailedValue { diff --git a/src/client/webapp/elements/require/setup-guild-recoil.ts b/archive/setup-guild-recoil.ts similarity index 100% rename from src/client/webapp/elements/require/setup-guild-recoil.ts rename to archive/setup-guild-recoil.ts diff --git a/src/client/webapp/elements/components/overlay.tsx b/src/client/webapp/elements/components/overlay.tsx index e3c92be..248d8b3 100644 --- a/src/client/webapp/elements/components/overlay.tsx +++ b/src/client/webapp/elements/components/overlay.tsx @@ -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; // clicks outside this ref will close the overlay diff --git a/src/client/webapp/elements/components/token-row.tsx b/src/client/webapp/elements/components/token-row.tsx index f07a195..bd4203c 100644 --- a/src/client/webapp/elements/components/token-row.tsx +++ b/src/client/webapp/elements/components/token-row.tsx @@ -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 + guild: CombinedGuild; } const TokenRow: FC = (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 = (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 ( -
+
{userText}
{token.token}
diff --git a/src/client/webapp/elements/contexts/context-menu-connection-info.tsx b/src/client/webapp/elements/contexts/context-menu-connection-info.tsx index 5b921e2..40f75cd 100644 --- a/src/client/webapp/elements/contexts/context-menu-connection-info.tsx +++ b/src/client/webapp/elements/contexts/context-menu-connection-info.tsx @@ -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; + guild: CombinedGuild; + selfMember: Member; relativeToRef: RefObject; close: () => void; } const ConnectionInfoContextMenu: FC = (props: ConnectionInfoContextMenuProps) => { - const { guildWithSelfMember, relativeToRef, close } = props; + const { guild, selfMember, relativeToRef, close } = props; const setOverlay = useSetRecoilState(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 = (props: Co const openPersonalize = useCallback(() => { close(); - setOverlay(); + setOverlay(); }, [ close ]); const alignment = useMemo(() => { diff --git a/src/client/webapp/elements/contexts/context-menu-guild-title.tsx b/src/client/webapp/elements/contexts/context-menu-guild-title.tsx index 13a7a4d..bf5ecd8 100644 --- a/src/client/webapp/elements/contexts/context-menu-guild-title.tsx +++ b/src/client/webapp/elements/contexts/context-menu-guild-title.tsx @@ -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; - selfMember: Member; } const GuildTitleContextMenu: FC = (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(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(); + setOverlay(); }, [ close ]); const openCreateChannel = useCallback(() => { @@ -40,7 +39,8 @@ const GuildTitleContextMenu: FC = (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 (
{BaseElements.COG}
@@ -50,7 +50,8 @@ const GuildTitleContextMenu: FC = (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 (
{BaseElements.CREATE}
diff --git a/src/client/webapp/elements/displays/display-guild-invites.tsx b/src/client/webapp/elements/displays/display-guild-invites.tsx index 5b6d245..d317a6d 100644 --- a/src/client/webapp/elements/displays/display-guild-invites.tsx +++ b/src/client/webapp/elements/displays/display-guild-invites.tsx @@ -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; + guild: CombinedGuild; } const GuildInvitesDisplay: FC = (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(moment.duration(1, 'day')); const [ expiresFromNowText, setExpiresFromNowText ] = useState('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 = (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 = (props: GuildInvitesDi // TODO: Try Again return
Unable to load tokens
; } - if (!guildWithMeta?.value) { - return
No Guild Metadata
; - } - return tokensResult?.value?.map((token: Token) => ); - }, [ url, guildWithMeta, tokensResult, tokensError ]); + // TODO: Try again? + return tokensResult?.value?.map((token: Token) => ); + }, [ url, guild, tokensResult, tokensError ]); return ( = (props: GuildInvitesDi
@@ -101,7 +104,8 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi
- ) + ); }; export default GuildInvitesDisplay; + diff --git a/src/client/webapp/elements/displays/display-guild-overview.tsx b/src/client/webapp/elements/displays/display-guild-overview.tsx index e4eb169..e5b2c5c 100644 --- a/src/client/webapp/elements/displays/display-guild-overview.tsx +++ b/src/client/webapp/elements/displays/display-guild-overview.tsx @@ -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; + guild: CombinedGuild; } const GuildOverviewDisplay: FC = (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(null); const [ savedIconBuff, setSavedIconBuff ] = useState(null); @@ -38,10 +41,10 @@ const GuildOverviewDisplay: FC = (props: GuildOvervie const [ iconInputMessage, setIconInputMessage ] = useState(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 = (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 = (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 = (props: GuildOvervie } setSaving(false); - }, [ errorMessage, saving, name, savedName, iconBuff ]); + }, [ name, iconBuff, errorMessage, saving, guild, iconBuff, savedIconBuff ]); return ( >; -} +const ChannelList: FC = () => { + const guild = useRecoilValue(currGuildState); + const selfMember = useRecoilValue(currGuildSelfMemberState); + const channels = useRecoilValue(currGuildChannelsState); -const ChannelList: FC = (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
Unable to load channels
- } - if (!channels) { - return
Loading channels...
- } - return channels.map((channel: Channel) => ( - { setActiveChannel(channel); }} - /> + // TODO: Retry button on error + if (isFailed(channels)) return
Unable to load channels
; + if (guild === null || !isLoaded(channels)) return
Loading channels...
; // Unloaded/Pending + return channels.value.map((channel: Channel) => ( + )); - }, [ selfMember, channelsFetchError, channels, guild, selfMember, activeChannel ]); + }, [ channels, guild ]); return (
diff --git a/src/client/webapp/elements/lists/components/channel-element.tsx b/src/client/webapp/elements/lists/components/channel-element.tsx index 746bd2e..fecce1b 100644 --- a/src/client/webapp/elements/lists/components/channel-element.tsx +++ b/src/client/webapp/elements/lists/components/channel-element.tsx @@ -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>; } const ChannelElement: FC = (props: ChannelElementProps) => { - const { guild, channel, selfMember, activeChannel, setActiveChannel } = props; + const { guild, channel } = props; - const modifyRef = useRef(null); + const activeChannel = useRecoilValue(currGuildActiveChannelState); + const setActiveChannelId = useSetRecoilState(guildActiveChannelIdState(guild.id)); const setOverlay = useSetRecoilState(overlayState); - const baseClassName = activeChannel?.id === channel.id ? 'channel text active' : 'channel text'; + const modifyRef = useRef(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 = (props: ChannelElementProps) => const setSelfActiveChannel = useCallback((event: MouseEvent) => { 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(); diff --git a/src/client/webapp/elements/lists/components/guild-list-element.tsx b/src/client/webapp/elements/lists/components/guild-list-element.tsx index 9c3e4c3..ee5cda8 100644 --- a/src/client/webapp/elements/lists/components/guild-list-element.tsx +++ b/src/client/webapp/elements/lists/components/guild-list-element.tsx @@ -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, + guild: CombinedGuild; } const GuildListElement: FC = (props: GuildListElementProps) => { - const { guildWithMeta: { guild, value: guildMeta, valueError: _guildMetaError } } = props; + const { guild } = props; const rootRef = useRef(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 ( - +
{BaseElements.TAB_LEFT}
-
{guildMeta.name}
-
+
{guildMeta.value.name}
+
-
{selfMemberResult.value.displayName}
+
{selfMember.value.displayName}
) - }, [ guildMeta, selfMemberResult ]); + }, [ guildMeta, selfMember ]); const leaveGuildCallable = useCallback(async () => { if (!guildsManager) return; @@ -67,12 +67,12 @@ const GuildListElement: FC = (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 (
diff --git a/src/client/webapp/elements/lists/components/message-element.tsx b/src/client/webapp/elements/lists/components/message-element.tsx index 8798289..1cf1619 100644 --- a/src/client/webapp/elements/lists/components/message-element.tsx +++ b/src/client/webapp/elements/lists/components/message-element.tsx @@ -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'; diff --git a/src/client/webapp/elements/lists/guild-list.tsx b/src/client/webapp/elements/lists/guild-list.tsx index ac595c7..eeb20f8 100644 --- a/src/client/webapp/elements/lists/guild-list.tsx +++ b/src/client/webapp/elements/lists/guild-list.tsx @@ -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) => ( - - )); - }, [ guildsWithMeta ]); + if (!allGuilds) return null; + return allGuilds.map(guild => ) + }, [ allGuilds ]); return (
diff --git a/src/client/webapp/elements/lists/member-list.tsx b/src/client/webapp/elements/lists/member-list.tsx index 1388dba..e682e20 100644 --- a/src/client/webapp/elements/lists/member-list.tsx +++ b/src/client/webapp/elements/lists/member-list.tsx @@ -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
Loading Members...
; - if (isFailed(guildWithMembers)) return
Unable to load members
; + if (!guild || isUnload(members)) return null + if (isPended(members)) return
Loading Members...
; + if (isFailed(members)) return
Unable to load members
; //LOG.debug(`drawing ${membersResult.value.length} members`); - return guildWithMembers.value.map((member: Member) => ); - }, [ guildWithMembers ]); + return members.value.map((member: Member) => ); + }, [ guild, members ]); return (
diff --git a/src/client/webapp/elements/overlays/overlay-add-guild.tsx b/src/client/webapp/elements/overlays/overlay-add-guild.tsx index aece1bd..b6c4551 100644 --- a/src/client/webapp/elements/overlays/overlay-add-guild.tsx +++ b/src/client/webapp/elements/overlays/overlay-add-guild.tsx @@ -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 = (props: AddGuildOverlayProps) const { addGuildData } = props; const guildsManager = useRecoilValue(guildsManagerState); - const setSelectedGuildId = useSetRecoilState(selectedGuildIdState); + const setCurrGuildId = useSetRecoilState(currGuildIdState); const rootRef = useRef(null); const setOverlay = useSetRecoilState(overlayState); @@ -109,7 +109,7 @@ const AddGuildOverlay: FC = (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 }; diff --git a/src/client/webapp/elements/overlays/overlay-channel.tsx b/src/client/webapp/elements/overlays/overlay-channel.tsx index cd3552b..8d119e6 100644 --- a/src/client/webapp/elements/overlays/overlay-channel.tsx +++ b/src/client/webapp/elements/overlays/overlay-channel.tsx @@ -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 = (props: ChannelOverlayProps) => { const { channel } = props; - const guild = useRecoilValue(selectedGuildState); - if (guild === null) return null; + const guild = useRecoilValue(currGuildState); const rootRef = useRef(null); const nameInputRef = useRef(null); @@ -67,6 +66,7 @@ const ChannelOverlay: FC = (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 = (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' } ); diff --git a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx index 3646ef6..a360aab 100644 --- a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx +++ b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx @@ -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; + guild: CombinedGuild; } const GuildSettingsOverlay: FC = (props: GuildSettingsOverlayProps) => { - const { guildWithMeta } = props; + const { guild } = props; + + const guildMeta = useRecoilValue(guildMetaState(guild.id)); const rootRef = useRef(null); @@ -18,15 +21,15 @@ const GuildSettingsOverlay: FC = (props: GuildSetting const [ display, setDisplay ] = useState(); useEffect(() => { - if (selectedId === 'overview') setDisplay(); + if (selectedId === 'overview') setDisplay(); //if (selectedId === 'roles' ) setDisplay(); - if (selectedId === 'invites' ) setDisplay(); - }, [ selectedId, guildWithMeta, setDisplay ]); + if (selectedId === 'invites' ) setDisplay(); + }, [ guild, selectedId, setDisplay ]); return (
- ; + guild: CombinedGuild; + selfMember: Member; } const PersonalizeOverlay: FC = (props: PersonalizeOverlayProps) => { - const { guildWithSelfMember } = props; + const { guild, selfMember } = props; const setOverlay = useSetRecoilState(overlayState); - const { guild, value: selfMember } = guildWithSelfMember; - const rootRef = useRef(null); const [ avatarResourceResult, avatarResourceError ] = useResourceSubscription(guild, selfMember.avatarResourceId, guild); diff --git a/src/client/webapp/elements/require/atoms-2.ts b/src/client/webapp/elements/require/atoms-2.ts new file mode 100644 index 0000000..ac5851d --- /dev/null +++ b/src/client/webapp/elements/require/atoms-2.ts @@ -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 extends (...args: infer A) => unknown ? A : never; +type Defined = T extends undefined ? never : T | Awaited; + +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 = { + value: Defined; + error: undefined; + retry: () => Promise; + hasError: false; + loading: false; +}; +export type FailedValue = { + value: undefined; + error: unknown; + retry: () => Promise; + hasError: true; + loading: false; +}; +export type LoadableValue = UnloadedValue | LoadingValue | LoadedValue | FailedValue; +export type QueriedValue = LoadingValue | LoadedValue | 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(value: Defined, retry: () => Promise): LoadedValue { + return { + value, + error: undefined, + retry, + hasError: false, + loading: false + }; +} +function createFailedValue(error: unknown, retry: () => Promise): FailedValue { + return { + value: undefined, + error, + retry, + hasError: true, + loading: false + }; +} + +export function isUnload(loadableValue: LoadableValue): loadableValue is UnloadedValue { + return loadableValue.value === undefined && loadableValue.hasError === false && loadableValue.loading === false; +} +export function isPended(loadableValue: LoadableValue): loadableValue is LoadingValue { + return loadableValue.loading === true; +} +export function isFailed(loadableValue: LoadableValue): loadableValue is FailedValue { + return loadableValue.hasError === true; +} +export function isLoaded(loadableValue: LoadableValue): loadableValue is LoadedValue { + return loadableValue.value !== undefined; +} + +export const overlayState = atom({ + key: 'overlayState', + default: null +}); + +export const guildsManagerState = atom({ + 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({ + key: 'allGuildsListState', + default: null, + dangerouslyAllowMutability: true +}); + +interface RecoilLoadableAtomEffectParams { + trigger: 'get' | 'set'; + setSelf: (loadableValue: LoadableValue) => void; + getPromise: (recoilValue: RecoilValue) => Promise; +} + +function createFetchValueFunc( + getPromise: (recoilValue: RecoilValue) => Promise, + guildId: number, + setSelf: (loadableValue: LoadableValue) => void, + stateAtomFamily: (guildId: number) => RecoilState>, + fetchFunc: (guild: CombinedGuild) => Promise>, +): () => Promise { + 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( + getPromise: (recoilValue: RecoilValue) => Promise, + guildId: number, + setSelf: (loadableValue: LoadableValue) => void, + stateAtomFamily: (guildId: number) => RecoilState>, + fetchValueFunc: () => Promise, + eventArgsMap: (...args: Arguments<(Connectable & Conflictable)[XE]>) => Defined +): (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(value: T[], newElements: T[], sortFunc: (a: T, b: T) => number): T[] { + return value.concat(newElements).sort(sortFunc); +} +function applyUpdated(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(value: T[], removedElements: T[]): T[] { + const removedIds = new Set(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( + getPromise: (recoilValue: RecoilValue) => Promise, + guildId: number, + setSelf: (loadableValue: LoadableValue) => void, + stateAtomFamily: (guildId: number) => RecoilState>, + fetchValueFunc: () => Promise, + eventArgsMap: (...args: Arguments<(Connectable & Conflictable)[XE]>) => Defined, + 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(value: T[], changes: Changes, sortFunc: (a: T, b: T) => number): T[] { + const removedIds = new Set(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( + getPromise: (recoilValue: RecoilValue) => Promise, + guildId: number, + setSelf: (loadableValue: LoadableValue) => void, + stateAtomFamily: (guildId: number) => RecoilState>, + fetchValueFunc: () => Promise, + eventArgsMap: (...args: Arguments<(Connectable & Conflictable)[XE]>) => Changes, + sortFunc: (a: T, b: T) => number, + applyFunc: (value: T[], eventArgsResult: Changes, 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 { + updatedEventName: UE; + updatedEventArgsMap: (...args: Arguments<(Connectable & Conflictable)[UE]>) => Defined; + conflictEventName: CE; + conflictEventArgsMap: (...args: Arguments<(Connectable & Conflictable)[CE]>) => Defined; +} + +function listenToSingle< + T, // e.g. GuildMetadata + UE extends keyof Connectable, // Update Event + CE extends keyof Conflictable, // Conflict Event +>( + getPromise: (recoilValue: RecoilValue) => Promise, + guildId: number, + setSelf: (loadableValue: LoadableValue) => void, + stateAtomFamily: (guildId: number) => RecoilState>, + fetchValueFunc: () => Promise, + eventMapping: SingleEventMappingParams +) { + // 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; + updatedEventName: UE; + updatedEventArgsMap: (...args: Arguments<(Connectable & Conflictable)[UE]>) => Defined; + removedEventName: RE; + removedEventArgsMap: (...args: Arguments<(Connectable & Conflictable)[RE]>) => Defined; + conflictEventName: CE; + conflictEventArgsMap: (...args: Arguments<(Connectable & Conflictable)[CE]>) => Changes; +} +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: (recoilValue: RecoilValue) => Promise, + guildId: number, + setSelf: (loadableValue: LoadableValue) => void, + stateAtomFamily: (guildId: number) => RecoilState>, + fetchValueFunc: () => Promise, + sortFunc: (a: T, b: T) => number, + eventMapping: MultipleEventMappingParams +) { + // 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>, + fetchFunc: (guild: CombinedGuild) => Promise>, + eventMapping: SingleEventMappingParams +) { + return (params: RecoilLoadableAtomEffectParams) => { + 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>, + fetchFunc: (guild: CombinedGuild) => Promise>, + sortFunc: (a: T, b: T) => number, + eventMapping: MultipleEventMappingParams, +) { + return (params: RecoilLoadableAtomEffectParams) => { + 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> = atomFamily, 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> = atomFamily, 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) => changes, + } + ) + ], + dangerouslyAllowMutability: true +}); + +// You probably want currGuildSelfMemberState +export const guildSelfMemberState = selectorFamily, 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> = atomFamily, 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) => changes + } + ) + ], + dangerouslyAllowMutability: true +}); + +// You probably want currGuildActiveChannel +export const guildActiveChannelIdState: (guildId: number) => RecoilState = atomFamily({ + key: 'guildActiveChannelIdState', + default: null, +}); +const guildActiveChannelState = selectorFamily, 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({ + 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({ + key: 'currGuildIdState', + default: null +}); + +function createCurrentGuildStateGetter(subSelectorFamily: (guildId: number) => RecoilValueReadOnly) { + return ({ get }: { get: GetRecoilValue }) => { + const currGuildId = get(currGuildIdState); + if (currGuildId === null) return null; + const value = get(subSelectorFamily(currGuildId)); + return value; + } +} +function createCurrentGuildLoadableStateGetter(subSelectorFamily: (guildId: number) => RecoilValueReadOnly> | RecoilState>) { + 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({ + key: 'currGuildState', + get: createCurrentGuildStateGetter(guildState), + dangerouslyAllowMutability: true +}); +export const currGuildMetaState = selector>({ + key: 'currGuildMetaState', + get: createCurrentGuildLoadableStateGetter(guildMetaState), + dangerouslyAllowMutability: true +}); +export const currGuildMembersState = selector>({ + key: 'currGuildMembersState', + get: createCurrentGuildLoadableStateGetter(guildMembersState), + dangerouslyAllowMutability: true +}); +export const currGuildSelfMemberState = selector>({ + key: 'currGuildSelfMemberState', + get: createCurrentGuildLoadableStateGetter(guildSelfMemberState), + dangerouslyAllowMutability: true +}); +export const currGuildChannelsState = selector>({ + key: 'currGuildChannelsState', + get: createCurrentGuildLoadableStateGetter(guildChannelsState), + dangerouslyAllowMutability: true +}); +export const currGuildActiveChannelState = selector>({ + 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 ]); +} + diff --git a/src/client/webapp/elements/root.tsx b/src/client/webapp/elements/root.tsx index fa5d837..054fca4 100644 --- a/src/client/webapp/elements/root.tsx +++ b/src/client/webapp/elements/root.tsx @@ -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 = (props: RootElementProps) => { const { guildsManager } = props; - const overlay = useRecoilValue(overlayState); + initRecoil(guildsManager); - useGuildsManagerWithRecoil(guildsManager); + const overlay = useRecoilValue(overlayState); return (
diff --git a/src/client/webapp/elements/sections/connection-info.tsx b/src/client/webapp/elements/sections/connection-info.tsx index 33582f3..601ebf0 100644 --- a/src/client/webapp/elements/sections/connection-info.tsx +++ b/src/client/webapp/elements/sections/connection-info.tsx @@ -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(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 ( - + ); - }, [ guildWithSelfMember ]); + }, [ guild, selfMember ]); - return guildWithSelfMember?.guild ? ( -
-
- {contextMenu} -
- ) : null; + if (guild) { + return ( +
+
+ {contextMenu} +
+ ); + } else { + return null; + } } export default ConnectionInfo; diff --git a/src/client/webapp/elements/sections/guild-list-container.tsx b/src/client/webapp/elements/sections/guild-list-container.tsx index ad50397..e05e5b7 100644 --- a/src/client/webapp/elements/sections/guild-list-container.tsx +++ b/src/client/webapp/elements/sections/guild-list-container.tsx @@ -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(null); diff --git a/src/client/webapp/elements/sections/guild-title.tsx b/src/client/webapp/elements/sections/guild-title.tsx index 7e657bc..44d6c17 100644 --- a/src/client/webapp/elements/sections/guild-title.tsx +++ b/src/client/webapp/elements/sections/guild-title.tsx @@ -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 | null; -} - -const GuildTitle: FC = (props: GuildTitleProps) => { - const { selfMemberResult } = props; - - const guildWithMeta = useRecoilValue(selectedGuildWithMetaState) +const GuildTitle: FC = () => { + const guildMeta = useRecoilValue(currGuildMetaState); + const selfMember = useRecoilValue(currGuildSelfMemberState); const rootRef = useRef(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 ( - + ); - }, [ selfMemberResult, rootRef ]); + }, [ rootRef ]); const nameStyle = useMemo(() => { if (hasContextMenu) { @@ -47,7 +35,7 @@ const GuildTitle: FC = (props: GuildTitleProps) => { return (
- {guildWithMeta?.value?.name ?? null} + {isLoaded(guildMeta) ? guildMeta.value.name : null}
{contextMenu}
diff --git a/src/client/webapp/elements/sections/guild.tsx b/src/client/webapp/elements/sections/guild.tsx index 8b62de3..0121d3a 100644 --- a/src/client/webapp/elements/sections/guild.tsx +++ b/src/client/webapp/elements/sections/guild.tsx @@ -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 = (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(null); - const [ activeChannelGuild, setActiveChannelGuild ] = useState(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) | 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 (
- - + +
- +
- {activeChannel && activeChannelGuild && } - {activeChannel && activeChannelGuild && } + {guild && isLoaded(activeChannel) && } + {guild && isLoaded(activeChannel) && }
diff --git a/src/client/webapp/elements/sections/guilds-manager.tsx b/src/client/webapp/elements/sections/guilds-manager.tsx index acb47b0..687f1c4 100644 --- a/src/client/webapp/elements/sections/guilds-manager.tsx +++ b/src/client/webapp/elements/sections/guilds-manager.tsx @@ -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 (
- {selectedGuild && } +
); }