Porównaj commity

...

8 Commity

Autor SHA1 Wiadomość Data
Alex Gleason 92b7eb0ffe
Merge remote-tracking branch 'origin/develop' into entity-store 2022-12-06 11:55:47 -06:00
Alex Gleason bd64fc4304
normalizeStatus: add internal fields 2022-12-05 11:27:11 -06:00
Alex Gleason e8d7517231
normalizeStatus: prevent infinite recursion 2022-12-05 11:10:31 -06:00
Alex Gleason babd66b25c
EntityStore: get Notifications (mostly) working 2022-12-05 10:40:38 -06:00
Alex Gleason 27500193d8
EntityStore: incorporate Notifications, go back to using POJOs instead of Maps 2022-12-04 18:54:54 -06:00
Alex Gleason f7bfc40b70
EntityStore: proper pagination support 2022-12-04 17:53:56 -06:00
Alex Gleason 52059f6f37
EntityStore: add request/success/fail actions 2022-12-04 17:26:28 -06:00
Alex Gleason 3b067c6fab
Scaffold entity store library 2022-12-04 17:05:01 -06:00
17 zmienionych plików z 516 dodań i 27 usunięć

Wyświetl plik

@ -29,6 +29,10 @@ export const getNextLink = (response: AxiosResponse): string | undefined => {
return getLinks(response).refs.find(link => link.rel === 'next')?.uri;
};
export const getPrevLink = (response: AxiosResponse): string | undefined => {
return getLinks(response).refs.find(link => link.rel === 'prev')?.uri;
};
const getToken = (state: RootState, authType: string) => {
return authType === 'app' ? getAppToken(state) : getAccessToken(state);
};

Wyświetl plik

@ -0,0 +1,62 @@
import type { Entity, EntityListState } from './types';
const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const;
const ENTITIES_FETCH_REQUEST = 'ENTITIES_FETCH_REQUEST' as const;
const ENTITIES_FETCH_SUCCESS = 'ENTITIES_FETCH_SUCCESS' as const;
const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const;
/** Action to import entities into the cache. */
function importEntities(entities: Entity[], entityType: string, listKey?: string) {
return {
type: ENTITIES_IMPORT,
entityType,
entities,
listKey,
};
}
function entitiesFetchRequest(entityType: string, listKey?: string) {
return {
type: ENTITIES_FETCH_REQUEST,
entityType,
listKey,
};
}
function entitiesFetchSuccess(entities: Entity[], entityType: string, listKey?: string, newState?: EntityListState) {
return {
type: ENTITIES_FETCH_SUCCESS,
entityType,
entities,
listKey,
newState,
};
}
function entitiesFetchFail(entityType: string, listKey: string | undefined, error: any) {
return {
type: ENTITIES_FETCH_FAIL,
entityType,
listKey,
error,
};
}
/** Any action pertaining to entities. */
type EntityAction =
ReturnType<typeof importEntities>
| ReturnType<typeof entitiesFetchRequest>
| ReturnType<typeof entitiesFetchSuccess>
| ReturnType<typeof entitiesFetchFail>;
export {
ENTITIES_IMPORT,
ENTITIES_FETCH_REQUEST,
ENTITIES_FETCH_SUCCESS,
ENTITIES_FETCH_FAIL,
importEntities,
entitiesFetchRequest,
entitiesFetchSuccess,
entitiesFetchFail,
EntityAction,
};

Wyświetl plik

@ -0,0 +1,2 @@
export { useEntities } from './useEntities';
export { useEntity } from './useEntity';

Wyświetl plik

