From 1c1d3209bf163d2e6c3085cb9ed92714753c1a9c Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Sun, 12 Dec 2021 23:25:23 -0600 Subject: [PATCH] tokens subscription --- src/client/webapp/data-types.ts | 15 +- .../elements/components/table-invites.tsx | 63 ------ .../displays/display-guild-invites.tsx | 35 ++- .../webapp/elements/overlay-token-log.tsx | 2 +- .../elements/require/guild-subscriptions.ts | 213 ++++++++++++++++-- src/client/webapp/guild-types.ts | 7 +- 6 files changed, 242 insertions(+), 93 deletions(-) delete mode 100644 src/client/webapp/elements/components/table-invites.tsx diff --git a/src/client/webapp/data-types.ts b/src/client/webapp/data-types.ts index 3ea69d8..d007a0d 100644 --- a/src/client/webapp/data-types.ts +++ b/src/client/webapp/data-types.ts @@ -332,7 +332,7 @@ export class Token implements WithEquals { public readonly token: string, public member: Member | { id: string } | null, public readonly created: Date, - public readonly expires: Date, + public readonly expires: Date | null, public readonly source?: unknown ) { this.id = token; // for comparison purposes @@ -365,6 +365,19 @@ export class Token implements WithEquals { this.expires === other.expires ) } + + static sortFurthestExpiresFirst(a: Token, b: Token) { + // reverse-expire time order (most future expires comes first) + if (a.expires && b.expires) { + return b.expires.getTime() - a.expires.getTime(); + } else if (a.expires) { + return -1; + } else if (b.expires) { + return 1; + } else { + return 0; + } + } } export class NotInitializedError extends Error { diff --git a/src/client/webapp/elements/components/table-invites.tsx b/src/client/webapp/elements/components/table-invites.tsx deleted file mode 100644 index c41651d..0000000 --- a/src/client/webapp/elements/components/table-invites.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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, 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'; - -export interface InvitesTableProps { - guild: CombinedGuild -} - -const InvitesTable: FC = (props: InvitesTableProps) => { - const { guild } = props; - - const [ tokens, setTokens ] = useState(null); - const [ tokensFailed, setTokensFailed ] = useState(false); - - 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 f58118f..ae6a17e 100644 --- a/src/client/webapp/elements/displays/display-guild-invites.tsx +++ b/src/client/webapp/elements/displays/display-guild-invites.tsx @@ -8,15 +8,15 @@ import { FC } from 'react'; import CombinedGuild from '../../guild-combined'; import Display from '../components/display'; import InvitePreview from '../components/invite-preview'; -import { GuildMetadata } from '../../data-types'; +import { Member, Token } from '../../data-types'; import ReactHelper from '../require/react-helper'; import { Duration } from 'moment'; import moment from 'moment'; import DropdownInput from '../components/input-dropdown'; -import Button from '../components/button'; -import InvitesTable from '../components/table-invites'; +import Button, { ButtonColorType } from '../components/button'; import GuildSubscriptions from '../require/guild-subscriptions'; import ElementsUtil from '../require/elements-util'; +import BaseElements from '../require/base-elements'; export interface GuildInvitesDisplayProps { @@ -27,6 +27,8 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi const url = 'https://localhost:3030'; // TODO: this will likely be a dropdown list at some point + const [ tokens, tokensError ] = GuildSubscriptions.useTokensSubscription(guild); + const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild); const [ iconSrc ] = ReactHelper.useAsyncActionSubscription( async () => await ElementsUtil.getImageSrcFromResourceFailSoftly(guild, guildMeta?.iconResourceId ?? null), @@ -52,6 +54,31 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi return null; }, [ guildMetaError ]); + const tokenElements = useMemo(() => { + if (tokensError) { + 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, tokensError ]); + return ( = (props: GuildInvitesDi
Invite History
- +
{tokenElements}
diff --git a/src/client/webapp/elements/overlay-token-log.tsx b/src/client/webapp/elements/overlay-token-log.tsx index 53b1936..f77dc59 100644 --- a/src/client/webapp/elements/overlay-token-log.tsx +++ b/src/client/webapp/elements/overlay-token-log.tsx @@ -32,7 +32,7 @@ export default function createTokenLogOverlay(document: Document, q: Q, guild: C // if (token.member != null) { // continue; // } - const expired = token.expires < new Date(); + const expired = token.expires && token.expires < new Date(); // NOTE: It may be nice to be able to click-to-copy the token let memberText: string; if (token.member instanceof Member) { diff --git a/src/client/webapp/elements/require/guild-subscriptions.ts b/src/client/webapp/elements/require/guild-subscriptions.ts index f301d4c..2a552d3 100644 --- a/src/client/webapp/elements/require/guild-subscriptions.ts +++ b/src/client/webapp/elements/require/guild-subscriptions.ts @@ -3,7 +3,7 @@ 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 { Changes, GuildMetadata, Resource } from "../../data-types"; import CombinedGuild from "../../guild-combined"; import React, { DependencyList, useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -11,14 +11,24 @@ import { AutoVerifierChangesType } from "../../auto-verifier"; import { Conflictable, Connectable } from "../../guild-types"; import { EventEmitter } from 'tsee'; import { IDQuery } from '../../auto-verifier-with-args'; +import { Token } from '../../data-types'; -export type SubscriptionEvents = { +export type SingleSubscriptionEvents = { 'fetch': () => void; - 'update': () => void; + 'updated': () => void; 'conflict': () => void; 'fetch-error': () => void; } +export type MultipleSubscriptionEvents = { + 'fetch': () => void; + 'fetch-error': () => void; + 'new': (newValues: T[]) => void; + 'updated': (updatedValues: T[]) => void; + 'removed': (removedValues: T[]) => void; + 'conflict': (changes: Changes) => void; +} + interface EffectParams { guild: CombinedGuild; onFetch: (value: T | null) => void; @@ -29,11 +39,31 @@ interface EffectParams { 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 +interface SingleEventMappingParams { + updatedEventName: UE; + updatedEventArgsMap: (...args: Arguments) => T; conflictEventName: CE; - conflictEventArgsMap: (...args: Arguments) => T; // should be the same as the params list from Conflictable + conflictEventArgsMap: (...args: Arguments) => T; +} + +interface MultipleEventMappingParams< + T, + NE extends keyof Connectable, + UE extends keyof Connectable, + RE extends keyof Connectable, + CE extends keyof Conflictable +> { + newEventName: NE; + newEventArgsMap: (...args: Arguments) => T[]; // list of new elements + updatedEventName: UE; + updatedEventArgsMap: (...args: Arguments) => T[]; // list of updated elements + removedEventName: RE; + removedEventArgsMap: (...args: Arguments) => T[]; // list of removed elements + + conflictEventName: CE; + conflictEventArgsMap: (...args: Arguments) => Changes; + + sortFunc: (a: T, b: T) => number; } export default class GuildSubscriptions { @@ -79,16 +109,19 @@ export default class GuildSubscriptions { } private static useSingleGuildSubscription( - guild: CombinedGuild, eventMappingParams: EventMappingParams, fetchFunc: (() => Promise) | (() => Promise), fetchDeps: DependencyList - ): [value: T | null, fetchError: unknown | null, events: EventEmitter] { - const { updateEventName, updateEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams; + guild: CombinedGuild, + eventMappingParams: SingleEventMappingParams, + fetchFunc: (() => Promise) | (() => Promise), + fetchDeps: DependencyList + ): [value: T | null, fetchError: unknown | null, events: EventEmitter] { + const { updatedEventName, updatedEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams; const isMountedRef = useRef(false); const [ fetchError, setFetchError ] = useState(null); const [ value, setValue ] = useState(null); - const events = useMemo(() => new EventEmitter(), []); + const events = useMemo(() => new EventEmitter(), []); const onFetch = useCallback((fetchValue: T | null) => { setValue(fetchValue); @@ -102,9 +135,9 @@ export default class GuildSubscriptions { events.emit('fetch-error'); }, []); - const onUpdate = useCallback((updateValue: T) => { + const onUpdated = useCallback((updateValue: T) => { setValue(updateValue); - events.emit('update'); + events.emit('updated'); }, []); const onConflict = useCallback((conflictValue: T) => { @@ -116,8 +149,8 @@ export default class GuildSubscriptions { // otherwise, I may have done this wrong. Forcing it to work with these calls const boundUpdateFunc = useCallback((...args: Arguments): void => { if (!isMountedRef.current) return; - const value = updateEventArgsMap(...args); - onUpdate(value); + const value = updatedEventArgsMap(...args); + onUpdated(value); }, []) as (Connectable & Conflictable)[UE]; const boundConflictFunc = useCallback((...args: Arguments): void => { if (!isMountedRef.current) return; @@ -126,11 +159,129 @@ export default class GuildSubscriptions { }, []) as (Connectable & Conflictable)[CE]; const bindEventsFunc = useCallback(() => { - guild.on(updateEventName, boundUpdateFunc); + guild.on(updatedEventName, boundUpdateFunc); guild.on(conflictEventName, boundConflictFunc); }, []); const unbindEventsFunc = useCallback(() => { - guild.off(updateEventName, boundUpdateFunc); + guild.off(updatedEventName, boundUpdateFunc); + guild.off(conflictEventName, boundConflictFunc); + }, []); + + GuildSubscriptions.useGuildSubscriptionEffect(isMountedRef, { + guild, + onFetch, + onFetchError, + bindEventsFunc, + unbindEventsFunc + }, fetchFunc, fetchDeps); + + return [ value, fetchError, events ]; + } + + private static useMultipleGuildSubscription< + T extends { id: string }, + NE extends keyof Connectable, + UE extends keyof Connectable, + RE extends keyof Connectable, + CE extends keyof Conflictable + >( + guild: CombinedGuild, + eventMappingParams: MultipleEventMappingParams, + fetchFunc: (() => Promise) | (() => Promise), + fetchDeps: DependencyList + ): [value: T[] | null, fetchError: unknown | null, events: EventEmitter>] { + const { + newEventName, newEventArgsMap, + updatedEventName, updatedEventArgsMap, + removedEventName, removedEventArgsMap, + conflictEventName, conflictEventArgsMap, + sortFunc + } = eventMappingParams; + + const isMountedRef = useRef(false); + + 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 onNew = useCallback((newElements: T[]) => { + setValue(currentValue => { + if (currentValue === null) return null; + return currentValue.concat(newElements).sort(sortFunc); + }) + events.emit('new', newElements); + }, []); + const onUpdated = useCallback((updatedElements: T[]) => { + setValue(currentValue => { + if (currentValue === null) return null; + return currentValue.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc); + }); + events.emit('updated', updatedElements); + }, []); + const onRemoved = useCallback((removedElements: T[]) => { + 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); + }); + events.emit('removed', removedElements); + }, []); + + const onConflict = useCallback((changes: Changes) => { + 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); + }); + events.emit('conflict', changes); + }, []); + + // 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 (!isMountedRef.current) return; + onNew(newEventArgsMap(...args)); + }, []) as (Connectable & Conflictable)[NE]; + const boundUpdateFunc = useCallback((...args: Arguments): void => { + if (!isMountedRef.current) return; + onUpdated(updatedEventArgsMap(...args)); + }, []) as (Connectable & Conflictable)[UE]; + const boundRemovedFunc = useCallback((...args: Arguments): void => { + if (!isMountedRef.current) return; + onRemoved(removedEventArgsMap(...args)); + }, []) as (Connectable & Conflictable)[RE]; + const boundConflictFunc = useCallback((...args: Arguments): void => { + if (!isMountedRef.current) return; + onConflict(conflictEventArgsMap(...args)); + }, []) as (Connectable & Conflictable)[CE]; + + const bindEventsFunc = useCallback(() => { + guild.on(newEventName, boundNewFunc); + guild.on(updatedEventName, boundUpdateFunc); + guild.on(removedEventName, boundRemovedFunc); + guild.on(conflictEventName, boundConflictFunc); + }, []); + const unbindEventsFunc = useCallback(() => { + guild.off(newEventName, boundNewFunc); + guild.off(updatedEventName, boundUpdateFunc); + guild.off(removedEventName, boundRemovedFunc); guild.off(conflictEventName, boundConflictFunc); }, []); @@ -147,17 +298,19 @@ export default class GuildSubscriptions { static useGuildMetadataSubscription(guild: CombinedGuild) { return GuildSubscriptions.useSingleGuildSubscription(guild, { - updateEventName: 'update-metadata', - updateEventArgsMap: (guildMeta: GuildMetadata) => guildMeta, + updatedEventName: 'update-metadata', + updatedEventArgsMap: (guildMeta: GuildMetadata) => guildMeta, conflictEventName: 'conflict-metadata', conflictEventArgsMap: (changesType: AutoVerifierChangesType, oldGuildMeta: GuildMetadata, newGuildMeta: GuildMetadata) => newGuildMeta - }, async () => await guild.fetchMetadata(), [ guild ]); + }, async () => { + return await guild.fetchMetadata() + }, [ guild ]); } static useResourceSubscription(guild: CombinedGuild, resourceId: string | null) { return GuildSubscriptions.useSingleGuildSubscription(guild, { - updateEventName: 'update-resource', - updateEventArgsMap: (resource: Resource) => resource, + updatedEventName: 'update-resource', + updatedEventArgsMap: (resource: Resource) => resource, conflictEventName: 'conflict-resource', conflictEventArgsMap: (query: IDQuery, changesType: AutoVerifierChangesType, oldResource: Resource, newResource: Resource) => newResource }, async () => { @@ -165,4 +318,20 @@ export default class GuildSubscriptions { return await guild.fetchResource(resourceId); }, [ guild, resourceId ]); } + + static useTokensSubscription(guild: CombinedGuild) { + return GuildSubscriptions.useMultipleGuildSubscription(guild, { + newEventName: 'new-tokens', + newEventArgsMap: (tokens: Token[]) => tokens, + updatedEventName: 'update-tokens', + updatedEventArgsMap: (updatedTokens: Token[]) => updatedTokens, + removedEventName: 'remove-tokens', + removedEventArgsMap: (removedTokens: Token[]) => removedTokens, + conflictEventName: 'conflict-tokens', + conflictEventArgsMap: (changesType: AutoVerifierChangesType, changes: Changes) => changes, + sortFunc: Token.sortFurthestExpiresFirst + }, async () => { + return await guild.fetchTokens(); + }, [ guild ]); + } } diff --git a/src/client/webapp/guild-types.ts b/src/client/webapp/guild-types.ts index 3cac066..09cbab2 100644 --- a/src/client/webapp/guild-types.ts +++ b/src/client/webapp/guild-types.ts @@ -131,7 +131,10 @@ export type Connectable = { // TODO: Implement these in server/combined guild 'update-resource': (updatedResource: Resource) => void; - 'remove-resource': (removedResource: Resource) => void; + + 'new-tokens': (tokens: Token[]) => void; + 'update-tokens': (updatedTokens: Token[]) => void; + 'remove-tokens': (removedTokens: Token[]) => void; } // A Conflictable could emit conflict-based events if data changed based on verification @@ -165,4 +168,4 @@ export const GuildEventNames = [ 'conflict-messages', 'conflict-tokens', 'conflict-resource', -] +];