diff --git a/src/client/webapp/elements/components/token-row.tsx b/src/client/webapp/elements/components/token-row.tsx index 8036dc0..3e7ca52 100644 --- a/src/client/webapp/elements/components/token-row.tsx +++ b/src/client/webapp/elements/components/token-row.tsx @@ -5,39 +5,38 @@ import CombinedGuild from '../../guild-combined'; import Util from '../../util'; import { IAddGuildData } from '../overlays/overlay-add-guild'; import BaseElements from '../require/base-elements'; -import { useSoftImageSrcResourceSubscription } from '../require/guild-subscriptions'; +import { SubscriptionResult, useSoftImageSrcResourceSubscription } from '../require/guild-subscriptions'; import { useAsyncVoidCallback, useDownloadButton, useOneTimeAsyncAction } from '../require/react-helper'; import Button, { ButtonColorType } from './button'; export interface TokenRowProps { url: string; guild: CombinedGuild; - guildMeta: GuildMetadata; - guildMetaGuild: CombinedGuild; + guildMetaResult: SubscriptionResult; token: Token; } const TokenRow: FC = (props: TokenRowProps) => { - const { guild, guildMeta, guildMetaGuild, token } = props; + const { guild, guildMetaResult, token } = props; const [ guildSocketConfigs ] = useOneTimeAsyncAction( async () => await guild.fetchSocketConfigs(), null, [ guild ] ); - const [ iconSrc ] = useSoftImageSrcResourceSubscription(guild, guildMeta.iconResourceId, guildMetaGuild); + const [ iconSrc ] = useSoftImageSrcResourceSubscription(guild, guildMetaResult.value.iconResourceId, guildMetaResult.guild); const [ revoke ] = useAsyncVoidCallback(async () => { await guild.requestDoRevokeToken(token.token); }, [ guild, token ]); const [ downloadFunc, downloadText, downloadShaking ] = useDownloadButton( - guildMeta.name + '.cordis', + guildMetaResult.value.name + '.cordis', async () => { if (guildSocketConfigs === null) return null; const guildSocketConfig = Util.randomChoice(guildSocketConfigs); const addGuildData: IAddGuildData = { - name: guildMeta.name, + name: guildMetaResult.value.name, url: guildSocketConfig.url, cert: guildSocketConfig.cert, token: token.token, @@ -47,7 +46,7 @@ const TokenRow: FC = (props: TokenRowProps) => { const json = JSON.stringify(addGuildData); return Buffer.from(json); }, - [ guildSocketConfigs, guildMeta, token, iconSrc ] + [ guildSocketConfigs, guildMetaResult, token, iconSrc ] ); const userText = (token.member instanceof Member ? 'Used by ' + token.member.displayName : token.member?.id) ?? 'Unused 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 79c5db3..8a63b21 100644 --- a/src/client/webapp/elements/contexts/context-menu-connection-info.tsx +++ b/src/client/webapp/elements/contexts/context-menu-connection-info.tsx @@ -2,25 +2,25 @@ import React, { Dispatch, FC, ReactNode, RefObject, SetStateAction, useCallback, import { Member } from '../../data-types'; import CombinedGuild from '../../guild-combined'; import PersonalizeOverlay from '../overlays/overlay-personalize'; +import { SubscriptionResult } from '../require/guild-subscriptions'; import ContextMenu from './components/context-menu'; export interface ConnectionInfoContextMenuProps { guild: CombinedGuild; - selfMember: Member; - selfMemberGuild: CombinedGuild; + selfMemberResult: SubscriptionResult; relativeToRef: RefObject; close: () => void; setOverlay: Dispatch>; } const ConnectionInfoContextMenu: FC = (props: ConnectionInfoContextMenuProps) => { - const { guild, selfMember, selfMemberGuild, relativeToRef, close, setOverlay } = props; + const { guild, selfMemberResult, relativeToRef, close, setOverlay } = props; const setSelfStatus = useCallback(async (status: string) => { - if (selfMember.status !== status) { + if (selfMemberResult.value.status !== status) { await guild.requestSetStatus(status); } - }, [ guild, selfMember ]); + }, [ guild, selfMemberResult ]); const statusElements = useMemo(() => { return [ 'online', 'away', 'busy', 'invisible' ].map(status => { @@ -36,8 +36,8 @@ const ConnectionInfoContextMenu: FC = (props: Co const openPersonalize = useCallback(() => { close(); - setOverlay( setOverlay(null)} />); - }, [ guild, selfMember, selfMemberGuild, close ]); + setOverlay( setOverlay(null)} />); + }, [ guild, selfMemberResult, 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 95db4ab..32d45ea 100644 --- a/src/client/webapp/elements/contexts/context-menu-guild-title.tsx +++ b/src/client/webapp/elements/contexts/context-menu-guild-title.tsx @@ -4,25 +4,25 @@ import CombinedGuild from '../../guild-combined'; import ChannelOverlay from '../overlays/overlay-channel'; import GuildSettingsOverlay from '../overlays/overlay-guild-settings'; import BaseElements from '../require/base-elements'; +import { SubscriptionResult } from '../require/guild-subscriptions'; import ContextMenu from './components/context-menu'; export interface GuildTitleContextMenuProps { close: () => void; relativeToRef: RefObject; guild: CombinedGuild; - guildMeta: GuildMetadata; - guildMetaGuild: CombinedGuild; + guildMetaResult: SubscriptionResult; selfMember: Member; setOverlay: Dispatch>; } const GuildTitleContextMenu: FC = (props: GuildTitleContextMenuProps) => { - const { close, relativeToRef, guild, guildMeta, guildMetaGuild, selfMember, setOverlay } = props; + const { close, relativeToRef, guild, guildMetaResult, selfMember, setOverlay } = props; const openGuildSettings = useCallback(() => { close(); - setOverlay( setOverlay(null)} />); - }, [ guild, guildMeta, guildMetaGuild, close ]); + setOverlay( setOverlay(null)} />); + }, [ guild, guildMetaResult, close ]); const openCreateChannel = useCallback(() => { close(); @@ -56,7 +56,7 @@ const GuildTitleContextMenu: FC = (props: GuildTitle }, []); return ( - +
{guildSettingsElement} {createChannelElement} @@ -66,3 +66,4 @@ const GuildTitleContextMenu: FC = (props: GuildTitle } export default GuildTitleContextMenu; + diff --git a/src/client/webapp/elements/displays/display-guild-invites.tsx b/src/client/webapp/elements/displays/display-guild-invites.tsx index e6751a4..8cf8151 100644 --- a/src/client/webapp/elements/displays/display-guild-invites.tsx +++ b/src/client/webapp/elements/displays/display-guild-invites.tsx @@ -13,26 +13,25 @@ import { Duration } from 'moment'; import moment from 'moment'; import DropdownInput from '../components/input-dropdown'; import Button from '../components/button'; -import { useTokensSubscription, useSoftImageSrcResourceSubscription } from '../require/guild-subscriptions'; +import { useTokensSubscription, useSoftImageSrcResourceSubscription, SubscriptionResult } from '../require/guild-subscriptions'; import TokenRow from '../components/token-row'; export interface GuildInvitesDisplayProps { guild: CombinedGuild; - guildMeta: GuildMetadata; - guildMetaGuild: CombinedGuild; + guildMetaResult: SubscriptionResult; } const GuildInvitesDisplay: FC = (props: GuildInvitesDisplayProps) => { - const { guild, guildMeta, guildMetaGuild } = props; + const { guild, guildMetaResult } = props; const url = 'https://localhost:3030'; // TODO: this will likely be a dropdown list at some point - const [ fetchRetryCallable, tokens, tokensGuild, tokensError ] = useTokensSubscription(guild); + const [ _fetchRetryCallable, tokensResult, tokensError ] = useTokensSubscription(guild); const [ expiresFromNow, setExpiresFromNow ] = useState(moment.duration(1, 'day')); const [ expiresFromNowText, setExpiresFromNowText ] = useState('1 day'); - const [ iconSrc ] = useSoftImageSrcResourceSubscription(guild, guildMeta?.iconResourceId ?? null, guildMetaGuild); + const [ iconSrc ] = useSoftImageSrcResourceSubscription(guild, guildMetaResult.value.iconResourceId ?? null, guildMetaResult.guild); useEffect(() => { if (expiresFromNowText === 'never') { @@ -68,11 +67,11 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi // TODO: Try Again return
Unable to load tokens
; } - if (!guildMeta) { + if (!guildMetaResult) { return
No Guild Metadata
; } - return tokens?.map((token: Token) => ); - }, [ url, guild, tokens, tokensError ]); + return tokensResult?.value?.map((token: Token) => ); + }, [ url, guild, tokensResult, tokensError ]); return ( = (props: GuildInvitesDi
diff --git a/src/client/webapp/elements/displays/display-guild-overview.tsx b/src/client/webapp/elements/displays/display-guild-overview.tsx index cf62d08..def1148 100644 --- a/src/client/webapp/elements/displays/display-guild-overview.tsx +++ b/src/client/webapp/elements/displays/display-guild-overview.tsx @@ -10,19 +10,18 @@ import CombinedGuild from '../../guild-combined'; 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 { SubscriptionResult, useResourceSubscription } from '../require/guild-subscriptions'; import { GuildMetadata } from '../../data-types'; export interface GuildOverviewDisplayProps { guild: CombinedGuild; - guildMeta: GuildMetadata; - guildMetaGuild: CombinedGuild; + guildMetaResult: SubscriptionResult; } const GuildOverviewDisplay: FC = (props: GuildOverviewDisplayProps) => { - const { guild, guildMeta, guildMetaGuild } = props; + const { guild, guildMetaResult } = props; // TODO: Use the one from guild.tsx (for both of these?) - const [ iconResource, iconResourceGuild, iconResourceError ] = useResourceSubscription(guild, guildMeta?.iconResourceId ?? null, guildMetaGuild); + const [ iconResourceResult, iconResourceError ] = useResourceSubscription(guild, guildMetaResult.value.iconResourceId, guildMetaResult.guild); const [ savedName, setSavedName ] = useState(null); const [ savedIconBuff, setSavedIconBuff ] = useState(null); @@ -40,18 +39,18 @@ const GuildOverviewDisplay: FC = (props: GuildOvervie const [ iconInputMessage, setIconInputMessage ] = useState(null); useEffect(() => { - if (guildMeta) { - if (name === savedName) setName(guildMeta.name); - setSavedName(guildMeta.name); + if (guildMetaResult) { + if (name === savedName) setName(guildMetaResult.value.name); + setSavedName(guildMetaResult.value.name); } - }, [ guildMeta ]); + }, [ guildMetaResult ]); useEffect(() => { - if (iconResource) { - if (iconBuff === savedIconBuff) setIconBuff(iconResource.data); - setSavedIconBuff(iconResource.data); + if (iconResourceResult && iconResourceResult.value) { + if (iconBuff === savedIconBuff) setIconBuff(iconResourceResult.value.data); + setSavedIconBuff(iconResourceResult.value.data); } - }, [ iconResource ]); + }, [ iconResourceResult ]); const changes = useMemo(() => { return name !== savedName || iconBuff?.toString('hex') !== savedIconBuff?.toString('hex') @@ -143,3 +142,4 @@ const GuildOverviewDisplay: FC = (props: GuildOvervie } export default GuildOverviewDisplay; + 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 113e1d0..0dd335b 100644 --- a/src/client/webapp/elements/lists/components/guild-list-element.tsx +++ b/src/client/webapp/elements/lists/components/guild-list-element.tsx @@ -22,29 +22,29 @@ const GuildListElement: FC = (props: GuildListElementProp // TODO: state higher up // TODO: handle metadata error - const [ guildMeta, guildMetaGuild, guildMetaError ] = useGuildMetadataSubscription(guild); - const [ selfMember ] = useSelfMemberSubscription(guild); - const [ iconSrc ] = useSoftImageSrcResourceSubscription(guild, guildMeta?.iconResourceId ?? null, guildMetaGuild); + const [ guildMetaResult, guildMetaError ] = useGuildMetadataSubscription(guild); + const [ selfMemberResult ] = useSelfMemberSubscription(guild); + const [ iconSrc ] = useSoftImageSrcResourceSubscription(guild, guildMetaResult?.value.iconResourceId ?? null, guildMetaResult?.guild ?? null); const [ contextHover, mouseEnterCallable, mouseLeaveCallable ] = useContextHover(() => { - if (!guildMeta) return null; - if (!selfMember) return null; - const nameStyle = selfMember.roleColor ? { color: selfMember.roleColor } : {}; + if (!guildMetaResult) return null; + if (!selfMemberResult || !selfMemberResult.value) return null; + const nameStyle = selfMemberResult.value.roleColor ? { color: selfMemberResult.value.roleColor } : {}; return ( - +
{BaseElements.TAB_LEFT}
-
{guildMeta.name}
-
+
{guildMetaResult.value.name}
+
-
{selfMember.displayName}
+
{selfMemberResult.value.displayName}
) - }, [ guildMeta, selfMember ]); + }, [ guildMetaResult, selfMemberResult ]); const leaveGuildCallable = useCallback(async () => { guild.disconnect(); @@ -85,3 +85,4 @@ const GuildListElement: FC = (props: GuildListElementProp } export default GuildListElement; + diff --git a/src/client/webapp/elements/lists/components/member-element.tsx b/src/client/webapp/elements/lists/components/member-element.tsx index 216f1c9..839ea40 100644 --- a/src/client/webapp/elements/lists/components/member-element.tsx +++ b/src/client/webapp/elements/lists/components/member-element.tsx @@ -1,9 +1,4 @@ -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, useMemo, useEffect } from 'react'; +import React, { FC, useMemo } from 'react'; import { Member } from '../../../data-types'; import CombinedGuild from '../../../guild-combined'; import { useSoftImageSrcResourceSubscription } from '../../require/guild-subscriptions'; @@ -16,6 +11,7 @@ export interface DummyMember { avatarResourceId: null; } +// Note: Using non-SubscriptionResult since we accept DummyMembers export interface MemberProps { guild: CombinedGuild; member: Member | DummyMember; diff --git a/src/client/webapp/elements/lists/components/message-element.tsx b/src/client/webapp/elements/lists/components/message-element.tsx index f95491b..0f7be58 100644 --- a/src/client/webapp/elements/lists/components/message-element.tsx +++ b/src/client/webapp/elements/lists/components/message-element.tsx @@ -54,17 +54,17 @@ const PreviewImageElement: FC = (props: PreviewImageEl const { guild, previewWidth, previewHeight, resourcePreviewId, resourceId, resourceName, resourceIdGuild, setOverlay } = props; // TODO: Handle resourceError - const [ previewImgSrc, previewResource, previewResourceGuild, previewResourceError ] = useSoftImageSrcResourceSubscription(guild, resourcePreviewId, resourceIdGuild); + const [ previewImgSrc, previewResourceResult, previewResourceError ] = useSoftImageSrcResourceSubscription(guild, resourcePreviewId, resourceIdGuild); const [ contextMenu, onContextMenu ] = useContextClickContextMenu((alignment: IAlignment, relativeToPos: { x: number, y: number }, close: () => void) => { - if (!previewResource) return null; + if (!previewResourceResult || !previewResourceResult.value) return null; return ( ); - }, [ previewResource, resourceName ]); + }, [ previewResourceResult, resourceName ]); const openImageOverlay = useCallback(() => { setOverlay( setOverlay(null)} />); diff --git a/src/client/webapp/elements/lists/member-list.tsx b/src/client/webapp/elements/lists/member-list.tsx index 580460a..9eec3a2 100644 --- a/src/client/webapp/elements/lists/member-list.tsx +++ b/src/client/webapp/elements/lists/member-list.tsx @@ -1,28 +1,34 @@ +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, 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'; export interface MemberListProps { guild: CombinedGuild; - members: Member[] | null; - membersGuild: CombinedGuild | null; + membersResult: SubscriptionResult | null; membersFetchError: unknown | null; } const MemberList: FC = (props: MemberListProps) => { - const { guild, members, membersGuild, membersFetchError } = props; + const { guild, membersResult, membersFetchError } = props; const memberElements = useMemo(() => { if (membersFetchError) { // TODO: Try Again return
Unable to load members
} - if (!members || !membersGuild) { + if (!isNonNullAndHasValue(membersResult)) { return
Loading members...
} - return members?.map((member: Member) => ); - }, [ guild, members, membersGuild, membersFetchError ]); + LOG.debug(`drawing ${membersResult.value.length} members`); + return membersResult.value.map((member: Member) => ); + }, [ guild, membersResult, membersFetchError ]); return (
@@ -32,3 +38,4 @@ const MemberList: FC = (props: MemberListProps) => { }; export default MemberList; + diff --git a/src/client/webapp/elements/lists/message-list.tsx b/src/client/webapp/elements/lists/message-list.tsx index 26ca13b..bb6d02c 100644 --- a/src/client/webapp/elements/lists/message-list.tsx +++ b/src/client/webapp/elements/lists/message-list.tsx @@ -1,8 +1,3 @@ -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, { Dispatch, FC, ReactNode, SetStateAction, useMemo } from 'react'; import { Channel, Message } from '../../data-types'; import CombinedGuild from '../../guild-combined'; @@ -26,8 +21,7 @@ const MessageList: FC = (props: MessageListProps) => { fetchBelowCallable, setScrollRatio, fetchResult, - messages, - messagesGuild, + messagesResult, messagesFetchError, messagesFetchAboveError, messagesFetchBelowError @@ -35,15 +29,15 @@ const MessageList: FC = (props: MessageListProps) => { const messageElements = useMemo(() => { const result = []; - if (messages && messagesGuild) { - for (let i = 0; i < messages.length; ++i) { - const prevMessage = messages[i - 1] ?? null; - const message = messages[i] as Message; - result.push(); + if (messagesResult && messagesResult.value) { + for (let i = 0; i < messagesResult.value.length; ++i) { + const prevMessage = messagesResult.value[i - 1] ?? null; + const message = messagesResult.value[i] as Message; + result.push(); } } return result; - }, [ messages, messagesGuild ]); + }, [ messagesResult ]); return (
@@ -66,3 +60,4 @@ const MessageList: FC = (props: MessageListProps) => { } export default MessageList; + diff --git a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx index 9f58219..6d98848 100644 --- a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx +++ b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx @@ -1,8 +1,3 @@ -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, useEffect, useRef, useState } from "react"; import CombinedGuild from "../../guild-combined"; import ChoicesControl from "../components/control-choices"; @@ -10,15 +5,15 @@ import GuildInvitesDisplay from "../displays/display-guild-invites"; import GuildOverviewDisplay from "../displays/display-guild-overview"; import { GuildMetadata } from '../../data-types'; import Overlay from '../components/overlay'; +import { SubscriptionResult } from '../require/guild-subscriptions'; export interface GuildSettingsOverlayProps { guild: CombinedGuild; - guildMeta: GuildMetadata; - guildMetaGuild: CombinedGuild; + guildMetaResult: SubscriptionResult; close: () => void; } const GuildSettingsOverlay: FC = (props: GuildSettingsOverlayProps) => { - const { guild, guildMeta, guildMetaGuild, close } = props; + const { guild, guildMetaResult, close } = props; const rootRef = useRef(null); @@ -26,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(); + if (selectedId === 'invites' ) setDisplay(); }, [ selectedId ]); return (
- = (props: GuildSetting } export default GuildSettingsOverlay; + diff --git a/src/client/webapp/elements/overlays/overlay-image.tsx b/src/client/webapp/elements/overlays/overlay-image.tsx index 05700c6..f44923a 100644 --- a/src/client/webapp/elements/overlays/overlay-image.tsx +++ b/src/client/webapp/elements/overlays/overlay-image.tsx @@ -25,19 +25,22 @@ const ImageOverlay: FC = (props: ImageOverlayProps) => { const rootRef = useRef(null); - const [ imgSrc, resource, resourceGuild, resourceError ] = useSoftImageSrcResourceSubscription(guild, resourceId, resourceIdGuild); + const [ imgSrc, resourceResult, resourceError ] = useSoftImageSrcResourceSubscription(guild, resourceId, resourceIdGuild); const [ contextMenu, onContextMenu ] = useContextClickContextMenu((alignment: IAlignment, relativeToPos: { x: number, y: number }, close: () => void) => { - if (!resource) return null; + if (!resourceResult || !resourceResult.value) return null; return ( ); - }, [ resource, resourceName ]); + }, [ resourceResult, resourceName ]); - const sizeText = useMemo(() => resource ? ElementsUtil.humanSize(resource.data.length) : 'Loading Size...', [ resource ]); + const sizeText = useMemo( + () => resourceResult?.value ? ElementsUtil.humanSize(resourceResult.value.data.length) : 'Loading Size...', + [ resourceResult ] + ); return ( @@ -48,8 +51,9 @@ const ImageOverlay: FC = (props: ImageOverlayProps) => {
{resourceName}
{sizeText}
+ {/* TODO: I think this may break if the download button gets clicked before the resource is loaded... */}
{contextMenu} diff --git a/src/client/webapp/elements/overlays/overlay-personalize.tsx b/src/client/webapp/elements/overlays/overlay-personalize.tsx index 0d97c1e..9ac6653 100644 --- a/src/client/webapp/elements/overlays/overlay-personalize.tsx +++ b/src/client/webapp/elements/overlays/overlay-personalize.tsx @@ -13,27 +13,26 @@ import SubmitOverlayLower from '../components/submit-overlay-lower'; import { useAsyncSubmitButton } from '../require/react-helper'; import Button from '../components/button'; import Overlay from '../components/overlay'; -import { useResourceSubscription } from '../require/guild-subscriptions'; +import { SubscriptionResult, useResourceSubscription } from '../require/guild-subscriptions'; export interface PersonalizeOverlayProps { guild: CombinedGuild; - selfMember: Member; - selfMemberGuild: CombinedGuild; + selfMemberResult: SubscriptionResult; close: () => void; } const PersonalizeOverlay: FC = (props: PersonalizeOverlayProps) => { - const { guild, selfMember, selfMemberGuild, close } = props; + const { guild, selfMemberResult, close } = props; const rootRef = useRef(null); - const [ avatarResource, avatarResourceGuild, avatarResourceError ] = useResourceSubscription(guild, selfMember.avatarResourceId, selfMemberGuild); + const [ avatarResourceResult, avatarResourceError ] = useResourceSubscription(guild, selfMemberResult.value.avatarResourceId, selfMemberResult.guild); const displayNameInputRef = createRef(); - const [ savedDisplayName, setSavedDisplayName ] = useState(selfMember.displayName); + const [ savedDisplayName, setSavedDisplayName ] = useState(selfMemberResult.value.displayName); const [ savedAvatarBuff, setSavedAvatarBuff ] = useState(null); - const [ displayName, setDisplayName ] = useState(selfMember.displayName); + const [ displayName, setDisplayName ] = useState(selfMemberResult.value.displayName); const [ avatarBuff, setAvatarBuff ] = useState(null); const [ displayNameInputValid, setDisplayNameInputValid ] = useState(false); @@ -43,11 +42,11 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl const [ avatarInputMessage, setAvatarInputMessage ] = useState(null); useEffect(() => { - if (avatarResource) { - if (avatarBuff === savedAvatarBuff) setAvatarBuff(avatarResource.data); - setSavedAvatarBuff(avatarResource.data); + if (avatarResourceResult && avatarResourceResult.value) { + if (avatarBuff === savedAvatarBuff) setAvatarBuff(avatarResourceResult.value.data); + setSavedAvatarBuff(avatarResourceResult.value.data); } - }, [ avatarResource ]); + }, [ avatarResourceResult ]); useEffect(() => { displayNameInputRef.current?.focus(); diff --git a/src/client/webapp/elements/require/guild-subscriptions.ts b/src/client/webapp/elements/require/guild-subscriptions.ts index c86b59c..fa6675f 100644 --- a/src/client/webapp/elements/require/guild-subscriptions.ts +++ b/src/client/webapp/elements/require/guild-subscriptions.ts @@ -9,13 +9,22 @@ import CombinedGuild from "../../guild-combined"; import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AutoVerifierChangesType } from "../../auto-verifier"; import { Conflictable, Connectable } from "../../guild-types"; -import { EventEmitter } from 'tsee'; import { IDQuery, PartialMessageListQuery } from '../../auto-verifier-with-args'; import { Token, Channel } from '../../data-types'; import { useIsMountedRef, useOneTimeAsyncAction } from './react-helper'; import Globals from '../../globals'; import ElementsUtil from './elements-util'; +// Abuse closures to get state in case it changed after an await call +function getStateAfterAwait(setState: Dispatch>): T { + let x: unknown; + setState(state => { + x = state; + return state; + }); + return x as T; +} + export type SingleSubscriptionEvents = { 'fetch': () => void; 'updated': () => void; @@ -34,7 +43,10 @@ export type MultipleSubscriptionEvents = { interface EffectParams { guild: CombinedGuild; - onFetch: (value: T | null, valueGuild: CombinedGuild) => void; + // TODO: I changed this from value: T | null to just value: T. I think + // this file (and potentially some others) has a bunch of spots that use .value?.xxx + // where it doesn't need to. Maybe there is an ESLint thing for this? + onFetch: (value: T, valueGuild: CombinedGuild) => void; onFetchError: (e: unknown) => void; bindEventsFunc: () => void; unbindEventsFunc: () => void; @@ -42,6 +54,15 @@ interface EffectParams { type Arguments = T extends (...args: infer A) => unknown ? A : never; +export interface SubscriptionResult { + value: T; + guild: CombinedGuild; +} + +export function isNonNullAndHasValue(subscriptionResult: SubscriptionResult | null): subscriptionResult is SubscriptionResult { + return !!(subscriptionResult !== null && subscriptionResult.value !== null); +} + interface SingleEventMappingParams { updatedEventName: UE; updatedEventArgsMap: (...args: Arguments) => T; @@ -69,9 +90,16 @@ interface MultipleEventMappingParams< sortFunc: (a: T, b: T) => number; // Friendly reminder that v8 uses timsort so this is O(n) for pre-sorted stuff } + +/** + * Core function to subscribe to a general fetchable guild function + * @param subscriptionParams Event callback functions + * @param fetchFunc Function that can be called to fetch the data for the subscription. This function will be called automatically if it is changed. + * Typically, this function will be set up in a useCallback with a dependency on at least the guild. + */ function useGuildSubscriptionEffect( subscriptionParams: EffectParams, - fetchFunc: (() => Promise) | (() => Promise) + fetchFunc: () => Promise ): [ fetchRetryCallable: () => Promise ] { const { guild, onFetch, onFetchError, bindEventsFunc, unbindEventsFunc } = subscriptionParams; @@ -85,7 +113,7 @@ function useGuildSubscriptionEffect( try { const value = await fetchFunc(); if (!isMounted.current) return; - if (guildRef.current !== guild) return; // Don't call onFetch if we changed guilds. TODO: Test this + if (guildRef.current !== guild) return; // Don't even call onFetch if we changed guilds. TODO: Test this onFetch(value, guild); } catch (e: unknown) { LOG.error('error fetching for subscription', e); @@ -115,66 +143,51 @@ function useGuildSubscriptionEffect( return [ fetchManagerFunc ]; } +/** + * Subscribe to a fetchable guild function that returns a single element (i.e. GuildMetadata) + * @param guild The guild to listen for changes on + * @param eventMappingParams The events to use to listen for changes (such as updates and conflicts) + * @param fetchFunc The function to call to fetch initial data + */ function useSingleGuildSubscription( guild: CombinedGuild, eventMappingParams: SingleEventMappingParams, - fetchFunc: (() => Promise) | (() => Promise) -): [value: T | null, valueGuild: CombinedGuild | null, fetchError: unknown | null, events: EventEmitter] { + fetchFunc: () => Promise +): [lastResult: SubscriptionResult | null, fetchError: unknown | null] { const { updatedEventName, updatedEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams; const isMounted = useIsMountedRef(); - const guildRef = useRef(guild); - guildRef.current = guild; const [ fetchError, setFetchError ] = useState(null); - const [ value, setValue ] = useState(null); - const [ valueGuild, setValueGuild ] = useState(null); - - const events = useMemo(() => new EventEmitter(), []); + const [ lastResult, setLastResult ] = useState<{ value: T, guild: CombinedGuild } | null>(null); const onFetch = useCallback((fetchValue: T | null, fetchValueGuild: CombinedGuild) => { - setValue(fetchValue); - setValueGuild(fetchValueGuild); + setLastResult(fetchValue ? { value: fetchValue, guild: fetchValueGuild } : null); setFetchError(null); - events.emit('fetch'); }, []); const onFetchError = useCallback((e: unknown) => { setFetchError(e); - setValue(null); - setValueGuild(null); - events.emit('fetch-error'); - }, []); - - const onUpdated = useCallback((updateValue: T, updateValueGuild: CombinedGuild) => { - setValue(updateValue); - if (updateValueGuild !== guildRef.current) { - LOG.warn(`update guild (${updateValueGuild.id}) != current guild (${guildRef.current})`); - } - events.emit('updated'); - }, []); - - const onConflict = useCallback((conflictValue: T, conflictValueGuild: CombinedGuild) => { - setValue(conflictValue); - if (conflictValueGuild !== guildRef.current) { - LOG.warn(`conflict guild (${conflictValueGuild.id}) != current guild (${guildRef.current})`); - } - events.emit('conflict'); + setLastResult(null) }, []); // 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 const boundUpdateFunc = useCallback((...args: Arguments): void => { if (!isMounted.current) return; - if (guildRef.current !== guild) return; const value = updatedEventArgsMap(...args); - onUpdated(value, guild); + setLastResult(lastResult => { + if (guild !== lastResult?.guild) return lastResult; + return { value: value, guild: guild }; + }); }, [ guild ]) as (Connectable & Conflictable)[UE]; const boundConflictFunc = useCallback((...args: Arguments): void => { if (!isMounted.current) return; - if (guildRef.current !== guild) return; const value = conflictEventArgsMap(...args); - onConflict(value, guild); + setLastResult(lastResult => { + if (guild !== lastResult?.guild) return lastResult; + return { value: value, guild: guild }; + }); }, [ guild ]) as (Connectable & Conflictable)[CE]; const bindEventsFunc = useCallback(() => { @@ -194,7 +207,7 @@ function useSingleGuildSubscription( guild: CombinedGuild, eventMappingParams: MultipleEventMappingParams, - fetchFunc: (() => Promise) | (() => Promise) + fetchFunc: () => Promise ): [ fetchRetryCallable: () => Promise, - value: T[] | null, - valueGuild: CombinedGuild | null, + lastResult: SubscriptionResult | null, fetchError: unknown | null, - events: EventEmitter> ] { const { newEventName, newEventArgsMap, @@ -223,101 +234,73 @@ function useMultipleGuildSubscription< } = eventMappingParams; const isMounted = useIsMountedRef(); - const guildRef = useRef(guild); - guildRef.current = guild; const [ fetchError, setFetchError ] = useState(null); - const [ value, setValue ] = useState(null); - const [ valueGuild, setValueGuild ] = useState(null); + const [ lastResult, setLastResult ] = useState<{ value: T[], guild: CombinedGuild } | null>(null); - const events = useMemo(() => new EventEmitter>(), []); - - const onFetch = useCallback((fetchValue: T[] | null, fetchValueGuild: CombinedGuild) => { + const onFetch = useCallback((fetchValue: T[], fetchValueGuild: CombinedGuild) => { if (fetchValue) fetchValue.sort(sortFunc); /* LOG.debug('onFetch', { valueType: (fetchValue?.length && (fetchValue[0] as T).constructor.name) ?? 'null', guild: fetchValueGuild.id }); */ - setValue(fetchValue); - setValueGuild(fetchValueGuild); + setLastResult({ value: fetchValue, guild: fetchValueGuild }); setFetchError(null); - events.emit('fetch'); }, [ sortFunc ]); const onFetchError = useCallback((e: unknown) => { setFetchError(e); - setValue(null); - events.emit('fetch-error'); + setLastResult(null) }, []); - const onNew = useCallback((newElements: T[], newElementsGuild: CombinedGuild) => { - setValue(currentValue => { - if (currentValue === null) return Array.from(newElements).sort(sortFunc); - return currentValue.concat(newElements).sort(sortFunc); - }); - if (newElementsGuild !== guildRef.current) { - LOG.warn(`new elements guild (${newElementsGuild.id}) != current guild (${guildRef.current})`); - } - events.emit('new', newElements); - }, [ sortFunc ]); - const onUpdated = useCallback((updatedElements: T[], updatedElementsGuild: CombinedGuild) => { - setValue(currentValue => { - if (currentValue === null) return null; - return currentValue.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc); - }); - if (updatedElementsGuild !== guildRef.current) { - LOG.warn(`updated elements guild (${updatedElementsGuild.id}) != current guild (${guildRef.current})`); - } - events.emit('updated', updatedElements); - }, [ sortFunc ]); - const onRemoved = useCallback((removedElements: T[], removedElementsGuild: CombinedGuild) => { - setValue(currentValue => { - if (currentValue === null) return null; - const deletedIds = new Set(removedElements.map(deletedElement => deletedElement.id)); - return currentValue.filter(element => !deletedIds.has(element.id)).sort(sortFunc); - }); - if (removedElementsGuild !== guildRef.current) { - LOG.warn(`removed elements guild (${removedElementsGuild.id}) != current guild (${guildRef.current})`); - } - events.emit('removed', removedElements); - }, [ sortFunc ]); - - const onConflict = useCallback((changes: Changes, changesGuild: CombinedGuild) => { - setValue(currentValue => { - if (currentValue === null) return null; - const deletedIds = new Set(changes.deleted.map(deletedElement => deletedElement.id)); - return currentValue - .concat(changes.added) - .map(element => changes.updated.find(change => change.newDataPoint.id === element.id)?.newDataPoint ?? element) - .filter(element => !deletedIds.has(element.id)) - .sort(sortFunc); - }); - if (changesGuild !== guildRef.current) { - LOG.warn(`conflict changes guild (${changesGuild.id}) != current guild (${guildRef.current})`); - } - setValueGuild(changesGuild); - events.emit('conflict', changes); - }, [ sortFunc ]); - // 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 const boundNewFunc = useCallback((...args: Arguments): void => { if (!isMounted.current) return; - if (guildRef.current !== guild) return; // prevent changes from a different guild - onNew(newEventArgsMap(...args), guild); - }, [ guild, onNew, newEventArgsMap ]) as (Connectable & Conflictable)[NE]; + const newElements = newEventArgsMap(...args); + setLastResult((lastResult) => { + // TODO: There's a bug in this and other functions like it in the file where if you switch + // from one guild and then back again, there is potential for the new/add to be triggered twice + // if the add result happens slowly. This could be mitigated by adding some sort of "selection id" + // each time the guild changes. For our purposes so far, this "bug" should be OK to leave in. + // In the incredibly rare case where this does happen, you will see things duplicated + if (guild !== lastResult?.guild) return lastResult; // prevent changes from a different guild + if (!lastResult) { LOG.warn('got onNew with no lastResult'); return null; } // Sanity check + return { value: (lastResult.value ?? []).concat(newElements).sort(sortFunc), guild: guild }; + }); + }, [ guild, newEventArgsMap ]) as (Connectable & Conflictable)[NE]; const boundUpdateFunc = useCallback((...args: Arguments): void => { if (!isMounted.current) return; - if (guildRef.current !== guild) return; - onUpdated(updatedEventArgsMap(...args), guild); - }, [ guild, onUpdated, updatedEventArgsMap ]) as (Connectable & Conflictable)[UE]; + if (guild !== lastResult?.guild) return; + const updatedElements = updatedEventArgsMap(...args); + setLastResult((lastResult) => { + if (!lastResult) { LOG.warn('got onUpdated with no lastResult'); return null; } // Sanity check + return { value: (lastResult.value ?? []).map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc), guild: guild }; + }); + }, [ guild, updatedEventArgsMap ]) as (Connectable & Conflictable)[UE]; const boundRemovedFunc = useCallback((...args: Arguments): void => { if (!isMounted.current) return; - if (guildRef.current !== guild) return; - onRemoved(removedEventArgsMap(...args), guild); - }, [ guild, onRemoved, removedEventArgsMap ]) as (Connectable & Conflictable)[RE]; + if (guild !== lastResult?.guild) return; + const removedElements = removedEventArgsMap(...args); + setLastResult((lastResult) => { + if (!lastResult) { LOG.warn('got onRemoved with no lastResult'); return null; } // Sanity check + const deletedIds = new Set(removedElements.map(deletedElement => deletedElement.id)); + return { value: (lastResult.value ?? []).filter(element => !deletedIds.has(element.id)), guild: guild }; + }); + }, [ guild, removedEventArgsMap ]) as (Connectable & Conflictable)[RE]; const boundConflictFunc = useCallback((...args: Arguments): void => { if (!isMounted.current) return; - if (guildRef.current !== guild) return; - onConflict(conflictEventArgsMap(...args), guild); - }, [ guild, onConflict, conflictEventArgsMap ]) as (Connectable & Conflictable)[CE]; + const changes = conflictEventArgsMap(...args); + setLastResult((lastResult) => { + if (!lastResult) { LOG.warn('got onConflict with no lastResult'); return null; } // Sanity check + const deletedIds = new Set(changes.deleted.map(deletedElement => deletedElement.id)); + return { + value: (lastResult.value ?? []) + .concat(changes.added) // Added + .map(element => changes.updated.find(change => change.newDataPoint.id === element.id)?.newDataPoint ?? element) // Updated + .filter(element => !deletedIds.has(element.id)) // Deleted + .sort(sortFunc), + guild: guild + }; + }); + }, [ guild, conflictEventArgsMap ]) as (Connectable & Conflictable)[CE]; const bindEventsFunc = useCallback(() => { guild.on(newEventName, boundNewFunc); @@ -340,7 +323,7 @@ function useMultipleGuildSubscription< unbindEventsFunc }, fetchFunc); - return [ fetchRetryCallable, value, valueGuild, fetchError, events ]; + return [ fetchRetryCallable, lastResult, fetchError ]; } function useMultipleGuildSubscriptionScrolling< @@ -354,7 +337,7 @@ function useMultipleGuildSubscriptionScrolling< eventMappingParams: MultipleEventMappingParams, maxElements: number, maxFetchElements: number, - fetchFunc: (() => Promise) | (() => Promise), + fetchFunc: () => Promise, fetchAboveFunc: ((reference: T) => Promise), fetchBelowFunc: ((reference: T) => Promise), ): [ @@ -363,12 +346,10 @@ function useMultipleGuildSubscriptionScrolling< fetchBelowCallable: () => Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }>, setScrollRatio: Dispatch>, fetchResult: { hasMoreAbove: boolean, hasMoreBelow: boolean } | null, - value: T[] | null, - valueGuild: CombinedGuild | null, + lastResult: SubscriptionResult | null, fetchError: unknown | null, fetchAboveError: unknown | null, fetchBelowError: unknown | null, - events: EventEmitter> ] { const { newEventName, newEventArgsMap, @@ -379,11 +360,9 @@ function useMultipleGuildSubscriptionScrolling< } = eventMappingParams; const isMounted = useIsMountedRef(); - const guildRef = useRef(guild); - guildRef.current = guild; - const [ value, setValue ] = useState(null); - const [ valueGuild, setValueGuild ] = useState(null); + // TODO: lastResult.value should really be only T[] instead of | null since we set it to [] anyway in the onUpdate, etc functions + const [ lastResult, setLastResult ] = useState<{ value: T[], guild: CombinedGuild } | null>(null); const [ fetchError, setFetchError ] = useState(null); const [ fetchAboveError, setFetchAboveError ] = useState(null); @@ -413,28 +392,29 @@ function useMultipleGuildSubscriptionScrolling< return elements.slice(fromTop, elements.length - fromBottom); } - const events = useMemo(() => new EventEmitter>(), []); - const fetchAboveCallable = useCallback(async (): Promise<{ hasMoreAbove: boolean, removedFromBottom: boolean }> => { if (!isMounted.current) return { hasMoreAbove: false, removedFromBottom: false }; - if (guildRef.current !== guild) return { hasMoreAbove: false, removedFromBottom: false }; - if (!value || value.length === 0) return { hasMoreAbove: false, removedFromBottom: false }; + if (!lastResult || !lastResult.value || lastResult.value.length === 0) return { hasMoreAbove: false, removedFromBottom: false }; + if (guild !== lastResult.guild) return { hasMoreAbove: false, removedFromBottom: false }; + try { - const reference = value[0] as T; + const reference = lastResult.value[0] as T; const aboveElements = await fetchAboveFunc(reference); + const lastResultAfterAwait = getStateAfterAwait(setLastResult); if (!isMounted.current) return { hasMoreAbove: false, removedFromBottom: false }; - if (guildRef.current !== guild) return { hasMoreAbove: false, removedFromBottom: false }; + if (!lastResultAfterAwait || guild !== lastResultAfterAwait.guild) return { hasMoreAbove: false, removedFromBottom: false }; setFetchAboveError(null); if (aboveElements) { const hasMoreAbove = aboveElements.length >= maxFetchElements; let removedFromBottom = false; - setValue(currentValue => { - let newValue = aboveElements.concat(currentValue ?? []).sort(sortFunc); + setLastResult((lastResult) => { + if (!lastResult) return null; + let newValue = aboveElements.concat(lastResult.value ?? []).sort(sortFunc); if (newValue.length > maxElements) { newValue = newValue.slice(0, maxElements); removedFromBottom = true; } - return newValue; + return { value: newValue, guild: lastResult.guild }; }); return { hasMoreAbove, removedFromBottom }; } else { @@ -442,48 +422,52 @@ function useMultipleGuildSubscriptionScrolling< } } catch (e: unknown) { LOG.error('error fetching above for subscription', e); + const lastResultAfterAwait = getStateAfterAwait(setLastResult); if (!isMounted.current) return { hasMoreAbove: false, removedFromBottom: false }; - if (guildRef.current !== guild) return { hasMoreAbove: false, removedFromBottom: false }; + if (!lastResultAfterAwait || guild !== lastResultAfterAwait.guild) return { hasMoreAbove: false, removedFromBottom: false }; setFetchAboveError(e); return { hasMoreAbove: true, removedFromBottom: false }; } - }, [ guild, value, fetchAboveFunc, maxFetchElements ]); + }, [ guild, lastResult, fetchAboveFunc, maxFetchElements ]); const fetchBelowCallable = useCallback(async (): Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }> => { if (!isMounted.current) return { hasMoreBelow: false, removedFromTop: false }; - if (guildRef.current !== guild) return { hasMoreBelow: false, removedFromTop: false }; - if (!value || value.length === 0) return { hasMoreBelow: false, removedFromTop: false }; + if (!lastResult || !lastResult.value || lastResult.value.length === 0) return { hasMoreBelow: false, removedFromTop: false }; + if (guild !== lastResult.guild) return { hasMoreBelow: false, removedFromTop: false }; try { - const reference = value[value.length - 1] as T; + const reference = lastResult.value[lastResult.value.length - 1] as T; const belowElements = await fetchBelowFunc(reference); + const lastResultAfterAwait = getStateAfterAwait(setLastResult); if (!isMounted.current) return { hasMoreBelow: false, removedFromTop: false }; - if (guildRef.current !== guild) return { hasMoreBelow: false, removedFromTop: false }; + if (!lastResultAfterAwait || guild !== lastResultAfterAwait.guild) return { hasMoreBelow: false, removedFromTop: false }; setFetchBelowError(null); if (belowElements) { const hasMoreBelow = belowElements.length >= maxFetchElements; let removedFromTop = false; - setValue(currentValue => { - let newValue = (currentValue ?? []).concat(belowElements).sort(sortFunc); + setLastResult((lastResult) => { + if (!lastResult) return null; + let newValue = (lastResult.value ?? []).concat(belowElements).sort(sortFunc); if (newValue.length > maxElements) { newValue = newValue.slice(Math.max(newValue.length - maxElements, 0)); removedFromTop = true; } - return newValue; - }); + return { value: newValue, guild: lastResult.guild }; + }) return { hasMoreBelow, removedFromTop }; } else { return { hasMoreBelow: false, removedFromTop: false }; } } catch (e: unknown) { LOG.error('error fetching below for subscription', e); + const lastResultAfterAwait = getStateAfterAwait(setLastResult); if (!isMounted.current) return { hasMoreBelow: false, removedFromTop: false }; - if (guildRef.current !== guild) return { hasMoreBelow: false, removedFromTop: false }; + if (!lastResultAfterAwait || guild !== lastResultAfterAwait.guild) return { hasMoreBelow: false, removedFromTop: false }; setFetchBelowError(e); return { hasMoreBelow: true, removedFromTop: false }; } - }, [ value, fetchBelowFunc, maxFetchElements ]); + }, [ lastResult, fetchBelowFunc, maxFetchElements ]); - const onFetch = useCallback((fetchValue: T[] | null, fetchValueGuild: CombinedGuild) => { + const onFetch = useCallback((fetchValue: T[], fetchValueGuild: CombinedGuild) => { let hasMoreAbove = false; if (fetchValue) { if (fetchValue.length >= maxFetchElements) hasMoreAbove = true; @@ -491,60 +475,58 @@ function useMultipleGuildSubscriptionScrolling< } //LOG.debug('Got items: ', { fetchValueLength: fetchValue?.length ?? '' }) setFetchResult({ hasMoreAbove, hasMoreBelow: false }); - setValue(fetchValue); - setValueGuild(fetchValueGuild); + setLastResult({ value: fetchValue, guild: fetchValueGuild }); setFetchError(null); - events.emit('fetch'); }, [ sortFunc, maxFetchElements, maxElements ]); const onFetchError = useCallback((e: unknown) => { setFetchError(e); - setValue(null); - setValueGuild(null); - events.emit('fetch-error'); + setLastResult(null); }, []); - const onNew = useCallback((newElements: T[], newElementsGuild: CombinedGuild) => { - setValue(currentValue => { - if (currentValue === null) return null; - let newValue = currentValue.concat(newElements).sort(sortFunc); + // 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 + const boundNewFunc = useCallback((...args: Arguments): void => { + if (!isMounted.current) return; + if (guild !== lastResult?.guild) return; // Cancel calls when the guild changes + const newElements = newEventArgsMap(...args); + setLastResult((lastResult) => { + if (lastResult === null) return null; + let newValue = (lastResult.value ?? []).concat(newElements).sort(sortFunc); if (newValue.length > maxElements) { + // Remove in a way that tries to keep the scrollbar position consistent newValue = removeByCounts(newValue, getRemoveCounts(newValue.length - maxElements)); } - return newValue; + return { value: newValue, guild: guild }; }); - if (newElementsGuild !== guildRef.current) { - LOG.warn(`new elements guild (${newElementsGuild.id}) != current guild (${guildRef.current})`); - } - events.emit('new', newElements); - }, [ sortFunc, getRemoveCounts ]); - const onUpdated = useCallback((updatedElements: T[], updatedElementsGuild: CombinedGuild) => { - setValue(currentValue => { - if (currentValue === null) return null; - return currentValue.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc); + }, [ guild, newEventArgsMap ]) as (Connectable & Conflictable)[NE]; + const boundUpdateFunc = useCallback((...args: Arguments): void => { + if (!isMounted.current) return; + if (guild !== lastResult?.guild) return; // Cancel calls when the guild changes + const updatedElements = updatedEventArgsMap(...args); + setLastResult((lastResult) => { + if (lastResult === null) return null; + return { value: (lastResult.value ?? []).map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc), guild: guild }; }); - if (updatedElementsGuild !== guildRef.current) { - LOG.warn(`updated elements guild (${updatedElementsGuild.id}) != current guild (${guildRef.current})`); - } - events.emit('updated', updatedElements); - }, [ sortFunc ]); - const onRemoved = useCallback((removedElements: T[], removedElementsGuild: CombinedGuild) => { - setValue(currentValue => { - if (currentValue === null) return null; - const deletedIds = new Set(removedElements.map(deletedElement => deletedElement.id)); - return currentValue.filter(element => !deletedIds.has(element.id)).sort(sortFunc); + }, [ guild, updatedEventArgsMap ]) as (Connectable & Conflictable)[UE]; + const boundRemovedFunc = useCallback((...args: Arguments): void => { + if (!isMounted.current) return; + if (guild !== lastResult?.guild) return; // Cancel calls when the guild changes + const removedElements = removedEventArgsMap(...args); + setLastResult((lastResult) => { + if (lastResult === null) return null; + const deletedIds = new Set(removedElements.map(removedElement => removedElement.id)); + return { value: (lastResult.value ?? []).filter(element => !deletedIds.has(element.id)), guild: guild }; }); - if (removedElementsGuild !== guildRef.current) { - LOG.warn(`updated elements guild (${removedElementsGuild.id}) != current guild (${guildRef.current})`); - } - events.emit('removed', removedElements); - }, [ sortFunc ]); - - const onConflict = useCallback((changes: Changes, changesGuild: CombinedGuild) => { - setValue(currentValue => { - if (currentValue === null) return null; + }, [ guild, removedEventArgsMap ]) as (Connectable & Conflictable)[RE]; + const boundConflictFunc = useCallback((...args: Arguments): void => { + if (!isMounted.current) return; + if (guild !== lastResult?.guild) return; // Cancel calls when the guild changes + const changes = conflictEventArgsMap(...args); + setLastResult((lastResult) => { + if (lastResult === null) return null; const deletedIds = new Set(changes.deleted.map(deletedElement => deletedElement.id)); - let newValue = currentValue + let newValue = (lastResult.value ?? []) .concat(changes.added) .map(element => changes.updated.find(change => change.newDataPoint.id === element.id)?.newDataPoint ?? element) .filter(element => !deletedIds.has(element.id)) @@ -552,36 +534,9 @@ function useMultipleGuildSubscriptionScrolling< if (newValue.length > maxElements) { newValue = removeByCounts(newValue, getRemoveCounts(newValue.length - maxElements)); } - return newValue; + return { value: newValue, guild: guild }; }); - if (changesGuild !== guildRef.current) { - LOG.warn(`conflict changes guild (${changesGuild.id}) != current guild (${guildRef.current})`); - } - events.emit('conflict', changes); - }, [ sortFunc, getRemoveCounts ]); - - // 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 - const boundNewFunc = useCallback((...args: Arguments): void => { - if (!isMounted.current) return; - if (guildRef.current !== guild) return; // Cancel calls when the guild changes - onNew(newEventArgsMap(...args), guild); - }, [ guild, onNew, newEventArgsMap ]) as (Connectable & Conflictable)[NE]; - const boundUpdateFunc = useCallback((...args: Arguments): void => { - if (!isMounted.current) return; - if (guildRef.current !== guild) return; // Cancel calls when the guild changes - onUpdated(updatedEventArgsMap(...args), guild); - }, [ onUpdated, updatedEventArgsMap ]) as (Connectable & Conflictable)[UE]; - const boundRemovedFunc = useCallback((...args: Arguments): void => { - if (!isMounted.current) return; - if (guildRef.current !== guild) return; // Cancel calls when the guild changes - onRemoved(removedEventArgsMap(...args), guild); - }, [ onRemoved, removedEventArgsMap ]) as (Connectable & Conflictable)[RE]; - const boundConflictFunc = useCallback((...args: Arguments): void => { - if (!isMounted.current) return; - if (guildRef.current !== guild) return; // Cancel calls when the guild changes - onConflict(conflictEventArgsMap(...args), guild); - }, [ onConflict, conflictEventArgsMap ]) as (Connectable & Conflictable)[CE]; + }, [ guild, conflictEventArgsMap ]) as (Connectable & Conflictable)[CE]; const bindEventsFunc = useCallback(() => { guild.on(newEventName, boundNewFunc); @@ -610,12 +565,10 @@ function useMultipleGuildSubscriptionScrolling< fetchBelowCallable, setScrollRatio, fetchResult, - value, - valueGuild, + lastResult, fetchError, fetchAboveError, fetchBelowError, - events ]; } @@ -628,47 +581,46 @@ export function useGuildMetadataSubscription(guild: CombinedGuild) { updatedEventName: 'update-metadata', updatedEventArgsMap: (guildMeta: GuildMetadata) => guildMeta, conflictEventName: 'conflict-metadata', - conflictEventArgsMap: (changesType: AutoVerifierChangesType, oldGuildMeta: GuildMetadata, newGuildMeta: GuildMetadata) => newGuildMeta + conflictEventArgsMap: (_changesType: AutoVerifierChangesType, _oldGuildMeta: GuildMetadata, newGuildMeta: GuildMetadata) => newGuildMeta }, fetchMetadataFunc); } export function useResourceSubscription(guild: CombinedGuild, resourceId: string | null, resourceIdGuild: CombinedGuild | null) { const fetchResourceFunc = useCallback(async () => { - // TODO: This function isn't working for the members list //LOG.silly('fetching resource for subscription (resourceId: ' + resourceId + ')'); if (resourceId === null) return null; if (resourceIdGuild === null) return null; if (resourceIdGuild !== guild) return null; - return await guild.fetchResource(resourceId); - }, [ guild, resourceIdGuild, resourceId ]); - return useSingleGuildSubscription(guild, { + const fetchResource = await guild.fetchResource(resourceId); + return fetchResource; + }, [ guild, resourceIdGuild, resourceId ]); // Explicitly do NOT want lastFetchResource since it would cause a re-fetch after fetching successfully + return useSingleGuildSubscription(guild, { updatedEventName: 'update-resource', updatedEventArgsMap: (resource: Resource) => resource, conflictEventName: 'conflict-resource', - conflictEventArgsMap: (query: IDQuery, changesType: AutoVerifierChangesType, oldResource: Resource, newResource: Resource) => newResource + conflictEventArgsMap: (_query: IDQuery, _changesType: AutoVerifierChangesType, _oldResource: Resource, newResource: Resource) => newResource }, fetchResourceFunc); } export function useSoftImageSrcResourceSubscription(guild: CombinedGuild, resourceId: string | null, resourceIdGuild: CombinedGuild | null): [ imgSrc: string, - resource: Resource | null, - resourceGuild: CombinedGuild | null, + resourceResult: { value: Resource | null, guild: CombinedGuild } | null, fetchError: unknown | null ] { - const [ resource, resourceGuild, fetchError ] = useResourceSubscription(guild, resourceId, resourceIdGuild); + const [ resourceResult, fetchError ] = useResourceSubscription(guild, resourceId, resourceIdGuild); const [ imgSrc ] = useOneTimeAsyncAction( async () => { //LOG.debug(`Fetching soft imgSrc for g#${guild.id} r#${resource?.id ?? ''}`, { fetchError }); if (fetchError) return './img/error.png'; - if (!resource) return './img/loading.svg'; - return await ElementsUtil.getImageSrcFromBufferFailSoftly(resource.data); + if (!resourceResult || !resourceResult.value) return './img/loading.svg'; + return await ElementsUtil.getImageSrcFromBufferFailSoftly(resourceResult.value.data); }, './img/loading.svg', - [ resource, fetchError ] + [ resourceResult, fetchError ] ); - return [ imgSrc, resource, resourceGuild, fetchError ]; + return [ imgSrc, resourceResult, fetchError ]; } export function useChannelsSubscription(guild: CombinedGuild) { @@ -683,7 +635,7 @@ export function useChannelsSubscription(guild: CombinedGuild) { removedEventName: 'remove-channels', removedEventArgsMap: (removedChannels: Channel[]) => removedChannels, conflictEventName: 'conflict-channels', - conflictEventArgsMap: (changesType: AutoVerifierChangesType, changes: Changes) => changes, + conflictEventArgsMap: (_changesType: AutoVerifierChangesType, changes: Changes) => changes, sortFunc: Channel.sortByIndex }, fetchChannelsFunc); } @@ -700,19 +652,22 @@ export function useMembersSubscription(guild: CombinedGuild) { removedEventName: 'remove-members', removedEventArgsMap: (removedMembers: Member[]) => removedMembers, conflictEventName: 'conflict-members', - conflictEventArgsMap: (changesType: AutoVerifierChangesType, changes: Changes) => changes, + conflictEventArgsMap: (_changesType: AutoVerifierChangesType, changes: Changes) => changes, sortFunc: Member.sortForList }, fetchMembersFunc); } -export function useSelfMemberSubscription(guild: CombinedGuild): [ selfMember: Member | null, selfMemberGuild: CombinedGuild | null ] { - const [ fetchRetryCallable, members, membersGuild, fetchError ] = useMembersSubscription(guild); +export function useSelfMemberSubscription(guild: CombinedGuild): [ + selfMemberResult: { value: Member | null + guild: CombinedGuild } | null +] { + const [ _fetchRetryCallable, membersResult, _fetchError ] = useMembersSubscription(guild); // TODO: Show an error if we can't fetch and allow retry const selfMember = useMemo(() => { - if (members) { - const member = members.find(m => m.id === guild.memberId); + if (membersResult && membersResult.value) { + const member = membersResult.value.find(m => m.id === guild.memberId); if (!member) { LOG.warn('Unable to find self in members'); return null; @@ -720,9 +675,9 @@ export function useSelfMemberSubscription(guild: CombinedGuild): [ selfMember: M return member; } return null; - }, [ guild.memberId, members ]); + }, [ guild.memberId, membersResult ]); - return [ selfMember, membersGuild ]; + return [ membersResult ? { value: selfMember, guild: membersResult.guild } : null ]; } export function useTokensSubscription(guild: CombinedGuild) { @@ -738,7 +693,7 @@ export function useTokensSubscription(guild: CombinedGuild) { removedEventName: 'remove-tokens', removedEventArgsMap: (removedTokens: Token[]) => removedTokens, conflictEventName: 'conflict-tokens', - conflictEventArgsMap: (changesType: AutoVerifierChangesType, changes: Changes) => changes, + conflictEventArgsMap: (_changesType: AutoVerifierChangesType, changes: Changes) => changes, sortFunc: Token.sortRecentCreatedFirst }, fetchTokensFunc); } @@ -770,10 +725,11 @@ export function useMessagesScrollingSubscription(guild: CombinedGuild, channel: removedEventName: 'remove-messages', removedEventArgsMap: (removedMessages) => removedMessages, conflictEventName: 'conflict-messages', - conflictEventArgsMap: (query: PartialMessageListQuery, changesType: AutoVerifierChangesType, changes: Changes) => changes, + conflictEventArgsMap: (_query: PartialMessageListQuery, _changesType: AutoVerifierChangesType, changes: Changes) => changes, sortFunc: Message.sortOrder }, maxElements, maxFetchElements, fetchMessagesFunc, fetchAboveFunc, fetchBelowFunc ) } + diff --git a/src/client/webapp/elements/sections/connection-info.tsx b/src/client/webapp/elements/sections/connection-info.tsx index 8a01a38..e914890 100644 --- a/src/client/webapp/elements/sections/connection-info.tsx +++ b/src/client/webapp/elements/sections/connection-info.tsx @@ -1,29 +1,24 @@ -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, { Dispatch, FC, ReactNode, SetStateAction, 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; - selfMember: Member | null; - selfMemberGuild: CombinedGuild | null; + selfMemberResult: SubscriptionResult | null; setOverlay: Dispatch>; } const ConnectionInfo: FC = (props: ConnectionInfoProps) => { - const { guild, selfMember, selfMemberGuild, setOverlay } = props; + const { guild, selfMemberResult, setOverlay } = props; const rootRef = useRef(null); const displayMember = useMemo((): Member | DummyMember => { - if (!selfMember) { + if (!isNonNullAndHasValue(selfMemberResult)) { return { id: 'dummy', displayName: 'Connecting...', @@ -32,25 +27,26 @@ const ConnectionInfo: FC = (props: ConnectionInfoProps) => avatarResourceId: null }; } - return selfMember; - }, [ selfMember ]); + return selfMemberResult.value; + }, [ selfMemberResult ]); const [ contextMenu, toggleContextMenu ] = useContextMenu((close: () => void) => { - if (!selfMember || !selfMemberGuild) return null; + if (!isNonNullAndHasValue(selfMemberResult)) return null; return ( ); - }, [ guild, selfMember, rootRef ]); + }, [ guild, selfMemberResult, rootRef ]); return (
-
+
{contextMenu}
); } export default ConnectionInfo; + diff --git a/src/client/webapp/elements/sections/guild-title.tsx b/src/client/webapp/elements/sections/guild-title.tsx index 27fa4ff..c1aa4d7 100644 --- a/src/client/webapp/elements/sections/guild-title.tsx +++ b/src/client/webapp/elements/sections/guild-title.tsx @@ -2,43 +2,41 @@ import React, { Dispatch, FC, ReactNode, SetStateAction, useMemo, useRef } from import { GuildMetadata, Member } from '../../data-types'; import CombinedGuild from '../../guild-combined'; import GuildTitleContextMenu from '../contexts/context-menu-guild-title'; +import { isNonNullAndHasValue, SubscriptionResult } from '../require/guild-subscriptions'; import { useContextMenu } from '../require/react-helper'; export interface GuildTitleProps { guild: CombinedGuild; - guildMeta: GuildMetadata | null; - guildMetaGuild: CombinedGuild | null; - selfMember: Member | null; - selfMemberGuild: CombinedGuild | null; + guildMetaResult: SubscriptionResult | null; + selfMemberResult: SubscriptionResult | null; setOverlay: Dispatch>; } const GuildTitle: FC = (props: GuildTitleProps) => { - const { guild, guildMeta, guildMetaGuild, selfMember, selfMemberGuild, setOverlay } = props; + const { guild, guildMetaResult, selfMemberResult, setOverlay } = props; const rootRef = useRef(null); const hasContextMenu = useMemo(() => { + if (!isNonNullAndHasValue(selfMemberResult)) return false; return ( - selfMember && - ( - selfMember.privileges.includes('modify_profile') || - selfMember.privileges.includes('modify_channels') - ) + selfMemberResult.value.privileges.includes('modify_profile') || + selfMemberResult.value.privileges.includes('modify_channels') ); - }, [ selfMember ]); + }, [ selfMemberResult ]); const [ contextMenu, toggleContextMenu ] = useContextMenu((close: () => void) => { - if (!guildMeta || !guildMetaGuild) return null; - if (!selfMember || !selfMemberGuild) return null; + if (!isNonNullAndHasValue(guildMetaResult)) return null; + if (!isNonNullAndHasValue(selfMemberResult)) return null; + return ( ); - }, [ guild, guildMeta, guildMetaGuild, selfMember, selfMemberGuild, rootRef ]); + }, [ guild, guildMetaResult, selfMemberResult, rootRef ]); const nameStyle = useMemo(() => { if (hasContextMenu) { @@ -51,7 +49,7 @@ const GuildTitle: FC = (props: GuildTitleProps) => { return (
- {guildMeta?.name ?? null} + {guildMetaResult?.value?.name ?? null}
{contextMenu}
diff --git a/src/client/webapp/elements/sections/guild.tsx b/src/client/webapp/elements/sections/guild.tsx index 1462d29..3a8cc68 100644 --- a/src/client/webapp/elements/sections/guild.tsx +++ b/src/client/webapp/elements/sections/guild.tsx @@ -28,10 +28,11 @@ const GuildElement: FC = (props: GuildElementProps) => { // TODO: React set hasMessagesAbove and hasMessagesBelow when re-verified? // TODO: React jump messages to bottom when the current user sent a message - const [ selfMember, selfMemberGuild ] = useSelfMemberSubscription(guild); - const [ guildMeta, guildMetaGuild, guildMetaFetchError ] = useGuildMetadataSubscription(guild); - const [ membersRetry, members, membersGuild, membersFetchError ] = useMembersSubscription(guild); - const [ channelsRetry, channels, channelsGuild, channelsFetchError ] = useChannelsSubscription(guild); + const [ selfMemberResult ] = useSelfMemberSubscription(guild); + const [ guildMetaResult, guildMetaFetchError ] = useGuildMetadataSubscription(guild); + const [ membersRetry, membersResult, membersFetchError ] = useMembersSubscription(guild); + const [ channelsRetry, channelsResult, channelsFetchError ] = useChannelsSubscription(guild); + const [ activeChannel, setActiveChannel ] = useState(null); const [ activeChannelGuild, setActiveChannelGuild ] = useState(null); @@ -42,31 +43,31 @@ const GuildElement: FC = (props: GuildElementProps) => { useEffect(() => { if (activeChannel === null) { // initial active channel is the first one in the list - if (channels && channelsGuild && channels.length > 0) { - setActiveChannel(channels[0] as Channel); - setActiveChannelGuild(channelsGuild); + 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 (channels && channelsGuild && activeChannel) { + } else if (channelsResult && activeChannel) { // in the active channel was updated - const newActiveChannel = channels.find(channel => channel.id === activeChannel.id) ?? null + const newActiveChannel = channelsResult.value.find(channel => channel.id === activeChannel.id) ?? null setActiveChannel(newActiveChannel); - setActiveChannelGuild(channelsGuild); + setActiveChannelGuild(channelsResult.guild); //LOG.debug('Active channel was updated...', { channel: newActiveChannel?.id, channelsGuild: channelsGuild?.id }); } - }, [ channels, channelsGuild, activeChannel ]); + }, [ channelsResult, activeChannel ]); return (
- + - +
@@ -76,7 +77,7 @@ const GuildElement: FC = (props: GuildElementProps) => { {activeChannel && activeChannelGuild && }
- +