@ -0,0 +1,85 @@
import { getNextLink, getPrevLink } from 'soapbox/api';
import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '../actions';
import type { Entity } from '../types';
type EntityPath = [entityType: string, listKey: string]
function useEntities<TEntity extends Entity>(path: EntityPath, endpoint: string) {
const api = useApi();
const dispatch = useAppDispatch();
const [entityType, listKey] = path;
const cache = useAppSelector(state => state.entities[entityType]);
const list = cache?.lists[listKey];
const entityIds = list?.ids;
const entities: readonly TEntity[] = entityIds ? (
Array.from(entityIds).reduce<TEntity[]>((result, id) => {
const entity = cache?.store[id] as TEntity | undefined;
if (entity) {
result.push(entity);
}
return result;
}, [])
) : [];
const isFetching = Boolean(list?.state.fetching);
const isLoading = isFetching && entities.length === 0;
const hasNextPage = Boolean(list?.state.next);
const hasPreviousPage = Boolean(list?.state.prev);
const fetchPage = async(url: string): Promise<void> => {
dispatch(entitiesFetchRequest(entityType, listKey));
try {
const response = await api.get(url);
dispatch(entitiesFetchSuccess(response.data, entityType, listKey, {
next: getNextLink(response),
prev: getPrevLink(response),
fetching: false,
error: null,
}));
} catch (error) {
dispatch(entitiesFetchFail(entityType, listKey, error));
}
};
const fetchEntities = async(): Promise<void> => {
await fetchPage(endpoint);
};
const fetchNextPage = async(): Promise<void> => {
const next = list?.state.next;
if (next) {
await fetchPage(next);
}
};
const fetchPreviousPage = async(): Promise<void> => {
const prev = list?.state.prev;
if (prev) {
await fetchPage(prev);
}
};
return {
entities,
fetchEntities,
isFetching,
isLoading,
hasNextPage,
hasPreviousPage,
fetchNextPage,
fetchPreviousPage,
};
}
export {
useEntities,
};

Wyświetl plik

@ -0,0 +1,41 @@
import { useState } from 'react';
import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { importEntities } from '../actions';
import type { Entity } from '../types';
type EntityPath = [entityType: string, entityId: string]
function useEntity<TEntity extends Entity>(path: EntityPath, endpoint: string) {
const api = useApi();
const dispatch = useAppDispatch();
const [entityType, entityId] = path;
const entity = useAppSelector(state => state.entities[entityType]?.store[entityId]) as TEntity | undefined;
const [isFetching, setIsFetching] = useState(false);
const isLoading = isFetching && !entity;
const fetchEntity = () => {
setIsFetching(true);
api.get(endpoint).then(({ data }) => {
dispatch(importEntities([data], entityType));
setIsFetching(false);
}).catch(() => {
setIsFetching(false);
});
};
return {
entity,
fetchEntity,
isFetching,
isLoading,
};
}
export {
useEntity,
};

Wyświetl plik

@ -0,0 +1,81 @@
import produce, { enableMapSet } from 'immer';
import {
ENTITIES_IMPORT,
ENTITIES_FETCH_REQUEST,
ENTITIES_FETCH_SUCCESS,
ENTITIES_FETCH_FAIL,
EntityAction,
} from './actions';
import { createCache, createList, updateStore, updateList } from './utils';
import type { Entity, EntityCache, EntityListState } from './types';
enableMapSet();
/** Entity reducer state. */
interface State {
[entityType: string]: EntityCache | undefined
}
/** Import entities into the cache. */
const importEntities = (
state: State,
entityType: string,
entities: Entity[],
listKey?: string,
newState?: EntityListState,
): State => {
return produce(state, draft => {
const cache = draft[entityType] ?? createCache();
cache.store = updateStore(cache.store, entities);
if (typeof listKey === 'string') {
let list = { ...(cache.lists[listKey] ?? createList()) };
list = updateList(list, entities);
if (newState) {
list.state = newState;
}
cache.lists[listKey] = list;
}
draft[entityType] = cache;
});
};
const setFetching = (
state: State,
entityType: string,
listKey: string | undefined,
isFetching: boolean,
) => {
return produce(state, draft => {
const cache = draft[entityType] ?? createCache();
if (typeof listKey === 'string') {
const list = cache.lists[listKey] ?? createList();
list.state.fetching = isFetching;
cache.lists[listKey] = list;
}
draft[entityType] = cache;
});
};
/** Stores various entity data and lists in a one reducer. */
function reducer(state: Readonly<State> = {}, action: EntityAction): State {
switch (action.type) {
case ENTITIES_IMPORT:
return importEntities(state, action.entityType, action.entities, action.listKey);
case ENTITIES_FETCH_SUCCESS:
return importEntities(state, action.entityType, action.entities, action.listKey, action.newState);
case ENTITIES_FETCH_REQUEST:
return setFetching(state, action.entityType, action.listKey, true);
case ENTITIES_FETCH_FAIL:
return setFetching(state, action.entityType, action.listKey, false);
default:
return state;
}
}
export default reducer;

