Switch to workspace

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2024-08-28 12:46:03 +02:00
parent 694abcb489
commit 4d5690d0c1
1318 changed files with 12005 additions and 11618 deletions

View File

@@ -0,0 +1,60 @@
import { type Account as BaseAccount, accountSchema } from 'pl-api';
import { useEffect, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntity } from 'soapbox/entity-store/hooks';
import { useAppSelector, useClient, useFeatures, useLoggedIn } from 'soapbox/hooks';
import { type Account, normalizeAccount } from 'soapbox/normalizers';
import { useRelationship } from './useRelationship';
interface UseAccountOpts {
withRelationship?: boolean;
}
const useAccount = (accountId?: string, opts: UseAccountOpts = {}) => {
const client = useClient();
const history = useHistory();
const features = useFeatures();
const { me } = useLoggedIn();
const { withRelationship } = opts;
const { entity, isUnauthorized, ...result } = useEntity<BaseAccount, Account>(
[Entities.ACCOUNTS, accountId!],
() => client.accounts.getAccount(accountId!),
{ schema: accountSchema, enabled: !!accountId, transform: normalizeAccount },
);
const meta = useAppSelector((state) => accountId && state.accounts_meta[accountId] || {});
const {
relationship,
isLoading: isRelationshipLoading,
} = useRelationship(accountId, { enabled: withRelationship });
const isBlocked = entity?.relationship?.blocked_by === true;
const isUnavailable = (me === entity?.id) ? false : (isBlocked && !features.blockersVisible);
const account = useMemo(
() => entity ? { ...entity, relationship, __meta: { meta, ...entity.__meta } } : undefined,
[entity, relationship],
);
useEffect(() => {
if (isUnauthorized) {
history.push('/login');
}
}, [isUnauthorized]);
return {
...result,
isLoading: result.isLoading,
isRelationshipLoading,
isUnauthorized,
isUnavailable,
account,
};
};
export { useAccount };

View File

@@ -0,0 +1,80 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { Entities } from 'soapbox/entity-store/entities';
import { useClient } from 'soapbox/hooks';
import { type Account, normalizeAccount } from 'soapbox/normalizers';
import { flattenPages } from 'soapbox/utils/queries';
import { useRelationships } from './useRelationships';
import type { PaginatedResponse, Account as BaseAccount } from 'pl-api';
import type { EntityFn } from 'soapbox/entity-store/hooks/types';
const useAccountList = (listKey: string[], entityFn: EntityFn<void>) => {
const getAccounts = async (pageParam?: Pick<PaginatedResponse<BaseAccount>, 'next'>) => {
const response = await (pageParam?.next ? pageParam.next() : entityFn()) as PaginatedResponse<BaseAccount>;
return {
...response,
items: response.items.map(normalizeAccount),
};
};
const queryInfo = useInfiniteQuery({
queryKey: [Entities.ACCOUNTS, ...listKey],
queryFn: ({ pageParam }) => getAccounts(pageParam),
enabled: true,
initialPageParam: { next: null as (() => Promise<PaginatedResponse<BaseAccount>>) | null },
getNextPageParam: (config) => config.next ? config : undefined,
});
const data = flattenPages<Account>(queryInfo.data as any)?.toReversed() || [];
const { relationships } = useRelationships(
listKey,
data.map(({ id }) => id),
);
const accounts = data.map((account) => ({
...account,
relationship: relationships[account.id],
}));
return { accounts, ...queryInfo };
};
const useBlocks = () => {
const client = useClient();
return useAccountList(['blocks'], () => client.filtering.getBlocks());
};
const useMutes = () => {
const client = useClient();
return useAccountList(['mutes'], () => client.filtering.getMutes());
};
const useFollowing = (accountId: string | undefined) => {
const client = useClient();
return useAccountList(
[accountId!, 'following'],
() => client.accounts.getAccountFollowing(accountId!),
);
};
const useFollowers = (accountId: string | undefined) => {
const client = useClient();
return useAccountList(
[accountId!, 'followers'],
() => client.accounts.getAccountFollowers(accountId!),
);
};
export {
useAccountList,
useBlocks,
useMutes,
useFollowing,
useFollowers,
};

View File

@@ -0,0 +1,54 @@
import { accountSchema, type Account as BaseAccount } from 'pl-api';
import { useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityLookup } from 'soapbox/entity-store/hooks';
import { useClient, useFeatures, useLoggedIn } from 'soapbox/hooks';
import { type Account, normalizeAccount } from 'soapbox/normalizers';
import { useRelationship } from './useRelationship';
interface UseAccountLookupOpts {
withRelationship?: boolean;
}
const useAccountLookup = (acct: string | undefined, opts: UseAccountLookupOpts = {}) => {
const client = useClient();
const features = useFeatures();
const history = useHistory();
const { me } = useLoggedIn();
const { withRelationship } = opts;
const { entity: account, isUnauthorized, ...result } = useEntityLookup<BaseAccount, Account>(
Entities.ACCOUNTS,
(account) => account.acct.toLowerCase() === acct?.toLowerCase(),
() => client.accounts.lookupAccount(acct!),
{ schema: accountSchema, enabled: !!acct, transform: normalizeAccount },
);
const {
relationship,
isLoading: isRelationshipLoading,
} = useRelationship(account?.id, { enabled: withRelationship });
const isBlocked = account?.relationship?.blocked_by === true;
const isUnavailable = (me === account?.id) ? false : (isBlocked && !features.blockersVisible);
useEffect(() => {
if (isUnauthorized) {
history.push('/login');
}
}, [isUnauthorized]);
return {
...result,
isLoading: result.isLoading,
isRelationshipLoading,
isUnauthorized,
isUnavailable,
account: account ? { ...account, relationship } : undefined,
};
};
export { useAccountLookup };

View File

