Merge pull request #114 from mkljczk/hooks-migration

Migrate to @tanstack/react-hooks for remote state management
This commit is contained in:
marcin mikołajczak 2024-10-23 15:13:49 +02:00 committed by GitHub
commit a0c0f17004
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 4016 additions and 9 deletions

41
.github/workflows/pl-hooks.yaml vendored Normal file
View file

@ -0,0 +1,41 @@
name: pl-hooks CI
on:
push:
branches: [ "develop" ]
pull_request:
branches: [ "develop" ]
jobs:
build:
runs-on: ubuntu-latest
name: Test for a successful build
strategy:
matrix:
node-version: [21.x]
steps:
- name: Install system dependencies
run: sudo apt install -y unzip
- name: Checkout code
uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install deps
working-directory: ./packages/pl-hooks
run: yarn install --ignore-scripts
- name: Lint
working-directory: ./packages/pl-hooks
run: yarn lint
- name: build
env:
NODE_ENV: production
working-directory: ./packages/pl-hooks
run: yarn build

View file

@ -7,6 +7,6 @@
"husky": "^9.0.0",
"lint-staged": ">=10"
},
"workspaces": ["pl-api", "pl-fe"],
"workspaces": ["pl-api", "pl-fe", "pl-hooks"],
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View file

@ -6,10 +6,7 @@ import { relationshipSchema } from './relationship';
import { roleSchema } from './role';
import { coerceObject, datetimeSchema, filteredArray } from './utils';
const filterBadges = (tags?: string[]) =>
tags?.filter(tag => tag.startsWith('badge:')).map(tag => v.parse(roleSchema, { id: tag, name: tag.replace(/^badge:/, '') }));
const getDomainFromURL = (account: any): string => {
const getDomainFromURL = (account: Pick<Account, 'url'>): string => {
try {
const url = account.url;
return new URL(url).host;
@ -18,7 +15,7 @@ const getDomainFromURL = (account: any): string => {
}
};
const guessFqn = (account: any): string => {
const guessFqn = (account: Pick<Account, 'acct' | 'url'>): string => {
const acct = account.acct;
const [user, domain] = acct.split('@');
@ -29,6 +26,9 @@ const guessFqn = (account: any): string => {
}
};
const filterBadges = (tags?: string[]) =>
tags?.filter(tag => tag.startsWith('badge:')).map(tag => v.parse(roleSchema, { id: tag, name: tag.replace(/^badge:/, '') }));
const preprocessAccount = v.transform((account: any) => {
if (!account?.acct) return null;
@ -144,6 +144,7 @@ const baseAccountSchema = v.object({
header_description: v.fallback(v.string(), ''),
verified: v.fallback(v.optional(v.boolean()), undefined),
domain: v.fallback(v.string(), ''),
__meta: coerceObject({
pleroma: v.fallback(v.any(), undefined),

View file

@ -1,7 +1,7 @@
import pick from 'lodash.pick';
import * as v from 'valibot';
import { accountSchema } from './account';
import { type Account, accountSchema } from './account';
import { customEmojiSchema } from './custom-emoji';
import { emojiReactionSchema } from './emoji-reaction';
import { filterResultSchema } from './filter-result';
@ -41,7 +41,7 @@ const baseStatusSchema = v.object({
uri: v.fallback(v.pipe(v.string(), v.url()), ''),
created_at: v.fallback(datetimeSchema, new Date().toISOString()),
account: accountSchema,
content: v.fallback(v.string(), ''),
content: v.fallback(v.pipe(v.string(), v.transform((note => note === '<p></p>' ? '' : note))), ''),
visibility: v.fallback(v.string(), 'public'),
sensitive: v.pipe(v.unknown(), v.transform(Boolean)),
spoiler_text: v.fallback(v.string(), ''),
@ -149,9 +149,15 @@ const statusWithoutAccountSchema = v.pipe(v.any(), v.transform(preprocess), v.ob
quote: v.fallback(v.nullable(v.lazy(() => statusSchema)), null),
}));
type StatusWithoutAccount = Omit<v.InferOutput<typeof baseStatusSchema>, 'account'> & {
account: Account | null;
reblog: Status | null;
quote: Status | null;
}
type Status = v.InferOutput<typeof baseStatusSchema> & {
reblog: Status | null;
quote: Status | null;
}
export { statusSchema, statusWithoutAccountSchema, type Status };
export { statusSchema, statusWithoutAccountSchema, type Status, type StatusWithoutAccount };

View file

@ -0,0 +1,9 @@
export { normalizeAccount, type Account } from './account';
export { normalizeAdminReport, type AdminReport } from './admin-report';
export { normalizeChatMessage, type ChatMessage } from './chat-message';
export { normalizeGroup, type Group } from './group';
export { normalizeGroupMember, type GroupMember } from './group-member';
export { normalizeNotification, type Notification } from './notification';
export { normalizeStatus, type Status } from './status';
export { PlFeConfigRecord, normalizePlFeConfig } from './pl-fe/pl-fe-config';

View file

@ -0,0 +1,7 @@
/node_modules/**
/dist/**
/static/**
/public/**
/tmp/**
/coverage/**
/custom/**

View file

@ -0,0 +1,214 @@
{
"root": true,
"extends": [
"eslint:recommended",
"plugin:import/typescript",
"plugin:compat/recommended"
],
"env": {
"browser": true,
"node": true,
"es6": true,
"jest": true
},
"globals": {
"ATTACHMENT_HOST": false
},
"plugins": [
"import",
"promise",
"@typescript-eslint"
],
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true
},
"ecmaVersion": 2018
},
"settings": {
"import/extensions": [
".js",
".cjs",
".mjs",
".ts"
],
"import/ignore": [
"node_modules",
"\\.(css|scss|json)$"
],
"import/resolver": {
"typescript": true,
"node": true
},
"polyfills": [
"es:all",
"fetch",
"IntersectionObserver",
"Promise",
"ResizeObserver",
"URL",
"URLSearchParams"
],
"tailwindcss": {
"config": "tailwind.config.ts"
}
},
"rules": {
"brace-style": "error",
"comma-dangle": [
"error",
"always-multiline"
],
"comma-spacing": [
"warn",
{
"before": false,
"after": true
}
],
"comma-style": [
"warn",
"last"
],
"import/no-duplicates": "error",
"space-before-function-paren": [
"error",
"never"
],
"space-infix-ops": "error",
"space-in-parens": [
"error",
"never"
],
"keyword-spacing": "error",
"dot-notation": "error",
"eqeqeq": "error",
"indent": [
"error",
2,
{
"SwitchCase": 1,
"ignoredNodes": [
"TemplateLiteral"
]
}
],
"key-spacing": [
"error",
{
"mode": "minimum"
}
],
"no-catch-shadow": "error",
"no-cond-assign": "error",
"no-console": [
"warn",
{
"allow": [
"error",
"warn"
]
}
],
"no-extra-semi": "error",
"no-const-assign": "error",
"no-fallthrough": "error",
"no-irregular-whitespace": "error",
"no-loop-func": "error",
"no-mixed-spaces-and-tabs": "error",
"no-nested-ternary": "warn",
"no-trailing-spaces": "warn",
"no-undef": "error",
"no-unreachable": "error",
"no-unused-expressions": "error",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"vars": "all",
"args": "none",
"ignoreRestSiblings": true
}
],
"no-useless-escape": "warn",
"no-var": "error",
"object-curly-spacing": [
"error",
"always"
],
"padded-blocks": [
"error",
{
"classes": "always"
}
],
"prefer-const": "error",
"quotes": [
"error",
"single"
],
"semi": "error",
"space-unary-ops": [
"error",
{
"words": true,
"nonwords": false
}
],
"strict": "off",
"valid-typeof": "error",
"import/extensions": [
"error",
"always",
{
"js": "never",
"mjs": "ignorePackages",
"ts": "never"
}
],
"import/newline-after-import": "error",
"import/no-extraneous-dependencies": "error",
"import/no-unresolved": "error",
"import/no-webpack-loader-syntax": "error",
"import/order": [
"error",
{
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
"object",
"type"
],
"newlines-between": "always",
"alphabetize": {
"order": "asc"
}
}
],
"@typescript-eslint/member-delimiter-style": "error",
"promise/catch-or-return": "error",
"sort-imports": [
"error",
{
"ignoreCase": true,
"ignoreDeclarationSort": true
}
],
"eol-last": "error"
},
"overrides": [
{
"files": ["**/*.ts"],
"rules": {
"no-undef": "off",
"space-before-function-paren": "off"
},
"parser": "@typescript-eslint/parser"
}
]
}

24
packages/pl-hooks/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -0,0 +1,18 @@
# `pl-hooks`
> This project should be considered unstable before the 1.0.0 release. I will not provide any changelog or information on breaking changes until then.
## License
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

View file

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>pl-hooks</title>
</head>
<body>
<script type="module" src="/lib/main.ts"></script>
</body>
</html>

View file

@ -0,0 +1,16 @@
import { PlApiClient } from 'pl-api';
import React from 'react';
const PlHooksApiClientContext = React.createContext<{
client: PlApiClient;
me: string | null | false;
}>({
client: new PlApiClient(''),
me: null,
});
const PlHooksApiClientProvider = PlHooksApiClientContext.Provider;
const usePlHooksApiClient = () => React.useContext(PlHooksApiClientContext);
export { PlHooksApiClientProvider, usePlHooksApiClient };

View file

@ -0,0 +1,21 @@
import { QueryClient } from '@tanstack/react-query';
import React from 'react';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 60000, // 1 minute
gcTime: Infinity,
retry: false,
},
},
});
const PlHooksQueryClientContext = React.createContext<QueryClient>(queryClient);
const PlHooksQueryClientProvider = PlHooksQueryClientContext.Provider;
const usePlHooksQueryClient = () => React.useContext(PlHooksQueryClientContext);
export { queryClient, PlHooksQueryClientProvider, usePlHooksQueryClient };

View file

@ -0,0 +1,55 @@
import { useQuery } from '@tanstack/react-query';
import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client';
import { queryClient, usePlHooksQueryClient } from 'pl-hooks/contexts/query-client';
import { importEntities } from 'pl-hooks/importer';
import { type Account, normalizeAccount } from 'pl-hooks/normalizers/normalizeAccount';
import { useAccountRelationship } from './useAccountRelationship';
import type { PlApiClient } from 'pl-api';
interface UseAccountOpts {
withRelationship?: boolean;
withScrobble?: boolean;
withMoveTarget?: boolean;
}
const useAccount = (accountId?: string, opts: UseAccountOpts = {}) => {
const { client } = usePlHooksApiClient();
const queryClient = usePlHooksQueryClient();
const accountQuery = useQuery({
queryKey: ['accounts', 'entities', accountId],
queryFn: () => client.accounts.getAccount(accountId!)
.then(normalizeAccount),
enabled: !!accountId,
}, queryClient);
const relationshipQuery = useAccountRelationship(opts.withRelationship ? accountId : undefined);
let data;
if (accountQuery.data) {
data = {
...accountQuery.data,
relationship: relationshipQuery.data,
moved: opts.withMoveTarget && queryClient.getQueryData(['accounts', 'entities', accountQuery.data?.moved_id]) as Account || null,
};
} else data = null;
return { ...accountQuery, data };
};
const prefetchAccount = (client: PlApiClient, accountId: string) =>
queryClient.prefetchQuery({
queryKey: ['accounts', 'entities', accountId],
queryFn: () => client.accounts.getAccount(accountId!)
.then(account => {
importEntities({ accounts: [account] }, { withParents: false });
return normalizeAccount(account);
}),
});
export { useAccount, prefetchAccount, type UseAccountOpts };

View file

@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query';
import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client';
import { usePlHooksQueryClient } from 'pl-hooks/contexts/query-client';
import { importEntities } from 'pl-hooks/importer';
import { useAccount, type UseAccountOpts } from './useAccount';
const useAccountLookup = (acct?: string, opts: UseAccountOpts = {}) => {
const { client } = usePlHooksApiClient();
const queryClient = usePlHooksQueryClient();
const { features } = client;
const accountIdQuery = useQuery({
queryKey: ['accounts', 'byAcct', acct?.toLocaleLowerCase()],
queryFn: () => (
features.accountByUsername && !features.accountLookup
? client.accounts.getAccount(acct!)
: client.accounts.lookupAccount(acct!)
).then((account) => {
importEntities({ accounts: [account] });
return account.id;
}),
enabled: !!acct,
}, queryClient);
return useAccount(accountIdQuery.data, opts);
};
export { useAccountLookup };

View file

@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client';
import { usePlHooksQueryClient } from 'pl-hooks/contexts/query-client';
const useAccountRelationship = (accountId?: string) => {
const { client } = usePlHooksApiClient();
const queryClient = usePlHooksQueryClient();
return useQuery({
queryKey: ['accounts', 'entities', accountId],
queryFn: async () => (await client.accounts.getRelationships([accountId!]))[0],
enabled: !!accountId,
}, queryClient);
};
export { useAccountRelationship };

View file

@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { instanceSchema } from 'pl-api';
import * as v from 'valibot';
import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client';
import { usePlHooksQueryClient } from 'pl-hooks/contexts/query-client';
const placeholderData = v.parse(instanceSchema, {});
const useInstance = () => {
const { client } = usePlHooksApiClient();
const queryClient = usePlHooksQueryClient();
return useQuery({
queryKey: ['instance'],
queryFn: client.instance.getInstance,
placeholderData,
}, queryClient);
};
export { useInstance };

View file

@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query';
import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client';
import { usePlHooksQueryClient } from 'pl-hooks/contexts/query-client';
import { useInstance } from './useInstance';
const useTranslationLanguages = () => {
const { client } = usePlHooksApiClient();
const queryClient = usePlHooksQueryClient();
const { data: instance } = useInstance();
const {
allow_unauthenticated: allowUnauthenticated,
} = instance!.pleroma.metadata.translation;
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();
};
return useQuery({
queryKey: ['instance', 'translationLanguages'],
queryFn: getTranslationLanguages,
placeholderData: {},
enabled: allowUnauthenticated && client.features.translations,
}, queryClient);
};
export { useTranslationLanguages };

View file

@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client';
import { queryClient, usePlHooksQueryClient } from 'pl-hooks/contexts/query-client';
import type { PlApiClient } from 'pl-api';
type Timeline = 'home' | 'notifications';
const useMarker = (timeline: Timeline) => {
const { client } = usePlHooksApiClient();
const queryClient = usePlHooksQueryClient();
return useQuery({
queryKey: ['markers', timeline],
queryFn: () => client.timelines.getMarkers([timeline]).then(markers => markers[timeline]),
}, queryClient);
};
const prefetchMarker = (client: PlApiClient, timeline: 'home' | 'notifications') =>
queryClient.prefetchQuery({
queryKey: ['markers', timeline],
queryFn: () => client.timelines.getMarkers([timeline]).then(markers => markers[timeline]),
});
export { useMarker, prefetchMarker, type Timeline };

View file

@ -0,0 +1,27 @@
import { useMutation } from '@tanstack/react-query';
import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client';
import { usePlHooksQueryClient } from 'pl-hooks/contexts/query-client';
import type { Timeline } from './useMarkers';
import type { Marker } from 'pl-api';
const useUpdateMarkerMutation = (timeline: Timeline) => {
const { client } = usePlHooksApiClient();
const queryClient = usePlHooksQueryClient();
return useMutation({
mutationFn: (lastReadId: string) => client.timelines.saveMarkers({
[timeline]: {
last_read_id: lastReadId,
},
}),
retry: false,
onMutate: (lastReadId) => queryClient.setQueryData<Marker>(['markers', timeline], (marker) => marker ? ({
...marker,
last_read_id: lastReadId,
}) : undefined),
}, queryClient);
};
export { useUpdateMarkerMutation };

View file

@ -0,0 +1,66 @@
import { useQuery } from '@tanstack/react-query';
import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client';
import { queryClient, usePlHooksQueryClient } from 'pl-hooks/contexts/query-client';
import { type NormalizedNotification, normalizeNotification } from 'pl-hooks/normalizers/normalizeNotifications';
import { useAccount } from '../accounts/useAccount';
import { useStatus } from '../statuses/useStatus';
import type { Account } from 'pl-hooks/normalizers/normalizeAccount';
import type { Status } from 'pl-hooks/normalizers/normalizeStatus';
const getNotificationStatusId = (n: NormalizedNotification) => {
if (['mention', 'status', 'reblog', 'favourite', 'poll', 'update', 'emoji_reaction', 'event_reminder', 'participation_accepted', 'participation_request'].includes(n.type))
// @ts-ignore
return n.status_id;
return null;
};
const importNotification = (notification: NormalizedNotification) => {
queryClient.setQueryData<NormalizedNotification>(
['notifications', 'entities', notification.id],
existingNotification => existingNotification?.duplicate ? existingNotification : notification,
);
};
const useNotification = (notificationId: string) => {
const { client } = usePlHooksApiClient();
const queryClient = usePlHooksQueryClient();
const notificationQuery = useQuery({
queryKey: ['notifications', 'entities', notificationId],
queryFn: () => client.notifications.getNotification(notificationId)
.then(normalizeNotification),
}, queryClient);
const notification = notificationQuery.data;
const accountsQuery = queryClient.getQueriesData<Account>({
queryKey: ['accounts', 'entities', notification?.account_ids],
});
const moveTargetAccountQuery = useAccount(notification?.type === 'move' ? notification.target_id : undefined);
const statusQuery = useStatus(notification ? getNotificationStatusId(notification) : false);
let data: (NormalizedNotification & {
account: Account;
accounts: Array<Account>;
target: Account | null;
status: Status | null;
}) | null = null;
if (notification) {
data = {
...notification,
account: accountsQuery[0][1]!,
accounts: accountsQuery.map(([_, account]) => account!).filter(Boolean),
target: moveTargetAccountQuery.data,
status: statusQuery.data,
};
}
return { ...notificationQuery, data };
};
export { useNotification, importNotification };

View file

@ -0,0 +1,72 @@
import { InfiniteData, useInfiniteQuery, UseInfiniteQueryResult } from '@tanstack/react-query';
import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client';
import { queryClient, usePlHooksQueryClient } from 'pl-hooks/contexts/query-client';
import { importEntities } from 'pl-hooks/importer';
import { deduplicateNotifications } from 'pl-hooks/normalizers/normalizeNotifications';
import { flattenPages } from 'pl-hooks/utils/queries';
import type { Notification as BaseNotification, PaginatedResponse, PlApiClient } from 'pl-api';
type UseNotificationParams = {
types?: Array<BaseNotification['type']>;
excludeTypes?: Array<BaseNotification['type']>;
}
const getQueryKey = (params: UseNotificationParams) => [
'notifications',
'lists',
params.types ? params.types.join('|') : params.excludeTypes ? ('exclude:' + params.excludeTypes.join('|')) : 'all',
];
const importNotifications = (response: PaginatedResponse<BaseNotification>) => {
const deduplicatedNotifications = deduplicateNotifications(response.items);
importEntities({
notifications: deduplicatedNotifications,
});
return {
items: deduplicatedNotifications.filter(({ duplicate }) => !duplicate).map(({ id }) => id),
previous: response.previous,
next: response.next,
};
};
const useNotificationList = (params: UseNotificationParams): Omit<UseInfiniteQueryResult<InfiniteData<{
items: string[];
previous: (() => Promise<PaginatedResponse<BaseNotification>>) | null;
next: (() => Promise<PaginatedResponse<BaseNotification>>) | null;
}, unknown>, Error>, 'data'> & { data: string[] } => {
const { client } = usePlHooksApiClient();
const queryClient = usePlHooksQueryClient();
const notificationsQuery = useInfiniteQuery({
queryKey: getQueryKey(params),
queryFn: ({ pageParam }) => (pageParam.next ? pageParam.next() : client.notifications.getNotifications({
types: params.types,
exclude_types: params.excludeTypes,
})).then(importNotifications),
initialPageParam: { previous: null, next: null } as Pick<PaginatedResponse<BaseNotification>, 'previous' | 'next'>,
getNextPageParam: (response) => response,
}, queryClient);
const data: string[] = flattenPages<string>(notificationsQuery.data) || [];
return {
...notificationsQuery,
data,
};
};
const prefetchNotifications = (client: PlApiClient, params: UseNotificationParams) =>
queryClient.prefetchInfiniteQuery({
queryKey: getQueryKey(params),
queryFn: () => client.notifications.getNotifications({
types: params.types,
exclude_types: params.excludeTypes,
}).then(importNotifications),
initialPageParam: { previous: null, next: null } as Pick<PaginatedResponse<BaseNotification>, 'previous' | 'next'>,
});
export { useNotificationList, prefetchNotifications };

View file

@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client';
import { usePlHooksQueryClient } from 'pl-hooks/contexts/query-client';
const usePoll = (pollId?: string) => {
const queryClient = usePlHooksQueryClient();
const { client } = usePlHooksApiClient();
return useQuery({
queryKey: ['polls', 'entities', pollId],
queryFn: () => client.polls.getPoll(pollId!),
enabled: !!pollId,
}, queryClient);
};
export { usePoll };

View file

@ -0,0 +1,131 @@
import { useQueries, useQuery } from '@tanstack/react-query';
import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client';
import { queryClient, usePlHooksQueryClient } from 'pl-hooks/contexts/query-client';
import { importEntities } from 'pl-hooks/importer';
import { type Account, normalizeAccount } from 'pl-hooks/normalizers/normalizeAccount';
import { normalizeStatus, type Status } from '../../normalizers/normalizeStatus';
// const toServerSideType = (columnType: string): Filter['context'][0] => {
// switch (columnType) {
// case 'home':
// case 'notifications':
// case 'public':
// case 'thread':
// return columnType;
// default:
// if (columnType.includes('list:')) {
// return 'home';
// } else {
// return 'public'; // community, account, hashtag
// }
// }
// };
// type FilterContext = { contextType?: string };
// const getFilters = (state: RootState, query: FilterContext) =>
// state.filters.filter((filter) =>
// (!query?.contextType || filter.context.includes(toServerSideType(query.contextType)))
// && (filter.expires_at === null || Date.parse(filter.expires_at) > new Date().getTime()),
// );
// const escapeRegExp = (string: string) =>
// string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
// const regexFromFilters = (filters: ImmutableList<Filter>) => {
// if (filters.size === 0) return null;
// return new RegExp(filters.map(filter =>
// filter.keywords.map(keyword => {
// let expr = escapeRegExp(keyword.keyword);
// if (keyword.whole_word) {
// if (/^[\w]/.test(expr)) {
// expr = `\\b${expr}`;
// }
// if (/[\w]$/.test(expr)) {
// expr = `${expr}\\b`;
// }
// }
// return expr;
// }).join('|'),
// ).join('|'), 'i');
// };
// const checkFiltered = (index: string, filters: ImmutableList<Filter>) =>
// filters.reduce((result: Array<string>, filter) =>
// result.concat(filter.keywords.reduce((result: Array<string>, keyword) => {
// let expr = escapeRegExp(keyword.keyword);
// if (keyword.whole_word) {
// if (/^[\w]/.test(expr)) {
// expr = `\\b${expr}`;
// }
// if (/[\w]$/.test(expr)) {
// expr = `${expr}\\b`;
// }
// }
// const regex = new RegExp(expr);
// if (regex.test(index)) return result.concat(filter.title);
// return result;
// }, [])), []);
const importStatus = (status: Status) => {
queryClient.setQueryData<Status>(
['statuses', 'entities', status.id],
status,
);
};
const useStatus = (statusId?: string, opts: { language?: string } = {}) => {
const queryClient = usePlHooksQueryClient();
const { client } = usePlHooksApiClient();
const statusQuery = useQuery({
queryKey: ['statuses', 'entities', statusId],
queryFn: () => client.statuses.getStatus(statusId!, {
language: opts.language,
})
.then(status => (importEntities({ statuses: [status] }, { withParents: false }), status))
.then(normalizeStatus),
enabled: !!statusId,
}, queryClient);
const status = statusQuery.data;
const accountsQuery = useQueries({
queries: status?.account_ids.map(accountId => ({
queryKey: ['accounts', 'entities', accountId],
queryFn: () => client.accounts.getAccount(accountId!)
.then(account => (importEntities({ accounts: [account] }, { withParents: false }), account))
.then(normalizeAccount),
})) || [],
}, queryClient);
let data: (Status & {
account: Account;
accounts: Array<Account>;
}) | null = null;
if (status) {
data = {
...status,
account: accountsQuery[0].data!,
accounts: accountsQuery.map(({ data }) => data!).filter(Boolean),
// quote,
// reblog,
// poll
};
}
return { ...statusQuery, data };
};
export { useStatus, importStatus };

View file

@ -0,0 +1,110 @@
import { queryClient } from 'pl-hooks/contexts/query-client';
import { type DeduplicatedNotification, type NormalizedNotification, normalizeNotification } from './normalizers/normalizeNotifications';
import { normalizeStatus, type Status } from './normalizers/normalizeStatus';
import type {
Account as BaseAccount,
Group as BaseGroup,
Poll as BasePoll,
Relationship as BaseRelationship,
Status as BaseStatus,
} from 'pl-api';
const importAccount = (account: BaseAccount) => queryClient.setQueryData<BaseAccount>(
['accounts', 'entities', account.id], account,
);
const importGroup = (group: BaseGroup) => queryClient.setQueryData<BaseGroup>(
['groups', 'entities', group.id], group,
);
const importNotification = (notification: DeduplicatedNotification) => queryClient.setQueryData<NormalizedNotification>(
['notifications', 'entities', notification.id],
existingNotification => existingNotification?.duplicate ? existingNotification : normalizeNotification(notification),
);
const importPoll = (poll: BasePoll) => queryClient.setQueryData<BasePoll>(
['polls', 'entities', poll.id], poll,
);
const importRelationship = (relationship: BaseRelationship) => queryClient.setQueryData<BaseRelationship>(
['relationships', 'entities', relationship.id], relationship,
);
const importStatus = (status: BaseStatus) => queryClient.setQueryData<Status>(
['statuses', 'entities', status.id], normalizeStatus(status),
);
const isEmpty = (object: Record<string, any>) => !Object.values(object).some(value => value);
const importEntities = (entities: {
accounts?: Array<BaseAccount>;
groups?: Array<BaseGroup>;
notifications?: Array<DeduplicatedNotification>;
polls?: Array<BasePoll>;
statuses?: Array<BaseStatus>;
relationships?: Array<BaseRelationship>;
}, options = {
withParents: true,
}) => {
const accounts: Record<string, BaseAccount> = {};
const groups: Record<string, BaseGroup> = {};
const notifications: Record<string, DeduplicatedNotification> = {};
const polls: Record<string, BasePoll> = {};
const relationships: Record<string, BaseRelationship> = {};
const statuses: Record<string, BaseStatus> = {};
const processAccount = (account: BaseAccount, withSelf = true) => {
if (withSelf) accounts[account.id] = account;
queryClient.setQueryData<string>(['accounts', 'byAcct', account.acct.toLocaleLowerCase()], account.id);
if (account.moved) processAccount(account.moved);
if (account.relationship) relationships[account.relationship.id] = account.relationship;
};
const processNotification = (notification: DeduplicatedNotification, withSelf = true) => {
if (withSelf) notifications[notification.id] = notification;
processAccount(notification.account);
if (notification.type === 'move') processAccount(notification.target);
if (['mention', 'status', 'reblog', 'favourite', 'poll', 'update', 'emoji_reaction', 'event_reminder', 'participation_accepted', 'participation_request'].includes(notification.type)) {
// @ts-ignore
processStatus(notification.status);
}
};
const processStatus = (status: BaseStatus, withSelf = true) => {
if (withSelf) statuses[status.id] = status;
if (status.account) {
processAccount(status.account);
}
if (status.quote) processStatus(status.quote);
if (status.reblog) processStatus(status.reblog);
if (status.poll) polls[status.poll.id] = status.poll;
if (status.group) groups[status.group.id] = status.group;
};
if (options.withParents) {
entities.groups?.forEach(group => groups[group.id] = group);
entities.polls?.forEach(poll => polls[poll.id] = poll);
entities.relationships?.forEach(relationship => relationships[relationship.id] = relationship);
}
entities.accounts?.forEach((account) => processAccount(account, options.withParents));
entities.notifications?.forEach((notification) => processNotification(notification, options.withParents));
entities.statuses?.forEach((status) => processStatus(status, options.withParents));
if (!isEmpty(accounts)) Object.values(accounts).forEach(importAccount);
if (!isEmpty(groups)) Object.values(groups).forEach(importGroup);
if (!isEmpty(notifications)) Object.values(notifications).forEach(importNotification);
if (!isEmpty(polls)) Object.values(polls).forEach(importPoll);
if (!isEmpty(relationships)) Object.values(relationships).forEach(importRelationship);
if (!isEmpty(statuses)) Object.values(statuses).forEach(importStatus);
};
export { importEntities };

View file

@ -0,0 +1,16 @@
export * from './contexts/api-client';
export * from './contexts/query-client';
export * from './hooks/accounts/useAccount';
export * from './hooks/accounts/useAccountLookup';
export * from './hooks/accounts/useAccountRelationship';
export * from './hooks/instance/useInstance';
export * from './hooks/instance/useTranslationLanguages';
export * from './hooks/markers/useMarkers';
export * from './hooks/markers/useUpdateMarkerMutation';
export * from './hooks/notifications/useNotification';
export * from './hooks/notifications/useNotificationList';
export * from './hooks/polls/usePoll';
export * from './hooks/statuses/useStatus';
export * from './importer';

View file

@ -0,0 +1,10 @@
import type { Account as BaseAccount } from 'pl-api';
const normalizeAccount = ({ moved, ...account }: BaseAccount) => ({
...account,
moved_id: moved?.id || null,
});
type Account = ReturnType<typeof normalizeAccount>;
export { normalizeAccount, type Account };

View file

@ -0,0 +1,129 @@
import omit from 'lodash/omit';
import type { AccountWarning, Account as BaseAccount, Notification as BaseNotification, RelationshipSeveranceEvent } from 'pl-api';
type DeduplicatedNotification = BaseNotification & {
accounts: Array<BaseAccount>;
duplicate?: boolean;
}
const STATUS_NOTIFICATION_TYPES = [
'mention',
'status',
'reblog',
'favourite',
'poll',
'update',
'emoji_reaction',
'event_reminder',
'participation_accepted',
'participation_request',
];
const getNotificationStatus = (n: Pick<BaseNotification, 'type'>) => {
if (STATUS_NOTIFICATION_TYPES.includes(n.type))
// @ts-ignore
return n.status;
return null;
};
const deduplicateNotifications = (notifications: Array<BaseNotification>): Array<DeduplicatedNotification> => {
const deduplicatedNotifications: DeduplicatedNotification[] = [];
for (const notification of notifications) {
if (STATUS_NOTIFICATION_TYPES.includes(notification.type)) {
const existingNotification = deduplicatedNotifications
.find(deduplicated =>
deduplicated.type === notification.type
&& ((notification.type === 'emoji_reaction' && deduplicated.type === 'emoji_reaction') ? notification.emoji === deduplicated.emoji : true)
&& getNotificationStatus(deduplicated)?.id === getNotificationStatus(notification)?.id,
);
if (existingNotification) {
existingNotification.accounts.push(notification.account);
deduplicatedNotifications.push({ ...notification, accounts: [notification.account], duplicate: true });
} else {
deduplicatedNotifications.push({ ...notification, accounts: [notification.account], duplicate: false });
}
} else {
deduplicatedNotifications.push({ ...notification, accounts: [notification.account], duplicate: false });
}
}
return deduplicatedNotifications;
};
const normalizeNotification = (notification: BaseNotification | DeduplicatedNotification) => {
// @ts-ignore
const minifiedNotification: {
duplicate: boolean;
account_id: string;
account_ids: string[];
created_at: string;
id: string;
group_key: string;
} & (
| { type: 'follow' | 'follow_request' | 'admin.sign_up' | 'bite' }
| {
type: 'mention';
subtype?: 'reply';
status_id: string;
}
| {
type: 'status' | 'reblog' | 'favourite' | 'poll' | 'update' | 'event_reminder';
status_id: string;
}
| {
type: 'admin.report';
report: Report;
}
| {
type: 'severed_relationships';
relationship_severance_event: RelationshipSeveranceEvent;
}
| {
type: 'moderation_warning';
moderation_warning: AccountWarning;
}
| {
type: 'move';
target_id: string;
}
| {
type: 'emoji_reaction';
emoji: string;
emoji_url: string | null;
status_id: string;
}
| {
type: 'chat_mention';
chat_message_id: string;
}
| {
type: 'participation_accepted' | 'participation_request';
status_id: string;
participation_message: string | null;
}
) = {
duplicate: false,
...omit(notification, ['account', 'accounts', 'status', 'target', 'chat_message']),
account_id: notification.account.id,
account_ids: ('accounts' in notification) ? notification.accounts.map(({ id }) => id) : [notification.account.id],
created_at: notification.created_at,
id: notification.id,
type: notification.type,
};
// @ts-ignore
if (notification.status) minifiedNotification.status_id = notification.status.id;
// @ts-ignore
if (notification.target) minifiedNotification.target_id = notification.target.id;
// @ts-ignore
if (notification.chat_message) minifiedNotification.chat_message_id = notification.chat_message.id;
return minifiedNotification;
};
type NormalizedNotification = ReturnType<typeof normalizeNotification>;
export { deduplicateNotifications, normalizeNotification, type DeduplicatedNotification, type NormalizedNotification };

View file

@ -0,0 +1,79 @@
import { type Account as BaseAccount, type Status as BaseStatus, type MediaAttachment, mentionSchema } from 'pl-api';
import * as v from 'valibot';
type StatusApprovalStatus = Exclude<BaseStatus['approval_status'], null>;
type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'group' | 'mutuals_only' | 'local';
const normalizeStatus = ({ account, accounts, reblog, poll, group, quote, ...status }: BaseStatus & { accounts?: Array<BaseAccount> }) => {
// Sort the replied-to mention to the top
let mentions = status.mentions.toSorted((a, _b) => {
if (a.id === status.in_reply_to_account_id) {
return -1;
} else {
return 0;
}
});
// Add self to mentions if it's a reply to self
const isSelfReply = account.id === status.in_reply_to_account_id;
const hasSelfMention = status.mentions.some(mention => account.id === mention.id);
if (isSelfReply && !hasSelfMention) {
const selfMention = v.parse(mentionSchema, account);
mentions = [selfMention, ...mentions];
}
// Normalize event
let event: BaseStatus['event'] & ({
banner: MediaAttachment | null;
links: Array<MediaAttachment>;
} | null) = null;
let media_attachments = status.media_attachments;
if (status.event) {
const firstAttachment = status.media_attachments[0];
let banner: MediaAttachment | null = null;
if (firstAttachment?.description === 'Banner' && firstAttachment.type === 'image') {
banner = firstAttachment;
media_attachments = media_attachments.slice(1);
}
const links = media_attachments.filter(attachment => attachment.mime_type === 'text/html');
media_attachments = media_attachments.filter(attachment => attachment.mime_type !== 'text/html');
event = {
...status.event,
banner,
links,
};
}
return {
account_id: account.id,
account_ids: accounts?.map(account => account.id) || [account.id],
reblog_id: reblog?.id || null,
poll_id: poll?.id || null,
group_id: group?.id || null,
translating: false,
expectsCard: false,
showFiltered: null as null | boolean,
...status,
quote_id: quote?.id || status.quote_id || null,
mentions,
expanded: null,
hidden: null,
filtered: status.filtered?.map(result => result.filter.title),
event,
media_attachments,
};
};
type Status = ReturnType<typeof normalizeStatus>;
export {
type StatusApprovalStatus,
type StatusVisibility,
normalizeStatus,
type Status,
};

View file

@ -0,0 +1,12 @@
import type { InfiniteData } from '@tanstack/react-query';
import type { PaginatedResponse } from 'pl-api';
/** Flatten paginated results into a single array. */
const flattenPages = <T>(queryData: InfiniteData<Pick<PaginatedResponse<T>, 'items'>> | undefined) => {
return queryData?.pages.reduce<T[]>(
(prev: T[], curr) => [...prev, ...(curr.items)],
[],
);
};
export { flattenPages };

View file

@ -0,0 +1,47 @@
{
"name": "pl-hooks",
"version": "0.0.1",
"type": "module",
"homepage": "https://github.com/mkljczk/pl-fe/tree/fork/packages/pl-hooks",
"repository": {
"type": "git",
"url": "https://github.com/mkljczk/pl-fe"
},
"bugs": {
"url": "https://github.com/mkljczk/pl-fe/issues"
},
"scripts": {
"dev": "vite",
"build": "tsc --p ./tsconfig-build.json && vite build",
"preview": "vite preview",
"lint": "npx eslint --ext .js,.jsx,.cjs,.mjs,.ts,.tsx . --cache"
},
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@types/lodash": "^4.17.10",
"@types/node": "^20.14.12",
"@types/react": "^18.3.11",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.49.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-compat": "^6.0.1",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-promise": "^6.0.0",
"typescript": "^5.6.2",
"vite": "^5.4.0",
"vite-plugin-dts": "^4.2.1"
},
"dependencies": {
"@tanstack/react-query": "^5.56.2",
"lodash": "^4.17.21",
"pl-api": "^0.1.1",
"react": "^18.3.1",
"valibot": "^0.42.1"
},
"module": "./dist/main.es.js",
"types": "dist/main.d.ts",
"files": [
"dist"
]
}

View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["lib"]
}

View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
"baseUrl": "./",
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"pl-hooks/*": ["lib/*"],
},
},
"include": ["lib"]
}

View file

@ -0,0 +1,30 @@
import { fileURLToPath, URL } from 'node:url';
import { resolve } from 'path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import pkg from './package.json';
export default defineConfig({
plugins: [dts({ include: ['lib'], insertTypesEntry: true })],
build: {
copyPublicDir: false,
lib: {
entry: resolve(__dirname, 'lib/main.ts'),
fileName: (format) => `main.${format}.js`,
formats: ['es'],
name: 'pl-hooks',
},
target: 'esnext',
sourcemap: true,
rollupOptions: {
external: Object.keys(pkg.dependencies),
},
},
resolve: {
alias: [
{ find: 'pl-hooks', replacement: fileURLToPath(new URL('./lib', import.meta.url)) },
],
},
});

2672
packages/pl-hooks/yarn.lock Normal file

File diff suppressed because it is too large Load diff