mirror of
https://activitypub.software/TransFem-org/Sharkey
synced 2024-11-25 15:35:12 +00: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
|
||||
ormconfig.json
|
||||
temp
|
||||
/packages/frontend/src/**/*.stories.ts
|
||||
|
||||
# blender backups
|
||||
*.blend1
|
||||
|
|
|
@ -34,6 +34,7 @@
|
|||
- ノート作成時のパフォーマンスを向上
|
||||
- アンテナのタイムライン取得時のパフォーマンスを向上
|
||||
- チャンネルのタイムライン取得時のパフォーマンスを向上
|
||||
- 通知に関する全体的なパフォーマンスを向上
|
||||
|
||||
## 13.10.3
|
||||
|
||||
|
|
110
CONTRIBUTING.md
110
CONTRIBUTING.md
|
@ -203,6 +203,116 @@ niraxは、Misskeyで使用しているオリジナルのフロントエンド
|
|||
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
|
||||
### 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 Redis from 'ioredis';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import type { UserProfile, UsersRepository } from '@/models/index.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import type { LocalUser, User } from '@/models/entities/User.js';
|
||||
import { DI } from '@/di-symbols.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';
|
||||
|
||||
@Injectable()
|
||||
export class UserCacheService implements OnApplicationShutdown {
|
||||
public userByIdCache: KVCache<User>;
|
||||
public localUserByNativeTokenCache: KVCache<LocalUser | null>;
|
||||
public localUserByIdCache: KVCache<LocalUser>;
|
||||
public uriPersonCache: KVCache<User | null>;
|
||||
export class CacheService implements OnApplicationShutdown {
|
||||
public userByIdCache: MemoryKVCache<User>;
|
||||
public localUserByNativeTokenCache: MemoryKVCache<LocalUser | null>;
|
||||
public localUserByIdCache: MemoryKVCache<LocalUser>;
|
||||
public uriPersonCache: MemoryKVCache<User | null>;
|
||||
public userProfileCache: RedisKVCache<UserProfile>;
|
||||
public userMutingsCache: RedisKVCache<string[]>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.redisSubscriber)
|
||||
private redisSubscriber: Redis.Redis,
|
||||
|
||||
|
@ -27,10 +32,12 @@ export class UserCacheService implements OnApplicationShutdown {
|
|||
) {
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
|
||||
this.userByIdCache = new KVCache<User>(Infinity);
|
||||
this.localUserByNativeTokenCache = new KVCache<LocalUser | null>(Infinity);
|
||||
this.localUserByIdCache = new KVCache<LocalUser>(Infinity);
|
||||
this.uriPersonCache = new KVCache<User | null>(Infinity);
|
||||
this.userByIdCache = new MemoryKVCache<User>(Infinity);
|
||||
this.localUserByNativeTokenCache = new MemoryKVCache<LocalUser | null>(Infinity);
|
||||
this.localUserByIdCache = new MemoryKVCache<LocalUser>(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);
|
||||
}
|
||||
|
@ -52,7 +59,7 @@ export class UserCacheService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
this.localUserByNativeTokenCache.set(user.token, user);
|
||||
this.localUserByNativeTokenCache.set(user.token!, user);
|
||||
this.localUserByIdCache.set(user.id, user);
|
||||
}
|
||||
break;
|
||||
|
@ -77,7 +84,7 @@ export class UserCacheService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public findById(userId: User['id']) {
|
||||
public findUserById(userId: User['id']) {
|
||||
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 { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js';
|
||||
import { UserBlockingService } from './UserBlockingService.js';
|
||||
import { UserCacheService } from './UserCacheService.js';
|
||||
import { CacheService } from './CacheService.js';
|
||||
import { UserFollowingService } from './UserFollowingService.js';
|
||||
import { UserKeypairStoreService } from './UserKeypairStoreService.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 $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService };
|
||||
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 $UserKeypairStoreService: Provider = { provide: 'UserKeypairStoreService', useExisting: UserKeypairStoreService };
|
||||
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
|
||||
|
@ -282,7 +282,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
SignupService,
|
||||
TwoFactorAuthenticationService,
|
||||
UserBlockingService,
|
||||
UserCacheService,
|
||||
CacheService,
|
||||
UserFollowingService,
|
||||
UserKeypairStoreService,
|
||||
UserListService,
|
||||
|
@ -399,7 +399,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$SignupService,
|
||||
$TwoFactorAuthenticationService,
|
||||
$UserBlockingService,
|
||||
$UserCacheService,
|
||||
$CacheService,
|
||||
$UserFollowingService,
|
||||
$UserKeypairStoreService,
|
||||
$UserListService,
|
||||
|
@ -517,7 +517,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
SignupService,
|
||||
TwoFactorAuthenticationService,
|
||||
UserBlockingService,
|
||||
UserCacheService,
|
||||
CacheService,
|
||||
UserFollowingService,
|
||||
UserKeypairStoreService,
|
||||
UserListService,
|
||||
|
@ -633,7 +633,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$SignupService,
|
||||
$TwoFactorAuthenticationService,
|
||||
$UserBlockingService,
|
||||
$UserCacheService,
|
||||
$CacheService,
|
||||
$UserFollowingService,
|
||||
$UserKeypairStoreService,
|
||||
$UserListService,
|
||||
|
|
|
@ -8,7 +8,7 @@ import type { DriveFile } from '@/models/entities/DriveFile.js';
|
|||
import type { Emoji } from '@/models/entities/Emoji.js';
|
||||
import type { EmojisRepository, Note } from '@/models/index.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 type { Config } from '@/config.js';
|
||||
import { ReactionService } from '@/core/ReactionService.js';
|
||||
|
@ -16,7 +16,7 @@ import { query } from '@/misc/prelude/url.js';
|
|||
|
||||
@Injectable()
|
||||
export class CustomEmojiService {
|
||||
private cache: KVCache<Emoji | null>;
|
||||
private cache: MemoryKVCache<Emoji | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
|
@ -34,7 +34,7 @@ export class CustomEmojiService {
|
|||
private globalEventService: GlobalEventService,
|
||||
private reactionService: ReactionService,
|
||||
) {
|
||||
this.cache = new KVCache<Emoji | null>(1000 * 60 * 60 * 12);
|
||||
this.cache = new MemoryKVCache<Emoji | null>(1000 * 60 * 60 * 12);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { InstancesRepository } from '@/models/index.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 { DI } from '@/di-symbols.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
|
@ -9,7 +9,7 @@ import { bindThis } from '@/decorators.js';
|
|||
|
||||
@Injectable()
|
||||
export class FederatedInstanceService {
|
||||
private cache: KVCache<Instance>;
|
||||
private cache: MemoryKVCache<Instance>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.instancesRepository)
|
||||
|
@ -18,7 +18,7 @@ export class FederatedInstanceService {
|
|||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
this.cache = new KVCache<Instance>(1000 * 60 * 60);
|
||||
this.cache = new MemoryKVCache<Instance>(1000 * 60 * 60);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import { IsNull } from 'typeorm';
|
||||
import type { LocalUser } from '@/models/entities/User.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 { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
@ -11,7 +11,7 @@ const ACTOR_USERNAME = 'instance.actor' as const;
|
|||
|
||||
@Injectable()
|
||||
export class InstanceActorService {
|
||||
private cache: KVCache<LocalUser>;
|
||||
private cache: MemoryCache<LocalUser>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
|
@ -19,12 +19,12 @@ export class InstanceActorService {
|
|||
|
||||
private createSystemUserService: CreateSystemUserService,
|
||||
) {
|
||||
this.cache = new KVCache<LocalUser>(Infinity);
|
||||
this.cache = new MemoryCache<LocalUser>(Infinity);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getInstanceActor(): Promise<LocalUser> {
|
||||
const cached = this.cache.get(null);
|
||||
const cached = this.cache.get();
|
||||
if (cached) return cached;
|
||||
|
||||
const user = await this.usersRepository.findOneBy({
|
||||
|
@ -33,11 +33,11 @@ export class InstanceActorService {
|
|||
}) as LocalUser | undefined;
|
||||
|
||||
if (user) {
|
||||
this.cache.set(null, user);
|
||||
this.cache.set(user);
|
||||
return user;
|
||||
} else {
|
||||
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as LocalUser;
|
||||
this.cache.set(null, created);
|
||||
this.cache.set(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 type { Channel } from '@/models/entities/Channel.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 { RelayService } from '@/core/RelayService.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 { 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';
|
||||
|
||||
|
@ -473,7 +473,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
this.incNotesCountOfUser(user);
|
||||
|
||||
// Word mute
|
||||
mutedWordsCache.fetch(null, () => this.userProfilesRepository.find({
|
||||
mutedWordsCache.fetch(() => this.userProfilesRepository.find({
|
||||
where: {
|
||||
enableWordMute: true,
|
||||
},
|
||||
|
|
|
@ -169,10 +169,6 @@ export class NoteReadService implements OnApplicationShutdown {
|
|||
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 Redis from 'ioredis';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
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 { Notification } from '@/models/entities/Notification.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 { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService implements OnApplicationShutdown {
|
||||
#shutdownController = new AbortController();
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
|
@ -34,54 +36,35 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private pushNotificationService: PushNotificationService,
|
||||
private cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async readNotification(
|
||||
public async readAllNotification(
|
||||
userId: User['id'],
|
||||
notificationIds: Notification['id'][],
|
||||
) {
|
||||
if (notificationIds.length === 0) return;
|
||||
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
|
||||
|
||||
const latestNotificationIdsRes = await this.redisClient.xrevrange(
|
||||
`notificationTimeline:${userId}`,
|
||||
'+',
|
||||
'-',
|
||||
'COUNT', 1);
|
||||
const latestNotificationId = latestNotificationIdsRes[0]?.[0];
|
||||
|
||||
// Update documents
|
||||
const result = await this.notificationsRepository.update({
|
||||
notifieeId: userId,
|
||||
id: In(notificationIds),
|
||||
isRead: false,
|
||||
}, {
|
||||
isRead: true,
|
||||
});
|
||||
if (latestNotificationId == null) return;
|
||||
|
||||
if (result.affected === 0) return;
|
||||
this.redisClient.set(`latestReadNotification:${userId}`, latestNotificationId);
|
||||
|
||||
if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.postReadAllNotifications(userId);
|
||||
else return this.postReadNotifications(userId, notificationIds);
|
||||
}
|
||||
|
||||
@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);
|
||||
if (latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) {
|
||||
return this.postReadAllNotifications(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private postReadAllNotifications(userId: User['id']) {
|
||||
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
|
||||
|
@ -90,45 +73,43 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
type: Notification['type'],
|
||||
data: Partial<Notification>,
|
||||
): Promise<Notification | null> {
|
||||
if (data.notifierId && (notifieeId === data.notifierId)) {
|
||||
return null;
|
||||
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;
|
||||
}
|
||||
|
||||
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 profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId });
|
||||
|
||||
const isMuted = profile?.mutingNotificationTypes.includes(type);
|
||||
|
||||
// Create notification
|
||||
const notification = await this.notificationsRepository.insert({
|
||||
const notification = {
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
notifieeId: notifieeId,
|
||||
type: type,
|
||||
// 相手がこの通知をミュートしているようなら、既読を予めつけておく
|
||||
isRead: isMuted,
|
||||
...data,
|
||||
} as Partial<Notification>)
|
||||
.then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
} as Notification;
|
||||
|
||||
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
|
||||
this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
|
||||
|
||||
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
|
||||
setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
|
||||
const fresh = await this.notificationsRepository.findOneBy({ id: notification.id });
|
||||
if (fresh == null) 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
|
||||
setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => {
|
||||
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`);
|
||||
if (latestReadNotificationId && (latestReadNotificationId >= notification.id)) return;
|
||||
|
||||
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
|
||||
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
|
||||
|
|
|
@ -15,10 +15,6 @@ type PushNotificationsTypes = {
|
|||
antenna: { id: string, name: string };
|
||||
note: Packed<'Note'>;
|
||||
};
|
||||
'readNotifications': { notificationIds: string[] };
|
||||
'readAllNotifications': undefined;
|
||||
'readAntenna': { antennaId: string };
|
||||
'readAllAntennas': undefined;
|
||||
};
|
||||
|
||||
// Reduce length because push message servers have character limits
|
||||
|
@ -72,14 +68,6 @@ export class PushNotificationService {
|
|||
});
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
// Continue if sendReadMessage is false
|
||||
if ([
|
||||
'readNotifications',
|
||||
'readAllNotifications',
|
||||
'readAntenna',
|
||||
'readAllAntennas',
|
||||
].includes(type) && !subscription.sendReadMessage) continue;
|
||||
|
||||
const pushSubscription = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
|
|
|
@ -3,7 +3,7 @@ import { IsNull } from 'typeorm';
|
|||
import type { LocalUser, User } from '@/models/entities/User.js';
|
||||
import type { RelaysRepository, UsersRepository } from '@/models/index.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 { QueueService } from '@/core/QueueService.js';
|
||||
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
|
||||
|
@ -16,7 +16,7 @@ const ACTOR_USERNAME = 'relay.actor' as const;
|
|||
|
||||
@Injectable()
|
||||
export class RelayService {
|
||||
private relaysCache: KVCache<Relay[]>;
|
||||
private relaysCache: MemoryCache<Relay[]>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
|
@ -30,7 +30,7 @@ export class RelayService {
|
|||
private createSystemUserService: CreateSystemUserService,
|
||||
private apRendererService: ApRendererService,
|
||||
) {
|
||||
this.relaysCache = new KVCache<Relay[]>(1000 * 60 * 10);
|
||||
this.relaysCache = new MemoryCache<Relay[]>(1000 * 60 * 10);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -109,7 +109,7 @@ export class RelayService {
|
|||
public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise<void> {
|
||||
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',
|
||||
}));
|
||||
if (relays.length === 0) return;
|
||||
|
|
|
@ -2,12 +2,12 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import Redis from 'ioredis';
|
||||
import { In } from 'typeorm';
|
||||
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 { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.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 { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { StreamMessages } from '@/server/api/stream/types.js';
|
||||
|
@ -57,8 +57,8 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
|
||||
@Injectable()
|
||||
export class RoleService implements OnApplicationShutdown {
|
||||
private rolesCache: KVCache<Role[]>;
|
||||
private roleAssignmentByUserIdCache: KVCache<RoleAssignment[]>;
|
||||
private rolesCache: MemoryCache<Role[]>;
|
||||
private roleAssignmentByUserIdCache: MemoryKVCache<RoleAssignment[]>;
|
||||
|
||||
public static AlreadyAssignedError = class extends Error {};
|
||||
public static NotAssignedError = class extends Error {};
|
||||
|
@ -77,15 +77,15 @@ export class RoleService implements OnApplicationShutdown {
|
|||
private roleAssignmentsRepository: RoleAssignmentsRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
private userCacheService: UserCacheService,
|
||||
private cacheService: CacheService,
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
|
||||
this.rolesCache = new KVCache<Role[]>(Infinity);
|
||||
this.roleAssignmentByUserIdCache = new KVCache<RoleAssignment[]>(Infinity);
|
||||
this.rolesCache = new MemoryCache<Role[]>(Infinity);
|
||||
this.roleAssignmentByUserIdCache = new MemoryKVCache<RoleAssignment[]>(Infinity);
|
||||
|
||||
this.redisSubscriber.on('message', this.onMessage);
|
||||
}
|
||||
|
@ -98,7 +98,7 @@ export class RoleService implements OnApplicationShutdown {
|
|||
const { type, body } = obj.message as StreamMessages['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'roleCreated': {
|
||||
const cached = this.rolesCache.get(null);
|
||||
const cached = this.rolesCache.get();
|
||||
if (cached) {
|
||||
cached.push({
|
||||
...body,
|
||||
|
@ -110,7 +110,7 @@ export class RoleService implements OnApplicationShutdown {
|
|||
break;
|
||||
}
|
||||
case 'roleUpdated': {
|
||||
const cached = this.rolesCache.get(null);
|
||||
const cached = this.rolesCache.get();
|
||||
if (cached) {
|
||||
const i = cached.findIndex(x => x.id === body.id);
|
||||
if (i > -1) {
|
||||
|
@ -125,9 +125,9 @@ export class RoleService implements OnApplicationShutdown {
|
|||
break;
|
||||
}
|
||||
case 'roleDeleted': {
|
||||
const cached = this.rolesCache.get(null);
|
||||
const cached = this.rolesCache.get();
|
||||
if (cached) {
|
||||
this.rolesCache.set(null, cached.filter(x => x.id !== body.id));
|
||||
this.rolesCache.set(cached.filter(x => x.id !== body.id));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@ -214,9 +214,9 @@ export class RoleService implements OnApplicationShutdown {
|
|||
// 期限切れのロールを除外
|
||||
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
||||
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 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));
|
||||
return [...assignedRoles, ...matchedCondRoles];
|
||||
}
|
||||
|
@ -231,11 +231,11 @@ export class RoleService implements OnApplicationShutdown {
|
|||
// 期限切れのロールを除外
|
||||
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
||||
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 badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
|
||||
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));
|
||||
return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
|
||||
} else {
|
||||
|
@ -301,7 +301,7 @@ export class RoleService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
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 assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({
|
||||
roleId: In(moderatorRoles.map(r => r.id)),
|
||||
|
@ -321,7 +321,7 @@ export class RoleService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
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 assigns = administratorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({
|
||||
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 { WebhookService } from '@/core/WebhookService.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';
|
||||
|
||||
@Injectable()
|
||||
|
@ -23,7 +23,7 @@ export class UserBlockingService implements OnApplicationShutdown {
|
|||
private logger: Logger;
|
||||
|
||||
// キーがユーザーIDで、値がそのユーザーがブロックしているユーザーのIDのリストなキャッシュ
|
||||
private blockingsByUserIdCache: KVCache<User['id'][]>;
|
||||
private blockingsByUserIdCache: MemoryKVCache<User['id'][]>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisSubscriber)
|
||||
|
@ -58,7 +58,7 @@ export class UserBlockingService implements OnApplicationShutdown {
|
|||
) {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { User } from '@/models/entities/User.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 { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserKeypairStoreService {
|
||||
private cache: KVCache<UserKeypair>;
|
||||
private cache: MemoryKVCache<UserKeypair>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.userKeypairsRepository)
|
||||
private userKeypairsRepository: UserKeypairsRepository,
|
||||
) {
|
||||
this.cache = new KVCache<UserKeypair>(Infinity);
|
||||
this.cache = new MemoryKVCache<UserKeypair>(Infinity);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -3,9 +3,9 @@ import escapeRegexp from 'escape-regexp';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.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 { UserCacheService } from '@/core/UserCacheService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RemoteUser, User } from '@/models/entities/User.js';
|
||||
|
@ -31,8 +31,8 @@ export type UriParseResult = {
|
|||
|
||||
@Injectable()
|
||||
export class ApDbResolverService {
|
||||
private publicKeyCache: KVCache<UserPublickey | null>;
|
||||
private publicKeyByUserIdCache: KVCache<UserPublickey | null>;
|
||||
private publicKeyCache: MemoryKVCache<UserPublickey | null>;
|
||||
private publicKeyByUserIdCache: MemoryKVCache<UserPublickey | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
|
@ -47,11 +47,11 @@ export class ApDbResolverService {
|
|||
@Inject(DI.userPublickeysRepository)
|
||||
private userPublickeysRepository: UserPublickeysRepository,
|
||||
|
||||
private userCacheService: UserCacheService,
|
||||
private cacheService: CacheService,
|
||||
private apPersonService: ApPersonService,
|
||||
) {
|
||||
this.publicKeyCache = new KVCache<UserPublickey | null>(Infinity);
|
||||
this.publicKeyByUserIdCache = new KVCache<UserPublickey | null>(Infinity);
|
||||
this.publicKeyCache = new MemoryKVCache<UserPublickey | null>(Infinity);
|
||||
this.publicKeyByUserIdCache = new MemoryKVCache<UserPublickey | null>(Infinity);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -107,11 +107,11 @@ export class ApDbResolverService {
|
|||
if (parsed.local) {
|
||||
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,
|
||||
}).then(x => x ?? undefined)) ?? null;
|
||||
} 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,
|
||||
}));
|
||||
}
|
||||
|
@ -138,7 +138,7 @@ export class ApDbResolverService {
|
|||
if (key == null) return null;
|
||||
|
||||
return {
|
||||
user: await this.userCacheService.findById(key.userId) as RemoteUser,
|
||||
user: await this.cacheService.findUserById(key.userId) as RemoteUser,
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import type { Config } from '@/config.js';
|
|||
import type { RemoteUser } from '@/models/entities/User.js';
|
||||
import { User } from '@/models/entities/User.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 { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import type Logger from '@/logger.js';
|
||||
|
@ -54,7 +54,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
private metaService: MetaService;
|
||||
private federatedInstanceService: FederatedInstanceService;
|
||||
private fetchInstanceMetadataService: FetchInstanceMetadataService;
|
||||
private userCacheService: UserCacheService;
|
||||
private cacheService: CacheService;
|
||||
private apResolverService: ApResolverService;
|
||||
private apNoteService: ApNoteService;
|
||||
private apImageService: ApImageService;
|
||||
|
@ -97,7 +97,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
//private metaService: MetaService,
|
||||
//private federatedInstanceService: FederatedInstanceService,
|
||||
//private fetchInstanceMetadataService: FetchInstanceMetadataService,
|
||||
//private userCacheService: UserCacheService,
|
||||
//private cacheService: CacheService,
|
||||
//private apResolverService: ApResolverService,
|
||||
//private apNoteService: ApNoteService,
|
||||
//private apImageService: ApImageService,
|
||||
|
@ -118,7 +118,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
this.metaService = this.moduleRef.get('MetaService');
|
||||
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
|
||||
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.apNoteService = this.moduleRef.get('ApNoteService');
|
||||
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> {
|
||||
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;
|
||||
|
||||
// URIがこのサーバーを指しているならデータベースからフェッチ
|
||||
if (uri.startsWith(this.config.url + '/')) {
|
||||
const id = uri.split('/').pop();
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -222,7 +222,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
const exist = await this.usersRepository.findOneBy({ uri });
|
||||
|
||||
if (exist) {
|
||||
this.userCacheService.uriPersonCache.set(uri, exist);
|
||||
this.cacheService.uriPersonCache.set(uri, exist);
|
||||
return exist;
|
||||
}
|
||||
//#endregion
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { In } from 'typeorm';
|
||||
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 type { Notification } from '@/models/entities/Notification.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
|
@ -25,8 +26,11 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.noteReactionsRepository)
|
||||
private noteReactionsRepository: NoteReactionsRepository,
|
||||
|
@ -48,30 +52,40 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
|
||||
@bindThis
|
||||
public async pack(
|
||||
src: Notification['id'] | Notification,
|
||||
src: Notification,
|
||||
meId: User['id'],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
options: {
|
||||
_hint_?: {
|
||||
packedNotes: Map<Note['id'], Packed<'Note'>>;
|
||||
};
|
||||
|
||||
},
|
||||
hint?: {
|
||||
packedNotes: Map<Note['id'], Packed<'Note'>>;
|
||||
packedUsers: Map<User['id'], Packed<'User'>>;
|
||||
},
|
||||
): 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 noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? (
|
||||
options._hint_?.packedNotes != null
|
||||
? options._hint_.packedNotes.get(notification.noteId)
|
||||
: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
|
||||
hint?.packedNotes != null
|
||||
? hint.packedNotes.get(notification.noteId)
|
||||
: this.noteEntityService.pack(notification.noteId!, { id: meId }, {
|
||||
detail: true,
|
||||
})
|
||||
) : 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({
|
||||
id: notification.id,
|
||||
createdAt: notification.createdAt.toISOString(),
|
||||
createdAt: new Date(notification.createdAt).toISOString(),
|
||||
type: notification.type,
|
||||
isRead: notification.isRead,
|
||||
userId: notification.notifierId,
|
||||
user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null,
|
||||
...(userIfNeed != null ? { user: userIfNeed } : {}),
|
||||
...(noteIfNeed != null ? { note: noteIfNeed } : {}),
|
||||
...(notification.type === 'reaction' ? {
|
||||
reaction: notification.reaction,
|
||||
|
@ -87,33 +101,36 @@ 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
|
||||
public async packMany(
|
||||
notifications: Notification[],
|
||||
meId: User['id'],
|
||||
) {
|
||||
if (notifications.length === 0) return [];
|
||||
|
||||
for (const notification of notifications) {
|
||||
if (meId !== notification.notifieeId) {
|
||||
// because we call note packMany with meId, all notifieeId should be same as meId
|
||||
throw new Error('TRY_TO_PACK_ANOTHER_USER_NOTIFICATION');
|
||||
}
|
||||
}
|
||||
|
||||
const notes = notifications.map(x => x.note).filter(isNotNull);
|
||||
const noteIds = notifications.map(x => x.noteId).filter(isNotNull);
|
||||
const notes = noteIds.length > 0 ? await this.notesRepository.find({
|
||||
where: { id: In(noteIds) },
|
||||
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 packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
|
||||
detail: true,
|
||||
});
|
||||
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
|
||||
|
||||
return await Promise.all(notifications.map(x => this.pack(x, {
|
||||
_hint_: {
|
||||
packedNotes,
|
||||
},
|
||||
const userIds = notifications.map(x => x.notifierId).filter(isNotNull);
|
||||
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,
|
||||
packedUsers,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, Not } from 'typeorm';
|
||||
import Redis from 'ioredis';
|
||||
import Ajv from 'ajv';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
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 { awaitAll } from '@/misc/prelude/await-all.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 { LocalUser, RemoteUser, User } 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 { RoleService } from '@/core/RoleService.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
|
@ -52,7 +53,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
private customEmojiService: CustomEmojiService;
|
||||
private antennaService: AntennaService;
|
||||
private roleService: RoleService;
|
||||
private userInstanceCache: KVCache<Instance | null>;
|
||||
private userInstanceCache: MemoryKVCache<Instance | null>;
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
|
@ -60,6 +61,9 @@ export class UserEntityService implements OnModuleInit {
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
|
@ -90,9 +94,6 @@ export class UserEntityService implements OnModuleInit {
|
|||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
|
||||
@Inject(DI.userNotePiningsRepository)
|
||||
private userNotePiningsRepository: UserNotePiningsRepository,
|
||||
|
||||
|
@ -118,7 +119,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
//private antennaService: AntennaService,
|
||||
//private roleService: RoleService,
|
||||
) {
|
||||
this.userInstanceCache = new KVCache<Instance | null>(1000 * 60 * 60 * 3);
|
||||
this.userInstanceCache = new MemoryKVCache<Instance | null>(1000 * 60 * 60 * 3);
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
|
@ -247,21 +248,16 @@ export class UserEntityService implements OnModuleInit {
|
|||
|
||||
@bindThis
|
||||
public async getHasUnreadNotification(userId: User['id']): Promise<boolean> {
|
||||
const mute = await this.mutingsRepository.findBy({
|
||||
muterId: userId,
|
||||
});
|
||||
const mutedUserIds = mute.map(m => m.muteeId);
|
||||
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
|
||||
|
||||
const latestNotificationIdsRes = await this.redisClient.xrevrange(
|
||||
`notificationTimeline:${userId}`,
|
||||
'+',
|
||||
'-',
|
||||
'COUNT', 1);
|
||||
const latestNotificationId = latestNotificationIdsRes[0]?.[0];
|
||||
|
||||
const count = await this.notificationsRepository.count({
|
||||
where: {
|
||||
notifieeId: userId,
|
||||
...(mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}),
|
||||
isRead: false,
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
return count > 0;
|
||||
return latestNotificationId != null && (latestReadNotificationId == null || latestReadNotificationId < latestNotificationId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -33,7 +33,6 @@ export const DI = {
|
|||
emojisRepository: Symbol('emojisRepository'),
|
||||
driveFilesRepository: Symbol('driveFilesRepository'),
|
||||
driveFoldersRepository: Symbol('driveFoldersRepository'),
|
||||
notificationsRepository: Symbol('notificationsRepository'),
|
||||
metasRepository: Symbol('metasRepository'),
|
||||
mutingsRepository: Symbol('mutingsRepository'),
|
||||
renoteMutingsRepository: Symbol('renoteMutingsRepository'),
|
||||
|
|
|
@ -1,18 +1,103 @@
|
|||
import Redis from 'ioredis';
|
||||
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: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
|
||||
|
||||
export class KVCache<T> {
|
||||
public cache: Map<string | null, { date: number; value: T; }>;
|
||||
export class MemoryKVCache<T> {
|
||||
public cache: Map<string, { date: number; value: T; }>;
|
||||
private lifetime: number;
|
||||
|
||||
constructor(lifetime: KVCache<never>['lifetime']) {
|
||||
constructor(lifetime: MemoryKVCache<never>['lifetime']) {
|
||||
this.cache = new Map();
|
||||
this.lifetime = lifetime;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public set(key: string | null, value: T): void {
|
||||
public set(key: string, value: T): void {
|
||||
this.cache.set(key, {
|
||||
date: Date.now(),
|
||||
value,
|
||||
|
@ -20,7 +105,7 @@ export class KVCache<T> {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public get(key: string | null): T | undefined {
|
||||
public get(key: string): T | undefined {
|
||||
const cached = this.cache.get(key);
|
||||
if (cached == null) return undefined;
|
||||
if ((Date.now() - cached.date) > this.lifetime) {
|
||||
|
@ -31,7 +116,7 @@ export class KVCache<T> {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public delete(key: string | null) {
|
||||
public delete(key: string) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
|
@ -40,7 +125,7 @@ export class KVCache<T> {
|
|||
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
|
||||
*/
|
||||
@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);
|
||||
if (cachedValue !== undefined) {
|
||||
if (validator) {
|
||||
|
@ -65,7 +150,7 @@ export class KVCache<T> {
|
|||
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
|
||||
*/
|
||||
@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);
|
||||
if (cachedValue !== undefined) {
|
||||
if (validator) {
|
||||
|
@ -88,12 +173,12 @@ export class KVCache<T> {
|
|||
}
|
||||
}
|
||||
|
||||
export class Cache<T> {
|
||||
export class MemoryCache<T> {
|
||||
private cachedAt: number | null = null;
|
||||
private value: T | undefined;
|
||||
private lifetime: number;
|
||||
|
||||
constructor(lifetime: Cache<never>['lifetime']) {
|
||||
constructor(lifetime: MemoryCache<never>['lifetime']) {
|
||||
this.lifetime = lifetime;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
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 { Provider } from '@nestjs/common';
|
||||
|
||||
|
@ -172,12 +172,6 @@ const $driveFoldersRepository: Provider = {
|
|||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $notificationsRepository: Provider = {
|
||||
provide: DI.notificationsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Notification),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $metasRepository: Provider = {
|
||||
provide: DI.metasRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Meta),
|
||||
|
@ -426,7 +420,6 @@ const $roleAssignmentsRepository: Provider = {
|
|||
$emojisRepository,
|
||||
$driveFilesRepository,
|
||||
$driveFoldersRepository,
|
||||
$notificationsRepository,
|
||||
$metasRepository,
|
||||
$mutingsRepository,
|
||||
$renoteMutingsRepository,
|
||||
|
@ -493,7 +486,6 @@ const $roleAssignmentsRepository: Provider = {
|
|||
$emojisRepository,
|
||||
$driveFilesRepository,
|
||||
$driveFoldersRepository,
|
||||
$notificationsRepository,
|
||||
$metasRepository,
|
||||
$mutingsRepository,
|
||||
$renoteMutingsRepository,
|
||||
|
|
|
@ -1,54 +1,19 @@
|
|||
import { Entity, Index, JoinColumn, ManyToOne, Column, PrimaryColumn } from 'typeorm';
|
||||
import { notificationTypes, obsoleteNotificationTypes } from '@/types.js';
|
||||
import { id } from '../id.js';
|
||||
import { notificationTypes } from '@/types.js';
|
||||
import { User } from './User.js';
|
||||
import { Note } from './Note.js';
|
||||
import { FollowRequest } from './FollowRequest.js';
|
||||
import { AccessToken } from './AccessToken.js';
|
||||
|
||||
@Entity()
|
||||
export class Notification {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
export type Notification = {
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
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;
|
||||
// RedisのためDateではなくstring
|
||||
createdAt: string;
|
||||
|
||||
/**
|
||||
* 通知の送信者(initiator)
|
||||
*/
|
||||
@Index()
|
||||
@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;
|
||||
notifierId: User['id'] | null;
|
||||
|
||||
/**
|
||||
* 通知の種類。
|
||||
|
@ -64,104 +29,37 @@ export class Notification {
|
|||
* achievementEarned - 実績を獲得
|
||||
* app - アプリ通知
|
||||
*/
|
||||
@Index()
|
||||
@Column('enum', {
|
||||
enum: [
|
||||
...notificationTypes,
|
||||
...obsoleteNotificationTypes,
|
||||
],
|
||||
comment: 'The type of the Notification.',
|
||||
})
|
||||
public type: typeof notificationTypes[number];
|
||||
type: typeof notificationTypes[number];
|
||||
|
||||
/**
|
||||
* 通知が読まれたかどうか
|
||||
*/
|
||||
@Index()
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
comment: 'Whether the Notification is read.',
|
||||
})
|
||||
public isRead: boolean;
|
||||
noteId: Note['id'] | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
})
|
||||
public noteId: Note['id'] | null;
|
||||
followRequestId: FollowRequest['id'] | null;
|
||||
|
||||
@ManyToOne(type => Note, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public note: Note | null;
|
||||
reaction: string | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
})
|
||||
public followRequestId: FollowRequest['id'] | null;
|
||||
choice: number | null;
|
||||
|
||||
@ManyToOne(type => FollowRequest, {
|
||||
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;
|
||||
achievement: string | null;
|
||||
|
||||
/**
|
||||
* アプリ通知のbody
|
||||
*/
|
||||
@Column('varchar', {
|
||||
length: 2048, nullable: true,
|
||||
})
|
||||
public customBody: string | null;
|
||||
customBody: string | null;
|
||||
|
||||
/**
|
||||
* アプリ通知のheader
|
||||
* (省略時はアプリ名で表示されることを期待)
|
||||
*/
|
||||
@Column('varchar', {
|
||||
length: 256, nullable: true,
|
||||
})
|
||||
public customHeader: string | null;
|
||||
customHeader: string | null;
|
||||
|
||||
/**
|
||||
* アプリ通知のicon(URL)
|
||||
* (省略時はアプリアイコンで表示されることを期待)
|
||||
*/
|
||||
@Column('varchar', {
|
||||
length: 1024, nullable: true,
|
||||
})
|
||||
public customIcon: string | null;
|
||||
customIcon: string | null;
|
||||
|
||||
/**
|
||||
* アプリ通知のアプリ(のトークン)
|
||||
*/
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
})
|
||||
public appAccessTokenId: AccessToken['id'] | null;
|
||||
|
||||
@ManyToOne(type => AccessToken, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public appAccessToken: AccessToken | null;
|
||||
appAccessTokenId: AccessToken['id'] | null;
|
||||
}
|
||||
|
|
|
@ -32,7 +32,6 @@ import { NoteFavorite } from '@/models/entities/NoteFavorite.js';
|
|||
import { NoteReaction } from '@/models/entities/NoteReaction.js';
|
||||
import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js';
|
||||
import { NoteUnread } from '@/models/entities/NoteUnread.js';
|
||||
import { Notification } from '@/models/entities/Notification.js';
|
||||
import { Page } from '@/models/entities/Page.js';
|
||||
import { PageLike } from '@/models/entities/PageLike.js';
|
||||
import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js';
|
||||
|
@ -100,7 +99,6 @@ export {
|
|||
NoteReaction,
|
||||
NoteThreadMuting,
|
||||
NoteUnread,
|
||||
Notification,
|
||||
Page,
|
||||
PageLike,
|
||||
PasswordResetRequest,
|
||||
|
@ -167,7 +165,6 @@ export type NoteFavoritesRepository = Repository<NoteFavorite>;
|
|||
export type NoteReactionsRepository = Repository<NoteReaction>;
|
||||
export type NoteThreadMutingsRepository = Repository<NoteThreadMuting>;
|
||||
export type NoteUnreadsRepository = Repository<NoteUnread>;
|
||||
export type NotificationsRepository = Repository<Notification>;
|
||||
export type PagesRepository = Repository<Page>;
|
||||
export type PageLikesRepository = Repository<PageLike>;
|
||||
export type PasswordResetRequestsRepository = Repository<PasswordResetRequest>;
|
||||
|
|
|
@ -14,10 +14,6 @@ export const packedNotificationSchema = {
|
|||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
isRead: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
|
|
|
@ -40,7 +40,6 @@ import { NoteFavorite } from '@/models/entities/NoteFavorite.js';
|
|||
import { NoteReaction } from '@/models/entities/NoteReaction.js';
|
||||
import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js';
|
||||
import { NoteUnread } from '@/models/entities/NoteUnread.js';
|
||||
import { Notification } from '@/models/entities/Notification.js';
|
||||
import { Page } from '@/models/entities/Page.js';
|
||||
import { PageLike } from '@/models/entities/PageLike.js';
|
||||
import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js';
|
||||
|
@ -155,7 +154,6 @@ export const entities = [
|
|||
DriveFolder,
|
||||
Poll,
|
||||
PollVote,
|
||||
Notification,
|
||||
Emoji,
|
||||
Hashtag,
|
||||
SwSubscription,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, LessThan } from 'typeorm';
|
||||
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 Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
@ -20,9 +20,6 @@ export class CleanProcessorService {
|
|||
@Inject(DI.userIpsRepository)
|
||||
private userIpsRepository: UserIpsRepository,
|
||||
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
|
||||
@Inject(DI.mutedNotesRepository)
|
||||
private mutedNotesRepository: MutedNotesRepository,
|
||||
|
||||
|
@ -46,10 +43,6 @@ export class CleanProcessorService {
|
|||
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({
|
||||
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
|
||||
reason: 'word',
|
||||
|
|
|
@ -7,7 +7,7 @@ import { MetaService } from '@/core/MetaService.js';
|
|||
import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.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 InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import ApRequestChart from '@/core/chart/charts/ap-request.js';
|
||||
|
@ -22,7 +22,7 @@ import type { DeliverJobData } from '../types.js';
|
|||
@Injectable()
|
||||
export class DeliverProcessorService {
|
||||
private logger: Logger;
|
||||
private suspendedHostsCache: KVCache<Instance[]>;
|
||||
private suspendedHostsCache: MemoryCache<Instance[]>;
|
||||
private latest: string | null;
|
||||
|
||||
constructor(
|
||||
|
@ -46,7 +46,7 @@ export class DeliverProcessorService {
|
|||
private queueLoggerService: QueueLoggerService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('deliver');
|
||||
this.suspendedHostsCache = new KVCache<Instance[]>(1000 * 60 * 60);
|
||||
this.suspendedHostsCache = new MemoryCache<Instance[]>(1000 * 60 * 60);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -60,14 +60,14 @@ export class DeliverProcessorService {
|
|||
}
|
||||
|
||||
// isSuspendedなら中断
|
||||
let suspendedHosts = this.suspendedHostsCache.get(null);
|
||||
let suspendedHosts = this.suspendedHostsCache.get();
|
||||
if (suspendedHosts == null) {
|
||||
suspendedHosts = await this.instancesRepository.find({
|
||||
where: {
|
||||
isSuspended: true,
|
||||
},
|
||||
});
|
||||
this.suspendedHostsCache.set(null, suspendedHosts);
|
||||
this.suspendedHostsCache.set(suspendedHosts);
|
||||
}
|
||||
if (suspendedHosts.map(x => x.host).includes(this.utilityService.toPuny(host))) {
|
||||
return 'skip (suspended)';
|
||||
|
|
|
@ -4,7 +4,7 @@ import type { NotesRepository, UsersRepository } from '@/models/index.js';
|
|||
import type { Config } from '@/config.js';
|
||||
import { MetaService } from '@/core/MetaService.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 { bindThis } from '@/decorators.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) => {
|
||||
const base = await cache.fetch(null, () => nodeinfo2());
|
||||
const base = await cache.fetch(() => nodeinfo2());
|
||||
|
||||
reply.header('Cache-Control', 'public, max-age=600');
|
||||
return { version: '2.1', ...base };
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
|
|
|
@ -3,9 +3,9 @@ import { DI } from '@/di-symbols.js';
|
|||
import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { LocalUser } from '@/models/entities/User.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 { UserCacheService } from '@/core/UserCacheService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import isNativeToken from '@/misc/is-native-token.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
|
@ -18,7 +18,7 @@ export class AuthenticationError extends Error {
|
|||
|
||||
@Injectable()
|
||||
export class AuthenticateService {
|
||||
private appCache: KVCache<App>;
|
||||
private appCache: MemoryKVCache<App>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
|
@ -30,9 +30,9 @@ export class AuthenticateService {
|
|||
@Inject(DI.appsRepository)
|
||||
private appsRepository: AppsRepository,
|
||||
|
||||
private userCacheService: UserCacheService,
|
||||
private cacheService: CacheService,
|
||||
) {
|
||||
this.appCache = new KVCache<App>(Infinity);
|
||||
this.appCache = new MemoryKVCache<App>(Infinity);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -42,7 +42,7 @@ export class AuthenticateService {
|
|||
}
|
||||
|
||||
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>);
|
||||
|
||||
if (user == null) {
|
||||
|
@ -67,7 +67,7 @@ export class AuthenticateService {
|
|||
lastUsedAt: new Date(),
|
||||
});
|
||||
|
||||
const user = await this.userCacheService.localUserByIdCache.fetch(accessToken.userId,
|
||||
const user = await this.cacheService.localUserByIdCache.fetch(accessToken.userId,
|
||||
() => this.usersRepository.findOneBy({
|
||||
id: accessToken.userId,
|
||||
}) 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___notifications_create from './endpoints/notifications/create.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___pages_create from './endpoints/pages/create.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 $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_read: Provider = { provide: 'ep:notifications/read', useClass: ep___notifications_read.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_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,
|
||||
$notifications_create,
|
||||
$notifications_markAllAsRead,
|
||||
$notifications_read,
|
||||
$pagePush,
|
||||
$pages_create,
|
||||
$pages_delete,
|
||||
|
@ -1266,7 +1263,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$notes_userListTimeline,
|
||||
$notifications_create,
|
||||
$notifications_markAllAsRead,
|
||||
$notifications_read,
|
||||
$pagePush,
|
||||
$pages_create,
|
||||
$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___notifications_create from './endpoints/notifications/create.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___pages_create from './endpoints/pages/create.js';
|
||||
import * as ep___pages_delete from './endpoints/pages/delete.js';
|
||||
|
@ -598,7 +597,6 @@ const eps = [
|
|||
['notes/user-list-timeline', ep___notes_userListTimeline],
|
||||
['notifications/create', ep___notifications_create],
|
||||
['notifications/mark-all-as-read', ep___notifications_markAllAsRead],
|
||||
['notifications/read', ep___notifications_read],
|
||||
['page-push', ep___pagePush],
|
||||
['pages/create', ep___pages_create],
|
||||
['pages/delete', ep___pages_delete],
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
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 { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
|
@ -36,9 +36,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private userSuspendService: UserSuspendService,
|
||||
|
@ -73,7 +70,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
(async () => {
|
||||
await this.userSuspendService.doPostSuspend(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);
|
||||
}
|
||||
}
|
||||
|
||||
@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 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 { Endpoint } from '@/server/api/endpoint-base.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 { NotificationService } from '@/core/NotificationService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { Notification } from '@/models/entities/Notification.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['account', 'notifications'],
|
||||
|
@ -38,8 +41,6 @@ export const paramDef = {
|
|||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
sinceId: { 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 },
|
||||
// 後方互換のため、廃止された通知タイプも受け付ける
|
||||
includeTypes: { type: 'array', items: {
|
||||
|
@ -56,21 +57,22 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private notificationEntityService: NotificationEntityService,
|
||||
private notificationService: NotificationService,
|
||||
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 excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
||||
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: me.id });
|
||||
const notificationsRes = await this.redisClient.xrevrange(
|
||||
`notificationTimeline:${me.id}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
|
||||
'-',
|
||||
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
|
||||
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.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());
|
||||
if (notificationsRes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId) as Notification[];
|
||||
|
||||
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) {
|
||||
query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes });
|
||||
notifications = notifications.filter(notification => !excludeTypes.includes(notification.type));
|
||||
}
|
||||
|
||||
if (ps.unreadOnly) {
|
||||
query.andWhere('notification.isRead = false');
|
||||
if (notifications.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const notifications = await query.take(ps.limit).getMany();
|
||||
|
||||
// Mark all as read
|
||||
if (notifications.length > 0 && ps.markAsRead) {
|
||||
this.notificationService.readNotification(me.id, notifications.map(x => x.id));
|
||||
if (ps.markAsRead) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -34,7 +34,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
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 });
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ import { AccountUpdateService } from '@/core/AccountUpdateService.js';
|
|||
import { HashtagService } from '@/core/HashtagService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -152,6 +153,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
private accountUpdateService: AccountUpdateService,
|
||||
private hashtagService: HashtagService,
|
||||
private roleService: RoleService,
|
||||
private cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, _user, token) => {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: _user.id });
|
||||
|
@ -276,9 +278,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
includeSecrets: isSecure,
|
||||
});
|
||||
|
||||
const updatedProfile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
|
||||
this.cacheService.userProfileCache.set(user.id, updatedProfile);
|
||||
|
||||
// Publish meUpdated event
|
||||
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) {
|
||||
|
|
|
@ -7,6 +7,7 @@ import type { Muting } from '@/models/entities/Muting.js';
|
|||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -65,6 +66,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
private globalEventService: GlobalEventService,
|
||||
private getterService: GetterService,
|
||||
private idService: IdService,
|
||||
private cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const muter = me;
|
||||
|
@ -103,6 +105,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
muteeId: mutee.id,
|
||||
} as Muting);
|
||||
|
||||
this.cacheService.userMutingsCache.delete(muter.id);
|
||||
this.globalEventService.publishUserEvent(me.id, 'mute', mutee);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { NotificationsRepository } from '@/models/index.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 { NotificationService } from '@/core/NotificationService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notifications', 'account'],
|
||||
|
@ -23,24 +21,10 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
|
||||
private globalEventService: GlobalEventService,
|
||||
private pushNotificationService: PushNotificationService,
|
||||
private notificationService: NotificationService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// Update documents
|
||||
await this.notificationsRepository.update({
|
||||
notifieeId: me.id,
|
||||
isRead: false,
|
||||
}, {
|
||||
isRead: true,
|
||||
});
|
||||
|
||||
// 全ての通知を読みましたよというイベントを発行
|
||||
this.globalEventService.publishMainStream(me.id, 'readAllNotifications');
|
||||
this.pushNotificationService.pushNotification(me.id, 'readAllNotifications', undefined);
|
||||
this.notificationService.readAllNotification(me.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
muteeId: mutee.id,
|
||||
} as RenoteMuting);
|
||||
|
||||
// publishUserEvent(user.id, 'mute', mutee);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -195,8 +195,7 @@ export default class Connection {
|
|||
|
||||
@bindThis
|
||||
private onReadNotification(payload: any) {
|
||||
if (!payload.id) return;
|
||||
this.notificationService.readNotification(this.user!.id, [payload.id]);
|
||||
this.notificationService.readAllNotification(this.user!.id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -19,7 +19,7 @@ import type { EventEmitter } from 'events';
|
|||
//#region Stream type-body definitions
|
||||
export interface InternalStreamTypes {
|
||||
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']; };
|
||||
follow: { 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 { MetaService } from '@/core/MetaService.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 { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { sleep } from '../utils.js';
|
||||
|
@ -65,7 +65,7 @@ describe('RoleService', () => {
|
|||
],
|
||||
providers: [
|
||||
RoleService,
|
||||
UserCacheService,
|
||||
CacheService,
|
||||
IdService,
|
||||
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": {
|
||||
"watch": "vite",
|
||||
"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-and-coverage": "vitest --run --coverage",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
|
@ -71,8 +74,27 @@
|
|||
"vuedraggable": "next"
|
||||
},
|
||||
"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",
|
||||
"@types/escape-regexp": "0.0.1",
|
||||
"@types/estree": "^1.0.0",
|
||||
"@types/gulp": "4.0.10",
|
||||
"@types/gulp-rename": "2.0.1",
|
||||
"@types/matter-js": "0.18.2",
|
||||
|
@ -80,6 +102,7 @@
|
|||
"@types/punycode": "2.1.0",
|
||||
"@types/sanitize-html": "2.9.0",
|
||||
"@types/seedrandom": "3.0.5",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/throttle-debounce": "5.0.0",
|
||||
"@types/tinycolor2": "1.4.3",
|
||||
"@types/uuid": "9.0.1",
|
||||
|
@ -89,13 +112,24 @@
|
|||
"@typescript-eslint/parser": "5.57.0",
|
||||
"@vitest/coverage-c8": "^0.29.8",
|
||||
"@vue/runtime-core": "3.2.47",
|
||||
"astring": "^1.8.4",
|
||||
"chokidar-cli": "^3.0.0",
|
||||
"chromatic": "^6.17.2",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "12.9.0",
|
||||
"eslint": "8.37.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-vue": "9.10.0",
|
||||
"fast-glob": "^3.2.12",
|
||||
"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",
|
||||
"storybook": "7.0.2",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"vitest": "^0.29.8",
|
||||
"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 { MenuItem } from './types/menu.vue';
|
||||
import contains from '@/scripts/contains';
|
||||
import * as os from '@/os';
|
||||
import { defaultStore } from '@/store';
|
||||
import * as os from '@/os';
|
||||
|
||||
const props = defineProps<{
|
||||
items: MenuItem[];
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div>
|
||||
<div role="menu">
|
||||
<div
|
||||
ref="itemsEl" v-hotkey="keymap"
|
||||
class="_popup _shadow"
|
||||
|
@ -8,37 +8,37 @@
|
|||
@contextmenu.self="e => e.preventDefault()"
|
||||
>
|
||||
<template v-for="(item, i) in items2">
|
||||
<div v-if="item === null" :class="$style.divider"></div>
|
||||
<span v-else-if="item.type === 'label'" :class="[$style.label, $style.item]">
|
||||
<div v-if="item === null" role="separator" :class="$style.divider"></div>
|
||||
<span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]">
|
||||
<span>{{ item.text }}</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>
|
||||
<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>
|
||||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||
<span>{{ item.text }}</span>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
</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>
|
||||
<span>{{ item.text }}</span>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
</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"/>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
</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>
|
||||
</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>
|
||||
<span>{{ item.text }}</span>
|
||||
<span :class="$style.caret"><i class="ti ti-chevron-right ti-fw"></i></span>
|
||||
</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>
|
||||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||
<span>{{ item.text }}</span>
|
||||
|
|
|
@ -83,7 +83,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { ref, shallowRef } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
||||
import MkFollowButton from '@/components/MkFollowButton.vue';
|
||||
|
@ -94,7 +94,6 @@ import { notePage } from '@/filters/note';
|
|||
import { userPage } from '@/filters/user';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
import { stream } from '@/stream';
|
||||
import { useTooltip } from '@/scripts/use-tooltip';
|
||||
import { $i } from '@/account';
|
||||
|
||||
|
@ -110,35 +109,6 @@ const props = withDefaults(defineProps<{
|
|||
const elRef = shallowRef<HTMLElement>(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 acceptFollowRequest = () => {
|
||||
|
|
|
@ -29,7 +29,6 @@ import { notificationTypes } from '@/const';
|
|||
|
||||
const props = defineProps<{
|
||||
includeTypes?: typeof notificationTypes[number][];
|
||||
unreadOnly?: boolean;
|
||||
}>();
|
||||
|
||||
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
|
@ -40,23 +39,17 @@ const pagination: Paging = {
|
|||
params: computed(() => ({
|
||||
includeTypes: props.includeTypes ?? undefined,
|
||||
excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes,
|
||||
unreadOnly: props.unreadOnly,
|
||||
})),
|
||||
};
|
||||
|
||||
const onNotification = (notification) => {
|
||||
const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type);
|
||||
if (isMuted || document.visibilityState === 'visible') {
|
||||
stream.send('readNotification', {
|
||||
id: notification.id,
|
||||
});
|
||||
stream.send('readNotification');
|
||||
}
|
||||
|
||||
if (!isMuted) {
|
||||
pagingComponent.value.prepend({
|
||||
...notification,
|
||||
isRead: document.visibilityState === 'visible',
|
||||
});
|
||||
pagingComponent.value.prepend(notification);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -65,30 +58,6 @@ let connection;
|
|||
onMounted(() => {
|
||||
connection = stream.useChannel('main');
|
||||
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(() => {
|
||||
|
|
|
@ -12,7 +12,7 @@ import { onMounted } from 'vue';
|
|||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
maxHeight: number;
|
||||
maxHeight?: number;
|
||||
}>(), {
|
||||
maxHeight: 200,
|
||||
});
|
||||
|
|
|
@ -150,7 +150,7 @@ function adjustTweetHeight(message: any) {
|
|||
}
|
||||
|
||||
const openPlayer = (): void => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkYoutubePlayer.vue')), {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkYouTubePlayer.vue')), {
|
||||
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);
|
||||
</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>
|
||||
import { ref } from 'vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { instance } from '@/instance';
|
||||
import { host } from '@/config';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
import * as os from '@/os';
|
||||
import { $i } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
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%;
|
||||
height: 100%;
|
||||
padding: 50%;
|
||||
pointer-events: none;
|
||||
|
||||
&.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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
static?: boolean;
|
||||
}>(), {
|
||||
static: false,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
@keyframes ellipsis {
|
||||
0%, 80%, 100% {
|
||||
|
@ -15,7 +25,9 @@
|
|||
}
|
||||
|
||||
.root {
|
||||
|
||||
&.static > .dot {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
|
||||
.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;"/>
|
||||
</g>
|
||||
</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)">
|
||||
<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>
|
||||
|
@ -19,11 +19,13 @@
|
|||
import { } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
static?: boolean;
|
||||
inline?: boolean;
|
||||
colored?: boolean;
|
||||
mini?: boolean;
|
||||
em?: boolean;
|
||||
}>(), {
|
||||
static: false,
|
||||
inline: false,
|
||||
colored: true,
|
||||
mini: false,
|
||||
|
@ -97,5 +99,9 @@ const props = withDefaults(defineProps<{
|
|||
|
||||
.fg {
|
||||
animation: spinner 0.5s linear infinite;
|
||||
|
||||
&.static {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
</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">
|
||||
export type Tab = {
|
||||
key: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
iconOnly?: boolean;
|
||||
onClick?: (ev: MouseEvent) => void;
|
||||
} & {
|
||||
iconOnly: true;
|
||||
iccn: string;
|
||||
};
|
||||
} & (
|
||||
| {
|
||||
iconOnly?: false;
|
||||
title: string;
|
||||
icon?: string;
|
||||
}
|
||||
| {
|
||||
iconOnly: true;
|
||||
icon: string;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<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<{
|
||||
time: Date | string | number | null;
|
||||
origin?: Date | null;
|
||||
mode?: 'relative' | 'absolute' | 'detail';
|
||||
}>(), {
|
||||
origin: null,
|
||||
mode: 'relative',
|
||||
});
|
||||
|
||||
|
@ -25,7 +27,7 @@ const _time = props.time == null ? NaN :
|
|||
const invalid = Number.isNaN(_time);
|
||||
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>(() => {
|
||||
if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
|
||||
if (invalid) return i18n.ts._ago.invalid;
|
||||
|
@ -46,7 +48,7 @@ const relative = $computed<string>(() => {
|
|||
let tickId: number;
|
||||
|
||||
function tick() {
|
||||
now = (new Date()).getTime();
|
||||
now = props.origin ?? (new Date()).getTime();
|
||||
const ago = (now - _time) / 1000/*ms*/;
|
||||
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>
|
||||
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="800">
|
||||
<div v-if="tab === 'all' || tab === 'unread'">
|
||||
<XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/>
|
||||
<div v-if="tab === 'all'">
|
||||
<XNotifications class="notifications" :include-types="includeTypes"/>
|
||||
</div>
|
||||
<div v-else-if="tab === 'mentions'">
|
||||
<MkNotes :pagination="mentionsPagination"/>
|
||||
|
@ -26,7 +26,6 @@ import { notificationTypes } from '@/const';
|
|||
|
||||
let tab = $ref('all');
|
||||
let includeTypes = $ref<string[] | null>(null);
|
||||
let unreadOnly = $computed(() => tab === 'unread');
|
||||
|
||||
const mentionsPagination = {
|
||||
endpoint: 'notes/mentions' as const,
|
||||
|
@ -76,10 +75,6 @@ const headerTabs = $computed(() => [{
|
|||
key: 'all',
|
||||
title: i18n.ts.all,
|
||||
icon: 'ti ti-point',
|
||||
}, {
|
||||
key: 'unread',
|
||||
title: i18n.ts.unread,
|
||||
icon: 'ti ti-loader',
|
||||
}, {
|
||||
key: 'mentions',
|
||||
title: i18n.ts.mentions,
|
||||
|
|
|
@ -77,7 +77,10 @@ async function renderChart() {
|
|||
barPercentage: 0.7,
|
||||
categoryPercentage: 0.7,
|
||||
fill: true,
|
||||
} satisfies ChartDataset, extra);
|
||||
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||
} satisfies ChartData, extra);
|
||||
*/
|
||||
}, extra);
|
||||
}
|
||||
|
||||
chartInstance = new Chart(chartEl, {
|
||||
|
|
|
@ -113,6 +113,9 @@ async function renderChart() {
|
|||
const a = c.chart.chartArea ?? {};
|
||||
return (a.bottom - a.top) / 7 - marginEachCell;
|
||||
},
|
||||
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||
}] satisfies ChartData[],
|
||||
*/
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
|
|
|
@ -76,7 +76,10 @@ async function renderChart() {
|
|||
borderRadius: 4,
|
||||
barPercentage: 0.9,
|
||||
fill: true,
|
||||
} satisfies ChartDataset, extra);
|
||||
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||
} satisfies ChartData, extra);
|
||||
*/
|
||||
}, extra);
|
||||
}
|
||||
|
||||
chartInstance = new Chart(chartEl, {
|
||||
|
|
|
@ -77,7 +77,10 @@ async function renderChart() {
|
|||
barPercentage: 0.7,
|
||||
categoryPercentage: 0.7,
|
||||
fill: true,
|
||||
} satisfies ChartDataset, extra);
|
||||
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||
} satisfies ChartData, extra);
|
||||
*/
|
||||
}, extra);
|
||||
}
|
||||
|
||||
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