@@ -0,0 +1,85 @@
import { importEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities';
import { useTransaction } from 'soapbox/entity-store/hooks';
import { useAppDispatch, useClient, useLoggedIn } from 'soapbox/hooks';
interface FollowOpts {
reblogs?: boolean;
notify?: boolean;
languages?: string[];
}
const useFollow = () => {
const client = useClient();
const dispatch = useAppDispatch();
const { isLoggedIn } = useLoggedIn();
const { transaction } = useTransaction();
const followEffect = (accountId: string) => {
transaction({
Accounts: {
[accountId]: (account) => ({
...account,
followers_count: account.followers_count + 1,
}),
},
Relationships: {
[accountId]: (relationship) => ({
...relationship,
following: true,
}),
},
});
};
const unfollowEffect = (accountId: string) => {
transaction({
Accounts: {
[accountId]: (account) => ({
...account,
followers_count: Math.max(0, account.followers_count - 1),
}),
},
Relationships: {
[accountId]: (relationship) => ({
...relationship,
following: false,
}),
},
});
};
const follow = async (accountId: string, options: FollowOpts = {}) => {
if (!isLoggedIn) return;
followEffect(accountId);
try {
const response = await client.accounts.followAccount(accountId, options);
if (response.id) {
dispatch(importEntities([response], Entities.RELATIONSHIPS));
}
} catch (e) {
unfollowEffect(accountId);
}
};
const unfollow = async (accountId: string) => {
if (!isLoggedIn) return;
unfollowEffect(accountId);
try {
await client.accounts.unfollowAccount(accountId);
} catch (e) {
followEffect(accountId);
}
};
return {
follow,
unfollow,
followEffect,
unfollowEffect,
};
};
export { useFollow };

View File

@@ -0,0 +1,28 @@
import { type Relationship, relationshipSchema } from 'pl-api';
import { z } from 'zod';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
interface UseRelationshipOpts {
enabled?: boolean;
}
const useRelationship = (accountId: string | undefined, opts: UseRelationshipOpts = {}) => {
const client = useClient();
const { enabled = false } = opts;
const { entity: relationship, ...result } = useEntity<Relationship>(
[Entities.RELATIONSHIPS, accountId!],
() => client.accounts.getRelationships([accountId!]),
{
enabled: enabled && !!accountId,
schema: z.array(relationshipSchema).nonempty().transform(arr => arr[0]),
},
);
return { relationship, ...result };
};
export { useRelationship };

View File

@@ -0,0 +1,23 @@
import { type Relationship, relationshipSchema } from 'pl-api';
import { Entities } from 'soapbox/entity-store/entities';
import { useBatchedEntities } from 'soapbox/entity-store/hooks/useBatchedEntities';
import { useClient, useLoggedIn } from 'soapbox/hooks';
const useRelationships = (listKey: string[], accountIds: string[]) => {
const client = useClient();
const { isLoggedIn } = useLoggedIn();
const fetchRelationships = (accountIds: string[]) => client.accounts.getRelationships(accountIds);
const { entityMap: relationships, ...result } = useBatchedEntities<Relationship>(
[Entities.RELATIONSHIPS, ...listKey],
accountIds,
fetchRelationships,
{ schema: relationshipSchema, enabled: isLoggedIn },
);
return { relationships, ...result };
};
export { useRelationships };

View File

@@ -0,0 +1,6 @@
export { useDomains } from './useDomains';
export { useModerationLog } from './useModerationLog';
export { useRelays } from './useRelays';
export { useRules } from './useRules';
export { useSuggest } from './useSuggest';
export { useVerify } from './useVerify';

View File

@@ -0,0 +1,91 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useClient } from 'soapbox/hooks';
import { queryClient } from 'soapbox/queries/client';
import { adminAnnouncementSchema, type AdminAnnouncement } from 'soapbox/schemas';
import { useAnnouncements as useUserAnnouncements } from '../announcements';
interface CreateAnnouncementParams {
content: string;
starts_at?: string | null;
ends_at?: string | null;
all_day?: boolean;
}
interface UpdateAnnouncementParams extends CreateAnnouncementParams {
id: string;
}
const useAnnouncements = () => {
const client = useClient();
const userAnnouncements = useUserAnnouncements();
const getAnnouncements = async () => {
const { json: data } = await client.request<AdminAnnouncement[]>('/api/v1/pleroma/admin/announcements');
const normalizedData = data.map((announcement) => adminAnnouncementSchema.parse(announcement));
return normalizedData;
};
const result = useQuery<ReadonlyArray<AdminAnnouncement>>({
queryKey: ['admin', 'announcements'],
queryFn: getAnnouncements,
placeholderData: [],
});
const {
mutate: createAnnouncement,
isPending: isCreating,
} = useMutation({
mutationFn: (params: CreateAnnouncementParams) => client.request('/api/v1/pleroma/admin/announcements', {
method: 'POST', body: params,
}),
retry: false,
onSuccess: ({ json: data }) =>
queryClient.setQueryData(['admin', 'announcements'], (prevResult: ReadonlyArray<AdminAnnouncement>) =>
[...prevResult, adminAnnouncementSchema.parse(data)],
),
onSettled: () => userAnnouncements.refetch(),
});
const {
mutate: updateAnnouncement,
isPending: isUpdating,
} = useMutation({
mutationFn: ({ id, ...params }: UpdateAnnouncementParams) => client.request(`/api/v1/pleroma/admin/announcements/${id}`, {
method: 'PATCH', body: params,
}),
retry: false,
onSuccess: ({ json: data }) =>
queryClient.setQueryData(['admin', 'announcements'], (prevResult: ReadonlyArray<AdminAnnouncement>) =>
prevResult.map((announcement) => announcement.id === data.id ? adminAnnouncementSchema.parse(data) : announcement),
),
onSettled: () => userAnnouncements.refetch(),
});
const {
mutate: deleteAnnouncement,
isPending: isDeleting,
} = useMutation({
mutationFn: (id: string) => client.request(`/api/v1/pleroma/admin/announcements/${id}`, { method: 'DELETE' }),
retry: false,
onSuccess: (_, id) =>
queryClient.setQueryData(['admin', 'announcements'], (prevResult: ReadonlyArray<AdminAnnouncement>) =>
prevResult.filter(({ id: announcementId }) => announcementId !== id),
),
onSettled: () => userAnnouncements.refetch(),
});
return {
...result,
createAnnouncement,
isCreating,
updateAnnouncement,
isUpdating,
deleteAnnouncement,
isDeleting,
};
};
export { useAnnouncements };

View File

@@ -0,0 +1,84 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useClient } from 'soapbox/hooks';
import { queryClient } from 'soapbox/queries/client';
import { domainSchema, type Domain } from 'soapbox/schemas';
interface CreateDomainParams {
domain: string;
public: boolean;
}
interface UpdateDomainParams {
id: string;
public: boolean;
}
const useDomains = () => {
const client = useClient();
const getDomains = async () => {
const { json: data } = await client.request<Domain[]>('/api/v1/pleroma/admin/domains');
const normalizedData = data.map((domain) => domainSchema.parse(domain));
return normalizedData;
};
const result = useQuery<ReadonlyArray<Domain>>({
queryKey: ['admin', 'domains'],
queryFn: getDomains,
placeholderData: [],
});
const {
mutate: createDomain,
isPending: isCreating,
} = useMutation({
mutationFn: (params: CreateDomainParams) => client.request('/api/v1/pleroma/admin/domains', {
method: 'POST', body: params,
}),
retry: false,
onSuccess: ({ data }) =>
queryClient.setQueryData(['admin', 'domains'], (prevResult: ReadonlyArray<Domain>) =>
[...prevResult, domainSchema.parse(data)],
),
});
const {
mutate: updateDomain,
isPending: isUpdating,
} = useMutation({
mutationFn: ({ id, ...params }: UpdateDomainParams) => client.request(`/api/v1/pleroma/admin/domains/${id}`, {
method: 'PATCH', body: params,
}),
retry: false,
onSuccess: ({ json: data }) =>
queryClient.setQueryData(['admin', 'domains'], (prevResult: ReadonlyArray<Domain>) =>
prevResult.map((domain) => domain.id === data.id ? domainSchema.parse(data) : domain),
),
});
const {
mutate: deleteDomain,
isPending: isDeleting,
} = useMutation({
mutationFn: (id: string) => client.request(`/api/v1/pleroma/admin/domains/${id}`, { method: 'DELETE' }),
retry: false,
onSuccess: (_, id) =>
queryClient.setQueryData(['admin', 'domains'], (prevResult: ReadonlyArray<Domain>) =>
prevResult.filter(({ id: domainId }) => domainId !== id),
),
});
return {
...result,
createDomain,
isCreating,
updateDomain,
isUpdating,
deleteDomain,
isDeleting,
};
};
export { useDomains };

