useSubscription techniques for react

This commit is contained in:
Michael Peters 2021-12-12 22:01:30 -06:00
parent b20943213c
commit 2ef1af3eff
16 changed files with 406 additions and 285 deletions

View File

@ -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<InvitesTableProps> = (props: InvitesTableProps) => {
const { guild } = props;
const fetchData = useCallback(async () => await guild.fetchTokens(), [ guild ]);
const header = (
<tr>
<th>Created</th>
<th>Expires</th>
<th>Actions</th>
</tr>
);
const mapToRow = useCallback((token: Token) => {
return (
<tr key={token.token}>
<td>{moment(token.created).fromNow()}</td>
<td>{moment(token.expires).fromNow()}</td>
<td className="actions">
<Button colorType={ButtonColorType.POSITIVE}>Save</Button>
<Button colorType={ButtonColorType.NEGATIVE}>Delete</Button>
</td>
</tr>
);
}, []);
const [ tokens, setTokens ] = useState<Token[] | null>(null);
const [ tokensFailed, setTokensFailed ] = useState<boolean>(false);
return <Table header={header} fetchData={fetchData} mapToRow={mapToRow} />
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

@ -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<GuildInvitesDisplayProps> = (props: GuildInvitesDi
const url = 'https://localhost:3030'; // TODO: this will likely be a dropdown list at some point
const [ guildName, setGuildName ] = useState<string | null>(null);
const [ iconResourceId, setIconResourceId ] = useState<string | null>(null);
const [ iconSrc, setIconSrc ] = useState<string | null>(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<Duration | null>(moment.duration(1, 'day'));
const [ expiresFromNowText, setExpiresFromNowText ] = useState<string>('1 day');
const [ guildMetaFailed, setGuildMetaFailed ] = useState<boolean>(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<GuildInvitesDisplayProps> = (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 (
<Display
@ -82,14 +70,14 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
<div><Button>Create Invite</Button></div>
</div>
<InvitePreview
name={guildName ?? ''} iconSrc={iconSrc}
name={guildMeta?.name ?? ''} iconSrc={iconSrc}
url={url} expiresFromNow={expiresFromNow}
/>
</div>
</div>
<div className="divider" />
<div className="view-invites">
<div className="title">Active Invites</div>
<div className="title">Invite History</div>
<InvitesTable guild={guild} />
</div>
</div>

View File

@ -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<React.SetStateAction<string>>; // to allow overlay title to update
}
const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOverviewDisplayProps) => {
const { guild, setContainerGuildName } = props;
const { guild } = props;
const [ iconResourceId, setIconResourceId ] = useState<string | null>(null);
const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild);
const [ iconResource, iconResourceError ] = GuildSubscriptions.useResourceSubscription(guild, guildMeta?.iconResourceId ?? null);
const [ savedName, setSavedName ] = useState<string | null>(null);
const [ savedIconBuff, setSavedIconBuff ] = useState<Buffer | null>(null);
@ -29,9 +30,6 @@ const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOvervie
const [ iconBuff, setIconBuff ] = useState<Buffer | null>(null);
const [ saving, setSaving ] = useState<boolean>(false);
const [ guildMetaFailed, setGuildMetaFailed ] = useState<boolean>(false);
const [ iconFailed, setIconFailed ] = useState<boolean>(false);
const [ saveFailed, setSaveFailed ] = useState<boolean>(false);
const [ nameInputValid, setNameInputValid ] = useState<boolean>(false);
@ -40,42 +38,31 @@ const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOvervie
const [ iconInputValid, setIconInputValid ] = useState<boolean>(false);
const [ iconInputMessage, setIconInputMessage ] = useState<string | null>(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('<unknown>');
}
})
}, [ 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<GuildOverviewDisplayProps> = (props: GuildOvervie
// Save name
try {
await guild.requestSetGuildName(name);
setContainerGuildName(name);
setSavedName(name);
} catch (e: unknown) {
LOG.error('error setting guild name', e);

View File

@ -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<AddGuildOverlayProps> = (props: AddGuildOverlayProps)
const [ displayName, setDisplayName ] = useState<string>(exampleDisplayName);
const [ avatarBuff, setAvatarBuff ] = useState<Buffer | null>(null);
const [ exampleAvatarFailed, setExampleAvatarFailed ] = useState<boolean>(false);
const [ addGuildFailedMessage, setAddGuildFailedMessage ] = useState<string | null>(null);
const [ displayNameInputMessage, setDisplayNameInputMessage ] = useState<string | null>(null);
@ -80,19 +80,27 @@ const AddGuildOverlay: FC<AddGuildOverlayProps> = (props: AddGuildOverlayProps)
const [ avatarInputMessage, setAvatarInputMessage ] = useState<string | null>(null);
const [ avatarInputValid, setAvatarInputValid ] = useState<boolean>(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<boolean> => {
if (!displayNameInputValid || !avatarInputValid || avatarBuff === null) {

View File

@ -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<GuildSettingsOverlayProps> = (props: GuildSettingsOverlayProps) => {
const { guild } = props;
const [ guildName, setGuildName ] = useState<string>('');
const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild);
const [ selectedId, setSelectedId ] = useState<string>('overview');
const [ display, setDisplay ] = useState<JSX.Element>();
useEffect(() => {
if (selectedId === 'overview') setDisplay(<GuildOverviewDisplay guild={guild} setContainerGuildName={setGuildName} />);
if (selectedId === 'overview') setDisplay(<GuildOverviewDisplay guild={guild} />);
//if (selectedId === 'channels') setDisplay(<GuildOverviewDisplay guild={guild} guildMeta={guildMeta} />);
//if (selectedId === 'roles' ) setDisplay(<GuildOverviewDisplay guild={guild} guildMeta={guildMeta} />);
if (selectedId === 'invites' ) setDisplay(<GuildInvitesDisplay guild={guild} />);
}, [ selectedId ]);
const guildNameText = useMemo(() => {
return guildMetaError ? 'metadata error' : guildMeta?.name ?? 'loading...';
}, [ guildMeta, guildMetaError ])
return (
<div className="content display-swapper guild-settings">
<ChoicesControl title={guildName} selectedId={selectedId} setSelectedId={setSelectedId} choices={[
<ChoicesControl title={guildNameText} selectedId={selectedId} setSelectedId={setSelectedId} choices={[
{ id: 'overview', display: 'Overview' },
{ id: 'channels', display: 'Channels' },
{ id: 'roles', display: 'Roles' },

View File

@ -13,6 +13,7 @@ import DownloadButton from '../components/button-download';
import createImageContextMenu from '../context-menu-img';
import Q from '../../q-module';
import ReactHelper from '../require/react-helper';
import GuildSubscriptions from '../require/guild-subscriptions';
export interface ImageOverlayProps {
guild: CombinedGuild
@ -23,44 +24,28 @@ export interface ImageOverlayProps {
const ImageOverlay: FC<ImageOverlayProps> = (props: ImageOverlayProps) => {
const { guild, resourceId, resourceName } = props;
const [ resource, setResource ] = useState<Resource | null>(null);
const [ resourceImgSrc, setResourceImgSrc ] = useState<string>('./img/loading.svg');
const [ resourceErr, setResourceErr ] = useState<boolean>(false);
const [ mime, setMime ] = useState<string | null>(null);
const [ ext, setExt ] = useState<string | null>(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<ImageOverlayProps> = (props: ImageOverlayProps) => {
<div className="size">{sizeText}</div>
</div>
<DownloadButton
downloadBuff={resource?.data} downloadBuffErr={resourceErr}
downloadBuff={resource?.data} downloadBuffErr={!!resourceError}
resourceName={resourceName}></DownloadButton>
</div>
</div>

View File

@ -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<PersonalizeOverlayProps> = (props: PersonalizeOverl
throw new Error('bad avatar');
}
const avatarResourceId = connection.avatarResourceId;
const [ avatarResource, avatarResourceError ] = GuildSubscriptions.useResourceSubscription(guild, connection.avatarResourceId)
const displayNameInputRef = createRef<HTMLInputElement>();
@ -40,7 +41,6 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
const [ displayName, setDisplayName ] = useState<string>(connection.displayName);
const [ avatarBuff, setAvatarBuff ] = useState<Buffer | null>(null);
const [ loadAvatarFailed, setLoadAvatarFailed ] = useState<boolean>(false);
const [ saveFailed, setSaveFailed ] = useState<boolean>(false);
const [ displayNameInputValid, setDisplayNameInputValid ] = useState<boolean>(false);
@ -49,27 +49,24 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
const [ avatarInputValid, setAvatarInputValid ] = useState<boolean>(false);
const [ avatarInputMessage, setAvatarInputMessage ] = useState<string | null>(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<PersonalizeOverlayProps> = (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;
}

View File

@ -143,6 +143,15 @@ export default class BaseElements {
4.00,7.10 4.90,8.00 6.00,8.00 Z` }
};
static TRASHCAN = (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 64 64">
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd"
d="M 11 12 C 9.338 12 8 13.338 8 15 L 8 61 C 8 62.662 9.338 64 11 64 L 53 64 C 54.662 64 56 62.662 56 61 L 56 15 C 56 13.338 54.662 12 53 12 L 11 12 z M 17 18 C 18.662 18 20 19.338 20 21 L 20 55 C 20 56.662 18.662 58 17 58 C 15.338 58 14 56.662 14 55 L 14 21 C 14 19.338 15.338 18 17 18 z M 32 18 C 33.662 18 35 19.338 35 21 L 35 55 C 35 56.662 33.662 58 32 58 C 30.338 58 29 56.662 29 55 L 29 21 C 29 19.338 30.338 18 32 18 z M 47 18 C 48.662 18 50 19.338 50 21 L 50 55 C 50 56.662 48.662 58 47 58 C 45.338 58 44 56.662 44 55 L 44 21 C 44 19.338 45.338 18 47 18 z"
></path>
<rect fill="currentColor" fillRule="evenodd" clipRule="evenodd" width="56" height="6" x="4" y="3" rx="3" ry="3" />
<rect fill="currentColor" fillRule="evenodd" clipRule="evenodd" width="16" height="6" x="24" y="0" rx="3" ry="3" />
</svg>
);
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 = (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 64 64">
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd"
d="M 32 0 A 32 32 0 0 0 0 32 A 32 32 0 0 0 32 64 A 32 32 0 0 0 64 32 A 32 32 0 0 0 32 0 z M 27 11.5 L 37 11.5 L 37 39.5 L 46 39.5 L 32 52.5 L 18 39.5 L 27 39.5 L 27 11.5 z"
></path>
</svg>
);
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',

View File

@ -135,7 +135,10 @@ export default class ElementsUtil {
}
}
static async getImageSrcFromBufferFailSoftly(buff: Buffer): Promise<string> {
static async getImageSrcFromBufferFailSoftly(buff: Buffer | null): Promise<string> {
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<string> {
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);

View File

@ -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<T> {
guild: CombinedGuild;
onFetch: (value: T | null) => void;
onUpdate: (value: T) => void;
onConflict: (value: T) => void;
onFetchError: (e: unknown) => void;
}
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
conflictEventName: CE;
conflictEventArgsMap: (...args: Arguments<Conflictable[CE]>) => T; // should be the same as the params list from Conflictable
fetchDeps: DependencyList;
}
export default class GuildSubscriptions {
private static useGuildSubscriptionEffect<T, UE extends keyof Connectable, CE extends keyof Conflictable>(
subscriptionParams: EffectParams<T>, eventMappingParams: EventMappingParams<T, UE, CE>, fetchFunc: (() => Promise<T>) | (() => Promise<T | null>)
) {
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<Connectable[UE]>): 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<Conflictable[CE]>): 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<T, UE extends keyof Connectable, CE extends keyof Conflictable>(
guild: CombinedGuild, eventMappingParams: EventMappingParams<T, UE, CE>, fetchFunc: (() => Promise<T>) | (() => Promise<T | null>)
): [value: T | null, fetchError: unknown | null, events: EventEmitter<SubscriptionEvents>] {
const [ fetchError, setFetchError ] = useState<unknown | null>(null);
const [ value, setValue ] = useState<T | null>(null);
const events = useMemo(() => new EventEmitter<SubscriptionEvents>(), []);
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<GuildMetadata, 'update-metadata', 'conflict-metadata'>(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<Resource, 'update-resource', 'conflict-resource'>(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);
});
}
}

View File

@ -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<T>(params: {
static useAsyncActionSubscription<T, V>(
actionFunc: () => Promise<T>,
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<T | V>(initialValue);
const [ error, setError ] = useState<unknown | null>(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 ];
}
}

View File

@ -319,7 +319,12 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
return await this.fetchable.fetchResource(resourceId);
}
async fetchTokens(): Promise<Token[]> {
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

View File

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

View File

@ -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;
}

View File

@ -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;
}
}
}
}