sforkowany z mirror/soapbox
Porównaj commity
8 Commity
develop
...
entity-sto
Autor | SHA1 | Data |
---|---|---|
![]() |
92b7eb0ffe | |
![]() |
bd64fc4304 | |
![]() |
e8d7517231 | |
![]() |
babd66b25c | |
![]() |
27500193d8 | |
![]() |
f7bfc40b70 | |
![]() |
52059f6f37 | |
![]() |
3b067c6fab |
|
@ -29,6 +29,10 @@ export const getNextLink = (response: AxiosResponse): string | undefined => {
|
||||||
return getLinks(response).refs.find(link => link.rel === 'next')?.uri;
|
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) => {
|
const getToken = (state: RootState, authType: string) => {
|
||||||
return authType === 'app' ? getAppToken(state) : getAccessToken(state);
|
return authType === 'app' ? getAppToken(state) : getAccessToken(state);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { useEntities } from './useEntities';
|
||||||
|
export { useEntity } from './useEntity';
|
|
@ -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,
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
|
@ -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;
|
|
@ -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,
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
|
@ -9,9 +9,9 @@ import { openModal } from 'soapbox/actions/modals';
|
||||||
import { getSettings } from 'soapbox/actions/settings';
|
import { getSettings } from 'soapbox/actions/settings';
|
||||||
import { hideStatus, revealStatus } from 'soapbox/actions/statuses';
|
import { hideStatus, revealStatus } from 'soapbox/actions/statuses';
|
||||||
import Icon from 'soapbox/components/icon';
|
import Icon from 'soapbox/components/icon';
|
||||||
|
import Status from 'soapbox/components/status';
|
||||||
import { HStack, Text, Emoji } from 'soapbox/components/ui';
|
import { HStack, Text, Emoji } from 'soapbox/components/ui';
|
||||||
import AccountContainer from 'soapbox/containers/account-container';
|
import AccountContainer from 'soapbox/containers/account-container';
|
||||||
import StatusContainer from 'soapbox/containers/status-container';
|
|
||||||
import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks';
|
||||||
import { makeGetNotification } from 'soapbox/selectors';
|
import { makeGetNotification } from 'soapbox/selectors';
|
||||||
import { NotificationType, validType } from 'soapbox/utils/notification';
|
import { NotificationType, validType } from 'soapbox/utils/notification';
|
||||||
|
@ -162,14 +162,9 @@ interface INotificaton {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Notification: React.FC<INotificaton> = (props) => {
|
const Notification: React.FC<INotificaton> = (props) => {
|
||||||
const { hidden = false, onMoveUp, onMoveDown } = props;
|
const { notification, hidden = false, onMoveUp, onMoveDown } = props;
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const getNotification = useCallback(makeGetNotification(), []);
|
|
||||||
|
|
||||||
const notification = useAppSelector((state) => getNotification(state, props.notification));
|
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const instance = useInstance();
|
const instance = useInstance();
|
||||||
|
@ -321,8 +316,8 @@ const Notification: React.FC<INotificaton> = (props) => {
|
||||||
case 'pleroma:participation_accepted':
|
case 'pleroma:participation_accepted':
|
||||||
case 'pleroma:participation_request':
|
case 'pleroma:participation_request':
|
||||||
return status && typeof status === 'object' ? (
|
return status && typeof status === 'object' ? (
|
||||||
<StatusContainer
|
<Status
|
||||||
id={status.id}
|
status={status}
|
||||||
withDismiss
|
withDismiss
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
onMoveDown={handleMoveDown}
|
onMoveDown={handleMoveDown}
|
||||||
|
|
|
@ -17,6 +17,7 @@ import ScrollableList from 'soapbox/components/scrollable-list';
|
||||||
import { Column } from 'soapbox/components/ui';
|
import { Column } from 'soapbox/components/ui';
|
||||||
import PlaceholderNotification from 'soapbox/features/placeholder/components/placeholder-notification';
|
import PlaceholderNotification from 'soapbox/features/placeholder/components/placeholder-notification';
|
||||||
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
|
||||||
|
import { useNotifications } from 'soapbox/hooks/useNotifications';
|
||||||
|
|
||||||
import FilterBar from './components/filter-bar';
|
import FilterBar from './components/filter-bar';
|
||||||
import Notification from './components/notification';
|
import Notification from './components/notification';
|
||||||
|
@ -50,25 +51,25 @@ const Notifications = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
|
||||||
|
const {
|
||||||
|
entities: notifications,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
hasNextPage: hasMore,
|
||||||
|
fetchEntities,
|
||||||
|
fetchNextPage,
|
||||||
|
} = useNotifications();
|
||||||
|
|
||||||
const showFilterBar = settings.getIn(['notifications', 'quickFilter', 'show']);
|
const showFilterBar = settings.getIn(['notifications', 'quickFilter', 'show']);
|
||||||
const activeFilter = settings.getIn(['notifications', 'quickFilter', 'active']);
|
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 totalQueuedNotificationsCount = useAppSelector(state => state.notifications.totalQueuedNotificationsCount || 0);
|
||||||
|
|
||||||
const node = useRef<VirtuosoHandle>(null);
|
const node = useRef<VirtuosoHandle>(null);
|
||||||
const column = useRef<HTMLDivElement>(null);
|
const column = useRef<HTMLDivElement>(null);
|
||||||
const scrollableContentRef = useRef<ImmutableList<JSX.Element> | null>(null);
|
const scrollableContentRef = useRef<Iterable<JSX.Element> | null>(null);
|
||||||
|
|
||||||
// const handleLoadGap = (maxId) => {
|
|
||||||
// dispatch(expandNotifications({ maxId }));
|
|
||||||
// };
|
|
||||||
|
|
||||||
const handleLoadOlder = useCallback(debounce(() => {
|
const handleLoadOlder = useCallback(debounce(() => {
|
||||||
const last = notifications.last();
|
fetchNextPage();
|
||||||
dispatch(expandNotifications({ maxId: last && last.get('id') }));
|
|
||||||
}, 300, { leading: true }), [notifications]);
|
}, 300, { leading: true }), [notifications]);
|
||||||
|
|
||||||
const handleScrollToTop = useCallback(debounce(() => {
|
const handleScrollToTop = useCallback(debounce(() => {
|
||||||
|
@ -112,6 +113,12 @@ const Notifications = () => {
|
||||||
return dispatch(expandNotifications());
|
return dispatch(expandNotifications());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFetching) {
|
||||||
|
fetchEntities();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleDequeueNotifications();
|
handleDequeueNotifications();
|
||||||
dispatch(scrollTopNotifications(true));
|
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' 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." />;
|
: <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
|
const filterBarContainer = showFilterBar
|
||||||
? (<FilterBar />)
|
? (<FilterBar />)
|
||||||
|
@ -136,7 +143,7 @@ const Notifications = () => {
|
||||||
|
|
||||||
if (isLoading && scrollableContentRef.current) {
|
if (isLoading && scrollableContentRef.current) {
|
||||||
scrollableContent = scrollableContentRef.current;
|
scrollableContent = scrollableContentRef.current;
|
||||||
} else if (notifications.size > 0 || hasMore) {
|
} else if (notifications.length > 0 || hasMore) {
|
||||||
scrollableContent = notifications.map((item) => (
|
scrollableContent = notifications.map((item) => (
|
||||||
<Notification
|
<Notification
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
@ -156,7 +163,7 @@ const Notifications = () => {
|
||||||
ref={node}
|
ref={node}
|
||||||
scrollKey='notifications'
|
scrollKey='notifications'
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
showLoading={isLoading && notifications.size === 0}
|
showLoading={isLoading && notifications.length === 0}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
placeholderComponent={PlaceholderNotification}
|
placeholderComponent={PlaceholderNotification}
|
||||||
|
@ -165,8 +172,8 @@ const Notifications = () => {
|
||||||
onScrollToTop={handleScrollToTop}
|
onScrollToTop={handleScrollToTop}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className={classNames({
|
className={classNames({
|
||||||
'divide-y divide-gray-200 dark:divide-primary-800 divide-solid': notifications.size > 0,
|
'divide-y divide-gray-200 dark:divide-primary-800 divide-solid': notifications.length > 0,
|
||||||
'space-y-2': notifications.size === 0,
|
'space-y-2': notifications.length === 0,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{scrollableContent as ImmutableList<JSX.Element>}
|
{scrollableContent as ImmutableList<JSX.Element>}
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
|
@ -9,6 +9,9 @@ import {
|
||||||
fromJS,
|
fromJS,
|
||||||
} from 'immutable';
|
} from 'immutable';
|
||||||
|
|
||||||
|
import { normalizeAccount } from './account';
|
||||||
|
import { normalizeStatus } from './status';
|
||||||
|
|
||||||
import type { Account, Status, EmbeddedEntity } from 'soapbox/types/entities';
|
import type { Account, Status, EmbeddedEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
// https://docs.joinmastodon.org/entities/notification/
|
// https://docs.joinmastodon.org/entities/notification/
|
||||||
|
@ -26,6 +29,14 @@ export const NotificationRecord = ImmutableRecord({
|
||||||
|
|
||||||
export const normalizeNotification = (notification: Record<string, any>) => {
|
export const normalizeNotification = (notification: Record<string, any>) => {
|
||||||
return NotificationRecord(
|
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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
* Converts API statuses into our internal format.
|
* Converts API statuses into our internal format.
|
||||||
* @see {@link https://docs.joinmastodon.org/entities/status/}
|
* @see {@link https://docs.joinmastodon.org/entities/status/}
|
||||||
*/
|
*/
|
||||||
|
import escapeTextContentForBrowser from 'escape-html';
|
||||||
import {
|
import {
|
||||||
Map as ImmutableMap,
|
Map as ImmutableMap,
|
||||||
List as ImmutableList,
|
List as ImmutableList,
|
||||||
|
@ -10,17 +11,26 @@ import {
|
||||||
fromJS,
|
fromJS,
|
||||||
} from 'immutable';
|
} from 'immutable';
|
||||||
|
|
||||||
|
import emojify from 'soapbox/features/emoji/emoji';
|
||||||
|
import { normalizeAccount } from 'soapbox/normalizers/account';
|
||||||
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
|
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
|
||||||
import { normalizeCard } from 'soapbox/normalizers/card';
|
import { normalizeCard } from 'soapbox/normalizers/card';
|
||||||
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
||||||
import { normalizeMention } from 'soapbox/normalizers/mention';
|
import { normalizeMention } from 'soapbox/normalizers/mention';
|
||||||
import { normalizePoll } from 'soapbox/normalizers/poll';
|
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 { ReducerAccount } from 'soapbox/reducers/accounts';
|
||||||
import type { Account, Attachment, Card, Emoji, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
import type { Account, Attachment, Card, Emoji, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'self';
|
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 EventJoinMode = 'free' | 'restricted' | 'invite';
|
||||||
export type EventJoinState = 'pending' | 'reject' | 'accept';
|
export type EventJoinState = 'pending' | 'reject' | 'accept';
|
||||||
|
|
||||||
|
@ -83,6 +93,11 @@ export const StatusRecord = ImmutableRecord({
|
||||||
translation: null as ImmutableMap<string, string> | null,
|
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>) => {
|
const normalizeAttachments = (status: ImmutableMap<string, any>) => {
|
||||||
return status.update('media_attachments', ImmutableList(), attachments => {
|
return status.update('media_attachments', ImmutableList(), attachments => {
|
||||||
return attachments.map(normalizeAttachment);
|
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
|
// Normalize event
|
||||||
const normalizeEvent = (status: ImmutableMap<string, any>) => {
|
const normalizeEvent = (status: ImmutableMap<string, any>) => {
|
||||||
if (status.getIn(['pleroma', 'event'])) {
|
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(
|
return StatusRecord(
|
||||||
ImmutableMap(fromJS(status)).withMutations(status => {
|
ImmutableMap(fromJS(status)).withMutations(status => {
|
||||||
|
normalizeStatusAccount(status);
|
||||||
normalizeAttachments(status);
|
normalizeAttachments(status);
|
||||||
normalizeMentions(status);
|
normalizeMentions(status);
|
||||||
normalizeEmojis(status);
|
normalizeEmojis(status);
|
||||||
|
@ -216,6 +266,8 @@ export const normalizeStatus = (status: Record<string, any>) => {
|
||||||
fixQuote(status);
|
fixQuote(status);
|
||||||
fixFiltered(status);
|
fixFiltered(status);
|
||||||
fixSensitivity(status);
|
fixSensitivity(status);
|
||||||
|
normalizeReblogQuote(status, depth);
|
||||||
|
addInternalFields(status);
|
||||||
normalizeEvent(status);
|
normalizeEvent(status);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { combineReducers } from 'redux-immutable';
|
||||||
|
|
||||||
import { AUTH_LOGGED_OUT } from 'soapbox/actions/auth';
|
import { AUTH_LOGGED_OUT } from 'soapbox/actions/auth';
|
||||||
import * as BuildConfig from 'soapbox/build-config';
|
import * as BuildConfig from 'soapbox/build-config';
|
||||||
|
import entities from 'soapbox/entity-store/reducer';
|
||||||
|
|
||||||
import account_notes from './account-notes';
|
import account_notes from './account-notes';
|
||||||
import accounts from './accounts';
|
import accounts from './accounts';
|
||||||
|
@ -120,6 +121,7 @@ const reducers = {
|
||||||
history,
|
history,
|
||||||
announcements,
|
announcements,
|
||||||
compose_event,
|
compose_event,
|
||||||
|
entities,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build a default state from all reducers: it has the key and `undefined`
|
// Build a default state from all reducers: it has the key and `undefined`
|
||||||
|
|
|
@ -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 */
|
/** Use new value only if old value is undefined */
|
||||||
export const mergeDefined = (oldVal: any, newVal: any) => oldVal === undefined ? newVal : oldVal;
|
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 => {
|
export const normalizeId = (id: any): string | null => {
|
||||||
return typeof id === 'string' ? id : 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')) || '';
|
||||||
|
};
|
|
@ -129,6 +129,7 @@
|
||||||
"html-webpack-harddisk-plugin": "^2.0.0",
|
"html-webpack-harddisk-plugin": "^2.0.0",
|
||||||
"html-webpack-plugin": "^5.5.0",
|
"html-webpack-plugin": "^5.5.0",
|
||||||
"http-link-header": "^1.0.2",
|
"http-link-header": "^1.0.2",
|
||||||
|
"immer": "^9.0.16",
|
||||||
"immutable": "^4.0.0",
|
"immutable": "^4.0.0",
|
||||||
"imports-loader": "^4.0.0",
|
"imports-loader": "^4.0.0",
|
||||||
"intersection-observer": "^0.12.0",
|
"intersection-observer": "^0.12.0",
|
||||||
|
|
|
@ -6707,6 +6707,11 @@ immediate@~3.0.5:
|
||||||
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
|
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
|
||||||
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
|
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:
|
immer@^9.0.7:
|
||||||
version "9.0.12"
|
version "9.0.12"
|
||||||
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.12.tgz#2d33ddf3ee1d247deab9d707ca472c8c942a0f20"
|
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.12.tgz#2d33ddf3ee1d247deab9d707ca472c8c942a0f20"
|
||||||
|
|
Ładowanie…
Reference in New Issue