View File

@@ -0,0 +1,42 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useClient } from 'soapbox/hooks';
import { moderationLogEntrySchema, type ModerationLogEntry } from 'soapbox/schemas';
interface ModerationLogResult {
items: ModerationLogEntry[];
total: number;
}
const flattenPages = (pages?: ModerationLogResult[]): ModerationLogEntry[] => (pages || []).map(({ items }) => items).flat();
const useModerationLog = () => {
const client = useClient();
const getModerationLog = async (page: number): Promise<ModerationLogResult> => {
const { json: data } = await client.request<ModerationLogResult>('/api/v1/pleroma/admin/moderation_log', { params: { page } });
const normalizedData = data.items.map((domain) => moderationLogEntrySchema.parse(domain));
return {
items: normalizedData,
total: data.total,
};
};
const queryInfo = useInfiniteQuery({
queryKey: ['admin', 'moderation_log'],
queryFn: ({ pageParam }) => getModerationLog(pageParam),
initialPageParam: 1,
getNextPageParam: (page, allPages) => flattenPages(allPages)!.length >= page.total ? undefined : allPages.length + 1,
});
const data = flattenPages(queryInfo.data?.pages);
return {
...queryInfo,
data,
};
};
export { useModerationLog };

View File

@@ -0,0 +1,62 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useClient } from 'soapbox/hooks';
import { queryClient } from 'soapbox/queries/client';
import { relaySchema, type Relay } from 'soapbox/schemas';
const useRelays = () => {
const client = useClient();
const getRelays = async () => {
const { json: data } = await client.request<{ relays: Relay[] }>('/api/v1/pleroma/admin/relay');
const normalizedData = data.relays?.map((relay) => relaySchema.parse(relay));
return normalizedData;
};
const result = useQuery<ReadonlyArray<Relay>>({
queryKey: ['admin', 'relays'],
queryFn: getRelays,
placeholderData: [],
});
const {
mutate: followRelay,
isPending: isPendingFollow,
} = useMutation({
mutationFn: (relayUrl: string) => client.request('/api/v1/pleroma/admin/relays', {
method: 'POST',
body: JSON.stringify({ relay_url: relayUrl }),
}),
retry: false,
onSuccess: ({ json: data }) =>
queryClient.setQueryData(['admin', 'relays'], (prevResult: ReadonlyArray<Relay>) =>
[...prevResult, relaySchema.parse(data)],
),
});
const {
mutate: unfollowRelay,
isPending: isPendingUnfollow,
} = useMutation({
mutationFn: (relayUrl: string) => client.request('/api/v1/pleroma/admin/relays', {
method: 'DELETE',
body: JSON.stringify({ relay_url: relayUrl }),
}),
retry: false,
onSuccess: (_, relayUrl) =>
queryClient.setQueryData(['admin', 'relays'], (prevResult: ReadonlyArray<Relay>) =>
prevResult.filter(({ actor }) => actor !== relayUrl),
),
});
return {
...result,
followRelay,
isPendingFollow,
unfollowRelay,
isPendingUnfollow,
};
};
export { useRelays };

View File

@@ -0,0 +1,87 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useClient } from 'soapbox/hooks';
import { queryClient } from 'soapbox/queries/client';
import { adminRuleSchema, type AdminRule } from 'soapbox/schemas';
interface CreateRuleParams {
priority?: number;
text: string;
hint?: string;
}
interface UpdateRuleParams {
id: string;
priority?: number;
text?: string;
hint?: string;
}
const useRules = () => {
const client = useClient();
const getRules = async () => {
const { json: data } = await client.request<AdminRule[]>('/api/v1/pleroma/admin/rules');
const normalizedData = data.map((rule) => adminRuleSchema.parse(rule));
return normalizedData;
};
const result = useQuery<ReadonlyArray<AdminRule>>({
queryKey: ['admin', 'rules'],
queryFn: getRules,
placeholderData: [],
});
const {
mutate: createRule,
isPending: isCreating,
} = useMutation({
mutationFn: (params: CreateRuleParams) => client.request('/api/v1/pleroma/admin/rules', {
method: 'POST', body: params,
}),
retry: false,
onSuccess: ({ json: data }) =>
queryClient.setQueryData(['admin', 'rules'], (prevResult: ReadonlyArray<AdminRule>) =>
[...prevResult, adminRuleSchema.parse(data)],
),
});
const {
mutate: updateRule,
isPending: isUpdating,
} = useMutation({
mutationFn: ({ id, ...params }: UpdateRuleParams) => client.request(`/api/v1/pleroma/admin/rules/${id}`, {
method: 'PATCH', body: params,
}),
retry: false,
onSuccess: ({ json: data }) =>
queryClient.setQueryData(['admin', 'rules'], (prevResult: ReadonlyArray<AdminRule>) =>
prevResult.map((rule) => rule.id === data.id ? adminRuleSchema.parse(data) : rule),
),
});
const {
mutate: deleteRule,
isPending: isDeleting,
} = useMutation({
mutationFn: (id: string) => client.request(`/api/v1/pleroma/admin/rules/${id}`, { method: 'DELETE' }),
retry: false,
onSuccess: (_, id) =>
queryClient.setQueryData(['admin', 'rules'], (prevResult: ReadonlyArray<AdminRule>) =>
prevResult.filter(({ id: ruleId }) => ruleId !== id),
),
});
return {
...result,
createRule,
isCreating,
updateRule,
isUpdating,
deleteRule,
isDeleting,
};
};
export { useRules };

View File

@@ -0,0 +1,60 @@
import { useTransaction } from 'soapbox/entity-store/hooks';
import { EntityCallbacks } from 'soapbox/entity-store/hooks/types';
import { useClient, useGetState } from 'soapbox/hooks';
import { accountIdsToAccts } from 'soapbox/selectors';
import type { Account } from 'soapbox/normalizers';
const useSuggest = () => {
const client = useClient();
const getState = useGetState();
const { transaction } = useTransaction();
const suggestEffect = (accountIds: string[], suggested: boolean) => {
const updater = (account: Account): Account => {
account.is_suggested = suggested;
return account;
};
transaction({
Accounts: accountIds.reduce<Record<string, (account: Account) => Account>>(
(result, id) => ({ ...result, [id]: updater }),
{}),
});
};
const suggest = async (accountIds: string[], callbacks?: EntityCallbacks<void, unknown>) => {
const accts = accountIdsToAccts(getState(), accountIds);
suggestEffect(accountIds, true);
try {
await client.request('/api/v1/pleroma/admin/users/suggest', {
method: 'PATCH', body: { nicknames: accts },
});
callbacks?.onSuccess?.();
} catch (e) {
callbacks?.onError?.(e);
suggestEffect(accountIds, false);
}
};
const unsuggest = async (accountIds: string[], callbacks?: EntityCallbacks<void, unknown>) => {
const accts = accountIdsToAccts(getState(), accountIds);
suggestEffect(accountIds, false);
try {
await client.request('/api/v1/pleroma/admin/users/unsuggest', {
method: 'PATCH', body: { nicknames: accts },
});
callbacks?.onSuccess?.();
} catch (e) {
callbacks?.onError?.(e);
suggestEffect(accountIds, true);
}
};
return {
suggest,
unsuggest,
};
};
export { useSuggest };

View File

@@ -0,0 +1,69 @@
import { useTransaction } from 'soapbox/entity-store/hooks';
import { EntityCallbacks } from 'soapbox/entity-store/hooks/types';
import { useClient, useGetState } from 'soapbox/hooks';
import { accountIdsToAccts } from 'soapbox/selectors';
import type { Account } from 'soapbox/normalizers';
const useVerify = () => {
const client = useClient();
const getState = useGetState();
const { transaction } = useTransaction();
const verifyEffect = (accountIds: string[], verified: boolean) => {
const updater = (account: Account): Account => {
if (account.__meta.pleroma) {
const tags = account.__meta.pleroma.tags.filter((tag: string) => tag !== 'verified');
if (verified) {
tags.push('verified');
}
account.__meta.pleroma.tags = tags;
}
account.verified = verified;
return account;
};
transaction({
Accounts: accountIds.reduce<Record<string, (account: Account) => Account>>(
(result, id) => ({ ...result, [id]: updater }),
{}),
});
};
const verify = async (accountIds: string[], callbacks?: EntityCallbacks<void, unknown>) => {
const accts = accountIdsToAccts(getState(), accountIds);
verifyEffect(accountIds, true);
try {
await client.request('/api/v1/pleroma/admin/users/tag', {
method: 'PUT',
body: { nicknames: accts, tags: ['verified'] },
});
callbacks?.onSuccess?.();
} catch (e) {
callbacks?.onError?.(e);
verifyEffect(accountIds, false);
}
};
const unverify = async (accountIds: string[], callbacks?: EntityCallbacks<void, unknown>) => {
const accts = accountIdsToAccts(getState(), accountIds);
verifyEffect(accountIds, false);
try {
await client.request('/api/v1/pleroma/admin/users/tag', {
method: 'DELETE',
body: { nicknames: accts, tags: ['verified'] },
});
callbacks?.onSuccess?.();
} catch (e) {
callbacks?.onError?.(e);
verifyEffect(accountIds, true);
}
};
return {
verify,
unverify,
};
};
export { useVerify };

View File

@@ -0,0 +1 @@
export { useAnnouncements } from './useAnnouncements';

View File

@@ -0,0 +1,97 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { announcementReactionSchema, type AnnouncementReaction } from 'pl-api';
import { useClient } from 'soapbox/hooks';
import { type Announcement, normalizeAnnouncement } from 'soapbox/normalizers';
import { queryClient } from 'soapbox/queries/client';
const updateReaction = (reaction: AnnouncementReaction, count: number, me?: boolean, overwrite?: boolean) => announcementReactionSchema.parse({
...reaction,
me: typeof me === 'boolean' ? me : reaction.me,
count: overwrite ? count : (reaction.count + count),
});
const updateReactions = (reactions: AnnouncementReaction[], name: string, count: number, me?: boolean, overwrite?: boolean) => {
const idx = reactions.findIndex(reaction => reaction.name === name);
if (idx > -1) {
reactions = reactions.map(reaction => reaction.name === name ? updateReaction(reaction, count, me, overwrite) : reaction);
}
return [...reactions, updateReaction(announcementReactionSchema.parse({ name }), count, me, overwrite)];
};
const useAnnouncements = () => {
const client = useClient();
const getAnnouncements = async () => {
const data = await client.announcements.getAnnouncements();
return data.map(normalizeAnnouncement);
};
const { data, ...result } = useQuery<ReadonlyArray<Announcement>>({
queryKey: ['announcements'],
queryFn: getAnnouncements,
placeholderData: [],
});
const {
mutate: addReaction,
} = useMutation({
mutationFn: ({ announcementId, name }: { announcementId: string; name: string }) =>
client.announcements.addAnnouncementReaction(announcementId, name),
retry: false,
onMutate: ({ announcementId: id, name }) => {
queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) =>
prevResult.map(value => value.id !== id ? value : {
...value,
reactions: updateReactions(value.reactions, name, 1, true),
}),
);
},
onError: (_, { announcementId: id, name }) => {
queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) =>
prevResult.map(value => value.id !== id ? value : {
...value,
reactions: updateReactions(value.reactions, name, -1, false),
}),
);
},
});
const {
mutate: removeReaction,
} = useMutation({
mutationFn: ({ announcementId, name }: { announcementId: string; name: string }) =>
client.announcements.deleteAnnouncementReaction(announcementId, name),
retry: false,
onMutate: ({ announcementId: id, name }) => {
queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) =>
prevResult.map(value => value.id !== id ? value : {
...value,
reactions: updateReactions(value.reactions, name, -1, false),
}),
);
},
onError: (_, { announcementId: id, name }) => {
queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) =>
prevResult.map(value => value.id !== id ? value : {
...value,
reactions: updateReactions(value.reactions, name, 1, true),
}),
);
},
});
return {
data: data ? [...data].sort(compareAnnouncements) : undefined,
...result,
addReaction,
removeReaction,
};
};
const compareAnnouncements = (a: Announcement, b: Announcement): number =>
new Date(a.starts_at || a.published_at).getDate() - new Date(b.starts_at || b.published_at).getDate();
export { updateReactions, useAnnouncements };

View File

@@ -0,0 +1,19 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
import type { Group } from 'pl-api';
import type { Account } from 'soapbox/normalizers';
const useBlockGroupMember = (group: Pick<Group, 'id'>, account: Pick<Account, 'id'>) => {
const client = useClient();
const { createEntity } = useCreateEntity(
[Entities.GROUP_MEMBERSHIPS, account.id],
(accountIds: string[]) => client.experimental.groups.blockGroupUsers(group.id, accountIds),
);
return createEntity;
};
export { useBlockGroupMember };

View File

@@ -0,0 +1,33 @@
import { groupSchema, type Group as BaseGroup } from 'pl-api';
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
import { normalizeGroup, type Group } from 'soapbox/normalizers';
interface CreateGroupParams {
display_name: string;
note?: string;
avatar?: File;
header?: File;
group_visibility?: 'members_only' | 'everyone';
discoverable?: boolean;
tags?: string[];
}
const useCreateGroup = () => {
const client = useClient();
const { createEntity, ...rest } = useCreateEntity<BaseGroup, Group, CreateGroupParams>(
[Entities.GROUPS, 'search', ''],
(params: CreateGroupParams) => client.experimental.groups.createGroup(params),
{ schema: groupSchema, transform: normalizeGroup },
);
return {
createGroup: createEntity,
...rest,
};
};
export { useCreateGroup, type CreateGroupParams };

View File

@@ -0,0 +1,19 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useDeleteEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
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

