Merge branch 'develop' into mkjs-n

This commit is contained in:
tamaina 2023-05-19 07:16:42 +00:00
commit c0cbbe6072
268 changed files with 4840 additions and 5205 deletions

View File

@ -16,12 +16,22 @@ jobs:
steps: steps:
- uses: actions/checkout@v3.3.0 - uses: actions/checkout@v3.3.0
if: github.event_name != 'pull_request_target'
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- name: Checkout HEAD - uses: actions/checkout@v3.3.0
if: github.event_name == 'pull_request_target' if: github.event_name == 'pull_request_target'
run: git checkout ${{ github.head_ref }} with:
fetch-depth: 0
submodules: true
ref: "refs/pull/${{ github.event.number }}/merge"
- name: Checkout actual HEAD
if: github.event_name == 'pull_request_target'
id: rev
run: |
echo "base=$(git rev-list --parents -n1 HEAD | cut -d" " -f2)" >> $GITHUB_OUTPUT
git checkout $(git rev-list --parents -n1 HEAD | cut -d" " -f3)
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
with: with:
@ -68,7 +78,7 @@ jobs:
if: github.event_name == 'pull_request_target' if: github.event_name == 'pull_request_target'
id: chromatic_pull_request id: chromatic_pull_request
run: | run: |
DIFF="${{ github.base_ref }} HEAD" DIFF="${{ steps.rev.outputs.base }} HEAD"
if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then
DIFF="HEAD" DIFF="HEAD"
fi fi
@ -91,18 +101,6 @@ jobs:
commit_sha: context.sha, commit_sha: context.sha,
body: 'Chromatic detects changes. Please [review the changes on Chromatic](https://www.chromatic.com/builds?appId=6428f7d7b962f0b79f97d6e4).' body: 'Chromatic detects changes. Please [review the changes on Chromatic](https://www.chromatic.com/builds?appId=6428f7d7b962f0b79f97d6e4).'
}) })
- name: Notify that Chromatic will skip testing
uses: actions/github-script@v6.4.0
if: github.event_name == 'pull_request_target' && steps.chromatic_pull_request.outputs.skip == 'true'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Chromatic will skip testing but you may still have to [review the changes on Chromatic](https://www.chromatic.com/pullrequests?appId=6428f7d7b962f0b79f97d6e4).'
})
- name: Upload Artifacts - name: Upload Artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:

View File

@ -12,6 +12,24 @@
--> -->
## 13.13.0
### General
- カスタム絵文字ごとにそれをリアクションとして使えるロールを設定できるように
- カスタム絵文字ごとに連合するかどうか設定できるように
- カスタム絵文字ごとにセンシティブフラグを設定できるように
- センシティブなカスタム絵文字のリアクションを受け入れない設定が可能に
- タイムラインにフォロイーの行った他人へのリプライを含めるかどうかの設定をアカウントに保存するのをやめるように
- 今後はAPI呼び出し時およびストリーミング接続時に設定するようになります
- リストを公開できるようになりました
### Client
- リアクションの取り消し/変更時に確認ダイアログを出すように
- 開発者モードを追加
- AiScriptを0.13.3に更新
- Fix: URLプレビューで情報が取得できなかった際の挙動を修正
- Fix: Safari、Firefoxでの新規登録時、パスワードマネージャーにメールアドレスが登録されていた挙動を修正
## 13.12.2 ## 13.12.2
## NOTE ## NOTE
@ -87,6 +105,7 @@ Meilisearchの設定に`index`が必要になりました。値はMisskeyサー
* 画像が全て隠れた状態で表示されるようになります * 画像が全て隠れた状態で表示されるようになります
- 閲覧注意設定された画像は表示した状態でもそれが閲覧注意だと分かる表示をするように - 閲覧注意設定された画像は表示した状態でもそれが閲覧注意だと分かる表示をするように
- モデレーターはートに添付された画像上から直接NSFW設定できるように - モデレーターはートに添付された画像上から直接NSFW設定できるように
- 1枚だけのメディアリストの画像のアスペクト比を画像に応じて縦長にするように
- プロフィール設定「追加情報」の項目の削除と並び替えができるように - プロフィール設定「追加情報」の項目の削除と並び替えができるように
- 新しい実績を追加 - 新しい実績を追加
- AiScriptを0.13.2に更新 - AiScriptを0.13.2に更新

View File

@ -52,6 +52,8 @@ addToList: "リストに追加"
sendMessage: "メッセージを送信" sendMessage: "メッセージを送信"
copyRSS: "RSSをコピー" copyRSS: "RSSをコピー"
copyUsername: "ユーザー名をコピー" copyUsername: "ユーザー名をコピー"
copyUserId: "ユーザーIDをコピー"
copyNoteId: "ートIDをコピー"
searchUser: "ユーザーを検索" searchUser: "ユーザーを検索"
reply: "返信" reply: "返信"
loadMore: "もっと見る" loadMore: "もっと見る"
@ -823,6 +825,7 @@ translatedFrom: "{x}から翻訳"
accountDeletionInProgress: "アカウントの削除が進行中です" accountDeletionInProgress: "アカウントの削除が進行中です"
usernameInfo: "サーバー上であなたのアカウントを一意に識別するための名前。アルファベット(a~z, A~Z)、数字(0~9)、およびアンダーバー(_)が使用できます。ユーザー名は後から変更することは出来ません。" usernameInfo: "サーバー上であなたのアカウントを一意に識別するための名前。アルファベット(a~z, A~Z)、数字(0~9)、およびアンダーバー(_)が使用できます。ユーザー名は後から変更することは出来ません。"
aiChanMode: "藍モード" aiChanMode: "藍モード"
devMode: "開発者モード"
keepCw: "CWを維持する" keepCw: "CWを維持する"
pubSub: "Pub/Subのアカウント" pubSub: "Pub/Subのアカウント"
lastCommunication: "直近の通信" lastCommunication: "直近の通信"
@ -987,7 +990,9 @@ postToTheChannel: "チャンネルに投稿"
cannotBeChangedLater: "後から変更できません。" cannotBeChangedLater: "後から変更できません。"
reactionAcceptance: "リアクションの受け入れ" reactionAcceptance: "リアクションの受け入れ"
likeOnly: "いいねのみ" likeOnly: "いいねのみ"
likeOnlyForRemote: "リモートからはいいねのみ" likeOnlyForRemote: "全て (リモートはいいねのみ)"
nonSensitiveOnly: "非センシティブのみ"
nonSensitiveOnlyForLocalLikeOnlyForRemote: "非センシティブのみ (リモートはいいねのみ)"
rolesAssignedToMe: "自分に割り当てられたロール" rolesAssignedToMe: "自分に割り当てられたロール"
resetPasswordConfirm: "パスワードリセットしますか?" resetPasswordConfirm: "パスワードリセットしますか?"
sensitiveWords: "センシティブワード" sensitiveWords: "センシティブワード"
@ -1045,6 +1050,13 @@ preventAiLearning: "生成AIによる学習を拒否"
preventAiLearningDescription: "外部の文章生成AIや画像生成AIに対して、投稿したートや画像などのコンテンツを学習の対象にしないように要求します。これはnoaiフラグをHTMLレスポンスに含めることによって実現されますが、この要求に従うかはそのAI次第であるため、学習を完全に防止するものではありません。" preventAiLearningDescription: "外部の文章生成AIや画像生成AIに対して、投稿したートや画像などのコンテンツを学習の対象にしないように要求します。これはnoaiフラグをHTMLレスポンスに含めることによって実現されますが、この要求に従うかはそのAI次第であるため、学習を完全に防止するものではありません。"
options: "オプション" options: "オプション"
specifyUser: "ユーザー指定" specifyUser: "ユーザー指定"
failedToPreviewUrl: "プレビューできません"
update: "更新"
rolesThatCanBeUsedThisEmojiAsReaction: "リアクションとして使えるロール"
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "ロールの指定が一つもない場合、誰でもリアクションとして使えます。"
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "ロールは公開ロールである必要があります。"
cancelReactionConfirm: "リアクションを取り消しますか?"
changeReactionConfirm: "リアクションを変更しますか?"
_initialAccountSetting: _initialAccountSetting:
accountCreated: "アカウントの作成が完了しました!" accountCreated: "アカウントの作成が完了しました!"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "13.12.2", "version": "13.13.0-beta.2",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -0,0 +1,13 @@
export class UserList1683847157541 {
name = 'UserList1683847157541'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list" ADD "isPublic" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`CREATE INDEX "IDX_48a00f08598662b9ca540521eb" ON "user_list" ("isPublic") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_48a00f08598662b9ca540521eb"`);
await queryRunner.query(`ALTER TABLE "user_list" DROP COLUMN "isPublic"`);
}
}

View File

@ -0,0 +1,19 @@
export class UserListFavorites1683869758873 {
name = 'UserListFavorites1683869758873'
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "user_list_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userListId" character varying(32) NOT NULL, CONSTRAINT "PK_c0974b21e18502a4c8178e09fe6" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_016f613dc4feb807e03e3e7da9" ON "user_list_favorite" ("userId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d6765a8c2a4c17c33f9d7f948b" ON "user_list_favorite" ("userId", "userListId") `);
await queryRunner.query(`ALTER TABLE "user_list_favorite" ADD CONSTRAINT "FK_016f613dc4feb807e03e3e7da92" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "user_list_favorite" ADD CONSTRAINT "FK_4d52b20bfe32c8552e7a61e80d2" FOREIGN KEY ("userListId") REFERENCES "user_list"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_favorite" DROP CONSTRAINT "FK_4d52b20bfe32c8552e7a61e80d2"`);
await queryRunner.query(`ALTER TABLE "user_list_favorite" DROP CONSTRAINT "FK_016f613dc4feb807e03e3e7da92"`);
await queryRunner.query(`DROP INDEX "public"."IDX_d6765a8c2a4c17c33f9d7f948b"`);
await queryRunner.query(`DROP INDEX "public"."IDX_016f613dc4feb807e03e3e7da9"`);
await queryRunner.query(`DROP TABLE "user_list_favorite"`);
}
}

View File

@ -0,0 +1,11 @@
export class RemoveShowTimelineReplies1684206886988 {
name = 'RemoveShowTimelineReplies1684206886988'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "showTimelineReplies"`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "showTimelineReplies" boolean NOT NULL DEFAULT false`);
}
}

View File

