tokens subscription

This commit is contained in:
Michael Peters 2021-12-12 23:25:23 -06:00
parent 2a79af8a0e
commit 1c1d3209bf
6 changed files with 242 additions and 93 deletions

View File

@ -332,7 +332,7 @@ export class Token implements WithEquals<Token> {
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<Token> {
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 {

View File

@ -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<InvitesTableProps> = (props: InvitesTableProps) => {
const { guild } = props;
const [ tokens, setTokens ] = useState<Token[] | null>(null);
const [ tokensFailed, setTokensFailed ] = useState<boolean>(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 <div className="tokens-failed">Unable to load tokens</div>;
}
return tokens?.map((token: Token) => {
const userText = (token.member instanceof Member ? 'Used by ' + token.member.displayName : token.member?.id) ?? 'Unused Token';
return (
<div key={token.token} className="token-row">
<div className="user-token">
<div className="user">{userText}</div>
<div className="token">{token.token}</div>
</div>
<div className="created-expires">
<div className="created">Created {moment(token.created).fromNow()}</div>
<div className="expires">Expires {moment(token.expires).fromNow()}</div>
</div>
<div className="actions">
<Button colorType={ButtonColorType.BRAND}>{BaseElements.DOWNLOAD}</Button>
<Button colorType={ButtonColorType.NEGATIVE}>{BaseElements.TRASHCAN}</Button>
</div>
</div>
);
});
}, [ tokens, tokensFailed ]);
return <div className="invites-table">{tokenElements}</div>
}
export default InvitesTable;

View File

@ -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<GuildInvitesDisplayProps> = (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<GuildInvitesDisplayProps> = (props: GuildInvitesDi
return null;
}, [ guildMetaError ]);
const tokenElements = useMemo(() => {
if (tokensError) {
return <div className="tokens-failed">Unable to load tokens</div>;
}
return tokens?.map((token: Token) => {
const userText = (token.member instanceof Member ? 'Used by ' + token.member.displayName : token.member?.id) ?? 'Unused Token';
return (
<div key={token.token} className="token-row">
<div className="user-token">
<div className="user">{userText}</div>
<div className="token">{token.token}</div>
</div>
<div className="created-expires">
<div className="created">Created {moment(token.created).fromNow()}</div>
<div className="expires">Expires {moment(token.expires).fromNow()}</div>
</div>
<div className="actions">
<Button colorType={ButtonColorType.BRAND}>{BaseElements.DOWNLOAD}</Button>
<Button colorType={ButtonColorType.NEGATIVE}>{BaseElements.TRASHCAN}</Button>
</div>
</div>
);
});
}, [ tokens, tokensError ]);
return (
<Display
infoMessage={null} errorMessage={errorMessage}
@ -78,7 +105,7 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
<div className="divider" />
<div className="view-invites">
<div className="title">Invite History</div>
<InvitesTable guild={guild} />
<div className="invites-table">{tokenElements}</div>
</div>
</div>
</Display>

View File

@ -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) {

View File

@ -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<T> = {
'fetch': () => void;
'fetch-error': () => void;
'new': (newValues: T[]) => void;
'updated': (updatedValues: T[]) => void;
'removed': (removedValues: T[]) => void;
'conflict': (changes: Changes<T>) => void;
}
interface EffectParams<T> {
guild: CombinedGuild;
onFetch: (value: T | null) => void;
@ -29,11 +39,31 @@ interface EffectParams<T> {
type Arguments<T> = T extends (...args: infer A) => unknown ? A : never;
interface EventMappingParams<T, UE extends keyof Connectable, CE extends keyof Conflictable> {
updateEventName: UE;
updateEventArgsMap: (...args: Arguments<Connectable[UE]>) => T; // should be same as the params list from Connectable
interface SingleEventMappingParams<T, UE extends keyof Connectable, CE extends keyof Conflictable> {
updatedEventName: UE;
updatedEventArgsMap: (...args: Arguments<Connectable[UE]>) => T;
conflictEventName: CE;
conflictEventArgsMap: (...args: Arguments<Conflictable[CE]>) => T; // should be the same as the params list from Conflictable
conflictEventArgsMap: (...args: Arguments<Conflictable[CE]>) => 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<Connectable[NE]>) => T[]; // list of new elements
updatedEventName: UE;
updatedEventArgsMap: (...args: Arguments<Connectable[UE]>) => T[]; // list of updated elements
removedEventName: RE;
removedEventArgsMap: (...args: Arguments<Connectable[RE]>) => T[]; // list of removed elements
conflictEventName: CE;
conflictEventArgsMap: (...args: Arguments<Conflictable[CE]>) => Changes<T>;
sortFunc: (a: T, b: T) => number;
}
export default class GuildSubscriptions {
@ -79,16 +109,19 @@ export default class GuildSubscriptions {
}
private static useSingleGuildSubscription<T, UE extends keyof Connectable, CE extends keyof Conflictable>(
guild: CombinedGuild, eventMappingParams: EventMappingParams<T, UE, CE>, fetchFunc: (() => Promise<T>) | (() => Promise<T | null>), fetchDeps: DependencyList
): [value: T | null, fetchError: unknown | null, events: EventEmitter<SubscriptionEvents>] {
const { updateEventName, updateEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams;
guild: CombinedGuild,
eventMappingParams: SingleEventMappingParams<T, UE, CE>,
fetchFunc: (() => Promise<T>) | (() => Promise<T | null>),
fetchDeps: DependencyList
): [value: T | null, fetchError: unknown | null, events: EventEmitter<SingleSubscriptionEvents>] {
const { updatedEventName, updatedEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams;
const isMountedRef = useRef<boolean>(false);
const [ fetchError, setFetchError ] = useState<unknown | null>(null);
const [ value, setValue ] = useState<T | null>(null);
const events = useMemo(() => new EventEmitter<SubscriptionEvents>(), []);
const events = useMemo(() => new EventEmitter<SingleSubscriptionEvents>(), []);
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<Connectable[UE]>): 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<Conflictable[CE]>): 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<T, NE, UE, RE, CE>,
fetchFunc: (() => Promise<T[]>) | (() => Promise<T[] | null>),
fetchDeps: DependencyList
): [value: T[] | null, fetchError: unknown | null, events: EventEmitter<MultipleSubscriptionEvents<T>>] {
const {
newEventName, newEventArgsMap,
updatedEventName, updatedEventArgsMap,
removedEventName, removedEventArgsMap,
conflictEventName, conflictEventArgsMap,
sortFunc
} = eventMappingParams;
const isMountedRef = useRef<boolean>(false);
const [ fetchError, setFetchError ] = useState<unknown | null>(null);
const [ value, setValue ] = useState<T[] | null>(null);
const events = useMemo(() => new EventEmitter<MultipleSubscriptionEvents<T>>(), []);
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<T>) => {
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<Connectable[NE]>): void => {
if (!isMountedRef.current) return;
onNew(newEventArgsMap(...args));
}, []) as (Connectable & Conflictable)[NE];
const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => {
if (!isMountedRef.current) return;
onUpdated(updatedEventArgsMap(...args));
}, []) as (Connectable & Conflictable)[UE];
const boundRemovedFunc = useCallback((...args: Arguments<Connectable[RE]>): void => {
if (!isMountedRef.current) return;
onRemoved(removedEventArgsMap(...args));
}, []) as (Connectable & Conflictable)[RE];
const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): 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<GuildMetadata, 'update-metadata', 'conflict-metadata'>(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<Resource, 'update-resource', 'conflict-resource'>(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<Token, 'new-tokens', 'update-tokens', 'remove-tokens', 'conflict-tokens'>(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<Token>) => changes,
sortFunc: Token.sortFurthestExpiresFirst
}, async () => {
return await guild.fetchTokens();
}, [ guild ]);
}
}

View File

@ -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',
]
];