@@ -0,0 +1,20 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useDeleteEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
import type { Group } from 'pl-api';
const useDeleteGroupStatus = (group: Pick<Group, 'id'>, statusId: string) => {
const client = useClient();
const { deleteEntity, isSubmitting } = useDeleteEntity(
Entities.STATUSES,
() => client.experimental.groups.deleteGroupStatus(group.id, statusId),
);
return {
mutate: deleteEntity,
isSubmitting,
};
};
export { useDeleteGroupStatus };

View File

@@ -0,0 +1,21 @@
import { groupMemberSchema, type Group, type GroupMember as GroupMember, type GroupRole } from 'pl-api';
import { z } from 'zod';
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
import { normalizeGroupMember } from 'soapbox/normalizers';
const useDemoteGroupMember = (group: Pick<Group, 'id'>, groupMember: Pick<GroupMember, 'id'>) => {
const client = useClient();
const { createEntity } = useCreateEntity(
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
({ account_ids, role }: { account_ids: string[]; role: GroupRole }) => client.experimental.groups.demoteGroupUsers(group.id, account_ids, role),
{ schema: z.array(groupMemberSchema).transform((arr) => arr[0]), transform: normalizeGroupMember },
);
return createEntity;
};
export { useDemoteGroupMember };

View File

@@ -0,0 +1,41 @@
import { __stub } from 'soapbox/api';
import { buildGroup } from 'soapbox/jest/factory';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import { useGroup } from './useGroup';
const group = buildGroup({ id: '1', display_name: 'soapbox' });
describe('useGroup hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/${group.id}`).reply(200, group);
});
});
it('is successful', async () => {
const { result } = renderHook(() => useGroup(group.id));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.group?.id).toBe(group.id);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/${group.id}`).networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(() => useGroup(group.id));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.group).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,41 @@
import { type Group as BaseGroup, groupSchema } from 'pl-api';
import { useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
import { normalizeGroup, type Group } from 'soapbox/normalizers';
import { useGroupRelationship } from './useGroupRelationship';
const useGroup = (groupId: string, refetch = true) => {
const client = useClient();
const history = useHistory();
const { entity: group, isUnauthorized, ...result } = useEntity<BaseGroup, Group>(
[Entities.GROUPS, groupId],
() => client.experimental.groups.getGroup(groupId),
{
schema: groupSchema,
transform: normalizeGroup,
refetch,
enabled: !!groupId,
},
);
const { groupRelationship: relationship } = useGroupRelationship(groupId);
useEffect(() => {
if (isUnauthorized) {
history.push('/login');
}
}, [isUnauthorized]);
return {
...result,
isUnauthorized,
group: group ? { ...group, relationship: relationship || null } : undefined,
};
};
export { useGroup };

View File

@@ -0,0 +1,44 @@
import { __stub } from 'soapbox/api';
import { buildStatus } from 'soapbox/jest/factory';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import { useGroupMedia } from './useGroupMedia';
const status = buildStatus();
const groupId = '1';
describe('useGroupMedia hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).reply(200, [status]);
});
});
it('is successful', async () => {
const { result } = renderHook(() => useGroupMedia(groupId));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.entities.length).toBe(1);
expect(result.current.entities[0].id).toBe(status.id);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(() => useGroupMedia(groupId));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.entities.length).toBe(0);
expect(result.current.isError).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,18 @@
import { statusSchema } from 'pl-api';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
import { normalizeStatus } from 'soapbox/normalizers';
const useGroupMedia = (groupId: string) => {
const client = useClient();
return useEntities(
[Entities.STATUSES, 'groupMedia', groupId],
() => client.timelines.groupTimeline(groupId, { only_media: true }),
{ schema: statusSchema, transform: normalizeStatus })
;
};
export { useGroupMedia };

View File

@@ -0,0 +1,46 @@
import { GroupRoles } from 'pl-api';
import { __stub } from 'soapbox/api';
import { buildGroupMember } from 'soapbox/jest/factory';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import { useGroupMembers } from './useGroupMembers';
const groupMember = buildGroupMember();
const groupId = '1';
describe('useGroupMembers hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).reply(200, [groupMember]);
});
});
it('is successful', async () => {
const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groupMembers.length).toBe(1);
expect(result.current.groupMembers[0].id).toBe(groupMember.id);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groupMembers.length).toBe(0);
expect(result.current.isError).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,23 @@
import { groupMemberSchema, type GroupMember as BaseGroupMember, type GroupRoles } from 'pl-api';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
import { normalizeGroupMember, type GroupMember } from 'soapbox/normalizers';
const useGroupMembers = (groupId: string, role: GroupRoles) => {
const client = useClient();
const { entities, ...result } = useEntities<BaseGroupMember, GroupMember>(
[Entities.GROUP_MEMBERSHIPS, groupId, role],
() => client.experimental.groups.getGroupMemberships(groupId, role),
{ schema: groupMemberSchema, transform: normalizeGroupMember },
);
return {
...result,
groupMembers: entities,
};
};
export { useGroupMembers };

View File

@@ -0,0 +1,49 @@
import { accountSchema, GroupRoles } from 'pl-api';
import { Entities } from 'soapbox/entity-store/entities';
import { useDismissEntity, useEntities } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
import { normalizeAccount } from 'soapbox/normalizers';
import { useGroupRelationship } from './useGroupRelationship';
import type { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types';
const useGroupMembershipRequests = (groupId: string) => {
const client = useClient();
const path: ExpandedEntitiesPath = [Entities.ACCOUNTS, 'membership_requests', groupId];
const { groupRelationship: relationship } = useGroupRelationship(groupId);
const { entities, invalidate, fetchEntities, ...rest } = useEntities(
path,
() => client.experimental.groups.getGroupMembershipRequests(groupId),
{
schema: accountSchema,
transform: normalizeAccount,
enabled: relationship?.role === GroupRoles.OWNER || relationship?.role === GroupRoles.ADMIN,
},
);
const { dismissEntity: authorize } = useDismissEntity(path, async (accountId: string) => {
const response = await client.experimental.groups.acceptGroupMembershipRequest(groupId, accountId);
invalidate();
return response;
});
const { dismissEntity: reject } = useDismissEntity(path, async (accountId: string) => {
const response = await client.experimental.groups.rejectGroupMembershipRequest(groupId, accountId);
invalidate();
return response;
});
return {
accounts: entities,
refetch: fetchEntities,
authorize,
reject,
...rest,
};
};
export { useGroupMembershipRequests };

View File

@@ -0,0 +1,26 @@
import { type GroupRelationship, groupRelationshipSchema } from 'pl-api';
import { z } from 'zod';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
const useGroupRelationship = (groupId: string | undefined) => {
const client = useClient();
const { entity: groupRelationship, ...result } = useEntity<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, groupId!],
() => client.experimental.groups.getGroupRelationships([groupId!]),
{
enabled: !!groupId,
schema: z.array(groupRelationshipSchema).nonempty().transform(arr => arr[0]),
},
);
return {
groupRelationship,
...result,
};
};
export { useGroupRelationship };

View File

@@ -0,0 +1,24 @@
import { type GroupRelationship, groupRelationshipSchema } from 'pl-api';
import { Entities } from 'soapbox/entity-store/entities';
import { useBatchedEntities } from 'soapbox/entity-store/hooks/useBatchedEntities';
import { useClient, useLoggedIn } from 'soapbox/hooks';
const useGroupRelationships = (listKey: string[], groupIds: string[]) => {
const client = useClient();
const { isLoggedIn } = useLoggedIn();
const fetchGroupRelationships = (groupIds: string[]) =>
client.experimental.groups.getGroupRelationships(groupIds);
const { entityMap: relationships, ...result } = useBatchedEntities<GroupRelationship>(
[Entities.RELATIONSHIPS, ...listKey],
groupIds,
fetchGroupRelationships,
{ schema: groupRelationshipSchema, enabled: isLoggedIn },
);
return { relationships, ...result };
};
export { useGroupRelationships };

View File

@@ -0,0 +1,47 @@
import { __stub } from 'soapbox/api';
import { buildGroup } from 'soapbox/jest/factory';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import { instanceSchema } from 'soapbox/schemas';
import { useGroups } from './useGroups';
const group = buildGroup({ id: '1', display_name: 'soapbox' });
const store = {
instance: instanceSchema.parse({
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
}),
};
describe('useGroups hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/groups').reply(200, [group]);
});
});
it('is successful', async () => {
const { result } = renderHook(useGroups, undefined, store);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groups).toHaveLength(1);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/groups').networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(useGroups, undefined, store);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groups).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,36 @@
import { groupSchema, type Group as BaseGroup } from 'pl-api';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
import { useFeatures } from 'soapbox/hooks/useFeatures';
import { normalizeGroup, type Group } from 'soapbox/normalizers';
import { useGroupRelationships } from './useGroupRelationships';
const useGroups = () => {
const client = useClient();
const features = useFeatures();
const { entities, ...result } = useEntities<BaseGroup, Group>(
[Entities.GROUPS, 'search', ''],
() => client.experimental.groups.getGroups(),
{ enabled: features.groups, schema: groupSchema, transform: normalizeGroup },
);
const { relationships } = useGroupRelationships(
['search', ''],
entities.map(entity => entity.id),
);
const groups = entities.map((group) => ({
...group,
relationship: relationships[group.id] || null,
}));
return {
...result,
groups,
};
};
export { useGroups };

View File

@@ -0,0 +1,25 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
import { useGroups } from './useGroups';
import type { Group } from 'pl-api';
const useJoinGroup = (group: Pick<Group, 'id'>) => {
const client = useClient();
const { invalidate } = useGroups();
const { createEntity, isSubmitting } = useCreateEntity(
[Entities.GROUP_RELATIONSHIPS, group.id],
() => client.experimental.groups.joinGroup(group.id),
);
return {
mutate: createEntity,
isSubmitting,
invalidate,
};
};
export { useJoinGroup };

View File

@@ -0,0 +1,25 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
import { useGroups } from './useGroups';
import type { Group } from 'pl-api';
const useLeaveGroup = (group: Pick<Group, 'id'>) => {
const client = useClient();
const { invalidate } = useGroups();
const { createEntity, isSubmitting } = useCreateEntity(
[Entities.GROUP_RELATIONSHIPS, group.id],
() => client.experimental.groups.leaveGroup(group.id),
);
return {
mutate: createEntity,
isSubmitting,
invalidate,
};
};
export { useLeaveGroup };

View File

@@ -0,0 +1,23 @@
import { groupMemberSchema } from 'pl-api';
import { z } from 'zod';
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
import { normalizeGroupMember } from 'soapbox/normalizers';
import type { Group, GroupMember, GroupRole } from 'pl-api';
const usePromoteGroupMember = (group: Pick<Group, 'id'>, groupMember: Pick<GroupMember, 'id'>) => {
const client = useClient();
const { createEntity } = useCreateEntity(
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
({ account_ids, role }: { account_ids: string[]; role: GroupRole }) => client.experimental.groups.promoteGroupUsers(group.id, account_ids, role),
{ schema: z.array(groupMemberSchema).transform((arr) => arr[0]), transform: normalizeGroupMember },
);
return createEntity;
};
export { usePromoteGroupMember };

View File

@@ -0,0 +1,32 @@
import { groupSchema } from 'pl-api';
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
import { normalizeGroup } from 'soapbox/normalizers';
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),
{ schema: groupSchema, transform: normalizeGroup },
);
return {
updateGroup: createEntity,
...rest,
};
};
export { useUpdateGroup };

View File

@@ -0,0 +1,55 @@
// Accounts
export { useAccount } from './accounts/useAccount';
export { useAccountLookup } from './accounts/useAccountLookup';
export {
useBlocks,
useMutes,
useFollowers,
useFollowing,
} from './accounts/useAccountList';
export { useFollow } from './accounts/useFollow';
export { useRelationships } from './accounts/useRelationships';
// Groups
export { useBlockGroupMember } from './groups/useBlockGroupMember';
export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup';
export { useDeleteGroup } from './groups/useDeleteGroup';
export { useDemoteGroupMember } from './groups/useDemoteGroupMember';
export { useGroup } from './groups/useGroup';
export { useGroupMedia } from './groups/useGroupMedia';
export { useGroupMembers } from './groups/useGroupMembers';
export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests';
export { useGroupRelationship } from './groups/useGroupRelationship';
export { useGroupRelationships } from './groups/useGroupRelationships';
export { useGroups } from './groups/useGroups';
export { useJoinGroup } from './groups/useJoinGroup';
export { useLeaveGroup } from './groups/useLeaveGroup';
export { usePromoteGroupMember } from './groups/usePromoteGroupMember';
export { useUpdateGroup } from './groups/useUpdateGroup';
// Instance
export { useTranslationLanguages } from './instance/useTranslationLanguages';
// Settings
export { useInteractionPolicies } from './settings/useInteractionPolicies';
// Statuses
export { useBookmarkFolders } from './statuses/useBookmarkFolders';
export { useBookmarkFolder } from './statuses/useBookmarkFolder';
export { useCreateBookmarkFolder } from './statuses/useCreateBookmarkFolder';
export { useDeleteBookmarkFolder } from './statuses/useDeleteBookmarkFolder';
export { useUpdateBookmarkFolder } from './statuses/useUpdateBookmarkFolder';
// Streaming
export { useUserStream } from './streaming/useUserStream';
export { useCommunityStream } from './streaming/useCommunityStream';
export { usePublicStream } from './streaming/usePublicStream';
export { useDirectStream } from './streaming/useDirectStream';
export { useHashtagStream } from './streaming/useHashtagStream';
export { useListStream } from './streaming/useListStream';
export { useGroupStream } from './streaming/useGroupStream';
export { useRemoteStream } from './streaming/useRemoteStream';
// Trends
export { useTrendingLinks } from './trends/useTrendingLinks';

View File

@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import { useClient, useFeatures, useInstance, useLoggedIn } from 'soapbox/hooks';
const useTranslationLanguages = () => {
const client = useClient();
const { isLoggedIn } = useLoggedIn();
const features = useFeatures();
const instance = useInstance();
const getTranslationLanguages = async () => {
const metadata = instance.pleroma.metadata;
if (metadata.translation.source_languages?.length) {
return Object.fromEntries(metadata.translation.source_languages.map(source => [
source,
metadata.translation.target_languages!.filter(lang => lang !== source),
]));
}
return client.instance.getInstanceTranslationLanguages();
};
const { data, ...result } = useQuery({
queryKey: ['translationLanguages'],
queryFn: getTranslationLanguages,
placeholderData: {},
enabled: isLoggedIn && features.translations,
});
return {
translationLanguages: data || {},
...result,
};
};
export { useTranslationLanguages };

View File

@@ -0,0 +1,41 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { type InteractionPolicies, interactionPoliciesSchema } from 'pl-api';
import { useClient, useFeatures, useLoggedIn } from 'soapbox/hooks';
import { queryClient } from 'soapbox/queries/client';
const emptySchema = interactionPoliciesSchema.parse({});
const useInteractionPolicies = () => {
const client = useClient();
const { isLoggedIn } = useLoggedIn();
const features = useFeatures();
const { data, ...result } = useQuery({
queryKey: ['interactionPolicies'],
queryFn: client.settings.getInteractionPolicies,
placeholderData: emptySchema,
enabled: isLoggedIn && features.interactionRequests,
});
const {
mutate: updateInteractionPolicies,
isPending: isUpdating,
} = useMutation({
mutationFn: (policy: InteractionPolicies) =>
client.settings.updateInteractionPolicies(policy),
retry: false,
onSuccess: (policy) => {
queryClient.setQueryData(['interactionPolicies'], policy);
},
});
return {
interactionPolicies: data || emptySchema,
updateInteractionPolicies,
isUpdating,
...result,
};
};
export { useInteractionPolicies };

View File

@@ -0,0 +1,32 @@
import { Entities } from 'soapbox/entity-store/entities';
import { selectEntity } from 'soapbox/entity-store/selectors';
import { useAppSelector } from 'soapbox/hooks';
import { useBookmarkFolders } from './useBookmarkFolders';
import type{ BookmarkFolder } from 'pl-api';
const useBookmarkFolder = (folderId?: string) => {
const {
isError,
isFetched,
isFetching,
isLoading,
invalidate,
} = useBookmarkFolders();
const bookmarkFolder = useAppSelector(state => folderId
? selectEntity<BookmarkFolder>(state, Entities.BOOKMARK_FOLDERS, folderId)
: undefined);
return {
bookmarkFolder,
isError,
isFetched,
isFetching,
isLoading,
invalidate,
};
};
export { useBookmarkFolder };

View File

@@ -0,0 +1,26 @@
import { bookmarkFolderSchema, type BookmarkFolder } from 'pl-api';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
import { useFeatures } from 'soapbox/hooks/useFeatures';
const useBookmarkFolders = () => {
const client = useClient();
const features = useFeatures();
const { entities, ...result } = useEntities<BookmarkFolder>(
[Entities.BOOKMARK_FOLDERS],
() => client.myAccount.getBookmarkFolders(),
{ enabled: features.bookmarkFolders, schema: bookmarkFolderSchema },
);
const bookmarkFolders = entities;
return {
...result,
bookmarkFolders,
};
};
export { useBookmarkFolders };

View File

@@ -0,0 +1,28 @@
import { bookmarkFolderSchema } from 'pl-api';
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
interface CreateBookmarkFolderParams {
name: string;
emoji?: string;
}
const useCreateBookmarkFolder = () => {
const client = useClient();
const { createEntity, ...rest } = useCreateEntity(
[Entities.BOOKMARK_FOLDERS],
(params: CreateBookmarkFolderParams) =>
client.myAccount.createBookmarkFolder(params),
{ schema: bookmarkFolderSchema },
);
return {
createBookmarkFolder: createEntity,
...rest,
};
};
export { useCreateBookmarkFolder };

View File

@@ -0,0 +1,19 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useDeleteEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
const useDeleteBookmarkFolder = () => {
const client = useClient();
const { deleteEntity, isSubmitting } = useDeleteEntity(
Entities.BOOKMARK_FOLDERS,
(folderId: string) => client.myAccount.deleteBookmarkFolder(folderId),
);
return {
deleteBookmarkFolder: deleteEntity,
isSubmitting,
};
};
export { useDeleteBookmarkFolder };

View File

@@ -0,0 +1,28 @@
import { bookmarkFolderSchema } from 'pl-api';
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useClient } from 'soapbox/hooks';
interface UpdateBookmarkFolderParams {
name: string;
emoji?: string;
}
const useUpdateBookmarkFolder = (folderId: string) => {
const client = useClient();
const { createEntity, ...rest } = useCreateEntity(
[Entities.BOOKMARK_FOLDERS],
(params: UpdateBookmarkFolderParams) =>
client.myAccount.updateBookmarkFolder(folderId, params),
{ schema: bookmarkFolderSchema },
);
return {
updateBookmarkFolder: createEntity,
...rest,
};
};
export { useUpdateBookmarkFolder };

View File

@@ -0,0 +1,11 @@
import { useTimelineStream } from './useTimelineStream';
interface UseCommunityStreamOpts {
onlyMedia?: boolean;
enabled?: boolean;
}
const useCommunityStream = ({ onlyMedia, enabled }: UseCommunityStreamOpts = {}) =>
useTimelineStream(`public:local${onlyMedia ? ':media' : ''}`, {}, enabled);
export { useCommunityStream };

View File

@@ -0,0 +1,5 @@
import { useTimelineStream } from './useTimelineStream';
const useDirectStream = () => useTimelineStream('direct');
export { useDirectStream };

View File

@@ -0,0 +1,5 @@
import { useTimelineStream } from './useTimelineStream';
const useGroupStream = (groupId: string) => useTimelineStream('group', { group: groupId } as any);
export { useGroupStream };

View File

@@ -0,0 +1,5 @@
import { useTimelineStream } from './useTimelineStream';
const useHashtagStream = (tag: string) => useTimelineStream('hashtag', { tag });
export { useHashtagStream };

View File

@@ -0,0 +1,11 @@
import { useLoggedIn } from 'soapbox/hooks';
import { useTimelineStream } from './useTimelineStream';
const useListStream = (listId: string) => {
const { isLoggedIn } = useLoggedIn();
return useTimelineStream('list', { list: listId }, isLoggedIn);
};
export { useListStream };

View File

@@ -0,0 +1,10 @@
import { useTimelineStream } from './useTimelineStream';
interface UsePublicStreamOpts {
onlyMedia?: boolean;
}
const usePublicStream = ({ onlyMedia }: UsePublicStreamOpts = {}) =>
useTimelineStream(`public${onlyMedia ? ':media' : ''}`);
export { usePublicStream };

View File

@@ -0,0 +1,11 @@
import { useTimelineStream } from './useTimelineStream';
interface UseRemoteStreamOpts {
instance: string;
onlyMedia?: boolean;
}
const useRemoteStream = ({ instance, onlyMedia }: UseRemoteStreamOpts) =>
useTimelineStream(`public:remote${onlyMedia ? ':media' : ''}`, { instance } as any);
export { useRemoteStream };

View File

@@ -0,0 +1,87 @@
import { useEffect, useRef } from 'react';
import { useAppSelector, useClient, useInstance } from 'soapbox/hooks';
import { getAccessToken } from 'soapbox/utils/auth';
import type { StreamingEvent } from 'pl-api';
const useTimelineStream = (stream: string, params: { list?: string; tag?: string } = {}, enabled = true, listener?: (event: StreamingEvent) => any) => {
const firstUpdate = useRef(true);
const client = useClient();
const instance = useInstance();
const socket = useRef<({
listen: (listener: any, stream?: string) => number;
unlisten: (listener: any) => void;
subscribe: (stream: string, params?: {
list?: string;
tag?: string;
}) => void;
unsubscribe: (stream: string, params?: {
list?: string;
tag?: string;
}) => void;
close: () => void;
}) | null>(null);
const accessToken = useAppSelector(getAccessToken);
const streamingUrl = instance.configuration.urls.streaming;
const connect = async () => {
if (!socket.current && streamingUrl) {
socket.current = client.streaming.connect();
socket.current.subscribe(stream, params);
if (listener) socket.current.listen(listener);
}
};
const disconnect = () => {
if (socket.current) {
socket.current.close();
socket.current = null;
}
};
useEffect(() => {
socket.current?.subscribe(stream, params);
return () => socket.current?.unsubscribe(stream, params);
}, [stream, params.list, params.tag, enabled]);
useEffect(() => {
if (enabled) {
connect();
return () => {
if (listener) socket.current?.unlisten(listener);
};
}
}, [enabled]);
useEffect(() => {
if (firstUpdate.current) {
firstUpdate.current = false;
} else {
disconnect();
connect();
return () => {
if (listener) socket.current?.unlisten(listener);
};
}
}, [accessToken, streamingUrl]);
useEffect(() => {
if (!enabled) {
disconnect();
}
}, [enabled]);
return {
disconnect,
};
};
export { useTimelineStream };

View File

@@ -0,0 +1,180 @@
import { announcementSchema, type Announcement, type AnnouncementReaction, type FollowRelationshipUpdate, type Relationship, type StreamingEvent } from 'pl-api';
import { useCallback } from 'react';
import { updateConversations } from 'soapbox/actions/conversations';
import { fetchFilters } from 'soapbox/actions/filters';
import { updateNotificationsQueue } from 'soapbox/actions/notifications';
import { getLocale, getSettings } from 'soapbox/actions/settings';
import { updateStatus } from 'soapbox/actions/statuses';
import { deleteFromTimelines, processTimelineUpdate } from 'soapbox/actions/timelines';
import { useStatContext } from 'soapbox/contexts/stat-context';
import { importEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities';
import { selectEntity } from 'soapbox/entity-store/selectors';
import { useAppDispatch, useLoggedIn } from 'soapbox/hooks';
import messages from 'soapbox/messages';
import { queryClient } from 'soapbox/queries/client';
import { getUnreadChatsCount, updateChatListItem } from 'soapbox/utils/chats';
import { play, soundCache } from 'soapbox/utils/sounds';
import { updateReactions } from '../announcements/useAnnouncements';
import { useTimelineStream } from './useTimelineStream';
import type { AppDispatch, RootState } from 'soapbox/store';
const updateAnnouncementReactions = ({ announcement_id: id, name }: AnnouncementReaction) => {
queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) =>
prevResult.map(value => {
if (value.id !== id) return value;
return {
...value,
reactions: updateReactions(value.reactions, name, -1, true),
};
}),
);
};
const updateAnnouncement = (announcement: Announcement) =>
queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => {
let updated = false;
const result = prevResult.map(value => value.id === announcement.id
? (updated = true, announcementSchema.parse(announcement))
: value);
if (!updated) return [announcementSchema.parse(announcement), ...result];
});
const deleteAnnouncement = (announcementId: string) =>
queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) =>
prevResult.filter(value => value.id !== announcementId),
);
const followStateToRelationship = (followState: FollowRelationshipUpdate['state']) => {
switch (followState) {
case 'follow_pending':
return { following: false, requested: true };
case 'follow_accept':
return { following: true, requested: false };
case 'follow_reject':
return { following: false, requested: false };
default:
return {};
}
};
const updateFollowRelationships = (update: FollowRelationshipUpdate) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const me = state.me;
const relationship = selectEntity<Relationship>(state, Entities.RELATIONSHIPS, update.following.id);
if (update.follower.id === me && relationship) {
const updated = {
...relationship,
...followStateToRelationship(update.state),
};
// Add a small delay to deal with API race conditions.
setTimeout(() => dispatch(importEntities([updated], Entities.RELATIONSHIPS)), 300);
}
};
const getTimelineFromStream = (stream: Array<string>) => {
switch (stream[0]) {
case 'user':
return 'home';
case 'hashtag':
case 'hashtag:local':
case 'list':
return `${stream[0]}:${stream[1]}`;
default:
return stream[0];
}
};
const useUserStream = () => {
const { isLoggedIn } = useLoggedIn();
const dispatch = useAppDispatch();
const statContext = useStatContext();
const listener = useCallback((event: StreamingEvent) => {
switch (event.event) {
case 'update':
dispatch(processTimelineUpdate(getTimelineFromStream(event.stream), event.payload));
break;
case 'status.update':
dispatch(updateStatus(event.payload));
break;
case 'delete':
dispatch(deleteFromTimelines(event.payload));
break;
case 'notification':
dispatch((dispatch, getState) => {
const locale = getLocale(getState());
messages[locale]().then(messages => {
dispatch(
updateNotificationsQueue(
event.payload,
messages,
locale,
window.location.pathname,
),
);
}).catch(error => {
console.error(error);
});
});
break;
case 'conversation':
dispatch(updateConversations(event.payload));
break;
case 'filters_changed':
dispatch(fetchFilters());
break;
case 'chat_update':
dispatch((_dispatch, getState) => {
const chat = event.payload;
const me = getState().me;
const messageOwned = chat.last_message?.account_id === me;
const settings = getSettings(getState());
// Don't update own messages from streaming
if (!messageOwned) {
updateChatListItem(chat);
if (settings.getIn(['chats', 'sound'])) {
play(soundCache.chat);
}
// Increment unread counter
statContext?.setUnreadChatsCount(getUnreadChatsCount());
}
});
break;
case 'follow_relationships_update':
dispatch(updateFollowRelationships(event.payload));
break;
case 'announcement':
updateAnnouncement(event.payload);
break;
case 'announcement.reaction':
updateAnnouncementReactions(event.payload);
break;
case 'announcement.delete':
deleteAnnouncement(event.payload);
break;
// case 'marker':
// dispatch({ type: MARKER_FETCH_SUCCESS, marker: JSON.parse(data.payload) });
// break;
}
}, []);
return useTimelineStream('user', {}, isLoggedIn, listener);
};
export { useUserStream };

View File

@@ -0,0 +1,20 @@
import { trendsLinkSchema } from 'pl-api';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useClient, useFeatures } from 'soapbox/hooks';
const useTrendingLinks = () => {
const client = useClient();
const features = useFeatures();
const { entities, ...rest } = useEntities(
[Entities.TRENDS_LINKS],
() => client.trends.getTrendingLinks(),
{ schema: trendsLinkSchema, enabled: features.trendingLinks },
);
return { trendingLinks: entities, ...rest };
};
export { useTrendingLinks };