Merge branch 'develop' into img-max
This commit is contained in:
commit
73d43f4d59
56
.github/workflows/storybook.yml
vendored
Normal file
56
.github/workflows/storybook.yml
vendored
Normal file
|
@ -0,0 +1,56 @@
|
|||
name: Storybook
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
pull_request_target:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3.3.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 7
|
||||
run_install: false
|
||||
- name: Use Node.js 18.x
|
||||
uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: 18.x
|
||||
cache: 'pnpm'
|
||||
- run: corepack enable
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- name: Check pnpm-lock.yaml
|
||||
run: git diff --exit-code pnpm-lock.yaml
|
||||
- name: Build misskey-js
|
||||
run: pnpm --filter misskey-js build
|
||||
- name: Build storybook
|
||||
run: pnpm --filter frontend build-storybook
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=7168"
|
||||
- name: Publish to Chromatic
|
||||
id: chromatic
|
||||
uses: chromaui/action@v1
|
||||
with:
|
||||
exitOnceUploaded: true
|
||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
storybookBuildDir: storybook-static
|
||||
workingDir: packages/frontend
|
||||
- name: Compare on Chromatic
|
||||
if: github.event_name == 'pull_request_target'
|
||||
run: pnpm --filter frontend chromatic -d storybook-static --exit-once-uploaded --patch-build ${{ github.head_ref }}...${{ github.base_ref }}
|
||||
env:
|
||||
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: storybook
|
||||
path: packages/frontend/storybook-static
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -56,6 +56,7 @@ api-docs.json
|
|||
/files
|
||||
ormconfig.json
|
||||
temp
|
||||
/packages/frontend/src/**/*.stories.ts
|
||||
|
||||
# blender backups
|
||||
*.blend1
|
||||
|
|
12
CHANGELOG.md
12
CHANGELOG.md
|
@ -21,9 +21,19 @@
|
|||
### Client
|
||||
- 検索ページでURLを入力した際に照会したときと同等の挙動をするように
|
||||
- ノートのリアクションを大きく表示するオプションを追加
|
||||
- オブジェクトストレージの設定画面を分かりやすく
|
||||
- 「にゃああああああああああああああ!!!!!!!!!!!!」 (`isCat`) 有効時にアバターに表示される猫耳について挙動を変更
|
||||
- 「UIにぼかし効果を使用」 (`useBlurEffect`) で次の挙動が有効になります
|
||||
- 猫耳のアバター内部部分をぼかしでマスク表示してより猫耳っぽく見えるように
|
||||
- 猫耳の色がアバター上部のピクセルから決定されます(無効化時はアバター全体の平均色)
|
||||
- 左耳は上からおよそ 10%, 左からおよそ 20% の位置で決定します
|
||||
- 右耳は上からおよそ 10%, 左からおよそ 80% の位置で決定します
|
||||
- 「UIのアニメーションを減らす」 (`reduceAnimation`) で猫耳を撫でられなくなります
|
||||
|
||||
### Server
|
||||
-
|
||||
- ノート作成時のパフォーマンスを向上
|
||||
- アンテナのタイムライン取得時のパフォーマンスを向上
|
||||
- チャンネルのタイムライン取得時のパフォーマンスを向上
|
||||
|
||||
## 13.10.3
|
||||
|
||||
|
|
110
CONTRIBUTING.md
110
CONTRIBUTING.md
|
@ -203,6 +203,116 @@ niraxは、Misskeyで使用しているオリジナルのフロントエンド
|
|||
vue-routerとの最大の違いは、niraxは複数のルーターが存在することを許可している点です。
|
||||
これにより、アプリ内ウィンドウでブラウザとは個別にルーティングすることなどが可能になります。
|
||||
|
||||
## Storybook
|
||||
|
||||
Misskey uses [Storybook](https://storybook.js.org/) for UI development.
|
||||
|
||||
### Setup & Run
|
||||
|
||||
#### Universal
|
||||
|
||||
##### Setup
|
||||
|
||||
```bash
|
||||
pnpm --filter misskey-js build
|
||||
pnpm --filter frontend tsc -p .storybook && (node packages/frontend/.storybook/preload-locale.js & node packages/frontend/.storybook/preload-theme.js)
|
||||
```
|
||||
|
||||
##### Run
|
||||
|
||||
```bash
|
||||
node packages/frontend/.storybook/generate.js && pnpm --filter frontend storybook dev
|
||||
```
|
||||
|
||||
#### macOS & Linux
|
||||
|
||||
##### Setup
|
||||
|
||||
```bash
|
||||
pnpm --filter misskey-js build
|
||||
```
|
||||
|
||||
##### Run
|
||||
|
||||
```bash
|
||||
pnpm --filter frontend storybook-dev
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
When you create a new component (in this example, `MyComponent.vue`), the story file (`MyComponent.stories.ts`) will be automatically generated by the `.storybook/generate.js` script.
|
||||
You can override the default story by creating a impl story file (`MyComponent.stories.impl.ts`).
|
||||
|
||||
```ts
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-duplicates */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MyComponent from './MyComponent.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MyComponent,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MyComponent v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
foo: 'bar',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAvatar>;
|
||||
```
|
||||
|
||||
If you want to opt-out from the automatic generation, create a `MyComponent.stories.impl.ts` file and add the following line to the file.
|
||||
|
||||
```ts
|
||||
import MyComponent from './MyComponent.vue';
|
||||
void MyComponent;
|
||||
```
|
||||
|
||||
You can override the component meta by creating a meta story file (`MyComponent.stories.meta.ts`).
|
||||
|
||||
```ts
|
||||
export const argTypes = {
|
||||
scale: {
|
||||
control: {
|
||||
type: 'range',
|
||||
min: 1,
|
||||
max: 4,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Also, you can use msw to mock API requests in the storybook. Creating a `MyComponent.stories.msw.ts` file to define the mock handlers.
|
||||
|
||||
```ts
|
||||
import { rest } from 'msw';
|
||||
export const handlers = [
|
||||
rest.post('/api/notes/timeline', (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.json([]),
|
||||
);
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
Don't forget to re-run the `.storybook/generate.js` script after adding, editing, or removing the above files.
|
||||
|
||||
## Notes
|
||||
### How to resolve conflictions occurred at pnpm-lock.yaml?
|
||||
|
||||
|
|
|
@ -506,6 +506,7 @@ objectStorageUseSSLDesc: "API接続にhttpsを使用しない場合はオフに
|
|||
objectStorageUseProxy: "Proxyを利用する"
|
||||
objectStorageUseProxyDesc: "API接続にproxyを利用しない場合はオフにしてください"
|
||||
objectStorageSetPublicRead: "アップロード時に'public-read'を設定する"
|
||||
s3ForcePathStyleDesc: "s3ForcePathStyleを有効にすると、バケット名をURLのホスト名ではなくパスの一部として指定することを強制します。セルフホストされたMinioなどの使用時に有効にする必要がある場合があります。"
|
||||
serverLogs: "サーバーログ"
|
||||
deleteAll: "全て削除"
|
||||
showFixedPostForm: "タイムライン上部に投稿フォームを表示する"
|
||||
|
|
10
packages/backend/migration/1680491187535-cleanup.js
Normal file
10
packages/backend/migration/1680491187535-cleanup.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
export class cleanup1680491187535 {
|
||||
name = 'cleanup1680491187535'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DROP TABLE "antenna_note" `);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@ import { PushNotificationService } from '@/core/PushNotificationService.js';
|
|||
import * as Acct from '@/misc/acct.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MutingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserListJoiningsRepository } from '@/models/index.js';
|
||||
import type { MutingsRepository, NotesRepository, AntennasRepository, UserListJoiningsRepository } from '@/models/index.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { StreamMessages } from '@/server/api/stream/types.js';
|
||||
|
@ -24,6 +24,9 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
private antennas: Antenna[];
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.redisSubscriber)
|
||||
private redisSubscriber: Redis.Redis,
|
||||
|
||||
|
@ -33,9 +36,6 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.antennaNotesRepository)
|
||||
private antennaNotesRepository: AntennaNotesRepository,
|
||||
|
||||
@Inject(DI.antennasRepository)
|
||||
private antennasRepository: AntennasRepository,
|
||||
|
||||
|
@ -92,54 +92,13 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise<void> {
|
||||
// 通知しない設定になっているか、自分自身の投稿なら既読にする
|
||||
const read = !antenna.notify || (antenna.userId === noteUser.id);
|
||||
|
||||
this.antennaNotesRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
antennaId: antenna.id,
|
||||
noteId: note.id,
|
||||
read: read,
|
||||
});
|
||||
this.redisClient.xadd(
|
||||
`antennaTimeline:${antenna.id}`,
|
||||
'MAXLEN', '~', '200',
|
||||
`${this.idService.parse(note.id).date.getTime()}-*`,
|
||||
'note', note.id);
|
||||
|
||||
this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
|
||||
|
||||
if (!read) {
|
||||
const mutings = await this.mutingsRepository.find({
|
||||
where: {
|
||||
muterId: antenna.userId,
|
||||
},
|
||||
select: ['muteeId'],
|
||||
});
|
||||
|
||||
// Copy
|
||||
const _note: Note = {
|
||||
...note,
|
||||
};
|
||||
|
||||
if (note.replyId != null) {
|
||||
_note.reply = await this.notesRepository.findOneByOrFail({ id: note.replyId });
|
||||
}
|
||||
if (note.renoteId != null) {
|
||||
_note.renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId });
|
||||
}
|
||||
|
||||
if (isUserRelated(_note, new Set<string>(mutings.map(x => x.muteeId)))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2秒経っても既読にならなかったら通知
|
||||
setTimeout(async () => {
|
||||
const unread = await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false });
|
||||
if (unread) {
|
||||
this.globalEventService.publishMainStream(antenna.userId, 'unreadAntenna', antenna);
|
||||
this.pushNotificationService.pushNotification(antenna.userId, 'unreadAntennaNote', {
|
||||
antenna: { id: antenna.id, name: antenna.name },
|
||||
note: await this.noteEntityService.pack(note),
|
||||
});
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import { ulid } from 'ulid';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { genAid } from '@/misc/id/aid.js';
|
||||
import { genAid, parseAid } from '@/misc/id/aid.js';
|
||||
import { genMeid } from '@/misc/id/meid.js';
|
||||
import { genMeidg } from '@/misc/id/meidg.js';
|
||||
import { genObjectId } from '@/misc/id/object-id.js';
|
||||
|
@ -32,4 +32,17 @@ export class IdService {
|
|||
default: throw new Error('unrecognized id generation method');
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public parse(id: string): { date: Date; } {
|
||||
switch (this.method) {
|
||||
case 'aid': return parseAid(id);
|
||||
// TODO
|
||||
//case 'meid':
|
||||
//case 'meidg':
|
||||
//case 'ulid':
|
||||
//case 'objectid':
|
||||
default: throw new Error('unrecognized id generation method');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { setImmediate } from 'node:timers/promises';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { In, DataSource } from 'typeorm';
|
||||
import Redis from 'ioredis';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import { extractMentions } from '@/misc/extract-mentions.js';
|
||||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||
|
@ -150,6 +151,9 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
|
@ -321,6 +325,14 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
|
||||
|
||||
if (data.channel) {
|
||||
this.redisClient.xadd(
|
||||
`channelTimeline:${data.channel.id}`,
|
||||
'MAXLEN', '~', '1000',
|
||||
`${this.idService.parse(note.id).date.getTime()}-*`,
|
||||
'note', note.id);
|
||||
}
|
||||
|
||||
setImmediate('post created', { signal: this.#shutdownController.signal }).then(
|
||||
() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
|
||||
() => { /* aborted, ignore this */ },
|
||||
|
|
|
@ -8,7 +8,7 @@ import type { Packed } from '@/misc/json-schema.js';
|
|||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { UsersRepository, NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository, FollowingsRepository, ChannelFollowingsRepository, AntennaNotesRepository } from '@/models/index.js';
|
||||
import type { UsersRepository, NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository, FollowingsRepository, ChannelFollowingsRepository } from '@/models/index.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
|
@ -38,9 +38,6 @@ export class NoteReadService implements OnApplicationShutdown {
|
|||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.antennaNotesRepository)
|
||||
private antennaNotesRepository: AntennaNotesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
|
@ -121,7 +118,6 @@ export class NoteReadService implements OnApplicationShutdown {
|
|||
const readMentions: (Note | Packed<'Note'>)[] = [];
|
||||
const readSpecifiedNotes: (Note | Packed<'Note'>)[] = [];
|
||||
const readChannelNotes: (Note | Packed<'Note'>)[] = [];
|
||||
const readAntennaNotes: (Note | Packed<'Note'>)[] = [];
|
||||
|
||||
for (const note of notes) {
|
||||
if (note.mentions && note.mentions.includes(userId)) {
|
||||
|
@ -133,14 +129,6 @@ export class NoteReadService implements OnApplicationShutdown {
|
|||
if (note.channelId && followingChannels.has(note.channelId)) {
|
||||
readChannelNotes.push(note);
|
||||
}
|
||||
|
||||
if (note.user != null) { // たぶんnullになることは無いはずだけど一応
|
||||
for (const antenna of myAntennas) {
|
||||
if (await this.antennaService.checkHitAntenna(antenna, note, note.user)) {
|
||||
readAntennaNotes.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) {
|
||||
|
@ -186,35 +174,6 @@ export class NoteReadService implements OnApplicationShutdown {
|
|||
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
|
||||
});
|
||||
}
|
||||
|
||||
if (readAntennaNotes.length > 0) {
|
||||
await this.antennaNotesRepository.update({
|
||||
antennaId: In(myAntennas.map(a => a.id)),
|
||||
noteId: In(readAntennaNotes.map(n => n.id)),
|
||||
}, {
|
||||
read: true,
|
||||
});
|
||||
|
||||
// TODO: まとめてクエリしたい
|
||||
for (const antenna of myAntennas) {
|
||||
const count = await this.antennaNotesRepository.countBy({
|
||||
antennaId: antenna.id,
|
||||
read: false,
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
this.globalEventService.publishMainStream(userId, 'readAntenna', antenna);
|
||||
this.pushNotificationService.pushNotification(userId, 'readAntenna', { antennaId: antenna.id });
|
||||
}
|
||||
}
|
||||
|
||||
this.userEntityService.getHasUnreadAntenna(userId).then(unread => {
|
||||
if (!unread) {
|
||||
this.globalEventService.publishMainStream(userId, 'readAllAntennas');
|
||||
this.pushNotificationService.pushNotification(userId, 'readAllAntennas', undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onApplicationShutdown(signal?: string | undefined): void {
|
||||
|
|
|
@ -96,6 +96,7 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
|
||||
const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId });
|
||||
|
||||
// TODO: Cache
|
||||
const isMuted = profile?.mutingNotificationTypes.includes(type);
|
||||
|
||||
// Create notification
|
||||
|
@ -122,6 +123,7 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
if (fresh.isRead) return;
|
||||
|
||||
//#region ただしミュートしているユーザーからの通知なら無視
|
||||
// TODO: Cache
|
||||
const mutings = await this.mutingsRepository.findBy({
|
||||
muterId: notifieeId,
|
||||
});
|
||||
|
|
|
@ -37,7 +37,7 @@ export class VideoProcessingService {
|
|||
});
|
||||
});
|
||||
|
||||
return await this.imageProcessingService.convertToWebp(`${dir}/out.png`, 498, 280);
|
||||
return await this.imageProcessingService.convertToWebp(`${dir}/out.png`, 498, 422);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { AntennaNotesRepository, AntennasRepository } from '@/models/index.js';
|
||||
import type { AntennasRepository } from '@/models/index.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { Antenna } from '@/models/entities/Antenna.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
@ -10,9 +10,6 @@ export class AntennaEntityService {
|
|||
constructor(
|
||||
@Inject(DI.antennasRepository)
|
||||
private antennasRepository: AntennasRepository,
|
||||
|
||||
@Inject(DI.antennaNotesRepository)
|
||||
private antennaNotesRepository: AntennaNotesRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -22,8 +19,6 @@ export class AntennaEntityService {
|
|||
): Promise<Packed<'Antenna'>> {
|
||||
const antenna = typeof src === 'object' ? src : await this.antennasRepository.findOneByOrFail({ id: src });
|
||||
|
||||
const hasUnreadNote = (await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false })) != null;
|
||||
|
||||
return {
|
||||
id: antenna.id,
|
||||
createdAt: antenna.createdAt.toISOString(),
|
||||
|
@ -38,7 +33,7 @@ export class AntennaEntityService {
|
|||
withReplies: antenna.withReplies,
|
||||
withFile: antenna.withFile,
|
||||
isActive: antenna.isActive,
|
||||
hasUnreadNote,
|
||||
hasUnreadNote: false, // TODO
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import { KVCache } 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, AntennaNotesRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.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 { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
|
@ -108,9 +108,6 @@ export class UserEntityService implements OnModuleInit {
|
|||
@Inject(DI.announcementsRepository)
|
||||
private announcementsRepository: AnnouncementsRepository,
|
||||
|
||||
@Inject(DI.antennaNotesRepository)
|
||||
private antennaNotesRepository: AntennaNotesRepository,
|
||||
|
||||
@Inject(DI.pagesRepository)
|
||||
private pagesRepository: PagesRepository,
|
||||
|
||||
|
@ -223,6 +220,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
|
||||
@bindThis
|
||||
public async getHasUnreadAntenna(userId: User['id']): Promise<boolean> {
|
||||
/*
|
||||
const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId);
|
||||
|
||||
const unread = myAntennas.length > 0 ? await this.antennaNotesRepository.findOneBy({
|
||||
|
@ -231,6 +229,8 @@ export class UserEntityService implements OnModuleInit {
|
|||
}) : null;
|
||||
|
||||
return unread != null;
|
||||
*/
|
||||
return false; // TODO
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -54,7 +54,6 @@ export const DI = {
|
|||
clipNotesRepository: Symbol('clipNotesRepository'),
|
||||
clipFavoritesRepository: Symbol('clipFavoritesRepository'),
|
||||
antennasRepository: Symbol('antennasRepository'),
|
||||
antennaNotesRepository: Symbol('antennaNotesRepository'),
|
||||
promoNotesRepository: Symbol('promoNotesRepository'),
|
||||
promoReadsRepository: Symbol('promoReadsRepository'),
|
||||
relaysRepository: Symbol('relaysRepository'),
|
||||
|
|
|
@ -23,3 +23,8 @@ export function genAid(date: Date): string {
|
|||
counter++;
|
||||
return getTime(t) + getNoise();
|
||||
}
|
||||
|
||||
export function parseAid(id: string): { date: Date; } {
|
||||
const time = parseInt(id.slice(0, 8), 36) + TIME2000;
|
||||
return { date: new Date(time) };
|
||||
}
|
||||
|
|
|
@ -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, AntennaNote, 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, 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 type { DataSource } from 'typeorm';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
||||
|
@ -298,12 +298,6 @@ const $antennasRepository: Provider = {
|
|||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $antennaNotesRepository: Provider = {
|
||||
provide: DI.antennaNotesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(AntennaNote),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $promoNotesRepository: Provider = {
|
||||
provide: DI.promoNotesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(PromoNote),
|
||||
|
@ -453,7 +447,6 @@ const $roleAssignmentsRepository: Provider = {
|
|||
$clipNotesRepository,
|
||||
$clipFavoritesRepository,
|
||||
$antennasRepository,
|
||||
$antennaNotesRepository,
|
||||
$promoNotesRepository,
|
||||
$promoReadsRepository,
|
||||
$relaysRepository,
|
||||
|
@ -521,7 +514,6 @@ const $roleAssignmentsRepository: Provider = {
|
|||
$clipNotesRepository,
|
||||
$clipFavoritesRepository,
|
||||
$antennasRepository,
|
||||
$antennaNotesRepository,
|
||||
$promoNotesRepository,
|
||||
$promoReadsRepository,
|
||||
$relaysRepository,
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm';
|
||||
import { id } from '../id.js';
|
||||
import { Note } from './Note.js';
|
||||
import { Antenna } from './Antenna.js';
|
||||
|
||||
@Entity()
|
||||
@Index(['noteId', 'antennaId'], { unique: true })
|
||||
export class AntennaNote {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The note ID.',
|
||||
})
|
||||
public noteId: Note['id'];
|
||||
|
||||
@ManyToOne(type => Note, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public note: Note | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The antenna ID.',
|
||||
})
|
||||
public antennaId: Antenna['id'];
|
||||
|
||||
@ManyToOne(type => Antenna, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public antenna: Antenna | null;
|
||||
|
||||
@Index()
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public read: boolean;
|
||||
}
|
|
@ -4,7 +4,6 @@ import { Ad } from '@/models/entities/Ad.js';
|
|||
import { Announcement } from '@/models/entities/Announcement.js';
|
||||
import { AnnouncementRead } from '@/models/entities/AnnouncementRead.js';
|
||||
import { Antenna } from '@/models/entities/Antenna.js';
|
||||
import { AntennaNote } from '@/models/entities/AntennaNote.js';
|
||||
import { App } from '@/models/entities/App.js';
|
||||
import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js';
|
||||
import { AuthSession } from '@/models/entities/AuthSession.js';
|
||||
|
@ -73,7 +72,6 @@ export {
|
|||
Announcement,
|
||||
AnnouncementRead,
|
||||
Antenna,
|
||||
AntennaNote,
|
||||
App,
|
||||
AttestationChallenge,
|
||||
AuthSession,
|
||||
|
@ -141,7 +139,6 @@ export type AdsRepository = Repository<Ad>;
|
|||
export type AnnouncementsRepository = Repository<Announcement>;
|
||||
export type AnnouncementReadsRepository = Repository<AnnouncementRead>;
|
||||
export type AntennasRepository = Repository<Antenna>;
|
||||
export type AntennaNotesRepository = Repository<AntennaNote>;
|
||||
export type AppsRepository = Repository<App>;
|
||||
export type AttestationChallengesRepository = Repository<AttestationChallenge>;
|
||||
export type AuthSessionsRepository = Repository<AuthSession>;
|
||||
|
|
|
@ -12,7 +12,6 @@ import { Ad } from '@/models/entities/Ad.js';
|
|||
import { Announcement } from '@/models/entities/Announcement.js';
|
||||
import { AnnouncementRead } from '@/models/entities/AnnouncementRead.js';
|
||||
import { Antenna } from '@/models/entities/Antenna.js';
|
||||
import { AntennaNote } from '@/models/entities/AntennaNote.js';
|
||||
import { App } from '@/models/entities/App.js';
|
||||
import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js';
|
||||
import { AuthSession } from '@/models/entities/AuthSession.js';
|
||||
|
@ -168,7 +167,6 @@ export const entities = [
|
|||
ClipNote,
|
||||
ClipFavorite,
|
||||
Antenna,
|
||||
AntennaNote,
|
||||
PromoNote,
|
||||
PromoRead,
|
||||
Relay,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, LessThan } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { AntennaNotesRepository, AntennasRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js';
|
||||
import type { AntennasRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
@ -29,9 +29,6 @@ export class CleanProcessorService {
|
|||
@Inject(DI.antennasRepository)
|
||||
private antennasRepository: AntennasRepository,
|
||||
|
||||
@Inject(DI.antennaNotesRepository)
|
||||
private antennaNotesRepository: AntennaNotesRepository,
|
||||
|
||||
@Inject(DI.roleAssignmentsRepository)
|
||||
private roleAssignmentsRepository: RoleAssignmentsRepository,
|
||||
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { NotesRepository, AntennaNotesRepository, AntennasRepository } from '@/models/index.js';
|
||||
import type { NotesRepository, AntennasRepository } from '@/models/index.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { NoteReadService } from '@/core/NoteReadService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -50,15 +52,16 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.antennasRepository)
|
||||
private antennasRepository: AntennasRepository,
|
||||
|
||||
@Inject(DI.antennaNotesRepository)
|
||||
private antennaNotesRepository: AntennaNotesRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private noteReadService: NoteReadService,
|
||||
|
@ -73,9 +76,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
throw new ApiError(meta.errors.noSuchAntenna);
|
||||
}
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.innerJoin(this.antennaNotesRepository.metadata.targetName, 'antennaNote', 'antennaNote.noteId = note.id')
|
||||
const noteIdsRes = await this.redisClient.xrevrange(
|
||||
`antennaTimeline:${antenna.id}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
|
||||
'-',
|
||||
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
|
||||
|
||||
if (noteIdsRes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
|
@ -86,16 +104,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
|
||||
.andWhere('antennaNote.antennaId = :antennaId', { antennaId: antenna.id });
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateMutedUserQuery(query, me);
|
||||
this.queryService.generateBlockedUserQuery(query, me);
|
||||
|
||||
const notes = await query
|
||||
.take(ps.limit)
|
||||
.getMany();
|
||||
const notes = await query.getMany();
|
||||
notes.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
|
||||
if (notes.length > 0) {
|
||||
this.noteReadService.read(me.id, notes);
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { ChannelsRepository, NotesRepository } from '@/models/index.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -48,12 +50,16 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
|
@ -67,9 +73,25 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
const noteIdsRes = await this.redisClient.xrevrange(
|
||||
`channelTimeline:${channel.id}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
|
||||
'-',
|
||||
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
|
||||
|
||||
if (noteIdsRes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
//#region Construct query
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.channelId = :channelId', { channelId: channel.id })
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
|
@ -90,7 +112,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
}
|
||||
//#endregion
|
||||
|
||||
const timeline = await query.take(ps.limit).getMany();
|
||||
const timeline = await query.getMany();
|
||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
|
||||
if (me) this.activeUsersChart.read(me);
|
||||
|
||||
|
|
1
packages/frontend/.gitignore
vendored
Normal file
1
packages/frontend/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/storybook-static
|
9
packages/frontend/.storybook/.gitignore
vendored
Normal file
9
packages/frontend/.storybook/.gitignore
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
# (cd path/to/frontend; pnpm tsc -p .storybook)
|
||||
# (cd path/to/frontend; node .storybook/generate.js)
|
||||
/generate.js
|
||||
# (cd path/to/frontend; node .storybook/preload-locale.js)
|
||||
/preload-locale.js
|
||||
/locale.ts
|
||||
# (cd path/to/frontend; node .storybook/preload-theme.js)
|
||||
/preload-theme.js
|
||||
/themes.ts
|
54
packages/frontend/.storybook/fakes.ts
Normal file
54
packages/frontend/.storybook/fakes.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import type { entities } from 'misskey-js'
|
||||
|
||||
export const userDetailed = {
|
||||
id: 'someuserid',
|
||||
username: 'miskist',
|
||||
host: 'misskey-hub.net',
|
||||
name: 'Misskey User',
|
||||
onlineStatus: 'unknown',
|
||||
avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
|
||||
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
|
||||
emojis: [],
|
||||
bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog',
|
||||
bannerColor: '#000000',
|
||||
bannerUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
|
||||
birthday: '2014-06-20',
|
||||
createdAt: '2016-12-28T22:49:51.000Z',
|
||||
description: 'I am a cool user!',
|
||||
ffVisibility: 'public',
|
||||
fields: [
|
||||
{
|
||||
name: 'Website',
|
||||
value: 'https://misskey-hub.net',
|
||||
},
|
||||
],
|
||||
followersCount: 1024,
|
||||
followingCount: 16,
|
||||
hasPendingFollowRequestFromYou: false,
|
||||
hasPendingFollowRequestToYou: false,
|
||||
isAdmin: false,
|
||||
isBlocked: false,
|
||||
isBlocking: false,
|
||||
isBot: false,
|
||||
isCat: false,
|
||||
isFollowed: false,
|
||||
isFollowing: false,
|
||||
isLocked: false,
|
||||
isModerator: false,
|
||||
isMuted: false,
|
||||
isSilenced: false,
|
||||
isSuspended: false,
|
||||
lang: 'en',
|
||||
location: 'Fediverse',
|
||||
notesCount: 65536,
|
||||
pinnedNoteIds: [],
|
||||
pinnedNotes: [],
|
||||
pinnedPage: null,
|
||||
pinnedPageId: null,
|
||||
publicReactions: false,
|
||||
securityKeys: false,
|
||||
twoFactorEnabled: false,
|
||||
updatedAt: null,
|
||||
uri: null,
|
||||
url: null,
|
||||
} satisfies entities.UserDetailed
|
406
packages/frontend/.storybook/generate.tsx
Normal file
406
packages/frontend/.storybook/generate.tsx
Normal file
|
@ -0,0 +1,406 @@
|
|||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { basename, dirname } from 'node:path/posix';
|
||||
import { GENERATOR, type State, generate } from 'astring';
|
||||
import type * as estree from 'estree';
|
||||
import glob from 'fast-glob';
|
||||
import { format } from 'prettier';
|
||||
|
||||
interface SatisfiesExpression extends estree.BaseExpression {
|
||||
type: 'SatisfiesExpression';
|
||||
expression: estree.Expression;
|
||||
reference: estree.Identifier;
|
||||
}
|
||||
|
||||
const generator = {
|
||||
...GENERATOR,
|
||||
SatisfiesExpression(node: SatisfiesExpression, state: State) {
|
||||
switch (node.expression.type) {
|
||||
case 'ArrowFunctionExpression': {
|
||||
state.write('(');
|
||||
this[node.expression.type](node.expression, state);
|
||||
state.write(')');
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// @ts-ignore
|
||||
this[node.expression.type](node.expression, state);
|
||||
break;
|
||||
}
|
||||
}
|
||||
state.write(' satisfies ', node as unknown as estree.Expression);
|
||||
this[node.reference.type](node.reference, state);
|
||||
},
|
||||
};
|
||||
|
||||
type SplitCamel<
|
||||
T extends string,
|
||||
YC extends string = '',
|
||||
YN extends readonly string[] = []
|
||||
> = T extends `${infer XH}${infer XR}`
|
||||
? XR extends ''
|
||||
? [...YN, Uncapitalize<`${YC}${XH}`>]
|
||||
: XH extends Uppercase<XH>
|
||||
? SplitCamel<XR, Lowercase<XH>, [...YN, YC]>
|
||||
: SplitCamel<XR, `${YC}${XH}`, YN>
|
||||
: YN;
|
||||
|
||||
// @ts-ignore
|
||||
type SplitKebab<T extends string> = T extends `${infer XH}-${infer XR}`
|
||||
? [XH, ...SplitKebab<XR>]
|
||||
: [T];
|
||||
|
||||
type ToKebab<T extends readonly string[]> = T extends readonly [
|
||||
infer XO extends string
|
||||
]
|
||||
? XO
|
||||
: T extends readonly [
|
||||
infer XH extends string,
|
||||
...infer XR extends readonly string[]
|
||||
]
|
||||
? `${XH}${XR extends readonly string[] ? `-${ToKebab<XR>}` : ''}`
|
||||
: '';
|
||||
|
||||
// @ts-ignore
|
||||
type ToPascal<T extends readonly string[]> = T extends readonly [
|
||||
infer XH extends string,
|
||||
...infer XR extends readonly string[]
|
||||
]
|
||||
? `${Capitalize<XH>}${ToPascal<XR>}`
|
||||
: '';
|
||||
|
||||
function h<T extends estree.Node>(
|
||||
component: T['type'],
|
||||
props: Omit<T, 'type'>
|
||||
): T {
|
||||
const type = component.replace(/(?:^|-)([a-z])/g, (_, c) => c.toUpperCase());
|
||||
return Object.assign(props || {}, { type }) as T;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
type Element = estree.Node;
|
||||
type ElementClass = never;
|
||||
type ElementAttributesProperty = never;
|
||||
type ElementChildrenAttribute = never;
|
||||
type IntrinsicAttributes = never;
|
||||
type IntrinsicClassAttributes<T> = never;
|
||||
type IntrinsicElements = {
|
||||
[T in keyof typeof generator as ToKebab<SplitCamel<Uncapitalize<T>>>]: {
|
||||
[K in keyof Omit<
|
||||
Parameters<(typeof generator)[T]>[0],
|
||||
'type'
|
||||
>]?: Parameters<(typeof generator)[T]>[0][K];
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toStories(component: string): string {
|
||||
const msw = `${component.slice(0, -'.vue'.length)}.msw`;
|
||||
const implStories = `${component.slice(0, -'.vue'.length)}.stories.impl`;
|
||||
const metaStories = `${component.slice(0, -'.vue'.length)}.stories.meta`;
|
||||
const hasMsw = existsSync(`${msw}.ts`);
|
||||
const hasImplStories = existsSync(`${implStories}.ts`);
|
||||
const hasMetaStories = existsSync(`${metaStories}.ts`);
|
||||
const base = basename(component);
|
||||
const dir = dirname(component);
|
||||
const literal =
|
||||
<literal
|
||||
value={component
|
||||
.slice('src/'.length, -'.vue'.length)
|
||||
.replace(/\./g, '/')}
|
||||
/> as estree.Literal;
|
||||
const identifier =
|
||||
<identifier
|
||||
name={base
|
||||
.slice(0, -'.vue'.length)
|
||||
.replace(/[-.]|^(?=\d)/g, '_')
|
||||
.replace(/(?<=^[^A-Z_]*$)/, '_')}
|
||||
/> as estree.Identifier;
|
||||
const parameters = (
|
||||
<object-expression
|
||||
properties={[
|
||||
<property
|
||||
key={<identifier name='layout' /> as estree.Identifier}
|
||||
value={<literal value={`${dir}/`.startsWith('src/pages/') ? 'fullscreen' : 'centered'}/> as estree.Literal}
|
||||
kind={'init' as const}
|
||||
/> as estree.Property,
|
||||
...(hasMsw
|
||||
? [
|
||||
<property
|
||||
key={<identifier name='msw' /> as estree.Identifier}
|
||||
value={<identifier name='msw' /> as estree.Identifier}
|
||||
kind={'init' as const}
|
||||
shorthand
|
||||
/> as estree.Property,
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
) as estree.ObjectExpression;
|
||||
const program = (
|
||||
<program
|
||||
body={[
|
||||
<import-declaration
|
||||
source={<literal value='@storybook/vue3' /> as estree.Literal}
|
||||
specifiers={[
|
||||
<import-specifier
|
||||
local={<identifier name='Meta' /> as estree.Identifier}
|
||||
imported={<identifier name='Meta' /> as estree.Identifier}
|
||||
/> as estree.ImportSpecifier,
|
||||
...(hasImplStories
|
||||
? []
|
||||
: [
|
||||
<import-specifier
|
||||
local={<identifier name='StoryObj' /> as estree.Identifier}
|
||||
imported={<identifier name='StoryObj' /> as estree.Identifier}
|
||||
/> as estree.ImportSpecifier,
|
||||
]),
|
||||
]}
|
||||
/> as estree.ImportDeclaration,
|
||||
...(hasMsw
|
||||
? [
|
||||
<import-declaration
|
||||
source={<literal value={`./${basename(msw)}`} /> as estree.Literal}
|
||||
specifiers={[
|
||||
<import-namespace-specifier
|
||||
local={<identifier name='msw' /> as estree.Identifier}
|
||||
/> as estree.ImportNamespaceSpecifier,
|
||||
]}
|
||||
/> as estree.ImportDeclaration,
|
||||
]
|
||||
: []),
|
||||
...(hasImplStories
|
||||
? []
|
||||
: [
|
||||
<import-declaration
|
||||
source={<literal value={`./${base}`} /> as estree.Literal}
|
||||
specifiers={[
|
||||
<import-default-specifier local={identifier} /> as estree.ImportDefaultSpecifier,
|
||||
]}
|
||||
/> as estree.ImportDeclaration,
|
||||
]),
|
||||
...(hasMetaStories
|
||||
? [
|
||||
<import-declaration
|
||||
source={<literal value={`./${basename(metaStories)}`} /> as estree.Literal}
|
||||
specifiers={[
|
||||
<import-namespace-specifier
|
||||
local={<identifier name='storiesMeta' /> as estree.Identifier}
|
||||
/> as estree.ImportNamespaceSpecifier,
|
||||
]}
|
||||
/> as estree.ImportDeclaration,
|
||||
]
|
||||
: []),
|
||||
<variable-declaration
|
||||
kind={'const' as const}
|
||||
declarations={[
|
||||
<variable-declarator
|
||||
id={<identifier name='meta' /> as estree.Identifier}
|
||||
init={
|
||||
<satisfies-expression
|
||||
expression={
|
||||
<object-expression
|
||||
properties={[
|
||||
<property
|
||||
key={<identifier name='title' /> as estree.Identifier}
|
||||
value={literal}
|
||||
kind={'init' as const}
|
||||
/> as estree.Property,
|
||||
<property
|
||||
key={<identifier name='component' /> as estree.Identifier}
|
||||
value={identifier}
|
||||
kind={'init' as const}
|
||||
/> as estree.Property,
|
||||
...(hasMetaStories
|
||||
? [
|
||||
<spread-element
|
||||
argument={<identifier name='storiesMeta' /> as estree.Identifier}
|
||||
/> as estree.SpreadElement,
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
/> as estree.ObjectExpression
|
||||
}
|
||||
reference={<identifier name={`Meta<typeof ${identifier.name}>`} /> as estree.Identifier}
|
||||
/> as estree.Expression
|
||||
}
|
||||
/> as estree.VariableDeclarator,
|
||||
]}
|
||||
/> as estree.VariableDeclaration,
|
||||
...(hasImplStories
|
||||
? []
|
||||
: [
|
||||
<export-named-declaration
|
||||
declaration={
|
||||
<variable-declaration
|
||||
kind={'const' as const}
|
||||
declarations={[
|
||||
<variable-declarator
|
||||
id={<identifier name='Default' /> as estree.Identifier}
|
||||
init={
|
||||
<satisfies-expression
|
||||
expression={
|
||||
<object-expression
|
||||
properties={[
|
||||
<property
|
||||
key={<identifier name='render' /> as estree.Identifier}
|
||||
value={
|
||||
<function-expression
|
||||
params={[
|
||||
<identifier name='args' /> as estree.Identifier,
|
||||
]}
|
||||
body={
|
||||
<block-statement
|
||||
body={[
|
||||
<return-statement
|
||||
argument={
|
||||
<object-expression
|
||||
properties={[
|
||||
<property
|
||||
key={<identifier name='components' /> as estree.Identifier}
|
||||
value={
|
||||
<object-expression
|
||||
properties={[
|
||||
<property key={identifier} value={identifier} kind={'init' as const} shorthand /> as estree.Property,
|
||||
]}
|
||||
/> as estree.ObjectExpression
|
||||
}
|
||||
kind={'init' as const}
|
||||
/> as estree.Property,
|
||||
<property
|
||||
key={<identifier name='setup' /> as estree.Identifier}
|
||||
value={
|
||||
<function-expression
|
||||
params={[]}
|
||||
body={
|
||||
<block-statement
|
||||
body={[
|
||||
<return-statement
|
||||
argument={
|
||||
<object-expression
|
||||
properties={[
|
||||
<property
|
||||
key={<identifier name='args' /> as estree.Identifier}
|
||||
value={<identifier name='args' /> as estree.Identifier}
|
||||
kind={'init' as const}
|
||||
shorthand
|
||||
/> as estree.Property,
|
||||
]}
|
||||
/> as estree.ObjectExpression
|
||||
}
|
||||
/> as estree.ReturnStatement,
|
||||
]}
|
||||
/> as estree.BlockStatement
|
||||
}
|
||||
/> as estree.FunctionExpression
|
||||
}
|
||||
method
|
||||
kind={'init' as const}
|
||||
/> as estree.Property,
|
||||
<property
|
||||
key={<identifier name='computed' /> as estree.Identifier}
|
||||
value={
|
||||
<object-expression
|
||||
properties={[
|
||||
<property
|
||||
key={<identifier name='props' /> as estree.Identifier}
|
||||
value={
|
||||
<function-expression
|
||||
params={[]}
|
||||
body={
|
||||
<block-statement
|
||||
body={[
|
||||
<return-statement
|
||||
argument={
|
||||
<object-expression
|
||||
properties={[
|
||||
<spread-element
|
||||
argument={
|
||||
<member-expression
|
||||
object={<this-expression /> as estree.ThisExpression}
|
||||
property={<identifier name='args' /> as estree.Identifier}
|
||||
/> as estree.MemberExpression
|
||||
}
|
||||
/> as estree.SpreadElement,
|
||||
]}
|
||||
/> as estree.ObjectExpression
|
||||
}
|
||||
/> as estree.ReturnStatement,
|
||||
]}
|
||||
/> as estree.BlockStatement
|
||||
}
|
||||
/> as estree.FunctionExpression
|
||||
}
|
||||
method
|
||||
kind={'init' as const}
|
||||
/> as estree.Property,
|
||||
]}
|
||||
/> as estree.ObjectExpression
|
||||
}
|
||||
kind={'init' as const}
|
||||
/> as estree.Property,
|
||||
<property
|
||||
key={<identifier name='template' /> as estree.Identifier}
|
||||
value={<literal value={`<${identifier.name} v-bind="props" />`} /> as estree.Literal}
|
||||
kind={'init' as const}
|
||||
/> as estree.Property,
|
||||
]}
|
||||
/> as estree.ObjectExpression
|
||||
}
|
||||
/> as estree.ReturnStatement,
|
||||
]}
|
||||
/> as estree.BlockStatement
|
||||
}
|
||||
/> as estree.FunctionExpression
|
||||
}
|
||||
method
|
||||
kind={'init' as const}
|
||||
/> as estree.Property,
|
||||
<property
|
||||
key={<identifier name='parameters' /> as estree.Identifier}
|
||||
value={parameters}
|
||||
kind={'init' as const}
|
||||
/> as estree.Property,
|
||||
]}
|
||||
/> as estree.ObjectExpression
|
||||
}
|
||||
reference={<identifier name={`StoryObj<typeof ${identifier.name}>`} /> as estree.Identifier}
|
||||
/> as estree.Expression
|
||||
}
|
||||
/> as estree.VariableDeclarator,
|
||||
]}
|
||||
/> as estree.VariableDeclaration
|
||||
}
|
||||
/> as estree.ExportNamedDeclaration,
|
||||
]),
|
||||
<export-default-declaration
|
||||
declaration={(<identifier name='meta' />) as estree.Identifier}
|
||||
/> as estree.ExportDefaultDeclaration,
|
||||
]}
|
||||
/>
|
||||
) as estree.Program;
|
||||
return format(
|
||||
'/* eslint-disable @typescript-eslint/explicit-function-return-type */\n' +
|
||||
'/* eslint-disable import/no-default-export */\n' +
|
||||
generate(program, { generator }) +
|
||||
(hasImplStories ? readFileSync(`${implStories}.ts`, 'utf-8') : ''),
|
||||
{
|
||||
parser: 'babel-ts',
|
||||
singleQuote: true,
|
||||
useTabs: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// promisify(glob)('src/{components,pages,ui,widgets}/**/*.vue').then(
|
||||
glob('src/components/global/**/*.vue').then(
|
||||
(components) =>
|
||||
Promise.all(
|
||||
components.map((component) => {
|
||||
const stories = component.replace(/\.vue$/, '.stories.ts');
|
||||
return writeFile(stories, toStories(component));
|
||||
})
|
||||
)
|
||||
);
|
35
packages/frontend/.storybook/main.ts
Normal file
35
packages/frontend/.storybook/main.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { resolve } from 'node:path';
|
||||
import type { StorybookConfig } from '@storybook/vue3-vite';
|
||||
import { mergeConfig } from 'vite';
|
||||
const config = {
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-interactions',
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-storysource',
|
||||
resolve(__dirname, '../node_modules/storybook-addon-misskey-theme'),
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/vue3-vite',
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: 'tag',
|
||||
},
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
},
|
||||
async viteFinal(config, options) {
|
||||
return mergeConfig(config, {
|
||||
build: {
|
||||
target: [
|
||||
'chrome108',
|
||||
'firefox109',
|
||||
'safari16',
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
} satisfies StorybookConfig;
|
||||
export default config;
|
12
packages/frontend/.storybook/manager.ts
Normal file
12
packages/frontend/.storybook/manager.ts
Normal file
File diff suppressed because one or more lines are too long
16
packages/frontend/.storybook/mocks.ts
Normal file
16
packages/frontend/.storybook/mocks.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { type SharedOptions, rest } from 'msw';
|
||||
|
||||
export const onUnhandledRequest = ((req, print) => {
|
||||
if (req.url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(req.url.pathname)) {
|
||||
return
|
||||
}
|
||||
print.warning()
|
||||
}) satisfies SharedOptions['onUnhandledRequest'];
|
||||
|
||||
export const commonHandlers = [
|
||||
rest.get('/twemoji/:codepoints.svg', async (req, res, ctx) => {
|
||||
const { codepoints } = req.params;
|
||||
const value = await fetch(`https://unpkg.com/@discordapp/twemoji@14.1.2/dist/svg/${codepoints}.svg`).then((response) => response.blob());
|
||||
return res(ctx.set('Content-Type', 'image/svg+xml'), ctx.body(value));
|
||||
}),
|
||||
];
|
9
packages/frontend/.storybook/preload-locale.ts
Normal file
9
packages/frontend/.storybook/preload-locale.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import * as locales from '../../../locales';
|
||||
|
||||
writeFile(
|
||||
resolve(__dirname, 'locale.ts'),
|
||||
`export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`,
|
||||
'utf8',
|
||||
)
|
39
packages/frontend/.storybook/preload-theme.ts
Normal file
39
packages/frontend/.storybook/preload-theme.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import * as JSON5 from 'json5';
|
||||
|
||||
const keys = [
|
||||
'_dark',
|
||||
'_light',
|
||||
'l-light',
|
||||
'l-coffee',
|
||||
'l-apricot',
|
||||
'l-rainy',
|
||||
'l-botanical',
|
||||
'l-vivid',
|
||||
'l-cherry',
|
||||
'l-sushi',
|
||||
'l-u0',
|
||||
'd-dark',
|
||||
'd-persimmon',
|
||||
'd-astro',
|
||||
'd-future',
|
||||
'd-botanical',
|
||||
'd-green-lime',
|
||||
'd-green-orange',
|
||||
'd-cherry',
|
||||
'd-ice',
|
||||
'd-u0',
|
||||
]
|
||||
|
||||
Promise.all(keys.map((key) => readFile(resolve(__dirname, `../src/themes/${key}.json5`), 'utf8'))).then((sources) => {
|
||||
writeFile(
|
||||
resolve(__dirname, './themes.ts'),
|
||||
`export default ${JSON.stringify(
|
||||
Object.fromEntries(sources.map((source, i) => [keys[i], JSON5.parse(source)])),
|
||||
undefined,
|
||||
2,
|
||||
)} as const;`,
|
||||
'utf8'
|
||||
);
|
||||
});
|
4
packages/frontend/.storybook/preview-head.html
Normal file
4
packages/frontend/.storybook/preview-head.html
Normal file
|
@ -0,0 +1,4 @@
|
|||
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@2.12.0/tabler-icons.min.css">
|
||||
<script>
|
||||
window.global = window;
|
||||
</script>
|
113
packages/frontend/.storybook/preview.ts
Normal file
113
packages/frontend/.storybook/preview.ts
Normal file
|
@ -0,0 +1,113 @@
|
|||
import { addons } from '@storybook/addons';
|
||||
import { FORCE_REMOUNT } from '@storybook/core-events';
|
||||
import { type Preview, setup } from '@storybook/vue3';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import { initialize, mswDecorator } from 'msw-storybook-addon';
|
||||
import locale from './locale';
|
||||
import { commonHandlers, onUnhandledRequest } from './mocks';
|
||||
import themes from './themes';
|
||||
import '../src/style.scss';
|
||||
|
||||
const appInitialized = Symbol();
|
||||
|
||||
let moduleInitialized = false;
|
||||
let unobserve = () => {};
|
||||
let misskeyOS = null;
|
||||
|
||||
function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme']) {
|
||||
unobserve();
|
||||
const theme = themes[document.documentElement.dataset.misskeyTheme];
|
||||
if (theme) {
|
||||
applyTheme(themes[document.documentElement.dataset.misskeyTheme]);
|
||||
} else if (isChromatic()) {
|
||||
applyTheme(themes['l-light']);
|
||||
}
|
||||
const observer = new MutationObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.attributeName === 'data-misskey-theme') {
|
||||
const target = entry.target as HTMLElement;
|
||||
const theme = themes[target.dataset.misskeyTheme];
|
||||
if (theme) {
|
||||
applyTheme(themes[target.dataset.misskeyTheme]);
|
||||
} else {
|
||||
target.removeAttribute('style');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-misskey-theme'],
|
||||
});
|
||||
unobserve = () => observer.disconnect();
|
||||
}
|
||||
|
||||
initialize({
|
||||
onUnhandledRequest,
|
||||
});
|
||||
localStorage.setItem("locale", JSON.stringify(locale));
|
||||
queueMicrotask(() => {
|
||||
Promise.all([
|
||||
import('../src/components'),
|
||||
import('../src/directives'),
|
||||
import('../src/widgets'),
|
||||
import('../src/scripts/theme'),
|
||||
import('../src/store'),
|
||||
import('../src/os'),
|
||||
]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { defaultStore }, os]) => {
|
||||
setup((app) => {
|
||||
moduleInitialized = true;
|
||||
if (app[appInitialized]) {
|
||||
return;
|
||||
}
|
||||
app[appInitialized] = true;
|
||||
loadTheme(applyTheme);
|
||||
components(app);
|
||||
directives(app);
|
||||
widgets(app);
|
||||
misskeyOS = os;
|
||||
if (isChromatic()) {
|
||||
defaultStore.set('animation', false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const preview = {
|
||||
decorators: [
|
||||
(Story, context) => {
|
||||
const story = Story();
|
||||
if (!moduleInitialized) {
|
||||
const channel = addons.getChannel();
|
||||
(globalThis.requestIdleCallback || setTimeout)(() => {
|
||||
channel.emit(FORCE_REMOUNT, { storyId: context.id });
|
||||
});
|
||||
}
|
||||
return story;
|
||||
},
|
||||
mswDecorator,
|
||||
(Story, context) => {
|
||||
return {
|
||||
setup() {
|
||||
return {
|
||||
context,
|
||||
popups: misskeyOS.popups,
|
||||
};
|
||||
},
|
||||
template:
|
||||
'<component :is="popup.component" v-for="popup in popups" :key="popup.id" v-bind="popup.props" v-on="popup.events"/>' +
|
||||
'<story />',
|
||||
};
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
controls: {
|
||||
exclude: /^__/,
|
||||
},
|
||||
msw: {
|
||||
handlers: commonHandlers,
|
||||
},
|
||||
},
|
||||
} satisfies Preview;
|
||||
|
||||
export default preview;
|
22
packages/frontend/.storybook/tsconfig.json
Normal file
22
packages/frontend/.storybook/tsconfig.json
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"allowUnusedLabels": false,
|
||||
"allowUnreachableCode": false,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react",
|
||||
"jsxFactory": "h"
|
||||
},
|
||||
"files": ["./generate.tsx", "./preload-locale.ts", "./preload-theme.ts"]
|
||||
}
|
|
@ -4,6 +4,9 @@
|
|||
"scripts": {
|
||||
"watch": "vite",
|
||||
"build": "vite build",
|
||||
"storybook-dev": "chokidar 'src/**/*.{mdx,ts,vue}' -d 1000 -t 1000 --initial -i '**/*.stories.ts' -c 'pkill -f node_modules/storybook/index.js; node_modules/.bin/tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && node_modules/.bin/storybook dev -p 6006 --ci'",
|
||||
"build-storybook": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && storybook build",
|
||||
"chromatic": "chromatic",
|
||||
"test": "vitest --run",
|
||||
"test-and-coverage": "vitest --run --coverage",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
|
@ -71,8 +74,27 @@
|
|||
"vuedraggable": "next"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-essentials": "7.0.0-rc.10",
|
||||
"@storybook/addon-interactions": "7.0.0-rc.10",
|
||||
"@storybook/addon-links": "7.0.0-rc.10",
|
||||
"@storybook/addon-storysource": "7.0.0-rc.10",
|
||||
"@storybook/addons": "7.0.0-rc.10",
|
||||
"@storybook/blocks": "7.0.0-rc.10",
|
||||
"@storybook/core-events": "7.0.0-rc.10",
|
||||
"@storybook/jest": "0.0.10",
|
||||
"@storybook/manager-api": "7.0.0-rc.10",
|
||||
"@storybook/preview-api": "7.0.0-rc.10",
|
||||
"@storybook/react": "7.0.0-rc.10",
|
||||
"@storybook/react-vite": "7.0.0-rc.10",
|
||||
"@storybook/testing-library": "0.0.14-next.1",
|
||||
"@storybook/theming": "7.0.0-rc.10",
|
||||
"@storybook/types": "7.0.0-rc.10",
|
||||
"@storybook/vue3": "7.0.0-rc.10",
|
||||
"@storybook/vue3-vite": "7.0.0-rc.10",
|
||||
"@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.0-rc.10",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"vitest": "^0.29.8",
|
||||
"vitest-fetch-mock": "^0.2.2",
|
||||
|
|
303
packages/frontend/public/mockServiceWorker.js
Normal file
303
packages/frontend/public/mockServiceWorker.js
Normal file
|
@ -0,0 +1,303 @@
|
|||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker (1.1.0).
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
|
||||
const activeClientIds = new Set()
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: INTEGRITY_CHECKSUM,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event
|
||||
const accept = request.headers.get('accept') || ''
|
||||
|
||||
// Bypass server-sent events.
|
||||
if (accept.includes('text/event-stream')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = Math.random().toString(16).slice(2)
|
||||
|
||||
event.respondWith(
|
||||
handleRequest(event, requestId).catch((error) => {
|
||||
if (error.name === 'NetworkError') {
|
||||
console.warn(
|
||||
'[MSW] Successfully emulated a network error for the "%s %s" request.',
|
||||
request.method,
|
||||
request.url,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// At this point, any exception indicates an issue with the original request/response.
|
||||
console.error(
|
||||
`\
|
||||
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
|
||||
request.method,
|
||||
request.url,
|
||||
`${error.name}: ${error.message}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event)
|
||||
const response = await getResponse(event, client, requestId)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
;(async function () {
|
||||
const clonedResponse = response.clone()
|
||||
sendToClient(client, {
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
type: clonedResponse.type,
|
||||
ok: clonedResponse.ok,
|
||||
status: clonedResponse.status,
|
||||
statusText: clonedResponse.statusText,
|
||||
body:
|
||||
clonedResponse.body === null ? null : await clonedResponse.text(),
|
||||
headers: Object.fromEntries(clonedResponse.headers.entries()),
|
||||
redirected: clonedResponse.redirected,
|
||||
},
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Resolve the main client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event
|
||||
const clonedRequest = request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const headers = Object.fromEntries(clonedRequest.headers.entries())
|
||||
|
||||
// Remove MSW-specific request headers so the bypassed requests
|
||||
// comply with the server's CORS preflight check.
|
||||
// Operate with the headers as an object because request "Headers"
|
||||
// are immutable.
|
||||
delete headers['x-msw-bypass']
|
||||
|
||||
return fetch(clonedRequest, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass requests with the explicit bypass header.
|
||||
// Such requests can be issued by "ctx.fetch()".
|
||||
if (request.headers.get('x-msw-bypass') === 'true') {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const clientMessage = await sendToClient(client, {
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
mode: request.mode,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.text(),
|
||||
bodyUsed: request.bodyUsed,
|
||||
keepalive: request.keepalive,
|
||||
},
|
||||
})
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'MOCK_NOT_FOUND': {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
case 'NETWORK_ERROR': {
|
||||
const { name, message } = clientMessage.data
|
||||
const networkError = new Error(message)
|
||||
networkError.name = name
|
||||
|
||||
// Rejecting a "respondWith" promise emulates a network error.
|
||||
throw networkError
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
function sendToClient(client, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [channel.port2])
|
||||
})
|
||||
}
|
||||
|
||||
function sleep(timeMs) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, timeMs)
|
||||
})
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
await sleep(response.delay)
|
||||
return new Response(response.body, response)
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkAnalogClock from './MkAnalogClock.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkAnalogClock,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkAnalogClock v-bind="props" />',
|
||||
};
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAnalogClock>;
|
30
packages/frontend/src/components/MkButton.stories.impl.ts
Normal file
30
packages/frontend/src/components/MkButton.stories.impl.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
/* eslint-disable import/no-duplicates */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkButton from './MkButton.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkButton,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkButton v-bind="props">Text</MkButton>',
|
||||
};
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkButton>;
|
|
@ -0,0 +1,2 @@
|
|||
import MkCaptcha from './MkCaptcha.vue';
|
||||
void MkCaptcha;
|
|
@ -17,8 +17,8 @@ import { onMounted, onBeforeUnmount } from 'vue';
|
|||
import MkMenu from './MkMenu.vue';
|
||||
import { MenuItem } from './types/menu.vue';
|
||||
import contains from '@/scripts/contains';
|
||||
import * as os from '@/os';
|
||||
import { defaultStore } from '@/store';
|
||||
import * as os from '@/os';
|
||||
|
||||
const props = defineProps<{
|
||||
items: MenuItem[];
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div>
|
||||
<div role="menu">
|
||||
<div
|
||||
ref="itemsEl" v-hotkey="keymap"
|
||||
class="_popup _shadow"
|
||||
|
@ -8,37 +8,37 @@
|
|||
@contextmenu.self="e => e.preventDefault()"
|
||||
>
|
||||
<template v-for="(item, i) in items2">
|
||||
<div v-if="item === null" :class="$style.divider"></div>
|
||||
<span v-else-if="item.type === 'label'" :class="[$style.label, $style.item]">
|
||||
<div v-if="item === null" role="separator" :class="$style.divider"></div>
|
||||
<span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]">
|
||||
<span>{{ item.text }}</span>
|
||||
</span>
|
||||
<span v-else-if="item.type === 'pending'" :tabindex="i" :class="[$style.pending, $style.item]">
|
||||
<span v-else-if="item.type === 'pending'" role="menuitem" :tabindex="i" :class="[$style.pending, $style.item]">
|
||||
<span><MkEllipsis/></span>
|
||||
</span>
|
||||
<MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<MkA v-else-if="item.type === 'link'" role="menuitem" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||
<span>{{ item.text }}</span>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
</MkA>
|
||||
<a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<a v-else-if="item.type === 'a'" role="menuitem" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<span>{{ item.text }}</span>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
</a>
|
||||
<button v-else-if="item.type === 'user'" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<button v-else-if="item.type === 'user'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
</button>
|
||||
<span v-else-if="item.type === 'switch'" :tabindex="i" :class="$style.item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<span v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" :class="$style.item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<MkSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</MkSwitch>
|
||||
</span>
|
||||
<button v-else-if="item.type === 'parent'" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)">
|
||||
<button v-else-if="item.type === 'parent'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)">
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<span>{{ item.text }}</span>
|
||||
<span :class="$style.caret"><i class="ti ti-chevron-right ti-fw"></i></span>
|
||||
</button>
|
||||
<button v-else :tabindex="i" class="_button" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||
<span>{{ item.text }}</span>
|
||||
|
|
|
@ -163,21 +163,22 @@ async function init(): Promise<void> {
|
|||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await os.api(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit: props.pagination.noPaging ? (props.pagination.limit || 10) : (props.pagination.limit || 10) + 1,
|
||||
limit: props.pagination.limit ?? 10,
|
||||
}).then(res => {
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
const item = res[i];
|
||||
if (i === 3) item._shouldInsertAd_ = true;
|
||||
}
|
||||
if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) {
|
||||
res.pop();
|
||||
|
||||
if (res.length === 0 || props.pagination.noPaging) {
|
||||
items.value = res;
|
||||
more.value = false;
|
||||
} else {
|
||||
if (props.pagination.reversed) moreFetching.value = true;
|
||||
items.value = res;
|
||||
more.value = true;
|
||||
} else {
|
||||
items.value = res;
|
||||
more.value = false;
|
||||
}
|
||||
|
||||
offset.value = res.length;
|
||||
error.value = false;
|
||||
fetching.value = false;
|
||||
|
@ -198,7 +199,7 @@ const fetchMore = async (): Promise<void> => {
|
|||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await os.api(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit: SECOND_FETCH_LIMIT + 1,
|
||||
limit: SECOND_FETCH_LIMIT,
|
||||
...(props.pagination.offsetMode ? {
|
||||
offset: offset.value,
|
||||
} : {
|
||||
|
@ -227,20 +228,7 @@ const fetchMore = async (): Promise<void> => {
|
|||
});
|
||||
};
|
||||
|
||||
if (res.length > SECOND_FETCH_LIMIT) {
|
||||
res.pop();
|
||||
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
});
|
||||
} else {
|
||||
items.value = items.value.concat(res);
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
}
|
||||
} else {
|
||||
if (res.length === 0) {
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = false;
|
||||
|
@ -251,6 +239,17 @@ const fetchMore = async (): Promise<void> => {
|
|||
more.value = false;
|
||||
moreFetching.value = false;
|
||||
}
|
||||
} else {
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
});
|
||||
} else {
|
||||
items.value = items.value.concat(res);
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
}
|
||||
}
|
||||
offset.value += res.length;
|
||||
}, err => {
|
||||
|
@ -264,20 +263,19 @@ const fetchMoreAhead = async (): Promise<void> => {
|
|||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await os.api(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit: SECOND_FETCH_LIMIT + 1,
|
||||
limit: SECOND_FETCH_LIMIT,
|
||||
...(props.pagination.offsetMode ? {
|
||||
offset: offset.value,
|
||||
} : {
|
||||
sinceId: items.value[items.value.length - 1].id,
|
||||
}),
|
||||
}).then(res => {
|
||||
if (res.length > SECOND_FETCH_LIMIT) {
|
||||
res.pop();
|
||||
items.value = items.value.concat(res);
|
||||
more.value = true;
|
||||
} else {
|
||||
if (res.length === 0) {
|
||||
items.value = items.value.concat(res);
|
||||
more.value = false;
|
||||
} else {
|
||||
items.value = items.value.concat(res);
|
||||
more.value = true;
|
||||
}
|
||||
offset.value += res.length;
|
||||
moreFetching.value = false;
|
||||
|
|
|
@ -150,7 +150,7 @@ function adjustTweetHeight(message: any) {
|
|||
}
|
||||
|
||||
const openPlayer = (): void => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkYoutubePlayer.vue')), {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkYouTubePlayer.vue')), {
|
||||
url: requestUrl.href,
|
||||
});
|
||||
};
|
||||
|
|
47
packages/frontend/src/components/global/MkA.stories.impl.ts
Normal file
47
packages/frontend/src/components/global/MkA.stories.impl.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { expect } from '@storybook/jest';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkA from './MkA.vue';
|
||||
import { tick } from '@/scripts/test-utils';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkA,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkA v-bind="props">Text</MkA>',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
const canvas = within(canvasElement);
|
||||
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||
await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
|
||||
await userEvent.click(a, { button: 2 });
|
||||
await tick();
|
||||
const menu = canvas.getByRole('menu');
|
||||
await expect(menu).toBeInTheDocument();
|
||||
await userEvent.click(a, { button: 0 });
|
||||
a.blur();
|
||||
await tick();
|
||||
await expect(menu).not.toBeInTheDocument();
|
||||
},
|
||||
args: {
|
||||
to: '#test',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkA>;
|
|
@ -0,0 +1,43 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { userDetailed } from '../../../.storybook/fakes';
|
||||
import MkAcct from './MkAcct.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkAcct,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkAcct v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
user: {
|
||||
...userDetailed,
|
||||
host: null,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAcct>;
|
||||
export const Detail = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
user: userDetailed,
|
||||
detail: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAcct>;
|
|
@ -18,4 +18,3 @@ defineProps<{
|
|||
|
||||
const host = toUnicode(hostRaw);
|
||||
</script>
|
||||
|
||||
|
|
120
packages/frontend/src/components/global/MkAd.stories.impl.ts
Normal file
120
packages/frontend/src/components/global/MkAd.stories.impl.ts
Normal file
|
@ -0,0 +1,120 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { expect } from '@storybook/jest';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { i18n } from '@/i18n';
|
||||
import MkAd from './MkAd.vue';
|
||||
const common = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkAd,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkAd v-bind="props" />',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement, args }) {
|
||||
const canvas = within(canvasElement);
|
||||
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||
await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
|
||||
const img = within(a).getByRole('img');
|
||||
await expect(img).toBeInTheDocument();
|
||||
let buttons = canvas.getAllByRole<HTMLButtonElement>('button');
|
||||
await expect(buttons).toHaveLength(1);
|
||||
const i = buttons[0];
|
||||
await expect(i).toBeInTheDocument();
|
||||
await userEvent.click(i);
|
||||
await expect(a).not.toBeInTheDocument();
|
||||
await expect(i).not.toBeInTheDocument();
|
||||
buttons = canvas.getAllByRole<HTMLButtonElement>('button');
|
||||
await expect(buttons).toHaveLength(args.__hasReduce ? 2 : 1);
|
||||
const reduce = args.__hasReduce ? buttons[0] : null;
|
||||
const back = buttons[args.__hasReduce ? 1 : 0];
|
||||
if (reduce) {
|
||||
await expect(reduce).toBeInTheDocument();
|
||||
await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd);
|
||||
}
|
||||
await expect(back).toBeInTheDocument();
|
||||
await expect(back).toHaveTextContent(i18n.ts._ad.back);
|
||||
await userEvent.click(back);
|
||||
if (reduce) {
|
||||
await expect(reduce).not.toBeInTheDocument();
|
||||
}
|
||||
await expect(back).not.toBeInTheDocument();
|
||||
const aAgain = canvas.getByRole<HTMLAnchorElement>('link');
|
||||
await expect(aAgain).toBeInTheDocument();
|
||||
const imgAgain = within(aAgain).getByRole('img');
|
||||
await expect(imgAgain).toBeInTheDocument();
|
||||
},
|
||||
args: {
|
||||
prefer: [],
|
||||
specify: {
|
||||
id: 'someadid',
|
||||
radio: 1,
|
||||
url: '#test',
|
||||
},
|
||||
__hasReduce: true,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAd>;
|
||||
export const Square = {
|
||||
...common,
|
||||
args: {
|
||||
...common.args,
|
||||
specify: {
|
||||
...common.args.specify,
|
||||
place: 'square',
|
||||
imageUrl:
|
||||
'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAd>;
|
||||
export const Horizontal = {
|
||||
...common,
|
||||
args: {
|
||||
...common.args,
|
||||
specify: {
|
||||
...common.args.specify,
|
||||
place: 'horizontal',
|
||||
imageUrl:
|
||||
'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAd>;
|
||||
export const HorizontalBig = {
|
||||
...common,
|
||||
args: {
|
||||
...common.args,
|
||||
specify: {
|
||||
...common.args.specify,
|
||||
place: 'horizontal-big',
|
||||
imageUrl:
|
||||
'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAd>;
|
||||
export const ZeroRatio = {
|
||||
...Square,
|
||||
args: {
|
||||
...Square.args,
|
||||
specify: {
|
||||
...Square.args.specify,
|
||||
ratio: 0,
|
||||
},
|
||||
__hasReduce: false,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAd>;
|
|
@ -20,13 +20,13 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { instance } from '@/instance';
|
||||
import { host } from '@/config';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
import * as os from '@/os';
|
||||
import { $i } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
type Ad = (typeof instance)['ads'][number];
|
||||
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { userDetailed } from '../../../.storybook/fakes';
|
||||
import MkAvatar from './MkAvatar.vue';
|
||||
const common = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkAvatar,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkAvatar v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
user: userDetailed,
|
||||
},
|
||||
decorators: [
|
||||
(Story, context) => ({
|
||||
// eslint-disable-next-line quotes
|
||||
template: `<div :style="{ display: 'grid', width: '${context.args.size}px', height: '${context.args.size}px' }"><story/></div>`,
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAvatar>;
|
||||
export const ProfilePage = {
|
||||
...common,
|
||||
args: {
|
||||
...common.args,
|
||||
size: 120,
|
||||
indicator: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAvatar>;
|
||||
export const ProfilePageCat = {
|
||||
...ProfilePage,
|
||||
args: {
|
||||
...ProfilePage.args,
|
||||
user: {
|
||||
...userDetailed,
|
||||
isCat: true,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
...ProfilePage.parameters,
|
||||
chromatic: {
|
||||
/* Your story couldn’t be captured because it exceeds our 25,000,000px limit. Its dimensions are 5,504,893x5,504,892px. Possible ways to resolve:
|
||||
* * Separate pages into components
|
||||
* * Minimize the number of very large elements in a story
|
||||
*/
|
||||
disableSnapshot: true,
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAvatar>;
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
|
||||
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
|
||||
<img :class="$style.inner" :src="url" decoding="async"/>
|
||||
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
|
||||
<div v-if="user.isCat" :class="[$style.ears, { [$style.mask]: useBlurEffect }]">
|
||||
|
@ -27,6 +27,7 @@ import { acct, userPage } from '@/filters/user';
|
|||
import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const animation = $ref(defaultStore.state.animation);
|
||||
const squareAvatars = $ref(defaultStore.state.squareAvatars);
|
||||
const useBlurEffect = $ref(defaultStore.state.useBlurEffect);
|
||||
|
||||
|
@ -86,6 +87,18 @@ watch(() => props.user.avatarBlurhash, () => {
|
|||
to { transform: rotate(-37.6deg) skew(-30deg); }
|
||||
}
|
||||
|
||||
@keyframes eartightleft {
|
||||
from { transform: rotate(37.6deg) skew(30deg); }
|
||||
50% { transform: rotate(37.4deg) skew(30deg); }
|
||||
to { transform: rotate(37.6deg) skew(30deg); }
|
||||
}
|
||||
|
||||
@keyframes eartightright {
|
||||
from { transform: rotate(-37.6deg) skew(-30deg); }
|
||||
50% { transform: rotate(-37.4deg) skew(-30deg); }
|
||||
to { transform: rotate(-37.6deg) skew(-30deg); }
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
@ -135,6 +148,7 @@ watch(() => props.user.avatarBlurhash, () => {
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 50%;
|
||||
pointer-events: none;
|
||||
|
||||
&.mask {
|
||||
-webkit-mask:
|
||||
|
@ -144,6 +158,14 @@ watch(() => props.user.avatarBlurhash, () => {
|
|||
mask:
|
||||
url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><filter id="a"><feGaussianBlur in="SourceGraphic" stdDeviation="1"/></filter><circle cx="16" cy="16" r="15" filter="url(%23a)"/></svg>') exclude center / 50% 50%,
|
||||
linear-gradient(#fff, #fff); // polyfill of `image(#fff)`
|
||||
|
||||
> .earLeft {
|
||||
animation: eartightleft 6s infinite;
|
||||
}
|
||||
|
||||
> .earRight {
|
||||
animation: eartightright 6s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
> .earLeft,
|
||||
|
@ -225,7 +247,7 @@ watch(() => props.user.avatarBlurhash, () => {
|
|||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&.animation:hover {
|
||||
> .ears {
|
||||
> .earLeft {
|
||||
animation: earwiggleleft 1s infinite;
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkCustomEmoji from './MkCustomEmoji.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkCustomEmoji,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkCustomEmoji v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
name: 'mi',
|
||||
url: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCustomEmoji>;
|
||||
export const Normal = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
normal: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCustomEmoji>;
|
||||
export const Missing = {
|
||||
...Default,
|
||||
args: {
|
||||
name: Default.args.name,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCustomEmoji>;
|
|
@ -0,0 +1,32 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import MkEllipsis from './MkEllipsis.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkEllipsis,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkEllipsis v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
static: isChromatic(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkEllipsis>;
|
|
@ -1,9 +1,19 @@
|
|||
<template>
|
||||
<span :class="$style.root">
|
||||
<span :class="[$style.root, { [$style.static]: static }]">
|
||||
<span :class="$style.dot">.</span><span :class="$style.dot">.</span><span :class="$style.dot">.</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
static?: boolean;
|
||||
}>(), {
|
||||
static: false,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
@keyframes ellipsis {
|
||||
0%, 80%, 100% {
|
||||
|
@ -15,7 +25,9 @@
|
|||
}
|
||||
|
||||
.root {
|
||||
|
||||
&.static > .dot {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkEmoji from './MkEmoji.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkEmoji,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkEmoji v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
emoji: '❤',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkEmoji>;
|
|
@ -0,0 +1,5 @@
|
|||
export const argTypes = {
|
||||
retry: {
|
||||
action: 'retry',
|
||||
},
|
||||
};
|
|
@ -0,0 +1,60 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import MkLoading from './MkLoading.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkLoading,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkLoading v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
static: isChromatic(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkLoading>;
|
||||
export const Inline = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
inline: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkLoading>;
|
||||
export const Colored = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
colored: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkLoading>;
|
||||
export const Mini = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
mini: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkLoading>;
|
||||
export const Em = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
em: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkLoading>;
|
|
@ -6,7 +6,7 @@
|
|||
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
|
||||
</g>
|
||||
</svg>
|
||||
<svg :class="[$style.spinner, $style.fg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg :class="[$style.spinner, $style.fg, { [$style.static]: static }]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1.125,0,0,1.125,12,12)">
|
||||
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
|
||||
</g>
|
||||
|
@ -19,11 +19,13 @@
|
|||
import { } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
static?: boolean;
|
||||
inline?: boolean;
|
||||
colored?: boolean;
|
||||
mini?: boolean;
|
||||
em?: boolean;
|
||||
}>(), {
|
||||
static: false,
|
||||
inline: false,
|
||||
colored: true,
|
||||
mini: false,
|
||||
|
@ -97,5 +99,9 @@ const props = withDefaults(defineProps<{
|
|||
|
||||
.fg {
|
||||
animation: spinner 0.5s linear infinite;
|
||||
|
||||
&.static {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkMisskeyFlavoredMarkdown from './MkMisskeyFlavoredMarkdown.vue';
|
||||
import { within } from '@storybook/testing-library';
|
||||
import { expect } from '@storybook/jest';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkMisskeyFlavoredMarkdown,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkMisskeyFlavoredMarkdown v-bind="props" />',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement, args }) {
|
||||
const canvas = within(canvasElement);
|
||||
if (args.plain) {
|
||||
const aiHelloMiskist = canvas.getByText('@ai *Hello*, #Miskist!');
|
||||
await expect(aiHelloMiskist).toBeInTheDocument();
|
||||
} else {
|
||||
const ai = canvas.getByText('@ai');
|
||||
await expect(ai).toBeInTheDocument();
|
||||
await expect(ai.closest('a')).toHaveAttribute('href', '/@ai');
|
||||
const hello = canvas.getByText('Hello');
|
||||
await expect(hello).toBeInTheDocument();
|
||||
await expect(hello.style.fontStyle).toBe('oblique');
|
||||
const miskist = canvas.getByText('#Miskist');
|
||||
await expect(miskist).toBeInTheDocument();
|
||||
await expect(miskist).toHaveAttribute('href', args.isNote ?? true ? '/tags/Miskist' : '/user-tags/Miskist');
|
||||
}
|
||||
const heart = canvas.getByAltText('❤');
|
||||
await expect(heart).toBeInTheDocument();
|
||||
await expect(heart).toHaveAttribute('src', '/twemoji/2764.svg');
|
||||
},
|
||||
args: {
|
||||
text: '@ai *Hello*, #Miskist! ❤',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
||||
export const Plain = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
plain: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
||||
export const Nowrap = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
nowrap: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
||||
export const IsNotNote = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
isNote: false,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
|
@ -0,0 +1,98 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkPageHeader from './MkPageHeader.vue';
|
||||
export const Empty = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkPageHeader,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkPageHeader v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
static: true,
|
||||
tabs: [],
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
chromatic: {
|
||||
/* This component has animations that are implemented with JavaScript. So it's unstable to take a snapshot. */
|
||||
disableSnapshot: true,
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkPageHeader>;
|
||||
export const OneTab = {
|
||||
...Empty,
|
||||
args: {
|
||||
...Empty.args,
|
||||
tab: 'sometabkey',
|
||||
tabs: [
|
||||
{
|
||||
key: 'sometabkey',
|
||||
title: 'Some Tab Title',
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies StoryObj<typeof MkPageHeader>;
|
||||
export const Icon = {
|
||||
...OneTab,
|
||||
args: {
|
||||
...OneTab.args,
|
||||
tabs: [
|
||||
{
|
||||
...OneTab.args.tabs[0],
|
||||
icon: 'ti ti-home',
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies StoryObj<typeof MkPageHeader>;
|
||||
export const IconOnly = {
|
||||
...Icon,
|
||||
args: {
|
||||
...Icon.args,
|
||||
tabs: [
|
||||
{
|
||||
...Icon.args.tabs[0],
|
||||
title: undefined,
|
||||
iconOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies StoryObj<typeof MkPageHeader>;
|
||||
export const SomeTabs = {
|
||||
...Empty,
|
||||
args: {
|
||||
...Empty.args,
|
||||
tab: 'princess',
|
||||
tabs: [
|
||||
{
|
||||
key: 'princess',
|
||||
title: 'Princess',
|
||||
icon: 'ti ti-crown',
|
||||
},
|
||||
{
|
||||
key: 'fairy',
|
||||
title: 'Fairy',
|
||||
icon: 'ti ti-snowflake',
|
||||
},
|
||||
{
|
||||
key: 'angel',
|
||||
title: 'Angel',
|
||||
icon: 'ti ti-feather',
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies StoryObj<typeof MkPageHeader>;
|
|
@ -0,0 +1,3 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import MkPageHeader_tabs from './MkPageHeader.tabs.vue';
|
||||
void MkPageHeader_tabs;
|
|
@ -33,14 +33,18 @@
|
|||
<script lang="ts">
|
||||
export type Tab = {
|
||||
key: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
iconOnly?: boolean;
|
||||
onClick?: (ev: MouseEvent) => void;
|
||||
} & {
|
||||
iconOnly: true;
|
||||
iccn: string;
|
||||
};
|
||||
} & (
|
||||
| {
|
||||
iconOnly?: false;
|
||||
title: string;
|
||||
icon?: string;
|
||||
}
|
||||
| {
|
||||
iconOnly: true;
|
||||
icon: string;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
|
|
@ -8,7 +8,9 @@
|
|||
|
||||
<template v-if="metadata">
|
||||
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
|
||||
<MkAvatar v-if="metadata.avatar" :class="$style.titleAvatar" :user="metadata.avatar" indicator/>
|
||||
<div v-if="metadata.avatar" :class="$style.titleAvatarContainer">
|
||||
<MkAvatar :class="$style.titleAvatar" :user="metadata.avatar" indicator/>
|
||||
</div>
|
||||
<i v-else-if="metadata.icon" :class="[$style.titleIcon, metadata.icon]"></i>
|
||||
|
||||
<div :class="$style.title">
|
||||
|
@ -249,13 +251,19 @@ onUnmounted(() => {
|
|||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.titleAvatar {
|
||||
.titleAvatarContainer {
|
||||
$size: 32px;
|
||||
display: inline-block;
|
||||
contain: strict;
|
||||
overflow: clip;
|
||||
width: $size;
|
||||
height: $size;
|
||||
vertical-align: bottom;
|
||||
margin: 0 8px;
|
||||
padding: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titleAvatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import MkStickyContainer from './MkStickyContainer.vue';
|
||||
void MkStickyContainer;
|
312
packages/frontend/src/components/global/MkTime.stories.impl.ts
Normal file
312
packages/frontend/src/components/global/MkTime.stories.impl.ts
Normal file
|
@ -0,0 +1,312 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { expect } from '@storybook/jest';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkTime from './MkTime.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { dateTimeFormat } from '@/scripts/intl-const';
|
||||
const now = new Date('2023-04-01T00:00:00.000Z');
|
||||
const future = new Date(8640000000000000);
|
||||
const oneHourAgo = new Date(now.getTime() - 3600000);
|
||||
const oneDayAgo = new Date(now.getTime() - 86400000);
|
||||
const oneWeekAgo = new Date(now.getTime() - 604800000);
|
||||
const oneMonthAgo = new Date(now.getTime() - 2592000000);
|
||||
const oneYearAgo = new Date(now.getTime() - 31536000000);
|
||||
export const Empty = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkTime,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkTime v-bind="props" />',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.ts._ago.invalid);
|
||||
},
|
||||
args: {
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const RelativeFuture = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.ts._ago.future);
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: future,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const AbsoluteFuture = {
|
||||
...Empty,
|
||||
async play({ canvasElement, args }) {
|
||||
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: future,
|
||||
mode: 'absolute',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const DetailFuture = {
|
||||
...Empty,
|
||||
async play(context) {
|
||||
await AbsoluteFuture.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||
await RelativeFuture.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(')');
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: future,
|
||||
mode: 'detail',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const RelativeNow = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.ts._ago.justNow);
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: now,
|
||||
origin: now,
|
||||
mode: 'relative',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const AbsoluteNow = {
|
||||
...Empty,
|
||||
async play({ canvasElement, args }) {
|
||||
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: now,
|
||||
origin: now,
|
||||
mode: 'absolute',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const DetailNow = {
|
||||
...Empty,
|
||||
async play(context) {
|
||||
await AbsoluteNow.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||
await RelativeNow.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(')');
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: now,
|
||||
origin: now,
|
||||
mode: 'detail',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const RelativeOneHourAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.hoursAgo', { n: 1 }));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneHourAgo,
|
||||
origin: now,
|
||||
mode: 'relative',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const AbsoluteOneHourAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement, args }) {
|
||||
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneHourAgo,
|
||||
origin: now,
|
||||
mode: 'absolute',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const DetailOneHourAgo = {
|
||||
...Empty,
|
||||
async play(context) {
|
||||
await AbsoluteOneHourAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||
await RelativeOneHourAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(')');
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneHourAgo,
|
||||
origin: now,
|
||||
mode: 'detail',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const RelativeOneDayAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.daysAgo', { n: 1 }));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneDayAgo,
|
||||
origin: now,
|
||||
mode: 'relative',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const AbsoluteOneDayAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement, args }) {
|
||||
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneDayAgo,
|
||||
origin: now,
|
||||
mode: 'absolute',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const DetailOneDayAgo = {
|
||||
...Empty,
|
||||
async play(context) {
|
||||
await AbsoluteOneDayAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||
await RelativeOneDayAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(')');
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneDayAgo,
|
||||
origin: now,
|
||||
mode: 'detail',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const RelativeOneWeekAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.weeksAgo', { n: 1 }));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneWeekAgo,
|
||||
origin: now,
|
||||
mode: 'relative',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const AbsoluteOneWeekAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement, args }) {
|
||||
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneWeekAgo,
|
||||
origin: now,
|
||||
mode: 'absolute',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const DetailOneWeekAgo = {
|
||||
...Empty,
|
||||
async play(context) {
|
||||
await AbsoluteOneWeekAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||
await RelativeOneWeekAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(')');
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneWeekAgo,
|
||||
origin: now,
|
||||
mode: 'detail',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const RelativeOneMonthAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.monthsAgo', { n: 1 }));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneMonthAgo,
|
||||
origin: now,
|
||||
mode: 'relative',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const AbsoluteOneMonthAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement, args }) {
|
||||
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneMonthAgo,
|
||||
origin: now,
|
||||
mode: 'absolute',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const DetailOneMonthAgo = {
|
||||
...Empty,
|
||||
async play(context) {
|
||||
await AbsoluteOneMonthAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||
await RelativeOneMonthAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(')');
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneMonthAgo,
|
||||
origin: now,
|
||||
mode: 'detail',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const RelativeOneYearAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.yearsAgo', { n: 1 }));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneYearAgo,
|
||||
origin: now,
|
||||
mode: 'relative',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const AbsoluteOneYearAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement, args }) {
|
||||
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneYearAgo,
|
||||
origin: now,
|
||||
mode: 'absolute',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const DetailOneYearAgo = {
|
||||
...Empty,
|
||||
async play(context) {
|
||||
await AbsoluteOneYearAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||
await RelativeOneYearAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(')');
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneYearAgo,
|
||||
origin: now,
|
||||
mode: 'detail',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
|
@ -14,8 +14,10 @@ import { dateTimeFormat } from '@/scripts/intl-const';
|
|||
|
||||
const props = withDefaults(defineProps<{
|
||||
time: Date | string | number | null;
|
||||
origin?: Date | null;
|
||||
mode?: 'relative' | 'absolute' | 'detail';
|
||||
}>(), {
|
||||
origin: null,
|
||||
mode: 'relative',
|
||||
});
|
||||
|
||||
|
@ -25,7 +27,7 @@ const _time = props.time == null ? NaN :
|
|||
const invalid = Number.isNaN(_time);
|
||||
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
|
||||
|
||||
let now = $ref((new Date()).getTime());
|
||||
let now = $ref((props.origin ?? new Date()).getTime());
|
||||
const relative = $computed<string>(() => {
|
||||
if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
|
||||
if (invalid) return i18n.ts._ago.invalid;
|
||||
|
@ -46,7 +48,7 @@ const relative = $computed<string>(() => {
|
|||
let tickId: number;
|
||||
|
||||
function tick() {
|
||||
now = (new Date()).getTime();
|
||||
now = props.origin ?? (new Date()).getTime();
|
||||
const ago = (now - _time) / 1000/*ms*/;
|
||||
const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
|
||||
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { expect } from '@storybook/jest';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { commonHandlers } from '../../../.storybook/mocks';
|
||||
import MkUrl from './MkUrl.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkUrl,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkUrl v-bind="props">Text</MkUrl>',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
const canvas = within(canvasElement);
|
||||
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||
await expect(a).toHaveAttribute('href', 'https://misskey-hub.net/');
|
||||
await userEvent.hover(a);
|
||||
/*
|
||||
await tick(); // FIXME: wait for network request
|
||||
const anchors = canvas.getAllByRole<HTMLAnchorElement>('link');
|
||||
const popup = anchors.find(anchor => anchor !== a)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||
await expect(popup).toBeInTheDocument();
|
||||
await expect(popup).toHaveAttribute('href', 'https://misskey-hub.net/');
|
||||
await expect(popup).toHaveTextContent('Misskey Hub');
|
||||
await expect(popup).toHaveTextContent('Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。');
|
||||
await expect(popup).toHaveTextContent('misskey-hub.net');
|
||||
const icon = within(popup).getByRole('img');
|
||||
await expect(icon).toBeInTheDocument();
|
||||
await expect(icon).toHaveAttribute('src', 'https://misskey-hub.net/favicon.ico');
|
||||
*/
|
||||
await userEvent.unhover(a);
|
||||
},
|
||||
args: {
|
||||
url: 'https://misskey-hub.net/',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.get('/url', (req, res, ctx) => {
|
||||
return res(ctx.json({
|
||||
title: 'Misskey Hub',
|
||||
icon: 'https://misskey-hub.net/favicon.ico',
|
||||
description: 'Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。',
|
||||
thumbnail: null,
|
||||
player: {
|
||||
url: null,
|
||||
width: null,
|
||||
height: null,
|
||||
allow: [],
|
||||
},
|
||||
sitename: 'misskey-hub.net',
|
||||
sensitive: false,
|
||||
url: 'https://misskey-hub.net/',
|
||||
}));
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkUrl>;
|
|
@ -0,0 +1,57 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { expect } from '@storybook/jest';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { userDetailed } from '../../../.storybook/fakes';
|
||||
import MkUserName from './MkUserName.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkUserName,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkUserName v-bind="props"/>',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(userDetailed.name);
|
||||
},
|
||||
args: {
|
||||
user: userDetailed,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkUserName>;
|
||||
export const Anonymous = {
|
||||
...Default,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(userDetailed.username);
|
||||
},
|
||||
args: {
|
||||
...Default.args,
|
||||
user: {
|
||||
...userDetailed,
|
||||
name: null,
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkUserName>;
|
||||
export const Wrap = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
nowrap: false,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkUserName>;
|
|
@ -0,0 +1,3 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import RouterView from './RouterView.vue';
|
||||
void RouterView;
|
12
packages/frontend/src/index.mdx
Normal file
12
packages/frontend/src/index.mdx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { Meta } from '@storybook/blocks'
|
||||
|
||||
<Meta title="index" />
|
||||
|
||||
# Welcome to Misskey Storybook
|
||||
|
||||
This project uses [Storybook](https://storybook.js.org/) to develop and document components.
|
||||
You can find more information about the usage of Storybook in this project in the CONTRIBUTING.md file placed in the root of this repository.
|
||||
|
||||
The Misskey Storybook is under development and not all components are documented yet.
|
||||
Contributions are welcome! Please refer to [#10336](https://github.com/misskey-dev/misskey/issues/10336) for more information.
|
||||
Thank you for your support!
|
|
@ -7,7 +7,7 @@
|
|||
<MkSwitch v-model="useObjectStorage">{{ i18n.ts.useObjectStorage }}</MkSwitch>
|
||||
|
||||
<template v-if="useObjectStorage">
|
||||
<MkInput v-model="objectStorageBaseUrl">
|
||||
<MkInput v-model="objectStorageBaseUrl" :placeholder="'https://example.com'">
|
||||
<template #label>{{ i18n.ts.objectStorageBaseUrl }}</template>
|
||||
<template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template>
|
||||
</MkInput>
|
||||
|
@ -22,8 +22,9 @@
|
|||
<template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput v-model="objectStorageEndpoint">
|
||||
<MkInput v-model="objectStorageEndpoint" :placeholder="'example.com'">
|
||||
<template #label>{{ i18n.ts.objectStorageEndpoint }}</template>
|
||||
<template #prefix>https://</template>
|
||||
<template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template>
|
||||
</MkInput>
|
||||
|
||||
|
@ -60,6 +61,7 @@
|
|||
|
||||
<MkSwitch v-model="objectStorageS3ForcePathStyle">
|
||||
<template #label>s3ForcePathStyle</template>
|
||||
<template #caption>{{ i18n.ts.s3ForcePathStyleDesc }}</template>
|
||||
</MkSwitch>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
@ -77,7 +77,10 @@ async function renderChart() {
|
|||
barPercentage: 0.7,
|
||||
categoryPercentage: 0.7,
|
||||
fill: true,
|
||||
} satisfies ChartDataset, extra);
|
||||
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||
} satisfies ChartData, extra);
|
||||
*/
|
||||
}, extra);
|
||||
}
|
||||
|
||||
chartInstance = new Chart(chartEl, {
|
||||
|
|
|
@ -113,6 +113,9 @@ async function renderChart() {
|
|||
const a = c.chart.chartArea ?? {};
|
||||
return (a.bottom - a.top) / 7 - marginEachCell;
|
||||
},
|
||||
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||
}] satisfies ChartData[],
|
||||
*/
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
|
|
|
@ -76,7 +76,10 @@ async function renderChart() {
|
|||
borderRadius: 4,
|
||||
barPercentage: 0.9,
|
||||
fill: true,
|
||||
} satisfies ChartDataset, extra);
|
||||
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||
} satisfies ChartData, extra);
|
||||
*/
|
||||
}, extra);
|
||||
}
|
||||
|
||||
chartInstance = new Chart(chartEl, {
|
||||
|
|
|
@ -77,7 +77,10 @@ async function renderChart() {
|
|||
barPercentage: 0.7,
|
||||
categoryPercentage: 0.7,
|
||||
fill: true,
|
||||
} satisfies ChartDataset, extra);
|
||||
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||
} satisfies ChartData, extra);
|
||||
*/
|
||||
}, extra);
|
||||
}
|
||||
|
||||
chartInstance = new Chart(chartEl, {
|
||||
|
|
|
@ -443,11 +443,14 @@ export const ACHIEVEMENT_BADGES = {
|
|||
bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||
} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], {
|
||||
img: string;
|
||||
bg: string | null;
|
||||
frame: 'bronze' | 'silver' | 'gold' | 'platinum';
|
||||
}>;
|
||||
*/
|
||||
} as const;
|
||||
|
||||
export const claimedAchievements: typeof ACHIEVEMENT_TYPES[number][] = ($i && $i.achievements) ? $i.achievements.map(x => x.name) : [];
|
||||
|
||||
|
|
6
packages/frontend/src/scripts/test-utils.ts
Normal file
6
packages/frontend/src/scripts/test-utils.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="@testing-library/jest-dom"/>
|
||||
|
||||
export async function tick(): Promise<void> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
await new Promise((globalThis.requestIdleCallback ?? setTimeout) as never);
|
||||
}
|
|
@ -43,5 +43,8 @@
|
|||
".eslintrc.js",
|
||||
"./**/*.ts",
|
||||
"./**/*.vue"
|
||||
],
|
||||
"exclude": [
|
||||
".storybook/**/*",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import path from 'path';
|
||||
import pluginVue from '@vitejs/plugin-vue';
|
||||
import { defineConfig } from 'vite';
|
||||
import { configDefaults as vitestConfigDefaults } from 'vitest/config';
|
||||
import { type UserConfig, defineConfig } from 'vite';
|
||||
|
||||
import locales from '../../locales';
|
||||
import meta from '../../package.json';
|
||||
|
@ -38,7 +37,7 @@ function toBase62(n: number): string {
|
|||
return result;
|
||||
}
|
||||
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
export function getConfig(): UserConfig {
|
||||
return {
|
||||
base: '/vite/',
|
||||
|
||||
|
@ -62,7 +61,7 @@ export default defineConfig(({ command, mode }) => {
|
|||
|
||||
css: {
|
||||
modules: {
|
||||
generateScopedName: (name, filename, css) => {
|
||||
generateScopedName(name, filename, _css): string {
|
||||
const id = (path.relative(__dirname, filename.split('?')[0]) + '-' + name).replace(/[\\\/\.\?&=]/g, '-').replace(/(src-|vue-)/g, '');
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return 'x' + toBase62(hash(id)).substring(0, 4);
|
||||
|
@ -132,4 +131,8 @@ export default defineConfig(({ command, mode }) => {
|
|||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const config = defineConfig(({ command, mode }) => getConfig());
|
||||
|
||||
export default config;
|
||||
|
|
|
@ -21,11 +21,11 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/api-extractor": "7.34.4",
|
||||
"@swc/jest": "0.2.24",
|
||||
"@types/jest": "29.5.0",
|
||||
"@types/node": "18.15.11",
|
||||
"@typescript-eslint/eslint-plugin": "5.57.0",
|
||||
"@typescript-eslint/parser": "5.57.0",
|
||||
"@swc/jest": "0.2.24",
|
||||
"eslint": "8.37.0",
|
||||
"jest": "^29.5.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
|
|
5580
pnpm-lock.yaml
5580
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue