0
0

Merge branch 'develop' into fix/visibility-widening

This commit is contained in:
Acid Chicken (硫酸鶏) 2023-04-05 00:41:49 +09:00 committed by GitHub
commit 7bd0001e76
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
111 changed files with 8019 additions and 1091 deletions

56
.github/workflows/storybook.yml vendored Normal file
View 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
View File

@ -56,6 +56,7 @@ api-docs.json
/files
ormconfig.json
temp
/packages/frontend/src/**/*.stories.ts
# blender backups
*.blend1

View File

@ -34,6 +34,7 @@
- ノート作成時のパフォーマンスを向上
- アンテナのタイムライン取得時のパフォーマンスを向上
- チャンネルのタイムライン取得時のパフォーマンスを向上
- 通知に関する全体的なパフォーマンスを向上
## 13.10.3

View File

@ -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?

View File

@ -0,0 +1,11 @@
export class cleanup1680582195041 {
name = 'cleanup1680582195041'
async up(queryRunner) {
await queryRunner.query(`DROP TABLE "notification" `);
}
async down(queryRunner) {
}
}

View File

@ -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 }));
}

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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;
}
}

View File

@ -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,
},

View File

@ -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)]),
});
}
}

View File

@ -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);

View File

@ -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: {

View File

@ -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;

View File

@ -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)),

View File

@ -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);
}

View File

@ -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

View File

@ -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,
};
}

View File

@ -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

View File

@ -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,
})));
}
}

View File

@ -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

View File

@ -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'),

View File

@ -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;
}

View File

@ -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,

View File

@ -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;
}

View File

@ -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>;

View File

@ -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,

View File

@ -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,

View File

@ -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',

View File

@ -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)';

View File

@ -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;

View File

@ -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>);

View File

@ -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,

View File

@ -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],

View File

@ -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,
});
}
}

View File

@ -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);
}

View File

@ -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 });

View File

@ -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) {

View File

@ -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);
});
}

View File

@ -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);
});
}
}

View File

@ -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);
});
}
}

View File

@ -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);
});
}
}

View File

@ -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);
}
/**

View File

@ -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']; };

View File

@ -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
View File

@ -0,0 +1 @@
/storybook-static

View 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

View 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

View 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));
})
)
);

View 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;

File diff suppressed because one or more lines are too long

View 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));
}),
];

View 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',
)

View 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'
);
});

View 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>

View 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;

View 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"]
}

View File

@ -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",

View 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)
}

View File

@ -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>;

View 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>;

View File

@ -0,0 +1,2 @@
import MkCaptcha from './MkCaptcha.vue';
void MkCaptcha;

View File

@ -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[];

View File

@ -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>

View File

@ -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 = () => {

View File

@ -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(() => {

View File

@ -12,7 +12,7 @@ import { onMounted } from 'vue';
import { i18n } from '@/i18n';
const props = withDefaults(defineProps<{
maxHeight: number;
maxHeight?: number;
}>(), {
maxHeight: 200,
});

View File

@ -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,
});
};

View 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>;

View File

@ -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>;

View File

@ -18,4 +18,3 @@ defineProps<{
const host = toUnicode(hostRaw);
</script>

View 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>;

View File

@ -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];

View File

@ -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 couldnt 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>;

View File

@ -148,6 +148,7 @@ watch(() => props.user.avatarBlurhash, () => {
width: 100%;
height: 100%;
padding: 50%;
pointer-events: none;
&.mask {
-webkit-mask:

View File

@ -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>;

View File

@ -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>;

View File

@ -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 {

View File

@ -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>;

View File

@ -0,0 +1,5 @@
export const argTypes = {
retry: {
action: 'retry',
},
};

View File

@ -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>;

View File

@ -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>

View File

@ -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>;

View File

@ -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>;

View File

@ -0,0 +1,3 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import MkPageHeader_tabs from './MkPageHeader.tabs.vue';
void MkPageHeader_tabs;

View File

@ -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>

View File

@ -0,0 +1,3 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import MkStickyContainer from './MkStickyContainer.vue';
void MkStickyContainer;

View 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>;

View File

@ -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 ''; // absoluterelative使
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;

View File

@ -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>;

View File

@ -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>;

View File

@ -0,0 +1,3 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import RouterView from './RouterView.vue';
void RouterView;

View 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!

View File

@ -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,

View File

@ -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, {

View File

@ -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: {

View File

@ -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, {

View File

@ -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