infinite scroll messages!
This commit is contained in:
parent
69c239ebc9
commit
49f3a7096a
@ -1,4 +1,4 @@
|
|||||||
import React, { FC, Ref, useCallback, useMemo } from 'react';
|
import React, { FC, ReactNode, Ref, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
export enum ButtonColorType {
|
export enum ButtonColorType {
|
||||||
BRAND = '',
|
BRAND = '',
|
||||||
@ -14,7 +14,7 @@ interface ButtonProps {
|
|||||||
|
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
shaking?: boolean;
|
shaking?: boolean;
|
||||||
children?: React.ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultButtonProps = {
|
const DefaultButtonProps = {
|
||||||
|
67
src/client/webapp/elements/components/infinite-scroll.tsx
Normal file
67
src/client/webapp/elements/components/infinite-scroll.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import React, { Dispatch, FC, ReactNode, SetStateAction, useEffect } from 'react';
|
||||||
|
import ReactHelper from '../require/react-helper';
|
||||||
|
import Retry from './retry';
|
||||||
|
|
||||||
|
export interface InfiniteScrollProps {
|
||||||
|
fetchRetryCallable: () => Promise<void>;
|
||||||
|
fetchAboveCallable: () => Promise<{ hasMoreAbove: boolean, removedFromBottom: boolean }>;
|
||||||
|
fetchBelowCallable: () => Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }>;
|
||||||
|
setScrollRatio: Dispatch<SetStateAction<number>>;
|
||||||
|
fetchResult: { hasMoreAbove: boolean, hasMoreBelow: boolean } | null;
|
||||||
|
fetchError: unknown | null;
|
||||||
|
fetchAboveError: unknown | null;
|
||||||
|
fetchBelowError: unknown | null;
|
||||||
|
fetchErrorMessage: string;
|
||||||
|
fetchAboveErrorMessage: string;
|
||||||
|
fetchBelowErrorMessage: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implements convenient features such as 'try again' components
|
||||||
|
const InfiniteScroll: FC<InfiniteScrollProps> = (props: InfiniteScrollProps) => {
|
||||||
|
const {
|
||||||
|
fetchRetryCallable,
|
||||||
|
fetchAboveCallable,
|
||||||
|
fetchBelowCallable,
|
||||||
|
setScrollRatio,
|
||||||
|
fetchResult,
|
||||||
|
fetchError,
|
||||||
|
fetchAboveError,
|
||||||
|
fetchBelowError,
|
||||||
|
fetchErrorMessage,
|
||||||
|
fetchAboveErrorMessage,
|
||||||
|
fetchBelowErrorMessage,
|
||||||
|
children
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [
|
||||||
|
updateScrollCallable,
|
||||||
|
onLoadCallable,
|
||||||
|
fetchAboveRetry,
|
||||||
|
fetchBelowRetry
|
||||||
|
] = ReactHelper.useColumnReverseInfiniteScroll(
|
||||||
|
600,
|
||||||
|
fetchAboveCallable,
|
||||||
|
fetchBelowCallable,
|
||||||
|
setScrollRatio
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fetchResult) {
|
||||||
|
onLoadCallable(fetchResult)
|
||||||
|
}
|
||||||
|
}, [ fetchResult, onLoadCallable ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="infinite-scroll-scroll-base" onScroll={updateScrollCallable}>
|
||||||
|
<div className="infinite-scroll-elements">
|
||||||
|
<Retry error={fetchAboveError} text={fetchAboveErrorMessage} retryFunc={fetchAboveRetry} />
|
||||||
|
{children}
|
||||||
|
<Retry error={fetchError} text={fetchErrorMessage} retryFunc={fetchRetryCallable} />
|
||||||
|
<Retry error={fetchBelowError} text={fetchBelowErrorMessage} retryFunc={fetchBelowRetry} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfiniteScroll;
|
38
src/client/webapp/elements/components/retry.tsx
Normal file
38
src/client/webapp/elements/components/retry.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import * as electronRemote from '@electron/remote';
|
||||||
|
const electronConsole = electronRemote.getGlobal('console') as Console;
|
||||||
|
import Logger from '../../../../logger/logger';
|
||||||
|
const LOG = Logger.create(__filename, electronConsole);
|
||||||
|
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import ReactHelper from '../require/react-helper';
|
||||||
|
import Button from './button';
|
||||||
|
|
||||||
|
export interface RetryProps {
|
||||||
|
error: unknown | null,
|
||||||
|
text: string,
|
||||||
|
retryFunc: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const Retry: FC<RetryProps> = (props: RetryProps) => {
|
||||||
|
const { error, text, retryFunc } = props;
|
||||||
|
|
||||||
|
const [ retryCallable, buttonText, buttonShaking ] = ReactHelper.useAsyncSubmitButton(
|
||||||
|
async () => {
|
||||||
|
await retryFunc(); // error handled by effect
|
||||||
|
return { result: null, errorMessage: null };
|
||||||
|
},
|
||||||
|
[ retryFunc ],
|
||||||
|
{ start: 'Try Again', pending: 'Fetching...' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="retry">
|
||||||
|
<div className="text">{text}</div>
|
||||||
|
<Button onClick={retryCallable} shaking={buttonShaking}>{buttonText}</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Retry;
|
@ -26,7 +26,7 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
|
|||||||
|
|
||||||
const url = 'https://localhost:3030'; // TODO: this will likely be a dropdown list at some point
|
const url = 'https://localhost:3030'; // TODO: this will likely be a dropdown list at some point
|
||||||
|
|
||||||
const [ tokens, tokensError ] = GuildSubscriptions.useTokensSubscription(guild);
|
const [ fetchRetryCallable, tokens, tokensError ] = GuildSubscriptions.useTokensSubscription(guild);
|
||||||
|
|
||||||
const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild);
|
const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild);
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ export interface MemberListProps {
|
|||||||
const MemberList: FC<MemberListProps> = (props: MemberListProps) => {
|
const MemberList: FC<MemberListProps> = (props: MemberListProps) => {
|
||||||
const { guild } = props;
|
const { guild } = props;
|
||||||
|
|
||||||
const [ members, fetchError ] = GuildSubscriptions.useMembersSubscription(guild);
|
const [ fetchRetryCallable, members, fetchError ] = GuildSubscriptions.useMembersSubscription(guild);
|
||||||
|
|
||||||
const memberElements = useMemo(() => {
|
const memberElements = useMemo(() => {
|
||||||
if (fetchError) {
|
if (fetchError) {
|
||||||
|
@ -3,7 +3,8 @@
|
|||||||
$scrollbarBottom: 4px;
|
$scrollbarBottom: 4px;
|
||||||
$borderRadius: 8px;
|
$borderRadius: 8px;
|
||||||
|
|
||||||
.message-list-anchor {
|
.message-list {
|
||||||
|
.infinite-scroll-scroll-base {
|
||||||
/* https://stackoverflow.com/q/18614301 */
|
/* https://stackoverflow.com/q/18614301 */
|
||||||
/* to keep the scrollbar at the bottom by default */
|
/* to keep the scrollbar at the bottom by default */
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -15,7 +16,8 @@ $borderRadius: 8px;
|
|||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
||||||
.message-list {
|
.infinite-scroll-elements {
|
||||||
|
.message-react {
|
||||||
.date-spacer {
|
.date-spacer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -38,8 +40,10 @@ $borderRadius: 8px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-react:hover {
|
&:hover {
|
||||||
background-color: $background-message-hover;
|
background-color: $background-message-hover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -6,8 +6,9 @@ const LOG = Logger.create(__filename, electronConsole);
|
|||||||
import React, { FC, useMemo } from 'react';
|
import React, { FC, useMemo } from 'react';
|
||||||
import { Channel, Message } from '../../data-types';
|
import { Channel, Message } from '../../data-types';
|
||||||
import CombinedGuild from '../../guild-combined';
|
import CombinedGuild from '../../guild-combined';
|
||||||
import ReactHelper from '../require/react-helper';
|
|
||||||
import MessageElement from './components/message-element';
|
import MessageElement from './components/message-element';
|
||||||
|
import GuildSubscriptions from '../require/guild-subscriptions';
|
||||||
|
import InfiniteScroll from '../components/infinite-scroll';
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
guild: CombinedGuild;
|
guild: CombinedGuild;
|
||||||
@ -17,26 +18,17 @@ interface MessageListProps {
|
|||||||
const MessageList: FC<MessageListProps> = (props: MessageListProps) => {
|
const MessageList: FC<MessageListProps> = (props: MessageListProps) => {
|
||||||
const { guild, channel } = props;
|
const { guild, channel } = props;
|
||||||
|
|
||||||
// TODO: Infinite scroll
|
const [
|
||||||
const [ messages, messagesError ] = ReactHelper.useOneTimeAsyncAction(
|
fetchRetryCallable,
|
||||||
async () => {
|
fetchAboveCallable,
|
||||||
const result = await guild.fetchMessagesRecent(channel.id, 100);
|
fetchBelowCallable,
|
||||||
return result;
|
setScrollRatio,
|
||||||
},
|
fetchResult,
|
||||||
null,
|
messages,
|
||||||
[ guild, channel ]
|
messagesFetchError,
|
||||||
);
|
messagesFetchAboveError,
|
||||||
|
messagesFetchBelowError
|
||||||
const pendingElement = useMemo(() => {
|
] = GuildSubscriptions.useMessagesScrollingSubscription(guild, channel);
|
||||||
if (messages === null) {
|
|
||||||
if (messagesError === null) {
|
|
||||||
return <div className="loading">Loading Messages...</div>;
|
|
||||||
} else {
|
|
||||||
return <div className="error">Error loading messages...</div>; // TODO: retry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [ messages, messagesError ]);
|
|
||||||
|
|
||||||
const messageElements = useMemo(() => {
|
const messageElements = useMemo(() => {
|
||||||
const result = [];
|
const result = [];
|
||||||
@ -52,9 +44,21 @@ const MessageList: FC<MessageListProps> = (props: MessageListProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message-list">
|
<div className="message-list">
|
||||||
{pendingElement}
|
<InfiniteScroll
|
||||||
{messageElements}
|
fetchRetryCallable={fetchRetryCallable}
|
||||||
|
fetchAboveCallable={fetchAboveCallable}
|
||||||
|
fetchBelowCallable={fetchBelowCallable}
|
||||||
|
setScrollRatio={setScrollRatio}
|
||||||
|
fetchResult={fetchResult}
|
||||||
|
fetchError={messagesFetchError}
|
||||||
|
fetchAboveError={messagesFetchAboveError}
|
||||||
|
fetchBelowError={messagesFetchBelowError}
|
||||||
|
fetchErrorMessage="Unable to retrieve recent messages"
|
||||||
|
fetchAboveErrorMessage="Unable to retrieve messages above"
|
||||||
|
fetchBelowErrorMessage="Unable to retrieve messages below"
|
||||||
|
>{messageElements}</InfiniteScroll>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,15 +3,17 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
|
|||||||
import Logger from '../../../../logger/logger';
|
import Logger from '../../../../logger/logger';
|
||||||
const LOG = Logger.create(__filename, electronConsole);
|
const LOG = Logger.create(__filename, electronConsole);
|
||||||
|
|
||||||
import { Changes, GuildMetadata, Member, Resource } from "../../data-types";
|
import { Changes, GuildMetadata, Member, Message, Resource } from "../../data-types";
|
||||||
import CombinedGuild from "../../guild-combined";
|
import CombinedGuild from "../../guild-combined";
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { AutoVerifierChangesType } from "../../auto-verifier";
|
import { AutoVerifierChangesType } from "../../auto-verifier";
|
||||||
import { Conflictable, Connectable } from "../../guild-types";
|
import { Conflictable, Connectable } from "../../guild-types";
|
||||||
import { EventEmitter } from 'tsee';
|
import { EventEmitter } from 'tsee';
|
||||||
import { IDQuery } from '../../auto-verifier-with-args';
|
import { IDQuery, PartialMessageListQuery } from '../../auto-verifier-with-args';
|
||||||
import { Token, Channel } from '../../data-types';
|
import { Token, Channel } from '../../data-types';
|
||||||
|
import ReactHelper from './react-helper';
|
||||||
|
import Globals from '../../globals';
|
||||||
|
|
||||||
export type SingleSubscriptionEvents = {
|
export type SingleSubscriptionEvents = {
|
||||||
'fetch': () => void;
|
'fetch': () => void;
|
||||||
@ -63,35 +65,32 @@ interface MultipleEventMappingParams<
|
|||||||
conflictEventName: CE;
|
conflictEventName: CE;
|
||||||
conflictEventArgsMap: (...args: Arguments<Conflictable[CE]>) => Changes<T>;
|
conflictEventArgsMap: (...args: Arguments<Conflictable[CE]>) => Changes<T>;
|
||||||
|
|
||||||
sortFunc: (a: T, b: T) => number;
|
sortFunc: (a: T, b: T) => number; // Friendly reminder that v8 uses timsort so this is O(n) for pre-sorted stuff
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class GuildSubscriptions {
|
export default class GuildSubscriptions {
|
||||||
private static useGuildSubscriptionEffect<T>(
|
private static useGuildSubscriptionEffect<T>(
|
||||||
isMountedRef: React.MutableRefObject<boolean>,
|
|
||||||
subscriptionParams: EffectParams<T>,
|
subscriptionParams: EffectParams<T>,
|
||||||
fetchFunc: (() => Promise<T>) | (() => Promise<T | null>)
|
fetchFunc: (() => Promise<T>) | (() => Promise<T | null>)
|
||||||
) {
|
): [ fetchRetryCallable: () => Promise<void> ] {
|
||||||
const { guild, onFetch, onFetchError, bindEventsFunc, unbindEventsFunc } = subscriptionParams;
|
const { guild, onFetch, onFetchError, bindEventsFunc, unbindEventsFunc } = subscriptionParams;
|
||||||
|
|
||||||
const fetchManagerFunc = useMemo(() => {
|
const isMounted = ReactHelper.useIsMountedRef();
|
||||||
return async () => {
|
|
||||||
if (!isMountedRef.current) return;
|
const fetchManagerFunc = useCallback(async () => {
|
||||||
|
if (!isMounted.current) return;
|
||||||
try {
|
try {
|
||||||
const value = await fetchFunc();
|
const value = await fetchFunc();
|
||||||
if (!isMountedRef.current) return;
|
if (!isMounted.current) return;
|
||||||
onFetch(value);
|
onFetch(value);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
LOG.error('error fetching for subscription', e);
|
LOG.error('error fetching for subscription', e);
|
||||||
if (!isMountedRef.current) return;
|
if (!isMounted.current) return;
|
||||||
onFetchError(e);
|
onFetchError(e);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, [ fetchFunc ]);
|
}, [ fetchFunc ]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true;
|
|
||||||
|
|
||||||
// Bind guild events to make sure we have the most up to date information
|
// Bind guild events to make sure we have the most up to date information
|
||||||
guild.on('connect', fetchManagerFunc);
|
guild.on('connect', fetchManagerFunc);
|
||||||
bindEventsFunc();
|
bindEventsFunc();
|
||||||
@ -100,13 +99,13 @@ export default class GuildSubscriptions {
|
|||||||
fetchManagerFunc();
|
fetchManagerFunc();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMountedRef.current = false;
|
|
||||||
|
|
||||||
// Unbind the events so that we don't have any memory leaks
|
// Unbind the events so that we don't have any memory leaks
|
||||||
guild.off('connect', fetchManagerFunc);
|
guild.off('connect', fetchManagerFunc);
|
||||||
unbindEventsFunc();
|
unbindEventsFunc();
|
||||||
}
|
}
|
||||||
}, [ fetchManagerFunc ]);
|
}, [ fetchManagerFunc ]);
|
||||||
|
|
||||||
|
return [ fetchManagerFunc ];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static useSingleGuildSubscription<T, UE extends keyof Connectable, CE extends keyof Conflictable>(
|
private static useSingleGuildSubscription<T, UE extends keyof Connectable, CE extends keyof Conflictable>(
|
||||||
@ -116,7 +115,7 @@ export default class GuildSubscriptions {
|
|||||||
): [value: T | null, fetchError: unknown | null, events: EventEmitter<SingleSubscriptionEvents>] {
|
): [value: T | null, fetchError: unknown | null, events: EventEmitter<SingleSubscriptionEvents>] {
|
||||||
const { updatedEventName, updatedEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams;
|
const { updatedEventName, updatedEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams;
|
||||||
|
|
||||||
const isMountedRef = useRef<boolean>(false);
|
const isMounted = ReactHelper.useIsMountedRef();
|
||||||
|
|
||||||
const [ fetchError, setFetchError ] = useState<unknown | null>(null);
|
const [ fetchError, setFetchError ] = useState<unknown | null>(null);
|
||||||
const [ value, setValue ] = useState<T | null>(null);
|
const [ value, setValue ] = useState<T | null>(null);
|
||||||
@ -148,12 +147,12 @@ export default class GuildSubscriptions {
|
|||||||
// I think the typed EventEmitter class isn't ready for this level of insane type safety
|
// 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
|
// otherwise, I may have done this wrong. Forcing it to work with these calls
|
||||||
const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => {
|
const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => {
|
||||||
if (!isMountedRef.current) return;
|
if (!isMounted.current) return;
|
||||||
const value = updatedEventArgsMap(...args);
|
const value = updatedEventArgsMap(...args);
|
||||||
onUpdated(value);
|
onUpdated(value);
|
||||||
}, []) as (Connectable & Conflictable)[UE];
|
}, []) as (Connectable & Conflictable)[UE];
|
||||||
const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): void => {
|
const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): void => {
|
||||||
if (!isMountedRef.current) return;
|
if (!isMounted.current) return;
|
||||||
const value = conflictEventArgsMap(...args);
|
const value = conflictEventArgsMap(...args);
|
||||||
onConflict(value);
|
onConflict(value);
|
||||||
}, []) as (Connectable & Conflictable)[CE];
|
}, []) as (Connectable & Conflictable)[CE];
|
||||||
@ -167,7 +166,7 @@ export default class GuildSubscriptions {
|
|||||||
guild.off(conflictEventName, boundConflictFunc);
|
guild.off(conflictEventName, boundConflictFunc);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
GuildSubscriptions.useGuildSubscriptionEffect(isMountedRef, {
|
GuildSubscriptions.useGuildSubscriptionEffect({
|
||||||
guild,
|
guild,
|
||||||
onFetch,
|
onFetch,
|
||||||
onFetchError,
|
onFetchError,
|
||||||
@ -188,7 +187,12 @@ export default class GuildSubscriptions {
|
|||||||
guild: CombinedGuild,
|
guild: CombinedGuild,
|
||||||
eventMappingParams: MultipleEventMappingParams<T, NE, UE, RE, CE>,
|
eventMappingParams: MultipleEventMappingParams<T, NE, UE, RE, CE>,
|
||||||
fetchFunc: (() => Promise<T[]>) | (() => Promise<T[] | null>)
|
fetchFunc: (() => Promise<T[]>) | (() => Promise<T[] | null>)
|
||||||
): [value: T[] | null, fetchError: unknown | null, events: EventEmitter<MultipleSubscriptionEvents<T>>] {
|
): [
|
||||||
|
fetchRetryCallable: () => Promise<void>,
|
||||||
|
value: T[] | null,
|
||||||
|
fetchError: unknown | null,
|
||||||
|
events: EventEmitter<MultipleSubscriptionEvents<T>>
|
||||||
|
] {
|
||||||
const {
|
const {
|
||||||
newEventName, newEventArgsMap,
|
newEventName, newEventArgsMap,
|
||||||
updatedEventName, updatedEventArgsMap,
|
updatedEventName, updatedEventArgsMap,
|
||||||
@ -197,7 +201,7 @@ export default class GuildSubscriptions {
|
|||||||
sortFunc
|
sortFunc
|
||||||
} = eventMappingParams;
|
} = eventMappingParams;
|
||||||
|
|
||||||
const isMountedRef = useRef<boolean>(false);
|
const isMounted = ReactHelper.useIsMountedRef();
|
||||||
|
|
||||||
const [ fetchError, setFetchError ] = useState<unknown | null>(null);
|
const [ fetchError, setFetchError ] = useState<unknown | null>(null);
|
||||||
const [ value, setValue ] = useState<T[] | null>(null);
|
const [ value, setValue ] = useState<T[] | null>(null);
|
||||||
@ -209,7 +213,7 @@ export default class GuildSubscriptions {
|
|||||||
setValue(fetchValue);
|
setValue(fetchValue);
|
||||||
setFetchError(null);
|
setFetchError(null);
|
||||||
events.emit('fetch');
|
events.emit('fetch');
|
||||||
}, []);
|
}, [ sortFunc ]);
|
||||||
|
|
||||||
const onFetchError = useCallback((e: unknown) => {
|
const onFetchError = useCallback((e: unknown) => {
|
||||||
setFetchError(e);
|
setFetchError(e);
|
||||||
@ -223,14 +227,14 @@ export default class GuildSubscriptions {
|
|||||||
return currentValue.concat(newElements).sort(sortFunc);
|
return currentValue.concat(newElements).sort(sortFunc);
|
||||||
})
|
})
|
||||||
events.emit('new', newElements);
|
events.emit('new', newElements);
|
||||||
}, []);
|
}, [ sortFunc ]);
|
||||||
const onUpdated = useCallback((updatedElements: T[]) => {
|
const onUpdated = useCallback((updatedElements: T[]) => {
|
||||||
setValue(currentValue => {
|
setValue(currentValue => {
|
||||||
if (currentValue === null) return null;
|
if (currentValue === null) return null;
|
||||||
return currentValue.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc);
|
return currentValue.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc);
|
||||||
});
|
});
|
||||||
events.emit('updated', updatedElements);
|
events.emit('updated', updatedElements);
|
||||||
}, []);
|
}, [ sortFunc ]);
|
||||||
const onRemoved = useCallback((removedElements: T[]) => {
|
const onRemoved = useCallback((removedElements: T[]) => {
|
||||||
setValue(currentValue => {
|
setValue(currentValue => {
|
||||||
if (currentValue === null) return null;
|
if (currentValue === null) return null;
|
||||||
@ -238,7 +242,7 @@ export default class GuildSubscriptions {
|
|||||||
return currentValue.filter(element => !deletedIds.has(element.id)).sort(sortFunc);
|
return currentValue.filter(element => !deletedIds.has(element.id)).sort(sortFunc);
|
||||||
});
|
});
|
||||||
events.emit('removed', removedElements);
|
events.emit('removed', removedElements);
|
||||||
}, []);
|
}, [ sortFunc ]);
|
||||||
|
|
||||||
const onConflict = useCallback((changes: Changes<T>) => {
|
const onConflict = useCallback((changes: Changes<T>) => {
|
||||||
setValue(currentValue => {
|
setValue(currentValue => {
|
||||||
@ -251,41 +255,41 @@ export default class GuildSubscriptions {
|
|||||||
.sort(sortFunc);
|
.sort(sortFunc);
|
||||||
});
|
});
|
||||||
events.emit('conflict', changes);
|
events.emit('conflict', changes);
|
||||||
}, []);
|
}, [ sortFunc ]);
|
||||||
|
|
||||||
// I think the typed EventEmitter class isn't ready for this level of insane type safety
|
// 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
|
// otherwise, I may have done this wrong. Forcing it to work with these calls
|
||||||
const boundNewFunc = useCallback((...args: Arguments<Connectable[NE]>): void => {
|
const boundNewFunc = useCallback((...args: Arguments<Connectable[NE]>): void => {
|
||||||
if (!isMountedRef.current) return;
|
if (!isMounted.current) return;
|
||||||
onNew(newEventArgsMap(...args));
|
onNew(newEventArgsMap(...args));
|
||||||
}, []) as (Connectable & Conflictable)[NE];
|
}, [ onNew, newEventArgsMap ]) as (Connectable & Conflictable)[NE];
|
||||||
const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => {
|
const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => {
|
||||||
if (!isMountedRef.current) return;
|
if (!isMounted.current) return;
|
||||||
onUpdated(updatedEventArgsMap(...args));
|
onUpdated(updatedEventArgsMap(...args));
|
||||||
}, []) as (Connectable & Conflictable)[UE];
|
}, [ onUpdated, updatedEventArgsMap ]) as (Connectable & Conflictable)[UE];
|
||||||
const boundRemovedFunc = useCallback((...args: Arguments<Connectable[RE]>): void => {
|
const boundRemovedFunc = useCallback((...args: Arguments<Connectable[RE]>): void => {
|
||||||
if (!isMountedRef.current) return;
|
if (!isMounted.current) return;
|
||||||
onRemoved(removedEventArgsMap(...args));
|
onRemoved(removedEventArgsMap(...args));
|
||||||
}, []) as (Connectable & Conflictable)[RE];
|
}, [ onRemoved, removedEventArgsMap ]) as (Connectable & Conflictable)[RE];
|
||||||
const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): void => {
|
const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): void => {
|
||||||
if (!isMountedRef.current) return;
|
if (!isMounted.current) return;
|
||||||
onConflict(conflictEventArgsMap(...args));
|
onConflict(conflictEventArgsMap(...args));
|
||||||
}, []) as (Connectable & Conflictable)[CE];
|
}, [ onConflict, conflictEventArgsMap ]) as (Connectable & Conflictable)[CE];
|
||||||
|
|
||||||
const bindEventsFunc = useCallback(() => {
|
const bindEventsFunc = useCallback(() => {
|
||||||
guild.on(newEventName, boundNewFunc);
|
guild.on(newEventName, boundNewFunc);
|
||||||
guild.on(updatedEventName, boundUpdateFunc);
|
guild.on(updatedEventName, boundUpdateFunc);
|
||||||
guild.on(removedEventName, boundRemovedFunc);
|
guild.on(removedEventName, boundRemovedFunc);
|
||||||
guild.on(conflictEventName, boundConflictFunc);
|
guild.on(conflictEventName, boundConflictFunc);
|
||||||
}, []);
|
}, [ boundNewFunc, boundUpdateFunc, boundRemovedFunc, boundConflictFunc ]);
|
||||||
const unbindEventsFunc = useCallback(() => {
|
const unbindEventsFunc = useCallback(() => {
|
||||||
guild.off(newEventName, boundNewFunc);
|
guild.off(newEventName, boundNewFunc);
|
||||||
guild.off(updatedEventName, boundUpdateFunc);
|
guild.off(updatedEventName, boundUpdateFunc);
|
||||||
guild.off(removedEventName, boundRemovedFunc);
|
guild.off(removedEventName, boundRemovedFunc);
|
||||||
guild.off(conflictEventName, boundConflictFunc);
|
guild.off(conflictEventName, boundConflictFunc);
|
||||||
}, []);
|
}, [ boundNewFunc, boundUpdateFunc, boundRemovedFunc, boundConflictFunc ]);
|
||||||
|
|
||||||
GuildSubscriptions.useGuildSubscriptionEffect(isMountedRef, {
|
const [ fetchRetryCallable ] = GuildSubscriptions.useGuildSubscriptionEffect({
|
||||||
guild,
|
guild,
|
||||||
onFetch,
|
onFetch,
|
||||||
onFetchError,
|
onFetchError,
|
||||||
@ -293,7 +297,253 @@ export default class GuildSubscriptions {
|
|||||||
unbindEventsFunc
|
unbindEventsFunc
|
||||||
}, fetchFunc);
|
}, fetchFunc);
|
||||||
|
|
||||||
return [ value, fetchError, events ];
|
return [ fetchRetryCallable, value, fetchError, events ];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static useMultipleGuildSubscriptionScrolling<
|
||||||
|
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>,
|
||||||
|
maxElements: number,
|
||||||
|
maxFetchElements: number,
|
||||||
|
fetchFunc: (() => Promise<T[]>) | (() => Promise<T[] | null>),
|
||||||
|
fetchAboveFunc: ((reference: T) => Promise<T[] | null>),
|
||||||
|
fetchBelowFunc: ((reference: T) => Promise<T[] | null>),
|
||||||
|
): [
|
||||||
|
fetchRetryCallable: () => Promise<void>,
|
||||||
|
fetchAboveCallable: () => Promise<{ hasMoreAbove: boolean, removedFromBottom: boolean }>,
|
||||||
|
fetchBelowCallable: () => Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }>,
|
||||||
|
setScrollRatio: Dispatch<SetStateAction<number>>,
|
||||||
|
fetchResult: { hasMoreAbove: boolean, hasMoreBelow: boolean } | null,
|
||||||
|
value: T[] | null,
|
||||||
|
fetchError: unknown | null,
|
||||||
|
fetchAboveError: unknown | null,
|
||||||
|
fetchBelowError: unknown | null,
|
||||||
|
events: EventEmitter<MultipleSubscriptionEvents<T>>
|
||||||
|
] {
|
||||||
|
const {
|
||||||
|
newEventName, newEventArgsMap,
|
||||||
|
updatedEventName, updatedEventArgsMap,
|
||||||
|
removedEventName, removedEventArgsMap,
|
||||||
|
conflictEventName, conflictEventArgsMap,
|
||||||
|
sortFunc
|
||||||
|
} = eventMappingParams;
|
||||||
|
|
||||||
|
const isMounted = ReactHelper.useIsMountedRef();
|
||||||
|
|
||||||
|
const [ value, setValue ] = useState<T[] | null>(null);
|
||||||
|
|
||||||
|
const [ fetchError, setFetchError ] = useState<unknown | null>(null);
|
||||||
|
const [ fetchAboveError, setFetchAboveError ] = useState<unknown | null>(null);
|
||||||
|
const [ fetchBelowError, setFetchBelowError ] = useState<unknown | null>(null);
|
||||||
|
|
||||||
|
const [ fetchResult, setFetchResult ] = useState<{ hasMoreAbove: boolean, hasMoreBelow: boolean } | null>(null);
|
||||||
|
|
||||||
|
// Percentage of scroll from top. i.e. 300px from top of 1000px scroll = 0.3 scroll ratio
|
||||||
|
const [ scrollRatio, setScrollRatio ] = useState<number>(0.5);
|
||||||
|
|
||||||
|
// Gets the number of elements to remove from the top or bottom. Tries to optimise so that if 10 elements
|
||||||
|
// are removed and we're 30% scrolled down, 3 get removed from the top and 7 are removed from the bottom.
|
||||||
|
// TBH, this function is pretty overkill but that's important
|
||||||
|
const getRemoveCounts = useCallback((toRemove: number): { fromTop: number, fromBottom: number } => {
|
||||||
|
// Make sure we round toward the side of the scrollbar that we are not closest to
|
||||||
|
// this is important for the most common case of 1 removed element.
|
||||||
|
const topRoundFunc = scrollRatio > 0.5 ? Math.ceil : Math.floor;
|
||||||
|
const bottomRoundFunc = scrollRatio > 0.5 ? Math.floor : Math.ceil;
|
||||||
|
return {
|
||||||
|
fromTop: topRoundFunc(toRemove * scrollRatio),
|
||||||
|
fromBottom: bottomRoundFunc(toRemove * scrollRatio)
|
||||||
|
}
|
||||||
|
}, [ scrollRatio ]);
|
||||||
|
|
||||||
|
function removeByCounts<T>(elements: T[], counts: { fromTop: number, fromBottom: number }): T[] {
|
||||||
|
const { fromTop, fromBottom } = counts;
|
||||||
|
return elements.slice(fromTop, elements.length - fromBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = useMemo(() => new EventEmitter<MultipleSubscriptionEvents<T>>(), []);
|
||||||
|
|
||||||
|
const fetchAboveCallable = useCallback(async (): Promise<{ hasMoreAbove: boolean, removedFromBottom: boolean }> => {
|
||||||
|
if (!isMounted.current) return { hasMoreAbove: false, removedFromBottom: false };
|
||||||
|
if (!value || value.length === 0) return { hasMoreAbove: false, removedFromBottom: false };
|
||||||
|
try {
|
||||||
|
const reference = value[0] as T;
|
||||||
|
const aboveElements = await fetchAboveFunc(reference);
|
||||||
|
if (!isMounted.current) return { hasMoreAbove: false, removedFromBottom: false };
|
||||||
|
setFetchAboveError(null);
|
||||||
|
if (aboveElements) {
|
||||||
|
const hasMoreAbove = aboveElements.length >= maxFetchElements;
|
||||||
|
let removedFromBottom = false;
|
||||||
|
setValue(currentValue => {
|
||||||
|
let newValue = aboveElements.concat(currentValue ?? []).sort(sortFunc);
|
||||||
|
if (newValue.length > maxElements) {
|
||||||
|
newValue = newValue.slice(0, maxElements);
|
||||||
|
removedFromBottom = true;
|
||||||
|
}
|
||||||
|
return newValue;
|
||||||
|
});
|
||||||
|
return { hasMoreAbove, removedFromBottom };
|
||||||
|
} else {
|
||||||
|
return { hasMoreAbove: false, removedFromBottom: false };
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
LOG.error('error fetching above for subscription', e);
|
||||||
|
if (!isMounted.current) return { hasMoreAbove: false, removedFromBottom: false };
|
||||||
|
setFetchAboveError(e);
|
||||||
|
return { hasMoreAbove: true, removedFromBottom: false };
|
||||||
|
}
|
||||||
|
}, [ value, fetchAboveFunc, maxFetchElements ]);
|
||||||
|
|
||||||
|
const fetchBelowCallable = useCallback(async (): Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }> => {
|
||||||
|
if (!isMounted.current) return { hasMoreBelow: false, removedFromTop: false };
|
||||||
|
if (!value || value.length === 0) return { hasMoreBelow: false, removedFromTop: false };
|
||||||
|
try {
|
||||||
|
const reference = value[value.length - 1] as T;
|
||||||
|
const belowElements = await fetchBelowFunc(reference);
|
||||||
|
if (!isMounted.current) return { hasMoreBelow: false, removedFromTop: false };
|
||||||
|
setFetchBelowError(null);
|
||||||
|
if (belowElements) {
|
||||||
|
const hasMoreBelow = belowElements.length >= maxFetchElements;
|
||||||
|
let removedFromTop = false;
|
||||||
|
setValue(currentValue => {
|
||||||
|
let newValue = (currentValue ?? []).concat(belowElements).sort(sortFunc);
|
||||||
|
if (newValue.length > maxElements) {
|
||||||
|
newValue = newValue.slice(Math.max(newValue.length - maxElements, 0));
|
||||||
|
removedFromTop = true;
|
||||||
|
}
|
||||||
|
return newValue;
|
||||||
|
});
|
||||||
|
return { hasMoreBelow, removedFromTop };
|
||||||
|
} else {
|
||||||
|
return { hasMoreBelow: false, removedFromTop: false };
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
LOG.error('error fetching below for subscription', e);
|
||||||
|
if (!isMounted.current) return { hasMoreBelow: false, removedFromTop: false };
|
||||||
|
setFetchBelowError(e);
|
||||||
|
return { hasMoreBelow: true, removedFromTop: false };
|
||||||
|
}
|
||||||
|
}, [ value, fetchBelowFunc, maxFetchElements ]);
|
||||||
|
|
||||||
|
const onFetch = useCallback((fetchValue: T[] | null) => {
|
||||||
|
let hasMoreAbove = false;
|
||||||
|
if (fetchValue) {
|
||||||
|
if (fetchValue.length >= maxFetchElements) hasMoreAbove = true;
|
||||||
|
fetchValue = fetchValue.slice(Math.max(fetchValue.length - maxElements)).sort(sortFunc);
|
||||||
|
}
|
||||||
|
setFetchResult({ hasMoreAbove, hasMoreBelow: false });
|
||||||
|
setValue(fetchValue);
|
||||||
|
setFetchError(null);
|
||||||
|
events.emit('fetch');
|
||||||
|
}, [ sortFunc, maxFetchElements, maxElements ]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
let newValue = currentValue.concat(newElements).sort(sortFunc);
|
||||||
|
if (newValue.length > maxElements) {
|
||||||
|
newValue = removeByCounts(newValue, getRemoveCounts(newValue.length - maxElements));
|
||||||
|
}
|
||||||
|
return newValue;
|
||||||
|
})
|
||||||
|
events.emit('new', newElements);
|
||||||
|
}, [ sortFunc, getRemoveCounts ]);
|
||||||
|
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);
|
||||||
|
}, [ sortFunc ]);
|
||||||
|
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);
|
||||||
|
}, [ sortFunc ]);
|
||||||
|
|
||||||
|
const onConflict = useCallback((changes: Changes<T>) => {
|
||||||
|
setValue(currentValue => {
|
||||||
|
if (currentValue === null) return null;
|
||||||
|
const deletedIds = new Set(changes.deleted.map(deletedElement => deletedElement.id));
|
||||||
|
let newValue = currentValue
|
||||||
|
.concat(changes.added)
|
||||||
|
.map(element => changes.updated.find(change => change.newDataPoint.id === element.id)?.newDataPoint ?? element)
|
||||||
|
.filter(element => !deletedIds.has(element.id))
|
||||||
|
.sort(sortFunc);
|
||||||
|
if (newValue.length > maxElements) {
|
||||||
|
newValue = removeByCounts(newValue, getRemoveCounts(newValue.length - maxElements));
|
||||||
|
}
|
||||||
|
return newValue;
|
||||||
|
});
|
||||||
|
events.emit('conflict', changes);
|
||||||
|
}, [ sortFunc, getRemoveCounts ]);
|
||||||
|
|
||||||
|
// I think the typed EventEmitter class isn't ready for this level of insane type safety
|
||||||
|
// otherwise, I may have done this wrong. Forcing it to work with these calls
|
||||||
|
const boundNewFunc = useCallback((...args: Arguments<Connectable[NE]>): void => {
|
||||||
|
if (!isMounted.current) return;
|
||||||
|
onNew(newEventArgsMap(...args));
|
||||||
|
}, [ onNew, newEventArgsMap ]) as (Connectable & Conflictable)[NE];
|
||||||
|
const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => {
|
||||||
|
if (!isMounted.current) return;
|
||||||
|
onUpdated(updatedEventArgsMap(...args));
|
||||||
|
}, [ onUpdated, updatedEventArgsMap ]) as (Connectable & Conflictable)[UE];
|
||||||
|
const boundRemovedFunc = useCallback((...args: Arguments<Connectable[RE]>): void => {
|
||||||
|
if (!isMounted.current) return;
|
||||||
|
onRemoved(removedEventArgsMap(...args));
|
||||||
|
}, [ onRemoved, removedEventArgsMap ]) as (Connectable & Conflictable)[RE];
|
||||||
|
const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): void => {
|
||||||
|
if (!isMounted.current) return;
|
||||||
|
onConflict(conflictEventArgsMap(...args));
|
||||||
|
}, [ onConflict, conflictEventArgsMap ]) as (Connectable & Conflictable)[CE];
|
||||||
|
|
||||||
|
const bindEventsFunc = useCallback(() => {
|
||||||
|
guild.on(newEventName, boundNewFunc);
|
||||||
|
guild.on(updatedEventName, boundUpdateFunc);
|
||||||
|
guild.on(removedEventName, boundRemovedFunc);
|
||||||
|
guild.on(conflictEventName, boundConflictFunc);
|
||||||
|
}, [ boundNewFunc, boundUpdateFunc, boundRemovedFunc, boundConflictFunc ]);
|
||||||
|
const unbindEventsFunc = useCallback(() => {
|
||||||
|
guild.off(newEventName, boundNewFunc);
|
||||||
|
guild.off(updatedEventName, boundUpdateFunc);
|
||||||
|
guild.off(removedEventName, boundRemovedFunc);
|
||||||
|
guild.off(conflictEventName, boundConflictFunc);
|
||||||
|
}, [ boundNewFunc, boundUpdateFunc, boundRemovedFunc, boundConflictFunc ]);
|
||||||
|
|
||||||
|
const [ fetchRetryCallable ] = GuildSubscriptions.useGuildSubscriptionEffect({
|
||||||
|
guild,
|
||||||
|
onFetch,
|
||||||
|
onFetchError,
|
||||||
|
bindEventsFunc,
|
||||||
|
unbindEventsFunc
|
||||||
|
}, fetchFunc);
|
||||||
|
|
||||||
|
return [
|
||||||
|
fetchRetryCallable,
|
||||||
|
fetchAboveCallable,
|
||||||
|
fetchBelowCallable,
|
||||||
|
setScrollRatio,
|
||||||
|
fetchResult,
|
||||||
|
value,
|
||||||
|
fetchError,
|
||||||
|
fetchAboveError,
|
||||||
|
fetchBelowError,
|
||||||
|
events
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static useGuildMetadataSubscription(guild: CombinedGuild) {
|
static useGuildMetadataSubscription(guild: CombinedGuild) {
|
||||||
@ -374,4 +624,33 @@ export default class GuildSubscriptions {
|
|||||||
sortFunc: Token.sortRecentCreatedFirst
|
sortFunc: Token.sortRecentCreatedFirst
|
||||||
}, fetchTokensFunc);
|
}, fetchTokensFunc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static useMessagesScrollingSubscription(guild: CombinedGuild, channel: Channel) {
|
||||||
|
const maxFetchElements = Globals.MESSAGES_PER_REQUEST;
|
||||||
|
const maxElements = Globals.MAX_CURRENT_MESSAGES;
|
||||||
|
const fetchMessagesFunc = useCallback(async () => {
|
||||||
|
return await guild.fetchMessagesRecent(channel.id, maxFetchElements);
|
||||||
|
}, [ guild, channel.id, maxFetchElements ]);
|
||||||
|
const fetchAboveFunc = useCallback(async (reference: Message) => {
|
||||||
|
return await guild.fetchMessagesBefore(channel.id, reference.id, maxFetchElements);
|
||||||
|
}, [ guild, channel.id, maxFetchElements ]);
|
||||||
|
const fetchBelowFunc = useCallback(async (reference: Message) => {
|
||||||
|
return await guild.fetchMessagesAfter(channel.id, reference.id, maxFetchElements);
|
||||||
|
}, [ guild, channel.id, maxFetchElements ]);
|
||||||
|
return GuildSubscriptions.useMultipleGuildSubscriptionScrolling(
|
||||||
|
guild, {
|
||||||
|
newEventName: 'new-messages',
|
||||||
|
newEventArgsMap: (messages: Message[]) => messages,
|
||||||
|
updatedEventName: 'update-messages',
|
||||||
|
updatedEventArgsMap: (updatedMessages) => updatedMessages,
|
||||||
|
removedEventName: 'remove-messages',
|
||||||
|
removedEventArgsMap: (removedMessages) => removedMessages,
|
||||||
|
conflictEventName: 'conflict-messages',
|
||||||
|
conflictEventArgsMap: (query: PartialMessageListQuery, changesType: AutoVerifierChangesType, changes: Changes<Message>) => changes,
|
||||||
|
sortFunc: Message.sortOrder
|
||||||
|
},
|
||||||
|
maxElements, maxFetchElements,
|
||||||
|
fetchMessagesFunc, fetchAboveFunc, fetchBelowFunc
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
|
|||||||
import Logger from '../../../../logger/logger';
|
import Logger from '../../../../logger/logger';
|
||||||
const LOG = Logger.create(__filename, electronConsole);
|
const LOG = Logger.create(__filename, electronConsole);
|
||||||
|
|
||||||
import { DependencyList, MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { DependencyList, Dispatch, MutableRefObject, SetStateAction, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import ReactDOMServer from "react-dom/server";
|
import ReactDOMServer from "react-dom/server";
|
||||||
import { ShouldNeverHappenError } from "../../data-types";
|
import { ShouldNeverHappenError } from "../../data-types";
|
||||||
import Util from '../../util';
|
import Util from '../../util';
|
||||||
@ -261,4 +261,80 @@ export default class ReactHelper {
|
|||||||
|
|
||||||
return [ callable, text, shaking, result, errorMessage ];
|
return [ callable, text, shaking, result, errorMessage ];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static useColumnReverseInfiniteScroll(
|
||||||
|
threshold: number,
|
||||||
|
loadMoreAbove: () => Promise<{ hasMoreAbove: boolean, removedFromBottom: boolean }>,
|
||||||
|
loadMoreBelow: () => Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }>,
|
||||||
|
setScrollRatio: Dispatch<SetStateAction<number>>
|
||||||
|
): [
|
||||||
|
updateCallable: (event: UIEvent<HTMLElement>) => void,
|
||||||
|
onLoadCallable: (params: { hasMoreAbove: boolean, hasMoreBelow: boolean }) => void,
|
||||||
|
loadAboveRetry: () => Promise<void>,
|
||||||
|
loadBelowRetry: () => Promise<void>
|
||||||
|
] {
|
||||||
|
const isMounted = ReactHelper.useIsMountedRef();
|
||||||
|
|
||||||
|
const [ loadingAbove, setLoadingAbove ] = useState<boolean>(false);
|
||||||
|
const [ loadingBelow, setLoadingBelow ] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [ hasMoreAbove, setHasMoreAbove ] = useState<boolean>(false);
|
||||||
|
const [ hasMoreBelow, setHasMoreBelow ] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const loadAbove = useCallback(async () => {
|
||||||
|
if (loadingAbove || !hasMoreAbove) return;
|
||||||
|
setLoadingAbove(true);
|
||||||
|
const loadResult = await loadMoreAbove();
|
||||||
|
if (!isMounted.current) return;
|
||||||
|
setHasMoreAbove(loadResult.hasMoreAbove);
|
||||||
|
setHasMoreBelow(oldHasMoreBelow => oldHasMoreBelow || loadResult.removedFromBottom);
|
||||||
|
setLoadingAbove(false);
|
||||||
|
}, [ loadingAbove, hasMoreAbove, loadMoreAbove ]);
|
||||||
|
|
||||||
|
const loadBelow = useCallback(async () => {
|
||||||
|
if (loadingBelow || !hasMoreBelow) return;
|
||||||
|
setLoadingBelow(true);
|
||||||
|
const loadResult = await loadMoreBelow();
|
||||||
|
if (!isMounted.current) return;
|
||||||
|
setHasMoreBelow(loadResult.hasMoreBelow);
|
||||||
|
setHasMoreAbove(oldHasMoreAbove => oldHasMoreAbove || loadResult.removedFromTop);
|
||||||
|
setLoadingBelow(false);
|
||||||
|
}, [ loadingBelow, hasMoreBelow, loadMoreBelow ]);
|
||||||
|
|
||||||
|
const onScrollCallable = useCallback(async (event: UIEvent<HTMLElement>) => {
|
||||||
|
const scrollTop = event.currentTarget.scrollTop;
|
||||||
|
const scrollHeight = event.currentTarget.scrollHeight;
|
||||||
|
const clientHeight = event.currentTarget.clientHeight;
|
||||||
|
|
||||||
|
// WARNING
|
||||||
|
// There's likely an inconsistency between browsers on this so have fun when you're working
|
||||||
|
// on the cross-platform implementation of this
|
||||||
|
// scrollTop apparantly is negative for column-reverse divs (this actually kindof makes sense if you flip your head upside down)
|
||||||
|
// have to reverse this
|
||||||
|
// I expect this was a change with some version of chromium.
|
||||||
|
// MDN documentation issue: https://github.com/mdn/content/issues/10968
|
||||||
|
|
||||||
|
setScrollRatio(Math.abs(scrollTop / scrollHeight));
|
||||||
|
|
||||||
|
const distToTop = -(clientHeight - scrollHeight - scrollTop); // keep in mind scrollTop is negative >:]
|
||||||
|
const distToBottom = -scrollTop;
|
||||||
|
|
||||||
|
//LOG.debug(`scroll callable update. to top: ${distToTop}, to bottom: ${distToBottom}`)
|
||||||
|
|
||||||
|
if (distToTop < threshold) {
|
||||||
|
await loadAbove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distToBottom < threshold) {
|
||||||
|
await loadBelow();
|
||||||
|
}
|
||||||
|
}, [ setScrollRatio, loadAbove, loadBelow, threshold ]);
|
||||||
|
|
||||||
|
const onLoadCallable = useCallback((params: { hasMoreAbove: boolean, hasMoreBelow: boolean }) => {
|
||||||
|
setHasMoreAbove(params.hasMoreAbove);
|
||||||
|
setHasMoreBelow(params.hasMoreBelow);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [ onScrollCallable, onLoadCallable, loadAbove, loadBelow ];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import { Message, ShouldNeverHappenError } from "./data-types";
|
import { Message, ShouldNeverHappenError } from "./data-types";
|
||||||
import Globals from "./globals";
|
import Globals from "./globals";
|
||||||
|
|
||||||
|
import * as electronRemote from '@electron/remote';
|
||||||
|
const electronConsole = electronRemote.getGlobal('console') as Console;
|
||||||
|
import Logger from '../../logger/logger';
|
||||||
|
const LOG = Logger.create(__filename, electronConsole);
|
||||||
|
|
||||||
interface MessagesWithMetadata {
|
interface MessagesWithMetadata {
|
||||||
messages: Map<string, Message>;
|
messages: Map<string, Message>;
|
||||||
totalCharacters: number;
|
totalCharacters: number;
|
||||||
@ -87,7 +92,7 @@ export default class MessageRAMCache {
|
|||||||
if (!value) throw new ShouldNeverHappenError('unable to get message map');
|
if (!value) throw new ShouldNeverHappenError('unable to get message map');
|
||||||
value.lastUsed = new Date();
|
value.lastUsed = new Date();
|
||||||
const allRecentMessages = Array.from(value.messages.values()).sort(Message.sortOrder);
|
const allRecentMessages = Array.from(value.messages.values()).sort(Message.sortOrder);
|
||||||
const start = Math.min(allRecentMessages.length - number, 0);
|
const start = Math.max(allRecentMessages.length - number, 0);
|
||||||
const result = allRecentMessages.slice(start);
|
const result = allRecentMessages.slice(start);
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -3,12 +3,12 @@
|
|||||||
/* channel feed custom scrollbar */
|
/* channel feed custom scrollbar */
|
||||||
/* Note: Using this hack... https://stackoverflow.com/questions/29866759/how-do-i-add-a-margin-to-a-css-webkit-scrollbar */
|
/* Note: Using this hack... https://stackoverflow.com/questions/29866759/how-do-i-add-a-margin-to-a-css-webkit-scrollbar */
|
||||||
#channel-feed-content-wrapper::-webkit-scrollbar,
|
#channel-feed-content-wrapper::-webkit-scrollbar,
|
||||||
.message-list-anchor::-webkit-scrollbar {
|
.message-list .infinite-scroll-scroll-base::-webkit-scrollbar {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#channel-feed-content-wrapper::-webkit-scrollbar-track,
|
#channel-feed-content-wrapper::-webkit-scrollbar-track,
|
||||||
.message-list-anchor::-webkit-scrollbar-track {
|
.message-list .infinite-scroll-scroll-base::-webkit-scrollbar-track {
|
||||||
visibility: visible; /* always visible, does not disappear when not hovered over */
|
visibility: visible; /* always visible, does not disappear when not hovered over */
|
||||||
box-shadow: 0 0 12px 12px $background-secondary inset;
|
box-shadow: 0 0 12px 12px $background-secondary inset;
|
||||||
border: 4px solid transparent;
|
border: 4px solid transparent;
|
||||||
@ -16,7 +16,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#channel-feed-content-wrapper::-webkit-scrollbar-thumb,
|
#channel-feed-content-wrapper::-webkit-scrollbar-thumb,
|
||||||
.message-list-anchor::-webkit-scrollbar-thumb {
|
.message-list .infinite-scroll-scroll-base::-webkit-scrollbar-thumb {
|
||||||
visibility: visible; /* always visible, does not disappear when not hovered over */
|
visibility: visible; /* always visible, does not disappear when not hovered over */
|
||||||
box-shadow: 0 0 12px 12px $background-tertiary inset;
|
box-shadow: 0 0 12px 12px $background-tertiary inset;
|
||||||
border: 4px solid transparent;
|
border: 4px solid transparent;
|
||||||
@ -24,14 +24,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#channel-feed-content-wrapper::-webkit-scrollbar-button,
|
#channel-feed-content-wrapper::-webkit-scrollbar-button,
|
||||||
.message-list-anchor::-webkit-scrollbar-button {
|
.message-list .infinite-scroll-scroll-base::-webkit-scrollbar-button {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* General custom scrollbar (much thinner than channel feed) */
|
/* General custom scrollbar (much thinner than channel feed) */
|
||||||
/* Note: Using this hack... https://stackoverflow.com/questions/29866759/how-do-i-add-a-margin-to-a-css-webkit-scrollbar */
|
/* Note: Using this hack... https://stackoverflow.com/questions/29866759/how-do-i-add-a-margin-to-a-css-webkit-scrollbar */
|
||||||
|
|
||||||
:not(:hover)::-webkit-scrollbar-thumb {
|
:not(.message-list .infinite-scroll-scroll-base):not(:hover)::-webkit-scrollbar-thumb {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user