@ -0,0 +1,15 @@
export class EmojiImprove1684386446061 {
name = 'EmojiImprove1684386446061'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "emoji" ADD "localOnly" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "emoji" ADD "isSensitive" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "emoji" ADD "roleIdsThatCanBeUsedThisEmojiAsReaction" character varying(128) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "roleIdsThatCanBeUsedThisEmojiAsReaction"`);
await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "isSensitive"`);
await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "localOnly"`);
}
}

View File

@ -144,7 +144,7 @@ export function loadConfig() {
const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json'); const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json');
const clientManifest = clientManifestExists ? const clientManifest = clientManifestExists ?
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_vite_/manifest.json`, 'utf-8')) JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_vite_/manifest.json`, 'utf-8'))
: { 'src/init.ts': { file: 'src/init.ts' } }; : { 'src/_boot_.ts': { file: 'src/_boot_.ts' } };
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
const mixin = {} as Mixin; const mixin = {} as Mixin;
@ -165,7 +165,7 @@ export function loadConfig() {
mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`; mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`;
mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`; mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`;
mixin.userAgent = `Misskey/${meta.version} (${config.url})`; mixin.userAgent = `Misskey/${meta.version} (${config.url})`;
mixin.clientEntry = clientManifest['src/init.ts']; mixin.clientEntry = clientManifest['src/_boot_.ts'];
mixin.clientManifestExists = clientManifestExists; mixin.clientManifestExists = clientManifestExists;
const externalMediaProxy = config.mediaProxy ? const externalMediaProxy = config.mediaProxy ?

View File

@ -7,7 +7,7 @@ import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Emoji } from '@/models/entities/Emoji.js'; import type { Emoji } from '@/models/entities/Emoji.js';
import type { EmojisRepository } from '@/models/index.js'; import type { EmojisRepository, Role } from '@/models/index.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
@ -15,6 +15,8 @@ import type { Config } from '@/config.js';
import { query } from '@/misc/prelude/url.js'; import { query } from '@/misc/prelude/url.js';
import type { Serialized } from '@/server/api/stream/types.js'; import type { Serialized } from '@/server/api/stream/types.js';
const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/;
@Injectable() @Injectable()
export class CustomEmojiService { export class CustomEmojiService {
private cache: MemoryKVCache<Emoji | null>; private cache: MemoryKVCache<Emoji | null>;
@ -63,6 +65,9 @@ export class CustomEmojiService {
aliases: string[]; aliases: string[];
host: string | null; host: string | null;
license: string | null; license: string | null;
isSensitive: boolean;
localOnly: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction: Role['id'][];
}): Promise<Emoji> { }): Promise<Emoji> {
const emoji = await this.emojisRepository.insert({ const emoji = await this.emojisRepository.insert({
id: this.idService.genId(), id: this.idService.genId(),
@ -75,6 +80,9 @@ export class CustomEmojiService {
publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url,
type: data.driveFile.webpublicType ?? data.driveFile.type, type: data.driveFile.webpublicType ?? data.driveFile.type,
license: data.license, license: data.license,
isSensitive: data.isSensitive,
localOnly: data.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction,
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
if (data.host == null) { if (data.host == null) {
@ -90,10 +98,14 @@ export class CustomEmojiService {
@bindThis @bindThis
public async update(id: Emoji['id'], data: { public async update(id: Emoji['id'], data: {
driveFile?: DriveFile;
name?: string; name?: string;
category?: string | null; category?: string | null;
aliases?: string[]; aliases?: string[];
license?: string | null; license?: string | null;
isSensitive?: boolean;
localOnly?: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction?: Role['id'][];
}): Promise<void> { }): Promise<void> {
const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); const emoji = await this.emojisRepository.findOneByOrFail({ id: id });
const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() }); const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() });
@ -105,6 +117,12 @@ export class CustomEmojiService {
category: data.category, category: data.category,
aliases: data.aliases, aliases: data.aliases,
license: data.license, license: data.license,
isSensitive: data.isSensitive,
localOnly: data.localOnly,
originalUrl: data.driveFile != null ? data.driveFile.url : undefined,
publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined,
type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined,
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined,
}); });
this.localEmojisCache.refresh(); this.localEmojisCache.refresh();
@ -259,7 +277,7 @@ export class CustomEmojiService {
@bindThis @bindThis
public parseEmojiStr(emojiName: string, noteUserHost: string | null) { public parseEmojiStr(emojiName: string, noteUserHost: string | null) {
const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); const match = emojiName.match(parseEmojiStrRegexp);
if (!match) return { name: null, host: null }; if (!match) return { name: null, host: null };
const name = match[1]; const name = match[1];

View File

@ -83,7 +83,7 @@ export class MfmService {
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
text += txt; text += txt;
// メンション // メンション
} else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) { } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {
const part = txt.split('@'); const part = txt.split('@');
if (part.length === 2 && href) { if (part.length === 2 && href) {

View File

@ -208,7 +208,7 @@ export class QueryService {
} }
@bindThis @bindThis
public generateRepliesQuery(q: SelectQueryBuilder<any>, me?: Pick<User, 'id' | 'showTimelineReplies'> | null): void { public generateRepliesQuery(q: SelectQueryBuilder<any>, withReplies: boolean, me?: Pick<User, 'id'> | null): void {
if (me == null) { if (me == null) {
q.andWhere(new Brackets(qb => { qb q.andWhere(new Brackets(qb => { qb
.where('note.replyId IS NULL') // 返信ではない .where('note.replyId IS NULL') // 返信ではない
@ -217,7 +217,7 @@ export class QueryService {
.andWhere('note.replyUserId = note.userId'); .andWhere('note.replyUserId = note.userId');
})); }));
})); }));
} else if (!me.showTimelineReplies) { } else if (!withReplies) {
q.andWhere(new Brackets(qb => { qb q.andWhere(new Brackets(qb => { qb
.where('note.replyId IS NULL') // 返信ではない .where('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信 .orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信

View File

@ -20,6 +20,7 @@ import { bindThis } from '@/decorators.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { RoleService } from '@/core/RoleService.js';
const FALLBACK = '❤'; const FALLBACK = '❤';
@ -54,6 +55,9 @@ type DecodedReaction = {
host?: string | null; host?: string | null;
}; };
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/;
@Injectable() @Injectable()
export class ReactionService { export class ReactionService {
constructor( constructor(
@ -72,6 +76,7 @@ export class ReactionService {
private utilityService: UtilityService, private utilityService: UtilityService,
private metaService: MetaService, private metaService: MetaService,
private customEmojiService: CustomEmojiService, private customEmojiService: CustomEmojiService,
private roleService: RoleService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private userBlockingService: UserBlockingService, private userBlockingService: UserBlockingService,
@ -85,7 +90,7 @@ export class ReactionService {
} }
@bindThis @bindThis
public async create(user: { id: User['id']; host: User['host']; isBot: User['isBot'] }, note: Note, reaction?: string | null) { public async create(user: { id: User['id']; host: User['host']; isBot: User['isBot'] }, note: Note, _reaction?: string | null) {
// Check blocking // Check blocking
if (note.userId !== user.id) { if (note.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id); const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
@ -99,10 +104,41 @@ export class ReactionService {
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
} }
if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) { let reaction = _reaction ?? FALLBACK;
if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && (user.host != null))) {
reaction = '❤️'; reaction = '❤️';
} else { } else if (_reaction) {
reaction = await this.toDbReaction(reaction, user.host); const custom = reaction.match(isCustomEmojiRegexp);
if (custom) {
const reacterHost = this.utilityService.toPunyNullable(user.host);
const name = custom[1];
const emoji = reacterHost == null
? (await this.customEmojiService.localEmojisCache.fetch()).get(name)
: await this.emojisRepository.findOneBy({
host: reacterHost,
name,
});
if (emoji) {
if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || (await this.roleService.getUserRoles(user.id)).some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))) {
reaction = reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
// センシティブ
if ((note.reactionAcceptance === 'nonSensitiveOnly') && emoji.isSensitive) {
reaction = FALLBACK;
}
} else {
// リアクションとして使う権限がない
reaction = FALLBACK;
}
} else {
reaction = FALLBACK;
}
} else {
reaction = this.normalize(reaction ?? null);
}
} }
const record: NoteReaction = { const record: NoteReaction = {
@ -288,11 +324,9 @@ export class ReactionService {
} }
@bindThis @bindThis
public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> { public normalize(reaction: string | null): string {
if (reaction == null) return FALLBACK; if (reaction == null) return FALLBACK;
reacterHost = this.utilityService.toPunyNullable(reacterHost);
// 文字列タイプのリアクションを絵文字に変換 // 文字列タイプのリアクションを絵文字に変換
if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
@ -306,25 +340,12 @@ export class ReactionService {
return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, ''); return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
} }
const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/);
if (custom) {
const name = custom[1];
const emoji = reacterHost == null
? (await this.customEmojiService.localEmojisCache.fetch()).get(name)
: await this.emojisRepository.findOneBy({
host: reacterHost,
name,
});
if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
}
return FALLBACK; return FALLBACK;
} }
@bindThis @bindThis
public decodeReaction(str: string): DecodedReaction { public decodeReaction(str: string): DecodedReaction {
const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/); const custom = str.match(decodeCustomEmojiRegexp);
if (custom) { if (custom) {
const name = custom[1]; const name = custom[1];

View File

@ -16,6 +16,9 @@ type IWebFinger = {
subject: string; subject: string;
}; };
const urlRegex = /^https?:\/\//;
const mRegex = /^([^@]+)@(.*)/;
@Injectable() @Injectable()
export class WebfingerService { export class WebfingerService {
constructor( constructor(
@ -35,12 +38,12 @@ export class WebfingerService {
@bindThis @bindThis
private genUrl(query: string): string { private genUrl(query: string): string {
if (query.match(/^https?:\/\//)) { if (query.match(urlRegex)) {
const u = new URL(query); const u = new URL(query);
return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query }); return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query });
} }
const m = query.match(/^([^@]+)@(.*)/); const m = query.match(mRegex);
if (m) { if (m) {
const hostname = m[2]; const hostname = m[2];
const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true'; const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true';

View File

@ -277,7 +277,7 @@ export class ApRendererService {
const name = reaction.replaceAll(':', ''); const name = reaction.replaceAll(':', '');
const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name); const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name);
if (emoji) object.tag = [this.renderEmoji(emoji)]; if (emoji && !emoji.localOnly) object.tag = [this.renderEmoji(emoji)];
} }
return object; return object;
@ -400,7 +400,7 @@ export class ApRendererService {
})); }));
const emojis = await this.getEmojis(note.emojis); const emojis = await this.getEmojis(note.emojis);
const apemojis = emojis.map(emoji => this.renderEmoji(emoji)); const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
const tag = [ const tag = [
...hashtagTags, ...hashtagTags,
@ -479,7 +479,7 @@ export class ApRendererService {
} }
const emojis = await this.getEmojis(user.emojis); const emojis = await this.getEmojis(user.emojis);
const apemojis = emojis.map(emoji => this.renderEmoji(emoji)); const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
const hashtagTags = (user.tags ?? []).map(tag => this.renderHashtag(tag)); const hashtagTags = (user.tags ?? []).map(tag => this.renderHashtag(tag));

View File

@ -32,6 +32,8 @@ import type { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { AccountMoveService } from '@/core/AccountMoveService.js';
import { checkHttps } from '@/misc/check-https.js';
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
import { extractApHashtags } from './tag.js'; import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
@ -42,8 +44,6 @@ import type { ApLoggerService } from '../ApLoggerService.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports // eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { ApImageService } from './ApImageService.js'; import type { ApImageService } from './ApImageService.js';
import type { IActor, IObject } from '../type.js'; import type { IActor, IObject } from '../type.js';
import type { AccountMoveService } from '@/core/AccountMoveService.js';
import { checkHttps } from '@/misc/check-https.js';
const nameLength = 128; const nameLength = 128;
const summaryLength = 2048; const summaryLength = 2048;
@ -306,7 +306,6 @@ export class ApPersonService implements OnModuleInit {
tags, tags,
isBot, isBot,
isCat: (person as any).isCat === true, isCat: (person as any).isCat === true,
showTimelineReplies: false,
})) as RemoteUser; })) as RemoteUser;
await transactionalEntityManager.save(new UserProfile({ await transactionalEntityManager.save(new UserProfile({
@ -696,7 +695,7 @@ export class ApPersonService implements OnModuleInit {
if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) { if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) {
return 'skip: dst.alsoKnownAs is empty'; return 'skip: dst.alsoKnownAs is empty';
} }
if (!dst.alsoKnownAs?.includes(src.uri)) { if (!dst.alsoKnownAs.includes(src.uri)) {
return 'skip: alsoKnownAs does not include from.uri'; return 'skip: alsoKnownAs does not include from.uri';
} }

View File

@ -26,6 +26,8 @@ export class EmojiEntityService {
category: emoji.category, category: emoji.category,
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: emoji.publicUrl || emoji.originalUrl, url: emoji.publicUrl || emoji.originalUrl,
isSensitive: emoji.isSensitive ? true : undefined,
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined,
}; };
} }
@ -51,6 +53,9 @@ export class EmojiEntityService {
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: emoji.publicUrl || emoji.originalUrl, url: emoji.publicUrl || emoji.originalUrl,
license: emoji.license, license: emoji.license,
isSensitive: emoji.isSensitive,
localOnly: emoji.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction,
}; };
} }

View File

@ -466,7 +466,6 @@ export class UserEntityService implements OnModuleInit {
mutedInstances: profile!.mutedInstances, mutedInstances: profile!.mutedInstances,
mutingNotificationTypes: profile!.mutingNotificationTypes, mutingNotificationTypes: profile!.mutingNotificationTypes,
emailNotificationTypes: profile!.emailNotificationTypes, emailNotificationTypes: profile!.emailNotificationTypes,
showTimelineReplies: user.showTimelineReplies ?? falsy,
achievements: profile!.achievements, achievements: profile!.achievements,
loggedInDays: profile!.loggedInDates.length, loggedInDays: profile!.loggedInDates.length,
policies: this.roleService.getUserPolicies(user.id), policies: this.roleService.getUserPolicies(user.id),

View File

@ -35,6 +35,7 @@ export class UserListEntityService {
createdAt: userList.createdAt.toISOString(), createdAt: userList.createdAt.toISOString(),
name: userList.name, name: userList.name,
userIds: users.map(x => x.userId), userIds: users.map(x => x.userId),
isPublic: userList.isPublic,
}; };
} }
} }

View File

@ -25,6 +25,7 @@ export const DI = {
userSecurityKeysRepository: Symbol('userSecurityKeysRepository'), userSecurityKeysRepository: Symbol('userSecurityKeysRepository'),
userPublickeysRepository: Symbol('userPublickeysRepository'), userPublickeysRepository: Symbol('userPublickeysRepository'),
userListsRepository: Symbol('userListsRepository'), userListsRepository: Symbol('userListsRepository'),
userListFavoritesRepository: Symbol('userListFavoritesRepository'),
userListJoiningsRepository: Symbol('userListJoiningsRepository'), userListJoiningsRepository: Symbol('userListJoiningsRepository'),
userNotePiningsRepository: Symbol('userNotePiningsRepository'), userNotePiningsRepository: Symbol('userNotePiningsRepository'),
userIpsRepository: Symbol('userIpsRepository'), userIpsRepository: Symbol('userIpsRepository'),

View File

@ -1,6 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, 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, UserMemo } from './index.js'; import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo, UserListFavorite } from './index.js';
import type { DataSource } from 'typeorm'; import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
@ -112,6 +112,12 @@ const $userListsRepository: Provider = {
inject: [DI.db], inject: [DI.db],
}; };
const $userListFavoritesRepository: Provider = {
provide: DI.userListFavoritesRepository,
useFactory: (db: DataSource) => db.getRepository(UserListFavorite),
inject: [DI.db],
};
const $userListJoiningsRepository: Provider = { const $userListJoiningsRepository: Provider = {
provide: DI.userListJoiningsRepository, provide: DI.userListJoiningsRepository,
useFactory: (db: DataSource) => db.getRepository(UserListJoining), useFactory: (db: DataSource) => db.getRepository(UserListJoining),
@ -416,6 +422,7 @@ const $userMemosRepository: Provider = {
$userSecurityKeysRepository, $userSecurityKeysRepository,
$userPublickeysRepository, $userPublickeysRepository,
$userListsRepository, $userListsRepository,
$userListFavoritesRepository,
$userListJoiningsRepository, $userListJoiningsRepository,
$userNotePiningsRepository, $userNotePiningsRepository,
$userIpsRepository, $userIpsRepository,
@ -483,6 +490,7 @@ const $userMemosRepository: Provider = {
$userSecurityKeysRepository, $userSecurityKeysRepository,
$userPublickeysRepository, $userPublickeysRepository,
$userListsRepository, $userListsRepository,
$userListFavoritesRepository,
$userListJoiningsRepository, $userListJoiningsRepository,
$userNotePiningsRepository, $userNotePiningsRepository,
$userIpsRepository, $userIpsRepository,

View File

@ -60,4 +60,20 @@ export class Emoji {
length: 1024, nullable: true, length: 1024, nullable: true,
}) })
public license: string | null; public license: string | null;
@Column('boolean', {
default: false,
})
public localOnly: boolean;
@Column('boolean', {
default: false,
})
public isSensitive: boolean;
// TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする
@Column('varchar', {
array: true, length: 128, default: '{}',
})
public roleIdsThatCanBeUsedThisEmojiAsReaction: string[];
} }

View File

@ -90,7 +90,7 @@ export class Note {
@Column('varchar', { @Column('varchar', {
length: 64, nullable: true, length: 64, nullable: true,
}) })
public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | null; public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null;
@Column('smallint', { @Column('smallint', {
default: 0, default: 0,

View File

@ -232,12 +232,6 @@ export class User {
}) })
public followersUri: string | null; public followersUri: string | null;
@Column('boolean', {
default: false,
comment: 'Whether to show users replying to other users in the timeline.',
})
public showTimelineReplies: boolean;
@Index({ unique: true }) @Index({ unique: true })
@Column('char', { @Column('char', {
length: 16, nullable: true, unique: true, length: 16, nullable: true, unique: true,

View File

@ -19,6 +19,12 @@ export class UserList {
}) })
public userId: User['id']; public userId: User['id'];
@Index()
@Column('boolean', {
default: false,
})
public isPublic: boolean;
@ManyToOne(type => User, { @ManyToOne(type => User, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })

View File

@ -0,0 +1,33 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from '../id.js';
import { User } from './User.js';
import { UserList } from './UserList.js';
@Entity()
@Index(['userId', 'userListId'], { unique: true })
export class UserListFavorite {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone')
public createdAt: Date;
@Index()
@Column(id())
public userId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: User | null;
@Column(id())
public userListId: UserList['id'];
@ManyToOne(type => UserList, {
onDelete: 'CASCADE',
})
@JoinColumn()
public userList: UserList | null;
}

View File

@ -49,6 +49,7 @@ import { User } from '@/models/entities/User.js';
import { UserIp } from '@/models/entities/UserIp.js'; import { UserIp } from '@/models/entities/UserIp.js';
import { UserKeypair } from '@/models/entities/UserKeypair.js'; import { UserKeypair } from '@/models/entities/UserKeypair.js';
import { UserList } from '@/models/entities/UserList.js'; import { UserList } from '@/models/entities/UserList.js';
import { UserListFavorite } from './entities/UserListFavorite.js';
import { UserListJoining } from '@/models/entities/UserListJoining.js'; import { UserListJoining } from '@/models/entities/UserListJoining.js';
import { UserNotePining } from '@/models/entities/UserNotePining.js'; import { UserNotePining } from '@/models/entities/UserNotePining.js';
import { UserPending } from '@/models/entities/UserPending.js'; import { UserPending } from '@/models/entities/UserPending.js';
@ -117,6 +118,7 @@ export {
UserIp, UserIp,
UserKeypair, UserKeypair,
UserList, UserList,
UserListFavorite,
UserListJoining, UserListJoining,
UserNotePining, UserNotePining,
UserPending, UserPending,
@ -184,6 +186,7 @@ export type UsersRepository = Repository<User>;
export type UserIpsRepository = Repository<UserIp>; export type UserIpsRepository = Repository<UserIp>;
export type UserKeypairsRepository = Repository<UserKeypair>; export type UserKeypairsRepository = Repository<UserKeypair>;
export type UserListsRepository = Repository<UserList>; export type UserListsRepository = Repository<UserList>;
export type UserListFavoritesRepository = Repository<UserListFavorite>;
export type UserListJoiningsRepository = Repository<UserListJoining>; export type UserListJoiningsRepository = Repository<UserListJoining>;
export type UserNotePiningsRepository = Repository<UserNotePining>; export type UserNotePiningsRepository = Repository<UserNotePining>;
export type UserPendingsRepository = Repository<UserPending>; export type UserPendingsRepository = Repository<UserPending>;

View File

@ -22,6 +22,19 @@ export const packedEmojiSimpleSchema = {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
}, },
isSensitive: {
type: 'boolean',
optional: true, nullable: false,
},
roleIdsThatCanBeUsedThisEmojiAsReaction: {
type: 'array',
optional: true, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
},
}, },
} as const; } as const;
@ -63,5 +76,22 @@ export const packedEmojiDetailedSchema = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
isSensitive: {
type: 'boolean',
optional: false, nullable: false,
},
localOnly: {
type: 'boolean',
optional: false, nullable: false,
},
roleIdsThatCanBeUsedThisEmojiAsReaction: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
},
}, },
} as const; } as const;

View File

@ -25,5 +25,10 @@ export const packedUserListSchema = {
format: 'id', format: 'id',
}, },
}, },
isPublic: {
type: 'boolean',
nullable: false,
optional: false,
},
}, },
} as const; } as const;

View File

@ -57,6 +57,7 @@ import { User } from '@/models/entities/User.js';
import { UserIp } from '@/models/entities/UserIp.js'; import { UserIp } from '@/models/entities/UserIp.js';
import { UserKeypair } from '@/models/entities/UserKeypair.js'; import { UserKeypair } from '@/models/entities/UserKeypair.js';
import { UserList } from '@/models/entities/UserList.js'; import { UserList } from '@/models/entities/UserList.js';
import { UserListFavorite } from '@/models/entities/UserListFavorite.js';
import { UserListJoining } from '@/models/entities/UserListJoining.js'; import { UserListJoining } from '@/models/entities/UserListJoining.js';
import { UserNotePining } from '@/models/entities/UserNotePining.js'; import { UserNotePining } from '@/models/entities/UserNotePining.js';
import { UserPending } from '@/models/entities/UserPending.js'; import { UserPending } from '@/models/entities/UserPending.js';
@ -132,6 +133,7 @@ export const entities = [
UserKeypair, UserKeypair,
UserPublickey, UserPublickey,
UserList, UserList,
UserListFavorite,
UserListJoining, UserListJoining,
UserNotePining, UserNotePining,
UserSecurityKey, UserSecurityKey,

View File

@ -107,6 +107,9 @@ export class ImportCustomEmojisProcessorService {
aliases: emojiInfo.aliases, aliases: emojiInfo.aliases,
driveFile, driveFile,
license: emojiInfo.license, license: emojiInfo.license,
isSensitive: emojiInfo.isSensitive,
localOnly: emojiInfo.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: [],
}); });
} }

View File

@ -585,7 +585,7 @@ export class ActivityPubServerService {
name: request.params.emoji, name: request.params.emoji,
}); });
if (emoji == null) { if (emoji == null || emoji.localOnly) {
reply.code(404); reply.code(404);
return; return;
} }

View File

@ -321,6 +321,9 @@ import * as ep___users_lists_pull from './endpoints/users/lists/pull.js';
import * as ep___users_lists_push from './endpoints/users/lists/push.js'; import * as ep___users_lists_push from './endpoints/users/lists/push.js';
import * as ep___users_lists_show from './endpoints/users/lists/show.js'; import * as ep___users_lists_show from './endpoints/users/lists/show.js';
import * as ep___users_lists_update from './endpoints/users/lists/update.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js';
import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js';
import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js';
import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js';
import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_notes from './endpoints/users/notes.js';
import * as ep___users_pages from './endpoints/users/pages.js'; import * as ep___users_pages from './endpoints/users/pages.js';
import * as ep___users_reactions from './endpoints/users/reactions.js'; import * as ep___users_reactions from './endpoints/users/reactions.js';
@ -659,6 +662,9 @@ const $users_lists_pull: Provider = { provide: 'ep:users/lists/pull', useClass:
const $users_lists_push: Provider = { provide: 'ep:users/lists/push', useClass: ep___users_lists_push.default }; const $users_lists_push: Provider = { provide: 'ep:users/lists/push', useClass: ep___users_lists_push.default };
const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass: ep___users_lists_show.default }; const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass: ep___users_lists_show.default };
const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default }; const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default };
const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default };
const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default };
const $users_lists_create_from_public: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_create_from_public.default };
const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default }; const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default };
const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default }; const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default };
const $users_reactions: Provider = { provide: 'ep:users/reactions', useClass: ep___users_reactions.default }; const $users_reactions: Provider = { provide: 'ep:users/reactions', useClass: ep___users_reactions.default };
@ -1001,6 +1007,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_lists_push, $users_lists_push,
$users_lists_show, $users_lists_show,
$users_lists_update, $users_lists_update,
$users_lists_favorite,
$users_lists_unfavorite,
$users_lists_create_from_public,
$users_notes, $users_notes,
$users_pages, $users_pages,
$users_reactions, $users_reactions,
@ -1335,6 +1344,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_lists_push, $users_lists_push,
$users_lists_show, $users_lists_show,
$users_lists_update, $users_lists_update,
$users_lists_favorite,
$users_lists_unfavorite,
$users_lists_create_from_public,
$users_notes, $users_notes,
$users_pages, $users_pages,
$users_reactions, $users_reactions,

View File

@ -320,6 +320,9 @@ import * as ep___users_lists_list from './endpoints/users/lists/list.js';
import * as ep___users_lists_pull from './endpoints/users/lists/pull.js'; import * as ep___users_lists_pull from './endpoints/users/lists/pull.js';
import * as ep___users_lists_push from './endpoints/users/lists/push.js'; import * as ep___users_lists_push from './endpoints/users/lists/push.js';
import * as ep___users_lists_show from './endpoints/users/lists/show.js'; import * as ep___users_lists_show from './endpoints/users/lists/show.js';
import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js';
import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js';
import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js';
import * as ep___users_lists_update from './endpoints/users/lists/update.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js';
import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_notes from './endpoints/users/notes.js';
import * as ep___users_pages from './endpoints/users/pages.js'; import * as ep___users_pages from './endpoints/users/pages.js';
@ -656,7 +659,10 @@ const eps = [
['users/lists/pull', ep___users_lists_pull], ['users/lists/pull', ep___users_lists_pull],
['users/lists/push', ep___users_lists_push], ['users/lists/push', ep___users_lists_push],
['users/lists/show', ep___users_lists_show], ['users/lists/show', ep___users_lists_show],
['users/lists/favorite', ep___users_lists_favorite],
['users/lists/unfavorite', ep___users_lists_unfavorite],
['users/lists/update', ep___users_lists_update], ['users/lists/update', ep___users_lists_update],
['users/lists/create-from-public', ep___users_lists_create_from_public],
['users/notes', ep___users_notes], ['users/notes', ep___users_notes],
['users/pages', ep___users_pages], ['users/pages', ep___users_pages],
['users/reactions', ep___users_reactions], ['users/reactions', ep___users_reactions],

View File

@ -25,9 +25,24 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
fileId: { type: 'string', format: 'misskey:id' }, fileId: { type: 'string', format: 'misskey:id' },
category: {
type: 'string',
nullable: true,
description: 'Use `null` to reset the category.',
},
aliases: { type: 'array', items: {
type: 'string',
} },
license: { type: 'string', nullable: true },
isSensitive: { type: 'boolean' },
localOnly: { type: 'boolean' },
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
type: 'string',
} },
}, },
required: ['fileId'], required: ['name', 'fileId'],
} as const; } as const;
// TODO: ロジックをサービスに切り出す // TODO: ロジックをサービスに切り出す
@ -45,18 +60,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
const name = driveFile.name.split('.')[0].match(/^[a-z0-9_]+$/) ? driveFile.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`;
const emoji = await this.customEmojiService.add({ const emoji = await this.customEmojiService.add({
driveFile, driveFile,
name, name: ps.name,
category: null, category: ps.category ?? null,
aliases: [], aliases: ps.aliases ?? [],
host: null, host: null,
license: null, license: ps.license ?? null,
isSensitive: ps.isSensitive ?? false,
localOnly: ps.localOnly ?? false,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [],
}); });
this.moderationLogService.insertModerationLog(me, 'addEmoji', { this.moderationLogService.insertModerationLog(me, 'addEmoji', {

View File

@ -1,6 +1,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import type { DriveFilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
@ -15,6 +17,11 @@ export const meta = {
code: 'NO_SUCH_EMOJI', code: 'NO_SUCH_EMOJI',
id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8', id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8',
}, },
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
id: '14fb9fd9-0731-4e2f-aeb9-f09e4740333d',
},
sameNameEmojiExists: { sameNameEmojiExists: {
message: 'Emoji that have same name already exists.', message: 'Emoji that have same name already exists.',
code: 'SAME_NAME_EMOJI_EXISTS', code: 'SAME_NAME_EMOJI_EXISTS',
@ -28,6 +35,7 @@ export const paramDef = {
properties: { properties: {
id: { type: 'string', format: 'misskey:id' }, id: { type: 'string', format: 'misskey:id' },
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
fileId: { type: 'string', format: 'misskey:id' },
category: { category: {
type: 'string', type: 'string',
nullable: true, nullable: true,
@ -37,6 +45,11 @@ export const paramDef = {
type: 'string', type: 'string',
} }, } },
license: { type: 'string', nullable: true }, license: { type: 'string', nullable: true },
isSensitive: { type: 'boolean' },
localOnly: { type: 'boolean' },
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
type: 'string',
} },
}, },
required: ['id', 'name', 'aliases'], required: ['id', 'name', 'aliases'],
} as const; } as const;
@ -45,14 +58,28 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor( constructor(
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private customEmojiService: CustomEmojiService, private customEmojiService: CustomEmojiService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
let driveFile;
if (ps.fileId) {
driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
}
await this.customEmojiService.update(ps.id, { await this.customEmojiService.update(ps.id, {
driveFile,
name: ps.name, name: ps.name,
category: ps.category ?? null, category: ps.category ?? null,
aliases: ps.aliases, aliases: ps.aliases,
license: ps.license ?? null, license: ps.license ?? null,
isSensitive: ps.isSensitive,
localOnly: ps.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,
}); });
}); });
} }

View File

@ -141,7 +141,6 @@ export const paramDef = {
preventAiLearning: { type: 'boolean' }, preventAiLearning: { type: 'boolean' },
isBot: { type: 'boolean' }, isBot: { type: 'boolean' },
isCat: { type: 'boolean' }, isCat: { type: 'boolean' },
showTimelineReplies: { type: 'boolean' },
injectFeaturedNote: { type: 'boolean' }, injectFeaturedNote: { type: 'boolean' },
receiveAnnouncementEmail: { type: 'boolean' }, receiveAnnouncementEmail: { type: 'boolean' },
alwaysMarkNsfw: { type: 'boolean' }, alwaysMarkNsfw: { type: 'boolean' },
@ -239,7 +238,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus;
if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions; if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions;
if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot;
if (typeof ps.showTimelineReplies === 'boolean') updates.showTimelineReplies = ps.showTimelineReplies;
if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot;
if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed;
if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle;

View File

@ -99,7 +99,7 @@ export const paramDef = {
} }, } },
cw: { type: 'string', nullable: true, maxLength: 100 }, cw: { type: 'string', nullable: true, maxLength: 100 },
localOnly: { type: 'boolean', default: false }, localOnly: { type: 'boolean', default: false },
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote'], default: null }, reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
noExtractMentions: { type: 'boolean', default: false }, noExtractMentions: { type: 'boolean', default: false },
noExtractHashtags: { type: 'boolean', default: false }, noExtractHashtags: { type: 'boolean', default: false },
noExtractEmojis: { type: 'boolean', default: false }, noExtractEmojis: { type: 'boolean', default: false },

View File

@ -34,11 +34,8 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
withFiles: { withFiles: { type: 'boolean', default: false },
type: 'boolean', withReplies: { type: 'boolean', default: false },
default: false,
description: 'Only show notes that have attached files.',
},
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
@ -78,7 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateRepliesQuery(query, me); this.queryService.generateRepliesQuery(query, ps.withReplies, me);
if (me) { if (me) {
this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateMutedNoteQuery(query, me);

View File

@ -46,11 +46,8 @@ export const paramDef = {
includeMyRenotes: { type: 'boolean', default: true }, includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { withFiles: { type: 'boolean', default: false },
type: 'boolean', withReplies: { type: 'boolean', default: false },
default: false,
description: 'Only show notes that have attached files.',
},
}, },
required: [], required: [],
} as const; } as const;
@ -98,7 +95,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.setParameters(followingQuery.getParameters()); .setParameters(followingQuery.getParameters());
this.queryService.generateChannelQuery(query, me); this.queryService.generateChannelQuery(query, me);
this.queryService.generateRepliesQuery(query, me); this.queryService.generateRepliesQuery(query, ps.withReplies, me);
this.queryService.generateVisibilityQuery(query, me); this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateMutedNoteQuery(query, me);

View File

@ -36,11 +36,8 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
withFiles: { withFiles: { type: 'boolean', default: false },
type: 'boolean', withReplies: { type: 'boolean', default: false },
default: false,
description: 'Only show notes that have attached files.',
},
fileType: { type: 'array', items: { fileType: { type: 'array', items: {
type: 'string', type: 'string',
} }, } },
@ -86,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateChannelQuery(query, me); this.queryService.generateChannelQuery(query, me);
this.queryService.generateRepliesQuery(query, me); this.queryService.generateRepliesQuery(query, ps.withReplies, me);
this.queryService.generateVisibilityQuery(query, me); this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateMutedNoteQuery(query, me); if (me) this.queryService.generateMutedNoteQuery(query, me);

View File

@ -35,11 +35,8 @@ export const paramDef = {
includeMyRenotes: { type: 'boolean', default: true }, includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { withFiles: { type: 'boolean', default: false },
type: 'boolean', withReplies: { type: 'boolean', default: false },
default: false,
description: 'Only show notes that have attached files.',
},
}, },
required: [], required: [],
} as const; } as const;
@ -84,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
} }
this.queryService.generateChannelQuery(query, me); this.queryService.generateChannelQuery(query, me);
this.queryService.generateRepliesQuery(query, me); this.queryService.generateRepliesQuery(query, ps.withReplies, me);
this.queryService.generateVisibilityQuery(query, me); this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateMutedNoteQuery(query, me);

View File

@ -0,0 +1,148 @@
import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/index.js';
import { IdService } from '@/core/IdService.js';
import type { UserList } from '@/models/entities/UserList.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { GetterService } from '@/server/api/GetterService.js';
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { RoleService } from '@/core/RoleService.js';
import { UserListService } from '@/core/UserListService.js';
export const meta = {
requireCredential: true,
prohibitMoved: true,
res: {
type: 'object',
optional: false, nullable: false,
ref: 'UserList',
},
errors: {
tooManyUserLists: {
message: 'You cannot create user list any more.',
code: 'TOO_MANY_USERLISTS',
id: 'e9c105b2-c595-47de-97fb-7f7c2c33e92f',
},
noSuchList: {
message: 'No such list.',
code: 'NO_SUCH_LIST',
id: '9292f798-6175-4f7d-93f4-b6742279667d',
},
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '13c457db-a8cb-4d88-b70a-211ceeeabb5f',
},
alreadyAdded: {
message: 'That user has already been added to that list.',
code: 'ALREADY_ADDED',
id: 'c3ad6fdb-692b-47ee-a455-7bd12c7af615',
},
youHaveBeenBlocked: {
message: 'You cannot push this user because you have been blocked by this user.',
code: 'YOU_HAVE_BEEN_BLOCKED',
id: 'a2497f2a-2389-439c-8626-5298540530f4',
},
tooManyUsers: {
message: 'You can not push users any more.',
code: 'TOO_MANY_USERS',
id: '1845ea77-38d1-426e-8e4e-8b83b24f5bd7',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
listId: { type: 'string', format: 'misskey:id' },
},
required: ['name', 'listId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
private userListService: UserListService,
private userListEntityService: UserListEntityService,
private idService: IdService,
private getterService: GetterService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const list = await this.userListsRepository.findOneBy({
id: ps.listId,
isPublic: true,
});
if (list === null) throw new ApiError(meta.errors.noSuchList);
const currentCount = await this.userListsRepository.countBy({
userId: me.id,
});
if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) {
throw new ApiError(meta.errors.tooManyUserLists);
}
const userList = await this.userListsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
userId: me.id,
name: ps.name,
} as UserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0]));
const users = (await this.userListJoiningsRepository.findBy({
userListId: ps.listId,
})).map(x => x.userId);
for (const user of users) {
const currentUser = await this.getterService.getUser(user).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
if (currentUser.id !== me.id) {
const block = await this.blockingsRepository.findOneBy({
blockerId: currentUser.id,
blockeeId: me.id,
});
if (block) {
throw new ApiError(meta.errors.youHaveBeenBlocked);
}
}
const exist = await this.userListJoiningsRepository.findOneBy({
userListId: userList.id,
userId: currentUser.id,
});
if (exist) {
throw new ApiError(meta.errors.alreadyAdded);
}
try {
await this.userListService.push(currentUser, userList, me);
} catch (err) {
if (err instanceof UserListService.TooManyUsersError) {
throw new ApiError(meta.errors.tooManyUsers);
}
throw err;
}
}
return await this.userListEntityService.pack(userList);
});
}
}

View File

@ -0,0 +1,70 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UserListFavoritesRepository, UserListsRepository } from '@/models/index.js';
import { IdService } from '@/core/IdService.js';
import { ApiError } from '@/server/api/error.js';
import { DI } from '@/di-symbols.js';
export const meta = {
requireCredential: true,
errors: {
noSuchList: {
message: 'No such user list.',
code: 'NO_SUCH_USER_LIST',
id: '7dbaf3cf-7b42-4b8f-b431-b3919e580dbe',
},
alreadyFavorited: {
message: 'The list has already been favorited.',
code: 'ALREADY_FAVORITED',
id: '6425bba0-985b-461e-af1b-518070e72081',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
listId: { type: 'string', format: 'misskey:id' },
},
required: ['listId'],
} as const;
@Injectable() // eslint-disable-next-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor (
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListFavoritesRepository)
private userListFavoritesRepository: UserListFavoritesRepository,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const userList = await this.userListsRepository.findOneBy({
id: ps.listId,
isPublic: true,
});
if (userList === null) {
throw new ApiError(meta.errors.noSuchList);
}
const exist = await this.userListFavoritesRepository.findOneBy({
userId: me.id,
userListId: ps.listId,
});
if (exist !== null) {
throw new ApiError(meta.errors.alreadyFavorited);
}
await this.userListFavoritesRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
userId: me.id,
userListId: ps.listId,
});
});
}
}

View File

@ -1,13 +1,14 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository } from '@/models/index.js'; import type { UserListsRepository, UsersRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
import { ApiError } from '@/server/api/error.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
export const meta = { export const meta = {
tags: ['lists', 'account'], tags: ['lists', 'account'],
requireCredential: true, requireCredential: false,
kind: 'read:account', kind: 'read:account',
@ -22,26 +23,58 @@ export const meta = {
ref: 'UserList', ref: 'UserList',
}, },
}, },
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'a8af4a82-0980-4cc4-a6af-8b0ffd54465e',
},
remoteUser: {
message: 'Not allowed to load the remote user\'s list',
code: 'REMOTE_USER_NOT_ALLOWED',
id: '53858f1b-3315-4a01-81b7-db9b48d4b79a',
},
invalidParam: {
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: 'ab36de0e-29e9-48cb-9732-d82f1281620d',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: {}, properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: [], required: [],
} as const; } as const;
// eslint-disable-next-line import/no-default-export @Injectable() // eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor( constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
private userListEntityService: UserListEntityService, private userListEntityService: UserListEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const userLists = await this.userListsRepository.findBy({ if (typeof ps.userId !== 'undefined') {
const user = await this.usersRepository.findOneBy({ id: ps.userId });
if (user === null) throw new ApiError(meta.errors.noSuchUser);
if (user.host !== null) throw new ApiError(meta.errors.remoteUser);
} else if (me === null) {
throw new ApiError(meta.errors.invalidParam);
}
const userLists = await this.userListsRepository.findBy(typeof ps.userId === 'undefined' && me !== null ? {
userId: me.id, userId: me.id,
} : {
userId: ps.userId,
isPublic: true,
}); });
return await Promise.all(userLists.map(x => this.userListEntityService.pack(x))); return await Promise.all(userLists.map(x => this.userListEntityService.pack(x)));

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository } from '@/models/index.js'; import type { UserListsRepository, UserListFavoritesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -8,7 +8,7 @@ import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
tags: ['lists', 'account'], tags: ['lists', 'account'],
requireCredential: true, requireCredential: false,
kind: 'read:account', kind: 'read:account',
@ -33,31 +33,54 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
listId: { type: 'string', format: 'misskey:id' }, listId: { type: 'string', format: 'misskey:id' },
forPublic: { type: 'boolean', default: false },
}, },
required: ['listId'], required: ['listId'],
} as const; } as const;
// eslint-disable-next-line import/no-default-export @Injectable() // eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor( constructor(
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListFavoritesRepository)
private userListFavoritesRepository: UserListFavoritesRepository,
private userListEntityService: UserListEntityService, private userListEntityService: UserListEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const additionalProperties: Partial<{ likedCount: number, isLiked: boolean }> = {};
// Fetch the list // Fetch the list
const userList = await this.userListsRepository.findOneBy({ const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? {
id: ps.listId, id: ps.listId,
userId: me.id, userId: me.id,
} : {
id: ps.listId,
isPublic: true,
}); });
if (userList == null) { if (userList == null) {
throw new ApiError(meta.errors.noSuchList); throw new ApiError(meta.errors.noSuchList);
} }
return await this.userListEntityService.pack(userList); if (ps.forPublic && userList.isPublic) {
additionalProperties.likedCount = await this.userListFavoritesRepository.countBy({
userListId: ps.listId,
});
if (me !== null) {
additionalProperties.isLiked = (await this.userListFavoritesRepository.findOneBy({
userId: me.id,
userListId: ps.listId,
}) !== null);
} else {
additionalProperties.isLiked = false;
}
}
return {
...await this.userListEntityService.pack(userList),
...additionalProperties,
};
}); });
} }
} }

View File

@ -0,0 +1,63 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UserListFavoritesRepository, UserListsRepository } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import { DI } from '@/di-symbols.js';
export const meta = {
requireCredential: true,
errors: {
noSuchList: {
message: 'No such user list.',
code: 'NO_SUCH_USER_LIST',
id: 'baedb33e-76b8-4b0c-86a8-9375c0a7b94b',
},
notFavorited: {
message: 'You have not favorited the list.',
code: 'ALREADY_FAVORITED',
id: '835c4b27-463d-4cfa-969b-a9058678d465',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
listId: { type: 'string', format: 'misskey:id' },
},
required: ['listId'],
} as const;
@Injectable() // eslint-disable-next-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor (
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListFavoritesRepository)
private userListFavoritesRepository: UserListFavoritesRepository,
) {
super(meta, paramDef, async (ps, me) => {
const userList = await this.userListsRepository.findOneBy({
id: ps.listId,
isPublic: true,
});
if (userList === null) {
throw new ApiError(meta.errors.noSuchList);
}
const exist = await this.userListFavoritesRepository.findOneBy({
userListId: ps.listId,
userId: me.id,
});
if (exist === null) {
throw new ApiError(meta.errors.notFavorited);
}
await this.userListFavoritesRepository.delete({ id: exist.id });
});
}
}

View File

@ -34,8 +34,9 @@ export const paramDef = {
properties: { properties: {
listId: { type: 'string', format: 'misskey:id' }, listId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', minLength: 1, maxLength: 100 }, name: { type: 'string', minLength: 1, maxLength: 100 },
isPublic: { type: 'boolean' },
}, },
required: ['listId', 'name'], required: ['listId'],
} as const; } as const;
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
@ -48,7 +49,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private userListEntityService: UserListEntityService, private userListEntityService: UserListEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
// Fetch the list
const userList = await this.userListsRepository.findOneBy({ const userList = await this.userListsRepository.findOneBy({
id: ps.listId, id: ps.listId,
userId: me.id, userId: me.id,
@ -60,6 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
await this.userListsRepository.update(userList.id, { await this.userListsRepository.update(userList.id, {
name: ps.name, name: ps.name,
isPublic: ps.isPublic,
}); });
return await this.userListEntityService.pack(userList.id); return await this.userListEntityService.pack(userList.id);

View File

@ -13,6 +13,7 @@ class GlobalTimelineChannel extends Channel {
public readonly chName = 'globalTimeline'; public readonly chName = 'globalTimeline';
public static shouldShare = true; public static shouldShare = true;
public static requireCredential = false; public static requireCredential = false;
private withReplies: boolean;
constructor( constructor(
private metaService: MetaService, private metaService: MetaService,
@ -31,6 +32,8 @@ class GlobalTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.gtlAvailable) return; if (!policies.gtlAvailable) return;
this.withReplies = params.withReplies as boolean;
// Subscribe events // Subscribe events
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);
} }
@ -54,7 +57,7 @@ class GlobalTimelineChannel extends Channel {
} }
// 関係ない返信は除外 // 関係ない返信は除外
if (note.reply && !this.user!.showTimelineReplies) { if (note.reply && !this.withReplies) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;

View File

@ -11,6 +11,7 @@ class HomeTimelineChannel extends Channel {
public readonly chName = 'homeTimeline'; public readonly chName = 'homeTimeline';
public static shouldShare = true; public static shouldShare = true;
public static requireCredential = true; public static requireCredential = true;
private withReplies: boolean;
constructor( constructor(
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
@ -24,6 +25,8 @@ class HomeTimelineChannel extends Channel {
@bindThis @bindThis
public async init(params: any) { public async init(params: any) {
this.withReplies = params.withReplies as boolean;
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);
} }
@ -63,7 +66,7 @@ class HomeTimelineChannel extends Channel {
} }
// 関係ない返信は除外 // 関係ない返信は除外
if (note.reply && !this.user!.showTimelineReplies) { if (note.reply && !this.withReplies) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;

View File

@ -13,6 +13,7 @@ class HybridTimelineChannel extends Channel {
public readonly chName = 'hybridTimeline'; public readonly chName = 'hybridTimeline';
public static shouldShare = true; public static shouldShare = true;
public static requireCredential = true; public static requireCredential = true;
private withReplies: boolean;
constructor( constructor(
private metaService: MetaService, private metaService: MetaService,
@ -31,6 +32,8 @@ class HybridTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.ltlAvailable) return; if (!policies.ltlAvailable) return;
this.withReplies = params.withReplies as boolean;
// Subscribe events // Subscribe events
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);
} }
@ -75,7 +78,7 @@ class HybridTimelineChannel extends Channel {
if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances ?? []))) return; if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances ?? []))) return;
// 関係ない返信は除外 // 関係ない返信は除外
if (note.reply && !this.user!.showTimelineReplies) { if (note.reply && !this.withReplies) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;

View File

@ -12,6 +12,7 @@ class LocalTimelineChannel extends Channel {
public readonly chName = 'localTimeline'; public readonly chName = 'localTimeline';
public static shouldShare = true; public static shouldShare = true;
public static requireCredential = false; public static requireCredential = false;
private withReplies: boolean;
constructor( constructor(
private metaService: MetaService, private metaService: MetaService,
@ -30,6 +31,8 @@ class LocalTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.ltlAvailable) return; if (!policies.ltlAvailable) return;
this.withReplies = params.withReplies as boolean;
// Subscribe events // Subscribe events
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);
} }
@ -54,7 +57,7 @@ class LocalTimelineChannel extends Channel {
} }
// 関係ない返信は除外 // 関係ない返信は除外
if (note.reply && this.user && !this.user.showTimelineReplies) { if (note.reply && this.user && !this.withReplies) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;

View File

@ -246,7 +246,7 @@ export default class Connection {
const ch: Channel = channelService.create(id, this); const ch: Channel = channelService.create(id, this);
this.channels.push(ch); this.channels.push(ch);
ch.init(params); ch.init(params ?? {});
if (pong) { if (pong) {
this.sendMessageToWs('connected', { this.sendMessageToWs('connected', {

View File

@ -43,7 +43,6 @@ describe('ユーザー', () => {
type MeDetailed = UserDetailedNotMe & type MeDetailed = UserDetailedNotMe &
misskey.entities.MeDetailed & { misskey.entities.MeDetailed & {
showTimelineReplies: boolean,
achievements: object[], achievements: object[],
loggedInDays: number, loggedInDays: number,
policies: object, policies: object,
@ -160,7 +159,6 @@ describe('ユーザー', () => {
mutedInstances: user.mutedInstances, mutedInstances: user.mutedInstances,
mutingNotificationTypes: user.mutingNotificationTypes, mutingNotificationTypes: user.mutingNotificationTypes,
emailNotificationTypes: user.emailNotificationTypes, emailNotificationTypes: user.emailNotificationTypes,
showTimelineReplies: user.showTimelineReplies,
achievements: user.achievements, achievements: user.achievements,
loggedInDays: user.loggedInDays, loggedInDays: user.loggedInDays,
policies: user.policies, policies: user.policies,
@ -406,7 +404,6 @@ describe('ユーザー', () => {
assert.deepStrictEqual(response.mutedInstances, []); assert.deepStrictEqual(response.mutedInstances, []);
assert.deepStrictEqual(response.mutingNotificationTypes, []); assert.deepStrictEqual(response.mutingNotificationTypes, []);
assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']); assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']);
assert.strictEqual(response.showTimelineReplies, false);
assert.deepStrictEqual(response.achievements, []); assert.deepStrictEqual(response.achievements, []);
assert.deepStrictEqual(response.loggedInDays, 0); assert.deepStrictEqual(response.loggedInDays, 0);
assert.deepStrictEqual(response.policies, DEFAULT_POLICIES); assert.deepStrictEqual(response.policies, DEFAULT_POLICIES);
@ -470,8 +467,6 @@ describe('ユーザー', () => {
{ parameters: (): object => ({ isBot: false }) }, { parameters: (): object => ({ isBot: false }) },
{ parameters: (): object => ({ isCat: true }) }, { parameters: (): object => ({ isCat: true }) },
{ parameters: (): object => ({ isCat: false }) }, { parameters: (): object => ({ isCat: false }) },
{ parameters: (): object => ({ showTimelineReplies: true }) },
{ parameters: (): object => ({ showTimelineReplies: false }) },
{ parameters: (): object => ({ injectFeaturedNote: true }) }, { parameters: (): object => ({ injectFeaturedNote: true }) },
{ parameters: (): object => ({ injectFeaturedNote: false }) }, { parameters: (): object => ({ injectFeaturedNote: false }) },
{ parameters: (): object => ({ receiveAnnouncementEmail: true }) }, { parameters: (): object => ({ receiveAnnouncementEmail: true }) },

View File

@ -15,78 +15,74 @@ describe('ReactionService', () => {
reactionService = app.get<ReactionService>(ReactionService); reactionService = app.get<ReactionService>(ReactionService);
}); });
describe('toDbReaction', () => { describe('normalize', () => {
test('絵文字リアクションはそのまま', async () => { test('絵文字リアクションはそのまま', async () => {
assert.strictEqual(await reactionService.toDbReaction('👍'), '👍'); assert.strictEqual(await reactionService.normalize('👍'), '👍');
assert.strictEqual(await reactionService.toDbReaction('🍅'), '🍅'); assert.strictEqual(await reactionService.normalize('🍅'), '🍅');
}); });
test('既存のリアクションは絵文字化する pudding', async () => { test('既存のリアクションは絵文字化する pudding', async () => {
assert.strictEqual(await reactionService.toDbReaction('pudding'), '🍮'); assert.strictEqual(await reactionService.normalize('pudding'), '🍮');
}); });
test('既存のリアクションは絵文字化する like', async () => { test('既存のリアクションは絵文字化する like', async () => {
assert.strictEqual(await reactionService.toDbReaction('like'), '👍'); assert.strictEqual(await reactionService.normalize('like'), '👍');
}); });
test('既存のリアクションは絵文字化する love', async () => { test('既存のリアクションは絵文字化する love', async () => {
assert.strictEqual(await reactionService.toDbReaction('love'), '❤'); assert.strictEqual(await reactionService.normalize('love'), '❤');
}); });
test('既存のリアクションは絵文字化する laugh', async () => { test('既存のリアクションは絵文字化する laugh', async () => {
assert.strictEqual(await reactionService.toDbReaction('laugh'), '😆'); assert.strictEqual(await reactionService.normalize('laugh'), '😆');
}); });
test('既存のリアクションは絵文字化する hmm', async () => { test('既存のリアクションは絵文字化する hmm', async () => {
assert.strictEqual(await reactionService.toDbReaction('hmm'), '🤔'); assert.strictEqual(await reactionService.normalize('hmm'), '🤔');
}); });
test('既存のリアクションは絵文字化する surprise', async () => { test('既存のリアクションは絵文字化する surprise', async () => {
assert.strictEqual(await reactionService.toDbReaction('surprise'), '😮'); assert.strictEqual(await reactionService.normalize('surprise'), '😮');
}); });
test('既存のリアクションは絵文字化する congrats', async () => { test('既存のリアクションは絵文字化する congrats', async () => {
assert.strictEqual(await reactionService.toDbReaction('congrats'), '🎉'); assert.strictEqual(await reactionService.normalize('congrats'), '🎉');
}); });
test('既存のリアクションは絵文字化する angry', async () => { test('既存のリアクションは絵文字化する angry', async () => {
assert.strictEqual(await reactionService.toDbReaction('angry'), '💢'); assert.strictEqual(await reactionService.normalize('angry'), '💢');
}); });
test('既存のリアクションは絵文字化する confused', async () => { test('既存のリアクションは絵文字化する confused', async () => {
assert.strictEqual(await reactionService.toDbReaction('confused'), '😥'); assert.strictEqual(await reactionService.normalize('confused'), '😥');
}); });
test('既存のリアクションは絵文字化する rip', async () => { test('既存のリアクションは絵文字化する rip', async () => {
assert.strictEqual(await reactionService.toDbReaction('rip'), '😇'); assert.strictEqual(await reactionService.normalize('rip'), '😇');
}); });
test('既存のリアクションは絵文字化する star', async () => { test('既存のリアクションは絵文字化する star', async () => {
assert.strictEqual(await reactionService.toDbReaction('star'), '⭐'); assert.strictEqual(await reactionService.normalize('star'), '⭐');
}); });
test('異体字セレクタ除去', async () => { test('異体字セレクタ除去', async () => {
assert.strictEqual(await reactionService.toDbReaction('㊗️'), '㊗'); assert.strictEqual(await reactionService.normalize('㊗️'), '㊗');
}); });
test('異体字セレクタ除去 必要なし', async () => { test('異体字セレクタ除去 必要なし', async () => {
assert.strictEqual(await reactionService.toDbReaction('㊗'), '㊗'); assert.strictEqual(await reactionService.normalize('㊗'), '㊗');
});
test('fallback - undefined', async () => {
assert.strictEqual(await reactionService.toDbReaction(undefined), '❤');
}); });
test('fallback - null', async () => { test('fallback - null', async () => {
assert.strictEqual(await reactionService.toDbReaction(null), '❤'); assert.strictEqual(await reactionService.normalize(null), '❤');
}); });
test('fallback - empty', async () => { test('fallback - empty', async () => {
assert.strictEqual(await reactionService.toDbReaction(''), '❤'); assert.strictEqual(await reactionService.normalize(''), '❤');
}); });
test('fallback - unknown', async () => { test('fallback - unknown', async () => {
assert.strictEqual(await reactionService.toDbReaction('unknown'), '❤'); assert.strictEqual(await reactionService.normalize('unknown'), '❤');
}); });
}); });
}); });

View File

@ -62,8 +62,8 @@ module.exports = {
'vue/max-attributes-per-line': 'off', 'vue/max-attributes-per-line': 'off',
'vue/html-self-closing': 'off', 'vue/html-self-closing': 'off',
'vue/singleline-html-element-content-newline': 'off', 'vue/singleline-html-element-content-newline': 'off',
// (vue/vue3-recommended disabled the autofix for Vue 2 compatibility) 'vue/v-on-event-hyphenation': ['error', 'never', { autofix: true }],
'vue/v-on-event-hyphenation': ['warn', 'always', { autofix: true }], 'vue/attribute-hyphenation': ['error', 'never'],
}, },
globals: { globals: {
// Node.js // Node.js

View File

@ -19,15 +19,15 @@
"@rollup/plugin-json": "6.0.0", "@rollup/plugin-json": "6.0.0",
"@rollup/plugin-replace": "5.0.2", "@rollup/plugin-replace": "5.0.2",
"@rollup/pluginutils": "5.0.2", "@rollup/pluginutils": "5.0.2",
"@syuilo/aiscript": "0.13.2", "@syuilo/aiscript": "0.13.3",
"@tabler/icons-webfont": "2.17.0", "@tabler/icons-webfont": "2.17.0",
"@vitejs/plugin-vue": "4.2.2", "@vitejs/plugin-vue": "4.2.3",
"@vue-macros/reactivity-transform": "0.3.6", "@vue-macros/reactivity-transform": "0.3.7",
"@vue/compiler-sfc": "3.3.1", "@vue/compiler-sfc": "3.3.2",
"autosize": "5.0.2", "autosize": "6.0.1",
"blurhash": "2.0.5",
"broadcast-channel": "4.20.2", "broadcast-channel": "4.20.2",
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3", "browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
"buraha": "github:misskey-dev/buraha",
"canvas-confetti": "1.6.0", "canvas-confetti": "1.6.0",
"chart.js": "4.3.0", "chart.js": "4.3.0",
"chartjs-adapter-date-fns": "3.0.0", "chartjs-adapter-date-fns": "3.0.0",
@ -53,7 +53,7 @@
"punycode": "2.3.0", "punycode": "2.3.0",
"querystring": "0.2.1", "querystring": "0.2.1",
"rndstr": "1.0.0", "rndstr": "1.0.0",
"rollup": "3.21.6", "rollup": "3.22.0",
"s-age": "1.1.2", "s-age": "1.1.2",
"sanitize-html": "2.10.0", "sanitize-html": "2.10.0",
"sass": "1.62.1", "sass": "1.62.1",
@ -70,31 +70,31 @@
"typescript": "5.0.4", "typescript": "5.0.4",
"uuid": "9.0.0", "uuid": "9.0.0",
"vanilla-tilt": "1.8.0", "vanilla-tilt": "1.8.0",
"vite": "4.3.5", "vite": "4.3.7",
"vue": "3.3.1", "vue": "3.3.2",
"vue-plyr": "7.0.0", "vue-plyr": "7.0.0",
"vue-prism-editor": "2.0.0-alpha.2", "vue-prism-editor": "2.0.0-alpha.2",
"vuedraggable": "next" "vuedraggable": "next"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-actions": "7.0.10", "@storybook/addon-actions": "7.0.12",
"@storybook/addon-essentials": "7.0.10", "@storybook/addon-essentials": "7.0.12",
"@storybook/addon-interactions": "7.0.10", "@storybook/addon-interactions": "7.0.12",
"@storybook/addon-links": "7.0.10", "@storybook/addon-links": "7.0.12",
"@storybook/addon-storysource": "7.0.10", "@storybook/addon-storysource": "7.0.12",
"@storybook/addons": "7.0.10", "@storybook/addons": "7.0.12",
"@storybook/blocks": "7.0.10", "@storybook/blocks": "7.0.12",
"@storybook/core-events": "7.0.10", "@storybook/core-events": "7.0.12",
"@storybook/jest": "0.1.0", "@storybook/jest": "0.1.0",
"@storybook/manager-api": "7.0.10", "@storybook/manager-api": "7.0.12",
"@storybook/preview-api": "7.0.10", "@storybook/preview-api": "7.0.12",
"@storybook/react": "7.0.10", "@storybook/react": "7.0.12",
"@storybook/react-vite": "7.0.10", "@storybook/react-vite": "7.0.12",
"@storybook/testing-library": "0.1.0", "@storybook/testing-library": "0.1.0",
"@storybook/theming": "7.0.10", "@storybook/theming": "7.0.12",
"@storybook/types": "7.0.10", "@storybook/types": "7.0.12",
"@storybook/vue3": "7.0.10", "@storybook/vue3": "7.0.12",
"@storybook/vue3-vite": "7.0.10", "@storybook/vue3-vite": "7.0.12",
"@testing-library/jest-dom": "5.16.5", "@testing-library/jest-dom": "5.16.5",
"@testing-library/vue": "7.0.0", "@testing-library/vue": "7.0.0",
"@types/escape-regexp": "0.0.1", "@types/escape-regexp": "0.0.1",
@ -103,7 +103,7 @@
"@types/gulp-rename": "2.0.2", "@types/gulp-rename": "2.0.2",
"@types/matter-js": "0.18.3", "@types/matter-js": "0.18.3",
"@types/micromatch": "4.0.2", "@types/micromatch": "4.0.2",
"@types/node": "20.1.3", "@types/node": "20.1.7",
"@types/punycode": "2.1.0", "@types/punycode": "2.1.0",
"@types/sanitize-html": "2.9.0", "@types/sanitize-html": "2.9.0",
"@types/seedrandom": "3.0.5", "@types/seedrandom": "3.0.5",
@ -116,16 +116,16 @@
"@typescript-eslint/eslint-plugin": "5.59.5", "@typescript-eslint/eslint-plugin": "5.59.5",
"@typescript-eslint/parser": "5.59.5", "@typescript-eslint/parser": "5.59.5",
"@vitest/coverage-c8": "0.31.0", "@vitest/coverage-c8": "0.31.0",
"@vue/runtime-core": "3.3.1", "@vue/runtime-core": "3.3.2",
"astring": "1.8.4", "astring": "1.8.4",
"chokidar-cli": "3.0.0", "chokidar-cli": "3.0.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "12.12.0", "cypress": "12.12.0",
"eslint": "8.40.0", "eslint": "8.40.0",
"eslint-plugin-import": "2.27.5", "eslint-plugin-import": "2.27.5",
"eslint-plugin-vue": "9.12.0", "eslint-plugin-vue": "9.13.0",
"fast-glob": "3.2.12", "fast-glob": "3.2.12",
"happy-dom": "9.16.0", "happy-dom": "9.18.3",
"micromatch": "3.1.10", "micromatch": "3.1.10",
"msw": "1.2.1", "msw": "1.2.1",
"msw-storybook-addon": "1.8.0", "msw-storybook-addon": "1.8.0",
@ -133,13 +133,13 @@
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"start-server-and-test": "2.0.0", "start-server-and-test": "2.0.0",
"storybook": "7.0.10", "storybook": "7.0.12",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly", "summaly": "github:misskey-dev/summaly",
"vite-plugin-turbosnap": "1.0.2", "vite-plugin-turbosnap": "1.0.2",
"vitest": "0.31.0", "vitest": "0.31.0",
"vitest-fetch-mock": "0.2.2", "vitest-fetch-mock": "0.2.2",
"vue-eslint-parser": "9.2.1", "vue-eslint-parser": "9.3.0",
"vue-tsc": "1.6.4" "vue-tsc": "1.6.5"
} }
} }

View File

@ -0,0 +1,12 @@
// https://vitejs.dev/config/build-options.html#build-modulepreload
import 'vite/modulepreload-polyfill';
import '@/style.scss';
import { mainBoot } from './boot/main-boot';
import { subBoot } from './boot/sub-boot';
if (['/share', '/auth', '/miauth'].includes(location.pathname)) {
subBoot();
} else {
mainBoot();
}

View File

@ -3,11 +3,11 @@ import * as misskey from 'misskey-js';
import { showSuspendedDialog } from './scripts/show-suspended-dialog'; import { showSuspendedDialog } from './scripts/show-suspended-dialog';
import { i18n } from './i18n'; import { i18n } from './i18n';
import { miLocalStorage } from './local-storage'; import { miLocalStorage } from './local-storage';
import { MenuButton } from './types/menu';
import { del, get, set } from '@/scripts/idb-proxy'; import { del, get, set } from '@/scripts/idb-proxy';
import { apiUrl } from '@/config'; import { apiUrl } from '@/config';
import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { waiting, api, popup, popupMenu, success, alert } from '@/os';
import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
import { MenuButton } from './types/menu';
// TODO: 他のタブと永続化されたstateを同期 // TODO: 他のタブと永続化されたstateを同期
@ -101,57 +101,57 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}) })
.then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => { .then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => {
if (res.status >= 500 && res.status < 600) { if (res.status >= 500 && res.status < 600) {
// サーバーエラー(5xx)の場合をrejectとする // サーバーエラー(5xx)の場合をrejectとする
// 認証エラーなど4xxはresolve // 認証エラーなど4xxはresolve
return fail2(res); return fail2(res);
} }
res.json().then(done2, fail2); res.json().then(done2, fail2);
})) }))
.then(async res => { .then(async res => {
if (res.error) { if (res.error) {
if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
// SUSPENDED // SUSPENDED
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
await showSuspendedDialog(); await showSuspendedDialog();
} }
} else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') {
// USER_IS_DELETED // USER_IS_DELETED
// アカウントが削除されている // アカウントが削除されている
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
await alert({ await alert({
type: 'error', type: 'error',
title: i18n.ts.accountDeleted, title: i18n.ts.accountDeleted,
text: i18n.ts.accountDeletedDescription, text: i18n.ts.accountDeletedDescription,
}); });
} }
} else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') {
// AUTHENTICATION_FAILED // AUTHENTICATION_FAILED
// トークンが無効化されていたりアカウントが削除されたりしている // トークンが無効化されていたりアカウントが削除されたりしている
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
await alert({
type: 'error',
title: i18n.ts.tokenRevoked,
text: i18n.ts.tokenRevokedDescription,
});
}
} else {
await alert({ await alert({
type: 'error', type: 'error',
title: i18n.ts.tokenRevoked, title: i18n.ts.failedToFetchAccountInformation,
text: i18n.ts.tokenRevokedDescription, text: JSON.stringify(res.error),
}); });
} }
} else {
await alert({
type: 'error',
title: i18n.ts.failedToFetchAccountInformation,
text: JSON.stringify(res.error),
});
}
// rejectかつ理由がtrueの場合、削除対象であることを示す // rejectかつ理由がtrueの場合、削除対象であることを示す
fail(true); fail(true);
} else { } else {
(res as Account).token = token; (res as Account).token = token;
done(res as Account); done(res as Account);
} }
}) })
.catch(fail); .catch(fail);
}); });
} }
@ -305,3 +305,7 @@ export async function openAccountMenu(opts: {
}); });
} }
} }
if (_DEV_) {
(window as any).$i = $i;
}

View File

@ -0,0 +1,263 @@
import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent, App } from 'vue';
import { compareVersions } from 'compare-versions';
import JSON5 from 'json5';
import widgets from '@/widgets';
import directives from '@/directives';
import components from '@/components';
import { version, ui, lang, updateLocale } from '@/config';
import { applyTheme } from '@/scripts/theme';
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
import { i18n, updateI18n } from '@/i18n';
import { confirm, alert, post, popup, toast } from '@/os';
import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
import { defaultStore, ColdDeviceStorage } from '@/store';
import { fetchInstance, instance } from '@/instance';
import { deviceKind } from '@/scripts/device-kind';
import { reloadChannel } from '@/scripts/unison-reload';
import { reactionPicker } from '@/scripts/reaction-picker';
import { getUrlWithoutLoginId } from '@/scripts/login-id';
import { getAccountFromId } from '@/scripts/get-account-from-id';
import { deckStore } from '@/ui/deck/deck-store';
import { miLocalStorage } from '@/local-storage';
import { fetchCustomEmojis } from '@/custom-emojis';
import { mainRouter } from '@/router';
export async function common(createVue: () => App<Element>) {
console.info(`Misskey v${version}`);
if (_DEV_) {
console.warn('Development mode!!!');
console.info(`vue ${vueVersion}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).$i = $i;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).$store = defaultStore;
window.addEventListener('error', event => {
console.error(event);
/*
alert({
type: 'error',
title: 'DEV: Unhandled error',
text: event.message
});
*/
});
window.addEventListener('unhandledrejection', event => {
console.error(event);
/*
alert({
type: 'error',
title: 'DEV: Unhandled promise rejection',
text: event.reason
});
*/
});
}
const splash = document.getElementById('splash');
// 念のためnullチェック(HTMLが古い場合があるため(そのうち消す))
if (splash) splash.addEventListener('transitionend', () => {
splash.remove();
});
let isClientUpdated = false;
//#region クライアントが更新されたかチェック
const lastVersion = miLocalStorage.getItem('lastVersion');
if (lastVersion !== version) {
miLocalStorage.setItem('lastVersion', version);
// テーマリビルドするため
miLocalStorage.removeItem('theme');
try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため
if (lastVersion != null && compareVersions(version, lastVersion) === 1) {
isClientUpdated = true;
}
} catch (err) { /* empty */ }
}
//#endregion
//#region Detect language & fetch translations
const localeVersion = miLocalStorage.getItem('localeVersion');
const localeOutdated = (localeVersion == null || localeVersion !== version);
if (localeOutdated) {
const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
if (res.status === 200) {
const newLocale = await res.text();
const parsedNewLocale = JSON.parse(newLocale);
miLocalStorage.setItem('locale', newLocale);
miLocalStorage.setItem('localeVersion', version);
updateLocale(parsedNewLocale);
updateI18n(parsedNewLocale);
}
}
//#endregion
// タッチデバイスでCSSの:hoverを機能させる
document.addEventListener('touchend', () => {}, { passive: true });
// 一斉リロード
reloadChannel.addEventListener('message', path => {
if (path !== null) location.href = path;
else location.reload();
});
// If mobile, insert the viewport meta tag
if (['smartphone', 'tablet'].includes(deviceKind)) {
const viewport = document.getElementsByName('viewport').item(0);
viewport.setAttribute('content',
`${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`);
}
//#region Set lang attr
const html = document.documentElement;
html.setAttribute('lang', lang);
//#endregion
await defaultStore.ready;
await deckStore.ready;
const fetchInstanceMetaPromise = fetchInstance();
fetchInstanceMetaPromise.then(() => {
miLocalStorage.setItem('v', instance.version);
});
//#region loginId
const params = new URLSearchParams(location.search);
const loginId = params.get('loginId');
if (loginId) {
const target = getUrlWithoutLoginId(location.href);
if (!$i || $i.id !== loginId) {
const account = await getAccountFromId(loginId);
if (account) {
await login(account.token, target);
}
}
history.replaceState({ misskey: 'loginId' }, '', target);
}
//#endregion
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
watch(defaultStore.reactiveState.darkMode, (darkMode) => {
applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
}, { immediate: miLocalStorage.getItem('theme') == null });
const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));
watch(darkTheme, (theme) => {
if (defaultStore.state.darkMode) {
applyTheme(theme);
}
});
watch(lightTheme, (theme) => {
if (!defaultStore.state.darkMode) {
applyTheme(theme);
}
});
//#region Sync dark mode
if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
defaultStore.set('darkMode', isDeviceDarkmode());
}
window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => {
if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
defaultStore.set('darkMode', mql.matches);
}
});
//#endregion
fetchInstanceMetaPromise.then(() => {
if (defaultStore.state.themeInitial) {
if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON5.parse(instance.defaultLightTheme));
if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON5.parse(instance.defaultDarkTheme));
defaultStore.set('themeInitial', false);
}
});
watch(defaultStore.reactiveState.useBlurEffectForModal, v => {
document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none');
}, { immediate: true });
watch(defaultStore.reactiveState.useBlurEffect, v => {
if (v) {
document.documentElement.style.removeProperty('--blur');
} else {
document.documentElement.style.setProperty('--blur', 'none');
}
}, { immediate: true });
//#region Fetch user
if ($i && $i.token) {
if (_DEV_) {
console.log('account cache found. refreshing...');
}
refreshAccount();
}
//#endregion
try {
await fetchCustomEmojis();
} catch (err) { /* empty */ }
const app = createVue();
if (_DEV_) {
app.config.performance = true;
}
widgets(app);
directives(app);
components(app);
// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210
// なぜか2回実行されることがあるため、mountするdivを1つに制限する
const rootEl = ((): HTMLElement => {
const MISSKEY_MOUNT_DIV_ID = 'misskey_app';
const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID);
if (currentRoot) {
console.warn('multiple import detected');
return currentRoot;
}
const root = document.createElement('div');
root.id = MISSKEY_MOUNT_DIV_ID;
document.body.appendChild(root);
return root;
})();
app.mount(rootEl);
// boot.jsのやつを解除
window.onerror = null;
window.onunhandledrejection = null;
removeSplash();
return {
isClientUpdated,
app,
};
}
function removeSplash() {
const splash = document.getElementById('splash');
if (splash) {
splash.style.opacity = '0';
splash.style.pointerEvents = 'none';
}
}

View File

@ -0,0 +1,254 @@
import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue';
import { common } from './common';
import { version, ui, lang, updateLocale } from '@/config';
import { i18n, updateI18n } from '@/i18n';
import { confirm, alert, post, popup, toast } from '@/os';
import { useStream } from '@/stream';
import * as sound from '@/scripts/sound';
import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
import { defaultStore, ColdDeviceStorage } from '@/store';
import { makeHotkey } from '@/scripts/hotkey';
import { reactionPicker } from '@/scripts/reaction-picker';
import { miLocalStorage } from '@/local-storage';
import { claimAchievement, claimedAchievements } from '@/scripts/achievements';
import { mainRouter } from '@/router';
import { initializeSw } from '@/scripts/initialize-sw';
export async function mainBoot() {
const { isClientUpdated } = await common(() => createApp(
new URLSearchParams(window.location.search).has('zen') ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
defineAsyncComponent(() => import('@/ui/universal.vue')),
));
reactionPicker.init();
if (isClientUpdated && $i) {
popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed');
}
const stream = useStream();
let reloadDialogShowing = false;
stream.on('_disconnected_', async () => {
if (defaultStore.state.serverDisconnectedBehavior === 'reload') {
location.reload();
} else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') {
if (reloadDialogShowing) return;
reloadDialogShowing = true;
const { canceled } = await confirm({
type: 'warning',
title: i18n.ts.disconnectedFromServer,
text: i18n.ts.reloadConfirm,
});
reloadDialogShowing = false;
if (!canceled) {
location.reload();
}
}
});
for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) {
import('../plugin').then(async ({ install }) => {
// Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740
await new Promise(r => setTimeout(r, 0));
install(plugin);
});
}
const hotkeys = {
'd': (): void => {
defaultStore.set('darkMode', !defaultStore.state.darkMode);
},
's': (): void => {
mainRouter.push('/search');
},
};
if ($i) {
// only add post shortcuts if logged in
hotkeys['p|n'] = post;
defaultStore.loaded.then(() => {
if (defaultStore.state.accountSetupWizard !== -1) {
popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed');
}
});
if ($i.isDeleted) {
alert({
type: 'warning',
text: i18n.ts.accountDeletionInProgress,
});
}
const now = new Date();
const m = now.getMonth() + 1;
const d = now.getDate();
if ($i.birthday) {
const bm = parseInt($i.birthday.split('-')[1]);
const bd = parseInt($i.birthday.split('-')[2]);
if (m === bm && d === bd) {
claimAchievement('loggedInOnBirthday');
}
}
if (m === 1 && d === 1) {
claimAchievement('loggedInOnNewYearsDay');
}
if ($i.loggedInDays >= 3) claimAchievement('login3');
if ($i.loggedInDays >= 7) claimAchievement('login7');
if ($i.loggedInDays >= 15) claimAchievement('login15');
if ($i.loggedInDays >= 30) claimAchievement('login30');
if ($i.loggedInDays >= 60) claimAchievement('login60');
if ($i.loggedInDays >= 100) claimAchievement('login100');
if ($i.loggedInDays >= 200) claimAchievement('login200');
if ($i.loggedInDays >= 300) claimAchievement('login300');
if ($i.loggedInDays >= 400) claimAchievement('login400');
if ($i.loggedInDays >= 500) claimAchievement('login500');
if ($i.loggedInDays >= 600) claimAchievement('login600');
if ($i.loggedInDays >= 700) claimAchievement('login700');
if ($i.loggedInDays >= 800) claimAchievement('login800');
if ($i.loggedInDays >= 900) claimAchievement('login900');
if ($i.loggedInDays >= 1000) claimAchievement('login1000');
if ($i.notesCount > 0) claimAchievement('notes1');
if ($i.notesCount >= 10) claimAchievement('notes10');
if ($i.notesCount >= 100) claimAchievement('notes100');
if ($i.notesCount >= 500) claimAchievement('notes500');
if ($i.notesCount >= 1000) claimAchievement('notes1000');
if ($i.notesCount >= 5000) claimAchievement('notes5000');
if ($i.notesCount >= 10000) claimAchievement('notes10000');
if ($i.notesCount >= 20000) claimAchievement('notes20000');
if ($i.notesCount >= 30000) claimAchievement('notes30000');
if ($i.notesCount >= 40000) claimAchievement('notes40000');
if ($i.notesCount >= 50000) claimAchievement('notes50000');
if ($i.notesCount >= 60000) claimAchievement('notes60000');
if ($i.notesCount >= 70000) claimAchievement('notes70000');
if ($i.notesCount >= 80000) claimAchievement('notes80000');
if ($i.notesCount >= 90000) claimAchievement('notes90000');
if ($i.notesCount >= 100000) claimAchievement('notes100000');
if ($i.followersCount > 0) claimAchievement('followers1');
if ($i.followersCount >= 10) claimAchievement('followers10');
if ($i.followersCount >= 50) claimAchievement('followers50');
if ($i.followersCount >= 100) claimAchievement('followers100');
if ($i.followersCount >= 300) claimAchievement('followers300');
if ($i.followersCount >= 500) claimAchievement('followers500');
if ($i.followersCount >= 1000) claimAchievement('followers1000');
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365) {
claimAchievement('passedSinceAccountCreated1');
}
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 2) {
claimAchievement('passedSinceAccountCreated2');
}
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 3) {
claimAchievement('passedSinceAccountCreated3');
}
if (claimedAchievements.length >= 30) {
claimAchievement('collectAchievements30');
}
window.setInterval(() => {
if (Math.floor(Math.random() * 20000) === 0) {
claimAchievement('justPlainLucky');
}
}, 1000 * 10);
window.setTimeout(() => {
claimAchievement('client30min');
}, 1000 * 60 * 30);
window.setTimeout(() => {
claimAchievement('client60min');
}, 1000 * 60 * 60);
const lastUsed = miLocalStorage.getItem('lastUsed');
if (lastUsed) {
const lastUsedDate = parseInt(lastUsed, 10);
// 二時間以上前なら
if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
toast(i18n.t('welcomeBackWithName', {
name: $i.name || $i.username,
}));
}
}
miLocalStorage.setItem('lastUsed', Date.now().toString());
const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt');
const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo');
if (neverShowDonationInfo !== 'true' && (new Date($i.createdAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) {
if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) {
popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed');
}
}
if ('Notification' in window) {
// 許可を得ていなかったらリクエスト
if (Notification.permission === 'default') {
Notification.requestPermission();
}
}
const main = markRaw(stream.useChannel('main', null, 'System'));
// 自分の情報が更新されたとき
main.on('meUpdated', i => {
updateAccount(i);
});
main.on('readAllNotifications', () => {
updateAccount({ hasUnreadNotification: false });
});
main.on('unreadNotification', () => {
updateAccount({ hasUnreadNotification: true });
});
main.on('unreadMention', () => {
updateAccount({ hasUnreadMentions: true });
});
main.on('readAllUnreadMentions', () => {
updateAccount({ hasUnreadMentions: false });
});
main.on('unreadSpecifiedNote', () => {
updateAccount({ hasUnreadSpecifiedNotes: true });
});
main.on('readAllUnreadSpecifiedNotes', () => {
updateAccount({ hasUnreadSpecifiedNotes: false });
});
main.on('readAllAntennas', () => {
updateAccount({ hasUnreadAntenna: false });
});
main.on('unreadAntenna', () => {
updateAccount({ hasUnreadAntenna: true });
sound.play('antenna');
});
main.on('readAllAnnouncements', () => {
updateAccount({ hasUnreadAnnouncement: false });
});
// トークンが再生成されたとき
// このままではMisskeyが利用できないので強制的にサインアウトさせる
main.on('myTokenRegenerated', () => {
signout();
});
}
// shortcut
document.addEventListener('keydown', makeHotkey(hotkeys));
initializeSw();
}

View File

@ -0,0 +1,8 @@
import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue';
import { common } from './common';
export async function subBoot() {
const { isClientUpdated } = await common(() => createApp(
defineAsyncComponent(() => import('@/ui/minimum.vue')),
));
}

View File

@ -1,5 +1,5 @@
<template> <template>
<MkWindow ref="uiWindow" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')"> <MkWindow ref="uiWindow" :initialWidth="400" :initialHeight="500" :canResize="true" @closed="emit('closed')">
<template #header> <template #header>
<i class="ti ti-exclamation-circle" style="margin-right: 0.5em;"></i> <i class="ti ti-exclamation-circle" style="margin-right: 0.5em;"></i>
<I18n :src="i18n.ts.reportAbuseOf" tag="span"> <I18n :src="i18n.ts.reportAbuseOf" tag="span">
@ -8,8 +8,8 @@
</template> </template>
</I18n> </I18n>
</template> </template>
<MkSpacer :margin-min="20" :margin-max="28"> <MkSpacer :marginMin="20" :marginMax="28">
<div class="dpvffvvy _gaps_m"> <div class="_gaps_m" :class="$style.root">
<div class=""> <div class="">
<MkTextarea v-model="comment"> <MkTextarea v-model="comment">
<template #label>{{ i18n.ts.details }}</template> <template #label>{{ i18n.ts.details }}</template>
@ -60,8 +60,8 @@ function send() {
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.dpvffvvy { .root {
--root-margin: 16px; --root-margin: 16px;
} }
</style> </style>

View File

@ -7,11 +7,11 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue';
import { UserLite } from 'misskey-js/built/entities';
import MkMention from './MkMention.vue'; import MkMention from './MkMention.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { host as localHost } from '@/config'; import { host as localHost } from '@/config';
import { ref } from 'vue';
import { UserLite } from 'misskey-js/built/entities';
import { api } from '@/os'; import { api } from '@/os';
const user = ref<UserLite>(); const user = ref<UserLite>();

View File

@ -0,0 +1,243 @@
<template>
<canvas ref="canvasEl" style="width: 100%; height: 100%; pointer-events: none;"></canvas>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, shallowRef } from 'vue';
import isChromatic from 'chromatic/isChromatic';
const canvasEl = shallowRef<HTMLCanvasElement>();
const props = withDefaults(defineProps<{
scale?: number;
focus?: number;
}>(), {
scale: 1.0,
focus: 1.0,
});
function loadShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
alert(
`falied to compile shader: ${gl.getShaderInfoLog(shader)}`,
);
gl.deleteShader(shader);
return null;
}
return shader;
}
function initShaderProgram(gl, vsSource, fsSource) {
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
const shaderProgram = gl.createProgram();
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
alert(
`failed to init shader: ${gl.getProgramInfoLog(
shaderProgram,
)}`,
);
return null;
}
return shaderProgram;
}
let handle: ReturnType<typeof window['requestAnimationFrame']> | null = null;
onMounted(() => {
const canvas = canvasEl.value!;
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
const gl = canvas.getContext('webgl', { premultipliedAlpha: true });
if (gl == null) return;
gl.clearColor(0.0, 0.0, 0.0, 0.0);
gl.clear(gl.COLOR_BUFFER_BIT);
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const shaderProgram = initShaderProgram(gl, `
attribute vec2 vertex;
uniform vec2 u_scale;
varying vec2 v_pos;
void main() {
gl_Position = vec4(vertex, 0.0, 1.0);
v_pos = vertex / u_scale;
}
`, `
precision mediump float;
vec3 mod289(vec3 x) {
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec2 mod289(vec2 x) {
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec3 permute(vec3 x) {
return mod289(((x*34.0)+1.0)*x);
}
float snoise(vec2 v) {
const vec4 C = vec4(0.211324865405187,
0.366025403784439,
-0.577350269189626,
0.024390243902439);
vec2 i = floor(v + dot(v, C.yy) );
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1;
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod289(i);
vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
+ i.x + vec3(0.0, i1.x, 1.0 ));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
m = m*m ;
m = m*m ;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
uniform float u_time;
uniform vec2 u_resolution;
uniform float u_spread;
uniform float u_speed;
uniform float u_warp;
uniform float u_focus;
uniform float u_itensity;
varying vec2 v_pos;
float circle( in vec2 _pos, in vec2 _origin, in float _radius ) {
float SPREAD = 0.7 * u_spread;
float SPEED = 0.00055 * u_speed;
float WARP = 1.5 * u_warp;
float FOCUS = 1.15 * u_focus;
vec2 dist = _pos - _origin;
float distortion = snoise( vec2(
_pos.x * 1.587 * WARP + u_time * SPEED * 0.5,
_pos.y * 1.192 * WARP + u_time * SPEED * 0.3
) ) * 0.5 + 0.5;
float feather = 0.01 + SPREAD * pow( distortion, FOCUS );
return 1.0 - smoothstep(
_radius - ( _radius * feather ),
_radius + ( _radius * feather ),
dot( dist, dist ) * 4.0
);
}
void main() {
vec3 green = vec3( 1.0 ) - vec3( 153.0 / 255.0, 211.0 / 255.0, 221.0 / 255.0 );
vec3 purple = vec3( 1.0 ) - vec3( 195.0 / 255.0, 165.0 / 255.0, 242.0 / 255.0 );
vec3 orange = vec3( 1.0 ) - vec3( 255.0 / 255.0, 156.0 / 255.0, 136.0 / 255.0 );
float ratio = u_resolution.x / u_resolution.y;
vec2 uv = vec2( v_pos.x, v_pos.y / ratio ) * 0.5 + 0.5;
vec3 color = vec3( 0.0 );
float greenMix = snoise( v_pos * 1.31 + u_time * 0.8 * 0.00017 ) * 0.5 + 0.5;
float purpleMix = snoise( v_pos * 1.26 + u_time * 0.8 * -0.0001 ) * 0.5 + 0.5;
float orangeMix = snoise( v_pos * 1.34 + u_time * 0.8 * 0.00015 ) * 0.5 + 0.5;
float alphaOne = 0.35 + 0.65 * pow( snoise( vec2( u_time * 0.00012, uv.x ) ) * 0.5 + 0.5, 1.2 );
float alphaTwo = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 1561.0 ) * 0.00014, uv.x ) ) * 0.5 + 0.5, 1.2 );
float alphaThree = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 3917.0 ) * 0.00013, uv.x ) ) * 0.5 + 0.5, 1.2 );
color += vec3( circle( uv, vec2( 0.22 + sin( u_time * 0.000201 ) * 0.06, 0.80 + cos( u_time * 0.000151 ) * 0.06 ), 0.15 ) ) * alphaOne * ( purple * purpleMix + orange * orangeMix );
color += vec3( circle( uv, vec2( 0.90 + cos( u_time * 0.000166 ) * 0.06, 0.42 + sin( u_time * 0.000138 ) * 0.06 ), 0.18 ) ) * alphaTwo * ( green * greenMix + purple * purpleMix );
color += vec3( circle( uv, vec2( 0.19 + sin( u_time * 0.000112 ) * 0.06, 0.25 + sin( u_time * 0.000192 ) * 0.06 ), 0.09 ) ) * alphaThree * ( orange * orangeMix );
color *= u_itensity + 1.0 * pow( snoise( vec2( v_pos.y + u_time * 0.00013, v_pos.x + u_time * -0.00009 ) ) * 0.5 + 0.5, 2.0 );
vec3 inverted = vec3( 1.0 ) - color;
gl_FragColor = vec4( color, max(max(color.x, color.y), color.z) );
}
`);
gl.useProgram(shaderProgram);
const u_resolution = gl.getUniformLocation(shaderProgram, 'u_resolution');
const u_time = gl.getUniformLocation(shaderProgram, 'u_time');
const u_spread = gl.getUniformLocation(shaderProgram, 'u_spread');
const u_speed = gl.getUniformLocation(shaderProgram, 'u_speed');
const u_warp = gl.getUniformLocation(shaderProgram, 'u_warp');
const u_focus = gl.getUniformLocation(shaderProgram, 'u_focus');
const u_itensity = gl.getUniformLocation(shaderProgram, 'u_itensity');
const u_scale = gl.getUniformLocation(shaderProgram, 'u_scale');
gl.uniform2fv(u_resolution, [canvas.width, canvas.height]);
gl.uniform1f(u_spread, 1.0);
gl.uniform1f(u_speed, 1.0);
gl.uniform1f(u_warp, 1.0);
gl.uniform1f(u_focus, props.focus);
gl.uniform1f(u_itensity, 0.5);
gl.uniform2fv(u_scale, [props.scale, props.scale]);
const vertex = gl.getAttribLocation(shaderProgram, 'vertex');
gl.enableVertexAttribArray(vertex);
gl.vertexAttribPointer(vertex, 2, gl.FLOAT, false, 0, 0);
const vertices = [1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW);
if (isChromatic()) {
gl!.uniform1f(u_time, 0);
gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
} else {
function render(timeStamp) {
gl!.uniform1f(u_time, timeStamp);
gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
handle = window.requestAnimationFrame(render);
}
handle = window.requestAnimationFrame(render);
}
});
onUnmounted(() => {
if (handle) {
window.cancelAnimationFrame(handle);
}
});
</script>
<style lang="scss" module>
</style>

View File

@ -11,29 +11,29 @@
<div v-else-if="c.type === 'buttons'" class="_buttons" :style="{ justifyContent: align }"> <div v-else-if="c.type === 'buttons'" class="_buttons" :style="{ justifyContent: align }">
<MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :disabled="button.disabled" inline :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton> <MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :disabled="button.disabled" inline :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton>
</div> </div>
<MkSwitch v-else-if="c.type === 'switch'" :model-value="valueForSwitch" @update:model-value="onSwitchUpdate"> <MkSwitch v-else-if="c.type === 'switch'" :modelValue="valueForSwitch" @update:modelValue="onSwitchUpdate">
<template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkSwitch> </MkSwitch>
<MkTextarea v-else-if="c.type === 'textarea'" :model-value="c.default" @update:model-value="c.onInput"> <MkTextarea v-else-if="c.type === 'textarea'" :modelValue="c.default" @update:modelValue="c.onInput">
<template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkTextarea> </MkTextarea>
<MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :model-value="c.default" @update:model-value="c.onInput"> <MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :modelValue="c.default" @update:modelValue="c.onInput">
<template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkInput> </MkInput>
<MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :model-value="c.default" type="number" @update:model-value="c.onInput"> <MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :modelValue="c.default" type="number" @update:modelValue="c.onInput">
<template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkInput> </MkInput>
<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :model-value="c.default" @update:model-value="c.onChange"> <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="c.default" @update:modelValue="c.onChange">
<template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template>
<option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option> <option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option>
</MkSelect> </MkSelect>
<MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" inline @click="openPostForm">{{ c.text }}</MkButton> <MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" inline @click="openPostForm">{{ c.text }}</MkButton>
<MkFolder v-else-if="c.type === 'folder'" :default-open="c.opened"> <MkFolder v-else-if="c.type === 'folder'" :defaultOpen="c.opened">
<template #label>{{ c.title }}</template> <template #label>{{ c.title }}</template>
<template v-for="child in c.children" :key="child"> <template v-for="child in c.children" :key="child">
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/> <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>

View File

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;"> <div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;">
<MkAvatar :user="user" style="width:32px;height:32px;" indicator link preview/> <MkAvatar :user="user" style="width:32px; height:32px;" indicator link preview/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -26,6 +26,3 @@ const props = withDefaults(defineProps<{
extractor: (item) => item, extractor: (item) => item,
}); });
</script> </script>
<style lang="scss" scoped>
</style>

View File

@ -1,8 +1,8 @@
<template> <template>
<div class="cbbedffa"> <div :class="$style.root">
<canvas ref="chartEl"></canvas> <canvas ref="chartEl"></canvas>
<MkChartLegend ref="legendEl" style="margin-top: 8px;"/> <MkChartLegend ref="legendEl" style="margin-top: 8px;"/>
<div v-if="fetching" class="fetching"> <div v-if="fetching" :class="$style.fetching">
<MkLoading/> <MkLoading/>
</div> </div>
</div> </div>
@ -817,22 +817,22 @@ onMounted(() => {
/* eslint-enable id-denylist */ /* eslint-enable id-denylist */
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.cbbedffa { .root {
position: relative; position: relative;
}
> .fetching { .fetching {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
-webkit-backdrop-filter: var(--blur, blur(12px)); -webkit-backdrop-filter: var(--blur, blur(12px));
backdrop-filter: var(--blur, blur(12px)); backdrop-filter: var(--blur, blur(12px));
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
cursor: wait; cursor: wait;
}
} }
</style> </style>

View File

@ -1,5 +1,5 @@
<template> <template>
<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'top'" :inner-margin="16" @closed="emit('closed')"> <MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :maxWidth="340" :direction="'top'" :innerMargin="16" @closed="emit('closed')">
<div v-if="title || series"> <div v-if="title || series">
<div v-if="title" :class="$style.title">{{ title }}</div> <div v-if="title" :class="$style.title">{{ title }}</div>
<template v-if="series"> <template v-if="series">

View File

@ -6,7 +6,7 @@
<slot name="header"></slot> <slot name="header"></slot>
</div> </div>
<div :class="$style.headerSub"> <div :class="$style.headerSub">
<slot name="func" :button-style-class="$style.headerButton"></slot> <slot name="func" :buttonStyleClass="$style.headerButton"></slot>
<button v-if="foldable" :class="$style.headerButton" class="_button" @click="() => showBody = !showBody"> <button v-if="foldable" :class="$style.headerButton" class="_button" @click="() => showBody = !showBody">
<template v-if="showBody"><i class="ti ti-chevron-up"></i></template> <template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
<template v-else><i class="ti ti-chevron-down"></i></template> <template v-else><i class="ti ti-chevron-down"></i></template>
@ -14,14 +14,14 @@
</div> </div>
</header> </header>
<Transition <Transition
:enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" :enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
:leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" :leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
:enter-from-class="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''" :enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
:leave-to-class="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''" :leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
@enter="enter" @enter="enter"
@after-enter="afterEnter" @afterEnter="afterEnter"
@leave="leave" @leave="leave"
@after-leave="afterLeave" @afterLeave="afterLeave"
> >
<div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]"> <div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]">
<slot></slot> <slot></slot>

View File

@ -1,10 +1,10 @@
<template> <template>
<Transition <Transition
appear appear
:enter-active-class="defaultStore.state.animation ? $style.transition_fade_enterActive : ''" :enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''"
:leave-active-class="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''" :leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''"
:enter-from-class="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''" :enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''"
:leave-to-class="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" :leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
> >
<div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}"> <div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/> <MkMenu :items="items" :align="'left'" @close="$emit('closed')"/>

View File

@ -4,7 +4,7 @@
:width="800" :width="800"
:height="500" :height="500"
:scroll="false" :scroll="false"
:with-ok-button="true" :withOkButton="true"
@close="cancel()" @close="cancel()"
@ok="ok()" @ok="ok()"
@closed="$emit('closed')" @closed="$emit('closed')"

View File

@ -1,5 +1,5 @@
<template> <template>
<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="emit('closed')"> <MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')">
<div :class="$style.root"> <div :class="$style.root">
<div v-if="icon" :class="$style.icon"> <div v-if="icon" :class="$style.icon">
<i :class="icon"></i> <i :class="icon"></i>

View File

@ -1,13 +1,13 @@
<template> <template>
<div class="drylbebk" <div
:class="{ draghover }" :class="[$style.root, { [$style.draghover]: draghover }]"
@click="onClick" @click="onClick"
@dragover.prevent.stop="onDragover" @dragover.prevent.stop="onDragover"
@dragenter="onDragenter" @dragenter="onDragenter"
@dragleave="onDragleave" @dragleave="onDragleave"
@drop.stop="onDrop" @drop.stop="onDrop"
> >
<i v-if="folder == null" class="ti ti-cloud"></i> <i v-if="folder == null" class="ti ti-cloud" style="margin-right: 4px;"></i>
<span>{{ folder == null ? i18n.ts.drive : folder.name }}</span> <span>{{ folder == null ? i18n.ts.drive : folder.name }}</span>
</div> </div>
</template> </template>
@ -130,18 +130,10 @@ function onDrop(ev: DragEvent) {
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.drylbebk { .root {
> * {
pointer-events: none;
}
&.draghover { &.draghover {
background: #eee; background: #eee;
} }
> i {
margin-right: 4px;
}
} }
</style> </style>

View File

@ -4,21 +4,21 @@
<div class="path" @contextmenu.prevent.stop="() => {}"> <div class="path" @contextmenu.prevent.stop="() => {}">
<XNavFolder <XNavFolder
:class="{ current: folder == null }" :class="{ current: folder == null }"
:parent-folder="folder" :parentFolder="folder"
@move="move" @move="move"
@upload="upload" @upload="upload"
@remove-file="removeFile" @removeFile="removeFile"
@remove-folder="removeFolder" @removeFolder="removeFolder"
/> />
<template v-for="f in hierarchyFolders"> <template v-for="f in hierarchyFolders">
<span class="separator"><i class="ti ti-chevron-right"></i></span> <span class="separator"><i class="ti ti-chevron-right"></i></span>
<XNavFolder <XNavFolder
:folder="f" :folder="f"
:parent-folder="folder" :parentFolder="folder"
@move="move" @move="move"
@upload="upload" @upload="upload"
@remove-file="removeFile" @removeFile="removeFile"
@remove-folder="removeFolder" @removeFolder="removeFolder"
/> />
</template> </template>
<span v-if="folder != null" class="separator"><i class="ti ti-chevron-right"></i></span> <span v-if="folder != null" class="separator"><i class="ti ti-chevron-right"></i></span>
@ -43,13 +43,13 @@
v-anim="i" v-anim="i"
class="folder" class="folder"
:folder="f" :folder="f"
:select-mode="select === 'folder'" :selectMode="select === 'folder'"
:is-selected="selectedFolders.some(x => x.id === f.id)" :isSelected="selectedFolders.some(x => x.id === f.id)"
@chosen="chooseFolder" @chosen="chooseFolder"
@move="move" @move="move"
@upload="upload" @upload="upload"
@remove-file="removeFile" @removeFile="removeFile"
@remove-folder="removeFolder" @removeFolder="removeFolder"
@dragstart="isDragSource = true" @dragstart="isDragSource = true"
@dragend="isDragSource = false" @dragend="isDragSource = false"
/> />
@ -64,8 +64,8 @@
v-anim="i" v-anim="i"
class="file" class="file"
:file="file" :file="file"
:select-mode="select === 'file'" :selectMode="select === 'file'"
:is-selected="selectedFiles.some(x => x.id === file.id)" :isSelected="selectedFiles.some(x => x.id === file.id)"
@chosen="chooseFile" @chosen="chooseFile"
@dragstart="isDragSource = true" @dragstart="isDragSource = true"
@dragend="isDragSource = false" @dragend="isDragSource = false"
@ -95,7 +95,7 @@ import XNavFolder from '@/components/MkDrive.navFolder.vue';
import XFolder from '@/components/MkDrive.folder.vue'; import XFolder from '@/components/MkDrive.folder.vue';
import XFile from '@/components/MkDrive.file.vue'; import XFile from '@/components/MkDrive.file.vue';
import * as os from '@/os'; import * as os from '@/os';
import { stream } from '@/stream'; import { useStream } from '@/stream';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { uploadFile, uploads } from '@/scripts/upload'; import { uploadFile, uploads } from '@/scripts/upload';
@ -131,7 +131,7 @@ const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
const selectedFiles = ref<Misskey.entities.DriveFile[]>([]); const selectedFiles = ref<Misskey.entities.DriveFile[]>([]);
const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]); const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
const uploadings = uploads; const uploadings = uploads;
const connection = stream.useChannel('drive'); const connection = useStream().useChannel('drive');
const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // $ref使 const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // $ref使
// //

View File

@ -1,16 +1,16 @@
<template> <template>
<div ref="thumbnail" class="zdjebgpv"> <div ref="thumbnail" :class="$style.root">
<ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/> <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/>
<i v-else-if="is === 'image'" class="ti ti-photo icon"></i> <i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i>
<i v-else-if="is === 'video'" class="ti ti-video icon"></i> <i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i>
<i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music icon"></i> <i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i>
<i v-else-if="is === 'csv'" class="ti ti-file-text icon"></i> <i v-else-if="is === 'csv'" class="ti ti-file-text" :class="$style.icon"></i>
<i v-else-if="is === 'pdf'" class="ti ti-file-text icon"></i> <i v-else-if="is === 'pdf'" class="ti ti-file-text" :class="$style.icon"></i>
<i v-else-if="is === 'textfile'" class="ti ti-file-text icon"></i> <i v-else-if="is === 'textfile'" class="ti ti-file-text" :class="$style.icon"></i>
<i v-else-if="is === 'archive'" class="ti ti-file-zip icon"></i> <i v-else-if="is === 'archive'" class="ti ti-file-zip" :class="$style.icon"></i>
<i v-else class="ti ti-file icon"></i> <i v-else class="ti ti-file" :class="$style.icon"></i>
<i v-if="isThumbnailAvailable && is === 'video'" class="ti ti-video icon-sub"></i> <i v-if="isThumbnailAvailable && is === 'video'" class="ti ti-video" :class="$style.iconSub"></i>
</div> </div>
</template> </template>
@ -53,28 +53,28 @@ const isThumbnailAvailable = computed(() => {
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.zdjebgpv { .root {
position: relative; position: relative;
display: flex; display: flex;
background: var(--panel); background: var(--panel);
border-radius: 8px; border-radius: 8px;
overflow: clip; overflow: clip;
}
> .icon-sub { .iconSub {
position: absolute; position: absolute;
width: 30%; width: 30%;
height: auto; height: auto;
margin: 0; margin: 0;
right: 4%; right: 4%;
bottom: 4%; bottom: 4%;
} }
> .icon { .icon {
pointer-events: none; pointer-events: none;
margin: auto; margin: auto;
font-size: 32px; font-size: 32px;
color: #777; color: #777;
}
} }
</style> </style>

View File

@ -3,8 +3,8 @@
ref="dialog" ref="dialog"
:width="800" :width="800"
:height="500" :height="500"
:with-ok-button="true" :withOkButton="true"
:ok-button-disabled="(type === 'file') && (selected.length === 0)" :okButtonDisabled="(type === 'file') && (selected.length === 0)"
@click="cancel()" @click="cancel()"
@close="cancel()" @close="cancel()"
@ok="ok()" @ok="ok()"
@ -14,7 +14,7 @@
{{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }} {{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }}
<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span> <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span>
</template> </template>
<XDrive :multiple="multiple" :select="type" @change-selection="onChangeSelection" @selected="ok()"/> <XDrive :multiple="multiple" :select="type" @changeSelection="onChangeSelection" @selected="ok()"/>
</MkModalWindow> </MkModalWindow>
</template> </template>

View File

@ -1,15 +1,15 @@
<template> <template>
<MkWindow <MkWindow
ref="window" ref="window"
:initial-width="800" :initialWidth="800"
:initial-height="500" :initialHeight="500"
:can-resize="true" :canResize="true"
@closed="emit('closed')" @closed="emit('closed')"
> >
<template #header> <template #header>
{{ i18n.ts.drive }} {{ i18n.ts.drive }}
</template> </template>
<XDrive :initial-folder="initialFolder"/> <XDrive :initialFolder="initialFolder"/>
</MkWindow> </MkWindow>
</template> </template>

View File

@ -69,8 +69,8 @@
<XSection <XSection
v-for="category in customEmojiCategories" v-for="category in customEmojiCategories"
:key="`custom:${category}`" :key="`custom:${category}`"
:initial-shown="false" :initialShown="false"
:emojis="computed(() => customEmojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).map(e => `:${e.name}:`))" :emojis="computed(() => customEmojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).filter(filterAvailable).map(e => `:${e.name}:`))"
@chosen="chosen" @chosen="chosen"
> >
{{ category || i18n.ts.other }} {{ category || i18n.ts.other }}
@ -102,6 +102,7 @@ import { deviceKind } from '@/scripts/device-kind';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { customEmojiCategories, customEmojis } from '@/custom-emojis'; import { customEmojiCategories, customEmojis } from '@/custom-emojis';
import { $i } from '@/account';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
showPinned?: boolean; showPinned?: boolean;
@ -274,10 +275,14 @@ watch(q, () => {
return matches; return matches;
}; };
searchResultCustom.value = Array.from(searchCustom()); searchResultCustom.value = Array.from(searchCustom()).filter(filterAvailable);
searchResultUnicode.value = Array.from(searchUnicode()); searchResultUnicode.value = Array.from(searchUnicode());
}); });
function filterAvailable(emoji: Misskey.entities.CustomEmoji): boolean {
return (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction == null || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id)));
}
function focus() { function focus() {
if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) { if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) {
searchEl.value?.focus({ searchEl.value?.focus({

View File

@ -2,10 +2,10 @@
<MkModal <MkModal
ref="modal" ref="modal"
v-slot="{ type, maxHeight }" v-slot="{ type, maxHeight }"
:z-priority="'middle'" :zPriority="'middle'"
:prefer-type="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'" :preferType="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
:transparent-bg="true" :transparentBg="true"
:manual-showing="manualShowing" :manualShowing="manualShowing"
:src="src" :src="src"
@click="modal?.close()" @click="modal?.close()"
@opening="opening" @opening="opening"
@ -16,9 +16,9 @@
ref="picker" ref="picker"
class="ryghynhb _popup _shadow" class="ryghynhb _popup _shadow"
:class="{ drawer: type === 'drawer' }" :class="{ drawer: type === 'drawer' }"
:show-pinned="showPinned" :showPinned="showPinned"
:as-reaction-picker="asReactionPicker" :asReactionPicker="asReactionPicker"
:as-drawer="type === 'drawer'" :asDrawer="type === 'drawer'"
:max-height="maxHeight" :max-height="maxHeight"
@chosen="chosen" @chosen="chosen"
/> />

View File

@ -1,13 +1,14 @@
<template> <template>
<MkWindow ref="window" <MkWindow
:initial-width="300" ref="window"
:initial-height="290" :initialWidth="300"
:can-resize="true" :initialHeight="290"
:canResize="true"
:mini="true" :mini="true"
:front="true" :front="true"
@closed="emit('closed')" @closed="emit('closed')"
> >
<MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" as-window :class="$style.picker" @chosen="chosen"/> <MkEmojiPicker :showPinned="showPinned" :asReactionPicker="asReactionPicker" asWindow :class="$style.picker" @chosen="chosen"/>
</MkWindow> </MkWindow>
</template> </template>

View File

@ -3,14 +3,14 @@
ref="dialog" ref="dialog"
:width="400" :width="400"
:height="450" :height="450"
:with-ok-button="true" :withOkButton="true"
:ok-button-disabled="false" :okButtonDisabled="false"
@ok="ok()" @ok="ok()"
@close="dialog.close()" @close="dialog.close()"
@closed="emit('closed')" @closed="emit('closed')"
> >
<template #header>{{ i18n.ts.describeFile }}</template> <template #header>{{ i18n.ts.describeFile }}</template>
<MkSpacer :margin-min="20" :margin-max="28"> <MkSpacer :marginMin="20" :marginMax="28">
<MkDriveFileThumbnail :file="file" fit="contain" style="height: 100px; margin-bottom: 16px;"/> <MkDriveFileThumbnail :file="file" fit="contain" style="height: 100px; margin-bottom: 16px;"/>
<MkTextarea v-model="caption" autofocus :placeholder="i18n.ts.inputNewDescription"> <MkTextarea v-model="caption" autofocus :placeholder="i18n.ts.inputNewDescription">
<template #label>{{ i18n.ts.caption }}</template> <template #label>{{ i18n.ts.caption }}</template>

View File

@ -1,9 +1,9 @@
<template> <template>
<div class="ssazuxis"> <div ref="el" :class="$style.root">
<header class="_button" :style="{ background: bg }" @click="showBody = !showBody"> <header :class="$style.header" class="_button" :style="{ background: bg }" @click="showBody = !showBody">
<div class="title"><div><slot name="header"></slot></div></div> <div :class="$style.title"><div><slot name="header"></slot></div></div>
<div class="divider"></div> <div :class="$style.divider"></div>
<button class="_button"> <button class="_button" :class="$style.button">
<template v-if="showBody"><i class="ti ti-chevron-up"></i></template> <template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
<template v-else><i class="ti ti-chevron-down"></i></template> <template v-else><i class="ti ti-chevron-down"></i></template>
</button> </button>
@ -11,9 +11,9 @@
<Transition <Transition
:name="defaultStore.state.animation ? 'folder-toggle' : ''" :name="defaultStore.state.animation ? 'folder-toggle' : ''"
@enter="enter" @enter="enter"
@after-enter="afterEnter" @afterEnter="afterEnter"
@leave="leave" @leave="leave"
@after-leave="afterLeave" @afterLeave="afterLeave"
> >
<div v-show="showBody"> <div v-show="showBody">
<slot></slot> <slot></slot>
@ -22,84 +22,71 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { onMounted, ref, shallowRef, watch } from 'vue';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { miLocalStorage } from '@/local-storage'; import { miLocalStorage } from '@/local-storage';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
const miLocalStoragePrefix = 'ui:folder:' as const; const miLocalStoragePrefix = 'ui:folder:' as const;
export default defineComponent({ const props = withDefaults(defineProps<{
props: { expanded?: boolean;
expanded: { persistKey?: string;
type: Boolean, }>(), {
required: false, expanded: true,
default: true, });
},
persistKey: {
type: String,
required: false,
default: null,
},
},
data() {
return {
defaultStore,
bg: null,
showBody: (this.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${this.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${this.persistKey}`) === 't') : this.expanded,
};
},
watch: {
showBody() {
if (this.persistKey) {
miLocalStorage.setItem(`${miLocalStoragePrefix}${this.persistKey}`, this.showBody ? 't' : 'f');
}
},
},
mounted() {
function getParentBg(el: Element | null): string {
if (el == null || el.tagName === 'BODY') return 'var(--bg)';
const bg = el.style.background || el.style.backgroundColor;
if (bg) {
return bg;
} else {
return getParentBg(el.parentElement);
}
}
const rawBg = getParentBg(this.$el);
const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
bg.setAlpha(0.85);
this.bg = bg.toRgbString();
},
methods: {
toggleContent(show: boolean) {
this.showBody = show;
},
enter(el) { const el = shallowRef<HTMLDivElement>();
const elementHeight = el.getBoundingClientRect().height; const bg = ref<string | null>(null);
el.style.height = 0; const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded);
el.offsetHeight; // reflow
el.style.height = elementHeight + 'px'; watch(showBody, () => {
}, if (props.persistKey) {
afterEnter(el) { miLocalStorage.setItem(`${miLocalStoragePrefix}${props.persistKey}`, showBody.value ? 't' : 'f');
el.style.height = null; }
}, });
leave(el) {
const elementHeight = el.getBoundingClientRect().height; function enter(el: Element) {
el.style.height = elementHeight + 'px'; const elementHeight = el.getBoundingClientRect().height;
el.offsetHeight; // reflow el.style.height = 0;
el.style.height = 0; el.offsetHeight; // reflow
}, el.style.height = elementHeight + 'px';
afterLeave(el) { }
el.style.height = null;
}, function afterEnter(el: Element) {
}, el.style.height = null;
}
function leave(el: Element) {
const elementHeight = el.getBoundingClientRect().height;
el.style.height = elementHeight + 'px';
el.offsetHeight; // reflow
el.style.height = 0;
}
function afterLeave(el: Element) {
el.style.height = null;
}
onMounted(() => {
function getParentBg(el: HTMLElement | null): string {
if (el == null || el.tagName === 'BODY') return 'var(--bg)';
const bg = el.style.background || el.style.backgroundColor;
if (bg) {
return bg;
} else {
return getParentBg(el.parentElement);
}
}
const rawBg = getParentBg(el.value);
const _bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
_bg.setAlpha(0.85);
bg.value = _bg.toRgbString();
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.folder-toggle-enter-active, .folder-toggle-leave-active { .folder-toggle-enter-active, .folder-toggle-leave-active {
overflow-y: clip; overflow-y: clip;
transition: opacity 0.5s, height 0.5s !important; transition: opacity 0.5s, height 0.5s !important;
@ -111,45 +98,41 @@ export default defineComponent({
opacity: 0; opacity: 0;
} }
.ssazuxis { .root {
position: relative; position: relative;
}
> header { .header {
display: flex; display: flex;
position: relative; position: relative;
z-index: 10; z-index: 10;
position: sticky; position: sticky;
top: var(--stickyTop, 0px); top: var(--stickyTop, 0px);
-webkit-backdrop-filter: var(--blur, blur(8px)); -webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(20px)); backdrop-filter: var(--blur, blur(20px));
}
> .title { .title {
display: grid; display: grid;
place-content: center; place-content: center;
margin: 0; margin: 0;
padding: 12px 16px 12px 0; padding: 12px 16px 12px 0;
} }
> .divider { .divider {
flex: 1; flex: 1;
margin: auto; margin: auto;
height: 1px; height: 1px;
background: var(--divider); background: var(--divider);
} }
> button { .button {
padding: 12px 0 12px 16px; padding: 12px 0 12px 16px;
}
}
} }
@container (max-width: 500px) { @container (max-width: 500px) {
.ssazuxis { .title {
> header { padding: 8px 10px 8px 0;
> .title {
padding: 8px 10px 8px 0;
}
}
} }
} }
</style> </style>

View File

@ -6,7 +6,7 @@
<div :class="$style.headerIcon"><slot name="icon"></slot></div> <div :class="$style.headerIcon"><slot name="icon"></slot></div>
<div :class="$style.headerText"> <div :class="$style.headerText">
<div :class="$style.headerTextMain"> <div :class="$style.headerTextMain">
<slot name="label"></slot> <MkCondensedLine :minScale="2 / 3"><slot name="label"></slot></MkCondensedLine>
</div> </div>
<div :class="$style.headerTextSub"> <div :class="$style.headerTextSub">
<slot name="caption"></slot> <slot name="caption"></slot>
@ -22,18 +22,18 @@
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }" :aria-hidden="!opened"> <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }" :aria-hidden="!opened">
<Transition <Transition
:enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" :enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
:leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" :leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
:enter-from-class="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''" :enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
:leave-to-class="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''" :leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
@enter="enter" @enter="enter"
@after-enter="afterEnter" @afterEnter="afterEnter"
@leave="leave" @leave="leave"
@after-leave="afterLeave" @afterLeave="afterLeave"
> >
<KeepAlive> <KeepAlive>
<div v-show="opened"> <div v-show="opened">
<MkSpacer :margin-min="14" :margin-max="22"> <MkSpacer :marginMin="14" :marginMax="22">
<slot></slot> <slot></slot>
</MkSpacer> </MkSpacer>
</div> </div>

View File

@ -33,7 +33,7 @@
import { onBeforeUnmount, onMounted } from 'vue'; import { onBeforeUnmount, onMounted } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import * as os from '@/os'; import * as os from '@/os';
import { stream } from '@/stream'; import { useStream } from '@/stream';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { claimAchievement } from '@/scripts/achievements'; import { claimAchievement } from '@/scripts/achievements';
import { $i } from '@/account'; import { $i } from '@/account';
@ -50,7 +50,7 @@ const props = withDefaults(defineProps<{
let isFollowing = $ref(props.user.isFollowing); let isFollowing = $ref(props.user.isFollowing);
let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou); let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou);
let wait = $ref(false); let wait = $ref(false);
const connection = stream.useChannel('main'); const connection = useStream().useChannel('main');
if (props.user.isFollowing == null) { if (props.user.isFollowing == null) {
os.api('users/show', { os.api('users/show', {

View File

@ -2,9 +2,9 @@
<MkModalWindow <MkModalWindow
ref="dialog" ref="dialog"
:width="450" :width="450"
:can-close="false" :canClose="false"
:with-ok-button="true" :withOkButton="true"
:ok-button-disabled="false" :okButtonDisabled="false"
@click="cancel()" @click="cancel()"
@ok="ok()" @ok="ok()"
@close="cancel()" @close="cancel()"
@ -14,7 +14,7 @@
{{ title }} {{ title }}
</template> </template>
<MkSpacer :margin-min="20" :margin-max="32"> <MkSpacer :marginMin="20" :marginMax="32">
<div class="_gaps_m"> <div class="_gaps_m">
<template v-for="item in Object.keys(form).filter(item => !form[item].hidden)"> <template v-for="item in Object.keys(form).filter(item => !form[item].hidden)">
<MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"> <MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1">
@ -41,7 +41,7 @@
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option> <option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option>
</MkRadios> </MkRadios>
<MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter"> <MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :textConverter="form[item].textConverter">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> <template v-if="form[item].description" #caption>{{ form[item].description }}</template>
</MkRange> </MkRange>
@ -54,8 +54,8 @@
</MkModalWindow> </MkModalWindow>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { reactive, shallowRef } from 'vue';
import MkInput from './MkInput.vue'; import MkInput from './MkInput.vue';
import MkTextarea from './MkTextarea.vue'; import MkTextarea from './MkTextarea.vue';
import MkSwitch from './MkSwitch.vue'; import MkSwitch from './MkSwitch.vue';
@ -66,58 +66,36 @@ import MkRadios from './MkRadios.vue';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
export default defineComponent({ const props = defineProps<{
components: { title: string;
MkModalWindow, form: any;
MkInput, }>();
MkTextarea,
MkSwitch,
MkSelect,
MkRange,
MkButton,
MkRadios,
},
props: { const emit = defineEmits<{
title: { (ev: 'done', v: {
type: String, canceled?: boolean;
required: true, result?: any;
}, }): void;
form: { }>();
type: Object,
required: true,
},
},
emits: ['done'], const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const values = reactive({});
data() { for (const item in props.form) {
return { values[item] = props.form[item].default ?? null;
values: {}, }
i18n,
};
},
created() { function ok() {
for (const item in this.form) { emit('done', {
this.values[item] = this.form[item].default ?? null; result: values,
} });
}, dialog.value.close();
}
methods: { function cancel() {
ok() { emit('done', {
this.$emit('done', { canceled: true,
result: this.values, });
}); dialog.value.close();
this.$refs.dialog.close(); }
},
cancel() {
this.$emit('done', {
canceled: true,
});
this.$refs.dialog.close();
},
},
});
</script> </script>

View File

@ -44,6 +44,10 @@ export const Default = {
], ],
parameters: { parameters: {
layout: 'centered', layout: 'centered',
chromatic: {
// FIXME: flaky
disableSnapshot: true,
},
}, },
} satisfies StoryObj<typeof MkGalleryPostPreview>; } satisfies StoryObj<typeof MkGalleryPostPreview>;
export const Hover = { export const Hover = {

View File

@ -5,16 +5,13 @@
<ImgWithBlurhash <ImgWithBlurhash
class="img layered" class="img layered"
:transition="safe ? null : { :transition="safe ? null : {
enterActiveClass: $style.transition_toggle_enterActive, duration: 500,
leaveActiveClass: $style.transition_toggle_leaveActive, leaveActiveClass: $style.transition_toggle_leaveActive,
enterFromClass: $style.transition_toggle_enterFrom,
leaveToClass: $style.transition_toggle_leaveTo, leaveToClass: $style.transition_toggle_leaveTo,
enterToClass: $style.transition_toggle_enterTo,
leaveFromClass: $style.transition_toggle_leaveFrom,
}" }"
:src="post.files[0].thumbnailUrl" :src="post.files[0].thumbnailUrl"
:hash="post.files[0].blurhash" :hash="post.files[0].blurhash"
:force-blurhash="!show" :forceBlurhash="!show"
/> />
</Transition> </Transition>
</div> </div>
@ -53,24 +50,16 @@ function leaveHover(): void {
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.transition_toggle_enterActive,
.transition_toggle_leaveActive { .transition_toggle_leaveActive {
transition: opacity 0.5s; transition: opacity .5s;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
} }
.transition_toggle_enterFrom,
.transition_toggle_leaveTo { .transition_toggle_leaveTo {
opacity: 0; opacity: 0;
} }
.transition_toggle_enterTo,
.transition_toggle_leaveFrom {
transition: none;
opacity: 1;
}
</style> </style>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -1,5 +1,5 @@
<template> <template>
<MkModal ref="modal" :z-priority="'middle'" @click="modal.close()" @closed="emit('closed')"> <MkModal ref="modal" :zPriority="'middle'" @click="modal.close()" @closed="emit('closed')">
<div class="xubzgfga"> <div class="xubzgfga">
<header>{{ image.name }}</header> <header>{{ image.name }}</header>
<img :src="image.url" :alt="image.comment" :title="image.comment" @click="modal.close()"/> <img :src="image.url" :alt="image.comment" :title="image.comment" @click="modal.close()"/>

View File

@ -1,30 +1,56 @@
<template> <template>
<div :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''"> <div ref="root" :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''">
<img v-if="!loaded && src && !forceBlurhash" :class="$style.loader" :src="src" @load="onLoad"/> <TransitionGroup
<Transition :duration="defaultStore.state.animation && props.transition?.duration || undefined"
mode="in-out" :enterActiveClass="defaultStore.state.animation && props.transition?.enterActiveClass || undefined"
:enter-active-class="defaultStore.state.animation && (props.transition?.enterActiveClass ?? $style['transition_toggle_enterActive']) || undefined" :leaveActiveClass="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style['transition_leaveActive']) || undefined"
:leave-active-class="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style['transition_toggle_leaveActive']) || undefined" :enterFromClass="defaultStore.state.animation && props.transition?.enterFromClass || undefined"
:enter-from-class="defaultStore.state.animation && props.transition?.enterFromClass || undefined" :leaveToClass="defaultStore.state.animation && props.transition?.leaveToClass || undefined"
:leave-to-class="defaultStore.state.animation && props.transition?.leaveToClass || undefined" :enterToClass="defaultStore.state.animation && props.transition?.enterToClass || undefined"
:enter-to-class="defaultStore.state.animation && (props.transition?.enterToClass ?? $style['transition_toggle_enterTo']) || undefined" :leaveFromClass="defaultStore.state.animation && props.transition?.leaveFromClass || undefined"
:leave-from-class="defaultStore.state.animation && (props.transition?.leaveFromClass ?? $style['transition_toggle_leaveFrom']) || undefined"
> >
<canvas v-if="!loaded || forceBlurhash" ref="canvas" :class="$style.canvas" :width="width" :height="height" :title="title ?? undefined"/> <canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined"/>
<img v-else :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined"/> <img v-show="!hide" key="img" ref="img" :height="imgHeight" :width="imgWidth" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async"/>
</Transition> </TransitionGroup>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts">
import { onMounted, shallowRef, useCssModule, watch } from 'vue'; import { $ref } from 'vue/macros';
import { decode } from 'blurhash'; import DrawBlurhash from '@/workers/draw-blurhash?worker';
import { defaultStore } from '@/store'; import TestWebGL2 from '@/workers/test-webgl2?worker';
import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch';
import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
const workerPromise = new Promise<WorkerMultiDispatch | null>(resolve => {
const testWorker = new TestWebGL2();
testWorker.addEventListener('message', event => {
if (event.data.result) {
const workers = new WorkerMultiDispatch(
() => new DrawBlurhash(),
Math.min(navigator.hardwareConcurrency - 1, 4),
);
resolve(workers);
if (_DEV_) console.log('WebGL2 in worker is supported!');
} else {
resolve(null);
if (_DEV_) console.log('WebGL2 in worker is not supported...');
}
testWorker.terminate();
});
});
</script>
<script lang="ts" setup>
import { computed, nextTick, onMounted, onUnmounted, shallowRef, useCssModule, watch } from 'vue';
import { v4 as uuid } from 'uuid';
import { render } from 'buraha';
import { defaultStore } from '@/store';
const $style = useCssModule(); const $style = useCssModule();
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
transition?: { transition?: {
duration?: number | { enter: number; leave: number; };
enterActiveClass?: string; enterActiveClass?: string;
leaveActiveClass?: string; leaveActiveClass?: string;
enterFromClass?: string; enterFromClass?: string;
@ -51,67 +77,141 @@ const props = withDefaults(defineProps<{
forceBlurhash: false, forceBlurhash: false,
}); });
const viewId = uuid();
const canvas = shallowRef<HTMLCanvasElement>(); const canvas = shallowRef<HTMLCanvasElement>();
const root = shallowRef<HTMLDivElement>();
const img = shallowRef<HTMLImageElement>();
let loaded = $ref(false); let loaded = $ref(false);
let width = $ref(props.width); let canvasWidth = $ref(64);
let height = $ref(props.height); let canvasHeight = $ref(64);
let imgWidth = $ref(props.width);
let imgHeight = $ref(props.height);
let bitmapTmp = $ref<CanvasImageSource | undefined>();
const hide = computed(() => !loaded || props.forceBlurhash);
function onLoad() { function waitForDecode() {
loaded = true; if (props.src != null && props.src !== '') {
nextTick()
.then(() => img.value?.decode())
.then(() => {
loaded = true;
}, error => {
console.error('Error occured during decoding image', img.value, error);
throw Error(error);
});
} else {
loaded = false;
}
} }
watch([() => props.width, () => props.height], () => { watch([() => props.width, () => props.height, root], () => {
const ratio = props.width / props.height; const ratio = props.width / props.height;
if (ratio > 1) { if (ratio > 1) {
width = Math.round(64 * ratio); canvasWidth = Math.round(64 * ratio);
height = 64; canvasHeight = 64;
} else { } else {
width = 64; canvasWidth = 64;
height = Math.round(64 / ratio); canvasHeight = Math.round(64 / ratio);
} }
const clientWidth = root.value?.clientWidth ?? 300;
imgWidth = clientWidth;
imgHeight = Math.round(clientWidth / ratio);
}, { }, {
immediate: true, immediate: true,
}); });
function draw() { function drawImage(bitmap: CanvasImageSource) {
if (props.hash == null || !canvas.value) return; // canvasmountedTmp
const pixels = decode(props.hash, width, height); if (!canvas.value) {
bitmapTmp = bitmap;
return;
}
// canvas
bitmapTmp = undefined;
const ctx = canvas.value.getContext('2d'); const ctx = canvas.value.getContext('2d');
const imageData = ctx!.createImageData(width, height); if (!ctx) return;
imageData.data.set(pixels); ctx.drawImage(bitmap, 0, 0, canvasWidth, canvasHeight);
ctx!.putImageData(imageData, 0, 0);
} }
watch([() => props.hash, canvas], () => { async function draw() {
if (!canvas.value || props.hash == null) return;
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
// avgColor
ctx.beginPath();
ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
const workers = await workerPromise;
if (workers) {
workers.postMessage(
{
id: viewId,
hash: props.hash,
width: canvasWidth,
height: canvasHeight,
},
undefined,
);
} else {
try {
const work = document.createElement('canvas');
work.width = canvasWidth;
work.height = canvasHeight;
render(props.hash, work);
ctx.drawImage(work, 0, 0, canvasWidth, canvasHeight);
} catch (error) {
console.error('Error occured during drawing blurhash', error);
}
}
}
function workerOnMessage(event: MessageEvent) {
if (event.data.id !== viewId) return;
drawImage(event.data.bitmap as ImageBitmap);
}
workerPromise.then(worker => {
if (worker) {
worker.addListener(workerOnMessage);
}
draw();
});
watch(() => props.src, () => {
waitForDecode();
});
watch(() => props.hash, () => {
draw(); draw();
}); });
onMounted(() => { onMounted(() => {
draw(); // drawImagemounted
if (bitmapTmp) {
drawImage(bitmapTmp);
}
waitForDecode();
});
onUnmounted(() => {
workerPromise.then(worker => {
worker?.removeListener(workerOnMessage);
});
}); });
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.transition_toggle_enterActive, .transition_leaveActive {
.transition_toggle_leaveActive {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
} }
.transition_toggle_enterTo,
.transition_toggle_leaveFrom {
opacity: 0;
}
.loader {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
}
.root { .root {
position: relative; position: relative;
width: 100%; width: 100%;

View File

@ -1,9 +1,9 @@
<template> <template>
<div class="alqyeyti" :class="{ oneline }"> <div :class="[$style.root, { [$style.oneline]: oneline }]">
<div class="key"> <div :class="$style.key">
<slot name="key"></slot> <slot name="key"></slot>
</div> </div>
<div class="value"> <div :class="$style.value">
<slot name="value"></slot> <slot name="value"></slot>
<button v-if="copy" v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="ti ti-copy"></i></button> <button v-if="copy" v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="ti ti-copy"></i></button>
</div> </div>
@ -30,24 +30,18 @@ const copy_ = () => {
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.alqyeyti { .root {
> .key {
font-size: 0.85em;
padding: 0 0 0.25em 0;
opacity: 0.75;
}
&.oneline { &.oneline {
display: flex; display: flex;
> .key { .key {
width: 30%; width: 30%;
font-size: 1em; font-size: 1em;
padding: 0 8px 0 0; padding: 0 8px 0 0;
} }
> .value { .value {
width: 70%; width: 70%;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -55,4 +49,10 @@ const copy_ = () => {
} }
} }
} }
.key {
font-size: 0.85em;
padding: 0 0 0.25em 0;
opacity: 0.75;
}
</style> </style>

View File

@ -1,5 +1,5 @@
<template> <template>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :prefer-type="preferedModalType" :anchor="anchor" :transparent-bg="true" :src="src" @click="modal.close()" @closed="emit('closed')"> <MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal.close()" @closed="emit('closed')">
<div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }"> <div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }">
<div class="main"> <div class="main">
<template v-for="item in items"> <template v-for="item in items">

View File

@ -1,29 +1,40 @@
<template> <template>
<div v-if="hide" :class="$style.hidden" @click="hide = false"> <div :class="hide ? $style.hidden : $style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'" @click="onclick">
<ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment" :width="image.properties.width" :height="image.properties.height" :force-blurhash="defaultStore.state.enableDataSaverMode"/>
<div :class="$style.hiddenText">
<div :class="$style.hiddenTextWrapper">
<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b>
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</div>
</div>
<div v-else :class="$style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'">
<a <a
:class="$style.imageContainer" :class="$style.imageContainer"
:href="image.url" :href="image.url"
:title="image.name" :title="image.name"
> >
<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :width="image.properties.width" :height="image.properties.height" :cover="false"/> <ImgWithBlurhash
:hash="image.blurhash"
:src="(defaultStore.state.enableDataSaverMode && hide) ? null : url"
:forceBlurhash="hide"
:cover="hide"
:alt="image.comment || image.name"
:title="image.comment || image.name"
:width="image.properties.width"
:height="image.properties.height"
:style="hide ? 'filter: brightness(0.5);' : null"
/>
</a> </a>
<div :class="$style.indicators"> <template v-if="hide">
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div> <div :class="$style.hiddenText">
<div v-if="image.comment" :class="$style.indicator">ALT</div> <div :class="$style.hiddenTextWrapper">
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div> <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
</div> <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b>
<button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button> <span style="display: block;">{{ i18n.ts.clickToShow }}</span>
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots"></i></button> </div>
</div>
</template>
<template v-else>
<div :class="$style.indicators">
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
<div v-if="image.comment" :class="$style.indicator">ALT</div>
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div>
</div>
<button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click.stop.prevent="hide = true"><i class="ti ti-eye-off"></i></button>
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots"></i></button>
</template>
</div> </div>
</template> </template>
@ -53,6 +64,12 @@ const url = $computed(() => (props.raw || defaultStore.state.loadRawImages)
: props.image.thumbnailUrl, : props.image.thumbnailUrl,
); );
function onclick() {
if (hide) {
hide = false;
}
}
// Plugin:register_note_view_interruptor 使watch // Plugin:register_note_view_interruptor 使watch
watch(() => props.image, () => { watch(() => props.image, () => {
hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore'); hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore');

View File

@ -7,6 +7,7 @@
:class="[ :class="[
$style.medias, $style.medias,
count <= 4 ? $style['n' + count] : $style.nMany, count <= 4 ? $style['n' + count] : $style.nMany,
$style[`n1${defaultStore.reactiveState.mediaListWithOneImageAppearance.value}`]
]" ]"
> >
<template v-for="media in mediaList.filter(media => previewable(media))"> <template v-for="media in mediaList.filter(media => previewable(media))">
@ -19,7 +20,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref, useCssModule, watch } from 'vue'; import { onMounted, ref, useCssModule, watch, shallowRef } from 'vue';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import PhotoSwipeLightbox from 'photoswipe/lightbox'; import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe'; import PhotoSwipe from 'photoswipe';
@ -38,11 +39,42 @@ const props = defineProps<{
const $style = useCssModule(); const $style = useCssModule();
const gallery = ref<HTMLDivElement>(); const gallery = shallowRef<HTMLDivElement>();
const pswpZIndex = os.claimZIndex('middle'); const pswpZIndex = os.claimZIndex('middle');
document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString()); document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
const count = $computed(() => props.mediaList.filter(media => previewable(media)).length); const count = $computed(() => props.mediaList.filter(media => previewable(media)).length);
function calcAspectRatio() {
if (!gallery.value) return;
let img = props.mediaList[0];
if (props.mediaList.length !== 1 || !(img.properties.width && img.properties.height)) {
gallery.value.style.aspectRatio = '';
return;
}
//
const ratioMax = (ratio: number) => `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`;
switch (defaultStore.state.mediaListWithOneImageAppearance) {
case '16_9':
gallery.value.style.aspectRatio = ratioMax(16 / 9);
break;
case '1_1':
gallery.value.style.aspectRatio = ratioMax(1);
break;
case '2_3':
gallery.value.style.aspectRatio = ratioMax(2 / 3);
break;
default:
gallery.value.style.aspectRatio = '';
break;
}
}
watch([defaultStore.reactiveState.mediaListWithOneImageAppearance, gallery], () => calcAspectRatio());
onMounted(() => { onMounted(() => {
const lightbox = new PhotoSwipeLightbox({ const lightbox = new PhotoSwipeLightbox({
dataSource: props.mediaList dataSource: props.mediaList
@ -162,12 +194,37 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
display: grid; display: grid;
grid-gap: 8px; grid-gap: 8px;
// for webkit
height: 100%; height: 100%;
width: 100%;
&.n1 { &.n1 {
aspect-ratio: 16/9;
grid-template-rows: 1fr; grid-template-rows: 1fr;
// default (expand)
min-height: 64px;
max-height: clamp(
64px,
50cqh,
min(360px, 50vh)
);
&.n116_9 {
min-height: none;
max-height: none;
aspect-ratio: 16 / 9; // fallback
}
&.n11_1{
min-height: none;
max-height: none;
aspect-ratio: 1 / 1; // fallback
}
&.n12_3 {
min-height: none;
max-height: none;
aspect-ratio: 2 / 3; // fallback
}
} }
&.n2 { &.n2 {

View File

@ -27,8 +27,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import bytes from '@/filters/bytes';
import VuePlyr from 'vue-plyr'; import VuePlyr from 'vue-plyr';
import bytes from '@/filters/bytes';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import 'vue-plyr/dist/vue-plyr.css'; import 'vue-plyr/dist/vue-plyr.css';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';

View File

@ -1,6 +1,6 @@
<template> <template>
<div ref="el" :class="$style.root"> <div ref="el" :class="$style.root">
<MkMenu :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/> <MkMenu :items="items" :align="align" :width="width" :asDrawer="false" @close="onChildClosed"/>
</div> </div>
</template> </template>

View File

@ -50,7 +50,7 @@
</span> </span>
</div> </div>
<div v-if="childMenu" :class="$style.child"> <div v-if="childMenu" :class="$style.child">
<XChild ref="child" :items="childMenu" :target-element="childTarget" :root-element="itemsEl" showing @actioned="childActioned"/> <XChild ref="child" :items="childMenu" :targetElement="childTarget" :rootElement="itemsEl" showing @actioned="childActioned"/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -1,11 +1,11 @@
<template> <template>
<Transition <Transition
:name="transitionName" :name="transitionName"
:enter-active-class="$style['transition_' + transitionName + '_enterActive']" :enterActiveClass="$style['transition_' + transitionName + '_enterActive']"
:leave-active-class="$style['transition_' + transitionName + '_leaveActive']" :leaveActiveClass="$style['transition_' + transitionName + '_leaveActive']"
:enter-from-class="$style['transition_' + transitionName + '_enterFrom']" :enterFromClass="$style['transition_' + transitionName + '_enterFrom']"
:leave-to-class="$style['transition_' + transitionName + '_leaveTo']" :leaveToClass="$style['transition_' + transitionName + '_leaveTo']"
:duration="transitionDuration" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened" :duration="transitionDuration" appear @afterLeave="emit('closed')" @enter="emit('opening')" @afterEnter="onOpened"
> >
<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> <div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
<div data-cy-bg :data-cy-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div> <div data-cy-bg :data-cy-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div>

View File

@ -55,17 +55,17 @@
<div :class="$style.text"> <div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> <MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/> <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
<div v-if="translating || translation" :class="$style.translation"> <div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/> <MkLoading v-if="translating" mini/>
<div v-else :class="$style.translated"> <div v-else :class="$style.translated">
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/> <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
</div> </div>
</div> </div>
</div> </div>
<div v-if="appearNote.files.length > 0" :class="$style.files"> <div v-if="appearNote.files.length > 0" :class="$style.files">
<MkMediaList :media-list="appearNote.files"/> <MkMediaList :mediaList="appearNote.files"/>
</div> </div>
<MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll"/> <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
@ -79,7 +79,7 @@
</div> </div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div> </div>
<MkReactionsViewer :note="appearNote" :max-number="16"> <MkReactionsViewer :note="appearNote" :maxNumber="16">
<template #more> <template #more>
<button class="_button" :class="$style.reactionDetailsButton" @click="showReactions"> <button class="_button" :class="$style.reactionDetailsButton" @click="showReactions">
{{ i18n.ts.more }} {{ i18n.ts.more }}

Some files were not shown because too many files have changed in this diff Show More