Wyświetl plik

@ -0,0 +1,48 @@
/** A Mastodon API entity. */
interface Entity {
/** Unique ID for the entity (usually the primary key in the database). */
id: string
}
/** Store of entities by ID. */
interface EntityStore {
[id: string]: Entity | undefined
}
/** List of entity IDs and fetch state. */
interface EntityList {
/** Set of entity IDs in this list. */
ids: Set<string>
/** Server state for this entity list. */
state: EntityListState
}
/** Fetch state for an entity list. */
interface EntityListState {
/** Next URL for pagination, if any. */
next: string | undefined
/** Previous URL for pagination, if any. */
prev: string | undefined
/** Error returned from the API, if any. */
error: any
/** Whether data for this list is currently being fetched. */
fetching: boolean
}
/** Cache data pertaining to a paritcular entity type.. */
interface EntityCache {
/** Map of entities of this type. */
store: EntityStore
/** Lists of entity IDs for a particular purpose. */
lists: {
[listKey: string]: EntityList | undefined
}
}
export {
Entity,
EntityStore,
EntityList,
EntityListState,
EntityCache,
};

Wyświetl plik

@ -0,0 +1,42 @@
import type { Entity, EntityStore, EntityList, EntityCache } from './types';
/** Insert the entities into the store. */
const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => {
return entities.reduce<EntityStore>((store, entity) => {
store[entity.id] = entity;
return store;
}, { ...store });
};
/** Update the list with new entity IDs. */
const updateList = (list: EntityList, entities: Entity[]): EntityList => {
const newIds = entities.map(entity => entity.id);
return {
...list,
ids: new Set([...Array.from(list.ids), ...newIds]),
};
};
/** Create an empty entity cache. */
const createCache = (): EntityCache => ({
store: {},
lists: {},
});
/** Create an empty entity list. */
const createList = (): EntityList => ({
ids: new Set(),
state: {
next: undefined,
prev: undefined,
fetching: false,
error: null,
},
});
export {
updateStore,
updateList,
createCache,
createList,
};

Wyświetl plik

