mirror of
https://git.joinsharkey.org/Sharkey/Sharkey.git
synced 2024-11-27 10:03:08 +02:00
Merge branch 'develop' into fix/visibility-widening
This commit is contained in:
commit
7bd0001e76
111 changed files with 8019 additions and 1091 deletions
56
.github/workflows/storybook.yml
vendored
Normal file
56
.github/workflows/storybook.yml
vendored
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
name: Storybook
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
pull_request_target:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3.3.0
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: true
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 7
|
||||||
|
run_install: false
|
||||||
|
- name: Use Node.js 18.x
|
||||||
|
uses: actions/setup-node@v3.6.0
|
||||||
|
with:
|
||||||
|
node-version: 18.x
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: corepack enable
|
||||||
|
- run: pnpm i --frozen-lockfile
|
||||||
|
- name: Check pnpm-lock.yaml
|
||||||
|
run: git diff --exit-code pnpm-lock.yaml
|
||||||
|
- name: Build misskey-js
|
||||||
|
run: pnpm --filter misskey-js build
|
||||||
|
- name: Build storybook
|
||||||
|
run: pnpm --filter frontend build-storybook
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: "--max_old_space_size=7168"
|
||||||
|
- name: Publish to Chromatic
|
||||||
|
id: chromatic
|
||||||
|
uses: chromaui/action@v1
|
||||||
|
with:
|
||||||
|
exitOnceUploaded: true
|
||||||
|
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||||
|
storybookBuildDir: storybook-static
|
||||||
|
workingDir: packages/frontend
|
||||||
|
- name: Compare on Chromatic
|
||||||
|
if: github.event_name == 'pull_request_target'
|
||||||
|
run: pnpm --filter frontend chromatic -d storybook-static --exit-once-uploaded --patch-build ${{ github.head_ref }}...${{ github.base_ref }}
|
||||||
|
env:
|
||||||
|
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||||
|
- name: Upload Artifacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: storybook
|
||||||
|
path: packages/frontend/storybook-static
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -56,6 +56,7 @@ api-docs.json
|
||||||
/files
|
/files
|
||||||
ormconfig.json
|
ormconfig.json
|
||||||
temp
|
temp
|
||||||
|
/packages/frontend/src/**/*.stories.ts
|
||||||
|
|
||||||
# blender backups
|
# blender backups
|
||||||
*.blend1
|
*.blend1
|
||||||
|
|
|
@ -34,6 +34,7 @@
|
||||||
- ノート作成時のパフォーマンスを向上
|
- ノート作成時のパフォーマンスを向上
|
||||||
- アンテナのタイムライン取得時のパフォーマンスを向上
|
- アンテナのタイムライン取得時のパフォーマンスを向上
|
||||||
- チャンネルのタイムライン取得時のパフォーマンスを向上
|
- チャンネルのタイムライン取得時のパフォーマンスを向上
|
||||||
|
- 通知に関する全体的なパフォーマンスを向上
|
||||||
|
|
||||||
## 13.10.3
|
## 13.10.3
|
||||||
|
|
||||||
|
|
110
CONTRIBUTING.md
110
CONTRIBUTING.md
|
@ -203,6 +203,116 @@ niraxは、Misskeyで使用しているオリジナルのフロントエンド
|
||||||
vue-routerとの最大の違いは、niraxは複数のルーターが存在することを許可している点です。
|
vue-routerとの最大の違いは、niraxは複数のルーターが存在することを許可している点です。
|
||||||
これにより、アプリ内ウィンドウでブラウザとは個別にルーティングすることなどが可能になります。
|
これにより、アプリ内ウィンドウでブラウザとは個別にルーティングすることなどが可能になります。
|
||||||
|
|
||||||
|
## Storybook
|
||||||
|
|
||||||
|
Misskey uses [Storybook](https://storybook.js.org/) for UI development.
|
||||||
|
|
||||||
|
### Setup & Run
|
||||||
|
|
||||||
|
#### Universal
|
||||||
|
|
||||||
|
##### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter misskey-js build
|
||||||
|
pnpm --filter frontend tsc -p .storybook && (node packages/frontend/.storybook/preload-locale.js & node packages/frontend/.storybook/preload-theme.js)
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node packages/frontend/.storybook/generate.js && pnpm --filter frontend storybook dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### macOS & Linux
|
||||||
|
|
||||||
|
##### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter misskey-js build
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter frontend storybook-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
When you create a new component (in this example, `MyComponent.vue`), the story file (`MyComponent.stories.ts`) will be automatically generated by the `.storybook/generate.js` script.
|
||||||
|
You can override the default story by creating a impl story file (`MyComponent.stories.impl.ts`).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
/* eslint-disable import/no-duplicates */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MyComponent from './MyComponent.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MyComponent,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MyComponent v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAvatar>;
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to opt-out from the automatic generation, create a `MyComponent.stories.impl.ts` file and add the following line to the file.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import MyComponent from './MyComponent.vue';
|
||||||
|
void MyComponent;
|
||||||
|
```
|
||||||
|
|
||||||
|
You can override the component meta by creating a meta story file (`MyComponent.stories.meta.ts`).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const argTypes = {
|
||||||
|
scale: {
|
||||||
|
control: {
|
||||||
|
type: 'range',
|
||||||
|
min: 1,
|
||||||
|
max: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Also, you can use msw to mock API requests in the storybook. Creating a `MyComponent.stories.msw.ts` file to define the mock handlers.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { rest } from 'msw';
|
||||||
|
export const handlers = [
|
||||||
|
rest.post('/api/notes/timeline', (req, res, ctx) => {
|
||||||
|
return res(
|
||||||
|
ctx.json([]),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't forget to re-run the `.storybook/generate.js` script after adding, editing, or removing the above files.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
### How to resolve conflictions occurred at pnpm-lock.yaml?
|
### How to resolve conflictions occurred at pnpm-lock.yaml?
|
||||||
|
|
||||||
|
|
11
packages/backend/migration/1680582195041-cleanup.js
Normal file
11
packages/backend/migration/1680582195041-cleanup.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export class cleanup1680582195041 {
|
||||||
|
name = 'cleanup1680582195041'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP TABLE "notification" `);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import type { UsersRepository } from '@/models/index.js';
|
import type { UserProfile, UsersRepository } from '@/models/index.js';
|
||||||
import { KVCache } from '@/misc/cache.js';
|
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||||
import type { LocalUser, User } from '@/models/entities/User.js';
|
import type { LocalUser, User } from '@/models/entities/User.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
|
@ -10,13 +10,18 @@ import { StreamMessages } from '@/server/api/stream/types.js';
|
||||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserCacheService implements OnApplicationShutdown {
|
export class CacheService implements OnApplicationShutdown {
|
||||||
public userByIdCache: KVCache<User>;
|
public userByIdCache: MemoryKVCache<User>;
|
||||||
public localUserByNativeTokenCache: KVCache<LocalUser | null>;
|
public localUserByNativeTokenCache: MemoryKVCache<LocalUser | null>;
|
||||||
public localUserByIdCache: KVCache<LocalUser>;
|
public localUserByIdCache: MemoryKVCache<LocalUser>;
|
||||||
public uriPersonCache: KVCache<User | null>;
|
public uriPersonCache: MemoryKVCache<User | null>;
|
||||||
|
public userProfileCache: RedisKVCache<UserProfile>;
|
||||||
|
public userMutingsCache: RedisKVCache<string[]>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(DI.redis)
|
||||||
|
private redisClient: Redis.Redis,
|
||||||
|
|
||||||
@Inject(DI.redisSubscriber)
|
@Inject(DI.redisSubscriber)
|
||||||
private redisSubscriber: Redis.Redis,
|
private redisSubscriber: Redis.Redis,
|
||||||
|
|
||||||
|
@ -27,10 +32,12 @@ export class UserCacheService implements OnApplicationShutdown {
|
||||||
) {
|
) {
|
||||||
//this.onMessage = this.onMessage.bind(this);
|
//this.onMessage = this.onMessage.bind(this);
|
||||||
|
|
||||||
this.userByIdCache = new KVCache<User>(Infinity);
|
this.userByIdCache = new MemoryKVCache<User>(Infinity);
|
||||||
this.localUserByNativeTokenCache = new KVCache<LocalUser | null>(Infinity);
|
this.localUserByNativeTokenCache = new MemoryKVCache<LocalUser | null>(Infinity);
|
||||||
this.localUserByIdCache = new KVCache<LocalUser>(Infinity);
|
this.localUserByIdCache = new MemoryKVCache<LocalUser>(Infinity);
|
||||||
this.uriPersonCache = new KVCache<User | null>(Infinity);
|
this.uriPersonCache = new MemoryKVCache<User | null>(Infinity);
|
||||||
|
this.userProfileCache = new RedisKVCache<UserProfile>(this.redisClient, 'userProfile', 1000 * 60 * 60 * 24, 1000 * 60);
|
||||||
|
this.userMutingsCache = new RedisKVCache<string[]>(this.redisClient, 'userMutings', 1000 * 60 * 60 * 24, 1000 * 60);
|
||||||
|
|
||||||
this.redisSubscriber.on('message', this.onMessage);
|
this.redisSubscriber.on('message', this.onMessage);
|
||||||
}
|
}
|
||||||
|
@ -52,7 +59,7 @@ export class UserCacheService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.userEntityService.isLocalUser(user)) {
|
if (this.userEntityService.isLocalUser(user)) {
|
||||||
this.localUserByNativeTokenCache.set(user.token, user);
|
this.localUserByNativeTokenCache.set(user.token!, user);
|
||||||
this.localUserByIdCache.set(user.id, user);
|
this.localUserByIdCache.set(user.id, user);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -77,7 +84,7 @@ export class UserCacheService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public findById(userId: User['id']) {
|
public findUserById(userId: User['id']) {
|
||||||
return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId }));
|
return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId }));
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,7 @@ import { S3Service } from './S3Service.js';
|
||||||
import { SignupService } from './SignupService.js';
|
import { SignupService } from './SignupService.js';
|
||||||
import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js';
|
import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js';
|
||||||
import { UserBlockingService } from './UserBlockingService.js';
|
import { UserBlockingService } from './UserBlockingService.js';
|
||||||
import { UserCacheService } from './UserCacheService.js';
|
import { CacheService } from './CacheService.js';
|
||||||
import { UserFollowingService } from './UserFollowingService.js';
|
import { UserFollowingService } from './UserFollowingService.js';
|
||||||
import { UserKeypairStoreService } from './UserKeypairStoreService.js';
|
import { UserKeypairStoreService } from './UserKeypairStoreService.js';
|
||||||
import { UserListService } from './UserListService.js';
|
import { UserListService } from './UserListService.js';
|
||||||
|
@ -159,7 +159,7 @@ const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
|
||||||
const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService };
|
const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService };
|
||||||
const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService };
|
const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService };
|
||||||
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
|
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
|
||||||
const $UserCacheService: Provider = { provide: 'UserCacheService', useExisting: UserCacheService };
|
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
|
||||||
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
|
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
|
||||||
const $UserKeypairStoreService: Provider = { provide: 'UserKeypairStoreService', useExisting: UserKeypairStoreService };
|
const $UserKeypairStoreService: Provider = { provide: 'UserKeypairStoreService', useExisting: UserKeypairStoreService };
|
||||||
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
|
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
|
||||||
|
@ -282,7 +282,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
SignupService,
|
SignupService,
|
||||||
TwoFactorAuthenticationService,
|
TwoFactorAuthenticationService,
|
||||||
UserBlockingService,
|
UserBlockingService,
|
||||||
UserCacheService,
|
CacheService,
|
||||||
UserFollowingService,
|
UserFollowingService,
|
||||||
UserKeypairStoreService,
|
UserKeypairStoreService,
|
||||||
UserListService,
|
UserListService,
|
||||||
|
@ -399,7 +399,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$SignupService,
|
$SignupService,
|
||||||
$TwoFactorAuthenticationService,
|
$TwoFactorAuthenticationService,
|
||||||
$UserBlockingService,
|
$UserBlockingService,
|
||||||
$UserCacheService,
|
$CacheService,
|
||||||
$UserFollowingService,
|
$UserFollowingService,
|
||||||
$UserKeypairStoreService,
|
$UserKeypairStoreService,
|
||||||
$UserListService,
|
$UserListService,
|
||||||
|
@ -517,7 +517,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
SignupService,
|
SignupService,
|
||||||
TwoFactorAuthenticationService,
|
TwoFactorAuthenticationService,
|
||||||
UserBlockingService,
|
UserBlockingService,
|
||||||
UserCacheService,
|
CacheService,
|
||||||
UserFollowingService,
|
UserFollowingService,
|
||||||
UserKeypairStoreService,
|
UserKeypairStoreService,
|
||||||
UserListService,
|
UserListService,
|
||||||
|
@ -633,7 +633,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$SignupService,
|
$SignupService,
|
||||||
$TwoFactorAuthenticationService,
|
$TwoFactorAuthenticationService,
|
||||||
$UserBlockingService,
|
$UserBlockingService,
|
||||||
$UserCacheService,
|
$CacheService,
|
||||||
$UserFollowingService,
|
$UserFollowingService,
|
||||||
$UserKeypairStoreService,
|
$UserKeypairStoreService,
|
||||||
$UserListService,
|
$UserListService,
|
||||||
|
|
|
@ -8,7 +8,7 @@ import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||||
import type { Emoji } from '@/models/entities/Emoji.js';
|
import type { Emoji } from '@/models/entities/Emoji.js';
|
||||||
import type { EmojisRepository, Note } from '@/models/index.js';
|
import type { EmojisRepository, Note } from '@/models/index.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { KVCache } from '@/misc/cache.js';
|
import { MemoryKVCache } from '@/misc/cache.js';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { ReactionService } from '@/core/ReactionService.js';
|
import { ReactionService } from '@/core/ReactionService.js';
|
||||||
|
@ -16,7 +16,7 @@ import { query } from '@/misc/prelude/url.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomEmojiService {
|
export class CustomEmojiService {
|
||||||
private cache: KVCache<Emoji | null>;
|
private cache: MemoryKVCache<Emoji | null>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
|
@ -34,7 +34,7 @@ export class CustomEmojiService {
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private reactionService: ReactionService,
|
private reactionService: ReactionService,
|
||||||
) {
|
) {
|
||||||
this.cache = new KVCache<Emoji | null>(1000 * 60 * 60 * 12);
|
this.cache = new MemoryKVCache<Emoji | null>(1000 * 60 * 60 * 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { InstancesRepository } from '@/models/index.js';
|
import type { InstancesRepository } from '@/models/index.js';
|
||||||
import type { Instance } from '@/models/entities/Instance.js';
|
import type { Instance } from '@/models/entities/Instance.js';
|
||||||
import { KVCache } from '@/misc/cache.js';
|
import { MemoryKVCache } from '@/misc/cache.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
|
@ -9,7 +9,7 @@ import { bindThis } from '@/decorators.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FederatedInstanceService {
|
export class FederatedInstanceService {
|
||||||
private cache: KVCache<Instance>;
|
private cache: MemoryKVCache<Instance>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.instancesRepository)
|
@Inject(DI.instancesRepository)
|
||||||
|
@ -18,7 +18,7 @@ export class FederatedInstanceService {
|
||||||
private utilityService: UtilityService,
|
private utilityService: UtilityService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
) {
|
) {
|
||||||
this.cache = new KVCache<Instance>(1000 * 60 * 60);
|
this.cache = new MemoryKVCache<Instance>(1000 * 60 * 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull } from 'typeorm';
|
||||||
import type { LocalUser } from '@/models/entities/User.js';
|
import type { LocalUser } from '@/models/entities/User.js';
|
||||||
import type { UsersRepository } from '@/models/index.js';
|
import type { UsersRepository } from '@/models/index.js';
|
||||||
import { KVCache } from '@/misc/cache.js';
|
import { MemoryCache } from '@/misc/cache.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
|
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
@ -11,7 +11,7 @@ const ACTOR_USERNAME = 'instance.actor' as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InstanceActorService {
|
export class InstanceActorService {
|
||||||
private cache: KVCache<LocalUser>;
|
private cache: MemoryCache<LocalUser>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.usersRepository)
|
@Inject(DI.usersRepository)
|
||||||
|
@ -19,12 +19,12 @@ export class InstanceActorService {
|
||||||
|
|
||||||
private createSystemUserService: CreateSystemUserService,
|
private createSystemUserService: CreateSystemUserService,
|
||||||
) {
|
) {
|
||||||
this.cache = new KVCache<LocalUser>(Infinity);
|
this.cache = new MemoryCache<LocalUser>(Infinity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getInstanceActor(): Promise<LocalUser> {
|
public async getInstanceActor(): Promise<LocalUser> {
|
||||||
const cached = this.cache.get(null);
|
const cached = this.cache.get();
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneBy({
|
const user = await this.usersRepository.findOneBy({
|
||||||
|
@ -33,11 +33,11 @@ export class InstanceActorService {
|
||||||
}) as LocalUser | undefined;
|
}) as LocalUser | undefined;
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
this.cache.set(null, user);
|
this.cache.set(user);
|
||||||
return user;
|
return user;
|
||||||
} else {
|
} else {
|
||||||
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as LocalUser;
|
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as LocalUser;
|
||||||
this.cache.set(null, created);
|
this.cache.set(created);
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js
|
||||||
import { checkWordMute } from '@/misc/check-word-mute.js';
|
import { checkWordMute } from '@/misc/check-word-mute.js';
|
||||||
import type { Channel } from '@/models/entities/Channel.js';
|
import type { Channel } from '@/models/entities/Channel.js';
|
||||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||||
import { KVCache } from '@/misc/cache.js';
|
import { MemoryCache } from '@/misc/cache.js';
|
||||||
import type { UserProfile } from '@/models/entities/UserProfile.js';
|
import type { UserProfile } from '@/models/entities/UserProfile.js';
|
||||||
import { RelayService } from '@/core/RelayService.js';
|
import { RelayService } from '@/core/RelayService.js';
|
||||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||||
|
@ -47,7 +47,7 @@ import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
|
|
||||||
const mutedWordsCache = new KVCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
|
const mutedWordsCache = new MemoryCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
|
||||||
|
|
||||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||||
|
|
||||||
|
@ -473,7 +473,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
this.incNotesCountOfUser(user);
|
this.incNotesCountOfUser(user);
|
||||||
|
|
||||||
// Word mute
|
// Word mute
|
||||||
mutedWordsCache.fetch(null, () => this.userProfilesRepository.find({
|
mutedWordsCache.fetch(() => this.userProfilesRepository.find({
|
||||||
where: {
|
where: {
|
||||||
enableWordMute: true,
|
enableWordMute: true,
|
||||||
},
|
},
|
||||||
|
|
|
@ -169,10 +169,6 @@ export class NoteReadService implements OnApplicationShutdown {
|
||||||
this.globalEventService.publishMainStream(userId, 'readAllChannels');
|
this.globalEventService.publishMainStream(userId, 'readAllChannels');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.notificationService.readNotificationByQuery(userId, {
|
|
||||||
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { setTimeout } from 'node:timers/promises';
|
import { setTimeout } from 'node:timers/promises';
|
||||||
|
import Redis from 'ioredis';
|
||||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { In } from 'typeorm';
|
import { In } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
import type { MutingsRepository, UserProfile, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||||
import type { User } from '@/models/entities/User.js';
|
import type { User } from '@/models/entities/User.js';
|
||||||
import type { Notification } from '@/models/entities/Notification.js';
|
import type { Notification } from '@/models/entities/Notification.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
|
@ -11,21 +12,22 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { PushNotificationService } from '@/core/PushNotificationService.js';
|
import { PushNotificationService } from '@/core/PushNotificationService.js';
|
||||||
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
|
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationService implements OnApplicationShutdown {
|
export class NotificationService implements OnApplicationShutdown {
|
||||||
#shutdownController = new AbortController();
|
#shutdownController = new AbortController();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(DI.redis)
|
||||||
|
private redisClient: Redis.Redis,
|
||||||
|
|
||||||
@Inject(DI.usersRepository)
|
@Inject(DI.usersRepository)
|
||||||
private usersRepository: UsersRepository,
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
@Inject(DI.userProfilesRepository)
|
@Inject(DI.userProfilesRepository)
|
||||||
private userProfilesRepository: UserProfilesRepository,
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
@Inject(DI.notificationsRepository)
|
|
||||||
private notificationsRepository: NotificationsRepository,
|
|
||||||
|
|
||||||
@Inject(DI.mutingsRepository)
|
@Inject(DI.mutingsRepository)
|
||||||
private mutingsRepository: MutingsRepository,
|
private mutingsRepository: MutingsRepository,
|
||||||
|
|
||||||
|
@ -34,54 +36,35 @@ export class NotificationService implements OnApplicationShutdown {
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private pushNotificationService: PushNotificationService,
|
private pushNotificationService: PushNotificationService,
|
||||||
|
private cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async readNotification(
|
public async readAllNotification(
|
||||||
userId: User['id'],
|
userId: User['id'],
|
||||||
notificationIds: Notification['id'][],
|
|
||||||
) {
|
) {
|
||||||
if (notificationIds.length === 0) return;
|
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
|
||||||
|
|
||||||
// Update documents
|
const latestNotificationIdsRes = await this.redisClient.xrevrange(
|
||||||
const result = await this.notificationsRepository.update({
|
`notificationTimeline:${userId}`,
|
||||||
notifieeId: userId,
|
'+',
|
||||||
id: In(notificationIds),
|
'-',
|
||||||
isRead: false,
|
'COUNT', 1);
|
||||||
}, {
|
const latestNotificationId = latestNotificationIdsRes[0]?.[0];
|
||||||
isRead: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.affected === 0) return;
|
if (latestNotificationId == null) return;
|
||||||
|
|
||||||
if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.postReadAllNotifications(userId);
|
this.redisClient.set(`latestReadNotification:${userId}`, latestNotificationId);
|
||||||
else return this.postReadNotifications(userId, notificationIds);
|
|
||||||
|
if (latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) {
|
||||||
|
return this.postReadAllNotifications(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
|
||||||
public async readNotificationByQuery(
|
|
||||||
userId: User['id'],
|
|
||||||
query: Record<string, any>,
|
|
||||||
) {
|
|
||||||
const notificationIds = await this.notificationsRepository.findBy({
|
|
||||||
...query,
|
|
||||||
notifieeId: userId,
|
|
||||||
isRead: false,
|
|
||||||
}).then(notifications => notifications.map(notification => notification.id));
|
|
||||||
|
|
||||||
return this.readNotification(userId, notificationIds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private postReadAllNotifications(userId: User['id']) {
|
private postReadAllNotifications(userId: User['id']) {
|
||||||
this.globalEventService.publishMainStream(userId, 'readAllNotifications');
|
this.globalEventService.publishMainStream(userId, 'readAllNotifications');
|
||||||
return this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
|
||||||
private postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) {
|
|
||||||
return this.pushNotificationService.pushNotification(userId, 'readNotifications', { notificationIds });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
@ -90,45 +73,43 @@ export class NotificationService implements OnApplicationShutdown {
|
||||||
type: Notification['type'],
|
type: Notification['type'],
|
||||||
data: Partial<Notification>,
|
data: Partial<Notification>,
|
||||||
): Promise<Notification | null> {
|
): Promise<Notification | null> {
|
||||||
if (data.notifierId && (notifieeId === data.notifierId)) {
|
const profile = await this.cacheService.userProfileCache.fetch(notifieeId, () => this.userProfilesRepository.findOneByOrFail({ userId: notifieeId }));
|
||||||
|
const isMuted = profile.mutingNotificationTypes.includes(type);
|
||||||
|
if (isMuted) return null;
|
||||||
|
|
||||||
|
if (data.notifierId) {
|
||||||
|
if (notifieeId === data.notifierId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId });
|
const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId, () => this.mutingsRepository.findBy({ muterId: notifieeId }).then(xs => xs.map(x => x.muteeId)));
|
||||||
|
if (mutings.includes(data.notifierId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isMuted = profile?.mutingNotificationTypes.includes(type);
|
const notification = {
|
||||||
|
|
||||||
// Create notification
|
|
||||||
const notification = await this.notificationsRepository.insert({
|
|
||||||
id: this.idService.genId(),
|
id: this.idService.genId(),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
notifieeId: notifieeId,
|
|
||||||
type: type,
|
type: type,
|
||||||
// 相手がこの通知をミュートしているようなら、既読を予めつけておく
|
|
||||||
isRead: isMuted,
|
|
||||||
...data,
|
...data,
|
||||||
} as Partial<Notification>)
|
} as Notification;
|
||||||
.then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0]));
|
|
||||||
|
|
||||||
const packed = await this.notificationEntityService.pack(notification, {});
|
this.redisClient.xadd(
|
||||||
|
`notificationTimeline:${notifieeId}`,
|
||||||
|
'MAXLEN', '~', '300',
|
||||||
|
`${this.idService.parse(notification.id).date.getTime()}-*`,
|
||||||
|
'data', JSON.stringify(notification));
|
||||||
|
|
||||||
|
const packed = await this.notificationEntityService.pack(notification, notifieeId, {});
|
||||||
|
|
||||||
// Publish notification event
|
// Publish notification event
|
||||||
this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
|
this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
|
||||||
|
|
||||||
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
|
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
|
||||||
setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
|
setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => {
|
||||||
const fresh = await this.notificationsRepository.findOneBy({ id: notification.id });
|
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`);
|
||||||
if (fresh == null) return; // 既に削除されているかもしれない
|
if (latestReadNotificationId && (latestReadNotificationId >= notification.id)) return;
|
||||||
if (fresh.isRead) return;
|
|
||||||
|
|
||||||
//#region ただしミュートしているユーザーからの通知なら無視
|
|
||||||
const mutings = await this.mutingsRepository.findBy({
|
|
||||||
muterId: notifieeId,
|
|
||||||
});
|
|
||||||
if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
|
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
|
||||||
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
|
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
|
||||||
|
|
|
@ -15,10 +15,6 @@ type PushNotificationsTypes = {
|
||||||
antenna: { id: string, name: string };
|
antenna: { id: string, name: string };
|
||||||
note: Packed<'Note'>;
|
note: Packed<'Note'>;
|
||||||
};
|
};
|
||||||
'readNotifications': { notificationIds: string[] };
|
|
||||||
'readAllNotifications': undefined;
|
|
||||||
'readAntenna': { antennaId: string };
|
|
||||||
'readAllAntennas': undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reduce length because push message servers have character limits
|
// Reduce length because push message servers have character limits
|
||||||
|
@ -72,14 +68,6 @@ export class PushNotificationService {
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const subscription of subscriptions) {
|
for (const subscription of subscriptions) {
|
||||||
// Continue if sendReadMessage is false
|
|
||||||
if ([
|
|
||||||
'readNotifications',
|
|
||||||
'readAllNotifications',
|
|
||||||
'readAntenna',
|
|
||||||
'readAllAntennas',
|
|
||||||
].includes(type) && !subscription.sendReadMessage) continue;
|
|
||||||
|
|
||||||
const pushSubscription = {
|
const pushSubscription = {
|
||||||
endpoint: subscription.endpoint,
|
endpoint: subscription.endpoint,
|
||||||
keys: {
|
keys: {
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { IsNull } from 'typeorm';
|
||||||
import type { LocalUser, User } from '@/models/entities/User.js';
|
import type { LocalUser, User } from '@/models/entities/User.js';
|
||||||
import type { RelaysRepository, UsersRepository } from '@/models/index.js';
|
import type { RelaysRepository, UsersRepository } from '@/models/index.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { KVCache } from '@/misc/cache.js';
|
import { MemoryCache } from '@/misc/cache.js';
|
||||||
import type { Relay } from '@/models/entities/Relay.js';
|
import type { Relay } from '@/models/entities/Relay.js';
|
||||||
import { QueueService } from '@/core/QueueService.js';
|
import { QueueService } from '@/core/QueueService.js';
|
||||||
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
|
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
|
||||||
|
@ -16,7 +16,7 @@ const ACTOR_USERNAME = 'relay.actor' as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RelayService {
|
export class RelayService {
|
||||||
private relaysCache: KVCache<Relay[]>;
|
private relaysCache: MemoryCache<Relay[]>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.usersRepository)
|
@Inject(DI.usersRepository)
|
||||||
|
@ -30,7 +30,7 @@ export class RelayService {
|
||||||
private createSystemUserService: CreateSystemUserService,
|
private createSystemUserService: CreateSystemUserService,
|
||||||
private apRendererService: ApRendererService,
|
private apRendererService: ApRendererService,
|
||||||
) {
|
) {
|
||||||
this.relaysCache = new KVCache<Relay[]>(1000 * 60 * 10);
|
this.relaysCache = new MemoryCache<Relay[]>(1000 * 60 * 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
@ -109,7 +109,7 @@ export class RelayService {
|
||||||
public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise<void> {
|
public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise<void> {
|
||||||
if (activity == null) return;
|
if (activity == null) return;
|
||||||
|
|
||||||
const relays = await this.relaysCache.fetch(null, () => this.relaysRepository.findBy({
|
const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
}));
|
}));
|
||||||
if (relays.length === 0) return;
|
if (relays.length === 0) return;
|
||||||
|
|
|
@ -2,12 +2,12 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { In } from 'typeorm';
|
import { In } from 'typeorm';
|
||||||
import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js';
|
import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js';
|
||||||
import { KVCache } from '@/misc/cache.js';
|
import { MemoryKVCache, MemoryCache } from '@/misc/cache.js';
|
||||||
import type { User } from '@/models/entities/User.js';
|
import type { User } from '@/models/entities/User.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
import { UserCacheService } from '@/core/UserCacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import type { RoleCondFormulaValue } from '@/models/entities/Role.js';
|
import type { RoleCondFormulaValue } from '@/models/entities/Role.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { StreamMessages } from '@/server/api/stream/types.js';
|
import { StreamMessages } from '@/server/api/stream/types.js';
|
||||||
|
@ -57,8 +57,8 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RoleService implements OnApplicationShutdown {
|
export class RoleService implements OnApplicationShutdown {
|
||||||
private rolesCache: KVCache<Role[]>;
|
private rolesCache: MemoryCache<Role[]>;
|
||||||
private roleAssignmentByUserIdCache: KVCache<RoleAssignment[]>;
|
private roleAssignmentByUserIdCache: MemoryKVCache<RoleAssignment[]>;
|
||||||
|
|
||||||
public static AlreadyAssignedError = class extends Error {};
|
public static AlreadyAssignedError = class extends Error {};
|
||||||
public static NotAssignedError = class extends Error {};
|
public static NotAssignedError = class extends Error {};
|
||||||
|
@ -77,15 +77,15 @@ export class RoleService implements OnApplicationShutdown {
|
||||||
private roleAssignmentsRepository: RoleAssignmentsRepository,
|
private roleAssignmentsRepository: RoleAssignmentsRepository,
|
||||||
|
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
private userCacheService: UserCacheService,
|
private cacheService: CacheService,
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
) {
|
) {
|
||||||
//this.onMessage = this.onMessage.bind(this);
|
//this.onMessage = this.onMessage.bind(this);
|
||||||
|
|
||||||
this.rolesCache = new KVCache<Role[]>(Infinity);
|
this.rolesCache = new MemoryCache<Role[]>(Infinity);
|
||||||
this.roleAssignmentByUserIdCache = new KVCache<RoleAssignment[]>(Infinity);
|
this.roleAssignmentByUserIdCache = new MemoryKVCache<RoleAssignment[]>(Infinity);
|
||||||
|
|
||||||
this.redisSubscriber.on('message', this.onMessage);
|
this.redisSubscriber.on('message', this.onMessage);
|
||||||
}
|
}
|
||||||
|
@ -98,7 +98,7 @@ export class RoleService implements OnApplicationShutdown {
|
||||||
const { type, body } = obj.message as StreamMessages['internal']['payload'];
|
const { type, body } = obj.message as StreamMessages['internal']['payload'];
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'roleCreated': {
|
case 'roleCreated': {
|
||||||
const cached = this.rolesCache.get(null);
|
const cached = this.rolesCache.get();
|
||||||
if (cached) {
|
if (cached) {
|
||||||
cached.push({
|
cached.push({
|
||||||
...body,
|
...body,
|
||||||
|
@ -110,7 +110,7 @@ export class RoleService implements OnApplicationShutdown {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'roleUpdated': {
|
case 'roleUpdated': {
|
||||||
const cached = this.rolesCache.get(null);
|
const cached = this.rolesCache.get();
|
||||||
if (cached) {
|
if (cached) {
|
||||||
const i = cached.findIndex(x => x.id === body.id);
|
const i = cached.findIndex(x => x.id === body.id);
|
||||||
if (i > -1) {
|
if (i > -1) {
|
||||||
|
@ -125,9 +125,9 @@ export class RoleService implements OnApplicationShutdown {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'roleDeleted': {
|
case 'roleDeleted': {
|
||||||
const cached = this.rolesCache.get(null);
|
const cached = this.rolesCache.get();
|
||||||
if (cached) {
|
if (cached) {
|
||||||
this.rolesCache.set(null, cached.filter(x => x.id !== body.id));
|
this.rolesCache.set(cached.filter(x => x.id !== body.id));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -214,9 +214,9 @@ export class RoleService implements OnApplicationShutdown {
|
||||||
// 期限切れのロールを除外
|
// 期限切れのロールを除外
|
||||||
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
||||||
const assignedRoleIds = assigns.map(x => x.roleId);
|
const assignedRoleIds = assigns.map(x => x.roleId);
|
||||||
const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({}));
|
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||||
const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id));
|
const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id));
|
||||||
const user = roles.some(r => r.target === 'conditional') ? await this.userCacheService.findById(userId) : null;
|
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
||||||
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula));
|
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula));
|
||||||
return [...assignedRoles, ...matchedCondRoles];
|
return [...assignedRoles, ...matchedCondRoles];
|
||||||
}
|
}
|
||||||
|
@ -231,11 +231,11 @@ export class RoleService implements OnApplicationShutdown {
|
||||||
// 期限切れのロールを除外
|
// 期限切れのロールを除外
|
||||||
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
||||||
const assignedRoleIds = assigns.map(x => x.roleId);
|
const assignedRoleIds = assigns.map(x => x.roleId);
|
||||||
const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({}));
|
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||||
const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id));
|
const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id));
|
||||||
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
|
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
|
||||||
if (badgeCondRoles.length > 0) {
|
if (badgeCondRoles.length > 0) {
|
||||||
const user = roles.some(r => r.target === 'conditional') ? await this.userCacheService.findById(userId) : null;
|
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
||||||
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, r.condFormula));
|
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, r.condFormula));
|
||||||
return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
|
return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
|
||||||
} else {
|
} else {
|
||||||
|
@ -301,7 +301,7 @@ export class RoleService implements OnApplicationShutdown {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getModeratorIds(includeAdmins = true): Promise<User['id'][]> {
|
public async getModeratorIds(includeAdmins = true): Promise<User['id'][]> {
|
||||||
const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({}));
|
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||||
const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator);
|
const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator);
|
||||||
const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({
|
const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({
|
||||||
roleId: In(moderatorRoles.map(r => r.id)),
|
roleId: In(moderatorRoles.map(r => r.id)),
|
||||||
|
@ -321,7 +321,7 @@ export class RoleService implements OnApplicationShutdown {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getAdministratorIds(): Promise<User['id'][]> {
|
public async getAdministratorIds(): Promise<User['id'][]> {
|
||||||
const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({}));
|
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||||
const administratorRoles = roles.filter(r => r.isAdministrator);
|
const administratorRoles = roles.filter(r => r.isAdministrator);
|
||||||
const assigns = administratorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({
|
const assigns = administratorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({
|
||||||
roleId: In(administratorRoles.map(r => r.id)),
|
roleId: In(administratorRoles.map(r => r.id)),
|
||||||
|
|
|
@ -15,7 +15,7 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import { WebhookService } from '@/core/WebhookService.js';
|
import { WebhookService } from '@/core/WebhookService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { KVCache } from '@/misc/cache.js';
|
import { MemoryKVCache } from '@/misc/cache.js';
|
||||||
import { StreamMessages } from '@/server/api/stream/types.js';
|
import { StreamMessages } from '@/server/api/stream/types.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -23,7 +23,7 @@ export class UserBlockingService implements OnApplicationShutdown {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
// キーがユーザーIDで、値がそのユーザーがブロックしているユーザーのIDのリストなキャッシュ
|
// キーがユーザーIDで、値がそのユーザーがブロックしているユーザーのIDのリストなキャッシュ
|
||||||
private blockingsByUserIdCache: KVCache<User['id'][]>;
|
private blockingsByUserIdCache: MemoryKVCache<User['id'][]>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.redisSubscriber)
|
@Inject(DI.redisSubscriber)
|
||||||
|
@ -58,7 +58,7 @@ export class UserBlockingService implements OnApplicationShutdown {
|
||||||
) {
|
) {
|
||||||
this.logger = this.loggerService.getLogger('user-block');
|
this.logger = this.loggerService.getLogger('user-block');
|
||||||
|
|
||||||
this.blockingsByUserIdCache = new KVCache<User['id'][]>(Infinity);
|
this.blockingsByUserIdCache = new MemoryKVCache<User['id'][]>(Infinity);
|
||||||
|
|
||||||
this.redisSubscriber.on('message', this.onMessage);
|
this.redisSubscriber.on('message', this.onMessage);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { User } from '@/models/entities/User.js';
|
import type { User } from '@/models/entities/User.js';
|
||||||
import type { UserKeypairsRepository } from '@/models/index.js';
|
import type { UserKeypairsRepository } from '@/models/index.js';
|
||||||
import { KVCache } from '@/misc/cache.js';
|
import { MemoryKVCache } from '@/misc/cache.js';
|
||||||
import type { UserKeypair } from '@/models/entities/UserKeypair.js';
|
import type { UserKeypair } from '@/models/entities/UserKeypair.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserKeypairStoreService {
|
export class UserKeypairStoreService {
|
||||||
private cache: KVCache<UserKeypair>;
|
private cache: MemoryKVCache<UserKeypair>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.userKeypairsRepository)
|
@Inject(DI.userKeypairsRepository)
|
||||||
private userKeypairsRepository: UserKeypairsRepository,
|
private userKeypairsRepository: UserKeypairsRepository,
|
||||||
) {
|
) {
|
||||||
this.cache = new KVCache<UserKeypair>(Infinity);
|
this.cache = new MemoryKVCache<UserKeypair>(Infinity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
@ -3,9 +3,9 @@ import escapeRegexp from 'escape-regexp';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
|
import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { KVCache } from '@/misc/cache.js';
|
import { MemoryKVCache } from '@/misc/cache.js';
|
||||||
import type { UserPublickey } from '@/models/entities/UserPublickey.js';
|
import type { UserPublickey } from '@/models/entities/UserPublickey.js';
|
||||||
import { UserCacheService } from '@/core/UserCacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import type { Note } from '@/models/entities/Note.js';
|
import type { Note } from '@/models/entities/Note.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { RemoteUser, User } from '@/models/entities/User.js';
|
import { RemoteUser, User } from '@/models/entities/User.js';
|
||||||
|
@ -31,8 +31,8 @@ export type UriParseResult = {
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApDbResolverService {
|
export class ApDbResolverService {
|
||||||
private publicKeyCache: KVCache<UserPublickey | null>;
|
private publicKeyCache: MemoryKVCache<UserPublickey | null>;
|
||||||
private publicKeyByUserIdCache: KVCache<UserPublickey | null>;
|
private publicKeyByUserIdCache: MemoryKVCache<UserPublickey | null>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
|
@ -47,11 +47,11 @@ export class ApDbResolverService {
|
||||||
@Inject(DI.userPublickeysRepository)
|
@Inject(DI.userPublickeysRepository)
|
||||||
private userPublickeysRepository: UserPublickeysRepository,
|
private userPublickeysRepository: UserPublickeysRepository,
|
||||||
|
|
||||||
private userCacheService: UserCacheService,
|
private cacheService: CacheService,
|
||||||
private apPersonService: ApPersonService,
|
private apPersonService: ApPersonService,
|
||||||
) {
|
) {
|
||||||
this.publicKeyCache = new KVCache<UserPublickey | null>(Infinity);
|
this.publicKeyCache = new MemoryKVCache<UserPublickey | null>(Infinity);
|
||||||
this.publicKeyByUserIdCache = new KVCache<UserPublickey | null>(Infinity);
|
this.publicKeyByUserIdCache = new MemoryKVCache<UserPublickey | null>(Infinity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
@ -107,11 +107,11 @@ export class ApDbResolverService {
|
||||||
if (parsed.local) {
|
if (parsed.local) {
|
||||||
if (parsed.type !== 'users') return null;
|
if (parsed.type !== 'users') return null;
|
||||||
|
|
||||||
return await this.userCacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({
|
return await this.cacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({
|
||||||
id: parsed.id,
|
id: parsed.id,
|
||||||
}).then(x => x ?? undefined)) ?? null;
|
}).then(x => x ?? undefined)) ?? null;
|
||||||
} else {
|
} else {
|
||||||
return await this.userCacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({
|
return await this.cacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({
|
||||||
uri: parsed.uri,
|
uri: parsed.uri,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@ -138,7 +138,7 @@ export class ApDbResolverService {
|
||||||
if (key == null) return null;
|
if (key == null) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: await this.userCacheService.findById(key.userId) as RemoteUser,
|
user: await this.cacheService.findUserById(key.userId) as RemoteUser,
|
||||||
key,
|
key,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import type { Config } from '@/config.js';
|
||||||
import type { RemoteUser } from '@/models/entities/User.js';
|
import type { RemoteUser } from '@/models/entities/User.js';
|
||||||
import { User } from '@/models/entities/User.js';
|
import { User } from '@/models/entities/User.js';
|
||||||
import { truncate } from '@/misc/truncate.js';
|
import { truncate } from '@/misc/truncate.js';
|
||||||
import type { UserCacheService } from '@/core/UserCacheService.js';
|
import type { CacheService } from '@/core/CacheService.js';
|
||||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
|
@ -54,7 +54,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
private metaService: MetaService;
|
private metaService: MetaService;
|
||||||
private federatedInstanceService: FederatedInstanceService;
|
private federatedInstanceService: FederatedInstanceService;
|
||||||
private fetchInstanceMetadataService: FetchInstanceMetadataService;
|
private fetchInstanceMetadataService: FetchInstanceMetadataService;
|
||||||
private userCacheService: UserCacheService;
|
private cacheService: CacheService;
|
||||||
private apResolverService: ApResolverService;
|
private apResolverService: ApResolverService;
|
||||||
private apNoteService: ApNoteService;
|
private apNoteService: ApNoteService;
|
||||||
private apImageService: ApImageService;
|
private apImageService: ApImageService;
|
||||||
|
@ -97,7 +97,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
//private metaService: MetaService,
|
//private metaService: MetaService,
|
||||||
//private federatedInstanceService: FederatedInstanceService,
|
//private federatedInstanceService: FederatedInstanceService,
|
||||||
//private fetchInstanceMetadataService: FetchInstanceMetadataService,
|
//private fetchInstanceMetadataService: FetchInstanceMetadataService,
|
||||||
//private userCacheService: UserCacheService,
|
//private cacheService: CacheService,
|
||||||
//private apResolverService: ApResolverService,
|
//private apResolverService: ApResolverService,
|
||||||
//private apNoteService: ApNoteService,
|
//private apNoteService: ApNoteService,
|
||||||
//private apImageService: ApImageService,
|
//private apImageService: ApImageService,
|
||||||
|
@ -118,7 +118,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
this.metaService = this.moduleRef.get('MetaService');
|
this.metaService = this.moduleRef.get('MetaService');
|
||||||
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
|
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
|
||||||
this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService');
|
this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService');
|
||||||
this.userCacheService = this.moduleRef.get('UserCacheService');
|
this.cacheService = this.moduleRef.get('CacheService');
|
||||||
this.apResolverService = this.moduleRef.get('ApResolverService');
|
this.apResolverService = this.moduleRef.get('ApResolverService');
|
||||||
this.apNoteService = this.moduleRef.get('ApNoteService');
|
this.apNoteService = this.moduleRef.get('ApNoteService');
|
||||||
this.apImageService = this.moduleRef.get('ApImageService');
|
this.apImageService = this.moduleRef.get('ApImageService');
|
||||||
|
@ -207,14 +207,14 @@ export class ApPersonService implements OnModuleInit {
|
||||||
public async fetchPerson(uri: string, resolver?: Resolver): Promise<User | null> {
|
public async fetchPerson(uri: string, resolver?: Resolver): Promise<User | null> {
|
||||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||||
|
|
||||||
const cached = this.userCacheService.uriPersonCache.get(uri);
|
const cached = this.cacheService.uriPersonCache.get(uri);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
// URIがこのサーバーを指しているならデータベースからフェッチ
|
// URIがこのサーバーを指しているならデータベースからフェッチ
|
||||||
if (uri.startsWith(this.config.url + '/')) {
|
if (uri.startsWith(this.config.url + '/')) {
|
||||||
const id = uri.split('/').pop();
|
const id = uri.split('/').pop();
|
||||||
const u = await this.usersRepository.findOneBy({ id });
|
const u = await this.usersRepository.findOneBy({ id });
|
||||||
if (u) this.userCacheService.uriPersonCache.set(uri, u);
|
if (u) this.cacheService.uriPersonCache.set(uri, u);
|
||||||
return u;
|
return u;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -222,7 +222,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
const exist = await this.usersRepository.findOneBy({ uri });
|
const exist = await this.usersRepository.findOneBy({ uri });
|
||||||
|
|
||||||
if (exist) {
|
if (exist) {
|
||||||
this.userCacheService.uriPersonCache.set(uri, exist);
|
this.cacheService.uriPersonCache.set(uri, exist);
|
||||||
return exist;
|
return exist;
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
import { In } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js';
|
import type { AccessTokensRepository, NoteReactionsRepository, NotesRepository, User, UsersRepository } from '@/models/index.js';
|
||||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||||
import type { Notification } from '@/models/entities/Notification.js';
|
import type { Notification } from '@/models/entities/Notification.js';
|
||||||
import type { Note } from '@/models/entities/Note.js';
|
import type { Note } from '@/models/entities/Note.js';
|
||||||
|
@ -25,8 +26,11 @@ export class NotificationEntityService implements OnModuleInit {
|
||||||
constructor(
|
constructor(
|
||||||
private moduleRef: ModuleRef,
|
private moduleRef: ModuleRef,
|
||||||
|
|
||||||
@Inject(DI.notificationsRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notificationsRepository: NotificationsRepository,
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
@Inject(DI.noteReactionsRepository)
|
@Inject(DI.noteReactionsRepository)
|
||||||
private noteReactionsRepository: NoteReactionsRepository,
|
private noteReactionsRepository: NoteReactionsRepository,
|
||||||
|
@ -48,30 +52,40 @@ export class NotificationEntityService implements OnModuleInit {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async pack(
|
public async pack(
|
||||||
src: Notification['id'] | Notification,
|
src: Notification,
|
||||||
|
meId: User['id'],
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
options: {
|
options: {
|
||||||
_hint_?: {
|
|
||||||
|
},
|
||||||
|
hint?: {
|
||||||
packedNotes: Map<Note['id'], Packed<'Note'>>;
|
packedNotes: Map<Note['id'], Packed<'Note'>>;
|
||||||
};
|
packedUsers: Map<User['id'], Packed<'User'>>;
|
||||||
},
|
},
|
||||||
): Promise<Packed<'Notification'>> {
|
): Promise<Packed<'Notification'>> {
|
||||||
const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src });
|
const notification = src;
|
||||||
const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null;
|
const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null;
|
||||||
const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? (
|
const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? (
|
||||||
options._hint_?.packedNotes != null
|
hint?.packedNotes != null
|
||||||
? options._hint_.packedNotes.get(notification.noteId)
|
? hint.packedNotes.get(notification.noteId)
|
||||||
: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
|
: this.noteEntityService.pack(notification.noteId!, { id: meId }, {
|
||||||
detail: true,
|
detail: true,
|
||||||
})
|
})
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
const userIfNeed = notification.notifierId != null ? (
|
||||||
|
hint?.packedUsers != null
|
||||||
|
? hint.packedUsers.get(notification.notifierId)
|
||||||
|
: this.userEntityService.pack(notification.notifierId!, { id: meId }, {
|
||||||
|
detail: false,
|
||||||
|
})
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
return await awaitAll({
|
return await awaitAll({
|
||||||
id: notification.id,
|
id: notification.id,
|
||||||
createdAt: notification.createdAt.toISOString(),
|
createdAt: new Date(notification.createdAt).toISOString(),
|
||||||
type: notification.type,
|
type: notification.type,
|
||||||
isRead: notification.isRead,
|
|
||||||
userId: notification.notifierId,
|
userId: notification.notifierId,
|
||||||
user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null,
|
...(userIfNeed != null ? { user: userIfNeed } : {}),
|
||||||
...(noteIfNeed != null ? { note: noteIfNeed } : {}),
|
...(noteIfNeed != null ? { note: noteIfNeed } : {}),
|
||||||
...(notification.type === 'reaction' ? {
|
...(notification.type === 'reaction' ? {
|
||||||
reaction: notification.reaction,
|
reaction: notification.reaction,
|
||||||
|
@ -87,9 +101,6 @@ export class NotificationEntityService implements OnModuleInit {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param notifications you should join "note" property when fetch from DB, and all notifieeId should be same as meId
|
|
||||||
*/
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async packMany(
|
public async packMany(
|
||||||
notifications: Notification[],
|
notifications: Notification[],
|
||||||
|
@ -97,23 +108,29 @@ export class NotificationEntityService implements OnModuleInit {
|
||||||
) {
|
) {
|
||||||
if (notifications.length === 0) return [];
|
if (notifications.length === 0) return [];
|
||||||
|
|
||||||
for (const notification of notifications) {
|
const noteIds = notifications.map(x => x.noteId).filter(isNotNull);
|
||||||
if (meId !== notification.notifieeId) {
|
const notes = noteIds.length > 0 ? await this.notesRepository.find({
|
||||||
// because we call note packMany with meId, all notifieeId should be same as meId
|
where: { id: In(noteIds) },
|
||||||
throw new Error('TRY_TO_PACK_ANOTHER_USER_NOTIFICATION');
|
relations: ['user', 'user.avatar', 'user.banner', 'reply', 'reply.user', 'reply.user.avatar', 'reply.user.banner', 'renote', 'renote.user', 'renote.user.avatar', 'renote.user.banner'],
|
||||||
}
|
}) : [];
|
||||||
}
|
|
||||||
|
|
||||||
const notes = notifications.map(x => x.note).filter(isNotNull);
|
|
||||||
const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
|
const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
|
||||||
detail: true,
|
detail: true,
|
||||||
});
|
});
|
||||||
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
|
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
|
||||||
|
|
||||||
return await Promise.all(notifications.map(x => this.pack(x, {
|
const userIds = notifications.map(x => x.notifierId).filter(isNotNull);
|
||||||
_hint_: {
|
const users = userIds.length > 0 ? await this.usersRepository.find({
|
||||||
|
where: { id: In(userIds) },
|
||||||
|
relations: ['avatar', 'banner'],
|
||||||
|
}) : [];
|
||||||
|
const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, {
|
||||||
|
detail: false,
|
||||||
|
});
|
||||||
|
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
|
||||||
|
|
||||||
|
return await Promise.all(notifications.map(x => this.pack(x, meId, {}, {
|
||||||
packedNotes,
|
packedNotes,
|
||||||
},
|
packedUsers,
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { In, Not } from 'typeorm';
|
import { In, Not } from 'typeorm';
|
||||||
|
import Redis from 'ioredis';
|
||||||
import Ajv from 'ajv';
|
import Ajv from 'ajv';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
@ -8,11 +9,11 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||||
import type { Promiseable } from '@/misc/prelude/await-all.js';
|
import type { Promiseable } from '@/misc/prelude/await-all.js';
|
||||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||||
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
|
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
|
||||||
import { KVCache } from '@/misc/cache.js';
|
import { MemoryKVCache } from '@/misc/cache.js';
|
||||||
import type { Instance } from '@/models/entities/Instance.js';
|
import type { Instance } from '@/models/entities/Instance.js';
|
||||||
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
|
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
|
||||||
import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js';
|
import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js';
|
||||||
import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js';
|
import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
import type { OnModuleInit } from '@nestjs/common';
|
import type { OnModuleInit } from '@nestjs/common';
|
||||||
|
@ -52,7 +53,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
private customEmojiService: CustomEmojiService;
|
private customEmojiService: CustomEmojiService;
|
||||||
private antennaService: AntennaService;
|
private antennaService: AntennaService;
|
||||||
private roleService: RoleService;
|
private roleService: RoleService;
|
||||||
private userInstanceCache: KVCache<Instance | null>;
|
private userInstanceCache: MemoryKVCache<Instance | null>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private moduleRef: ModuleRef,
|
private moduleRef: ModuleRef,
|
||||||
|
@ -60,6 +61,9 @@ export class UserEntityService implements OnModuleInit {
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
private config: Config,
|
private config: Config,
|
||||||
|
|
||||||
|
@Inject(DI.redis)
|
||||||
|
private redisClient: Redis.Redis,
|
||||||
|
|
||||||
@Inject(DI.usersRepository)
|
@Inject(DI.usersRepository)
|
||||||
private usersRepository: UsersRepository,
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
|
@ -90,9 +94,6 @@ export class UserEntityService implements OnModuleInit {
|
||||||
@Inject(DI.channelFollowingsRepository)
|
@Inject(DI.channelFollowingsRepository)
|
||||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||||
|
|
||||||
@Inject(DI.notificationsRepository)
|
|
||||||
private notificationsRepository: NotificationsRepository,
|
|
||||||
|
|
||||||
@Inject(DI.userNotePiningsRepository)
|
@Inject(DI.userNotePiningsRepository)
|
||||||
private userNotePiningsRepository: UserNotePiningsRepository,
|
private userNotePiningsRepository: UserNotePiningsRepository,
|
||||||
|
|
||||||
|
@ -118,7 +119,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
//private antennaService: AntennaService,
|
//private antennaService: AntennaService,
|
||||||
//private roleService: RoleService,
|
//private roleService: RoleService,
|
||||||
) {
|
) {
|
||||||
this.userInstanceCache = new KVCache<Instance | null>(1000 * 60 * 60 * 3);
|
this.userInstanceCache = new MemoryKVCache<Instance | null>(1000 * 60 * 60 * 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
|
@ -247,21 +248,16 @@ export class UserEntityService implements OnModuleInit {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getHasUnreadNotification(userId: User['id']): Promise<boolean> {
|
public async getHasUnreadNotification(userId: User['id']): Promise<boolean> {
|
||||||
const mute = await this.mutingsRepository.findBy({
|
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
|
||||||
muterId: userId,
|
|
||||||
});
|
|
||||||
const mutedUserIds = mute.map(m => m.muteeId);
|
|
||||||
|
|
||||||
const count = await this.notificationsRepository.count({
|
const latestNotificationIdsRes = await this.redisClient.xrevrange(
|
||||||
where: {
|
`notificationTimeline:${userId}`,
|
||||||
notifieeId: userId,
|
'+',
|
||||||
...(mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}),
|
'-',
|
||||||
isRead: false,
|
'COUNT', 1);
|
||||||
},
|
const latestNotificationId = latestNotificationIdsRes[0]?.[0];
|
||||||
take: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
return count > 0;
|
return latestNotificationId != null && (latestReadNotificationId == null || latestReadNotificationId < latestNotificationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
@ -33,7 +33,6 @@ export const DI = {
|
||||||
emojisRepository: Symbol('emojisRepository'),
|
emojisRepository: Symbol('emojisRepository'),
|
||||||
driveFilesRepository: Symbol('driveFilesRepository'),
|
driveFilesRepository: Symbol('driveFilesRepository'),
|
||||||
driveFoldersRepository: Symbol('driveFoldersRepository'),
|
driveFoldersRepository: Symbol('driveFoldersRepository'),
|
||||||
notificationsRepository: Symbol('notificationsRepository'),
|
|
||||||
metasRepository: Symbol('metasRepository'),
|
metasRepository: Symbol('metasRepository'),
|
||||||
mutingsRepository: Symbol('mutingsRepository'),
|
mutingsRepository: Symbol('mutingsRepository'),
|
||||||
renoteMutingsRepository: Symbol('renoteMutingsRepository'),
|
renoteMutingsRepository: Symbol('renoteMutingsRepository'),
|
||||||
|
|
|
@ -1,18 +1,103 @@
|
||||||
|
import Redis from 'ioredis';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
|
||||||
|
// redis通すとDateのインスタンスはstringに変換されるので
|
||||||
|
type Serialized<T> = {
|
||||||
|
[K in keyof T]:
|
||||||
|
T[K] extends Date
|
||||||
|
? string
|
||||||
|
: T[K] extends (Date | null)
|
||||||
|
? (string | null)
|
||||||
|
: T[K] extends Record<string, any>
|
||||||
|
? Serialized<T[K]>
|
||||||
|
: T[K];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RedisKVCache<T> {
|
||||||
|
private redisClient: Redis.Redis;
|
||||||
|
private name: string;
|
||||||
|
private lifetime: number;
|
||||||
|
private memoryCache: MemoryKVCache<T>;
|
||||||
|
|
||||||
|
constructor(redisClient: RedisKVCache<never>['redisClient'], name: RedisKVCache<never>['name'], lifetime: RedisKVCache<never>['lifetime'], memoryCacheLifetime: number) {
|
||||||
|
this.redisClient = redisClient;
|
||||||
|
this.name = name;
|
||||||
|
this.lifetime = lifetime;
|
||||||
|
this.memoryCache = new MemoryKVCache(memoryCacheLifetime);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async set(key: string, value: T): Promise<void> {
|
||||||
|
this.memoryCache.set(key, value);
|
||||||
|
if (this.lifetime === Infinity) {
|
||||||
|
await this.redisClient.set(
|
||||||
|
`kvcache:${this.name}:${key}`,
|
||||||
|
JSON.stringify(value),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.redisClient.set(
|
||||||
|
`kvcache:${this.name}:${key}`,
|
||||||
|
JSON.stringify(value),
|
||||||
|
'ex', Math.round(this.lifetime / 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async get(key: string): Promise<Serialized<T> | T | undefined> {
|
||||||
|
const memoryCached = this.memoryCache.get(key);
|
||||||
|
if (memoryCached !== undefined) return memoryCached;
|
||||||
|
|
||||||
|
const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`);
|
||||||
|
if (cached == null) return undefined;
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async delete(key: string): Promise<void> {
|
||||||
|
this.memoryCache.delete(key);
|
||||||
|
await this.redisClient.del(`kvcache:${this.name}:${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
|
||||||
|
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async fetch(key: string, fetcher: () => Promise<T>, validator?: (cachedValue: Serialized<T> | T) => boolean): Promise<Serialized<T> | T> {
|
||||||
|
const cachedValue = await this.get(key);
|
||||||
|
if (cachedValue !== undefined) {
|
||||||
|
if (validator) {
|
||||||
|
if (validator(cachedValue)) {
|
||||||
|
// Cache HIT
|
||||||
|
return cachedValue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Cache HIT
|
||||||
|
return cachedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache MISS
|
||||||
|
const value = await fetcher();
|
||||||
|
this.set(key, value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
|
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
|
||||||
|
|
||||||
export class KVCache<T> {
|
export class MemoryKVCache<T> {
|
||||||
public cache: Map<string | null, { date: number; value: T; }>;
|
public cache: Map<string, { date: number; value: T; }>;
|
||||||
private lifetime: number;
|
private lifetime: number;
|
||||||
|
|
||||||
constructor(lifetime: KVCache<never>['lifetime']) {
|
constructor(lifetime: MemoryKVCache<never>['lifetime']) {
|
||||||
this.cache = new Map();
|
this.cache = new Map();
|
||||||
this.lifetime = lifetime;
|
this.lifetime = lifetime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public set(key: string | null, value: T): void {
|
public set(key: string, value: T): void {
|
||||||
this.cache.set(key, {
|
this.cache.set(key, {
|
||||||
date: Date.now(),
|
date: Date.now(),
|
||||||
value,
|
value,
|
||||||
|
@ -20,7 +105,7 @@ export class KVCache<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public get(key: string | null): T | undefined {
|
public get(key: string): T | undefined {
|
||||||
const cached = this.cache.get(key);
|
const cached = this.cache.get(key);
|
||||||
if (cached == null) return undefined;
|
if (cached == null) return undefined;
|
||||||
if ((Date.now() - cached.date) > this.lifetime) {
|
if ((Date.now() - cached.date) > this.lifetime) {
|
||||||
|
@ -31,7 +116,7 @@ export class KVCache<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public delete(key: string | null) {
|
public delete(key: string) {
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,7 +125,7 @@ export class KVCache<T> {
|
||||||
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
|
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async fetch(key: string | null, fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> {
|
public async fetch(key: string, fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> {
|
||||||
const cachedValue = this.get(key);
|
const cachedValue = this.get(key);
|
||||||
if (cachedValue !== undefined) {
|
if (cachedValue !== undefined) {
|
||||||
if (validator) {
|
if (validator) {
|
||||||
|
@ -65,7 +150,7 @@ export class KVCache<T> {
|
||||||
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
|
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async fetchMaybe(key: string | null, fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> {
|
public async fetchMaybe(key: string, fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> {
|
||||||
const cachedValue = this.get(key);
|
const cachedValue = this.get(key);
|
||||||
if (cachedValue !== undefined) {
|
if (cachedValue !== undefined) {
|
||||||
if (validator) {
|
if (validator) {
|
||||||
|
@ -88,12 +173,12 @@ export class KVCache<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Cache<T> {
|
export class MemoryCache<T> {
|
||||||
private cachedAt: number | null = null;
|
private cachedAt: number | null = null;
|
||||||
private value: T | undefined;
|
private value: T | undefined;
|
||||||
private lifetime: number;
|
private lifetime: number;
|
||||||
|
|
||||||
constructor(lifetime: Cache<never>['lifetime']) {
|
constructor(lifetime: MemoryCache<never>['lifetime']) {
|
||||||
this.lifetime = lifetime;
|
this.lifetime = lifetime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js';
|
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js';
|
||||||
import type { DataSource } from 'typeorm';
|
import type { DataSource } from 'typeorm';
|
||||||
import type { Provider } from '@nestjs/common';
|
import type { Provider } from '@nestjs/common';
|
||||||
|
|
||||||
|
@ -172,12 +172,6 @@ const $driveFoldersRepository: Provider = {
|
||||||
inject: [DI.db],
|
inject: [DI.db],
|
||||||
};
|
};
|
||||||
|
|
||||||
const $notificationsRepository: Provider = {
|
|
||||||
provide: DI.notificationsRepository,
|
|
||||||
useFactory: (db: DataSource) => db.getRepository(Notification),
|
|
||||||
inject: [DI.db],
|
|
||||||
};
|
|
||||||
|
|
||||||
const $metasRepository: Provider = {
|
const $metasRepository: Provider = {
|
||||||
provide: DI.metasRepository,
|
provide: DI.metasRepository,
|
||||||
useFactory: (db: DataSource) => db.getRepository(Meta),
|
useFactory: (db: DataSource) => db.getRepository(Meta),
|
||||||
|
@ -426,7 +420,6 @@ const $roleAssignmentsRepository: Provider = {
|
||||||
$emojisRepository,
|
$emojisRepository,
|
||||||
$driveFilesRepository,
|
$driveFilesRepository,
|
||||||
$driveFoldersRepository,
|
$driveFoldersRepository,
|
||||||
$notificationsRepository,
|
|
||||||
$metasRepository,
|
$metasRepository,
|
||||||
$mutingsRepository,
|
$mutingsRepository,
|
||||||
$renoteMutingsRepository,
|
$renoteMutingsRepository,
|
||||||
|
@ -493,7 +486,6 @@ const $roleAssignmentsRepository: Provider = {
|
||||||
$emojisRepository,
|
$emojisRepository,
|
||||||
$driveFilesRepository,
|
$driveFilesRepository,
|
||||||
$driveFoldersRepository,
|
$driveFoldersRepository,
|
||||||
$notificationsRepository,
|
|
||||||
$metasRepository,
|
$metasRepository,
|
||||||
$mutingsRepository,
|
$mutingsRepository,
|
||||||
$renoteMutingsRepository,
|
$renoteMutingsRepository,
|
||||||
|
|
|
@ -1,54 +1,19 @@
|
||||||
import { Entity, Index, JoinColumn, ManyToOne, Column, PrimaryColumn } from 'typeorm';
|
import { notificationTypes } from '@/types.js';
|
||||||
import { notificationTypes, obsoleteNotificationTypes } from '@/types.js';
|
|
||||||
import { id } from '../id.js';
|
|
||||||
import { User } from './User.js';
|
import { User } from './User.js';
|
||||||
import { Note } from './Note.js';
|
import { Note } from './Note.js';
|
||||||
import { FollowRequest } from './FollowRequest.js';
|
import { FollowRequest } from './FollowRequest.js';
|
||||||
import { AccessToken } from './AccessToken.js';
|
import { AccessToken } from './AccessToken.js';
|
||||||
|
|
||||||
@Entity()
|
export type Notification = {
|
||||||
export class Notification {
|
id: string;
|
||||||
@PrimaryColumn(id())
|
|
||||||
public id: string;
|
|
||||||
|
|
||||||
@Index()
|
// RedisのためDateではなくstring
|
||||||
@Column('timestamp with time zone', {
|
createdAt: string;
|
||||||
comment: 'The created date of the Notification.',
|
|
||||||
})
|
|
||||||
public createdAt: Date;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通知の受信者
|
|
||||||
*/
|
|
||||||
@Index()
|
|
||||||
@Column({
|
|
||||||
...id(),
|
|
||||||
comment: 'The ID of recipient user of the Notification.',
|
|
||||||
})
|
|
||||||
public notifieeId: User['id'];
|
|
||||||
|
|
||||||
@ManyToOne(type => User, {
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
})
|
|
||||||
@JoinColumn()
|
|
||||||
public notifiee: User | null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通知の送信者(initiator)
|
* 通知の送信者(initiator)
|
||||||
*/
|
*/
|
||||||
@Index()
|
notifierId: User['id'] | null;
|
||||||
@Column({
|
|
||||||
...id(),
|
|
||||||
nullable: true,
|
|
||||||
comment: 'The ID of sender user of the Notification.',
|
|
||||||
})
|
|
||||||
public notifierId: User['id'] | null;
|
|
||||||
|
|
||||||
@ManyToOne(type => User, {
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
})
|
|
||||||
@JoinColumn()
|
|
||||||
public notifier: User | null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通知の種類。
|
* 通知の種類。
|
||||||
|
@ -64,104 +29,37 @@ export class Notification {
|
||||||
* achievementEarned - 実績を獲得
|
* achievementEarned - 実績を獲得
|
||||||
* app - アプリ通知
|
* app - アプリ通知
|
||||||
*/
|
*/
|
||||||
@Index()
|
type: typeof notificationTypes[number];
|
||||||
@Column('enum', {
|
|
||||||
enum: [
|
|
||||||
...notificationTypes,
|
|
||||||
...obsoleteNotificationTypes,
|
|
||||||
],
|
|
||||||
comment: 'The type of the Notification.',
|
|
||||||
})
|
|
||||||
public type: typeof notificationTypes[number];
|
|
||||||
|
|
||||||
/**
|
noteId: Note['id'] | null;
|
||||||
* 通知が読まれたかどうか
|
|
||||||
*/
|
|
||||||
@Index()
|
|
||||||
@Column('boolean', {
|
|
||||||
default: false,
|
|
||||||
comment: 'Whether the Notification is read.',
|
|
||||||
})
|
|
||||||
public isRead: boolean;
|
|
||||||
|
|
||||||
@Column({
|
followRequestId: FollowRequest['id'] | null;
|
||||||
...id(),
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
public noteId: Note['id'] | null;
|
|
||||||
|
|
||||||
@ManyToOne(type => Note, {
|
reaction: string | null;
|
||||||
onDelete: 'CASCADE',
|
|
||||||
})
|
|
||||||
@JoinColumn()
|
|
||||||
public note: Note | null;
|
|
||||||
|
|
||||||
@Column({
|
choice: number | null;
|
||||||
...id(),
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
public followRequestId: FollowRequest['id'] | null;
|
|
||||||
|
|
||||||
@ManyToOne(type => FollowRequest, {
|
achievement: string | null;
|
||||||
onDelete: 'CASCADE',
|
|
||||||
})
|
|
||||||
@JoinColumn()
|
|
||||||
public followRequest: FollowRequest | null;
|
|
||||||
|
|
||||||
@Column('varchar', {
|
|
||||||
length: 128, nullable: true,
|
|
||||||
})
|
|
||||||
public reaction: string | null;
|
|
||||||
|
|
||||||
@Column('integer', {
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
public choice: number | null;
|
|
||||||
|
|
||||||
@Column('varchar', {
|
|
||||||
length: 128, nullable: true,
|
|
||||||
})
|
|
||||||
public achievement: string | null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* アプリ通知のbody
|
* アプリ通知のbody
|
||||||
*/
|
*/
|
||||||
@Column('varchar', {
|
customBody: string | null;
|
||||||
length: 2048, nullable: true,
|
|
||||||
})
|
|
||||||
public customBody: string | null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* アプリ通知のheader
|
* アプリ通知のheader
|
||||||
* (省略時はアプリ名で表示されることを期待)
|
* (省略時はアプリ名で表示されることを期待)
|
||||||
*/
|
*/
|
||||||
@Column('varchar', {
|
customHeader: string | null;
|
||||||
length: 256, nullable: true,
|
|
||||||
})
|
|
||||||
public customHeader: string | null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* アプリ通知のicon(URL)
|
* アプリ通知のicon(URL)
|
||||||
* (省略時はアプリアイコンで表示されることを期待)
|
* (省略時はアプリアイコンで表示されることを期待)
|
||||||
*/
|
*/
|
||||||
@Column('varchar', {
|
customIcon: string | null;
|
||||||
length: 1024, nullable: true,
|
|
||||||
})
|
|
||||||
public customIcon: string | null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* アプリ通知のアプリ(のトークン)
|
* アプリ通知のアプリ(のトークン)
|
||||||
*/
|
*/
|
||||||
@Index()
|
appAccessTokenId: AccessToken['id'] | null;
|
||||||
@Column({
|
|
||||||
...id(),
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
public appAccessTokenId: AccessToken['id'] | null;
|
|
||||||
|
|
||||||
@ManyToOne(type => AccessToken, {
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
})
|
|
||||||
@JoinColumn()
|
|
||||||
public appAccessToken: AccessToken | null;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,7 +32,6 @@ import { NoteFavorite } from '@/models/entities/NoteFavorite.js';
|
||||||
import { NoteReaction } from '@/models/entities/NoteReaction.js';
|
import { NoteReaction } from '@/models/entities/NoteReaction.js';
|
||||||
import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js';
|
import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js';
|
||||||
import { NoteUnread } from '@/models/entities/NoteUnread.js';
|
import { NoteUnread } from '@/models/entities/NoteUnread.js';
|
||||||
import { Notification } from '@/models/entities/Notification.js';
|
|
||||||
import { Page } from '@/models/entities/Page.js';
|
import { Page } from '@/models/entities/Page.js';
|
||||||
import { PageLike } from '@/models/entities/PageLike.js';
|
import { PageLike } from '@/models/entities/PageLike.js';
|
||||||
import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js';
|
import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js';
|
||||||
|
@ -100,7 +99,6 @@ export {
|
||||||
NoteReaction,
|
NoteReaction,
|
||||||
NoteThreadMuting,
|
NoteThreadMuting,
|
||||||
NoteUnread,
|
NoteUnread,
|
||||||
Notification,
|
|
||||||
Page,
|
Page,
|
||||||
PageLike,
|
PageLike,
|
||||||
PasswordResetRequest,
|
PasswordResetRequest,
|
||||||
|
@ -167,7 +165,6 @@ export type NoteFavoritesRepository = Repository<NoteFavorite>;
|
||||||
export type NoteReactionsRepository = Repository<NoteReaction>;
|
export type NoteReactionsRepository = Repository<NoteReaction>;
|
||||||
export type NoteThreadMutingsRepository = Repository<NoteThreadMuting>;
|
export type NoteThreadMutingsRepository = Repository<NoteThreadMuting>;
|
||||||
export type NoteUnreadsRepository = Repository<NoteUnread>;
|
export type NoteUnreadsRepository = Repository<NoteUnread>;
|
||||||
export type NotificationsRepository = Repository<Notification>;
|
|
||||||
export type PagesRepository = Repository<Page>;
|
export type PagesRepository = Repository<Page>;
|
||||||
export type PageLikesRepository = Repository<PageLike>;
|
export type PageLikesRepository = Repository<PageLike>;
|
||||||
export type PasswordResetRequestsRepository = Repository<PasswordResetRequest>;
|
export type PasswordResetRequestsRepository = Repository<PasswordResetRequest>;
|
||||||
|
|
|
@ -14,10 +14,6 @@ export const packedNotificationSchema = {
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
format: 'date-time',
|
format: 'date-time',
|
||||||
},
|
},
|
||||||
isRead: {
|
|
||||||
type: 'boolean',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
type: {
|
type: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
|
|
|
@ -40,7 +40,6 @@ import { NoteFavorite } from '@/models/entities/NoteFavorite.js';
|
||||||
import { NoteReaction } from '@/models/entities/NoteReaction.js';
|
import { NoteReaction } from '@/models/entities/NoteReaction.js';
|
||||||
import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js';
|
import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js';
|
||||||
import { NoteUnread } from '@/models/entities/NoteUnread.js';
|
import { NoteUnread } from '@/models/entities/NoteUnread.js';
|
||||||
import { Notification } from '@/models/entities/Notification.js';
|
|
||||||
import { Page } from '@/models/entities/Page.js';
|
import { Page } from '@/models/entities/Page.js';
|
||||||
import { PageLike } from '@/models/entities/PageLike.js';
|
import { PageLike } from '@/models/entities/PageLike.js';
|
||||||
import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js';
|
import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js';
|
||||||
|
@ -155,7 +154,6 @@ export const entities = [
|
||||||
DriveFolder,
|
DriveFolder,
|
||||||
Poll,
|
Poll,
|
||||||
PollVote,
|
PollVote,
|
||||||
Notification,
|
|
||||||
Emoji,
|
Emoji,
|
||||||
Hashtag,
|
Hashtag,
|
||||||
SwSubscription,
|
SwSubscription,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { In, LessThan } from 'typeorm';
|
import { In, LessThan } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { AntennasRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js';
|
import type { AntennasRepository, MutedNotesRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
@ -20,9 +20,6 @@ export class CleanProcessorService {
|
||||||
@Inject(DI.userIpsRepository)
|
@Inject(DI.userIpsRepository)
|
||||||
private userIpsRepository: UserIpsRepository,
|
private userIpsRepository: UserIpsRepository,
|
||||||
|
|
||||||
@Inject(DI.notificationsRepository)
|
|
||||||
private notificationsRepository: NotificationsRepository,
|
|
||||||
|
|
||||||
@Inject(DI.mutedNotesRepository)
|
@Inject(DI.mutedNotesRepository)
|
||||||
private mutedNotesRepository: MutedNotesRepository,
|
private mutedNotesRepository: MutedNotesRepository,
|
||||||
|
|
||||||
|
@ -46,10 +43,6 @@ export class CleanProcessorService {
|
||||||
createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))),
|
createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.notificationsRepository.delete({
|
|
||||||
createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.mutedNotesRepository.delete({
|
this.mutedNotesRepository.delete({
|
||||||
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
|
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
|
||||||
reason: 'word',
|
reason: 'word',
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { MetaService } from '@/core/MetaService.js';
|
||||||
import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
|
import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
|
||||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||||
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
||||||
import { KVCache } from '@/misc/cache.js';
|
import { MemoryCache } from '@/misc/cache.js';
|
||||||
import type { Instance } from '@/models/entities/Instance.js';
|
import type { Instance } from '@/models/entities/Instance.js';
|
||||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||||
import ApRequestChart from '@/core/chart/charts/ap-request.js';
|
import ApRequestChart from '@/core/chart/charts/ap-request.js';
|
||||||
|
@ -22,7 +22,7 @@ import type { DeliverJobData } from '../types.js';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DeliverProcessorService {
|
export class DeliverProcessorService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private suspendedHostsCache: KVCache<Instance[]>;
|
private suspendedHostsCache: MemoryCache<Instance[]>;
|
||||||
private latest: string | null;
|
private latest: string | null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -46,7 +46,7 @@ export class DeliverProcessorService {
|
||||||
private queueLoggerService: QueueLoggerService,
|
private queueLoggerService: QueueLoggerService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.queueLoggerService.logger.createSubLogger('deliver');
|
this.logger = this.queueLoggerService.logger.createSubLogger('deliver');
|
||||||
this.suspendedHostsCache = new KVCache<Instance[]>(1000 * 60 * 60);
|
this.suspendedHostsCache = new MemoryCache<Instance[]>(1000 * 60 * 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
@ -60,14 +60,14 @@ export class DeliverProcessorService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// isSuspendedなら中断
|
// isSuspendedなら中断
|
||||||
let suspendedHosts = this.suspendedHostsCache.get(null);
|
let suspendedHosts = this.suspendedHostsCache.get();
|
||||||
if (suspendedHosts == null) {
|
if (suspendedHosts == null) {
|
||||||
suspendedHosts = await this.instancesRepository.find({
|
suspendedHosts = await this.instancesRepository.find({
|
||||||
where: {
|
where: {
|
||||||
isSuspended: true,
|
isSuspended: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.suspendedHostsCache.set(null, suspendedHosts);
|
this.suspendedHostsCache.set(suspendedHosts);
|
||||||
}
|
}
|
||||||
if (suspendedHosts.map(x => x.host).includes(this.utilityService.toPuny(host))) {
|
if (suspendedHosts.map(x => x.host).includes(this.utilityService.toPuny(host))) {
|
||||||
return 'skip (suspended)';
|
return 'skip (suspended)';
|
||||||
|
|
|
@ -4,7 +4,7 @@ import type { NotesRepository, UsersRepository } from '@/models/index.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||||
import { KVCache } from '@/misc/cache.js';
|
import { MemoryCache } from '@/misc/cache.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import NotesChart from '@/core/chart/charts/notes.js';
|
import NotesChart from '@/core/chart/charts/notes.js';
|
||||||
|
@ -118,17 +118,17 @@ export class NodeinfoServerService {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const cache = new KVCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
|
const cache = new MemoryCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
|
||||||
|
|
||||||
fastify.get(nodeinfo2_1path, async (request, reply) => {
|
fastify.get(nodeinfo2_1path, async (request, reply) => {
|
||||||
const base = await cache.fetch(null, () => nodeinfo2());
|
const base = await cache.fetch(() => nodeinfo2());
|
||||||
|
|
||||||
reply.header('Cache-Control', 'public, max-age=600');
|
reply.header('Cache-Control', 'public, max-age=600');
|
||||||
return { version: '2.1', ...base };
|
return { version: '2.1', ...base };
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get(nodeinfo2_0path, async (request, reply) => {
|
fastify.get(nodeinfo2_0path, async (request, reply) => {
|
||||||
const base = await cache.fetch(null, () => nodeinfo2());
|
const base = await cache.fetch(() => nodeinfo2());
|
||||||
|
|
||||||
delete (base as any).software.repository;
|
delete (base as any).software.repository;
|
||||||
|
|
||||||
|
|
|
@ -3,9 +3,9 @@ import { DI } from '@/di-symbols.js';
|
||||||
import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js';
|
import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js';
|
||||||
import type { LocalUser } from '@/models/entities/User.js';
|
import type { LocalUser } from '@/models/entities/User.js';
|
||||||
import type { AccessToken } from '@/models/entities/AccessToken.js';
|
import type { AccessToken } from '@/models/entities/AccessToken.js';
|
||||||
import { KVCache } from '@/misc/cache.js';
|
import { MemoryKVCache } from '@/misc/cache.js';
|
||||||
import type { App } from '@/models/entities/App.js';
|
import type { App } from '@/models/entities/App.js';
|
||||||
import { UserCacheService } from '@/core/UserCacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import isNativeToken from '@/misc/is-native-token.js';
|
import isNativeToken from '@/misc/is-native-token.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ export class AuthenticationError extends Error {
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthenticateService {
|
export class AuthenticateService {
|
||||||
private appCache: KVCache<App>;
|
private appCache: MemoryKVCache<App>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.usersRepository)
|
@Inject(DI.usersRepository)
|
||||||
|
@ -30,9 +30,9 @@ export class AuthenticateService {
|
||||||
@Inject(DI.appsRepository)
|
@Inject(DI.appsRepository)
|
||||||
private appsRepository: AppsRepository,
|
private appsRepository: AppsRepository,
|
||||||
|
|
||||||
private userCacheService: UserCacheService,
|
private cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
this.appCache = new KVCache<App>(Infinity);
|
this.appCache = new MemoryKVCache<App>(Infinity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
@ -42,7 +42,7 @@ export class AuthenticateService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNativeToken(token)) {
|
if (isNativeToken(token)) {
|
||||||
const user = await this.userCacheService.localUserByNativeTokenCache.fetch(token,
|
const user = await this.cacheService.localUserByNativeTokenCache.fetch(token,
|
||||||
() => this.usersRepository.findOneBy({ token }) as Promise<LocalUser | null>);
|
() => this.usersRepository.findOneBy({ token }) as Promise<LocalUser | null>);
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
|
@ -67,7 +67,7 @@ export class AuthenticateService {
|
||||||
lastUsedAt: new Date(),
|
lastUsedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = await this.userCacheService.localUserByIdCache.fetch(accessToken.userId,
|
const user = await this.cacheService.localUserByIdCache.fetch(accessToken.userId,
|
||||||
() => this.usersRepository.findOneBy({
|
() => this.usersRepository.findOneBy({
|
||||||
id: accessToken.userId,
|
id: accessToken.userId,
|
||||||
}) as Promise<LocalUser>);
|
}) as Promise<LocalUser>);
|
||||||
|
|
|
@ -268,7 +268,6 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js';
|
||||||
import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js';
|
import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js';
|
||||||
import * as ep___notifications_create from './endpoints/notifications/create.js';
|
import * as ep___notifications_create from './endpoints/notifications/create.js';
|
||||||
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
|
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
|
||||||
import * as ep___notifications_read from './endpoints/notifications/read.js';
|
|
||||||
import * as ep___pagePush from './endpoints/page-push.js';
|
import * as ep___pagePush from './endpoints/page-push.js';
|
||||||
import * as ep___pages_create from './endpoints/pages/create.js';
|
import * as ep___pages_create from './endpoints/pages/create.js';
|
||||||
import * as ep___pages_delete from './endpoints/pages/delete.js';
|
import * as ep___pages_delete from './endpoints/pages/delete.js';
|
||||||
|
@ -600,7 +599,6 @@ const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep__
|
||||||
const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default };
|
const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default };
|
||||||
const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default };
|
const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default };
|
||||||
const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default };
|
const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default };
|
||||||
const $notifications_read: Provider = { provide: 'ep:notifications/read', useClass: ep___notifications_read.default };
|
|
||||||
const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default };
|
const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default };
|
||||||
const $pages_create: Provider = { provide: 'ep:pages/create', useClass: ep___pages_create.default };
|
const $pages_create: Provider = { provide: 'ep:pages/create', useClass: ep___pages_create.default };
|
||||||
const $pages_delete: Provider = { provide: 'ep:pages/delete', useClass: ep___pages_delete.default };
|
const $pages_delete: Provider = { provide: 'ep:pages/delete', useClass: ep___pages_delete.default };
|
||||||
|
@ -936,7 +934,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||||
$notes_userListTimeline,
|
$notes_userListTimeline,
|
||||||
$notifications_create,
|
$notifications_create,
|
||||||
$notifications_markAllAsRead,
|
$notifications_markAllAsRead,
|
||||||
$notifications_read,
|
|
||||||
$pagePush,
|
$pagePush,
|
||||||
$pages_create,
|
$pages_create,
|
||||||
$pages_delete,
|
$pages_delete,
|
||||||
|
@ -1266,7 +1263,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||||
$notes_userListTimeline,
|
$notes_userListTimeline,
|
||||||
$notifications_create,
|
$notifications_create,
|
||||||
$notifications_markAllAsRead,
|
$notifications_markAllAsRead,
|
||||||
$notifications_read,
|
|
||||||
$pagePush,
|
$pagePush,
|
||||||
$pages_create,
|
$pages_create,
|
||||||
$pages_delete,
|
$pages_delete,
|
||||||
|
|
|
@ -268,7 +268,6 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js';
|
||||||
import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js';
|
import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js';
|
||||||
import * as ep___notifications_create from './endpoints/notifications/create.js';
|
import * as ep___notifications_create from './endpoints/notifications/create.js';
|
||||||
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
|
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
|
||||||
import * as ep___notifications_read from './endpoints/notifications/read.js';
|
|
||||||
import * as ep___pagePush from './endpoints/page-push.js';
|
import * as ep___pagePush from './endpoints/page-push.js';
|
||||||
import * as ep___pages_create from './endpoints/pages/create.js';
|
import * as ep___pages_create from './endpoints/pages/create.js';
|
||||||
import * as ep___pages_delete from './endpoints/pages/delete.js';
|
import * as ep___pages_delete from './endpoints/pages/delete.js';
|
||||||
|
@ -598,7 +597,6 @@ const eps = [
|
||||||
['notes/user-list-timeline', ep___notes_userListTimeline],
|
['notes/user-list-timeline', ep___notes_userListTimeline],
|
||||||
['notifications/create', ep___notifications_create],
|
['notifications/create', ep___notifications_create],
|
||||||
['notifications/mark-all-as-read', ep___notifications_markAllAsRead],
|
['notifications/mark-all-as-read', ep___notifications_markAllAsRead],
|
||||||
['notifications/read', ep___notifications_read],
|
|
||||||
['page-push', ep___pagePush],
|
['page-push', ep___pagePush],
|
||||||
['pages/create', ep___pages_create],
|
['pages/create', ep___pages_create],
|
||||||
['pages/delete', ep___pages_delete],
|
['pages/delete', ep___pages_delete],
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { UsersRepository, FollowingsRepository, NotificationsRepository } from '@/models/index.js';
|
import type { UsersRepository, FollowingsRepository } from '@/models/index.js';
|
||||||
import type { User } from '@/models/entities/User.js';
|
import type { User } from '@/models/entities/User.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
|
@ -36,9 +36,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
@Inject(DI.followingsRepository)
|
@Inject(DI.followingsRepository)
|
||||||
private followingsRepository: FollowingsRepository,
|
private followingsRepository: FollowingsRepository,
|
||||||
|
|
||||||
@Inject(DI.notificationsRepository)
|
|
||||||
private notificationsRepository: NotificationsRepository,
|
|
||||||
|
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private userFollowingService: UserFollowingService,
|
private userFollowingService: UserFollowingService,
|
||||||
private userSuspendService: UserSuspendService,
|
private userSuspendService: UserSuspendService,
|
||||||
|
@ -73,7 +70,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
(async () => {
|
(async () => {
|
||||||
await this.userSuspendService.doPostSuspend(user).catch(e => {});
|
await this.userSuspendService.doPostSuspend(user).catch(e => {});
|
||||||
await this.unFollowAll(user).catch(e => {});
|
await this.unFollowAll(user).catch(e => {});
|
||||||
await this.readAllNotify(user).catch(e => {});
|
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -96,14 +92,4 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
await this.userFollowingService.unfollow(follower, followee, true);
|
await this.userFollowingService.unfollow(follower, followee, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
|
||||||
private async readAllNotify(notifier: User) {
|
|
||||||
await this.notificationsRepository.update({
|
|
||||||
notifierId: notifier.id,
|
|
||||||
isRead: false,
|
|
||||||
}, {
|
|
||||||
isRead: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Brackets } from 'typeorm';
|
import { Brackets, In } from 'typeorm';
|
||||||
|
import Redis from 'ioredis';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotificationsRepository } from '@/models/index.js';
|
import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotesRepository } from '@/models/index.js';
|
||||||
import { obsoleteNotificationTypes, notificationTypes } from '@/types.js';
|
import { obsoleteNotificationTypes, notificationTypes } from '@/types.js';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
|
@ -8,6 +9,8 @@ import { NoteReadService } from '@/core/NoteReadService.js';
|
||||||
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
|
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
|
||||||
import { NotificationService } from '@/core/NotificationService.js';
|
import { NotificationService } from '@/core/NotificationService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
import { Notification } from '@/models/entities/Notification.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['account', 'notifications'],
|
tags: ['account', 'notifications'],
|
||||||
|
@ -38,8 +41,6 @@ export const paramDef = {
|
||||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||||
sinceId: { type: 'string', format: 'misskey:id' },
|
sinceId: { type: 'string', format: 'misskey:id' },
|
||||||
untilId: { type: 'string', format: 'misskey:id' },
|
untilId: { type: 'string', format: 'misskey:id' },
|
||||||
following: { type: 'boolean', default: false },
|
|
||||||
unreadOnly: { type: 'boolean', default: false },
|
|
||||||
markAsRead: { type: 'boolean', default: true },
|
markAsRead: { type: 'boolean', default: true },
|
||||||
// 後方互換のため、廃止された通知タイプも受け付ける
|
// 後方互換のため、廃止された通知タイプも受け付ける
|
||||||
includeTypes: { type: 'array', items: {
|
includeTypes: { type: 'array', items: {
|
||||||
|
@ -56,21 +57,22 @@ export const paramDef = {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(DI.redis)
|
||||||
|
private redisClient: Redis.Redis,
|
||||||
|
|
||||||
@Inject(DI.usersRepository)
|
@Inject(DI.usersRepository)
|
||||||
private usersRepository: UsersRepository,
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
@Inject(DI.followingsRepository)
|
|
||||||
private followingsRepository: FollowingsRepository,
|
|
||||||
|
|
||||||
@Inject(DI.mutingsRepository)
|
@Inject(DI.mutingsRepository)
|
||||||
private mutingsRepository: MutingsRepository,
|
private mutingsRepository: MutingsRepository,
|
||||||
|
|
||||||
@Inject(DI.userProfilesRepository)
|
@Inject(DI.userProfilesRepository)
|
||||||
private userProfilesRepository: UserProfilesRepository,
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
@Inject(DI.notificationsRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notificationsRepository: NotificationsRepository,
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
|
private idService: IdService,
|
||||||
private notificationEntityService: NotificationEntityService,
|
private notificationEntityService: NotificationEntityService,
|
||||||
private notificationService: NotificationService,
|
private notificationService: NotificationService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
|
@ -89,85 +91,39 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
||||||
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
||||||
|
|
||||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
const notificationsRes = await this.redisClient.xrevrange(
|
||||||
.select('following.followeeId')
|
`notificationTimeline:${me.id}`,
|
||||||
.where('following.followerId = :followerId', { followerId: me.id });
|
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
|
||||||
|
'-',
|
||||||
|
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
|
||||||
|
|
||||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
if (notificationsRes.length === 0) {
|
||||||
.select('muting.muteeId')
|
return [];
|
||||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
|
||||||
|
|
||||||
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
|
|
||||||
.select('user_profile.mutedInstances')
|
|
||||||
.where('user_profile.userId = :muterId', { muterId: me.id });
|
|
||||||
|
|
||||||
const suspendedQuery = this.usersRepository.createQueryBuilder('users')
|
|
||||||
.select('users.id')
|
|
||||||
.where('users.isSuspended = TRUE');
|
|
||||||
|
|
||||||
const query = this.queryService.makePaginationQuery(this.notificationsRepository.createQueryBuilder('notification'), ps.sinceId, ps.untilId)
|
|
||||||
.andWhere('notification.notifieeId = :meId', { meId: me.id })
|
|
||||||
.leftJoinAndSelect('notification.notifier', 'notifier')
|
|
||||||
.leftJoinAndSelect('notification.note', 'note')
|
|
||||||
.leftJoinAndSelect('notifier.avatar', 'notifierAvatar')
|
|
||||||
.leftJoinAndSelect('notifier.banner', 'notifierBanner')
|
|
||||||
.leftJoinAndSelect('note.user', 'user')
|
|
||||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
|
||||||
.leftJoinAndSelect('user.banner', 'banner')
|
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
|
||||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
|
||||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
|
||||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
|
||||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
|
||||||
|
|
||||||
// muted users
|
|
||||||
query.andWhere(new Brackets(qb => { qb
|
|
||||||
.where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`)
|
|
||||||
.orWhere('notification.notifierId IS NULL');
|
|
||||||
}));
|
|
||||||
query.setParameters(mutingQuery.getParameters());
|
|
||||||
|
|
||||||
// muted instances
|
|
||||||
query.andWhere(new Brackets(qb => { qb
|
|
||||||
.andWhere('notifier.host IS NULL')
|
|
||||||
.orWhere(`NOT (( ${mutingInstanceQuery.getQuery()} )::jsonb ? notifier.host)`);
|
|
||||||
}));
|
|
||||||
query.setParameters(mutingInstanceQuery.getParameters());
|
|
||||||
|
|
||||||
// suspended users
|
|
||||||
query.andWhere(new Brackets(qb => { qb
|
|
||||||
.where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`)
|
|
||||||
.orWhere('notification.notifierId IS NULL');
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (ps.following) {
|
|
||||||
query.andWhere(`((notification.notifierId IN (${ followingQuery.getQuery() })) OR (notification.notifierId = :meId))`, { meId: me.id });
|
|
||||||
query.setParameters(followingQuery.getParameters());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId) as Notification[];
|
||||||
|
|
||||||
if (includeTypes && includeTypes.length > 0) {
|
if (includeTypes && includeTypes.length > 0) {
|
||||||
query.andWhere('notification.type IN (:...includeTypes)', { includeTypes });
|
notifications = notifications.filter(notification => includeTypes.includes(notification.type));
|
||||||
} else if (excludeTypes && excludeTypes.length > 0) {
|
} else if (excludeTypes && excludeTypes.length > 0) {
|
||||||
query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes });
|
notifications = notifications.filter(notification => !excludeTypes.includes(notification.type));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ps.unreadOnly) {
|
if (notifications.length === 0) {
|
||||||
query.andWhere('notification.isRead = false');
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifications = await query.take(ps.limit).getMany();
|
|
||||||
|
|
||||||
// Mark all as read
|
// Mark all as read
|
||||||
if (notifications.length > 0 && ps.markAsRead) {
|
if (ps.markAsRead) {
|
||||||
this.notificationService.readNotification(me.id, notifications.map(x => x.id));
|
this.notificationService.readAllNotification(me.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const notes = notifications.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)).map(notification => notification.note!);
|
const noteIds = notifications
|
||||||
|
.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type))
|
||||||
|
.map(notification => notification.noteId!);
|
||||||
|
|
||||||
if (notes.length > 0) {
|
if (noteIds.length > 0) {
|
||||||
|
const notes = await this.notesRepository.findBy({ id: In(noteIds) });
|
||||||
this.noteReadService.read(me.id, notes);
|
this.noteReadService.read(me.id, notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const freshUser = await this.usersRepository.findOneByOrFail({ id: me.id });
|
const freshUser = await this.usersRepository.findOneByOrFail({ id: me.id });
|
||||||
const oldToken = freshUser.token;
|
const oldToken = freshUser.token!;
|
||||||
|
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { AccountUpdateService } from '@/core/AccountUpdateService.js';
|
||||||
import { HashtagService } from '@/core/HashtagService.js';
|
import { HashtagService } from '@/core/HashtagService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -152,6 +153,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
private accountUpdateService: AccountUpdateService,
|
private accountUpdateService: AccountUpdateService,
|
||||||
private hashtagService: HashtagService,
|
private hashtagService: HashtagService,
|
||||||
private roleService: RoleService,
|
private roleService: RoleService,
|
||||||
|
private cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, _user, token) => {
|
super(meta, paramDef, async (ps, _user, token) => {
|
||||||
const user = await this.usersRepository.findOneByOrFail({ id: _user.id });
|
const user = await this.usersRepository.findOneByOrFail({ id: _user.id });
|
||||||
|
@ -276,9 +278,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
includeSecrets: isSecure,
|
includeSecrets: isSecure,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updatedProfile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||||
|
|
||||||
|
this.cacheService.userProfileCache.set(user.id, updatedProfile);
|
||||||
|
|
||||||
// Publish meUpdated event
|
// Publish meUpdated event
|
||||||
this.globalEventService.publishMainStream(user.id, 'meUpdated', iObj);
|
this.globalEventService.publishMainStream(user.id, 'meUpdated', iObj);
|
||||||
this.globalEventService.publishUserEvent(user.id, 'updateUserProfile', await this.userProfilesRepository.findOneByOrFail({ userId: user.id }));
|
this.globalEventService.publishUserEvent(user.id, 'updateUserProfile', updatedProfile);
|
||||||
|
|
||||||
// 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認
|
// 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認
|
||||||
if (user.isLocked && ps.isLocked === false) {
|
if (user.isLocked && ps.isLocked === false) {
|
||||||
|
|
|
@ -7,6 +7,7 @@ import type { Muting } from '@/models/entities/Muting.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { GetterService } from '@/server/api/GetterService.js';
|
import { GetterService } from '@/server/api/GetterService.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -65,6 +66,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private getterService: GetterService,
|
private getterService: GetterService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
|
private cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const muter = me;
|
const muter = me;
|
||||||
|
@ -103,6 +105,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
muteeId: mutee.id,
|
muteeId: mutee.id,
|
||||||
} as Muting);
|
} as Muting);
|
||||||
|
|
||||||
|
this.cacheService.userMutingsCache.delete(muter.id);
|
||||||
this.globalEventService.publishUserEvent(me.id, 'mute', mutee);
|
this.globalEventService.publishUserEvent(me.id, 'mute', mutee);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { NotificationsRepository } from '@/models/index.js';
|
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
|
||||||
import { PushNotificationService } from '@/core/PushNotificationService.js';
|
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { NotificationService } from '@/core/NotificationService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['notifications', 'account'],
|
tags: ['notifications', 'account'],
|
||||||
|
@ -23,24 +21,10 @@ export const paramDef = {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.notificationsRepository)
|
private notificationService: NotificationService,
|
||||||
private notificationsRepository: NotificationsRepository,
|
|
||||||
|
|
||||||
private globalEventService: GlobalEventService,
|
|
||||||
private pushNotificationService: PushNotificationService,
|
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
// Update documents
|
this.notificationService.readAllNotification(me.id);
|
||||||
await this.notificationsRepository.update({
|
|
||||||
notifieeId: me.id,
|
|
||||||
isRead: false,
|
|
||||||
}, {
|
|
||||||
isRead: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 全ての通知を読みましたよというイベントを発行
|
|
||||||
this.globalEventService.publishMainStream(me.id, 'readAllNotifications');
|
|
||||||
this.pushNotificationService.pushNotification(me.id, 'readAllNotifications', undefined);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,57 +0,0 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
|
||||||
import { NotificationService } from '@/core/NotificationService.js';
|
|
||||||
|
|
||||||
export const meta = {
|
|
||||||
tags: ['notifications', 'account'],
|
|
||||||
|
|
||||||
requireCredential: true,
|
|
||||||
|
|
||||||
kind: 'write:notifications',
|
|
||||||
|
|
||||||
description: 'Mark a notification as read.',
|
|
||||||
|
|
||||||
errors: {
|
|
||||||
noSuchNotification: {
|
|
||||||
message: 'No such notification.',
|
|
||||||
code: 'NO_SUCH_NOTIFICATION',
|
|
||||||
id: 'efa929d5-05b5-47d1-beec-e6a4dbed011e',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const paramDef = {
|
|
||||||
oneOf: [
|
|
||||||
{
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
notificationId: { type: 'string', format: 'misskey:id' },
|
|
||||||
},
|
|
||||||
required: ['notificationId'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
notificationIds: {
|
|
||||||
type: 'array',
|
|
||||||
items: { type: 'string', format: 'misskey:id' },
|
|
||||||
maxItems: 100,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['notificationIds'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-default-export
|
|
||||||
@Injectable()
|
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|
||||||
constructor(
|
|
||||||
private notificationService: NotificationService,
|
|
||||||
) {
|
|
||||||
super(meta, paramDef, async (ps, me) => {
|
|
||||||
if ('notificationId' in ps) return this.notificationService.readNotification(me.id, [ps.notificationId]);
|
|
||||||
return this.notificationService.readNotification(me.id, ps.notificationIds);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -92,8 +92,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
muterId: muter.id,
|
muterId: muter.id,
|
||||||
muteeId: mutee.id,
|
muteeId: mutee.id,
|
||||||
} as RenoteMuting);
|
} as RenoteMuting);
|
||||||
|
|
||||||
// publishUserEvent(user.id, 'mute', mutee);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -195,8 +195,7 @@ export default class Connection {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private onReadNotification(payload: any) {
|
private onReadNotification(payload: any) {
|
||||||
if (!payload.id) return;
|
this.notificationService.readAllNotification(this.user!.id);
|
||||||
this.notificationService.readNotification(this.user!.id, [payload.id]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -19,7 +19,7 @@ import type { EventEmitter } from 'events';
|
||||||
//#region Stream type-body definitions
|
//#region Stream type-body definitions
|
||||||
export interface InternalStreamTypes {
|
export interface InternalStreamTypes {
|
||||||
userChangeSuspendedState: { id: User['id']; isSuspended: User['isSuspended']; };
|
userChangeSuspendedState: { id: User['id']; isSuspended: User['isSuspended']; };
|
||||||
userTokenRegenerated: { id: User['id']; oldToken: User['token']; newToken: User['token']; };
|
userTokenRegenerated: { id: User['id']; oldToken: string; newToken: string; };
|
||||||
remoteUserUpdated: { id: User['id']; };
|
remoteUserUpdated: { id: User['id']; };
|
||||||
follow: { followerId: User['id']; followeeId: User['id']; };
|
follow: { followerId: User['id']; followeeId: User['id']; };
|
||||||
unfollow: { followerId: User['id']; followeeId: User['id']; };
|
unfollow: { followerId: User['id']; followeeId: User['id']; };
|
||||||
|
|
|
@ -11,7 +11,7 @@ import type { Role, RolesRepository, RoleAssignmentsRepository, UsersRepository,
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
import { genAid } from '@/misc/id/aid.js';
|
import { genAid } from '@/misc/id/aid.js';
|
||||||
import { UserCacheService } from '@/core/UserCacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { sleep } from '../utils.js';
|
import { sleep } from '../utils.js';
|
||||||
|
@ -65,7 +65,7 @@ describe('RoleService', () => {
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
RoleService,
|
RoleService,
|
||||||
UserCacheService,
|
CacheService,
|
||||||
IdService,
|
IdService,
|
||||||
GlobalEventService,
|
GlobalEventService,
|
||||||
],
|
],
|
||||||
|
|
1
packages/frontend/.gitignore
vendored
Normal file
1
packages/frontend/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/storybook-static
|
9
packages/frontend/.storybook/.gitignore
vendored
Normal file
9
packages/frontend/.storybook/.gitignore
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
# (cd path/to/frontend; pnpm tsc -p .storybook)
|
||||||
|
# (cd path/to/frontend; node .storybook/generate.js)
|
||||||
|
/generate.js
|
||||||
|
# (cd path/to/frontend; node .storybook/preload-locale.js)
|
||||||
|
/preload-locale.js
|
||||||
|
/locale.ts
|
||||||
|
# (cd path/to/frontend; node .storybook/preload-theme.js)
|
||||||
|
/preload-theme.js
|
||||||
|
/themes.ts
|
54
packages/frontend/.storybook/fakes.ts
Normal file
54
packages/frontend/.storybook/fakes.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import type { entities } from 'misskey-js'
|
||||||
|
|
||||||
|
export const userDetailed = {
|
||||||
|
id: 'someuserid',
|
||||||
|
username: 'miskist',
|
||||||
|
host: 'misskey-hub.net',
|
||||||
|
name: 'Misskey User',
|
||||||
|
onlineStatus: 'unknown',
|
||||||
|
avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
|
||||||
|
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
|
||||||
|
emojis: [],
|
||||||
|
bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog',
|
||||||
|
bannerColor: '#000000',
|
||||||
|
bannerUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
|
||||||
|
birthday: '2014-06-20',
|
||||||
|
createdAt: '2016-12-28T22:49:51.000Z',
|
||||||
|
description: 'I am a cool user!',
|
||||||
|
ffVisibility: 'public',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Website',
|
||||||
|
value: 'https://misskey-hub.net',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
followersCount: 1024,
|
||||||
|
followingCount: 16,
|
||||||
|
hasPendingFollowRequestFromYou: false,
|
||||||
|
hasPendingFollowRequestToYou: false,
|
||||||
|
isAdmin: false,
|
||||||
|
isBlocked: false,
|
||||||
|
isBlocking: false,
|
||||||
|
isBot: false,
|
||||||
|
isCat: false,
|
||||||
|
isFollowed: false,
|
||||||
|
isFollowing: false,
|
||||||
|
isLocked: false,
|
||||||
|
isModerator: false,
|
||||||
|
isMuted: false,
|
||||||
|
isSilenced: false,
|
||||||
|
isSuspended: false,
|
||||||
|
lang: 'en',
|
||||||
|
location: 'Fediverse',
|
||||||
|
notesCount: 65536,
|
||||||
|
pinnedNoteIds: [],
|
||||||
|
pinnedNotes: [],
|
||||||
|
pinnedPage: null,
|
||||||
|
pinnedPageId: null,
|
||||||
|
publicReactions: false,
|
||||||
|
securityKeys: false,
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
updatedAt: null,
|
||||||
|
uri: null,
|
||||||
|
url: null,
|
||||||
|
} satisfies entities.UserDetailed
|
406
packages/frontend/.storybook/generate.tsx
Normal file
406
packages/frontend/.storybook/generate.tsx
Normal file
|
@ -0,0 +1,406 @@
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { basename, dirname } from 'node:path/posix';
|
||||||
|
import { GENERATOR, type State, generate } from 'astring';
|
||||||
|
import type * as estree from 'estree';
|
||||||
|
import glob from 'fast-glob';
|
||||||
|
import { format } from 'prettier';
|
||||||
|
|
||||||
|
interface SatisfiesExpression extends estree.BaseExpression {
|
||||||
|
type: 'SatisfiesExpression';
|
||||||
|
expression: estree.Expression;
|
||||||
|
reference: estree.Identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = {
|
||||||
|
...GENERATOR,
|
||||||
|
SatisfiesExpression(node: SatisfiesExpression, state: State) {
|
||||||
|
switch (node.expression.type) {
|
||||||
|
case 'ArrowFunctionExpression': {
|
||||||
|
state.write('(');
|
||||||
|
this[node.expression.type](node.expression, state);
|
||||||
|
state.write(')');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// @ts-ignore
|
||||||
|
this[node.expression.type](node.expression, state);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.write(' satisfies ', node as unknown as estree.Expression);
|
||||||
|
this[node.reference.type](node.reference, state);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type SplitCamel<
|
||||||
|
T extends string,
|
||||||
|
YC extends string = '',
|
||||||
|
YN extends readonly string[] = []
|
||||||
|
> = T extends `${infer XH}${infer XR}`
|
||||||
|
? XR extends ''
|
||||||
|
? [...YN, Uncapitalize<`${YC}${XH}`>]
|
||||||
|
: XH extends Uppercase<XH>
|
||||||
|
? SplitCamel<XR, Lowercase<XH>, [...YN, YC]>
|
||||||
|
: SplitCamel<XR, `${YC}${XH}`, YN>
|
||||||
|
: YN;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
type SplitKebab<T extends string> = T extends `${infer XH}-${infer XR}`
|
||||||
|
? [XH, ...SplitKebab<XR>]
|
||||||
|
: [T];
|
||||||
|
|
||||||
|
type ToKebab<T extends readonly string[]> = T extends readonly [
|
||||||
|
infer XO extends string
|
||||||
|
]
|
||||||
|
? XO
|
||||||
|
: T extends readonly [
|
||||||
|
infer XH extends string,
|
||||||
|
...infer XR extends readonly string[]
|
||||||
|
]
|
||||||
|
? `${XH}${XR extends readonly string[] ? `-${ToKebab<XR>}` : ''}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
type ToPascal<T extends readonly string[]> = T extends readonly [
|
||||||
|
infer XH extends string,
|
||||||
|
...infer XR extends readonly string[]
|
||||||
|
]
|
||||||
|
? `${Capitalize<XH>}${ToPascal<XR>}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
function h<T extends estree.Node>(
|
||||||
|
component: T['type'],
|
||||||
|
props: Omit<T, 'type'>
|
||||||
|
): T {
|
||||||
|
const type = component.replace(/(?:^|-)([a-z])/g, (_, c) => c.toUpperCase());
|
||||||
|
return Object.assign(props || {}, { type }) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace JSX {
|
||||||
|
type Element = estree.Node;
|
||||||
|
type ElementClass = never;
|
||||||
|
type ElementAttributesProperty = never;
|
||||||
|
type ElementChildrenAttribute = never;
|
||||||
|
type IntrinsicAttributes = never;
|
||||||
|
type IntrinsicClassAttributes<T> = never;
|
||||||
|
type IntrinsicElements = {
|
||||||
|
[T in keyof typeof generator as ToKebab<SplitCamel<Uncapitalize<T>>>]: {
|
||||||
|
[K in keyof Omit<
|
||||||
|
Parameters<(typeof generator)[T]>[0],
|
||||||
|
'type'
|
||||||
|
>]?: Parameters<(typeof generator)[T]>[0][K];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStories(component: string): string {
|
||||||
|
const msw = `${component.slice(0, -'.vue'.length)}.msw`;
|
||||||
|
const implStories = `${component.slice(0, -'.vue'.length)}.stories.impl`;
|
||||||
|
const metaStories = `${component.slice(0, -'.vue'.length)}.stories.meta`;
|
||||||
|
const hasMsw = existsSync(`${msw}.ts`);
|
||||||
|
const hasImplStories = existsSync(`${implStories}.ts`);
|
||||||
|
const hasMetaStories = existsSync(`${metaStories}.ts`);
|
||||||
|
const base = basename(component);
|
||||||
|
const dir = dirname(component);
|
||||||
|
const literal =
|
||||||
|
<literal
|
||||||
|
value={component
|
||||||
|
.slice('src/'.length, -'.vue'.length)
|
||||||
|
.replace(/\./g, '/')}
|
||||||
|
/> as estree.Literal;
|
||||||
|
const identifier =
|
||||||
|
<identifier
|
||||||
|
name={base
|
||||||
|
.slice(0, -'.vue'.length)
|
||||||
|
.replace(/[-.]|^(?=\d)/g, '_')
|
||||||
|
.replace(/(?<=^[^A-Z_]*$)/, '_')}
|
||||||
|
/> as estree.Identifier;
|
||||||
|
const parameters = (
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<property
|
||||||
|
key={<identifier name='layout' /> as estree.Identifier}
|
||||||
|
value={<literal value={`${dir}/`.startsWith('src/pages/') ? 'fullscreen' : 'centered'}/> as estree.Literal}
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
...(hasMsw
|
||||||
|
? [
|
||||||
|
<property
|
||||||
|
key={<identifier name='msw' /> as estree.Identifier}
|
||||||
|
value={<identifier name='msw' /> as estree.Identifier}
|
||||||
|
kind={'init' as const}
|
||||||
|
shorthand
|
||||||
|
/> as estree.Property,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) as estree.ObjectExpression;
|
||||||
|
const program = (
|
||||||
|
<program
|
||||||
|
body={[
|
||||||
|
<import-declaration
|
||||||
|
source={<literal value='@storybook/vue3' /> as estree.Literal}
|
||||||
|
specifiers={[
|
||||||
|
<import-specifier
|
||||||
|
local={<identifier name='Meta' /> as estree.Identifier}
|
||||||
|
imported={<identifier name='Meta' /> as estree.Identifier}
|
||||||
|
/> as estree.ImportSpecifier,
|
||||||
|
...(hasImplStories
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
<import-specifier
|
||||||
|
local={<identifier name='StoryObj' /> as estree.Identifier}
|
||||||
|
imported={<identifier name='StoryObj' /> as estree.Identifier}
|
||||||
|
/> as estree.ImportSpecifier,
|
||||||
|
]),
|
||||||
|
]}
|
||||||
|
/> as estree.ImportDeclaration,
|
||||||
|
...(hasMsw
|
||||||
|
? [
|
||||||
|
<import-declaration
|
||||||
|
source={<literal value={`./${basename(msw)}`} /> as estree.Literal}
|
||||||
|
specifiers={[
|
||||||
|
<import-namespace-specifier
|
||||||
|
local={<identifier name='msw' /> as estree.Identifier}
|
||||||
|
/> as estree.ImportNamespaceSpecifier,
|
||||||
|
]}
|
||||||
|
/> as estree.ImportDeclaration,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(hasImplStories
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
<import-declaration
|
||||||
|
source={<literal value={`./${base}`} /> as estree.Literal}
|
||||||
|
specifiers={[
|
||||||
|
<import-default-specifier local={identifier} /> as estree.ImportDefaultSpecifier,
|
||||||
|
]}
|
||||||
|
/> as estree.ImportDeclaration,
|
||||||
|
]),
|
||||||
|
...(hasMetaStories
|
||||||
|
? [
|
||||||
|
<import-declaration
|
||||||
|
source={<literal value={`./${basename(metaStories)}`} /> as estree.Literal}
|
||||||
|
specifiers={[
|
||||||
|
<import-namespace-specifier
|
||||||
|
local={<identifier name='storiesMeta' /> as estree.Identifier}
|
||||||
|
/> as estree.ImportNamespaceSpecifier,
|
||||||
|
]}
|
||||||
|
/> as estree.ImportDeclaration,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
<variable-declaration
|
||||||
|
kind={'const' as const}
|
||||||
|
declarations={[
|
||||||
|
<variable-declarator
|
||||||
|
id={<identifier name='meta' /> as estree.Identifier}
|
||||||
|
init={
|
||||||
|
<satisfies-expression
|
||||||
|
expression={
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<property
|
||||||
|
key={<identifier name='title' /> as estree.Identifier}
|
||||||
|
value={literal}
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
<property
|
||||||
|
key={<identifier name='component' /> as estree.Identifier}
|
||||||
|
value={identifier}
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
...(hasMetaStories
|
||||||
|
? [
|
||||||
|
<spread-element
|
||||||
|
argument={<identifier name='storiesMeta' /> as estree.Identifier}
|
||||||
|
/> as estree.SpreadElement,
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]}
|
||||||
|
/> as estree.ObjectExpression
|
||||||
|
}
|
||||||
|
reference={<identifier name={`Meta<typeof ${identifier.name}>`} /> as estree.Identifier}
|
||||||
|
/> as estree.Expression
|
||||||
|
}
|
||||||
|
/> as estree.VariableDeclarator,
|
||||||
|
]}
|
||||||
|
/> as estree.VariableDeclaration,
|
||||||
|
...(hasImplStories
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
<export-named-declaration
|
||||||
|
declaration={
|
||||||
|
<variable-declaration
|
||||||
|
kind={'const' as const}
|
||||||
|
declarations={[
|
||||||
|
<variable-declarator
|
||||||
|
id={<identifier name='Default' /> as estree.Identifier}
|
||||||
|
init={
|
||||||
|
<satisfies-expression
|
||||||
|
expression={
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<property
|
||||||
|
key={<identifier name='render' /> as estree.Identifier}
|
||||||
|
value={
|
||||||
|
<function-expression
|
||||||
|
params={[
|
||||||
|
<identifier name='args' /> as estree.Identifier,
|
||||||
|
]}
|
||||||
|
body={
|
||||||
|
<block-statement
|
||||||
|
body={[
|
||||||
|
<return-statement
|
||||||
|
argument={
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<property
|
||||||
|
key={<identifier name='components' /> as estree.Identifier}
|
||||||
|
value={
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<property key={identifier} value={identifier} kind={'init' as const} shorthand /> as estree.Property,
|
||||||
|
]}
|
||||||
|
/> as estree.ObjectExpression
|
||||||
|
}
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
<property
|
||||||
|
key={<identifier name='setup' /> as estree.Identifier}
|
||||||
|
value={
|
||||||
|
<function-expression
|
||||||
|
params={[]}
|
||||||
|
body={
|
||||||
|
<block-statement
|
||||||
|
body={[
|
||||||
|
<return-statement
|
||||||
|
argument={
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<property
|
||||||
|
key={<identifier name='args' /> as estree.Identifier}
|
||||||
|
value={<identifier name='args' /> as estree.Identifier}
|
||||||
|
kind={'init' as const}
|
||||||
|
shorthand
|
||||||
|
/> as estree.Property,
|
||||||
|
]}
|
||||||
|
/> as estree.ObjectExpression
|
||||||
|
}
|
||||||
|
/> as estree.ReturnStatement,
|
||||||
|
]}
|
||||||
|
/> as estree.BlockStatement
|
||||||
|
}
|
||||||
|
/> as estree.FunctionExpression
|
||||||
|
}
|
||||||
|
method
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
<property
|
||||||
|
key={<identifier name='computed' /> as estree.Identifier}
|
||||||
|
value={
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<property
|
||||||
|
key={<identifier name='props' /> as estree.Identifier}
|
||||||
|
value={
|
||||||
|
<function-expression
|
||||||
|
params={[]}
|
||||||
|
body={
|
||||||
|
<block-statement
|
||||||
|
body={[
|
||||||
|
<return-statement
|
||||||
|
argument={
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<spread-element
|
||||||
|
argument={
|
||||||
|
<member-expression
|
||||||
|
object={<this-expression /> as estree.ThisExpression}
|
||||||
|
property={<identifier name='args' /> as estree.Identifier}
|
||||||
|
/> as estree.MemberExpression
|
||||||
|
}
|
||||||
|
/> as estree.SpreadElement,
|
||||||
|
]}
|
||||||
|
/> as estree.ObjectExpression
|
||||||
|
}
|
||||||
|
/> as estree.ReturnStatement,
|
||||||
|
]}
|
||||||
|
/> as estree.BlockStatement
|
||||||
|
}
|
||||||
|
/> as estree.FunctionExpression
|
||||||
|
}
|
||||||
|
method
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
]}
|
||||||
|
/> as estree.ObjectExpression
|
||||||
|
}
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
<property
|
||||||
|
key={<identifier name='template' /> as estree.Identifier}
|
||||||
|
value={<literal value={`<${identifier.name} v-bind="props" />`} /> as estree.Literal}
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
]}
|
||||||
|
/> as estree.ObjectExpression
|
||||||
|
}
|
||||||
|
/> as estree.ReturnStatement,
|
||||||
|
]}
|
||||||
|
/> as estree.BlockStatement
|
||||||
|
}
|
||||||
|
/> as estree.FunctionExpression
|
||||||
|
}
|
||||||
|
method
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
<property
|
||||||
|
key={<identifier name='parameters' /> as estree.Identifier}
|
||||||
|
value={parameters}
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
]}
|
||||||
|
/> as estree.ObjectExpression
|
||||||
|
}
|
||||||
|
reference={<identifier name={`StoryObj<typeof ${identifier.name}>`} /> as estree.Identifier}
|
||||||
|
/> as estree.Expression
|
||||||
|
}
|
||||||
|
/> as estree.VariableDeclarator,
|
||||||
|
]}
|
||||||
|
/> as estree.VariableDeclaration
|
||||||
|
}
|
||||||
|
/> as estree.ExportNamedDeclaration,
|
||||||
|
]),
|
||||||
|
<export-default-declaration
|
||||||
|
declaration={(<identifier name='meta' />) as estree.Identifier}
|
||||||
|
/> as estree.ExportDefaultDeclaration,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) as estree.Program;
|
||||||
|
return format(
|
||||||
|
'/* eslint-disable @typescript-eslint/explicit-function-return-type */\n' +
|
||||||
|
'/* eslint-disable import/no-default-export */\n' +
|
||||||
|
generate(program, { generator }) +
|
||||||
|
(hasImplStories ? readFileSync(`${implStories}.ts`, 'utf-8') : ''),
|
||||||
|
{
|
||||||
|
parser: 'babel-ts',
|
||||||
|
singleQuote: true,
|
||||||
|
useTabs: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// glob('src/{components,pages,ui,widgets}/**/*.vue').then(
|
||||||
|
glob('src/components/global/**/*.vue').then(
|
||||||
|
(components) =>
|
||||||
|
Promise.all(
|
||||||
|
components.map((component) => {
|
||||||
|
const stories = component.replace(/\.vue$/, '.stories.ts');
|
||||||
|
return writeFile(stories, toStories(component));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
35
packages/frontend/.storybook/main.ts
Normal file
35
packages/frontend/.storybook/main.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import type { StorybookConfig } from '@storybook/vue3-vite';
|
||||||
|
import { mergeConfig } from 'vite';
|
||||||
|
const config = {
|
||||||
|
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
|
||||||
|
addons: [
|
||||||
|
'@storybook/addon-essentials',
|
||||||
|
'@storybook/addon-interactions',
|
||||||
|
'@storybook/addon-links',
|
||||||
|
'@storybook/addon-storysource',
|
||||||
|
resolve(__dirname, '../node_modules/storybook-addon-misskey-theme'),
|
||||||
|
],
|
||||||
|
framework: {
|
||||||
|
name: '@storybook/vue3-vite',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
autodocs: 'tag',
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
disableTelemetry: true,
|
||||||
|
},
|
||||||
|
async viteFinal(config, options) {
|
||||||
|
return mergeConfig(config, {
|
||||||
|
build: {
|
||||||
|
target: [
|
||||||
|
'chrome108',
|
||||||
|
'firefox109',
|
||||||
|
'safari16',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
} satisfies StorybookConfig;
|
||||||
|
export default config;
|
12
packages/frontend/.storybook/manager.ts
Normal file
12
packages/frontend/.storybook/manager.ts
Normal file
File diff suppressed because one or more lines are too long
16
packages/frontend/.storybook/mocks.ts
Normal file
16
packages/frontend/.storybook/mocks.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { type SharedOptions, rest } from 'msw';
|
||||||
|
|
||||||
|
export const onUnhandledRequest = ((req, print) => {
|
||||||
|
if (req.url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(req.url.pathname)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
print.warning()
|
||||||
|
}) satisfies SharedOptions['onUnhandledRequest'];
|
||||||
|
|
||||||
|
export const commonHandlers = [
|
||||||
|
rest.get('/twemoji/:codepoints.svg', async (req, res, ctx) => {
|
||||||
|
const { codepoints } = req.params;
|
||||||
|
const value = await fetch(`https://unpkg.com/@discordapp/twemoji@14.1.2/dist/svg/${codepoints}.svg`).then((response) => response.blob());
|
||||||
|
return res(ctx.set('Content-Type', 'image/svg+xml'), ctx.body(value));
|
||||||
|
}),
|
||||||
|
];
|
9
packages/frontend/.storybook/preload-locale.ts
Normal file
9
packages/frontend/.storybook/preload-locale.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import * as locales from '../../../locales';
|
||||||
|
|
||||||
|
writeFile(
|
||||||
|
resolve(__dirname, 'locale.ts'),
|
||||||
|
`export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`,
|
||||||
|
'utf8',
|
||||||
|
)
|
39
packages/frontend/.storybook/preload-theme.ts
Normal file
39
packages/frontend/.storybook/preload-theme.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import * as JSON5 from 'json5';
|
||||||
|
|
||||||
|
const keys = [
|
||||||
|
'_dark',
|
||||||
|
'_light',
|
||||||
|
'l-light',
|
||||||
|
'l-coffee',
|
||||||
|
'l-apricot',
|
||||||
|
'l-rainy',
|
||||||
|
'l-botanical',
|
||||||
|
'l-vivid',
|
||||||
|
'l-cherry',
|
||||||
|
'l-sushi',
|
||||||
|
'l-u0',
|
||||||
|
'd-dark',
|
||||||
|
'd-persimmon',
|
||||||
|
'd-astro',
|
||||||
|
'd-future',
|
||||||
|
'd-botanical',
|
||||||
|
'd-green-lime',
|
||||||
|
'd-green-orange',
|
||||||
|
'd-cherry',
|
||||||
|
'd-ice',
|
||||||
|
'd-u0',
|
||||||
|
]
|
||||||
|
|
||||||
|
Promise.all(keys.map((key) => readFile(resolve(__dirname, `../src/themes/${key}.json5`), 'utf8'))).then((sources) => {
|
||||||
|
writeFile(
|
||||||
|
resolve(__dirname, './themes.ts'),
|
||||||
|
`export default ${JSON.stringify(
|
||||||
|
Object.fromEntries(sources.map((source, i) => [keys[i], JSON5.parse(source)])),
|
||||||
|
undefined,
|
||||||
|
2,
|
||||||
|
)} as const;`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
});
|
4
packages/frontend/.storybook/preview-head.html
Normal file
4
packages/frontend/.storybook/preview-head.html
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@2.12.0/tabler-icons.min.css">
|
||||||
|
<script>
|
||||||
|
window.global = window;
|
||||||
|
</script>
|
113
packages/frontend/.storybook/preview.ts
Normal file
113
packages/frontend/.storybook/preview.ts
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import { addons } from '@storybook/addons';
|
||||||
|
import { FORCE_REMOUNT } from '@storybook/core-events';
|
||||||
|
import { type Preview, setup } from '@storybook/vue3';
|
||||||
|
import isChromatic from 'chromatic/isChromatic';
|
||||||
|
import { initialize, mswDecorator } from 'msw-storybook-addon';
|
||||||
|
import locale from './locale';
|
||||||
|
import { commonHandlers, onUnhandledRequest } from './mocks';
|
||||||
|
import themes from './themes';
|
||||||
|
import '../src/style.scss';
|
||||||
|
|
||||||
|
const appInitialized = Symbol();
|
||||||
|
|
||||||
|
let moduleInitialized = false;
|
||||||
|
let unobserve = () => {};
|
||||||
|
let misskeyOS = null;
|
||||||
|
|
||||||
|
function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme']) {
|
||||||
|
unobserve();
|
||||||
|
const theme = themes[document.documentElement.dataset.misskeyTheme];
|
||||||
|
if (theme) {
|
||||||
|
applyTheme(themes[document.documentElement.dataset.misskeyTheme]);
|
||||||
|
} else if (isChromatic()) {
|
||||||
|
applyTheme(themes['l-light']);
|
||||||
|
}
|
||||||
|
const observer = new MutationObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.attributeName === 'data-misskey-theme') {
|
||||||
|
const target = entry.target as HTMLElement;
|
||||||
|
const theme = themes[target.dataset.misskeyTheme];
|
||||||
|
if (theme) {
|
||||||
|
applyTheme(themes[target.dataset.misskeyTheme]);
|
||||||
|
} else {
|
||||||
|
target.removeAttribute('style');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-misskey-theme'],
|
||||||
|
});
|
||||||
|
unobserve = () => observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize({
|
||||||
|
onUnhandledRequest,
|
||||||
|
});
|
||||||
|
localStorage.setItem("locale", JSON.stringify(locale));
|
||||||
|
queueMicrotask(() => {
|
||||||
|
Promise.all([
|
||||||
|
import('../src/components'),
|
||||||
|
import('../src/directives'),
|
||||||
|
import('../src/widgets'),
|
||||||
|
import('../src/scripts/theme'),
|
||||||
|
import('../src/store'),
|
||||||
|
import('../src/os'),
|
||||||
|
]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { defaultStore }, os]) => {
|
||||||
|
setup((app) => {
|
||||||
|
moduleInitialized = true;
|
||||||
|
if (app[appInitialized]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app[appInitialized] = true;
|
||||||
|
loadTheme(applyTheme);
|
||||||
|
components(app);
|
||||||
|
directives(app);
|
||||||
|
widgets(app);
|
||||||
|
misskeyOS = os;
|
||||||
|
if (isChromatic()) {
|
||||||
|
defaultStore.set('animation', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = {
|
||||||
|
decorators: [
|
||||||
|
(Story, context) => {
|
||||||
|
const story = Story();
|
||||||
|
if (!moduleInitialized) {
|
||||||
|
const channel = addons.getChannel();
|
||||||
|
(globalThis.requestIdleCallback || setTimeout)(() => {
|
||||||
|
channel.emit(FORCE_REMOUNT, { storyId: context.id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return story;
|
||||||
|
},
|
||||||
|
mswDecorator,
|
||||||
|
(Story, context) => {
|
||||||
|
return {
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
popups: misskeyOS.popups,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
template:
|
||||||
|
'<component :is="popup.component" v-for="popup in popups" :key="popup.id" v-bind="popup.props" v-on="popup.events"/>' +
|
||||||
|
'<story />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
controls: {
|
||||||
|
exclude: /^__/,
|
||||||
|
},
|
||||||
|
msw: {
|
||||||
|
handlers: commonHandlers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Preview;
|
||||||
|
|
||||||
|
export default preview;
|
22
packages/frontend/.storybook/tsconfig.json
Normal file
22
packages/frontend/.storybook/tsconfig.json
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"allowUnusedLabels": false,
|
||||||
|
"allowUnreachableCode": false,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"jsx": "react",
|
||||||
|
"jsxFactory": "h"
|
||||||
|
},
|
||||||
|
"files": ["./generate.tsx", "./preload-locale.ts", "./preload-theme.ts"]
|
||||||
|
}
|
|
@ -4,6 +4,9 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"watch": "vite",
|
"watch": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"storybook-dev": "chokidar 'src/**/*.{mdx,ts,vue}' -d 1000 -t 1000 --initial -i '**/*.stories.ts' -c 'pkill -f node_modules/storybook/index.js; node_modules/.bin/tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && node_modules/.bin/storybook dev -p 6006 --ci'",
|
||||||
|
"build-storybook": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && storybook build",
|
||||||
|
"chromatic": "chromatic",
|
||||||
"test": "vitest --run",
|
"test": "vitest --run",
|
||||||
"test-and-coverage": "vitest --run --coverage",
|
"test-and-coverage": "vitest --run --coverage",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
@ -71,8 +74,27 @@
|
||||||
"vuedraggable": "next"
|
"vuedraggable": "next"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@storybook/addon-essentials": "7.0.2",
|
||||||
|
"@storybook/addon-interactions": "7.0.2",
|
||||||
|
"@storybook/addon-links": "7.0.2",
|
||||||
|
"@storybook/addon-storysource": "7.0.2",
|
||||||
|
"@storybook/addons": "7.0.2",
|
||||||
|
"@storybook/blocks": "7.0.2",
|
||||||
|
"@storybook/core-events": "7.0.2",
|
||||||
|
"@storybook/jest": "0.1.0",
|
||||||
|
"@storybook/manager-api": "7.0.2",
|
||||||
|
"@storybook/preview-api": "7.0.2",
|
||||||
|
"@storybook/react": "7.0.2",
|
||||||
|
"@storybook/react-vite": "7.0.2",
|
||||||
|
"@storybook/testing-library": "0.0.14-next.1",
|
||||||
|
"@storybook/theming": "7.0.2",
|
||||||
|
"@storybook/types": "7.0.2",
|
||||||
|
"@storybook/vue3": "7.0.2",
|
||||||
|
"@storybook/vue3-vite": "7.0.2",
|
||||||
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
"@testing-library/vue": "^6.6.1",
|
"@testing-library/vue": "^6.6.1",
|
||||||
"@types/escape-regexp": "0.0.1",
|
"@types/escape-regexp": "0.0.1",
|
||||||
|
"@types/estree": "^1.0.0",
|
||||||
"@types/gulp": "4.0.10",
|
"@types/gulp": "4.0.10",
|
||||||
"@types/gulp-rename": "2.0.1",
|
"@types/gulp-rename": "2.0.1",
|
||||||
"@types/matter-js": "0.18.2",
|
"@types/matter-js": "0.18.2",
|
||||||
|
@ -80,6 +102,7 @@
|
||||||
"@types/punycode": "2.1.0",
|
"@types/punycode": "2.1.0",
|
||||||
"@types/sanitize-html": "2.9.0",
|
"@types/sanitize-html": "2.9.0",
|
||||||
"@types/seedrandom": "3.0.5",
|
"@types/seedrandom": "3.0.5",
|
||||||
|
"@types/testing-library__jest-dom": "^5.14.5",
|
||||||
"@types/throttle-debounce": "5.0.0",
|
"@types/throttle-debounce": "5.0.0",
|
||||||
"@types/tinycolor2": "1.4.3",
|
"@types/tinycolor2": "1.4.3",
|
||||||
"@types/uuid": "9.0.1",
|
"@types/uuid": "9.0.1",
|
||||||
|
@ -89,13 +112,24 @@
|
||||||
"@typescript-eslint/parser": "5.57.0",
|
"@typescript-eslint/parser": "5.57.0",
|
||||||
"@vitest/coverage-c8": "^0.29.8",
|
"@vitest/coverage-c8": "^0.29.8",
|
||||||
"@vue/runtime-core": "3.2.47",
|
"@vue/runtime-core": "3.2.47",
|
||||||
|
"astring": "^1.8.4",
|
||||||
|
"chokidar-cli": "^3.0.0",
|
||||||
|
"chromatic": "^6.17.2",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"cypress": "12.9.0",
|
"cypress": "12.9.0",
|
||||||
"eslint": "8.37.0",
|
"eslint": "8.37.0",
|
||||||
"eslint-plugin-import": "2.27.5",
|
"eslint-plugin-import": "2.27.5",
|
||||||
"eslint-plugin-vue": "9.10.0",
|
"eslint-plugin-vue": "9.10.0",
|
||||||
|
"fast-glob": "^3.2.12",
|
||||||
"happy-dom": "8.9.0",
|
"happy-dom": "8.9.0",
|
||||||
|
"msw": "^1.1.0",
|
||||||
|
"msw-storybook-addon": "^1.8.0",
|
||||||
|
"prettier": "^2.8.4",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
"start-server-and-test": "2.0.0",
|
"start-server-and-test": "2.0.0",
|
||||||
|
"storybook": "7.0.2",
|
||||||
|
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||||
"summaly": "github:misskey-dev/summaly",
|
"summaly": "github:misskey-dev/summaly",
|
||||||
"vitest": "^0.29.8",
|
"vitest": "^0.29.8",
|
||||||
"vitest-fetch-mock": "^0.2.2",
|
"vitest-fetch-mock": "^0.2.2",
|
||||||
|
|
303
packages/frontend/public/mockServiceWorker.js
Normal file
303
packages/frontend/public/mockServiceWorker.js
Normal file
|
@ -0,0 +1,303 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Service Worker (1.1.0).
|
||||||
|
* @see https://github.com/mswjs/msw
|
||||||
|
* - Please do NOT modify this file.
|
||||||
|
* - Please do NOT serve this file on production.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
|
||||||
|
const activeClientIds = new Set()
|
||||||
|
|
||||||
|
self.addEventListener('install', function () {
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', function (event) {
|
||||||
|
event.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('message', async function (event) {
|
||||||
|
const clientId = event.source.id
|
||||||
|
|
||||||
|
if (!clientId || !self.clients) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await self.clients.get(clientId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
switch (event.data) {
|
||||||
|
case 'KEEPALIVE_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'KEEPALIVE_RESPONSE',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'INTEGRITY_CHECK_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||||
|
payload: INTEGRITY_CHECKSUM,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_ACTIVATE': {
|
||||||
|
activeClientIds.add(clientId)
|
||||||
|
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'MOCKING_ENABLED',
|
||||||
|
payload: true,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_DEACTIVATE': {
|
||||||
|
activeClientIds.delete(clientId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CLIENT_CLOSED': {
|
||||||
|
activeClientIds.delete(clientId)
|
||||||
|
|
||||||
|
const remainingClients = allClients.filter((client) => {
|
||||||
|
return client.id !== clientId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unregister itself when there are no more clients
|
||||||
|
if (remainingClients.length === 0) {
|
||||||
|
self.registration.unregister()
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', function (event) {
|
||||||
|
const { request } = event
|
||||||
|
const accept = request.headers.get('accept') || ''
|
||||||
|
|
||||||
|
// Bypass server-sent events.
|
||||||
|
if (accept.includes('text/event-stream')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass navigation requests.
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening the DevTools triggers the "only-if-cached" request
|
||||||
|
// that cannot be handled by the worker. Bypass such requests.
|
||||||
|
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass all requests when there are no active clients.
|
||||||
|
// Prevents the self-unregistered worked from handling requests
|
||||||
|
// after it's been deleted (still remains active until the next reload).
|
||||||
|
if (activeClientIds.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique request ID.
|
||||||
|
const requestId = Math.random().toString(16).slice(2)
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
handleRequest(event, requestId).catch((error) => {
|
||||||
|
if (error.name === 'NetworkError') {
|
||||||
|
console.warn(
|
||||||
|
'[MSW] Successfully emulated a network error for the "%s %s" request.',
|
||||||
|
request.method,
|
||||||
|
request.url,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point, any exception indicates an issue with the original request/response.
|
||||||
|
console.error(
|
||||||
|
`\
|
||||||
|
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
|
||||||
|
request.method,
|
||||||
|
request.url,
|
||||||
|
`${error.name}: ${error.message}`,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleRequest(event, requestId) {
|
||||||
|
const client = await resolveMainClient(event)
|
||||||
|
const response = await getResponse(event, client, requestId)
|
||||||
|
|
||||||
|
// Send back the response clone for the "response:*" life-cycle events.
|
||||||
|
// Ensure MSW is active and ready to handle the message, otherwise
|
||||||
|
// this message will pend indefinitely.
|
||||||
|
if (client && activeClientIds.has(client.id)) {
|
||||||
|
;(async function () {
|
||||||
|
const clonedResponse = response.clone()
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'RESPONSE',
|
||||||
|
payload: {
|
||||||
|
requestId,
|
||||||
|
type: clonedResponse.type,
|
||||||
|
ok: clonedResponse.ok,
|
||||||
|
status: clonedResponse.status,
|
||||||
|
statusText: clonedResponse.statusText,
|
||||||
|
body:
|
||||||
|
clonedResponse.body === null ? null : await clonedResponse.text(),
|
||||||
|
headers: Object.fromEntries(clonedResponse.headers.entries()),
|
||||||
|
redirected: clonedResponse.redirected,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the main client for the given event.
|
||||||
|
// Client that issues a request doesn't necessarily equal the client
|
||||||
|
// that registered the worker. It's with the latter the worker should
|
||||||
|
// communicate with during the response resolving phase.
|
||||||
|
async function resolveMainClient(event) {
|
||||||
|
const client = await self.clients.get(event.clientId)
|
||||||
|
|
||||||
|
if (client?.frameType === 'top-level') {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
return allClients
|
||||||
|
.filter((client) => {
|
||||||
|
// Get only those clients that are currently visible.
|
||||||
|
return client.visibilityState === 'visible'
|
||||||
|
})
|
||||||
|
.find((client) => {
|
||||||
|
// Find the client ID that's recorded in the
|
||||||
|
// set of clients that have registered the worker.
|
||||||
|
return activeClientIds.has(client.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getResponse(event, client, requestId) {
|
||||||
|
const { request } = event
|
||||||
|
const clonedRequest = request.clone()
|
||||||
|
|
||||||
|
function passthrough() {
|
||||||
|
// Clone the request because it might've been already used
|
||||||
|
// (i.e. its body has been read and sent to the client).
|
||||||
|
const headers = Object.fromEntries(clonedRequest.headers.entries())
|
||||||
|
|
||||||
|
// Remove MSW-specific request headers so the bypassed requests
|
||||||
|
// comply with the server's CORS preflight check.
|
||||||
|
// Operate with the headers as an object because request "Headers"
|
||||||
|
// are immutable.
|
||||||
|
delete headers['x-msw-bypass']
|
||||||
|
|
||||||
|
return fetch(clonedRequest, { headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass mocking when the client is not active.
|
||||||
|
if (!client) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass initial page load requests (i.e. static assets).
|
||||||
|
// The absence of the immediate/parent client in the map of the active clients
|
||||||
|
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||||
|
// and is not ready to handle requests.
|
||||||
|
if (!activeClientIds.has(client.id)) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass requests with the explicit bypass header.
|
||||||
|
// Such requests can be issued by "ctx.fetch()".
|
||||||
|
if (request.headers.get('x-msw-bypass') === 'true') {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the client that a request has been intercepted.
|
||||||
|
const clientMessage = await sendToClient(client, {
|
||||||
|
type: 'REQUEST',
|
||||||
|
payload: {
|
||||||
|
id: requestId,
|
||||||
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
|
headers: Object.fromEntries(request.headers.entries()),
|
||||||
|
cache: request.cache,
|
||||||
|
mode: request.mode,
|
||||||
|
credentials: request.credentials,
|
||||||
|
destination: request.destination,
|
||||||
|
integrity: request.integrity,
|
||||||
|
redirect: request.redirect,
|
||||||
|
referrer: request.referrer,
|
||||||
|
referrerPolicy: request.referrerPolicy,
|
||||||
|
body: await request.text(),
|
||||||
|
bodyUsed: request.bodyUsed,
|
||||||
|
keepalive: request.keepalive,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
switch (clientMessage.type) {
|
||||||
|
case 'MOCK_RESPONSE': {
|
||||||
|
return respondWithMock(clientMessage.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_NOT_FOUND': {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'NETWORK_ERROR': {
|
||||||
|
const { name, message } = clientMessage.data
|
||||||
|
const networkError = new Error(message)
|
||||||
|
networkError.name = name
|
||||||
|
|
||||||
|
// Rejecting a "respondWith" promise emulates a network error.
|
||||||
|
throw networkError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendToClient(client, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
if (event.data && event.data.error) {
|
||||||
|
return reject(event.data.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.postMessage(message, [channel.port2])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(timeMs) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, timeMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function respondWithMock(response) {
|
||||||
|
await sleep(response.delay)
|
||||||
|
return new Response(response.body, response)
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkAnalogClock from './MkAnalogClock.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkAnalogClock,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkAnalogClock v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAnalogClock>;
|
30
packages/frontend/src/components/MkButton.stories.impl.ts
Normal file
30
packages/frontend/src/components/MkButton.stories.impl.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
/* eslint-disable import/no-default-export */
|
||||||
|
/* eslint-disable import/no-duplicates */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkButton from './MkButton.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkButton,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkButton v-bind="props">Text</MkButton>',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkButton>;
|
|
@ -0,0 +1,2 @@
|
||||||
|
import MkCaptcha from './MkCaptcha.vue';
|
||||||
|
void MkCaptcha;
|
|
@ -17,8 +17,8 @@ import { onMounted, onBeforeUnmount } from 'vue';
|
||||||
import MkMenu from './MkMenu.vue';
|
import MkMenu from './MkMenu.vue';
|
||||||
import { MenuItem } from './types/menu.vue';
|
import { MenuItem } from './types/menu.vue';
|
||||||
import contains from '@/scripts/contains';
|
import contains from '@/scripts/contains';
|
||||||
import * as os from '@/os';
|
|
||||||
import { defaultStore } from '@/store';
|
import { defaultStore } from '@/store';
|
||||||
|
import * as os from '@/os';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: MenuItem[];
|
items: MenuItem[];
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div role="menu">
|
||||||
<div
|
<div
|
||||||
ref="itemsEl" v-hotkey="keymap"
|
ref="itemsEl" v-hotkey="keymap"
|
||||||
class="_popup _shadow"
|
class="_popup _shadow"
|
||||||
|
@ -8,37 +8,37 @@
|
||||||
@contextmenu.self="e => e.preventDefault()"
|
@contextmenu.self="e => e.preventDefault()"
|
||||||
>
|
>
|
||||||
<template v-for="(item, i) in items2">
|
<template v-for="(item, i) in items2">
|
||||||
<div v-if="item === null" :class="$style.divider"></div>
|
<div v-if="item === null" role="separator" :class="$style.divider"></div>
|
||||||
<span v-else-if="item.type === 'label'" :class="[$style.label, $style.item]">
|
<span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]">
|
||||||
<span>{{ item.text }}</span>
|
<span>{{ item.text }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="item.type === 'pending'" :tabindex="i" :class="[$style.pending, $style.item]">
|
<span v-else-if="item.type === 'pending'" role="menuitem" :tabindex="i" :class="[$style.pending, $style.item]">
|
||||||
<span><MkEllipsis/></span>
|
<span><MkEllipsis/></span>
|
||||||
</span>
|
</span>
|
||||||
<MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
<MkA v-else-if="item.type === 'link'" role="menuitem" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||||
<span>{{ item.text }}</span>
|
<span>{{ item.text }}</span>
|
||||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||||
</MkA>
|
</MkA>
|
||||||
<a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
<a v-else-if="item.type === 'a'" role="menuitem" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||||
<span>{{ item.text }}</span>
|
<span>{{ item.text }}</span>
|
||||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||||
</a>
|
</a>
|
||||||
<button v-else-if="item.type === 'user'" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
<button v-else-if="item.type === 'user'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||||
<MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/>
|
<MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/>
|
||||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||||
</button>
|
</button>
|
||||||
<span v-else-if="item.type === 'switch'" :tabindex="i" :class="$style.item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
<span v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" :class="$style.item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||||
<MkSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</MkSwitch>
|
<MkSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</MkSwitch>
|
||||||
</span>
|
</span>
|
||||||
<button v-else-if="item.type === 'parent'" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)">
|
<button v-else-if="item.type === 'parent'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)">
|
||||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||||
<span>{{ item.text }}</span>
|
<span>{{ item.text }}</span>
|
||||||
<span :class="$style.caret"><i class="ti ti-chevron-right ti-fw"></i></span>
|
<span :class="$style.caret"><i class="ti ti-chevron-right ti-fw"></i></span>
|
||||||
</button>
|
</button>
|
||||||
<button v-else :tabindex="i" class="_button" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
<button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||||
<span>{{ item.text }}</span>
|
<span>{{ item.text }}</span>
|
||||||
|
|
|
@ -83,7 +83,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, shallowRef, onMounted, onUnmounted, watch } from 'vue';
|
import { ref, shallowRef } from 'vue';
|
||||||
import * as misskey from 'misskey-js';
|
import * as misskey from 'misskey-js';
|
||||||
import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
||||||
import MkFollowButton from '@/components/MkFollowButton.vue';
|
import MkFollowButton from '@/components/MkFollowButton.vue';
|
||||||
|
@ -94,7 +94,6 @@ import { notePage } from '@/filters/note';
|
||||||
import { userPage } from '@/filters/user';
|
import { userPage } from '@/filters/user';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { stream } from '@/stream';
|
|
||||||
import { useTooltip } from '@/scripts/use-tooltip';
|
import { useTooltip } from '@/scripts/use-tooltip';
|
||||||
import { $i } from '@/account';
|
import { $i } from '@/account';
|
||||||
|
|
||||||
|
@ -110,35 +109,6 @@ const props = withDefaults(defineProps<{
|
||||||
const elRef = shallowRef<HTMLElement>(null);
|
const elRef = shallowRef<HTMLElement>(null);
|
||||||
const reactionRef = ref(null);
|
const reactionRef = ref(null);
|
||||||
|
|
||||||
let readObserver: IntersectionObserver | undefined;
|
|
||||||
let connection;
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!props.notification.isRead) {
|
|
||||||
readObserver = new IntersectionObserver((entries, observer) => {
|
|
||||||
if (!entries.some(entry => entry.isIntersecting)) return;
|
|
||||||
stream.send('readNotification', {
|
|
||||||
id: props.notification.id,
|
|
||||||
});
|
|
||||||
observer.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
readObserver.observe(elRef.value);
|
|
||||||
|
|
||||||
connection = stream.useChannel('main');
|
|
||||||
connection.on('readAllNotifications', () => readObserver.disconnect());
|
|
||||||
|
|
||||||
watch(props.notification.isRead, () => {
|
|
||||||
readObserver.disconnect();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (readObserver) readObserver.disconnect();
|
|
||||||
if (connection) connection.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
const followRequestDone = ref(false);
|
const followRequestDone = ref(false);
|
||||||
|
|
||||||
const acceptFollowRequest = () => {
|
const acceptFollowRequest = () => {
|
||||||
|
|
|
@ -29,7 +29,6 @@ import { notificationTypes } from '@/const';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
includeTypes?: typeof notificationTypes[number][];
|
includeTypes?: typeof notificationTypes[number][];
|
||||||
unreadOnly?: boolean;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
||||||
|
@ -40,23 +39,17 @@ const pagination: Paging = {
|
||||||
params: computed(() => ({
|
params: computed(() => ({
|
||||||
includeTypes: props.includeTypes ?? undefined,
|
includeTypes: props.includeTypes ?? undefined,
|
||||||
excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes,
|
excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes,
|
||||||
unreadOnly: props.unreadOnly,
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
const onNotification = (notification) => {
|
const onNotification = (notification) => {
|
||||||
const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type);
|
const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type);
|
||||||
if (isMuted || document.visibilityState === 'visible') {
|
if (isMuted || document.visibilityState === 'visible') {
|
||||||
stream.send('readNotification', {
|
stream.send('readNotification');
|
||||||
id: notification.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isMuted) {
|
if (!isMuted) {
|
||||||
pagingComponent.value.prepend({
|
pagingComponent.value.prepend(notification);
|
||||||
...notification,
|
|
||||||
isRead: document.visibilityState === 'visible',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -65,30 +58,6 @@ let connection;
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
connection = stream.useChannel('main');
|
connection = stream.useChannel('main');
|
||||||
connection.on('notification', onNotification);
|
connection.on('notification', onNotification);
|
||||||
connection.on('readAllNotifications', () => {
|
|
||||||
if (pagingComponent.value) {
|
|
||||||
for (const item of pagingComponent.value.queue) {
|
|
||||||
item.isRead = true;
|
|
||||||
}
|
|
||||||
for (const item of pagingComponent.value.items) {
|
|
||||||
item.isRead = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
connection.on('readNotifications', notificationIds => {
|
|
||||||
if (pagingComponent.value) {
|
|
||||||
for (let i = 0; i < pagingComponent.value.queue.length; i++) {
|
|
||||||
if (notificationIds.includes(pagingComponent.value.queue[i].id)) {
|
|
||||||
pagingComponent.value.queue[i].isRead = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let i = 0; i < (pagingComponent.value.items || []).length; i++) {
|
|
||||||
if (notificationIds.includes(pagingComponent.value.items[i].id)) {
|
|
||||||
pagingComponent.value.items[i].isRead = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { onMounted } from 'vue';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
maxHeight: number;
|
maxHeight?: number;
|
||||||
}>(), {
|
}>(), {
|
||||||
maxHeight: 200,
|
maxHeight: 200,
|
||||||
});
|
});
|
||||||
|
|
|
@ -150,7 +150,7 @@ function adjustTweetHeight(message: any) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const openPlayer = (): void => {
|
const openPlayer = (): void => {
|
||||||
os.popup(defineAsyncComponent(() => import('@/components/MkYoutubePlayer.vue')), {
|
os.popup(defineAsyncComponent(() => import('@/components/MkYouTubePlayer.vue')), {
|
||||||
url: requestUrl.href,
|
url: requestUrl.href,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
47
packages/frontend/src/components/global/MkA.stories.impl.ts
Normal file
47
packages/frontend/src/components/global/MkA.stories.impl.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
import { userEvent, within } from '@storybook/testing-library';
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkA from './MkA.vue';
|
||||||
|
import { tick } from '@/scripts/test-utils';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkA,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkA v-bind="props">Text</MkA>',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||||
|
await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
|
||||||
|
await userEvent.click(a, { button: 2 });
|
||||||
|
await tick();
|
||||||
|
const menu = canvas.getByRole('menu');
|
||||||
|
await expect(menu).toBeInTheDocument();
|
||||||
|
await userEvent.click(a, { button: 0 });
|
||||||
|
a.blur();
|
||||||
|
await tick();
|
||||||
|
await expect(menu).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
to: '#test',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkA>;
|
|
@ -0,0 +1,43 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import { userDetailed } from '../../../.storybook/fakes';
|
||||||
|
import MkAcct from './MkAcct.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkAcct,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkAcct v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
user: {
|
||||||
|
...userDetailed,
|
||||||
|
host: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAcct>;
|
||||||
|
export const Detail = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
user: userDetailed,
|
||||||
|
detail: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAcct>;
|
|
@ -18,4 +18,3 @@ defineProps<{
|
||||||
|
|
||||||
const host = toUnicode(hostRaw);
|
const host = toUnicode(hostRaw);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
120
packages/frontend/src/components/global/MkAd.stories.impl.ts
Normal file
120
packages/frontend/src/components/global/MkAd.stories.impl.ts
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
import { userEvent, within } from '@storybook/testing-library';
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
import MkAd from './MkAd.vue';
|
||||||
|
const common = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkAd,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkAd v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||||
|
await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
|
||||||
|
const img = within(a).getByRole('img');
|
||||||
|
await expect(img).toBeInTheDocument();
|
||||||
|
let buttons = canvas.getAllByRole<HTMLButtonElement>('button');
|
||||||
|
await expect(buttons).toHaveLength(1);
|
||||||
|
const i = buttons[0];
|
||||||
|
await expect(i).toBeInTheDocument();
|
||||||
|
await userEvent.click(i);
|
||||||
|
await expect(a).not.toBeInTheDocument();
|
||||||
|
await expect(i).not.toBeInTheDocument();
|
||||||
|
buttons = canvas.getAllByRole<HTMLButtonElement>('button');
|
||||||
|
await expect(buttons).toHaveLength(args.__hasReduce ? 2 : 1);
|
||||||
|
const reduce = args.__hasReduce ? buttons[0] : null;
|
||||||
|
const back = buttons[args.__hasReduce ? 1 : 0];
|
||||||
|
if (reduce) {
|
||||||
|
await expect(reduce).toBeInTheDocument();
|
||||||
|
await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd);
|
||||||
|
}
|
||||||
|
await expect(back).toBeInTheDocument();
|
||||||
|
await expect(back).toHaveTextContent(i18n.ts._ad.back);
|
||||||
|
await userEvent.click(back);
|
||||||
|
if (reduce) {
|
||||||
|
await expect(reduce).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
await expect(back).not.toBeInTheDocument();
|
||||||
|
const aAgain = canvas.getByRole<HTMLAnchorElement>('link');
|
||||||
|
await expect(aAgain).toBeInTheDocument();
|
||||||
|
const imgAgain = within(aAgain).getByRole('img');
|
||||||
|
await expect(imgAgain).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
prefer: [],
|
||||||
|
specify: {
|
||||||
|
id: 'someadid',
|
||||||
|
radio: 1,
|
||||||
|
url: '#test',
|
||||||
|
},
|
||||||
|
__hasReduce: true,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAd>;
|
||||||
|
export const Square = {
|
||||||
|
...common,
|
||||||
|
args: {
|
||||||
|
...common.args,
|
||||||
|
specify: {
|
||||||
|
...common.args.specify,
|
||||||
|
place: 'square',
|
||||||
|
imageUrl:
|
||||||
|
'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAd>;
|
||||||
|
export const Horizontal = {
|
||||||
|
...common,
|
||||||
|
args: {
|
||||||
|
...common.args,
|
||||||
|
specify: {
|
||||||
|
...common.args.specify,
|
||||||
|
place: 'horizontal',
|
||||||
|
imageUrl:
|
||||||
|
'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAd>;
|
||||||
|
export const HorizontalBig = {
|
||||||
|
...common,
|
||||||
|
args: {
|
||||||
|
...common.args,
|
||||||
|
specify: {
|
||||||
|
...common.args.specify,
|
||||||
|
place: 'horizontal-big',
|
||||||
|
imageUrl:
|
||||||
|
'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAd>;
|
||||||
|
export const ZeroRatio = {
|
||||||
|
...Square,
|
||||||
|
args: {
|
||||||
|
...Square.args,
|
||||||
|
specify: {
|
||||||
|
...Square.args.specify,
|
||||||
|
ratio: 0,
|
||||||
|
},
|
||||||
|
__hasReduce: false,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAd>;
|
|
@ -20,13 +20,13 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
import { instance } from '@/instance';
|
import { instance } from '@/instance';
|
||||||
import { host } from '@/config';
|
import { host } from '@/config';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import { defaultStore } from '@/store';
|
import { defaultStore } from '@/store';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { $i } from '@/account';
|
import { $i } from '@/account';
|
||||||
import { i18n } from '@/i18n';
|
|
||||||
|
|
||||||
type Ad = (typeof instance)['ads'][number];
|
type Ad = (typeof instance)['ads'][number];
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import { userDetailed } from '../../../.storybook/fakes';
|
||||||
|
import MkAvatar from './MkAvatar.vue';
|
||||||
|
const common = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkAvatar,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkAvatar v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
user: userDetailed,
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story, context) => ({
|
||||||
|
// eslint-disable-next-line quotes
|
||||||
|
template: `<div :style="{ display: 'grid', width: '${context.args.size}px', height: '${context.args.size}px' }"><story/></div>`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAvatar>;
|
||||||
|
export const ProfilePage = {
|
||||||
|
...common,
|
||||||
|
args: {
|
||||||
|
...common.args,
|
||||||
|
size: 120,
|
||||||
|
indicator: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAvatar>;
|
||||||
|
export const ProfilePageCat = {
|
||||||
|
...ProfilePage,
|
||||||
|
args: {
|
||||||
|
...ProfilePage.args,
|
||||||
|
user: {
|
||||||
|
...userDetailed,
|
||||||
|
isCat: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
...ProfilePage.parameters,
|
||||||
|
chromatic: {
|
||||||
|
/* Your story couldn’t be captured because it exceeds our 25,000,000px limit. Its dimensions are 5,504,893x5,504,892px. Possible ways to resolve:
|
||||||
|
* * Separate pages into components
|
||||||
|
* * Minimize the number of very large elements in a story
|
||||||
|
*/
|
||||||
|
disableSnapshot: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAvatar>;
|
|
@ -148,6 +148,7 @@ watch(() => props.user.avatarBlurhash, () => {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 50%;
|
padding: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
&.mask {
|
&.mask {
|
||||||
-webkit-mask:
|
-webkit-mask:
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkCustomEmoji from './MkCustomEmoji.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkCustomEmoji,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkCustomEmoji v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
name: 'mi',
|
||||||
|
url: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkCustomEmoji>;
|
||||||
|
export const Normal = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
normal: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkCustomEmoji>;
|
||||||
|
export const Missing = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
name: Default.args.name,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkCustomEmoji>;
|
|
@ -0,0 +1,32 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import isChromatic from 'chromatic/isChromatic';
|
||||||
|
import MkEllipsis from './MkEllipsis.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkEllipsis,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkEllipsis v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
static: isChromatic(),
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkEllipsis>;
|
|
@ -1,9 +1,19 @@
|
||||||
<template>
|
<template>
|
||||||
<span :class="$style.root">
|
<span :class="[$style.root, { [$style.static]: static }]">
|
||||||
<span :class="$style.dot">.</span><span :class="$style.dot">.</span><span :class="$style.dot">.</span>
|
<span :class="$style.dot">.</span><span :class="$style.dot">.</span><span :class="$style.dot">.</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
static?: boolean;
|
||||||
|
}>(), {
|
||||||
|
static: false,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
@keyframes ellipsis {
|
@keyframes ellipsis {
|
||||||
0%, 80%, 100% {
|
0%, 80%, 100% {
|
||||||
|
@ -15,7 +25,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
|
&.static > .dot {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkEmoji from './MkEmoji.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkEmoji,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkEmoji v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
emoji: '❤',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkEmoji>;
|
|
@ -0,0 +1,5 @@
|
||||||
|
export const argTypes = {
|
||||||
|
retry: {
|
||||||
|
action: 'retry',
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,60 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import isChromatic from 'chromatic/isChromatic';
|
||||||
|
import MkLoading from './MkLoading.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkLoading,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkLoading v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
static: isChromatic(),
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkLoading>;
|
||||||
|
export const Inline = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkLoading>;
|
||||||
|
export const Colored = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
colored: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkLoading>;
|
||||||
|
export const Mini = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
mini: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkLoading>;
|
||||||
|
export const Em = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
em: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkLoading>;
|
|
@ -6,7 +6,7 @@
|
||||||
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
|
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<svg :class="[$style.spinner, $style.fg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
|
<svg :class="[$style.spinner, $style.fg, { [$style.static]: static }]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g transform="matrix(1.125,0,0,1.125,12,12)">
|
<g transform="matrix(1.125,0,0,1.125,12,12)">
|
||||||
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
|
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
|
||||||
</g>
|
</g>
|
||||||
|
@ -19,11 +19,13 @@
|
||||||
import { } from 'vue';
|
import { } from 'vue';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
|
static?: boolean;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
colored?: boolean;
|
colored?: boolean;
|
||||||
mini?: boolean;
|
mini?: boolean;
|
||||||
em?: boolean;
|
em?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
|
static: false,
|
||||||
inline: false,
|
inline: false,
|
||||||
colored: true,
|
colored: true,
|
||||||
mini: false,
|
mini: false,
|
||||||
|
@ -97,5 +99,9 @@ const props = withDefaults(defineProps<{
|
||||||
|
|
||||||
.fg {
|
.fg {
|
||||||
animation: spinner 0.5s linear infinite;
|
animation: spinner 0.5s linear infinite;
|
||||||
|
|
||||||
|
&.static {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkMisskeyFlavoredMarkdown from './MkMisskeyFlavoredMarkdown.vue';
|
||||||
|
import { within } from '@storybook/testing-library';
|
||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkMisskeyFlavoredMarkdown,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkMisskeyFlavoredMarkdown v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
if (args.plain) {
|
||||||
|
const aiHelloMiskist = canvas.getByText('@ai *Hello*, #Miskist!');
|
||||||
|
await expect(aiHelloMiskist).toBeInTheDocument();
|
||||||
|
} else {
|
||||||
|
const ai = canvas.getByText('@ai');
|
||||||
|
await expect(ai).toBeInTheDocument();
|
||||||
|
await expect(ai.closest('a')).toHaveAttribute('href', '/@ai');
|
||||||
|
const hello = canvas.getByText('Hello');
|
||||||
|
await expect(hello).toBeInTheDocument();
|
||||||
|
await expect(hello.style.fontStyle).toBe('oblique');
|
||||||
|
const miskist = canvas.getByText('#Miskist');
|
||||||
|
await expect(miskist).toBeInTheDocument();
|
||||||
|
await expect(miskist).toHaveAttribute('href', args.isNote ?? true ? '/tags/Miskist' : '/user-tags/Miskist');
|
||||||
|
}
|
||||||
|
const heart = canvas.getByAltText('❤');
|
||||||
|
await expect(heart).toBeInTheDocument();
|
||||||
|
await expect(heart).toHaveAttribute('src', '/twemoji/2764.svg');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
text: '@ai *Hello*, #Miskist! ❤',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
||||||
|
export const Plain = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
plain: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
||||||
|
export const Nowrap = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
nowrap: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
||||||
|
export const IsNotNote = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
isNote: false,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
|
@ -0,0 +1,98 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkPageHeader from './MkPageHeader.vue';
|
||||||
|
export const Empty = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkPageHeader,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkPageHeader v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
static: true,
|
||||||
|
tabs: [],
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
chromatic: {
|
||||||
|
/* This component has animations that are implemented with JavaScript. So it's unstable to take a snapshot. */
|
||||||
|
disableSnapshot: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkPageHeader>;
|
||||||
|
export const OneTab = {
|
||||||
|
...Empty,
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
tab: 'sometabkey',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
key: 'sometabkey',
|
||||||
|
title: 'Some Tab Title',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkPageHeader>;
|
||||||
|
export const Icon = {
|
||||||
|
...OneTab,
|
||||||
|
args: {
|
||||||
|
...OneTab.args,
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
...OneTab.args.tabs[0],
|
||||||
|
icon: 'ti ti-home',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkPageHeader>;
|
||||||
|
export const IconOnly = {
|
||||||
|
...Icon,
|
||||||
|
args: {
|
||||||
|
...Icon.args,
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
...Icon.args.tabs[0],
|
||||||
|
title: undefined,
|
||||||
|
iconOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkPageHeader>;
|
||||||
|
export const SomeTabs = {
|
||||||
|
...Empty,
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
tab: 'princess',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
key: 'princess',
|
||||||
|
title: 'Princess',
|
||||||
|
icon: 'ti ti-crown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fairy',
|
||||||
|
title: 'Fairy',
|
||||||
|
icon: 'ti ti-snowflake',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'angel',
|
||||||
|
title: 'Angel',
|
||||||
|
icon: 'ti ti-feather',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkPageHeader>;
|
|
@ -0,0 +1,3 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import MkPageHeader_tabs from './MkPageHeader.tabs.vue';
|
||||||
|
void MkPageHeader_tabs;
|
|
@ -33,14 +33,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export type Tab = {
|
export type Tab = {
|
||||||
key: string;
|
key: string;
|
||||||
|
onClick?: (ev: MouseEvent) => void;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
iconOnly?: false;
|
||||||
title: string;
|
title: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
iconOnly?: boolean;
|
}
|
||||||
onClick?: (ev: MouseEvent) => void;
|
| {
|
||||||
} & {
|
|
||||||
iconOnly: true;
|
iconOnly: true;
|
||||||
iccn: string;
|
icon: string;
|
||||||
};
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import MkStickyContainer from './MkStickyContainer.vue';
|
||||||
|
void MkStickyContainer;
|
312
packages/frontend/src/components/global/MkTime.stories.impl.ts
Normal file
312
packages/frontend/src/components/global/MkTime.stories.impl.ts
Normal file
|
@ -0,0 +1,312 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkTime from './MkTime.vue';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
import { dateTimeFormat } from '@/scripts/intl-const';
|
||||||
|
const now = new Date('2023-04-01T00:00:00.000Z');
|
||||||
|
const future = new Date(8640000000000000);
|
||||||
|
const oneHourAgo = new Date(now.getTime() - 3600000);
|
||||||
|
const oneDayAgo = new Date(now.getTime() - 86400000);
|
||||||
|
const oneWeekAgo = new Date(now.getTime() - 604800000);
|
||||||
|
const oneMonthAgo = new Date(now.getTime() - 2592000000);
|
||||||
|
const oneYearAgo = new Date(now.getTime() - 31536000000);
|
||||||
|
export const Empty = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkTime,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkTime v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.ts._ago.invalid);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const RelativeFuture = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.ts._ago.future);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: future,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const AbsoluteFuture = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: future,
|
||||||
|
mode: 'absolute',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const DetailFuture = {
|
||||||
|
...Empty,
|
||||||
|
async play(context) {
|
||||||
|
await AbsoluteFuture.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||||
|
await RelativeFuture.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(')');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: future,
|
||||||
|
mode: 'detail',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const RelativeNow = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.ts._ago.justNow);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: now,
|
||||||
|
origin: now,
|
||||||
|
mode: 'relative',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const AbsoluteNow = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: now,
|
||||||
|
origin: now,
|
||||||
|
mode: 'absolute',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const DetailNow = {
|
||||||
|
...Empty,
|
||||||
|
async play(context) {
|
||||||
|
await AbsoluteNow.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||||
|
await RelativeNow.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(')');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: now,
|
||||||
|
origin: now,
|
||||||
|
mode: 'detail',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const RelativeOneHourAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.hoursAgo', { n: 1 }));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneHourAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'relative',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const AbsoluteOneHourAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneHourAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'absolute',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const DetailOneHourAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play(context) {
|
||||||
|
await AbsoluteOneHourAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||||
|
await RelativeOneHourAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(')');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneHourAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'detail',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const RelativeOneDayAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.daysAgo', { n: 1 }));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneDayAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'relative',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const AbsoluteOneDayAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneDayAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'absolute',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const DetailOneDayAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play(context) {
|
||||||
|
await AbsoluteOneDayAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||||
|
await RelativeOneDayAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(')');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneDayAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'detail',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const RelativeOneWeekAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.weeksAgo', { n: 1 }));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneWeekAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'relative',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const AbsoluteOneWeekAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneWeekAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'absolute',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const DetailOneWeekAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play(context) {
|
||||||
|
await AbsoluteOneWeekAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||||
|
await RelativeOneWeekAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(')');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneWeekAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'detail',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const RelativeOneMonthAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.monthsAgo', { n: 1 }));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneMonthAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'relative',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const AbsoluteOneMonthAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneMonthAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'absolute',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const DetailOneMonthAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play(context) {
|
||||||
|
await AbsoluteOneMonthAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||||
|
await RelativeOneMonthAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(')');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneMonthAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'detail',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const RelativeOneYearAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.yearsAgo', { n: 1 }));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneYearAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'relative',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const AbsoluteOneYearAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneYearAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'absolute',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const DetailOneYearAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play(context) {
|
||||||
|
await AbsoluteOneYearAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||||
|
await RelativeOneYearAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(')');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneYearAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'detail',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
|
@ -14,8 +14,10 @@ import { dateTimeFormat } from '@/scripts/intl-const';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
time: Date | string | number | null;
|
time: Date | string | number | null;
|
||||||
|
origin?: Date | null;
|
||||||
mode?: 'relative' | 'absolute' | 'detail';
|
mode?: 'relative' | 'absolute' | 'detail';
|
||||||
}>(), {
|
}>(), {
|
||||||
|
origin: null,
|
||||||
mode: 'relative',
|
mode: 'relative',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -25,7 +27,7 @@ const _time = props.time == null ? NaN :
|
||||||
const invalid = Number.isNaN(_time);
|
const invalid = Number.isNaN(_time);
|
||||||
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
|
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
|
||||||
|
|
||||||
let now = $ref((new Date()).getTime());
|
let now = $ref((props.origin ?? new Date()).getTime());
|
||||||
const relative = $computed<string>(() => {
|
const relative = $computed<string>(() => {
|
||||||
if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
|
if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
|
||||||
if (invalid) return i18n.ts._ago.invalid;
|
if (invalid) return i18n.ts._ago.invalid;
|
||||||
|
@ -46,7 +48,7 @@ const relative = $computed<string>(() => {
|
||||||
let tickId: number;
|
let tickId: number;
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
now = (new Date()).getTime();
|
now = props.origin ?? (new Date()).getTime();
|
||||||
const ago = (now - _time) / 1000/*ms*/;
|
const ago = (now - _time) / 1000/*ms*/;
|
||||||
const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
|
const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,77 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
import { userEvent, within } from '@storybook/testing-library';
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import { rest } from 'msw';
|
||||||
|
import { commonHandlers } from '../../../.storybook/mocks';
|
||||||
|
import MkUrl from './MkUrl.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkUrl,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkUrl v-bind="props">Text</MkUrl>',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||||
|
await expect(a).toHaveAttribute('href', 'https://misskey-hub.net/');
|
||||||
|
await userEvent.hover(a);
|
||||||
|
/*
|
||||||
|
await tick(); // FIXME: wait for network request
|
||||||
|
const anchors = canvas.getAllByRole<HTMLAnchorElement>('link');
|
||||||
|
const popup = anchors.find(anchor => anchor !== a)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||||
|
await expect(popup).toBeInTheDocument();
|
||||||
|
await expect(popup).toHaveAttribute('href', 'https://misskey-hub.net/');
|
||||||
|
await expect(popup).toHaveTextContent('Misskey Hub');
|
||||||
|
await expect(popup).toHaveTextContent('Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。');
|
||||||
|
await expect(popup).toHaveTextContent('misskey-hub.net');
|
||||||
|
const icon = within(popup).getByRole('img');
|
||||||
|
await expect(icon).toBeInTheDocument();
|
||||||
|
await expect(icon).toHaveAttribute('src', 'https://misskey-hub.net/favicon.ico');
|
||||||
|
*/
|
||||||
|
await userEvent.unhover(a);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
url: 'https://misskey-hub.net/',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
...commonHandlers,
|
||||||
|
rest.get('/url', (req, res, ctx) => {
|
||||||
|
return res(ctx.json({
|
||||||
|
title: 'Misskey Hub',
|
||||||
|
icon: 'https://misskey-hub.net/favicon.ico',
|
||||||
|
description: 'Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。',
|
||||||
|
thumbnail: null,
|
||||||
|
player: {
|
||||||
|
url: null,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
allow: [],
|
||||||
|
},
|
||||||
|
sitename: 'misskey-hub.net',
|
||||||
|
sensitive: false,
|
||||||
|
url: 'https://misskey-hub.net/',
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkUrl>;
|
|
@ -0,0 +1,57 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
import { userEvent, within } from '@storybook/testing-library';
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import { userDetailed } from '../../../.storybook/fakes';
|
||||||
|
import MkUserName from './MkUserName.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkUserName,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkUserName v-bind="props"/>',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(userDetailed.name);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
user: userDetailed,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkUserName>;
|
||||||
|
export const Anonymous = {
|
||||||
|
...Default,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(userDetailed.username);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
user: {
|
||||||
|
...userDetailed,
|
||||||
|
name: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkUserName>;
|
||||||
|
export const Wrap = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
nowrap: false,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkUserName>;
|
|
@ -0,0 +1,3 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import RouterView from './RouterView.vue';
|
||||||
|
void RouterView;
|
12
packages/frontend/src/index.mdx
Normal file
12
packages/frontend/src/index.mdx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { Meta } from '@storybook/blocks'
|
||||||
|
|
||||||
|
<Meta title="index" />
|
||||||
|
|
||||||
|
# Welcome to Misskey Storybook
|
||||||
|
|
||||||
|
This project uses [Storybook](https://storybook.js.org/) to develop and document components.
|
||||||
|
You can find more information about the usage of Storybook in this project in the CONTRIBUTING.md file placed in the root of this repository.
|
||||||
|
|
||||||
|
The Misskey Storybook is under development and not all components are documented yet.
|
||||||
|
Contributions are welcome! Please refer to [#10336](https://github.com/misskey-dev/misskey/issues/10336) for more information.
|
||||||
|
Thank you for your support!
|
|
@ -2,8 +2,8 @@
|
||||||
<MkStickyContainer>
|
<MkStickyContainer>
|
||||||
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
||||||
<MkSpacer :content-max="800">
|
<MkSpacer :content-max="800">
|
||||||
<div v-if="tab === 'all' || tab === 'unread'">
|
<div v-if="tab === 'all'">
|
||||||
<XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/>
|
<XNotifications class="notifications" :include-types="includeTypes"/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab === 'mentions'">
|
<div v-else-if="tab === 'mentions'">
|
||||||
<MkNotes :pagination="mentionsPagination"/>
|
<MkNotes :pagination="mentionsPagination"/>
|
||||||
|
@ -26,7 +26,6 @@ import { notificationTypes } from '@/const';
|
||||||
|
|
||||||
let tab = $ref('all');
|
let tab = $ref('all');
|
||||||
let includeTypes = $ref<string[] | null>(null);
|
let includeTypes = $ref<string[] | null>(null);
|
||||||
let unreadOnly = $computed(() => tab === 'unread');
|
|
||||||
|
|
||||||
const mentionsPagination = {
|
const mentionsPagination = {
|
||||||
endpoint: 'notes/mentions' as const,
|
endpoint: 'notes/mentions' as const,
|
||||||
|
@ -76,10 +75,6 @@ const headerTabs = $computed(() => [{
|
||||||
key: 'all',
|
key: 'all',
|
||||||
title: i18n.ts.all,
|
title: i18n.ts.all,
|
||||||
icon: 'ti ti-point',
|
icon: 'ti ti-point',
|
||||||
}, {
|
|
||||||
key: 'unread',
|
|
||||||
title: i18n.ts.unread,
|
|
||||||
icon: 'ti ti-loader',
|
|
||||||
}, {
|
}, {
|
||||||
key: 'mentions',
|
key: 'mentions',
|
||||||
title: i18n.ts.mentions,
|
title: i18n.ts.mentions,
|
||||||
|
|
|
@ -77,7 +77,10 @@ async function renderChart() {
|
||||||
barPercentage: 0.7,
|
barPercentage: 0.7,
|
||||||
categoryPercentage: 0.7,
|
categoryPercentage: 0.7,
|
||||||
fill: true,
|
fill: true,
|
||||||
} satisfies ChartDataset, extra);
|
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||||
|
} satisfies ChartData, extra);
|
||||||
|
*/
|
||||||
|
}, extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
chartInstance = new Chart(chartEl, {
|
chartInstance = new Chart(chartEl, {
|
||||||
|
|
|
@ -113,6 +113,9 @@ async function renderChart() {
|
||||||
const a = c.chart.chartArea ?? {};
|
const a = c.chart.chartArea ?? {};
|
||||||
return (a.bottom - a.top) / 7 - marginEachCell;
|
return (a.bottom - a.top) / 7 - marginEachCell;
|
||||||
},
|
},
|
||||||
|
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||||
|
}] satisfies ChartData[],
|
||||||
|
*/
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
|
|
@ -76,7 +76,10 @@ async function renderChart() {
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
barPercentage: 0.9,
|
barPercentage: 0.9,
|
||||||
fill: true,
|
fill: true,
|
||||||
} satisfies ChartDataset, extra);
|
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||||
|
} satisfies ChartData, extra);
|
||||||
|
*/
|
||||||
|
}, extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
chartInstance = new Chart(chartEl, {
|
chartInstance = new Chart(chartEl, {
|
||||||
|
|
|
@ -77,7 +77,10 @@ async function renderChart() {
|
||||||
barPercentage: 0.7,
|
barPercentage: 0.7,
|
||||||
categoryPercentage: 0.7,
|
categoryPercentage: 0.7,
|
||||||
fill: true,
|
fill: true,
|
||||||
} satisfies ChartDataset, extra);
|
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||||
|
} satisfies ChartData, extra);
|
||||||
|
*/
|
||||||
|
}, extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
chartInstance = new Chart(chartEl, {
|
chartInstance = new Chart(chartEl, {
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue