From 0609aa687c1cb6f15d8e34057b73bbcad11b6df2 Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Sat, 29 Jan 2022 17:05:12 -0600 Subject: [PATCH] recoilize members --- .../webapp/elements/components/token-row.tsx | 16 +-- .../contexts/context-menu-connection-info.tsx | 21 ++-- .../contexts/context-menu-guild-title.tsx | 17 ++- .../displays/display-guild-invites.tsx | 15 +-- .../displays/display-guild-overview.tsx | 13 ++- .../lists/components/member-element.tsx | 6 +- .../webapp/elements/lists/member-list.tsx | 28 ++--- .../overlays/overlay-guild-settings.tsx | 19 +-- .../elements/overlays/overlay-personalize.tsx | 22 ++-- src/client/webapp/elements/require/atoms.ts | 109 +++++++++++++----- .../require/guild-subscriptions-recoil.ts | 0 .../elements/require/setup-guild-recoil.ts | 104 ++++++++++++++--- .../elements/sections/connection-info.tsx | 47 ++++---- src/client/webapp/elements/sections/guild.tsx | 5 +- 14 files changed, 270 insertions(+), 152 deletions(-) create mode 100644 src/client/webapp/elements/require/guild-subscriptions-recoil.ts diff --git a/src/client/webapp/elements/components/token-row.tsx b/src/client/webapp/elements/components/token-row.tsx index 9627ad2..f07a195 100644 --- a/src/client/webapp/elements/components/token-row.tsx +++ b/src/client/webapp/elements/components/token-row.tsx @@ -1,10 +1,9 @@ import moment from 'moment'; import React, { FC } from 'react'; -import { useRecoilValue } from 'recoil'; -import { Member, Token } from '../../data-types'; +import { GuildMetadata, Member, Token } from '../../data-types'; import Util from '../../util'; import { IAddGuildData } from '../overlays/overlay-add-guild'; -import { selectedGuildWithMetaState } from '../require/atoms'; +import { GuildWithValue } from '../require/atoms'; import BaseElements from '../require/base-elements'; import { useSoftImageSrcResourceSubscription } from '../require/guild-subscriptions'; import { useAsyncVoidCallback, useDownloadButton, useOneTimeAsyncAction } from '../require/react-helper'; @@ -13,17 +12,18 @@ import Button, { ButtonColorType } from './button'; export interface TokenRowProps { url: string; token: Token; + guildWithMeta: GuildWithValue } const TokenRow: FC = (props: TokenRowProps) => { - const { token } = props; - const guildWithMeta = useRecoilValue(selectedGuildWithMetaState); - if (guildWithMeta === null || guildWithMeta.value === null) return null; + const { token, guildWithMeta } = props; const [ guildSocketConfigs ] = useOneTimeAsyncAction( - async () => await guildWithMeta.guild.fetchSocketConfigs(), + async () => { + return await guildWithMeta.guild.fetchSocketConfigs() + }, null, - [ guildWithMeta.guild ] + [ guildWithMeta ] ); const [ iconSrc ] = useSoftImageSrcResourceSubscription(guildWithMeta.guild, guildWithMeta.value.iconResourceId, guildWithMeta.guild); 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 0d66585..5b921e2 100644 --- a/src/client/webapp/elements/contexts/context-menu-connection-info.tsx +++ b/src/client/webapp/elements/contexts/context-menu-connection-info.tsx @@ -1,29 +1,26 @@ import React, { FC, ReactNode, RefObject, useCallback, useMemo } from 'react'; import { useSetRecoilState } from 'recoil'; -import { Member } from '../../data-types'; -import CombinedGuild from '../../guild-combined'; -import { overlayState } from '../require/atoms'; +import { GuildMetadata, Member } from '../../data-types'; +import { GuildWithValue, overlayState } from '../require/atoms'; import PersonalizeOverlay from '../overlays/overlay-personalize'; -import { SubscriptionResult } from '../require/guild-subscriptions'; import ContextMenu from './components/context-menu'; export interface ConnectionInfoContextMenuProps { - guild: CombinedGuild; - selfMemberResult: SubscriptionResult; + guildWithSelfMember: GuildWithValue; relativeToRef: RefObject; close: () => void; } const ConnectionInfoContextMenu: FC = (props: ConnectionInfoContextMenuProps) => { - const { guild, selfMemberResult, relativeToRef, close } = props; + const { guildWithSelfMember, relativeToRef, close } = props; const setOverlay = useSetRecoilState(overlayState) const setSelfStatus = useCallback(async (status: string) => { - if (selfMemberResult.value.status !== status) { - await guild.requestSetStatus(status); + if (guildWithSelfMember.value.status !== status) { + await guildWithSelfMember.guild.requestSetStatus(status); } - }, [ guild, selfMemberResult ]); + }, [ guildWithSelfMember ]); const statusElements = useMemo(() => { return [ 'online', 'away', 'busy', 'invisible' ].map(status => { @@ -39,8 +36,8 @@ const ConnectionInfoContextMenu: FC = (props: Co const openPersonalize = useCallback(() => { close(); - setOverlay(); - }, [ guild, selfMemberResult, close ]); + setOverlay(); + }, [ close ]); const alignment = useMemo(() => { return { bottom: 'top', centerX: 'centerX' } 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 1f15740..13a7a4d 100644 --- a/src/client/webapp/elements/contexts/context-menu-guild-title.tsx +++ b/src/client/webapp/elements/contexts/context-menu-guild-title.tsx @@ -1,7 +1,12 @@ +import * as electronRemote from '@electron/remote'; +const electronConsole = electronRemote.getGlobal('console') as Console; +import Logger from '../../../../logger/logger'; +const LOG = Logger.create(__filename, electronConsole); + import React, { FC, ReactNode, RefObject, useCallback, useMemo } from 'react'; -import { useSetRecoilState } from 'recoil'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { Member } from '../../data-types'; -import { overlayState } from '../require/atoms'; +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'; @@ -16,11 +21,17 @@ export interface GuildTitleContextMenuProps { const GuildTitleContextMenu: FC = (props: GuildTitleContextMenuProps) => { const { close, relativeToRef, selfMember } = props; + const guildWithMeta = useRecoilValue(selectedGuildWithMetaState); + const setOverlay = useSetRecoilState(overlayState); const openGuildSettings = useCallback(() => { close(); - setOverlay(); + if (isDevoidPendedOrFailed(guildWithMeta)) { + LOG.warn('not opening guild settings context menu due to devoid/pended/failed guildWithMeta'); + return; + } + setOverlay(); }, [ close ]); const openCreateChannel = useCallback(() => { diff --git a/src/client/webapp/elements/displays/display-guild-invites.tsx b/src/client/webapp/elements/displays/display-guild-invites.tsx index 17ca076..5b6d245 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 { Token } from '../../data-types'; +import { GuildMetadata, Token } from '../../data-types'; import { useAsyncSubmitButton } from '../require/react-helper'; import { Duration } from 'moment'; import moment from 'moment'; @@ -14,12 +14,13 @@ 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 { useRecoilValue } from 'recoil'; -import { selectedGuildWithMetaState } from '../require/atoms'; +import { GuildWithValue } from '../require/atoms'; -const GuildInvitesDisplay: FC = () => { - const guildWithMeta = useRecoilValue(selectedGuildWithMetaState); - if (guildWithMeta === null || guildWithMeta.value === null) return null; +export interface GuildInvitesDisplayProps { + guildWithMeta: GuildWithValue; +} +const GuildInvitesDisplay: FC = (props: GuildInvitesDisplayProps) => { + const { guildWithMeta } = props; const url = 'https://localhost:3030'; // TODO: this will likely be a dropdown list at some point @@ -67,7 +68,7 @@ const GuildInvitesDisplay: FC = () => { if (!guildWithMeta?.value) { return
No Guild Metadata
; } - return tokensResult?.value?.map((token: Token) => ); + return tokensResult?.value?.map((token: Token) => ); }, [ url, guildWithMeta, tokensResult, tokensError ]); return ( diff --git a/src/client/webapp/elements/displays/display-guild-overview.tsx b/src/client/webapp/elements/displays/display-guild-overview.tsx index 0462e32..e4eb169 100644 --- a/src/client/webapp/elements/displays/display-guild-overview.tsx +++ b/src/client/webapp/elements/displays/display-guild-overview.tsx @@ -10,13 +10,14 @@ 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 { useRecoilValue } from 'recoil'; -import { selectedGuildWithMetaState } from '../require/atoms'; +import { GuildWithValue } from '../require/atoms'; +import { GuildMetadata } from '../../data-types'; -const GuildOverviewDisplay: FC = () => { - const guildWithMeta = useRecoilValue(selectedGuildWithMetaState); - - if (guildWithMeta === null || guildWithMeta.value === null) return null; +interface GuildOverviewDisplayProps { + guildWithMeta: GuildWithValue; +} +const GuildOverviewDisplay: FC = (props: GuildOverviewDisplayProps) => { + const { guildWithMeta } = props; // TODO: Use the one from guild.tsx (for both of these?) const [ iconResourceResult, iconResourceError ] = useResourceSubscription(guildWithMeta.guild, guildWithMeta.value.iconResourceId, guildWithMeta.guild); diff --git a/src/client/webapp/elements/lists/components/member-element.tsx b/src/client/webapp/elements/lists/components/member-element.tsx index 839ea40..f033a6b 100644 --- a/src/client/webapp/elements/lists/components/member-element.tsx +++ b/src/client/webapp/elements/lists/components/member-element.tsx @@ -15,13 +15,13 @@ export interface DummyMember { export interface MemberProps { guild: CombinedGuild; member: Member | DummyMember; - memberGuild: CombinedGuild | null; } const MemberElement: FC = (props: MemberProps) => { - const { guild, member, memberGuild } = props; + const { guild, member } = props; - const [ avatarSrc ] = useSoftImageSrcResourceSubscription(guild, member.avatarResourceId, memberGuild); + // TODO: This is a terrible line of code. Make sure to fix it whe we do resources in recoil + const [ avatarSrc ] = useSoftImageSrcResourceSubscription(guild, member.avatarResourceId, guild); const nameStyle = useMemo(() => member.roleColor ? { color: member.roleColor } : {}, [ member.roleColor ]); diff --git a/src/client/webapp/elements/lists/member-list.tsx b/src/client/webapp/elements/lists/member-list.tsx index 79595d1..1388dba 100644 --- a/src/client/webapp/elements/lists/member-list.tsx +++ b/src/client/webapp/elements/lists/member-list.tsx @@ -5,30 +5,20 @@ const LOG = Logger.create(__filename, electronConsole); import React, { FC, useMemo } from 'react'; import { Member } from '../../data-types'; -import CombinedGuild from '../../guild-combined'; -import { isNonNullAndHasValue, SubscriptionResult } from '../require/guild-subscriptions'; import MemberElement from './components/member-element'; +import { useRecoilValue } from 'recoil'; +import { isDevoid, isPended, isFailed, selectedGuildWithMembersState } from '../require/atoms'; -export interface MemberListProps { - guild: CombinedGuild; - membersResult: SubscriptionResult | null; - membersFetchError: unknown | null; -} - -const MemberList: FC = (props: MemberListProps) => { - const { guild, membersResult, membersFetchError } = props; +const MemberList: FC = () => { + const guildWithMembers = useRecoilValue(selectedGuildWithMembersState); const memberElements = useMemo(() => { - if (membersFetchError) { - // TODO: Try Again - return
Unable to load members
- } - if (!isNonNullAndHasValue(membersResult)) { - return
Loading members...
- } + if (isDevoid(guildWithMembers)) return null; + if (isPended(guildWithMembers)) return
Loading Members...
; + if (isFailed(guildWithMembers)) return
Unable to load members
; //LOG.debug(`drawing ${membersResult.value.length} members`); - return membersResult.value.map((member: Member) => ); - }, [ guild, membersResult, membersFetchError ]); + return guildWithMembers.value.map((member: Member) => ); + }, [ guildWithMembers ]); return (
diff --git a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx index 31f54b7..3646ef6 100644 --- a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx +++ b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx @@ -3,13 +3,14 @@ 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 { useRecoilValue } from "recoil"; -import { selectedGuildWithMetaState } from "../require/atoms"; +import { GuildWithValue } from "../require/atoms"; +import { GuildMetadata } from "../../data-types"; -const GuildSettingsOverlay: FC = () => { - const guildWithMeta = useRecoilValue(selectedGuildWithMetaState); - - if (guildWithMeta === null || guildWithMeta.value === null) return null; +interface GuildSettingsOverlayProps { + guildWithMeta: GuildWithValue; +} +const GuildSettingsOverlay: FC = (props: GuildSettingsOverlayProps) => { + const { guildWithMeta } = props; const rootRef = useRef(null); @@ -17,10 +18,10 @@ const GuildSettingsOverlay: FC = () => { const [ display, setDisplay ] = useState(); useEffect(() => { - if (selectedId === 'overview') setDisplay(); + if (selectedId === 'overview') setDisplay(); //if (selectedId === 'roles' ) setDisplay(); - if (selectedId === 'invites' ) setDisplay(); - }, [ selectedId ]); + if (selectedId === 'invites' ) setDisplay(); + }, [ selectedId, guildWithMeta, setDisplay ]); return ( diff --git a/src/client/webapp/elements/overlays/overlay-personalize.tsx b/src/client/webapp/elements/overlays/overlay-personalize.tsx index ec2a03d..d1f7933 100644 --- a/src/client/webapp/elements/overlays/overlay-personalize.tsx +++ b/src/client/webapp/elements/overlays/overlay-personalize.tsx @@ -4,38 +4,38 @@ import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); import React, { createRef, FC, MutableRefObject, ReactNode, useEffect, useMemo, useRef, useState } from 'react'; -import { Member } from '../../data-types'; import Globals from '../../globals'; -import CombinedGuild from '../../guild-combined'; import ImageEditInput from '../components/input-image-edit'; import TextInput from '../components/input-text'; import SubmitOverlayLower from '../components/submit-overlay-lower'; import { useAsyncSubmitButton } from '../require/react-helper'; import Button from '../components/button'; import Overlay from '../components/overlay'; -import { SubscriptionResult, useResourceSubscription } from '../require/guild-subscriptions'; +import { useResourceSubscription } from '../require/guild-subscriptions'; import { useSetRecoilState } from 'recoil'; -import { overlayState } from '../require/atoms'; +import { overlayState, GuildWithValue } from '../require/atoms'; +import { Member } from '../../data-types'; -export interface PersonalizeOverlayProps { - guild: CombinedGuild; - selfMemberResult: SubscriptionResult; +interface PersonalizeOverlayProps { + guildWithSelfMember: GuildWithValue; } const PersonalizeOverlay: FC = (props: PersonalizeOverlayProps) => { - const { guild, selfMemberResult } = props; + const { guildWithSelfMember } = props; const setOverlay = useSetRecoilState(overlayState); + const { guild, value: selfMember } = guildWithSelfMember; + const rootRef = useRef(null); - const [ avatarResourceResult, avatarResourceError ] = useResourceSubscription(guild, selfMemberResult.value.avatarResourceId, selfMemberResult.guild); + const [ avatarResourceResult, avatarResourceError ] = useResourceSubscription(guild, selfMember.avatarResourceId, guild); const displayNameInputRef = createRef(); - const [ savedDisplayName, setSavedDisplayName ] = useState(selfMemberResult.value.displayName); + const [ savedDisplayName, setSavedDisplayName ] = useState(selfMember.displayName); const [ savedAvatarBuff, setSavedAvatarBuff ] = useState(null); - const [ displayName, setDisplayName ] = useState(selfMemberResult.value.displayName); + const [ displayName, setDisplayName ] = useState(selfMember.displayName); const [ avatarBuff, setAvatarBuff ] = useState(null); const [ displayNameInputValid, setDisplayNameInputValid ] = useState(false); diff --git a/src/client/webapp/elements/require/atoms.ts b/src/client/webapp/elements/require/atoms.ts index c5c8756..f34d9a3 100644 --- a/src/client/webapp/elements/require/atoms.ts +++ b/src/client/webapp/elements/require/atoms.ts @@ -1,24 +1,57 @@ +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 } from 'react'; -import { atom, DefaultValue, selector } from 'recoil'; +import { atom, selector } from 'recoil'; import { Channel, GuildMetadata, Member, Message } from '../../data-types'; import CombinedGuild from '../../guild-combined'; import GuildsManager from '../../guilds-manager'; -export interface GuildWithValue { - guild: CombinedGuild; +export type WithUnloadedValue = { + value: undefined; + hasValueError: false; + valueError: undefined; +}; +export type WithLoadedValue = T extends undefined ? never : { value: T; + hasValueError: false; + valueError: undefined; +}; +export type WithFailedValue = { + value: undefined; + hasValueError: true; + valueError: unknown; +}; +export type WithLoadableValue = WithUnloadedValue | WithLoadedValue | WithFailedValue; + +// NOTE: Should not extend this type. It is for type guards + +export type GuildWithValue = WithLoadedValue & { guild: CombinedGuild }; +export type GuildWithErrorableValue = WithLoadableValue & { guild: CombinedGuild }; +export type ChannelWithErrorableValue = WithLoadableValue & { channel: Channel }; + +// Devoid is when we don't have a selected guild yet. +export function isDevoid(state: NonNullable | null): state is null { + return state === null; +} +// NOTE: Using "pended" instead of "pending" since it has 6 letters like the rest of these functions. It will make the code look nicer :) +export function isPended(withLoadableValue: WithLoadableValue): withLoadableValue is WithUnloadedValue { + return withLoadableValue.value === undefined && withLoadableValue.hasValueError === false; +} +export function isLoaded(withLoadableValue: WithLoadableValue): withLoadableValue is WithLoadedValue { + return withLoadableValue.value !== undefined; +} +export function isFailed(withLoadableValue: WithLoadableValue): withLoadableValue is WithFailedValue { + return withLoadableValue.hasValueError; } -export interface GuildWithErrorableValue { - guild: CombinedGuild; - value: T | null; - valueError: unknown | null; -} - -export interface ChannelWithErrorableValue { - channel: Channel; - value: T | null; - valueError: unknown | null; +// WARNING: This should 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 { + return withNullableLoadableValue === null || isPended(withNullableLoadableValue) || isFailed(withNullableLoadableValue); } export const overlayState = atom({ @@ -32,7 +65,7 @@ export const guildsManagerState = atom({ // 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 -}) +}); export const guildsState = atom[] | null>({ key: 'guildsState', @@ -59,45 +92,59 @@ export const selectedGuildWithMetaState = selector({ key: 'selectedGuildState', get: ({ get }) => { const guildWithMeta = get(selectedGuildWithMetaState); - if (guildWithMeta === null) return null; + if (isDevoid(guildWithMeta)) return null; return guildWithMeta.guild; }, dangerouslyAllowMutability: true }); -export const selectedGuildMembersState = atom | null>({ - key: 'selectedGuildMembersState', - default: null +export const selectedGuildWithMembersState = atom | null>({ + key: 'selectedGuildWithMembersState', + default: null, + dangerouslyAllowMutability: true +}); + +export const selectedGuildWithSelfMemberState = selector | null>({ + key: 'selectedGuildWithSelfMemberState', + get: ({ get }) => { + const guildWithMembers = get(selectedGuildWithMembersState); + if (isDevoid(guildWithMembers)) return null; + if (isFailed(guildWithMembers)) return { guild: guildWithMembers.guild, value: undefined, hasValueError: true, valueError: 'members fetch error' }; + if (isPended(guildWithMembers)) return { guild: guildWithMembers.guild, value: undefined, hasValueError: false, valueError: undefined }; + const selfMember = guildWithMembers.value.find(member => member.id === guildWithMembers.guild.memberId) ?? null; + if (selfMember === null) { + LOG.warn('unable to find self member in members'); + return { guild: guildWithMembers.guild, value: undefined, hasValueError: true, valueError: 'unable to find self in members' }; + } + return { guild: guildWithMembers.guild, value: selfMember, hasValueError: false, valueError: undefined }; + }, + dangerouslyAllowMutability: true }); export const selectedGuildWithChannelsState = atom | null>({ key: 'selectedGuildChannelsState', - default: null + default: null, + dangerouslyAllowMutability: true }); export const selectedGuildWithActiveChannelIdState = atom | null>({ key: 'selectedGuildWithActiveChannelIdState', - default: null + default: null, + dangerouslyAllowMutability: true }); -export const selectedGuildWithActiveChannelMessagesState = atom | null> | null>({ +export const selectedGuildWithActiveChannelMessagesState = atom> | null>({ key: 'selectedGuildWithActiveChannelMessagesState', - default: null + default: null, + dangerouslyAllowMutability: true }); -export const selectedGuildWithActiveChannelState = selector | null>({ - key: 'selectedGuildActiveChannelState', - get: ({ get }) => { - const guildWithChannelMessages = get(selectedGuildWithActiveChannelMessagesState); - if (guildWithChannelMessages === null || guildWithChannelMessages.value === null) return null; - - return { guild: guildWithChannelMessages.guild, value: guildWithChannelMessages.value.channel }; - } -}); +// TODO: selectedGuildWithActiveChannelState diff --git a/src/client/webapp/elements/require/guild-subscriptions-recoil.ts b/src/client/webapp/elements/require/guild-subscriptions-recoil.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/client/webapp/elements/require/setup-guild-recoil.ts b/src/client/webapp/elements/require/setup-guild-recoil.ts index 363a08c..49138c7 100644 --- a/src/client/webapp/elements/require/setup-guild-recoil.ts +++ b/src/client/webapp/elements/require/setup-guild-recoil.ts @@ -5,13 +5,13 @@ const LOG = Logger.create(__filename, electronConsole); import { useCallback, useEffect, useRef } from "react"; import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"; -import { GuildMetadata } from "../../data-types"; +import { Changes, GuildMetadata, Member } from "../../data-types"; import GuildsManager from "../../guilds-manager"; -import { guildsManagerState, guildsState, GuildWithErrorableValue, selectedGuildIdState, selectedGuildState } from "./atoms"; +import { guildsManagerState, guildsState, GuildWithErrorableValue, selectedGuildIdState, selectedGuildState, selectedGuildWithMembersState } from "./atoms"; import { useIsMountedRef } from "./react-helper"; import CombinedGuild from '../../guild-combined'; import { AutoVerifierChangesType } from '../../auto-verifier'; - +import * as uuid from 'uuid'; /** Manages the guildsManager to ensure that guildsState is up to date with a list of guilds * and that the guilds attempt to fetch their corresponding guildMetas @@ -26,15 +26,14 @@ function useManagedGuildsManager(guildsManager: GuildsManager) { const onChange = useCallback(() => { const guilds = guildsManager.guilds; setGuildsWithMeta((prevGuildsWithMeta) => { - if (!prevGuildsWithMeta) { - return guilds.map(guild => ({ guild, value: null, valueError: null })); - } else { - const newGuilds = guilds.filter(guild => !prevGuildsWithMeta.find(guildWithMeta => guildWithMeta.guild.id === guild.id)); - const newGuildsWithMeta = prevGuildsWithMeta - .filter(guildWithMeta => guilds.find(guild => guildWithMeta.guild.id === guild.id)) // Remove old guilds - .concat(newGuilds.map(guild => ({ guild, value: null, valueError: null }))); // Add new guilds - return newGuildsWithMeta; + if (prevGuildsWithMeta === null) { + return guilds.map(guild => ({ guild, value: undefined, hasValueError: false, valueError: undefined })); } + const newGuilds = guilds.filter(guild => !prevGuildsWithMeta.find(guildWithMeta => guildWithMeta.guild.id === guild.id)); + const newGuildsWithMeta = prevGuildsWithMeta + .filter(guildWithMeta => guilds.find(guild => guildWithMeta.guild.id === guild.id)) // Remove old guilds + .concat(newGuilds.map(guild => ({ guild, value: undefined, hasValueError: false, valueError: undefined }))); // Add new guilds + return newGuildsWithMeta; }); }, [ guildsManager, setGuildsWithMeta ]); @@ -62,19 +61,21 @@ function useManagedGuildsManager(guildsManager: GuildsManager) { if (guildsWithMeta === null) return; // Load guild metadatas - for (const { guild, value, valueError } of guildsWithMeta) { + // TODO: How do we add a retry for guild metadata? It's used in a lot of places! + for (const { guild, value, hasValueError } of guildsWithMeta) { // Only fetch if the value has not been fetched yet and we are not currently fetching it. - if (!metadataInProgress.current.has(guild.id) && value === null && valueError === null) { + if (!metadataInProgress.current.has(guild.id) && value === undefined && hasValueError === false) { (async () => { metadataInProgress.current.add(guild.id); try { const guildMeta = await guild.fetchMetadata(); if (!isMounted.current) return; - setGuildWithMeta(guild.id, { guild, value: guildMeta, valueError: null }); + LOG.debug('loaded metadata for g#' + guild.id); + setGuildWithMeta(guild.id, { guild, value: guildMeta, hasValueError: false, valueError: undefined }); } catch (e: unknown) { LOG.error(`error fetching metadata for g#${guild.id}`, e); if (!isMounted.current) return; - setGuildWithMeta(guild.id, { guild, value: null, valueError: e }); + setGuildWithMeta(guild.id, { guild, value: undefined, hasValueError: true, valueError: e }); } metadataInProgress.current.delete(guild.id); })(); @@ -88,7 +89,7 @@ function useManagedGuildsManager(guildsManager: GuildsManager) { // Listen for changes to metadata function handleUpdateOrConflict(guild: CombinedGuild, guildMeta: GuildMetadata) { - setGuildWithMeta(guild.id, { guild, value: guildMeta, valueError: null }); + setGuildWithMeta(guild.id, { guild, value: guildMeta, hasValueError: false, valueError: undefined }); } const callbacks = new Map void, @@ -120,6 +121,72 @@ function useManagedGuildsManager(guildsManager: GuildsManager) { //}, [ guildsWithMeta ]); } +function applyChanges(src: T[], changes: Changes) { + const deletedIds = new Set(changes.deleted.map(deletedElement => deletedElement.id)) + return src.concat(changes.added) + .map(element => changes.updated.find(change => change.newDataPoint.id === element.id)?.newDataPoint ?? element) + .filter(element => !deletedIds.has(element.id)); +} + +export function useRecoilSelectedGuildMembers(): void { + const isMounted = useIsMountedRef(); + + const guild = useRecoilValue(selectedGuildState); + const setGuildWithMembers = useSetRecoilState(selectedGuildWithMembersState); + + const lastFetchId = useRef(null); + + + // TODO: Test this custom effect out + + // Fetch initial value + useEffect(() => { + if (guild === null) return; + (async () => { + // TODO: Set value to null if the fetch takes > 300 ms + const fetchId = uuid.v4(); + try { + lastFetchId.current = fetchId; + const members = await guild.fetchMembers(); + if (!isMounted.current) return; + if (lastFetchId.current !== fetchId) return; // Another fetch happened while we were fetching + setGuildWithMembers({ guild, value: members, hasValueError: false, valueError: undefined }); + } catch (e: unknown) { + LOG.error('unable to perform members fetch', e); + if (!isMounted.current) return; + if (lastFetchId.current !== fetchId) return; // Another fetch happened while we were fetching + setGuildWithMembers({ guild, value: undefined, hasValueError: true, valueError: e }); + } + })(); + }, [ guild, isMounted ]); + + // Listen for Changes + useEffect(() => { + if (guild === null) return; + + const updateCallback = (updatedMembers: Member[]) => setGuildWithMembers({ guild, value: updatedMembers, hasValueError: false, valueError: undefined }); + const conflictCallback = (_changesType: AutoVerifierChangesType, changes: Changes) => { + setGuildWithMembers(prevGuildWithMembers => { + if (prevGuildWithMembers === null) return null; + if (prevGuildWithMembers.valueError) return prevGuildWithMembers; + return { + guild, + value: applyChanges(prevGuildWithMembers.value ?? [], changes), + hasValueError: false, + valueError: undefined + }; + }); + }; + + guild.on('update-members', updateCallback); + guild.on('conflict-members', conflictCallback); + return () => { + guild.off('update-members', updateCallback); + guild.off('conflict-members', conflictCallback); + } + }, [ guild ]); +} + // Entrypoint for recoil setup with guilds export function useGuildsManagerWithRecoil(guildsManager: GuildsManager): void { const setGuildsManager = useSetRecoilState(guildsManagerState); @@ -137,6 +204,9 @@ export function useGuildsManagerWithRecoil(guildsManager: GuildsManager): void { if (guilds && guilds.length > 0 && selectedGuildId === null) { setSelectedGuildId((guilds[0] as GuildWithErrorableValue).guild.id); } - }); + }, [ guilds, selectedGuildId ]); + + // Automatically load members for the selected guild + useRecoilSelectedGuildMembers(); } diff --git a/src/client/webapp/elements/sections/connection-info.tsx b/src/client/webapp/elements/sections/connection-info.tsx index 31a4c84..33582f3 100644 --- a/src/client/webapp/elements/sections/connection-info.tsx +++ b/src/client/webapp/elements/sections/connection-info.tsx @@ -1,23 +1,18 @@ -import React, { Dispatch, FC, ReactNode, SetStateAction, useMemo, useRef } from 'react'; +import React, { FC, useMemo, useRef } from 'react'; import { Member } from '../../data-types'; -import CombinedGuild from '../../guild-combined'; import MemberElement, { DummyMember } from '../lists/components/member-element'; import ConnectionInfoContextMenu from '../contexts/context-menu-connection-info'; import { useContextMenu } from '../require/react-helper'; -import { isNonNullAndHasValue, SubscriptionResult } from '../require/guild-subscriptions'; - -export interface ConnectionInfoProps { - guild: CombinedGuild; - selfMemberResult: SubscriptionResult | null; -} - -const ConnectionInfo: FC = (props: ConnectionInfoProps) => { - const { guild, selfMemberResult } = props; +import { useRecoilValue } from 'recoil'; +import { isDevoid, isPended, isFailed, selectedGuildWithSelfMemberState, isDevoidPendedOrFailed } from '../require/atoms'; +const ConnectionInfo: FC = () => { const rootRef = useRef(null); + const guildWithSelfMember = useRecoilValue(selectedGuildWithSelfMemberState); + const displayMember = useMemo((): Member | DummyMember => { - if (!isNonNullAndHasValue(selfMemberResult)) { + if (isDevoid(guildWithSelfMember) || isPended(guildWithSelfMember)) { return { id: 'dummy', displayName: 'Connecting...', @@ -26,25 +21,31 @@ const ConnectionInfo: FC = (props: ConnectionInfoProps) => avatarResourceId: null }; } - return selfMemberResult.value; - }, [ selfMemberResult ]); + if (isFailed(guildWithSelfMember)) { + return { + id: 'dummy', + displayName: 'UNKNOWN', + status: 'unknown', + roleColor: null, + avatarResourceId: null + }; + } + return guildWithSelfMember.value; + }, [ guildWithSelfMember ]); const [ contextMenu, toggleContextMenu ] = useContextMenu((close: () => void) => { - if (!isNonNullAndHasValue(selfMemberResult)) return null; + if (isDevoidPendedOrFailed(guildWithSelfMember)) return null; return ( - + ); - }, [ guild, selfMemberResult, rootRef ]); + }, [ guildWithSelfMember ]); - return ( + return guildWithSelfMember?.guild ? (
-
+
{contextMenu}
- ); + ) : null; } export default ConnectionInfo; diff --git a/src/client/webapp/elements/sections/guild.tsx b/src/client/webapp/elements/sections/guild.tsx index 439e96e..30470fb 100644 --- a/src/client/webapp/elements/sections/guild.tsx +++ b/src/client/webapp/elements/sections/guild.tsx @@ -28,7 +28,6 @@ const GuildElement: FC = (props: GuildElementProps) => { // TODO: React jump messages to bottom when the current user sent a message const [ selfMemberResult ] = useSelfMemberSubscription(guild); - const [ membersRetry, membersResult, membersFetchError ] = useMembersSubscription(guild); const [ channelsRetry, channelsResult, channelsFetchError ] = useChannelsSubscription(guild); const [ activeChannel, setActiveChannel ] = useState(null); @@ -88,7 +87,7 @@ const GuildElement: FC = (props: GuildElementProps) => { channels={channelsResult?.value ?? null} channelsFetchError={channelsFetchError} activeChannel={activeChannel} setActiveChannel={setActiveChannel} /> - +
@@ -98,7 +97,7 @@ const GuildElement: FC = (props: GuildElementProps) => { {activeChannel && activeChannelGuild && }
- +