nicolium: group migrations

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-02-22 20:36:05 +01:00
parent fbbcbdce3f
commit f1aea5f17e
24 changed files with 121 additions and 806 deletions

View File

@ -109,7 +109,12 @@ const importEntities =
if (!isEmpty(accounts))
dispatch(importEntityStoreEntities(Object.values(accounts), Entities.ACCOUNTS));
if (!isEmpty(groups))
dispatch(importEntityStoreEntities(Object.values(groups), Entities.GROUPS));
for (const group of Object.values(groups)) {
queryClient.setQueryData<BaseGroup>(['groups', group.id], group);
if (group.relationship) {
queryClient.setQueryData<BaseGroup>(['groupRelationships', group.id], group.relationship);
}
}
if (!isEmpty(polls)) {
for (const poll of Object.values(polls)) {
queryClient.setQueryData<BasePoll>(['statuses', 'polls', poll.id], poll);

View File

@ -1,21 +0,0 @@
import { Entities } from '@/entity-store/entities';
import { useCreateEntity } from '@/entity-store/hooks/use-create-entity';
import { useClient } from '@/hooks/use-client';
import type { Group, CreateGroupParams } from 'pl-api';
const useCreateGroup = () => {
const client = useClient();
const { createEntity, ...rest } = useCreateEntity<Group, Group, CreateGroupParams>(
[Entities.GROUPS, 'search', ''],
(params: CreateGroupParams) => client.experimental.groups.createGroup(params),
);
return {
createGroup: createEntity,
...rest,
};
};
export { useCreateGroup };

View File

@ -1,18 +0,0 @@
import { Entities } from '@/entity-store/entities';
import { useDeleteEntity } from '@/entity-store/hooks/use-delete-entity';
import { useClient } from '@/hooks/use-client';
const useDeleteGroup = () => {
const client = useClient();
const { deleteEntity, isSubmitting } = useDeleteEntity(Entities.GROUPS, (groupId: string) =>
client.experimental.groups.deleteGroup(groupId),
);
return {
mutate: deleteEntity,
isSubmitting,
};
};
export { useDeleteGroup };

View File

@ -1,28 +0,0 @@
import { Entities } from '@/entity-store/entities';
import { useCreateEntity } from '@/entity-store/hooks/use-create-entity';
import { useClient } from '@/hooks/use-client';
interface UpdateGroupParams {
display_name?: string;
note?: string;
avatar?: File | '';
header?: File | '';
group_visibility?: string;
discoverable?: boolean;
}
const useUpdateGroup = (groupId: string) => {
const client = useClient();
const { createEntity, ...rest } = useCreateEntity(
[Entities.GROUPS],
(params: UpdateGroupParams) => client.experimental.groups.updateGroup(groupId, params),
);
return {
updateGroup: createEntity,
...rest,
};
};
export { useUpdateGroup };

View File

@ -1,5 +1,5 @@
import { useMatch, useNavigate } from '@tanstack/react-router';
import { type Account, type CustomEmoji, type Group, GroupRoles } from 'pl-api';
import { type Account, type CustomEmoji, GroupRoles } from 'pl-api';
import React, { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -26,7 +26,6 @@ import { useUnblockAccountMutation } from '@/queries/accounts/use-relationship';
import { useChats } from '@/queries/chats';
import { useGroupQuery } from '@/queries/groups/use-group';
import { useBlockGroupUserMutation } from '@/queries/groups/use-group-blocks';
import { useGroupRelationshipQuery } from '@/queries/groups/use-group-relationship';
import { useCustomEmojis } from '@/queries/instance/use-custom-emojis';
import { useTranslationLanguages } from '@/queries/instance/use-translation-languages';
import {
@ -345,12 +344,12 @@ const ReplyButton: React.FC<IReplyButton> = ({
const intl = useIntl();
const canReply = useCanInteract(status, 'can_reply');
const { data: groupRelationship } = useGroupRelationshipQuery(status.group_id ?? undefined);
const { data: group } = useGroupQuery(status.group_id ?? undefined, true);
let replyTitle;
let replyDisabled = false;
if ((status.group as Group)?.membership_required && !groupRelationship?.member) {
if (group?.membership_required && !group.relationship?.member) {
replyDisabled = true;
replyTitle = intl.formatMessage(messages.replies_disabled_group);
}
@ -394,8 +393,8 @@ const ReplyButton: React.FC<IReplyButton> = ({
</Popover>
);
return status.group ? (
<GroupPopover group={status.group} isEnabled={replyDisabled}>
return group ? (
<GroupPopover group={group} isEnabled={replyDisabled}>
{replyButton}
</GroupPopover>
) : (
@ -741,8 +740,8 @@ const MenuButton: React.FC<IMenuButton> = ({
const { openModal } = useModalsActions();
const { data: group } = useGroupQuery(status.group_id || undefined, true);
const { mutate: blockGroupMember } = useBlockGroupUserMutation(
status.group?.id as string,
status.account.id,
status.group_id as string,
status.account_id,
);
const { getOrCreateChatByAccountId } = useChats();
const { mutate: bookmarkStatus } = useBookmarkStatus(status.id);
@ -1032,7 +1031,7 @@ const MenuButton: React.FC<IMenuButton> = ({
});
}
const isGroupStatus = typeof status.group === 'object';
const isGroupStatus = typeof status.group_id === 'string';
if (features.bookmarks) {
menu.push({
@ -1227,7 +1226,7 @@ const MenuButton: React.FC<IMenuButton> = ({
});
}
if (isGroupStatus && !!status.group) {
if (isGroupStatus && !!status.group_id) {
const isGroupOwner = group?.relationship?.role === GroupRoles.OWNER;
const isGroupAdmin = group?.relationship?.role === GroupRoles.ADMIN;
// const isStatusFromOwner = group.owner.id === account.id;

View File

@ -205,13 +205,14 @@ const Status: React.FC<IStatus> = (props) => {
(state) => (status.reblog_id && getStatus(state, { id: status.reblog_id })!) || status,
);
const { data: group } = useGroupQuery(actualStatus.group_id ?? undefined);
const { mutate: favouriteStatus } = useFavouriteStatus(actualStatus.id);
const { mutate: unfavouriteStatus } = useUnfavouriteStatus(actualStatus.id);
const { mutate: reblogStatus } = useReblogStatus(actualStatus.id);
const { mutate: unreblogStatus } = useUnreblogStatus(actualStatus.id);
const isReblog = status.reblog_id;
const group = actualStatus.group;
const filterResults = useMemo(() => {
return [...status.filtered, ...actualStatus.filtered]

View File

@ -1,13 +1,8 @@
import type { Entities } from './entities';
import type { EntitiesTransaction, Entity, EntityListState, ImportPosition } from './types';
import type { EntitiesTransaction, Entity, ImportPosition } from './types';
const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const;
const ENTITIES_DELETE = 'ENTITIES_DELETE' as const;
const ENTITIES_DISMISS = 'ENTITIES_DISMISS' 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;
const ENTITIES_INVALIDATE_LIST = 'ENTITIES_INVALIDATE_LIST' as const;
const ENTITIES_TRANSACTION = 'ENTITIES_TRANSACTION' as const;
/** Action to import entities into the cache. */
@ -39,49 +34,6 @@ const deleteEntities = (
opts,
});
const dismissEntities = (ids: Iterable<string>, entityType: string, listKey: string) => ({
type: ENTITIES_DISMISS,
ids,
entityType,
listKey,
});
const entitiesFetchRequest = (entityType: string, listKey?: string) => ({
type: ENTITIES_FETCH_REQUEST,
entityType,
listKey,
});
const entitiesFetchSuccess = (
entities: Entity[],
entityType: Entities,
listKey?: string,
pos?: ImportPosition,
newState?: EntityListState,
overwrite = false,
) => ({
type: ENTITIES_FETCH_SUCCESS,
entityType,
entities,
listKey,
pos,
newState,
overwrite,
});
const entitiesFetchFail = (entityType: Entities, listKey: string | undefined, error: any) => ({
type: ENTITIES_FETCH_FAIL,
entityType,
listKey,
error,
});
const invalidateEntityList = (entityType: Entities, listKey: string) => ({
type: ENTITIES_INVALIDATE_LIST,
entityType,
listKey,
});
const entitiesTransaction = (transaction: EntitiesTransaction) => ({
type: ENTITIES_TRANSACTION,
transaction,
@ -91,11 +43,6 @@ const entitiesTransaction = (transaction: EntitiesTransaction) => ({
type EntityAction =
| ReturnType<typeof importEntities>
| ReturnType<typeof deleteEntities>
| ReturnType<typeof dismissEntities>
| ReturnType<typeof entitiesFetchRequest>
| ReturnType<typeof entitiesFetchSuccess>
| ReturnType<typeof entitiesFetchFail>
| ReturnType<typeof invalidateEntityList>
| ReturnType<typeof entitiesTransaction>;
export {
@ -103,18 +50,8 @@ export {
type EntityAction,
ENTITIES_IMPORT,
ENTITIES_DELETE,
ENTITIES_DISMISS,
ENTITIES_FETCH_REQUEST,
ENTITIES_FETCH_SUCCESS,
ENTITIES_FETCH_FAIL,
ENTITIES_INVALIDATE_LIST,
ENTITIES_TRANSACTION,
importEntities,
deleteEntities,
dismissEntities,
entitiesFetchRequest,
entitiesFetchSuccess,
entitiesFetchFail,
invalidateEntityList,
entitiesTransaction,
};

View File

@ -1,17 +1,11 @@
import type { Account, Group, GroupMember, GroupRelationship } from 'pl-api';
import type { Account } from 'pl-api';
enum Entities {
ACCOUNTS = 'Accounts',
GROUPS = 'Groups',
GROUP_MEMBERSHIPS = 'GroupMemberships',
GROUP_RELATIONSHIPS = 'GroupRelationships',
}
interface EntityTypes {
[Entities.ACCOUNTS]: Account;
[Entities.GROUPS]: Group;
[Entities.GROUP_MEMBERSHIPS]: GroupMember;
[Entities.GROUP_RELATIONSHIPS]: GroupRelationship;
}
export { Entities, type EntityTypes };

View File

@ -4,24 +4,6 @@ import type { BaseSchema, BaseIssue } from 'valibot';
type EntitySchema<TEntity extends Entity = Entity> = BaseSchema<any, TEntity, BaseIssue<unknown>>;
/**
* Tells us where to find/store the entity in the cache.
* This value is accepted in hooks, but needs to be parsed into an `EntitiesPath`
* before being passed to the store.
*/
type ExpandedEntitiesPath = [
/** Name of the entity type for use in the global cache, eg `'Notification'`. */
entityType: Entities,
/**
* Name of a particular index of this entity type.
* Multiple params get combined into one string with a `:` separator.
*/
...listKeys: string[],
];
/** Used to look up an entity in a list. */
type EntitiesPath = [entityType: Entities, listKey: string];
/** Used to look up a single entity by its ID. */
type EntityPath = [entityType: Entities, entityId: string];
@ -37,11 +19,4 @@ interface EntityCallbacks<Value, Error = unknown> {
*/
type EntityFn<T> = (value: T) => Promise<any>;
export type {
EntitySchema,
ExpandedEntitiesPath,
EntitiesPath,
EntityPath,
EntityCallbacks,
EntityFn,
};
export type { EntitySchema, EntityPath, EntityCallbacks, EntityFn };

View File

@ -1,107 +0,0 @@
import { useEffect } from 'react';
import * as v from 'valibot';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useAppSelector } from '@/hooks/use-app-selector';
import { useGetState } from '@/hooks/use-get-state';
import { filteredArray } from '@/schemas/utils';
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '../actions';
import { selectCache, selectListState, useListState } from '../selectors';
import { parseEntitiesPath } from './utils';
import type { Entity } from '../types';
import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
import type { RootState } from '@/store';
interface UseBatchedEntitiesOpts<TEntity extends Entity> {
schema?: EntitySchema<TEntity>;
enabled?: boolean;
}
const useBatchedEntities = <TEntity extends Entity>(
expandedPath: ExpandedEntitiesPath,
ids: string[],
entityFn: EntityFn<string[]>,
opts: UseBatchedEntitiesOpts<TEntity> = {},
) => {
const getState = useGetState();
const dispatch = useAppDispatch();
const { entityType, listKey, path } = parseEntitiesPath(expandedPath);
const schema = opts.schema ?? v.custom<TEntity>(() => true);
const isEnabled = opts.enabled ?? true;
const isFetching = useListState(path, 'fetching');
const lastFetchedAt = useListState(path, 'lastFetchedAt');
const isFetched = useListState(path, 'fetched');
const isInvalid = useListState(path, 'invalid');
const error = useListState(path, 'error');
/** Get IDs of entities not yet in the store. */
const filteredIds = useAppSelector((state) => {
const cache = selectCache(state, path);
if (!cache) return ids;
return ids.filter((id) => !cache.store[id]);
});
const entityMap = useAppSelector((state) => selectEntityMap<TEntity>(state, path, ids));
const fetchEntities = async () => {
const isFetching = selectListState(getState(), path, 'fetching');
if (isFetching) return;
dispatch(entitiesFetchRequest(entityType, listKey));
try {
const response = await entityFn(filteredIds);
const entities = v.parse(filteredArray(schema), response);
dispatch(
entitiesFetchSuccess(entities, entityType, listKey, 'end', {
next: null,
prev: null,
totalCount: undefined,
fetching: false,
fetched: true,
error: null,
lastFetchedAt: new Date(),
invalid: false,
}),
);
} catch (e) {
dispatch(entitiesFetchFail(entityType, listKey, e));
}
};
useEffect(() => {
if (filteredIds.length && isEnabled) {
fetchEntities();
}
}, [filteredIds.length]);
return {
entityMap,
isFetching,
lastFetchedAt,
isFetched,
isError: !!error,
isInvalid,
};
};
const selectEntityMap = <TEntity extends Entity>(
state: RootState,
path: EntitiesPath,
entityIds: string[],
): Record<string, TEntity> => {
const cache = selectCache(state, path);
return entityIds.reduce<Record<string, TEntity>>((result, id) => {
const entity = cache?.store[id];
if (entity) {
result[id] = entity as TEntity;
}
return result;
}, {});
};
export { useBatchedEntities };

View File

@ -1,59 +0,0 @@
import * as v from 'valibot';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useLoading } from '@/hooks/use-loading';
import { importEntities } from '../actions';
import { parseEntitiesPath } from './utils';
import type { Entity } from '../types';
import type { EntityCallbacks, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
import type { PlfeResponse } from '@/api';
interface UseCreateEntityOpts<
TEntity extends Entity = Entity,
TTransformedEntity extends Entity = TEntity,
> {
schema?: EntitySchema<TEntity>;
transform?: (schema: TEntity) => TTransformedEntity;
}
const useCreateEntity = <
TEntity extends Entity = Entity,
TTransformedEntity extends Entity = TEntity,
Data = unknown,
>(
expandedPath: ExpandedEntitiesPath,
entityFn: EntityFn<Data>,
opts: UseCreateEntityOpts<TEntity, TTransformedEntity> = {},
) => {
const dispatch = useAppDispatch();
const [isSubmitting, setPromise] = useLoading();
const { entityType, listKey } = parseEntitiesPath(expandedPath);
const createEntity = async (
data: Data,
callbacks: EntityCallbacks<TTransformedEntity, { response?: PlfeResponse }> = {},
): Promise<void> => {
const result = await setPromise(entityFn(data));
const schema = opts.schema ?? v.custom<TEntity>(() => true);
let entity: TEntity | TTransformedEntity = v.parse(schema, result);
if (opts.transform) entity = opts.transform(entity);
// TODO: optimistic updating
dispatch(importEntities([entity], entityType, listKey, 'start'));
if (callbacks.onSuccess) {
callbacks.onSuccess(entity as TTransformedEntity);
}
};
return {
createEntity,
isSubmitting,
};
};
export { useCreateEntity };

View File

@ -1,57 +0,0 @@
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useGetState } from '@/hooks/use-get-state';
import { useLoading } from '@/hooks/use-loading';
import { deleteEntities, importEntities } from '../actions';
import type { Entities } from '../entities';
import type { EntityCallbacks, EntityFn } from './types';
/**
* Optimistically deletes an entity from the store.
* This hook should be used to globally delete an entity from all lists.
* To remove an entity from a single list, see `useDismissEntity`.
*/
const useDeleteEntity = (entityType: Entities, entityFn: EntityFn<string>) => {
const dispatch = useAppDispatch();
const getState = useGetState();
const [isSubmitting, setPromise] = useLoading();
const deleteEntity = async (
entityId: string,
callbacks: EntityCallbacks<string> = {},
): Promise<void> => {
// Get the entity before deleting, so we can reverse the action if the API request fails.
const entity = getState().entities[entityType]?.store[entityId];
// Optimistically delete the entity from the _store_ but keep the lists in tact.
dispatch(deleteEntities([entityId], entityType, { preserveLists: true }));
try {
await setPromise(entityFn(entityId));
// Success - finish deleting entity from the state.
dispatch(deleteEntities([entityId], entityType));
if (callbacks.onSuccess) {
callbacks.onSuccess(entityId);
}
} catch (e) {
if (entity) {
// If the API failed, reimport the entity.
dispatch(importEntities([entity], entityType));
}
if (callbacks.onError) {
callbacks.onError(e);
}
}
};
return {
deleteEntity,
isSubmitting,
};
};
export { useDeleteEntity };

View File

@ -1,33 +0,0 @@
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useLoading } from '@/hooks/use-loading';
import { dismissEntities } from '../actions';
import { parseEntitiesPath } from './utils';
import type { EntityFn, ExpandedEntitiesPath } from './types';
/**
* Removes an entity from a specific list.
* To remove an entity globally from all lists, see `useDeleteEntity`.
*/
const useDismissEntity = (expandedPath: ExpandedEntitiesPath, entityFn: EntityFn<string>) => {
const dispatch = useAppDispatch();
const [isLoading, setPromise] = useLoading();
const { entityType, listKey } = parseEntitiesPath(expandedPath);
// TODO: optimistic dismissing
const dismissEntity = async (entityId: string) => {
const result = await setPromise(entityFn(entityId));
dispatch(dismissEntities([entityId], entityType, listKey));
return result;
};
return {
dismissEntity,
isLoading,
};
};
export { useDismissEntity };

View File

@ -1,154 +0,0 @@
import { useEffect } from 'react';
import * as v from 'valibot';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useAppSelector } from '@/hooks/use-app-selector';
import { useGetState } from '@/hooks/use-get-state';
import { filteredArray } from '@/schemas/utils';
import {
entitiesFetchFail,
entitiesFetchRequest,
entitiesFetchSuccess,
invalidateEntityList,
} from '../actions';
import { selectEntities, selectListState, useListState } from '../selectors';
import { parseEntitiesPath } from './utils';
import type { Entity } from '../types';
import type { EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
import type { PaginatedResponse } from 'pl-api';
/** Additional options for the hook. */
interface UseEntitiesOpts<TEntity extends Entity, TTransformedEntity extends Entity> {
/** A valibot schema to parse the API entities. */
schema?: EntitySchema<TEntity>;
/**
* Time (milliseconds) until this query becomes stale and should be refetched.
* It is 1 minute by default, and can be set to `Infinity` to opt-out of automatic fetching.
*/
staleTime?: number;
/** A flag to potentially disable sending requests to the API. */
enabled?: boolean;
transform?: (schema: TEntity) => TTransformedEntity;
}
/** A hook for fetching and displaying API entities. */
const useEntities = <TEntity extends Entity, TTransformedEntity extends Entity = TEntity>(
/** Tells us where to find/store the entity in the cache. */
expandedPath: ExpandedEntitiesPath,
/** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */
entityFn: EntityFn<void>,
/** Additional options for the hook. */
opts: UseEntitiesOpts<TEntity, TTransformedEntity> = {},
) => {
const dispatch = useAppDispatch();
const getState = useGetState();
const { entityType, listKey, path } = parseEntitiesPath(expandedPath);
const entities = useAppSelector((state) => selectEntities<TTransformedEntity>(state, path));
const schema = opts.schema ?? v.custom<TEntity>(() => true);
const isEnabled = opts.enabled ?? true;
const isFetching = useListState(path, 'fetching');
const lastFetchedAt = useListState(path, 'lastFetchedAt');
const isFetched = useListState(path, 'fetched');
const isError = !!useListState(path, 'error');
const totalCount = useListState(path, 'totalCount');
const isInvalid = useListState(path, 'invalid');
const next = useListState(path, 'next');
const prev = useListState(path, 'prev');
const fetchPage = async (
req: () => Promise<PaginatedResponse<any>>,
pos: 'start' | 'end',
overwrite = false,
): Promise<void> => {
// Get `isFetching` state from the store again to prevent race conditions.
const isFetching = selectListState(getState(), path, 'fetching');
if (isFetching) return;
dispatch(entitiesFetchRequest(entityType, listKey));
try {
const response = await req();
const entities = v.parse(filteredArray(schema), response);
const transformedEntities = opts.transform && entities.map(opts.transform);
dispatch(
entitiesFetchSuccess(
transformedEntities ?? entities,
entityType,
listKey,
pos,
{
next: response.next,
prev: response.previous,
totalCount: undefined,
fetching: false,
fetched: true,
error: null,
lastFetchedAt: new Date(),
invalid: false,
},
overwrite,
),
);
} catch (error) {
dispatch(entitiesFetchFail(entityType, listKey, error));
}
};
const fetchEntities = async (): Promise<void> => {
await fetchPage(entityFn, 'end', true);
};
const fetchNextPage = async (): Promise<void> => {
if (next) {
await fetchPage(() => next(), 'end');
}
};
const fetchPreviousPage = async (): Promise<void> => {
if (prev) {
await fetchPage(() => prev(), 'start');
}
};
const invalidate = () => {
dispatch(invalidateEntityList(entityType, listKey));
};
const staleTime = opts.staleTime ?? 60000;
useEffect(() => {
if (!isEnabled) return;
if (isFetching) return;
const isUnset = !lastFetchedAt;
const isStale = lastFetchedAt ? Date.now() >= lastFetchedAt.getTime() + staleTime : false;
if (isInvalid || isUnset || isStale) {
fetchEntities();
}
}, [isEnabled, ...path]);
return {
entities,
fetchEntities,
fetchNextPage,
fetchPreviousPage,
hasNextPage: !!next,
hasPreviousPage: !!prev,
totalCount,
isError,
isFetched,
isFetching,
isLoading: isFetching && entities.length === 0,
invalidate,
/** The `X-Total-Count` from the API if available, or the length of items in the store. */
count: typeof totalCount === 'number' ? totalCount : entities.length,
};
};
export { useEntities };

View File

@ -1,15 +0,0 @@
import type { EntitiesPath, ExpandedEntitiesPath } from './types';
const parseEntitiesPath = (expandedPath: ExpandedEntitiesPath) => {
const [entityType, ...listKeys] = expandedPath;
const listKey = (listKeys || []).join(':');
const path: EntitiesPath = [entityType, listKey];
return {
entityType,
listKey,
path,
};
};
export { parseEntitiesPath };

View File

@ -3,11 +3,6 @@ import { create, type Immutable, type Draft } from 'mutative';
import {
ENTITIES_IMPORT,
ENTITIES_DELETE,
ENTITIES_DISMISS,
ENTITIES_FETCH_REQUEST,
ENTITIES_FETCH_SUCCESS,
ENTITIES_FETCH_FAIL,
ENTITIES_INVALIDATE_LIST,
ENTITIES_TRANSACTION,
type EntityAction,
type DeleteEntitiesOpts,
@ -58,18 +53,6 @@ const importEntities = (
}
draft[entityType] = cache;
if (entityType === Entities.GROUPS) {
importEntities(
draft,
Entities.GROUP_RELATIONSHIPS,
entities
.map((entity: any) => entity?.relationship)
.filter((relationship: any) => relationship),
listKey,
pos,
);
}
};
const deleteEntities = (
@ -99,53 +82,6 @@ const deleteEntities = (
draft[entityType] = cache;
};
const dismissEntities = (
draft: Draft<State>,
entityType: string,
ids: Iterable<string>,
listKey: string,
) => {
const cache = draft[entityType] ?? createCache();
const list = cache.lists[listKey];
if (list) {
for (const id of ids) {
list.ids.delete(id);
if (typeof list.state.totalCount === 'number') {
list.state.totalCount--;
}
}
draft[entityType] = cache;
}
};
const setFetching = (
draft: Draft<State>,
entityType: string,
listKey: string | undefined,
isFetching: boolean,
error?: any,
) => {
const cache = draft[entityType] ?? createCache();
if (typeof listKey === 'string') {
const list = cache.lists[listKey] ?? createList();
list.state.fetching = isFetching;
list.state.error = error;
cache.lists[listKey] = list;
}
draft[entityType] = cache;
};
const invalidateEntityList = (draft: Draft<State>, entityType: string, listKey: string) => {
const cache = draft[entityType] ?? createCache();
const list = cache.lists[listKey] ?? createList();
list.state.invalid = true;
};
const doTransaction = (draft: Draft<State>, transaction: EntitiesTransaction) => {
for (const [entityType, changes] of Object.entries(transaction)) {
const cache = draft[entityType] ?? createCache();
@ -173,38 +109,6 @@ const reducer = (state: Readonly<State> = {}, action: EntityAction): State => {
return create(state, (draft) => {
deleteEntities(draft, action.entityType, action.ids, action.opts);
});
case ENTITIES_DISMISS:
return create(state, (draft) => {
dismissEntities(draft, action.entityType, action.ids, action.listKey);
});
case ENTITIES_FETCH_SUCCESS:
return create(
state,
(draft) => {
importEntities(
draft,
action.entityType,
action.entities,
action.listKey,
action.pos,
action.newState,
action.overwrite,
);
},
{ enableAutoFreeze: true },
);
case ENTITIES_FETCH_REQUEST:
return create(state, (draft) => {
setFetching(draft, action.entityType, action.listKey, true);
});
case ENTITIES_FETCH_FAIL:
return create(state, (draft) => {
setFetching(draft, action.entityType, action.listKey, false, action.error);
});
case ENTITIES_INVALIDATE_LIST:
return create(state, (draft) => {
invalidateEntityList(draft, action.entityType, action.listKey);
});
case ENTITIES_TRANSACTION:
return create(state, (draft) => {
doTransaction(draft, action.transaction);

View File

@ -1,34 +1,6 @@
import { useAppSelector } from '@/hooks/use-app-selector';
import type { EntitiesPath } from './hooks/types';
import type { Entity, EntityListState } from './types';
import type { Entity } from './types';
import type { RootState } from '@/store';
/** Get cache at path from Redux. */
const selectCache = (state: RootState, path: EntitiesPath) => state.entities[path[0]];
/** Get list at path from Redux. */
const selectList = (state: RootState, path: EntitiesPath) => {
const [, ...listKeys] = path;
const listKey = listKeys.join(':');
return selectCache(state, path)?.lists[listKey];
};
/** Select a particular item from a list state. */
const selectListState = <K extends keyof EntityListState>(
state: RootState,
path: EntitiesPath,
key: K,
) => {
const listState = selectList(state, path)?.state;
return listState ? listState[key] : undefined;
};
/** Hook to get a particular item from a list state. */
const useListState = <K extends keyof EntityListState>(path: EntitiesPath, key: K) =>
useAppSelector((state) => selectListState(state, path, key));
/** Get a single entity by its ID from the store. */
const selectEntity = <TEntity extends Entity>(
state: RootState,
@ -36,27 +8,6 @@ const selectEntity = <TEntity extends Entity>(
id: string,
): TEntity | undefined => state.entities[entityType]?.store[id] as TEntity | undefined;
/** Get list of entities from Redux. */
const selectEntities = <TEntity extends Entity>(
state: RootState,
path: EntitiesPath,
): readonly TEntity[] => {
const cache = selectCache(state, path);
const list = selectList(state, path);
const entityIds = list?.ids;
return entityIds
? Array.from(entityIds).reduce<TEntity[]>((result, id) => {
const entity = cache?.store[id];
if (entity) {
result.push(entity as TEntity);
}
return result;
}, [])
: [];
};
/** Find an entity using a finder function. */
const findEntity = <TEntity extends Entity>(
state: RootState,
@ -70,4 +21,4 @@ const findEntity = <TEntity extends Entity>(
}
};
export { selectCache, selectListState, useListState, selectEntities, selectEntity, findEntity };
export { selectEntity, findEntity };

View File

@ -7,10 +7,7 @@ import { useAccount } from '@/api/hooks/accounts/use-account';
import Account from '@/components/account';
import DropdownMenu from '@/components/dropdown-menu/dropdown-menu';
import HStack from '@/components/ui/hstack';
import { deleteEntities } from '@/entity-store/actions';
import { Entities } from '@/entity-store/entities';
import PlaceholderAccount from '@/features/placeholder/components/placeholder-account';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useBlockGroupUserMutation } from '@/queries/groups/use-group-blocks';
import {
useDemoteGroupMemberMutation,
@ -73,7 +70,6 @@ interface IGroupMemberListItem {
}
const GroupMemberListItem = ({ member, group }: IGroupMemberListItem) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { openModal } = useModalsActions();
@ -117,7 +113,6 @@ const GroupMemberListItem = ({ member, group }: IGroupMemberListItem) => {
onConfirm: () => {
blockGroupMember(undefined, {
onSuccess() {
dispatch(deleteEntities([member.id], Entities.GROUP_MEMBERSHIPS));
toast.success(intl.formatMessage(messages.blocked, { name: account?.acct }));
},
});

View File

@ -14,6 +14,7 @@ import Icon from '@/components/ui/icon';
import Stack from '@/components/ui/stack';
import Text from '@/components/ui/text';
import Emojify from '@/features/emoji/emojify';
import { useGroupQuery } from '@/queries/groups/use-group';
import StatusInteractionBar from './status-interaction-bar';
import StatusTypeIcon from './status-type-icon';
@ -39,12 +40,14 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
const node = useRef<HTMLDivElement>(null);
const { data: group } = useGroupQuery(status.group_id ?? undefined);
const handleOpenCompareHistoryModal = () => {
onOpenCompareHistoryModal(status);
};
const renderStatusInfo = () => {
if (status.group) {
if (status.group_id) {
return (
<div className='mb-4'>
<StatusInfo
@ -58,28 +61,32 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
/>
}
text={
<FormattedMessage
id='status.group'
defaultMessage='Posted in {group}'
values={{
group: (
<Link
to='/groups/$groupId'
params={{ groupId: status.group.id }}
className='hover:underline'
>
<bdi className='truncate'>
<strong className='text-gray-800 dark:text-gray-200'>
<Emojify
text={status.account.display_name}
emojis={status.account.emojis}
/>
</strong>
</bdi>
</Link>
),
}}
/>
group ? (
<FormattedMessage
id='status.group'
defaultMessage='Posted in {group}'
values={{
group: (
<Link
to='/groups/$groupId'
params={{ groupId: status.group.id }}
className='hover:underline'
>
<bdi className='truncate'>
<strong className='text-gray-800 dark:text-gray-200'>
<Emojify
text={status.account.display_name}
emojis={status.account.emojis}
/>
</strong>
</bdi>
</Link>
),
}}
/>
) : (
<FormattedMessage id='status.group.unknown' defaultMessage='Posted in a group' />
)
}
/>
</div>

View File

@ -2,9 +2,9 @@ import React, { useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import * as v from 'valibot';
import { useCreateGroup } from '@/api/hooks/groups/use-create-group';
import Modal from '@/components/ui/modal';
import Stack from '@/components/ui/stack';
import { useCreateGroupMutation } from '@/queries/groups/use-group';
import toast from '@/toast';
import ConfirmationStep from './steps/confirmation-step';
@ -32,7 +32,7 @@ const CreateGroupModal: React.FC<BaseModalProps> = ({ onClose }) => {
});
const [currentStep, setCurrentStep] = useState<Steps>(Steps.ONE);
const { createGroup, isSubmitting } = useCreateGroup();
const { mutate: createGroup, isPending } = useCreateGroupMutation();
const handleClose = () => {
onClose('CREATE_GROUP');
@ -55,8 +55,11 @@ const CreateGroupModal: React.FC<BaseModalProps> = ({ onClose }) => {
setCurrentStep(Steps.TWO);
setGroup(group);
},
onError(error: { response?: PlfeResponse }) {
const msg = v.safeParse(v.object({ error: v.string() }), error?.response?.json);
onError(error) {
const msg = v.safeParse(
v.object({ error: v.string() }),
(error as { response?: PlfeResponse })?.response?.json,
);
if (msg.success) {
toast.error(msg.output.error);
}
@ -89,7 +92,7 @@ const CreateGroupModal: React.FC<BaseModalProps> = ({ onClose }) => {
title={renderModalTitle()}
confirmationAction={handleNextStep}
confirmationText={confirmationText}
confirmationDisabled={isSubmitting}
confirmationDisabled={isPending}
onClose={handleClose}
>
<Stack space={2}>{renderStep()}</Stack>

View File

@ -121,9 +121,6 @@ const normalizeStatus = (
};
}
// Normalize group
const group = status.group ?? null;
return {
account_id: status.account.id,
reblog_id: status.reblog?.id ?? null,
@ -136,7 +133,6 @@ const normalizeStatus = (
quote_id: status.quote_id ?? null,
mentions,
event,
group,
media_attachments,
search_index: searchIndex,
};

View File

@ -1,7 +1,6 @@
import React, { useState } from 'react';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useUpdateGroup } from '@/api/hooks/groups/use-update-group';
import Button from '@/components/ui/button';
import Column from '@/components/ui/column';
import Form from '@/components/ui/form';
@ -18,9 +17,12 @@ import { useImageField } from '@/hooks/forms/use-image-field';
import { useTextField } from '@/hooks/forms/use-text-field';
import { useAppSelector } from '@/hooks/use-app-selector';
import { useInstance } from '@/hooks/use-instance';
import { useGroupQuery } from '@/queries/groups/use-group';
import { useGroupQuery, useUpdateGroupMutation } from '@/queries/groups/use-group';
import toast from '@/toast';
import { unescapeHTML } from '@/utils/html';
import type { PlfeResponse } from '@/api';
const messages = defineMessages({
heading: { id: 'navigation_bar.edit_group', defaultMessage: 'Edit Group' },
groupNamePlaceholder: {
@ -41,9 +43,7 @@ const EditGroup: React.FC = () => {
const instance = useInstance();
const { data: group, isLoading } = useGroupQuery(groupId);
const { updateGroup } = useUpdateGroup(groupId);
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutate: updateGroup, isPending: isUpdatePending } = useUpdateGroupMutation(groupId);
const avatar = useImageField({
maxPixels: 400 * 400,
@ -67,8 +67,6 @@ const EditGroup: React.FC = () => {
.join(',');
const handleSubmit = async () => {
setIsSubmitting(true);
await updateGroup(
{
display_name: displayName.value,
@ -81,16 +79,15 @@ const EditGroup: React.FC = () => {
toast.success(intl.formatMessage(messages.groupSaved));
},
onError(error) {
const message = error.response?.json?.error;
const response = (error as { response?: PlfeResponse })?.response;
const message = response?.json?.error;
if (error.response?.status === 422 && typeof message !== 'undefined') {
if (response?.status === 422 && typeof message !== 'undefined') {
toast.error(message);
}
},
},
);
setIsSubmitting(false);
};
if (isLoading) {
@ -101,8 +98,8 @@ const EditGroup: React.FC = () => {
<Column label={intl.formatMessage(messages.heading)}>
<Form onSubmit={handleSubmit}>
<div className='relative mb-12 flex'>
<HeaderPicker accept={attachmentTypes} disabled={isSubmitting} {...header} />
<AvatarPicker accept={attachmentTypes} disabled={isSubmitting} {...avatar} />
<HeaderPicker accept={attachmentTypes} disabled={isUpdatePending} {...header} />
<AvatarPicker accept={attachmentTypes} disabled={isUpdatePending} {...avatar} />
</div>
<FormGroup
labelText={
@ -149,7 +146,7 @@ const EditGroup: React.FC = () => {
</FormGroup>
<FormActions>
<Button theme='primary' type='submit' disabled={isSubmitting} block>
<Button theme='primary' type='submit' disabled={isUpdatePending} block>
<FormattedMessage id='edit_profile.save' defaultMessage='Save' />
</Button>
</FormActions>

View File

@ -3,7 +3,6 @@ import { GroupRoles } from 'pl-api';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDeleteGroup } from '@/api/hooks/groups/use-delete-group';
import List, { ListItem } from '@/components/list';
import { CardBody, CardHeader, CardTitle } from '@/components/ui/card';
import Column from '@/components/ui/column';
@ -12,7 +11,7 @@ import Text from '@/components/ui/text';
import Emojify from '@/features/emoji/emojify';
import ColumnForbidden from '@/features/ui/components/column-forbidden';
import { manageGroupRoute } from '@/features/ui/router';
import { useGroupQuery } from '@/queries/groups/use-group';
import { useDeleteGroupMutation, useGroupQuery } from '@/queries/groups/use-group';
import { useModalsActions } from '@/stores/modals';
import toast from '@/toast';
@ -43,7 +42,7 @@ const ManageGroup: React.FC = () => {
const { data: group } = useGroupQuery(groupId, true);
const deleteGroup = useDeleteGroup();
const { mutate: deleteGroup } = useDeleteGroupMutation(groupId);
const isOwner = group?.relationship?.role === GroupRoles.OWNER;
@ -68,7 +67,7 @@ const ManageGroup: React.FC = () => {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
deleteGroup.mutate(groupId, {
deleteGroup(undefined, {
onSuccess() {
toast.success(intl.formatMessage(messages.deleteSuccess));
navigate({ to: '/groups' });

View File

@ -1,10 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useClient } from '@/hooks/use-client';
import { useGroupRelationshipQuery } from './use-group-relationship';
import type { CreateGroupParams, UpdateGroupParams } from 'pl-api';
const useGroupQuery = (groupId?: string, withRelationship = true) => {
const client = useClient();
@ -30,4 +32,46 @@ const useGroupQuery = (groupId?: string, withRelationship = true) => {
);
};
export { useGroupQuery };
const useCreateGroupMutation = () => {
const client = useClient();
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['groups', 'create'],
mutationFn: (params: CreateGroupParams) => client.experimental.groups.createGroup(params),
onSuccess: (data) => {
queryClient.setQueryData(['groups', data.id], data);
queryClient.invalidateQueries({ queryKey: ['groupLists', 'myGroups'] });
},
});
};
const useUpdateGroupMutation = (groupId: string) => {
const client = useClient();
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['groups', 'update'],
mutationFn: (params: UpdateGroupParams) =>
client.experimental.groups.updateGroup(groupId, params),
onSuccess: (data) => {
queryClient.setQueryData(['groups', data.id], data);
},
});
};
const useDeleteGroupMutation = (groupId: string) => {
const client = useClient();
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['groups', 'delete'],
mutationFn: () => client.experimental.groups.deleteGroup(groupId),
onSuccess: () => {
queryClient.removeQueries({ queryKey: ['groups', groupId] });
queryClient.invalidateQueries({ queryKey: ['groupLists', 'myGroups'] });
},
});
};
export { useGroupQuery, useCreateGroupMutation, useUpdateGroupMutation, useDeleteGroupMutation };