Merge pull request #114 from mkljczk/hooks-migration
Migrate to @tanstack/react-hooks for remote state management
This commit is contained in:
commit
a0c0f17004
34 changed files with 4016 additions and 9 deletions
41
.github/workflows/pl-hooks.yaml
vendored
Normal file
41
.github/workflows/pl-hooks.yaml
vendored
Normal 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
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 };
|
||||
|
|
9
packages/pl-fe/src/normalizers/index.ts
Normal file
9
packages/pl-fe/src/normalizers/index.ts
Normal 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';
|
7
packages/pl-hooks/.eslintignore
Normal file
7
packages/pl-hooks/.eslintignore
Normal file
|
@ -0,0 +1,7 @@
|
|||
/node_modules/**
|
||||
/dist/**
|
||||
/static/**
|
||||
/public/**
|
||||
/tmp/**
|
||||
/coverage/**
|
||||
/custom/**
|
214
packages/pl-hooks/.eslintrc.json
Normal file
214
packages/pl-hooks/.eslintrc.json
Normal 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
24
packages/pl-hooks/.gitignore
vendored
Normal 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?
|
18
packages/pl-hooks/README.md
Normal file
18
packages/pl-hooks/README.md
Normal 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/>.
|
11
packages/pl-hooks/index.html
Normal file
11
packages/pl-hooks/index.html
Normal 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>
|
16
packages/pl-hooks/lib/contexts/api-client.ts
Normal file
16
packages/pl-hooks/lib/contexts/api-client.ts
Normal 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 };
|
21
packages/pl-hooks/lib/contexts/query-client.ts
Normal file
21
packages/pl-hooks/lib/contexts/query-client.ts
Normal 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 };
|
55
packages/pl-hooks/lib/hooks/accounts/useAccount.ts
Normal file
55
packages/pl-hooks/lib/hooks/accounts/useAccount.ts
Normal 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 };
|
31
packages/pl-hooks/lib/hooks/accounts/useAccountLookup.ts
Normal file
31
packages/pl-hooks/lib/hooks/accounts/useAccountLookup.ts
Normal 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 };
|
|
@ -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 };
|
21
packages/pl-hooks/lib/hooks/instance/useInstance.ts
Normal file
21
packages/pl-hooks/lib/hooks/instance/useInstance.ts
Normal 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 };
|
|
@ -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 };
|
26
packages/pl-hooks/lib/hooks/markers/useMarkers.ts
Normal file
26
packages/pl-hooks/lib/hooks/markers/useMarkers.ts
Normal 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 };
|
|
@ -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 };
|
66
packages/pl-hooks/lib/hooks/notifications/useNotification.ts
Normal file
66
packages/pl-hooks/lib/hooks/notifications/useNotification.ts
Normal 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 };
|
|
@ -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 };
|
17
packages/pl-hooks/lib/hooks/polls/usePoll.ts
Normal file
17
packages/pl-hooks/lib/hooks/polls/usePoll.ts
Normal 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 };
|
131
packages/pl-hooks/lib/hooks/statuses/useStatus.ts
Normal file
131
packages/pl-hooks/lib/hooks/statuses/useStatus.ts
Normal 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 };
|
110
packages/pl-hooks/lib/importer.ts
Normal file
110
packages/pl-hooks/lib/importer.ts
Normal 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 };
|
16
packages/pl-hooks/lib/main.ts
Normal file
16
packages/pl-hooks/lib/main.ts
Normal 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';
|
10
packages/pl-hooks/lib/normalizers/normalizeAccount.ts
Normal file
10
packages/pl-hooks/lib/normalizers/normalizeAccount.ts
Normal 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 };
|
129
packages/pl-hooks/lib/normalizers/normalizeNotifications.ts
Normal file
129
packages/pl-hooks/lib/normalizers/normalizeNotifications.ts
Normal 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 };
|
79
packages/pl-hooks/lib/normalizers/normalizeStatus.ts
Normal file
79
packages/pl-hooks/lib/normalizers/normalizeStatus.ts
Normal 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,
|
||||
};
|
12
packages/pl-hooks/lib/utils/queries.ts
Normal file
12
packages/pl-hooks/lib/utils/queries.ts
Normal 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 };
|
47
packages/pl-hooks/package.json
Normal file
47
packages/pl-hooks/package.json
Normal 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"
|
||||
]
|
||||
}
|
4
packages/pl-hooks/tsconfig-build.json
Normal file
4
packages/pl-hooks/tsconfig-build.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["lib"]
|
||||
}
|
29
packages/pl-hooks/tsconfig.json
Normal file
29
packages/pl-hooks/tsconfig.json
Normal 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"]
|
||||
}
|
30
packages/pl-hooks/vite.config.ts
Normal file
30
packages/pl-hooks/vite.config.ts
Normal 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
2672
packages/pl-hooks/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue