From 2ef1af3eff30e2c6e6996193b24853c535102c79 Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Sun, 12 Dec 2021 22:01:30 -0600 Subject: [PATCH] useSubscription techniques for react --- .../elements/components => archive}/table.tsx | 0 .../elements/components/table-invites.tsx | 72 +++++--- .../displays/display-guild-invites.tsx | 36 ++-- .../displays/display-guild-overview.tsx | 46 ++--- .../elements/overlays/overlay-add-guild.tsx | 24 ++- .../overlays/overlay-guild-settings.tsx | 13 +- .../elements/overlays/overlay-image.tsx | 53 +++--- .../elements/overlays/overlay-personalize.tsx | 25 ++- .../webapp/elements/require/base-elements.tsx | 16 ++ .../webapp/elements/require/elements-util.tsx | 10 +- .../elements/require/guild-subscriptions.ts | 158 ++++++++++++++++++ .../webapp/elements/require/react-helper.ts | 158 +++--------------- src/client/webapp/guild-combined.ts | 7 +- src/client/webapp/guild-types.ts | 4 + src/client/webapp/styles/components.scss | 3 +- src/client/webapp/styles/overlays.scss | 66 +++++++- 16 files changed, 406 insertions(+), 285 deletions(-) rename {src/client/webapp/elements/components => archive}/table.tsx (100%) create mode 100644 src/client/webapp/elements/require/guild-subscriptions.ts diff --git a/src/client/webapp/elements/components/table.tsx b/archive/table.tsx similarity index 100% rename from src/client/webapp/elements/components/table.tsx rename to archive/table.tsx diff --git a/src/client/webapp/elements/components/table-invites.tsx b/src/client/webapp/elements/components/table-invites.tsx index ed10c71..c41651d 100644 --- a/src/client/webapp/elements/components/table-invites.tsx +++ b/src/client/webapp/elements/components/table-invites.tsx @@ -1,9 +1,14 @@ +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 moment from 'moment'; -import React, { FC, useCallback } from 'react'; -import { Token } from '../../data-types'; +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { Member, Token } from '../../data-types'; import CombinedGuild from '../../guild-combined'; +import BaseElements from '../require/base-elements'; import Button, { ButtonColorType } from './button'; -import Table from './table'; export interface InvitesTableProps { guild: CombinedGuild @@ -12,28 +17,47 @@ export interface InvitesTableProps { const InvitesTable: FC = (props: InvitesTableProps) => { const { guild } = props; - const fetchData = useCallback(async () => await guild.fetchTokens(), [ guild ]); - const header = ( - - Created - Expires - Actions - - ); - const mapToRow = useCallback((token: Token) => { - return ( - - {moment(token.created).fromNow()} - {moment(token.expires).fromNow()} - - - - - - ); - }, []); + const [ tokens, setTokens ] = useState(null); + const [ tokensFailed, setTokensFailed ] = useState(false); - return + useEffect(() => { + (async () => { + try { + const tokens = await guild.fetchTokens(); + setTokens(tokens); + } catch (e: unknown) { + LOG.error('unable to fetch tokens', e); + setTokensFailed(true); + } + })(); + }, []) + + const tokenElements = useMemo(() => { + if (tokensFailed) { + return
Unable to load tokens
; + } + return tokens?.map((token: Token) => { + const userText = (token.member instanceof Member ? 'Used by ' + token.member.displayName : token.member?.id) ?? 'Unused Token'; + return ( +
+
+
{userText}
+
{token.token}
+
+
+
Created {moment(token.created).fromNow()}
+
Expires {moment(token.expires).fromNow()}
+
+
+ + +
+
+ ); + }); + }, [ tokens, tokensFailed ]); + + return
{tokenElements}
} export default InvitesTable; diff --git a/src/client/webapp/elements/displays/display-guild-invites.tsx b/src/client/webapp/elements/displays/display-guild-invites.tsx index 138a90d..f58118f 100644 --- a/src/client/webapp/elements/displays/display-guild-invites.tsx +++ b/src/client/webapp/elements/displays/display-guild-invites.tsx @@ -15,6 +15,8 @@ import moment from 'moment'; import DropdownInput from '../components/input-dropdown'; import Button from '../components/button'; import InvitesTable from '../components/table-invites'; +import GuildSubscriptions from '../require/guild-subscriptions'; +import ElementsUtil from '../require/elements-util'; export interface GuildInvitesDisplayProps { @@ -25,30 +27,16 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi const url = 'https://localhost:3030'; // TODO: this will likely be a dropdown list at some point - const [ guildName, setGuildName ] = useState(null); - const [ iconResourceId, setIconResourceId ] = useState(null); - - const [ iconSrc, setIconSrc ] = useState(null); + const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild); + const [ iconSrc ] = ReactHelper.useAsyncActionSubscription( + async () => await ElementsUtil.getImageSrcFromResourceFailSoftly(guild, guildMeta?.iconResourceId ?? null), + './img/loading.svg', + [ guild, guildMeta?.iconResourceId ] + ); const [ expiresFromNow, setExpiresFromNow ] = useState(moment.duration(1, 'day')); const [ expiresFromNowText, setExpiresFromNowText ] = useState('1 day'); - const [ guildMetaFailed, setGuildMetaFailed ] = useState(false); - - ReactHelper.useGuildMetadataEffect({ - guild, - onSuccess: (guildMeta: GuildMetadata) => { - setGuildName(guildMeta.name); - setIconResourceId(guildMeta.iconResourceId); - }, - onError: () => setGuildMetaFailed(true) - }); - - ReactHelper.useSoftImageSrcResourceEffect({ - guild, resourceId: iconResourceId, - onSuccess: setIconSrc - }); - useEffect(() => { if (expiresFromNowText === 'never') { setExpiresFromNow(null); @@ -60,9 +48,9 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi }, [ expiresFromNowText ]); const errorMessage = useMemo(() => { - if (guildMetaFailed) return 'Unable to load guild metadata'; + if (guildMetaError) return 'Unable to load guild metadata'; return null; - }, [ guildMetaFailed ]); + }, [ guildMetaError ]); return ( = (props: GuildInvitesDi
-
Active Invites
+
Invite History
diff --git a/src/client/webapp/elements/displays/display-guild-overview.tsx b/src/client/webapp/elements/displays/display-guild-overview.tsx index 89858fa..0923f0d 100644 --- a/src/client/webapp/elements/displays/display-guild-overview.tsx +++ b/src/client/webapp/elements/displays/display-guild-overview.tsx @@ -12,15 +12,16 @@ import Display from '../components/display'; import TextInput from '../components/input-text'; import ImageEditInput from '../components/input-image-edit'; import ReactHelper from '../require/react-helper'; +import GuildSubscriptions from '../require/guild-subscriptions'; export interface GuildOverviewDisplayProps { guild: CombinedGuild; - setContainerGuildName: React.Dispatch>; // to allow overlay title to update } const GuildOverviewDisplay: FC = (props: GuildOverviewDisplayProps) => { - const { guild, setContainerGuildName } = props; + const { guild } = props; - const [ iconResourceId, setIconResourceId ] = useState(null); + const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild); + const [ iconResource, iconResourceError ] = GuildSubscriptions.useResourceSubscription(guild, guildMeta?.iconResourceId ?? null); const [ savedName, setSavedName ] = useState(null); const [ savedIconBuff, setSavedIconBuff ] = useState(null); @@ -29,9 +30,6 @@ const GuildOverviewDisplay: FC = (props: GuildOvervie const [ iconBuff, setIconBuff ] = useState(null); const [ saving, setSaving ] = useState(false); - - const [ guildMetaFailed, setGuildMetaFailed ] = useState(false); - const [ iconFailed, setIconFailed ] = useState(false); const [ saveFailed, setSaveFailed ] = useState(false); const [ nameInputValid, setNameInputValid ] = useState(false); @@ -40,42 +38,31 @@ const GuildOverviewDisplay: FC = (props: GuildOvervie const [ iconInputValid, setIconInputValid ] = useState(false); const [ iconInputMessage, setIconInputMessage ] = useState(null); - ReactHelper.useGuildMetadataEffect({ - guild, - onSuccess: (guildMeta: GuildMetadata) => { - setContainerGuildName(guildMeta.name); + useEffect(() => { + if (guildMeta) { + if (name === savedName) setName(guildMeta.name); setSavedName(guildMeta.name); - setName(guildMeta.name); - setIconResourceId(guildMeta.iconResourceId); - }, - onError: () => { - setGuildMetaFailed(true); - setContainerGuildName(''); } - }) + }, [ guildMeta ]); - ReactHelper.useNullableResourceEffect({ - guild, resourceId: iconResourceId, - onSuccess: (resource) => { - setSavedIconBuff(resource?.data ?? null); - setIconBuff(resource?.data ?? null); - }, - onError: () => { - setIconFailed(true); + useEffect(() => { + if (iconResource) { + if (iconBuff === savedIconBuff) setIconBuff(iconResource.data); + setSavedIconBuff(iconResource.data); } - }); + }, [ iconResource ]); const changes = useMemo(() => { return name !== savedName || iconBuff?.toString('hex') !== savedIconBuff?.toString('hex') }, [ name, savedName, iconBuff, savedIconBuff ]); const errorMessage = useMemo(() => { - if (guildMetaFailed) return 'Unable to load guild metadata'; - if (iconFailed) return 'Unable to load icon'; + if (guildMetaError) return 'Unable to load guild metadata'; + if (iconResourceError) return 'Unable to load icon'; if (!iconInputValid && iconInputMessage) return iconInputMessage; if (!nameInputValid && nameInputMessage) return nameInputMessage; return null; - }, [ iconFailed, iconInputValid, iconInputMessage, nameInputValid, nameInputMessage ]); + }, [ guildMetaError, iconResourceError, iconInputValid, iconInputMessage, nameInputValid, nameInputMessage ]); const infoMessage = useMemo(() => { if (iconInputValid && iconInputMessage) return iconInputMessage; @@ -99,7 +86,6 @@ const GuildOverviewDisplay: FC = (props: GuildOvervie // Save name try { await guild.requestSetGuildName(name); - setContainerGuildName(name); setSavedName(name); } catch (e: unknown) { LOG.error('error setting guild name', e); diff --git a/src/client/webapp/elements/overlays/overlay-add-guild.tsx b/src/client/webapp/elements/overlays/overlay-add-guild.tsx index 2fca21e..ba6b8df 100644 --- a/src/client/webapp/elements/overlays/overlay-add-guild.tsx +++ b/src/client/webapp/elements/overlays/overlay-add-guild.tsx @@ -16,6 +16,7 @@ import CombinedGuild from '../../guild-combined'; import ElementsUtil from '../require/elements-util'; import InvitePreview from '../components/invite-preview'; import ReactHelper from '../require/react-helper'; +import * as fs from 'fs/promises'; export interface IAddGuildData { name: string, @@ -71,7 +72,6 @@ const AddGuildOverlay: FC = (props: AddGuildOverlayProps) const [ displayName, setDisplayName ] = useState(exampleDisplayName); const [ avatarBuff, setAvatarBuff ] = useState(null); - const [ exampleAvatarFailed, setExampleAvatarFailed ] = useState(false); const [ addGuildFailedMessage, setAddGuildFailedMessage ] = useState(null); const [ displayNameInputMessage, setDisplayNameInputMessage ] = useState(null); @@ -80,19 +80,27 @@ const AddGuildOverlay: FC = (props: AddGuildOverlayProps) const [ avatarInputMessage, setAvatarInputMessage ] = useState(null); const [ avatarInputValid, setAvatarInputValid ] = useState(false); - ReactHelper.useBufferFileEffect({ - filePath: exampleAvatarPath, - onSuccess: (buff) => { setAvatarBuff(buff); setExampleAvatarFailed(false); }, - onError: () => { setExampleAvatarFailed(true); } - }); + const [ exampleAvatarBuff, exampleAvatarBuffError ] = ReactHelper.useAsyncActionSubscription( + async () => { + return await fs.readFile(exampleAvatarPath); + }, + null, + [ exampleAvatarPath ] + ); + + useEffect(() => { + if (exampleAvatarBuff) { + if (avatarBuff === null) setAvatarBuff(exampleAvatarBuff); + } + }, [ exampleAvatarBuff ]); const errorMessage = useMemo(() => { - if (exampleAvatarFailed && !avatarBuff) return 'Unable to load example avatar'; + if (exampleAvatarBuffError && !avatarBuff) return 'Unable to load example avatar'; if ( !avatarInputValid && avatarInputMessage) return avatarInputMessage; if (!displayNameInputValid && displayNameInputMessage) return displayNameInputMessage; if (addGuildFailedMessage !== null) return addGuildFailedMessage; return null; - }, [ exampleAvatarFailed, avatarBuff, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage, addGuildFailedMessage ]); + }, [ exampleAvatarBuffError, avatarBuff, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage, addGuildFailedMessage ]); const doSubmit = useCallback(async (): Promise => { if (!displayNameInputValid || !avatarInputValid || avatarBuff === null) { diff --git a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx index e97776f..ffb584f 100644 --- a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx +++ b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx @@ -1,8 +1,9 @@ -import React, { FC, useEffect, useState } from "react"; +import React, { FC, useEffect, useMemo, useState } from "react"; import CombinedGuild from "../../guild-combined"; import ChoicesControl from "../components/control-choices"; import GuildInvitesDisplay from "../displays/display-guild-invites"; import GuildOverviewDisplay from "../displays/display-guild-overview"; +import GuildSubscriptions from "../require/guild-subscriptions"; export interface GuildSettingsOverlayProps { guild: CombinedGuild; @@ -10,21 +11,25 @@ export interface GuildSettingsOverlayProps { const GuildSettingsOverlay: FC = (props: GuildSettingsOverlayProps) => { const { guild } = props; - const [ guildName, setGuildName ] = useState(''); + const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild); const [ selectedId, setSelectedId ] = useState('overview'); const [ display, setDisplay ] = useState(); useEffect(() => { - if (selectedId === 'overview') setDisplay(); + if (selectedId === 'overview') setDisplay(); //if (selectedId === 'channels') setDisplay(); //if (selectedId === 'roles' ) setDisplay(); if (selectedId === 'invites' ) setDisplay(); }, [ selectedId ]); + const guildNameText = useMemo(() => { + return guildMetaError ? 'metadata error' : guildMeta?.name ?? 'loading...'; + }, [ guildMeta, guildMetaError ]) + return (
- = (props: ImageOverlayProps) => { const { guild, resourceId, resourceName } = props; - const [ resource, setResource ] = useState(null); - const [ resourceImgSrc, setResourceImgSrc ] = useState('./img/loading.svg'); - const [ resourceErr, setResourceErr ] = useState(false); - const [ mime, setMime ] = useState(null); - const [ ext, setExt ] = useState(null); - - ReactHelper.useResourceEffect({ - guild, resourceId, - onSuccess: async (newResource: Resource) => { - setResource(newResource); - const { mime, ext } = (await FileType.fromBuffer(newResource.data)) ?? { mime: null, ext: null }; - if (mime === null || ext === null) throw new Error('unable to get mime/ext'); - if (resource === newResource) { - setMime(mime); - setExt(ext); - } + const [ resource, resourceError ] = GuildSubscriptions.useResourceSubscription(guild, resourceId); + const [ resourceImgSrc, resourceImgSrcError ] = ReactHelper.useAsyncActionSubscription( + async () => await ElementsUtil.getImageSrcFromBufferFailSoftly(resource?.data ?? null), + './img/loading.svg', + [ guild, resource ] + ) + const [ resourceFileTypeInfo, resourceFileTypeInfoError ] = ReactHelper.useAsyncActionSubscription( + async () => { + if (!resource) return null; + const fileTypeInfo = (await FileType.fromBuffer(resource.data)) ?? null; + if (fileTypeInfo === null) throw new Error('unable to get mime/ext'); + return fileTypeInfo; }, - onError: () => { - setResource(null); - setResourceImgSrc('./img/error.png'); - setResourceErr(true); - } - }); - - ReactHelper.useImageSrcBufferEffect({ - buffer: resource?.data ?? null, - onSuccess: setResourceImgSrc, - onError: () => { - setResource(null); - setResourceImgSrc('./img/error.png'); - setResourceErr(true); - } - }); + null, + [ resource ] + ); const onImageContextMenu = (e: React.MouseEvent) => { // TODO: This should be in react! if (!resource) return; - const contextMenu = createImageContextMenu(document, new Q(document), guild, resourceName, resource.data, mime as string, ext as string, false); + if (!resourceFileTypeInfo) return; + const contextMenu = createImageContextMenu(document, new Q(document), guild, resourceName, resource.data, resourceFileTypeInfo.mime, resourceFileTypeInfo.ext, false); document.body.appendChild(contextMenu); const relativeTo = { x: e.pageX, y: e.pageY }; ElementsUtil.alignContextElement(contextMenu, relativeTo, { top: 'centerY', left: 'right' }); @@ -77,7 +62,7 @@ const ImageOverlay: FC = (props: ImageOverlayProps) => {
{sizeText}
diff --git a/src/client/webapp/elements/overlays/overlay-personalize.tsx b/src/client/webapp/elements/overlays/overlay-personalize.tsx index 8118bc2..52ceb91 100644 --- a/src/client/webapp/elements/overlays/overlay-personalize.tsx +++ b/src/client/webapp/elements/overlays/overlay-personalize.tsx @@ -13,6 +13,7 @@ import TextInput from '../components/input-text'; import SubmitOverlayLower from '../components/submit-overlay-lower'; import ElementsUtil from '../require/elements-util'; import ReactHelper from '../require/react-helper'; +import GuildSubscriptions from '../require/guild-subscriptions'; export interface PersonalizeOverlayProps { document: Document; @@ -26,7 +27,7 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl throw new Error('bad avatar'); } - const avatarResourceId = connection.avatarResourceId; + const [ avatarResource, avatarResourceError ] = GuildSubscriptions.useResourceSubscription(guild, connection.avatarResourceId) const displayNameInputRef = createRef(); @@ -40,7 +41,6 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl const [ displayName, setDisplayName ] = useState(connection.displayName); const [ avatarBuff, setAvatarBuff ] = useState(null); - const [ loadAvatarFailed, setLoadAvatarFailed ] = useState(false); const [ saveFailed, setSaveFailed ] = useState(false); const [ displayNameInputValid, setDisplayNameInputValid ] = useState(false); @@ -49,27 +49,24 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl const [ avatarInputValid, setAvatarInputValid ] = useState(false); const [ avatarInputMessage, setAvatarInputMessage ] = useState(null); - ReactHelper.useResourceEffect({ - guild, resourceId: avatarResourceId, - onSuccess: (resource) => { - setSavedAvatarBuff(resource.data); - setAvatarBuff(resource.data); - setLoadAvatarFailed(false); - }, - onError: () => { setLoadAvatarFailed(true); } - }); + useEffect(() => { + if (avatarResource) { + if (avatarBuff === savedAvatarBuff) setAvatarBuff(avatarResource.data); + setSavedAvatarBuff(avatarResource.data); + } + }, [ avatarResource ]); useEffect(() => { displayNameInputRef.current?.focus(); }, []); const errorMessage = useMemo(() => { - if (loadAvatarFailed) return 'Unable to load avatar'; + if (avatarResourceError) return 'Unable to load avatar'; if ( !avatarInputValid && avatarInputMessage) return avatarInputMessage; if (!displayNameInputValid && displayNameInputMessage) return displayNameInputMessage; if (saveFailed) return 'Unable to save personalization'; return null; - }, [ saveFailed, loadAvatarFailed, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]); + }, [ saveFailed, avatarResourceError, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]); const infoMessage = useMemo(() => { if (avatarInputValid && avatarInputMessage) return avatarInputMessage; @@ -98,7 +95,7 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl await guild.requestSetAvatar(avatarBuff); setSavedAvatarBuff(avatarBuff); } catch (e: unknown) { - LOG.error('error setting guild icon', e); + LOG.error('error setting avatar', e); setSaveFailed(true); return false; } diff --git a/src/client/webapp/elements/require/base-elements.tsx b/src/client/webapp/elements/require/base-elements.tsx index cd9d441..2d8bf6d 100644 --- a/src/client/webapp/elements/require/base-elements.tsx +++ b/src/client/webapp/elements/require/base-elements.tsx @@ -143,6 +143,15 @@ export default class BaseElements { 4.00,7.10 4.90,8.00 6.00,8.00 Z` } }; + static TRASHCAN = ( + + + + + + ); static Q_TRASHCAN = { ns: 'http://www.w3.org/2000/svg', tag: 'svg', width: 16, height: 16, viewBox: '0 0 64 64', content: [ { ns: 'http://www.w3.org/2000/svg', tag: 'path', fill: 'currentColor', 'fill-rule': 'evenodd', 'clip-rule': 'evenodd', @@ -154,6 +163,13 @@ export default class BaseElements { ] } + static DOWNLOAD = ( + + + + ); static Q_DOWNLOAD = { ns: 'http://www.w3.org/2000/svg', tag: 'svg', width: 16, height: 16, viewBox: '0 0 64 64', content: [ { ns: 'http://www.w3.org/2000/svg', tag: 'path', fill: 'currentColor', 'fill-rule': 'evenodd', 'clip-rule': 'evenodd', diff --git a/src/client/webapp/elements/require/elements-util.tsx b/src/client/webapp/elements/require/elements-util.tsx index b47452d..d8a16ee 100644 --- a/src/client/webapp/elements/require/elements-util.tsx +++ b/src/client/webapp/elements/require/elements-util.tsx @@ -135,7 +135,10 @@ export default class ElementsUtil { } } - static async getImageSrcFromBufferFailSoftly(buff: Buffer): Promise { + static async getImageSrcFromBufferFailSoftly(buff: Buffer | null): Promise { + if (buff === null) { + return './img/loading.svg'; + } try { const src = await ElementsUtil.getImageBufferSrc(buff); return src; @@ -146,9 +149,8 @@ export default class ElementsUtil { } static async getImageSrcFromResourceFailSoftly(guild: CombinedGuild, resourceId: string | null): Promise { - if (!resourceId) { - LOG.warn('no guild resource specified, showing error instead', new Error()); - return './img/error.png'; + if (resourceId === null) { + return './img/loading.svg'; } try { const resource = await guild.fetchResource(resourceId); diff --git a/src/client/webapp/elements/require/guild-subscriptions.ts b/src/client/webapp/elements/require/guild-subscriptions.ts new file mode 100644 index 0000000..5b1233d --- /dev/null +++ b/src/client/webapp/elements/require/guild-subscriptions.ts @@ -0,0 +1,158 @@ +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 { GuildMetadata, Resource } from "../../data-types"; +import CombinedGuild from "../../guild-combined"; + +import React, { DependencyList, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AutoVerifierChangesType } from "../../auto-verifier"; +import { Conflictable, Connectable } from "../../guild-types"; +import { EventEmitter } from 'tsee'; +import { IDQuery } from '../../auto-verifier-with-args'; + +export type SubscriptionEvents = { + 'fetch': () => void; + 'update': () => void; + 'conflict': () => void; + 'fetch-error': () => void; +} + +interface EffectParams { + guild: CombinedGuild; + onFetch: (value: T | null) => void; + onUpdate: (value: T) => void; + onConflict: (value: T) => void; + onFetchError: (e: unknown) => void; +} + +type Arguments = T extends (...args: infer A) => unknown ? A : never; + +interface EventMappingParams { + updateEventName: UE; + updateEventArgsMap: (...args: Arguments) => T; // should be same as the params list from Connectable + conflictEventName: CE; + conflictEventArgsMap: (...args: Arguments) => T; // should be the same as the params list from Conflictable + fetchDeps: DependencyList; +} + +export default class GuildSubscriptions { + private static useGuildSubscriptionEffect( + subscriptionParams: EffectParams, eventMappingParams: EventMappingParams, fetchFunc: (() => Promise) | (() => Promise) + ) { + const { guild, onFetch, onUpdate, onConflict, onFetchError } = subscriptionParams; + const { updateEventName, updateEventArgsMap, conflictEventName, conflictEventArgsMap, fetchDeps } = eventMappingParams; + + const isMounted = useRef(false); + + const fetchManagerFunc = useCallback(async () => { + if (!isMounted.current) return; + try { + const value = await fetchFunc(); + if (!isMounted.current) return; + onFetch(value); + } catch (e: unknown) { + LOG.error('error fetching for subscription', e); + if (!isMounted.current) return; + onFetchError(e); + } + }, [ ...fetchDeps, fetchFunc ]); + + const boundUpdateFunc = useCallback((...args: Arguments): void => { + if (!isMounted.current) return; + const value = updateEventArgsMap(...args); + onUpdate(value); + }, []) as (Connectable & Conflictable)[UE]; // I think the typed EventEmitter class isn't ready for this level of type safety + const boundConflictFunc = useCallback((...args: Arguments): void => { + if (!isMounted.current) return; + const value = conflictEventArgsMap(...args); // otherwise, I may have done this wrong. Using never to force it to work + onConflict(value); + }, []) as (Connectable & Conflictable)[CE]; + + useEffect(() => { + isMounted.current = true; + + // Bind guild events to make sure we have the most up to date information + guild.on('connect', fetchManagerFunc); + guild.on(updateEventName, boundUpdateFunc); + guild.on(conflictEventName, boundConflictFunc); + + // Fetch the data once + fetchManagerFunc(); + + return () => { + isMounted.current = false; + + // Unbind the events so that we don't have any memory leaks + guild.off('connect', fetchManagerFunc); + guild.off(updateEventName, boundUpdateFunc); + guild.off(conflictEventName, boundConflictFunc); + } + }, [ fetchManagerFunc ]); + } + + private static useGuildSubscription( + guild: CombinedGuild, eventMappingParams: EventMappingParams, fetchFunc: (() => Promise) | (() => Promise) + ): [value: T | null, fetchError: unknown | null, events: EventEmitter] { + const [ fetchError, setFetchError ] = useState(null); + const [ value, setValue ] = useState(null); + + const events = useMemo(() => new EventEmitter(), []); + + const onFetch = useCallback((fetchValue: T | null) => { + setValue(fetchValue); + setFetchError(null); + events.emit('fetch'); + }, []); + + const onFetchError = useCallback((e: unknown) => { + setFetchError(e); + setValue(null); + events.emit('fetch-error'); + }, []); + + const onUpdate = useCallback((updateValue: T) => { + setValue(updateValue); + events.emit('update'); + }, []); + + const onConflict = useCallback((conflictValue: T) => { + setValue(conflictValue); + events.emit('conflict'); + }, []); + + GuildSubscriptions.useGuildSubscriptionEffect({ + guild, + onFetch, + onUpdate, + onConflict, + onFetchError + }, eventMappingParams, fetchFunc); + + return [ value, fetchError, events ]; + } + + static useGuildMetadataSubscription(guild: CombinedGuild) { + return GuildSubscriptions.useGuildSubscription(guild, { + updateEventName: 'update-metadata', + updateEventArgsMap: (guildMeta: GuildMetadata) => guildMeta, + conflictEventName: 'conflict-metadata', + conflictEventArgsMap: (changesType: AutoVerifierChangesType, oldGuildMeta: GuildMetadata, newGuildMeta: GuildMetadata) => newGuildMeta, + fetchDeps: [ guild ] + }, async () => await guild.fetchMetadata()); + } + + static useResourceSubscription(guild: CombinedGuild, resourceId: string | null) { + return GuildSubscriptions.useGuildSubscription(guild, { + updateEventName: 'update-resource', + updateEventArgsMap: (resource: Resource) => resource, + conflictEventName: 'conflict-resource', + conflictEventArgsMap: (query: IDQuery, changesType: AutoVerifierChangesType, oldResource: Resource, newResource: Resource) => newResource, + fetchDeps: [ guild, resourceId ] + }, async () => { + if (resourceId === null) return null; + return await guild.fetchResource(resourceId); + }); + } +} diff --git a/src/client/webapp/elements/require/react-helper.ts b/src/client/webapp/elements/require/react-helper.ts index 9b814f7..b197f54 100644 --- a/src/client/webapp/elements/require/react-helper.ts +++ b/src/client/webapp/elements/require/react-helper.ts @@ -3,12 +3,9 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import { DependencyList, useEffect } from "react"; +import { DependencyList, useEffect, useRef, useState } from "react"; import ReactDOMServer from "react-dom/server"; -import { GuildMetadata, Resource, ShouldNeverHappenError } from "../../data-types"; -import CombinedGuild from "../../guild-combined"; -import ElementsUtil from './elements-util'; -import * as fs from 'fs/promises'; +import { ShouldNeverHappenError } from "../../data-types"; // Helper function so we can use JSX before fully committing to React @@ -23,146 +20,35 @@ export default class ReactHelper { return document.body.firstElementChild; } - private static useCustomAsyncEffect(params: { + static useAsyncActionSubscription( actionFunc: () => Promise, - onSuccess: (value: T) => void, - onError: (e: unknown) => void, + initialValue: V, deps: DependencyList - }) { - const { actionFunc, onSuccess, onError, deps } = params; + ): [ value: T | V, error: unknown | null ] { + const isMounted = useRef(false); + + const [ value, setValue ] = useState(initialValue); + const [ error, setError ] = useState(null); + useEffect(() => { + isMounted.current = true; + (async () => { try { const value = await actionFunc(); - onSuccess(value); - } catch (e) { - onError(e); + if (!isMounted.current) return; + setValue(value); + setError(null); + } catch (e: unknown) { + LOG.error('unable to perform async action subscription', e); + if (!isMounted.current) return; + setError(e); } })(); + + return () => { isMounted.current = false; }; }, deps); - } - static useGuildMetadataEffect(params: { - guild: CombinedGuild, - onSuccess: (guildMeta: GuildMetadata) => void, - onError: () => void, - }): void { - const { guild, onSuccess, onError } = params; - - ReactHelper.useCustomAsyncEffect({ - actionFunc: async () => { - return await guild.fetchMetadata(); - }, - onSuccess: (value) => onSuccess(value), - onError: (e) => { LOG.error('unable to load guild metadata', e); onError() }, - deps: [ guild ] - }); - } - - static useBufferFileEffect(params: { - filePath: string, - onSuccess: (buff: Buffer) => void, - onError: () => void - }): void { - const { filePath, onSuccess, onError } = params; - - ReactHelper.useCustomAsyncEffect({ - actionFunc: async () => { - const buff = await fs.readFile(filePath); - return buff; - }, - onSuccess: (value) => onSuccess(value), - onError: (e) => { LOG.error('unable to load file', e); onError() }, - deps: [ filePath ] - }); - } - - static useNullableResourceEffect(params: { - guild: CombinedGuild, resourceId: string | null, - onSuccess: (resource: Resource | null) => void, - onError: () => void - }): void { - const { guild, resourceId, onSuccess, onError } = params; - - ReactHelper.useCustomAsyncEffect({ - actionFunc: async () => { - if (resourceId === null) return null; - const resource = await guild.fetchResource(resourceId); - return resource; - }, - onSuccess: (value) => onSuccess(value), - onError: (e) => { LOG.error('unable to fetch resource', e); onError(); }, - deps: [ guild, resourceId ] - }); - } - - static useResourceEffect(params: { - guild: CombinedGuild, resourceId: string, - onSuccess: (resource: Resource) => void, - onError: () => void - }): void { - const { guild, resourceId, onSuccess, onError } = params; - - ReactHelper.useCustomAsyncEffect({ - actionFunc: async () => { - const resource = await guild.fetchResource(resourceId); - return resource; - }, - onSuccess: (value) => onSuccess(value), - onError: (e) => { LOG.error('unable to fetch resource', e); onError(); }, - deps: [ guild, resourceId ] - }); - } - - static useImageSrcResourceEffect(params: { - guild: CombinedGuild, resourceId: string | null, - onSuccess: (imgSrc: string) => void, - onError: () => void - }): void { - const { guild, resourceId, onSuccess, onError } = params; - - ReactHelper.useCustomAsyncEffect({ - actionFunc: async () => { - if (resourceId === null) return './img/loading.svg'; - const resource = await guild.fetchResource(resourceId); - const imgSrc = await ElementsUtil.getImageBufferSrc(resource.data); - return imgSrc; - }, - onSuccess: (value) => onSuccess(value), - onError: (e) => { LOG.error('unable to fetch and convert resource', e); onError(); }, - deps: [ guild, resourceId ] - }); - } - - static useSoftImageSrcResourceEffect(params: { - guild: CombinedGuild, resourceId: string | null, - onSuccess: (imgSrc: string) => void - }): void { - const { guild, resourceId, onSuccess } = params; - - this.useImageSrcResourceEffect({ - guild, resourceId, - onSuccess, - onError: () => { onSuccess('./img/error.png'); } - }); - } - - static useImageSrcBufferEffect(params: { - buffer: Buffer | null, - onSuccess: (imgSrc: string) => void, - onError: () => void - }): void { - const { buffer, onSuccess, onError } = params; - - ReactHelper.useCustomAsyncEffect({ - actionFunc: async () => { - if (buffer === null) return './img/loading.svg'; - const imgSrc = await ElementsUtil.getImageBufferSrc(buffer); - return imgSrc; - }, - onSuccess: (value) => onSuccess(value), - onError: (e) => { LOG.error('unable to convert buffer', e); onError(); }, - deps: [ buffer ] - }); + return [ value, error ]; } } diff --git a/src/client/webapp/guild-combined.ts b/src/client/webapp/guild-combined.ts index 3d05d53..84fac75 100644 --- a/src/client/webapp/guild-combined.ts +++ b/src/client/webapp/guild-combined.ts @@ -319,7 +319,12 @@ export default class CombinedGuild extends EventEmitter { - return await this.fetchable.fetchTokens(); + const members = await this.grabRAMMembersMap(); + const tokens = await this.fetchable.fetchTokens(); + for (const token of tokens) { + token.fill(members); + } + return tokens; } // Simply forwarded to the socket guild diff --git a/src/client/webapp/guild-types.ts b/src/client/webapp/guild-types.ts index db9fa2b..3cac066 100644 --- a/src/client/webapp/guild-types.ts +++ b/src/client/webapp/guild-types.ts @@ -128,6 +128,10 @@ export type Connectable = { 'new-messages': (messages: Message[]) => void; 'update-messages': (updatedMessages: Message[]) => void; 'remove-messages': (removedMessages: Message[]) => void; + + // TODO: Implement these in server/combined guild + 'update-resource': (updatedResource: Resource) => void; + 'remove-resource': (removedResource: Resource) => void; } // A Conflictable could emit conflict-based events if data changed based on verification diff --git a/src/client/webapp/styles/components.scss b/src/client/webapp/styles/components.scss index 72d62e4..9f5dcaa 100644 --- a/src/client/webapp/styles/components.scss +++ b/src/client/webapp/styles/components.scss @@ -126,13 +126,14 @@ border-top-right-radius: $content-border-radius; border-bottom-right-radius: $content-border-radius; background-color: $background-primary; + overflow-y: scroll; position: relative; > .scroll { margin: 32px; .divider { - margin: 24px 0; + margin: 16px 0; height: 1px; background-color: $background-primary-divider; } diff --git a/src/client/webapp/styles/overlays.scss b/src/client/webapp/styles/overlays.scss index e3c42e0..ff8bcd6 100644 --- a/src/client/webapp/styles/overlays.scss +++ b/src/client/webapp/styles/overlays.scss @@ -309,7 +309,8 @@ body > .overlay, font-weight: 600; color: $text-normal; font-size: 1.1em; - margin-bottom: 4px; + line-height: 1; + margin-bottom: 16px; } } } @@ -355,10 +356,61 @@ body > .overlay, .view-invites { color: $text-normal; - th { - text-transform: uppercase; - text-align: left; - font-size: 0.75em; + .invites-table { + .token-row { + display: flex; + align-items: center; + line-height: 1; + background-color: $background-secondary; + padding: 8px; + margin: 0 -8px; + border-radius: 4px; + + &:hover { + background-color: $background-secondary-alt; + } + + &:not(:last-child) { + margin-bottom: 8px; + } + + .user-token { + flex: 1; + + .user { + font-weight: 600; + margin-bottom: 4px; + } + + .token { + font-family: 'Courier New', Courier, monospace; + font-size: 0.85em; + color: $text-muted; + } + } + + .created-expires { + text-align: right; + font-size: 0.85em; + margin-right: 8px; + + > :not(:last-child) { + margin-bottom: 4px; + } + } + + .actions { + display: flex; + + .button { + padding: 8px; + } + + > :not(:last-child) { + margin-right: 8px; + } + } + } } td.actions { @@ -367,6 +419,10 @@ body > .overlay, :not(:last-child) { margin-right: 8px; } + + .button { + padding: 8px; + } } } }