@ -9,9 +9,9 @@ import { openModal } from 'soapbox/actions/modals';
import { getSettings } from 'soapbox/actions/settings';
import { hideStatus, revealStatus } from 'soapbox/actions/statuses';
import Icon from 'soapbox/components/icon';
import Status from 'soapbox/components/status';
import { HStack, Text, Emoji } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import StatusContainer from 'soapbox/containers/status-container';
import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks';
import { makeGetNotification } from 'soapbox/selectors';
import { NotificationType, validType } from 'soapbox/utils/notification';
@ -162,14 +162,9 @@ interface INotificaton {
}
const Notification: React.FC<INotificaton> = (props) => {
const { hidden = false, onMoveUp, onMoveDown } = props;
const { notification, hidden = false, onMoveUp, onMoveDown } = props;
const dispatch = useAppDispatch();
const getNotification = useCallback(makeGetNotification(), []);
const notification = useAppSelector((state) => getNotification(state, props.notification));
const history = useHistory();
const intl = useIntl();
const instance = useInstance();
@ -321,8 +316,8 @@ const Notification: React.FC<INotificaton> = (props) => {
case 'pleroma:participation_accepted':
case 'pleroma:participation_request':
return status && typeof status === 'object' ? (
<StatusContainer
id={status.id}
<Status
status={status}
withDismiss
hidden={hidden}
onMoveDown={handleMoveDown}

Wyświetl plik

@ -17,6 +17,7 @@ import ScrollableList from 'soapbox/components/scrollable-list';
import { Column } from 'soapbox/components/ui';
import PlaceholderNotification from 'soapbox/features/placeholder/components/placeholder-notification';
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
import { useNotifications } from 'soapbox/hooks/useNotifications';
import FilterBar from './components/filter-bar';
import Notification from './components/notification';
@ -50,25 +51,25 @@ const Notifications = () => {
const intl = useIntl();
const settings = useSettings();
const {
entities: notifications,
isLoading,
isFetching,
hasNextPage: hasMore,
fetchEntities,
fetchNextPage,
} = useNotifications();
const showFilterBar = settings.getIn(['notifications', 'quickFilter', 'show']);
const activeFilter = settings.getIn(['notifications', 'quickFilter', 'active']);
const notifications = useAppSelector(state => getNotifications(state));
const isLoading = useAppSelector(state => state.notifications.isLoading);
// const isUnread = useAppSelector(state => state.notifications.unread > 0);
const hasMore = useAppSelector(state => state.notifications.hasMore);
const totalQueuedNotificationsCount = useAppSelector(state => state.notifications.totalQueuedNotificationsCount || 0);
const node = useRef<VirtuosoHandle>(null);
const column = useRef<HTMLDivElement>(null);
const scrollableContentRef = useRef<ImmutableList<JSX.Element> | null>(null);
// const handleLoadGap = (maxId) => {
// dispatch(expandNotifications({ maxId }));
// };
const scrollableContentRef = useRef<Iterable<JSX.Element> | null>(null);
const handleLoadOlder = useCallback(debounce(() => {
const last = notifications.last();
dispatch(expandNotifications({ maxId: last && last.get('id') }));
fetchNextPage();
}, 300, { leading: true }), [notifications]);
const handleScrollToTop = useCallback(debounce(() => {
@ -112,6 +113,12 @@ const Notifications = () => {
return dispatch(expandNotifications());
};
useEffect(() => {
if (!isFetching) {
fetchEntities();
}
}, []);
useEffect(() => {
handleDequeueNotifications();
dispatch(scrollTopNotifications(true));
@ -128,7 +135,7 @@ const Notifications = () => {
? <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
: <FormattedMessage id='empty_column.notifications_filtered' defaultMessage="You don't have any notifications of this type yet." />;
let scrollableContent: ImmutableList<JSX.Element> | null = null;
let scrollableContent: Iterable<JSX.Element> | null = null;
const filterBarContainer = showFilterBar
? (<FilterBar />)
@ -136,7 +143,7 @@ const Notifications = () => {
if (isLoading && scrollableContentRef.current) {
scrollableContent = scrollableContentRef.current;
} else if (notifications.size > 0 || hasMore) {
} else if (notifications.length > 0 || hasMore) {
scrollableContent = notifications.map((item) => (
<Notification
key={item.id}
@ -156,7 +163,7 @@ const Notifications = () => {
ref={node}
scrollKey='notifications'
isLoading={isLoading}
showLoading={isLoading && notifications.size === 0}
showLoading={isLoading && notifications.length === 0}
hasMore={hasMore}
emptyMessage={emptyMessage}
placeholderComponent={PlaceholderNotification}
@ -165,8 +172,8 @@ const Notifications = () => {
onScrollToTop={handleScrollToTop}
onScroll={handleScroll}
className={classNames({
'divide-y divide-gray-200 dark:divide-primary-800 divide-solid': notifications.size > 0,
'space-y-2': notifications.size === 0,
'divide-y divide-gray-200 dark:divide-primary-800 divide-solid': notifications.length > 0,
'space-y-2': notifications.length === 0,
})}
>
{scrollableContent as ImmutableList<JSX.Element>}

Wyświetl plik

@ -0,0 +1,18 @@
import { useEntities } from 'soapbox/entity-store/hooks';
import { normalizeNotification } from 'soapbox/normalizers';
import type { Notification } from 'soapbox/types/entities';
function useNotifications() {
const result = useEntities<Notification>(['Notification', ''], '/api/v1/notifications');
return {
...result,
// TODO: handle this in the reducer by passing config.
entities: result.entities.map(normalizeNotification),
};
}
export {
useNotifications,
};

Wyświetl plik

@ -9,6 +9,9 @@ import {
fromJS,
} from 'immutable';
import { normalizeAccount } from './account';
import { normalizeStatus } from './status';
import type { Account, Status, EmbeddedEntity } from 'soapbox/types/entities';
// https://docs.joinmastodon.org/entities/notification/
@ -26,6 +29,14 @@ export const NotificationRecord = ImmutableRecord({
export const normalizeNotification = (notification: Record<string, any>) => {
return NotificationRecord(
ImmutableMap(fromJS(notification)),
ImmutableMap(fromJS(notification)).withMutations(notification => {
if (notification.get('status')) {
notification.set('status', normalizeStatus(notification.get('status') as any) as any);
}
if (notification.get('account')) {
notification.set('account', normalizeAccount(notification.get('account') as any) as any);
}
}),
);
};

Wyświetl plik

@ -3,6 +3,7 @@
* Converts API statuses into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/status/}
*/
import escapeTextContentForBrowser from 'escape-html';
import {
Map as ImmutableMap,
List as ImmutableList,
@ -10,17 +11,26 @@ import {
fromJS,
} from 'immutable';
import emojify from 'soapbox/features/emoji/emoji';
import { normalizeAccount } from 'soapbox/normalizers/account';
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import { normalizeCard } from 'soapbox/normalizers/card';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { normalizeMention } from 'soapbox/normalizers/mention';
import { normalizePoll } from 'soapbox/normalizers/poll';
import { stripCompatibilityFeatures } from 'soapbox/utils/html';
import { buildSearchContent, makeEmojiMap } from 'soapbox/utils/normalizers';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
import type { Account, Attachment, Card, Emoji, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'self';
/** Maximum number of times to recurse sub-entities. */
const MAX_DEPTH = 1;
const domParser = new DOMParser();
export type EventJoinMode = 'free' | 'restricted' | 'invite';
export type EventJoinState = 'pending' | 'reject' | 'accept';
@ -83,6 +93,11 @@ export const StatusRecord = ImmutableRecord({
translation: null as ImmutableMap<string, string> | null,
});
const normalizeStatusAccount = (status: ImmutableMap<string, any>) => {
const account = normalizeAccount(status.get('account'));
return status.set('account', account);
};
const normalizeAttachments = (status: ImmutableMap<string, any>) => {
return status.update('media_attachments', ImmutableList(), attachments => {
return attachments.map(normalizeAttachment);
@ -176,6 +191,40 @@ const fixSensitivity = (status: ImmutableMap<string, any>) => {
}
};
const normalizeReblogQuote = (status: ImmutableMap<string, any>, depth = 0) => {
if (depth >= MAX_DEPTH) {
return status;
} else {
depth++;
}
const reblog = status.get('reblog');
const quote = status.get('quote');
return status.withMutations(status => {
if (reblog) {
status.set('reblog', normalizeStatus(reblog, depth));
}
if (quote) {
status.set('quote', normalizeStatus(quote, depth));
}
});
};
const addInternalFields = (status: ImmutableMap<string, any>) => {
const spoilerText = status.get('spoiler_text');
const searchContent = buildSearchContent(status);
const emojiMap = makeEmojiMap(status.get('emojis'));
return status.merge({
search_index: domParser.parseFromString(searchContent, 'text/html').documentElement.textContent || '',
contentHtml: stripCompatibilityFeatures(emojify(status.get('content'), emojiMap)),
spoilerHtml: emojify(escapeTextContentForBrowser(spoilerText), emojiMap),
// hidden: expandSpoilers ? false : spoilerText.length > 0 || status.get('sensitive'),
});
};
// Normalize event
const normalizeEvent = (status: ImmutableMap<string, any>) => {
if (status.getIn(['pleroma', 'event'])) {
@ -203,9 +252,10 @@ const normalizeEvent = (status: ImmutableMap<string, any>) => {
}
};
export const normalizeStatus = (status: Record<string, any>) => {
export const normalizeStatus = (status: Record<string, any>, depth = 0) => {
return StatusRecord(
ImmutableMap(fromJS(status)).withMutations(status => {
normalizeStatusAccount(status);
normalizeAttachments(status);
normalizeMentions(status);
normalizeEmojis(status);
@ -216,6 +266,8 @@ export const normalizeStatus = (status: Record<string, any>) => {
fixQuote(status);
fixFiltered(status);
fixSensitivity(status);
normalizeReblogQuote(status, depth);
addInternalFields(status);
normalizeEvent(status);
}),
);

Wyświetl plik

@ -3,6 +3,7 @@ import { combineReducers } from 'redux-immutable';
import { AUTH_LOGGED_OUT } from 'soapbox/actions/auth';
import * as BuildConfig from 'soapbox/build-config';
import entities from 'soapbox/entity-store/reducer';
import account_notes from './account-notes';
import accounts from './accounts';
@ -120,6 +121,7 @@ const reducers = {
history,
announcements,
compose_event,
entities,
};
// Build a default state from all reducers: it has the key and `undefined`

Wyświetl plik

@ -1,3 +1,7 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { unescapeHTML } from './html';
/** Use new value only if old value is undefined */
export const mergeDefined = (oldVal: any, newVal: any) => oldVal === undefined ? newVal : oldVal;
@ -10,3 +14,32 @@ export const makeEmojiMap = (emojis: any) => emojis.reduce((obj: any, emoji: any
export const normalizeId = (id: any): string | null => {
return typeof id === 'string' ? id : null;
};
/** Gets titles of poll options from status. */
const getPollOptionTitles = (status: ImmutableMap<string, any>): ImmutableList<string> => {
const poll = status.get('poll');
if (poll && typeof poll === 'object') {
return poll.get('options').map((option: ImmutableMap<string, any>) => option.get('title'));
} else {
return ImmutableList();
}
};
/** Gets usernames of mentioned users from status. */
const getMentionedUsernames = (status: ImmutableMap<string, any>): ImmutableList<string> => {
return status.get('mentions').map((mention: ImmutableMap<string, any>) => `@${mention.get('acct')}`);
};
/** Creates search text from the status. */
export const buildSearchContent = (status: ImmutableMap<string, any>): string => {
const pollOptionTitles = getPollOptionTitles(status);
const mentionedUsernames = getMentionedUsernames(status);
const fields = ImmutableList([
status.get('spoiler_text'),
status.get('content'),
]).concat(pollOptionTitles).concat(mentionedUsernames);
return unescapeHTML(fields.join('\n\n')) || '';
};

Wyświetl plik

@ -129,6 +129,7 @@
"html-webpack-harddisk-plugin": "^2.0.0",
"html-webpack-plugin": "^5.5.0",
"http-link-header": "^1.0.2",
"immer": "^9.0.16",
"immutable": "^4.0.0",
"imports-loader": "^4.0.0",
"intersection-observer": "^0.12.0",

Wyświetl plik

@ -6707,6 +6707,11 @@ immediate@~3.0.5:
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
immer@^9.0.16:
version "9.0.16"
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.16.tgz#8e7caab80118c2b54b37ad43e05758cdefad0198"
integrity sha512-qenGE7CstVm1NrHQbMh8YaSzTZTFNP3zPqr3YU0S0UY441j4bJTg4A2Hh5KAhwgaiU6ZZ1Ar6y/2f4TblnMReQ==
immer@^9.0.7:
version "9.0.12"
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.12.tgz#2d33ddf3ee1d247deab9d707ca472c8c942a0f20"