diff --git a/CHANGELOG.md b/CHANGELOG.md index a77a95c025..9bfb12d25e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,12 @@ - Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正 - Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正 - Fix: v2023.12.1で追加された`$[clickable ...]`および`onClickEv`が正しく機能していないのを修正 +- Fix: Renoteのキーボードショートカットが機能していなかった問題を修正 +- Fix: 投稿フォームでアンケートの日時指定をした状態で再読み込みをすると期日が復元されない問題を修正 +- Fix: アンケートを設定したノートを「削除して編集」をするとアンケートの期日が引き継がれず、リセットされてしまう問題を修正 +- Fix: デッキのプロファイル作成時に名前を空にできる問題を修正 +- Fix: テーマ作成時に名称が空欄でも作成できてしまう問題を修正 +- Fix: プラグインで`Plugin:register_note_post_interruptor`を使用すると、ノートが投稿できなくなる問題を修正 - Enhance: ページ遷移時にPlayerを閉じるように - Fix: iOSで大きな画像を変換してアップロードできない問題を修正 diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 5b4c8cb44f..6a72671665 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -54,9 +54,9 @@ export interface MainEventTypes { reply: Packed<'Note'>; renote: Packed<'Note'>; follow: Packed<'UserDetailedNotMe'>; - followed: Packed<'User'>; - unfollow: Packed<'User'>; - meUpdated: Packed<'User'>; + followed: Packed<'UserDetailed' | 'UserLite'>; + unfollow: Packed<'UserDetailed'>; + meUpdated: Packed<'UserDetailed'>; pageEvent: { pageId: MiPage['id']; event: string; diff --git a/packages/backend/src/core/chart/core.ts b/packages/backend/src/core/chart/core.ts index 8d0a89f2d6..b1cde2f6e2 100644 --- a/packages/backend/src/core/chart/core.ts +++ b/packages/backend/src/core/chart/core.ts @@ -94,6 +94,29 @@ type ToJsonSchema<S> = { }; export function getJsonSchema<S extends Schema>(schema: S): ToJsonSchema<Unflatten<ChartResult<S>>> { + const unflatten = (str: string, parent: Record<string, any>) => { + const keys = str.split('.'); + const key = keys.shift(); + const nextKey = keys[0]; + + if (key == null) return; + + if (parent.properties[key] == null) { + parent.properties[key] = nextKey ? { + type: 'object', + properties: {}, + required: [], + } : { + type: 'array', + items: { + type: 'number', + }, + }; + } + + if (nextKey) unflatten(keys.join('.'), parent.properties[key] as Record<string, any>); + }; + const jsonSchema = { type: 'object', properties: {} as Record<string, unknown>, @@ -101,10 +124,7 @@ export function getJsonSchema<S extends Schema>(schema: S): ToJsonSchema<Unflatt }; for (const k in schema) { - jsonSchema.properties[k] = { - type: 'array', - items: { type: 'number' }, - }; + unflatten(k, jsonSchema); } return jsonSchema as ToJsonSchema<Unflatten<ChartResult<S>>>; diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 1777e2cf54..03cd58917b 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -164,7 +164,7 @@ export class NoteEntityService implements OnModuleInit { return { multiple: poll.multiple, - expiresAt: poll.expiresAt, + expiresAt: poll.expiresAt?.toISOString() ?? null, choices, }; } diff --git a/packages/backend/src/misc/clone.ts b/packages/backend/src/misc/clone.ts index 9d20deac3b..52e6c825f9 100644 --- a/packages/backend/src/misc/clone.ts +++ b/packages/backend/src/misc/clone.ts @@ -6,7 +6,7 @@ // structredCloneが遅いため // SEE: http://var.blog.jp/archives/86038606.html -type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[]; +type Cloneable = string | number | boolean | null | undefined | { [key: string]: Cloneable } | Cloneable[]; export function deepClone<T extends Cloneable>(x: T): T { if (typeof x === 'object') { @@ -14,7 +14,7 @@ export function deepClone<T extends Cloneable>(x: T): T { if (Array.isArray(x)) return x.map(deepClone) as T; const obj = {} as Record<string, Cloneable>; for (const [k, v] of Object.entries(x)) { - obj[k] = deepClone(v); + obj[k] = v === undefined ? undefined : deepClone(v); } return obj as T; } else { diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index b4f0541712..0dd8f15d9a 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -25,7 +25,7 @@ import { packedBlockingSchema } from '@/models/json-schema/blocking.js'; import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js'; import { packedHashtagSchema } from '@/models/json-schema/hashtag.js'; import { packedInviteCodeSchema } from '@/models/json-schema/invite-code.js'; -import { packedPageSchema } from '@/models/json-schema/page.js'; +import { packedPageSchema, packedPageBlockSchema } from '@/models/json-schema/page.js'; import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js'; import { packedChannelSchema } from '@/models/json-schema/channel.js'; import { packedAntennaSchema } from '@/models/json-schema/antenna.js'; @@ -37,7 +37,7 @@ import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/jso import { packedFlashSchema } from '@/models/json-schema/flash.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; import { packedSigninSchema } from '@/models/json-schema/signin.js'; -import { packedRoleLiteSchema, packedRoleSchema } from '@/models/json-schema/role.js'; +import { packedRoleLiteSchema, packedRoleSchema, packedRolePoliciesSchema } from '@/models/json-schema/role.js'; import { packedAdSchema } from '@/models/json-schema/ad.js'; import { packedReversiGameLiteSchema, packedReversiGameDetailedSchema } from '@/models/json-schema/reversi-game.js'; @@ -67,6 +67,7 @@ export const refs = { Hashtag: packedHashtagSchema, InviteCode: packedInviteCodeSchema, Page: packedPageSchema, + PageBlock: packedPageBlockSchema, Channel: packedChannelSchema, QueueCount: packedQueueCountSchema, Antenna: packedAntennaSchema, @@ -79,12 +80,16 @@ export const refs = { Signin: packedSigninSchema, RoleLite: packedRoleLiteSchema, Role: packedRoleSchema, + RolePolicies: packedRolePoliciesSchema, ReversiGameLite: packedReversiGameLiteSchema, ReversiGameDetailed: packedReversiGameDetailedSchema, }; export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>; +export type KeyOf<x extends keyof typeof refs> = PropertiesToUnion<typeof refs[x]>; +type PropertiesToUnion<p extends Schema> = p['properties'] extends NonNullable<Obj> ? keyof p['properties'] : never; + type TypeStringef = 'null' | 'boolean' | 'integer' | 'number' | 'string' | 'array' | 'object' | 'any'; type StringDefToType<T extends TypeStringef> = T extends 'null' ? null : diff --git a/packages/backend/src/models/Announcement.ts b/packages/backend/src/models/Announcement.ts index 8f8be88fed..c2d9e9878c 100644 --- a/packages/backend/src/models/Announcement.ts +++ b/packages/backend/src/models/Announcement.ts @@ -38,7 +38,7 @@ export class MiAnnouncement { length: 256, nullable: false, default: 'info', }) - public icon: string; + public icon: 'info' | 'warning' | 'error' | 'success'; // normal ... お知らせページ掲載 // banner ... お知らせページ掲載 + バナー表示 @@ -47,7 +47,7 @@ export class MiAnnouncement { length: 256, nullable: false, default: 'normal', }) - public display: string; + public display: 'normal' | 'banner' | 'dialog'; @Column('boolean', { default: false, diff --git a/packages/backend/src/models/json-schema/announcement.ts b/packages/backend/src/models/json-schema/announcement.ts index 78a98872b2..57fd7d605d 100644 --- a/packages/backend/src/models/json-schema/announcement.ts +++ b/packages/backend/src/models/json-schema/announcement.ts @@ -37,10 +37,12 @@ export const packedAnnouncementSchema = { icon: { type: 'string', optional: false, nullable: false, + enum: ['info', 'warning', 'error', 'success'], }, display: { type: 'string', optional: false, nullable: false, + enum: ['dialog', 'normal', 'banner'], }, needConfirmationToRead: { type: 'boolean', diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index 2b7722129b..929f697e8a 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -69,6 +69,7 @@ export const packedNoteSchema = { visibility: { type: 'string', optional: false, nullable: false, + enum: ['public', 'home', 'followers', 'specified'], }, mentions: { type: 'array', @@ -117,6 +118,48 @@ export const packedNoteSchema = { poll: { type: 'object', optional: true, nullable: true, + properties: { + expiresAt: { + type: 'string', + optional: true, nullable: true, + format: 'date-time', + }, + multiple: { + type: 'boolean', + optional: false, nullable: false, + }, + choices: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + isVoted: { + type: 'boolean', + optional: false, nullable: false, + }, + text: { + type: 'string', + optional: false, nullable: false, + }, + votes: { + type: 'number', + optional: false, nullable: false, + }, + }, + }, + }, + }, + }, + emojis: { + type: 'object', + optional: true, nullable: false, + additionalProperties: { + anyOf: [{ + type: 'string', + }], + }, }, channelId: { type: 'string', @@ -162,9 +205,23 @@ export const packedNoteSchema = { type: 'string', optional: false, nullable: true, }, + reactionEmojis: { + type: 'object', + optional: false, nullable: false, + additionalProperties: { + anyOf: [{ + type: 'string', + }], + }, + }, reactions: { type: 'object', optional: false, nullable: false, + additionalProperties: { + anyOf: [{ + type: 'number', + }], + }, }, renoteCount: { type: 'number', @@ -196,7 +253,7 @@ export const packedNoteSchema = { }, myReaction: { - type: 'object', + type: 'string', optional: true, nullable: true, }, }, diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index c6d6e84317..6286950de5 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -5,7 +5,7 @@ import { notificationTypes } from '@/types.js'; -export const packedNotificationSchema = { +const baseSchema = { type: 'object', properties: { id: { @@ -23,68 +23,368 @@ export const packedNotificationSchema = { optional: false, nullable: false, enum: [...notificationTypes, 'reaction:grouped', 'renote:grouped'], }, - user: { - type: 'object', - ref: 'UserLite', - optional: true, nullable: true, - }, - userId: { - type: 'string', - optional: true, nullable: true, - format: 'id', - }, - note: { - type: 'object', - ref: 'Note', - optional: true, nullable: true, - }, - reaction: { - type: 'string', - optional: true, nullable: true, - }, - achievement: { - type: 'string', - optional: true, nullable: false, - }, - body: { - type: 'string', - optional: true, nullable: true, - }, - header: { - type: 'string', - optional: true, nullable: true, - }, - icon: { - type: 'string', - optional: true, nullable: true, - }, - reactions: { - type: 'array', - optional: true, nullable: true, - items: { - type: 'object', - properties: { - user: { - type: 'object', - ref: 'UserLite', - optional: false, nullable: false, - }, - reaction: { - type: 'string', - optional: false, nullable: false, - }, - }, - required: ['user', 'reaction'], + }, +} as const; + +export const packedNotificationSchema = { + type: 'object', + oneOf: [{ + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['note'], }, - }, - users: { - type: 'array', - optional: true, nullable: true, - items: { + user: { type: 'object', ref: 'UserLite', optional: false, nullable: false, }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + note: { + type: 'object', + ref: 'Note', + optional: false, nullable: false, + }, }, - }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['mention'], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + note: { + type: 'object', + ref: 'Note', + optional: false, nullable: false, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['reply'], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + note: { + type: 'object', + ref: 'Note', + optional: false, nullable: false, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['renote'], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + note: { + type: 'object', + ref: 'Note', + optional: false, nullable: false, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['quote'], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + note: { + type: 'object', + ref: 'Note', + optional: false, nullable: false, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['reaction'], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + note: { + type: 'object', + ref: 'Note', + optional: false, nullable: false, + }, + reaction: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['pollEnded'], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + note: { + type: 'object', + ref: 'Note', + optional: false, nullable: false, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['follow'], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['receiveFollowRequest'], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['followRequestAccepted'], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['roleAssigned'], + }, + role: { + type: 'object', + ref: 'Role', + optional: false, nullable: false, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['achievementEarned'], + }, + achievement: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['app'], + }, + body: { + type: 'string', + optional: false, nullable: false, + }, + header: { + type: 'string', + optional: false, nullable: false, + }, + icon: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['reaction:grouped'], + }, + note: { + type: 'object', + ref: 'Note', + optional: false, nullable: false, + }, + reactions: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + properties: { + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + reaction: { + type: 'string', + optional: false, nullable: false, + }, + }, + required: ['user', 'reaction'], + }, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['renote:grouped'], + }, + note: { + type: 'object', + ref: 'Note', + optional: false, nullable: false, + }, + users: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['test'], + }, + }, + }], } as const; diff --git a/packages/backend/src/models/json-schema/page.ts b/packages/backend/src/models/json-schema/page.ts index 9baacd6884..402db76e52 100644 --- a/packages/backend/src/models/json-schema/page.ts +++ b/packages/backend/src/models/json-schema/page.ts @@ -3,6 +3,107 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +const blockBaseSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + type: { + type: 'string', + optional: false, nullable: false, + }, + }, +} as const; + +const textBlockSchema = { + type: 'object', + properties: { + ...blockBaseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['text'], + }, + text: { + type: 'string', + optional: false, nullable: false, + }, + }, +} as const; + +const sectionBlockSchema = { + type: 'object', + properties: { + ...blockBaseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['section'], + }, + title: { + type: 'string', + optional: false, nullable: false, + }, + children: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'PageBlock', + }, + }, + }, +} as const; + +const imageBlockSchema = { + type: 'object', + properties: { + ...blockBaseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['image'], + }, + fileId: { + type: 'string', + optional: false, nullable: true, + }, + }, +} as const; + +const noteBlockSchema = { + type: 'object', + properties: { + ...blockBaseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['note'], + }, + detailed: { + type: 'boolean', + optional: false, nullable: false, + }, + note: { + type: 'string', + optional: false, nullable: true, + }, + }, +} as const; + +export const packedPageBlockSchema = { + type: 'object', + oneOf: [ + textBlockSchema, + sectionBlockSchema, + imageBlockSchema, + noteBlockSchema, + ], +} as const; + export const packedPageSchema = { type: 'object', properties: { @@ -38,6 +139,7 @@ export const packedPageSchema = { items: { type: 'object', optional: false, nullable: false, + ref: 'PageBlock', }, }, variables: { diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index b0c6804bb8..55348d4f3d 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -1,26 +1,103 @@ -const rolePolicyValue = { +export const packedRolePoliciesSchema = { type: 'object', + optional: false, nullable: false, properties: { - value: { - oneOf: [ - { - type: 'integer', - optional: false, nullable: false, - }, - { - type: 'boolean', - optional: false, nullable: false, - }, - ], + gtlAvailable: { + type: 'boolean', + optional: false, nullable: false, }, - priority: { + ltlAvailable: { + type: 'boolean', + optional: false, nullable: false, + }, + canPublicNote: { + type: 'boolean', + optional: false, nullable: false, + }, + canInvite: { + type: 'boolean', + optional: false, nullable: false, + }, + inviteLimit: { type: 'integer', optional: false, nullable: false, }, - useDefault: { + inviteLimitCycle: { + type: 'integer', + optional: false, nullable: false, + }, + inviteExpirationTime: { + type: 'integer', + optional: false, nullable: false, + }, + canManageCustomEmojis: { type: 'boolean', optional: false, nullable: false, }, + canManageAvatarDecorations: { + type: 'boolean', + optional: false, nullable: false, + }, + canSearchNotes: { + type: 'boolean', + optional: false, nullable: false, + }, + canUseTranslator: { + type: 'boolean', + optional: false, nullable: false, + }, + canHideAds: { + type: 'boolean', + optional: false, nullable: false, + }, + driveCapacityMb: { + type: 'integer', + optional: false, nullable: false, + }, + alwaysMarkNsfw: { + type: 'boolean', + optional: false, nullable: false, + }, + pinLimit: { + type: 'integer', + optional: false, nullable: false, + }, + antennaLimit: { + type: 'integer', + optional: false, nullable: false, + }, + wordMuteLimit: { + type: 'integer', + optional: false, nullable: false, + }, + webhookLimit: { + type: 'integer', + optional: false, nullable: false, + }, + clipLimit: { + type: 'integer', + optional: false, nullable: false, + }, + noteEachClipsLimit: { + type: 'integer', + optional: false, nullable: false, + }, + userListLimit: { + type: 'integer', + optional: false, nullable: false, + }, + userEachUserListsLimit: { + type: 'integer', + optional: false, nullable: false, + }, + rateLimitFactor: { + type: 'integer', + optional: false, nullable: false, + }, + avatarDecorationLimit: { + type: 'integer', + optional: false, nullable: false, + }, }, } as const; @@ -121,31 +198,28 @@ export const packedRoleSchema = { policies: { type: 'object', optional: false, nullable: false, - properties: { - pinLimit: rolePolicyValue, - canInvite: rolePolicyValue, - clipLimit: rolePolicyValue, - canHideAds: rolePolicyValue, - inviteLimit: rolePolicyValue, - antennaLimit: rolePolicyValue, - gtlAvailable: rolePolicyValue, - ltlAvailable: rolePolicyValue, - webhookLimit: rolePolicyValue, - canPublicNote: rolePolicyValue, - userListLimit: rolePolicyValue, - wordMuteLimit: rolePolicyValue, - alwaysMarkNsfw: rolePolicyValue, - canSearchNotes: rolePolicyValue, - driveCapacityMb: rolePolicyValue, - rateLimitFactor: rolePolicyValue, - inviteLimitCycle: rolePolicyValue, - noteEachClipsLimit: rolePolicyValue, - inviteExpirationTime: rolePolicyValue, - canManageCustomEmojis: rolePolicyValue, - userEachUserListsLimit: rolePolicyValue, - canManageAvatarDecorations: rolePolicyValue, - canUseTranslator: rolePolicyValue, - avatarDecorationLimit: rolePolicyValue, + additionalProperties: { + anyOf: [{ + type: 'object', + properties: { + value: { + oneOf: [ + { + type: 'integer', + }, + { + type: 'boolean', + }, + ], + }, + priority: { + type: 'integer', + }, + useDefault: { + type: 'boolean', + }, + }, + }], }, }, usersCount: { diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 6a0d43b1ac..7447513a93 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -590,104 +590,7 @@ export const packedMeDetailedOnlySchema = { policies: { type: 'object', nullable: false, optional: false, - properties: { - gtlAvailable: { - type: 'boolean', - nullable: false, optional: false, - }, - ltlAvailable: { - type: 'boolean', - nullable: false, optional: false, - }, - canPublicNote: { - type: 'boolean', - nullable: false, optional: false, - }, - canInvite: { - type: 'boolean', - nullable: false, optional: false, - }, - inviteLimit: { - type: 'number', - nullable: false, optional: false, - }, - inviteLimitCycle: { - type: 'number', - nullable: false, optional: false, - }, - inviteExpirationTime: { - type: 'number', - nullable: false, optional: false, - }, - canManageCustomEmojis: { - type: 'boolean', - nullable: false, optional: false, - }, - canManageAvatarDecorations: { - type: 'boolean', - nullable: false, optional: false, - }, - canSearchNotes: { - type: 'boolean', - nullable: false, optional: false, - }, - canUseTranslator: { - type: 'boolean', - nullable: false, optional: false, - }, - canHideAds: { - type: 'boolean', - nullable: false, optional: false, - }, - driveCapacityMb: { - type: 'number', - nullable: false, optional: false, - }, - alwaysMarkNsfw: { - type: 'boolean', - nullable: false, optional: false, - }, - pinLimit: { - type: 'number', - nullable: false, optional: false, - }, - antennaLimit: { - type: 'number', - nullable: false, optional: false, - }, - wordMuteLimit: { - type: 'number', - nullable: false, optional: false, - }, - webhookLimit: { - type: 'number', - nullable: false, optional: false, - }, - clipLimit: { - type: 'number', - nullable: false, optional: false, - }, - noteEachClipsLimit: { - type: 'number', - nullable: false, optional: false, - }, - userListLimit: { - type: 'number', - nullable: false, optional: false, - }, - userEachUserListsLimit: { - type: 'number', - nullable: false, optional: false, - }, - rateLimitFactor: { - type: 'number', - nullable: false, optional: false, - }, - avatarDecorationLimit: { - type: 'number', - nullable: false, optional: false, - }, - }, + ref: 'RolePolicies', }, //#region secrets email: { diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 4a88216d06..75e6395f18 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -4,8 +4,7 @@ */ import { permissions } from 'misskey-js'; -import type { Schema } from '@/misc/json-schema.js'; -import { RolePolicies } from '@/core/RoleService.js'; +import type { KeyOf, Schema } from '@/misc/json-schema.js'; import * as ep___admin_meta from './endpoints/admin/meta.js'; import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; @@ -776,7 +775,7 @@ interface IEndpointMetaBase { */ readonly requireAdmin?: boolean; - readonly requireRolePolicy?: keyof RolePolicies; + readonly requireRolePolicy?: KeyOf<'RolePolicies'>; /** * 引っ越し済みのユーザーによるリクエストを禁止するか diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 529e82678d..e1d3473482 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -303,6 +303,11 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + policies: { + type: 'object', + optional: false, nullable: false, + ref: 'RolePolicies', + }, }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/retention.ts b/packages/backend/src/server/api/endpoints/retention.ts index dac6d65407..2631693139 100644 --- a/packages/backend/src/server/api/endpoints/retention.ts +++ b/packages/backend/src/server/api/endpoints/retention.ts @@ -14,6 +14,32 @@ export const meta = { requireCredential: false, res: { + type: 'array', + items: { + type: 'object', + properties: { + createdAt: { + type: 'string', + format: 'date-time', + }, + users: { + type: 'number', + }, + data: { + type: 'object', + additionalProperties: { + anyOf: [{ + type: 'number', + }], + }, + }, + }, + required: [ + 'createdAt', + 'users', + 'data', + ], + }, }, allowGet: true, diff --git a/packages/frontend/@types/global.d.ts b/packages/frontend/@types/global.d.ts index 7d9335cc52..936e74decf 100644 --- a/packages/frontend/@types/global.d.ts +++ b/packages/frontend/@types/global.d.ts @@ -16,3 +16,8 @@ declare const _DATA_TRANSFER_DECK_COLUMN_: string; // for dev-mode declare const _LANGS_FULL_: string[][]; + +// TagCanvas +interface Window { + TagCanvas: any; +} diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index 7814681ea2..39745a97ce 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -39,7 +39,7 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ - user: Misskey.entities.User; + user: Misskey.entities.UserDetailed; initialComment?: string; }>(); diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index 1137eaf970..ff8a9fa1a5 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> <div v-if="achievements" :class="$style.root"> - <div v-for="achievement in achievements" :key="achievement" :class="$style.achievement" class="_panel"> + <div v-for="achievement in achievements" :key="achievement.name" :class="$style.achievement" class="_panel"> <div :class="$style.icon"> <div :class="[$style.iconFrame, { diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 54cbbe18c2..80a18b0708 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -49,7 +49,7 @@ async function ok() { if (confirm.canceled) return; } - modal.value.close(); + modal.value?.close(); misskeyApi('i/read-announcement', { announcementId: props.announcement.id }); updateAccount({ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id), @@ -57,7 +57,7 @@ async function ok() { } function onBgClick() { - rootEl.value.animate([{ + rootEl.value?.animate([{ offset: 0, transform: 'scale(1)', }, { diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 0ff5bd7036..537f6341da 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -10,8 +10,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/> </template> </div> - <span v-else-if="c.type === 'text'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }">{{ c.text }}</span> - <Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }" :text="c.text" @clickEv="c.onClickEv"/> + <span v-else-if="c.type === 'text'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : undefined, fontWeight: c.bold ? 'bold' : undefined, color: c.color }">{{ c.text }}</span> + <Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }" :text="c.text ?? ''" @clickEv="c.onClickEv"/> <MkButton v-else-if="c.type === 'button'" :primary="c.primary" :rounded="c.rounded" :disabled="c.disabled" :small="size === 'small'" inline @click="c.onClick">{{ c.text }}</MkButton> <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> @@ -20,19 +20,19 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkSwitch> - <MkTextarea v-else-if="c.type === 'textarea'" :modelValue="c.default" @update:modelValue="c.onInput"> + <MkTextarea v-else-if="c.type === 'textarea'" :modelValue="c.default ?? null" @update:modelValue="c.onInput"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkTextarea> - <MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :modelValue="c.default" @update:modelValue="c.onInput"> + <MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :modelValue="c.default ?? null" @update:modelValue="c.onInput"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkInput> - <MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :modelValue="c.default" type="number" @update:modelValue="c.onInput"> + <MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :modelValue="c.default ?? null" type="number" @update:modelValue="c.onInput"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkInput> - <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="c.default" @update:modelValue="c.onChange"> + <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="c.default ?? null" @update:modelValue="c.onChange"> <template v-if="c.label" #label>{{ c.label }}</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> @@ -42,8 +42,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPostForm fixed :instant="true" - :initialText="c.form.text" - :initialCw="c.form.cw" + :initialText="c.form?.text" + :initialCw="c.form?.cw" /> </div> <MkFolder v-else-if="c.type === 'folder'" :defaultOpen="c.opened"> @@ -52,7 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/> </template> </MkFolder> - <div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }]" :style="{ textAlign: c.align ?? null, backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }"> + <div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }]" :style="{ textAlign: c.align, backgroundColor: c.bgColor, color: c.fgColor, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }"> <template v-for="child in c.children" :key="child"> <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size" :align="c.align"/> </template> @@ -68,7 +68,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSelect from '@/components/MkSelect.vue'; -import { AsUiComponent } from '@/scripts/aiscript/ui.js'; +import { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/scripts/aiscript/ui.js'; import MkFolder from '@/components/MkFolder.vue'; import MkPostForm from '@/components/MkPostForm.vue'; @@ -85,20 +85,32 @@ const props = withDefaults(defineProps<{ const c = props.component; function g(id) { - return props.components.find(x => x.value.id === id).value; + const v = props.components.find(x => x.value.id === id)?.value; + if (v) return v; + + return { + id: 'dummy', + type: 'root', + children: [], + } as AsUiRoot; } -const valueForSwitch = ref(c.default ?? false); +const valueForSwitch = ref('default' in c && typeof c.default === 'boolean' ? c.default : false); function onSwitchUpdate(v) { valueForSwitch.value = v; - if (c.onChange) c.onChange(v); + if ('onChange' in c && c.onChange) { + c.onChange(v as never); + } } function openPostForm() { + const form = (c as AsUiPostFormButton).form; + if (!form) return; + os.post({ - initialText: c.form.text, - initialCw: c.form.cw, + initialText: form.text, + initialCw: form.cw, instant: true, }); } diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index 8d4631968d..70de6a851a 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-else class="_button" :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" - :to="to" + :to="to ?? '#'" @mousedown="onMousedown" > <div ref="ripples" :class="$style.ripples" :data-children-class="$style.ripple"></div> diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index f60c721eae..7aa08cf51f 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> - <span v-if="!available">{{ i18n.ts.waiting }}<MkEllipsis/></span> + <span v-if="!available">Loading<MkEllipsis/></span> <div v-if="props.provider == 'mcaptcha'"> <div id="mcaptcha__widget-container" class="m-captcha-style"></div> <div ref="captchaEl"></div> @@ -17,7 +17,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue'; import { defaultStore } from '@/store.js'; -import { i18n } from '@/i18n.js'; // APIs provided by Captcha services export type Captcha = { diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index 82605123c5..06cdc37977 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -21,13 +21,13 @@ SPDX-License-Identifier: AGPL-3.0-only */ import { onMounted, ref, shallowRef, watch, PropType } from 'vue'; import { Chart } from 'chart.js'; -import gradient from 'chartjs-plugin-gradient'; import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { chartVLine } from '@/scripts/chart-vline.js'; import { alpha } from '@/scripts/color.js'; import date from '@/filters/date.js'; +import bytes from '@/filters/bytes.js'; import { initChart } from '@/scripts/init-chart.js'; import { chartLegend } from '@/scripts/chart-legend.js'; import MkChartLegend from '@/components/MkChartLegend.vue'; @@ -95,7 +95,7 @@ const getColor = (i) => { }; const now = new Date(); -let chartInstance: Chart = null; +let chartInstance: Chart | null = null; let chartData: { series: { name: string; @@ -108,9 +108,10 @@ let chartData: { y: number; }[]; }[]; -} = null; + bytes?: boolean; +} | null = null; -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); const fetching = ref(true); const getDate = (ago: number) => { @@ -132,6 +133,7 @@ const format = (arr) => { const { handler: externalTooltipHandler } = useChartTooltip(); const render = () => { + if (chartData == null || chartEl.value == null) return; if (chartInstance) { chartInstance.destroy(); } @@ -188,7 +190,6 @@ const render = () => { stacked: props.stacked, offset: false, time: { - stepSize: 1, unit: props.span === 'day' ? 'month' : 'day', displayFormats: { day: 'M/d', @@ -198,6 +199,7 @@ const render = () => { grid: { }, ticks: { + stepSize: 1, display: props.detailed, maxRotation: 0, autoSkipPadding: 16, @@ -237,6 +239,9 @@ const render = () => { duration: 0, }, external: externalTooltipHandler, + callbacks: { + label: (item) => chartData?.bytes ? bytes(item.parsed.y * 1000, 1) : item.parsed.y.toString(), + }, }, zoom: props.detailed ? { pan: { @@ -265,10 +270,9 @@ const render = () => { }, }, } : undefined, - gradient, }, }, - plugins: [chartVLine(vLineColor), ...(props.detailed ? [chartLegend(legendEl.value)] : [])], + plugins: [chartVLine(vLineColor), ...(props.detailed && legendEl.value ? [chartLegend(legendEl.value)] : [])], }); }; @@ -566,7 +570,7 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => { }; const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'In', @@ -588,7 +592,7 @@ const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { }; const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Users', @@ -603,7 +607,7 @@ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData }; const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Notes', @@ -618,7 +622,7 @@ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData }; const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Following', @@ -641,7 +645,7 @@ const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> = }; const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { bytes: true, series: [{ @@ -649,7 +653,7 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char type: 'area', color: '#008FFB', data: format(total - ? raw.drive.totalUsage + ? sum(raw.drive.incUsage) : sum(raw.drive.incUsage, negate(raw.drive.decUsage)), ), }], @@ -657,7 +661,7 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char }; const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Drive files', @@ -672,11 +676,11 @@ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof char }; const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/notes', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { - series: [...(props.args.withoutAll ? [] : [{ + series: [...(props.args?.withoutAll ? [] : [{ name: 'All', - type: 'line', + type: 'line' as const, data: format(sum(raw.inc, negate(raw.dec))), color: '#888888', }]), { @@ -704,7 +708,7 @@ const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { }; const fetchPerUserPvChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/pv', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/pv', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Unique PV (user)', @@ -731,7 +735,7 @@ const fetchPerUserPvChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/following', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -746,7 +750,7 @@ const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/following', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -761,8 +765,9 @@ const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { }; const fetchPerUserDriveChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/drive', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { + bytes: true, series: [{ name: 'Inc', type: 'area', @@ -806,6 +811,8 @@ const fetchAndRender = async () => { case 'per-user-following': return fetchPerUserFollowingChart(); case 'per-user-followers': return fetchPerUserFollowersChart(); case 'per-user-drive': return fetchPerUserDriveChart(); + + default: return null; } }; fetching.value = true; diff --git a/packages/frontend/src/components/MkChartLegend.vue b/packages/frontend/src/components/MkChartLegend.vue index 1a1b4323d9..110eeca5db 100644 --- a/packages/frontend/src/components/MkChartLegend.vue +++ b/packages/frontend/src/components/MkChartLegend.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> <button v-for="item in items" class="_button item" :class="{ disabled: item.hidden }" @click="onClick(item)"> - <span class="box" :style="{ background: chart.config.type === 'line' ? item.strokeStyle?.toString() : item.fillStyle?.toString() }"></span> + <span class="box" :style="{ background: type === 'line' ? item.strokeStyle?.toString() : item.fillStyle?.toString() }"></span> {{ item.text }} </button> </div> @@ -16,25 +16,23 @@ SPDX-License-Identifier: AGPL-3.0-only import { shallowRef } from 'vue'; import { Chart, LegendItem } from 'chart.js'; -const props = defineProps({ -}); - const chart = shallowRef<Chart>(); +const type = shallowRef<string>(); const items = shallowRef<LegendItem[]>([]); function update(_chart: Chart, _items: LegendItem[]) { chart.value = _chart, items.value = _items; + if ('type' in _chart.config) type.value = _chart.config.type; } function onClick(item: LegendItem) { if (chart.value == null) return; - const { type } = chart.value.config; - if (type === 'pie' || type === 'doughnut') { + if (type.value === 'pie' || type.value === 'doughnut') { // Pie and doughnut charts only have a single dataset and visibility is per item - chart.value.toggleDataVisibility(item.index); + if (item.index) chart.value.toggleDataVisibility(item.index); } else { - chart.value.setDatasetVisibility(item.datasetIndex, !chart.value.isDatasetVisible(item.datasetIndex)); + if (item.datasetIndex) chart.value.setDatasetVisibility(item.datasetIndex, !chart.value.isDatasetVisible(item.datasetIndex)); } chart.value.update(); } diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue index a7a3eff5af..118aeeeb37 100644 --- a/packages/frontend/src/components/MkColorInput.vue +++ b/packages/frontend/src/components/MkColorInput.vue @@ -41,8 +41,8 @@ const { modelValue } = toRefs(props); const v = ref(modelValue.value); const inputEl = shallowRef<HTMLElement>(); -const onInput = (ev: KeyboardEvent) => { - emit('update:modelValue', v.value); +const onInput = () => { + emit('update:modelValue', v.value ?? ''); }; </script> diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index e29cf472f7..d330d66b28 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -44,8 +44,8 @@ onMounted(() => { let left = props.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 let top = props.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 - const width = rootEl.value.offsetWidth; - const height = rootEl.value.offsetHeight; + const width = rootEl.value!.offsetWidth; + const height = rootEl.value!.offsetHeight; if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) { left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset; @@ -63,8 +63,10 @@ onMounted(() => { left = 0; } - rootEl.value.style.top = `${top}px`; - rootEl.value.style.left = `${left}px`; + if (rootEl.value) { + rootEl.value.style.top = `${top}px`; + rootEl.value.style.left = `${left}px`; + } document.body.addEventListener('mousedown', onMousedown); }); diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue index ca19a2122d..c7395ad3d5 100644 --- a/packages/frontend/src/components/MkCwButton.vue +++ b/packages/frontend/src/components/MkCwButton.vue @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed } from 'vue'; import * as Misskey from 'misskey-js'; +import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; import { concat } from '@/scripts/array.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; @@ -17,22 +18,9 @@ import MkButton from '@/components/MkButton.vue'; const props = defineProps<{ modelValue: boolean; text: string | null; - renote: Misskey.entities.Note | null; - files: Misskey.entities.DriveFile[]; - poll?: { - expiresAt: string | null; - multiple: boolean; - choices: { - isVoted: boolean; - text: string; - votes: number; - }[]; - } | { - choices: string[]; - multiple: boolean; - expiresAt: string | null; - expiredAfter: string | null; - }; + renote?: Misskey.entities.Note | null; + files?: Misskey.entities.DriveFile[]; + poll?: Misskey.entities.Note['poll'] | PollEditorModelValue | null; }>(); const emit = defineEmits<{ @@ -43,7 +31,7 @@ const label = computed(() => { return concat([ props.text ? [i18n.tsx._cw.chars({ count: props.text.length })] : [], props.renote ? [i18n.ts.quote] : [], - props.files.length !== 0 ? [i18n.tsx._cw.files({ count: props.files.length })] : [], + props.files && props.files.length !== 0 ? [i18n.tsx._cw.files({ count: props.files.length })] : [], props.poll != null ? [i18n.ts.poll] : [], ] as string[][]).join(' / '); }); diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 7b9a052868..939182564f 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -118,34 +118,36 @@ export default defineComponent({ return children; }; - function onBeforeLeave(el: HTMLElement) { + function onBeforeLeave(element: Element) { + const el = element as HTMLElement; el.style.top = `${el.offsetTop}px`; el.style.left = `${el.offsetLeft}px`; } - function onLeaveCanceled(el: HTMLElement) { + function onLeaveCancelled(element: Element) { + const el = element as HTMLElement; el.style.top = ''; el.style.left = ''; } - return () => h( - defaultStore.state.animation ? TransitionGroup : 'div', - { - class: { - [$style['date-separated-list']]: true, - [$style['date-separated-list-nogap']]: props.noGap, - [$style['reversed']]: props.reversed, - [$style['direction-down']]: props.direction === 'down', - [$style['direction-up']]: props.direction === 'up', - }, - ...(defaultStore.state.animation ? { - name: 'list', - tag: 'div', - onBeforeLeave, - onLeaveCanceled, - } : {}), - }, - { default: renderChildren }); + // eslint-disable-next-line vue/no-setup-props-destructure + const classes = { + [$style['date-separated-list']]: true, + [$style['date-separated-list-nogap']]: props.noGap, + [$style['reversed']]: props.reversed, + [$style['direction-down']]: props.direction === 'down', + [$style['direction-up']]: props.direction === 'up', + }; + + return () => defaultStore.state.animation ? h(TransitionGroup, { + class: classes, + name: 'list', + tag: 'div', + onBeforeLeave, + onLeaveCancelled, + }, { default: renderChildren }) : h('div', { + class: classes, + }, { default: renderChildren }); }, }); </script> diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 3fc9f0e357..706c3d5f8e 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -30,8 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown"> <template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template> <template #caption> - <span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.tsx._dialog.charactersExceeded({ current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/> - <span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/> + <span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.tsx._dialog.charactersExceeded({ current: (inputValue as string)?.length ?? 0, max: input.maxLength ?? 'NaN' })"/> + <span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"/> </template> </MkInput> <MkSelect v-if="select" v-model="selectedValue" autofocus> @@ -125,7 +125,7 @@ const selectedValue = ref(props.select?.default ?? null); const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => { if (props.input) { if (props.input.minLength) { - if ((inputValue.value || inputValue.value === '') && (inputValue.value as string).length < props.input.minLength) { + if (inputValue.value == null || (inputValue.value as string).length < props.input.minLength) { return 'charactersBelow'; } } diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 0d02aa5cb7..b3569bd009 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -205,7 +205,7 @@ function onDragend() { } function go() { - emit('move', props.folder.id); + emit('move', props.folder); } function rename() { diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 560d5502d4..80206f86f7 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -98,6 +98,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from './MkButton.vue'; +import type { MenuItem } from '@/types/menu.js'; import XNavFolder from '@/components/MkDrive.navFolder.vue'; import XFolder from '@/components/MkDrive.folder.vue'; import XFile from '@/components/MkDrive.file.vue'; @@ -427,7 +428,7 @@ function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) { } } -function move(target?: Misskey.entities.DriveFolder) { +function move(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id' | 'parentId']) { if (!target) { goRoot(); return; @@ -613,7 +614,7 @@ function fetchMoreFiles() { } function getMenu() { - return [{ + const menu: MenuItem[] = [{ type: 'switch', text: i18n.ts.keepOriginalUploading, ref: keepOriginal, @@ -634,7 +635,7 @@ function getMenu() { }, folder.value ? { text: i18n.ts.renameFolder, icon: 'ti ti-forms', - action: () => { renameFolder(folder.value); }, + action: () => { if (folder.value) renameFolder(folder.value); }, } : undefined, folder.value ? { text: i18n.ts.deleteFolder, icon: 'ti ti-trash', @@ -644,6 +645,8 @@ function getMenu() { icon: 'ti ti-folder-plus', action: () => { createFolder(); }, }]; + + return menu; } function showMenu(ev: MouseEvent) { diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index 14f3f5770f..43a6664d11 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <!-- フォルダの中にはカスタム絵文字やフォルダがある --> <section v-else v-panel style="border-radius: 6px; border-bottom: 0.5px solid var(--divider);"> <header class="_acrylic" @click="shown = !shown"> - <i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> (<i class="ti ti-folder ti-fw"></i>:{{ customEmojiTree.length }} <i class="ti ti-icons ti-fw"></i>:{{ emojis.length }}) + <i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> (<i class="ti ti-folder ti-fw"></i>:{{ customEmojiTree?.length }} <i class="ti ti-icons ti-fw"></i>:{{ emojis.length }}) </header> <div v-if="shown" style="padding-left: 9px;"> <MkEmojiPickerSection @@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, Ref } from 'vue'; import { CustomEmojiFolderTree, getEmojiName } from '@/scripts/emojilist.js'; -import { i18n } from '../i18n.js'; +import { i18n } from '@/i18n.js'; import { customEmojis } from '@/custom-emojis.js'; import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue'; @@ -87,7 +87,7 @@ function computeButtonTitle(ev: MouseEvent): void { elm.title = getEmojiName(emoji) ?? emoji; } -function nestedChosen(emoji: any, ev?: MouseEvent) { +function nestedChosen(emoji: any, ev: MouseEvent) { emit('chosen', emoji, ev); } </script> diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 84424c58ed..58160cdf5b 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only </section> <div v-if="tab === 'index'" class="group index"> - <section v-if="showPinned && pinned.length > 0"> + <section v-if="showPinned && (pinned && pinned.length > 0)"> <div class="body"> <button v-for="emoji in pinned" @@ -340,7 +340,7 @@ watch(q, () => { }); function filterAvailable(emoji: Misskey.entities.EmojiSimple): boolean { - return (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction == null || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))); + return ((emoji.roleIdsThatCanBeUsedThisEmojiAsReaction == null || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction?.includes(r.id)))) ?? false; } function focus() { diff --git a/packages/frontend/src/components/MkFileCaptionEditWindow.vue b/packages/frontend/src/components/MkFileCaptionEditWindow.vue index 922089a78b..b9d0e34809 100644 --- a/packages/frontend/src/components/MkFileCaptionEditWindow.vue +++ b/packages/frontend/src/components/MkFileCaptionEditWindow.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :withOkButton="true" :okButtonDisabled="false" @ok="ok()" - @close="dialog.close()" + @close="dialog?.close()" @closed="emit('closed')" > <template #header>{{ i18n.ts.describeFile }}</template> @@ -48,6 +48,6 @@ const caption = ref(props.default); async function ok() { emit('done', caption.value); - dialog.value.close(); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkFileListForAdmin.vue b/packages/frontend/src/components/MkFileListForAdmin.vue index 3edd30bc37..955a90a50d 100644 --- a/packages/frontend/src/components/MkFileListForAdmin.vue +++ b/packages/frontend/src/components/MkFileListForAdmin.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }"> <MkA - v-for="file in items" + v-for="file in (items as Misskey.entities.DriveFile[])" :key="file.id" v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${dateString(file.createdAt)}\nby ${file.user ? '@' + Misskey.acct.toString(file.user) : 'system'}`" :to="`/admin/file/${file.id}`" diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue index 1ffc95d944..e0f75a1090 100644 --- a/packages/frontend/src/components/MkFoldableSection.vue +++ b/packages/frontend/src/components/MkFoldableSection.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div ref="el" :class="$style.root"> +<div ref="rootEl" :class="$style.root"> <header :class="$style.header" class="_button" :style="{ background: bg }" @click="showBody = !showBody"> <div :class="$style.title"><div><slot name="header"></slot></div></div> <div :class="$style.divider"></div> @@ -14,7 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </header> <Transition - :name="defaultStore.state.animation ? 'folder-toggle' : ''" + :enterActiveClass="defaultStore.state.animation ? $style['folder-toggle-enter-active'] : ''" + :leaveActiveClass="defaultStore.state.animation ? $style['folder-toggle-leave-active'] : ''" + :enterFromClass="defaultStore.state.animation ? $style['folder-toggle-enter-from'] : ''" + :leaveToClass="defaultStore.state.animation ? $style['folder-toggle-leave-to'] : ''" @enter="enter" @afterEnter="afterEnter" @leave="leave" @@ -42,8 +45,8 @@ const props = withDefaults(defineProps<{ expanded: true, }); -const el = shallowRef<HTMLDivElement>(); -const bg = ref<string | null>(null); +const rootEl = shallowRef<HTMLDivElement>(); +const bg = ref<string>(); const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded); watch(showBody, () => { @@ -52,40 +55,44 @@ watch(showBody, () => { } }); -function enter(el: Element) { +function enter(element: Element) { + const el = element as HTMLElement; const elementHeight = el.getBoundingClientRect().height; - el.style.height = 0; + el.style.height = '0'; el.offsetHeight; // reflow el.style.height = elementHeight + 'px'; } -function afterEnter(el: Element) { - el.style.height = null; +function afterEnter(element: Element) { + const el = element as HTMLElement; + el.style.height = 'unset'; } -function leave(el: Element) { +function leave(element: Element) { + const el = element as HTMLElement; const elementHeight = el.getBoundingClientRect().height; el.style.height = elementHeight + 'px'; el.offsetHeight; // reflow - el.style.height = 0; + el.style.height = '0'; } -function afterLeave(el: Element) { - el.style.height = null; +function afterLeave(element: Element) { + const el = element as HTMLElement; + el.style.height = 'unset'; } onMounted(() => { - function getParentBg(el: HTMLElement | null): string { + 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; + const background = el.style.background || el.style.backgroundColor; + if (background) { + return background; } else { return getParentBg(el.parentElement); } } - const rawBg = getParentBg(el.value); + const rawBg = getParentBg(rootEl.value); const _bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); _bg.setAlpha(0.85); bg.value = _bg.toRgbString(); @@ -97,10 +104,8 @@ onMounted(() => { overflow-y: clip; transition: opacity 0.5s, height 0.5s !important; } -.folder-toggle-enter-from { - opacity: 0; -} -.folder-toggle-leave-to { + +.folder-toggle-enter-from, .folder-toggle-leave-to { opacity: 0; } diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 6b7dfb20e3..c143c2af1f 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> - <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` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened"> <Transition :enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" :leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" @@ -109,7 +109,7 @@ function toggle() { onMounted(() => { const computedStyle = getComputedStyle(document.documentElement); - const parentBg = getBgColor(rootEl.value.parentElement); + const parentBg = getBgColor(rootEl.value!.parentElement!); const myBg = computedStyle.getPropertyValue('--panel'); bgSame.value = parentBg === myBg; }); diff --git a/packages/frontend/src/components/MkForgotPassword.vue b/packages/frontend/src/components/MkForgotPassword.vue index 9b57688a02..4a0a35b4cf 100644 --- a/packages/frontend/src/components/MkForgotPassword.vue +++ b/packages/frontend/src/components/MkForgotPassword.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="dialog" :width="370" :height="400" - @close="dialog.close()" + @close="dialog?.close()" @closed="emit('closed')" > <template #header>{{ i18n.ts.forgotPassword }}</template> @@ -66,6 +66,6 @@ async function onSubmit() { email: email.value, }); emit('done'); - dialog.value.close(); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 2095a1dcea..61e23a0a42 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -40,11 +40,11 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> <MkSelect v-else-if="form[item].type === 'enum'" v-model="values[item]"> <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].enum" :key="item.value" :value="item.value">{{ item.label }}</option> + <option v-for="option in form[item].enum" :key="option.value" :value="option.value">{{ option.label }}</option> </MkSelect> <MkRadios v-else-if="form[item].type === 'radio'" v-model="values[item]"> <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="option in form[item].options" :key="option.value" :value="option.value">{{ option.label }}</option> </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" :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> @@ -86,6 +86,7 @@ const emit = defineEmits<{ canceled?: boolean; result?: any; }): void; + (ev: 'closed'): void; }>(); const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); @@ -99,13 +100,13 @@ function ok() { emit('done', { result: values, }); - dialog.value.close(); + dialog.value?.close(); } function cancel() { emit('done', { canceled: true, }); - dialog.value.close(); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue index 316632b1a6..0d8612fe26 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.vue +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -14,8 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only leaveActiveClass: $style.transition_toggle_leaveActive, leaveToClass: $style.transition_toggle_leaveTo, }" - :src="post.files[0].thumbnailUrl" - :hash="post.files[0].blurhash" + :src="post.files?.[0]?.thumbnailUrl" + :hash="post.files?.[0]?.blurhash" :forceBlurhash="!show" /> </Transition> diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue index a77f3627f9..336e127725 100644 --- a/packages/frontend/src/components/MkHeatmap.vue +++ b/packages/frontend/src/components/MkHeatmap.vue @@ -35,10 +35,10 @@ const props = withDefaults(defineProps<{ label: '', }); -const rootEl = shallowRef<HTMLDivElement>(null); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const rootEl = shallowRef<HTMLDivElement | null>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); const now = new Date(); -let chartInstance: Chart = null; +let chartInstance: Chart | null = null; const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip({ @@ -46,6 +46,7 @@ const { handler: externalTooltipHandler } = useChartTooltip({ }); async function renderChart() { + if (rootEl.value == null) return; if (chartInstance) { chartInstance.destroy(); } @@ -64,7 +65,7 @@ async function renderChart() { return new Date(y, m, d - ago); }; - const format = (arr) => { + const format = (arr: number[]) => { return arr.map((v, i) => { const dt = getDate(i); const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1).toString().padStart(2, '0')}-${dt.getDate().toString().padStart(2, '0')}`; @@ -77,7 +78,7 @@ async function renderChart() { }); }; - let values; + let values: number[] = []; if (props.src === 'active-users') { const raw = await misskeyApi('charts/active-users', { limit: chartLimit, span: 'day' }); @@ -114,25 +115,25 @@ async function renderChart() { const marginEachCell = 4; + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'matrix', data: { datasets: [{ label: props.label, - data: format(values), - pointRadius: 0, + data: format(values) as any, borderWidth: 0, - borderJoinStyle: 'round', borderRadius: 3, backgroundColor(c) { - const value = c.dataset.data[c.dataIndex].v; + // @ts-expect-error TS(2339) + const value = c.dataset.data[c.dataIndex].v as number; let a = (value - min) / max; if (value !== 0) { // 0でない限りは完全に不可視にはしない a = Math.max(a, 0.05); } return alpha(color, a); }, - fill: true, width(c) { const a = c.chart.chartArea ?? {}; return (a.right - a.left) / weeks - marginEachCell; @@ -206,11 +207,13 @@ async function renderChart() { enabled: false, callbacks: { title(context) { - const v = context[0].dataset.data[context[0].dataIndex]; - return v.d; + // @ts-expect-error TS(2339) + return context[0].dataset.data[context[0].dataIndex].d; }, label(context) { const v = context.dataset.data[context.dataIndex]; + + // @ts-expect-error TS(2339) return [v.v]; }, }, diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 942861e1f4..03f5106aa3 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -73,7 +73,7 @@ const props = withDefaults(defineProps<{ leaveFromClass?: string; } | null; src?: string | null; - hash?: string; + hash?: string | null; alt?: string | null; title?: string | null; height?: number; diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index ae797eb7d2..b0c1036ba8 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -88,17 +88,18 @@ const focused = ref(false); const changed = ref(false); const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); -const inputEl = shallowRef<HTMLElement>(); +const inputEl = shallowRef<HTMLInputElement>(); const prefixEl = shallowRef<HTMLElement>(); const suffixEl = shallowRef<HTMLElement>(); const height = props.small ? 33 : props.large ? 39 : 36; -let autocomplete: Autocomplete; +let autocompleteWorker: Autocomplete | null = null; -const focus = () => inputEl.value.focus(); -const onInput = (ev: KeyboardEvent) => { +const focus = () => inputEl.value?.focus(); +const onInput = (event: Event) => { + const ev = event as KeyboardEvent; changed.value = true; emit('change', ev); }; @@ -115,9 +116,9 @@ const onKeydown = (ev: KeyboardEvent) => { const updated = () => { changed.value = false; if (type.value === 'number') { - emit('update:modelValue', parseFloat(v.value)); + emit('update:modelValue', typeof v.value === 'number' ? v.value : parseFloat(v.value ?? '0')); } else { - emit('update:modelValue', v.value); + emit('update:modelValue', v.value ?? ''); } }; @@ -127,7 +128,7 @@ watch(modelValue, newValue => { v.value = newValue; }); -watch(v, newValue => { +watch(v, () => { if (!props.manualSave) { if (props.debounce) { debouncedUpdated(); @@ -136,12 +137,14 @@ watch(v, newValue => { } } - invalid.value = inputEl.value.validity.badInput; + invalid.value = inputEl.value?.validity.badInput ?? true; }); // このコンポーネントが作成された時、非表示状態である場合がある // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する useInterval(() => { + if (inputEl.value == null) return; + if (prefixEl.value) { if (prefixEl.value.offsetWidth) { inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; @@ -163,15 +166,15 @@ onMounted(() => { focus(); } }); - - if (props.mfmAutocomplete) { - autocomplete = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? null : props.mfmAutocomplete); + + if (props.mfmAutocomplete && inputEl.value) { + autocompleteWorker = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? undefined : props.mfmAutocomplete); } }); onUnmounted(() => { - if (autocomplete) { - autocomplete.detach(); + if (autocompleteWorker) { + autocompleteWorker.detach(); } }); diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 00f5e96286..6c24156c2f 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -138,7 +138,8 @@ function createDoughnut(chartEl, tooltip, data) { }, }, onClick: (ev) => { - const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0]; + if (ev.native == null) return; + const hit = chartInstance.getElementsAtEventForMode(ev.native, 'nearest', { intersect: true }, false)[0]; if (hit && data[hit.index].onClick) { data[hit.index].onClick(); } @@ -164,23 +165,46 @@ function createDoughnut(chartEl, tooltip, data) { onMounted(() => { misskeyApiGet('federation/stats', { limit: 30 }).then(fedStats => { - createDoughnut(subDoughnutEl.value, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({ + type ChartData = { + name: string, + color: string | null, + value: number, + onClick?: () => void, + }[]; + + const subs: ChartData = fedStats.topSubInstances.map(x => ({ name: x.host, color: x.themeColor, value: x.followersCount, onClick: () => { os.pageWindow(`/instance-info/${x.host}`); }, - })).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowersCount }])); + })); - createDoughnut(pubDoughnutEl.value, externalTooltipHandler2, fedStats.topPubInstances.map(x => ({ + subs.push({ + name: '(other)', + color: '#80808080', + value: fedStats.otherFollowersCount, + }); + + createDoughnut(subDoughnutEl.value, externalTooltipHandler1, subs); + + const pubs: ChartData = fedStats.topPubInstances.map(x => ({ name: x.host, color: x.themeColor, value: x.followingCount, onClick: () => { os.pageWindow(`/instance-info/${x.host}`); }, - })).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowingCount }])); + })); + + pubs.push({ + name: '(other)', + color: '#80808080', + value: fedStats.otherFollowingCount, + }); + + createDoughnut(pubDoughnutEl.value, externalTooltipHandler2, pubs); }); }); </script> diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue index 3ee2aa7174..27cd693481 100644 --- a/packages/frontend/src/components/MkInstanceTicker.vue +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -18,9 +18,9 @@ import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; const props = defineProps<{ instance?: { - faviconUrl?: string - name: string - themeColor?: string + faviconUrl?: string | null + name?: string | null + themeColor?: string | null } }>(); @@ -30,7 +30,7 @@ const instance = props.instance ?? { themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content, }; -const faviconUrl = computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico'); +const faviconUrl = computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? '/favicon.ico'); const themeColor = instance.themeColor ?? '#777777'; diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index 6980192d01..21a382f2f1 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="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="main"> <template v-for="item in items" :key="item.text"> @@ -63,7 +63,7 @@ const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => })); function close() { - modal.value.close(); + modal.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkMarquee.vue b/packages/frontend/src/components/MkMarquee.vue index 145b60c8e7..8b4066fb68 100644 --- a/packages/frontend/src/components/MkMarquee.vue +++ b/packages/frontend/src/components/MkMarquee.vue @@ -30,6 +30,7 @@ export default { const contentEl = ref<HTMLElement>(); function calc() { + if (contentEl.value == null) return; const eachLength = contentEl.value.offsetWidth / props.repeat; const factor = 3000; const duration = props.duration / ((1 / eachLength) * factor); diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index 09c5ad9222..31cf33cd88 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -52,7 +52,7 @@ const count = computed(() => props.mediaList.filter(media => previewable(media)) let lightbox: PhotoSwipeLightbox | null; const popstateHandler = (): void => { - if (lightbox.pswp && lightbox.pswp.isOpen === true) { + if (lightbox?.pswp && lightbox.pswp.isOpen === true) { lightbox.pswp.close(); } }; @@ -67,7 +67,10 @@ async function calcAspectRatio() { return; } - const ratioMax = (ratio: number) => `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`; + const ratioMax = (ratio: number) => { + if (img.properties.width == null || img.properties.height == null) return ''; + return `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`; + }; switch (defaultStore.state.mediaListWithOneImageAppearance) { case '16_9': @@ -137,7 +140,7 @@ onMounted(() => { // element is children const { element } = itemData; - const id = element.dataset.id; + const id = element?.dataset.id; const file = props.mediaList.find(media => media.id === id); if (!file) return; @@ -147,14 +150,14 @@ onMounted(() => { if (file.properties.orientation != null && file.properties.orientation >= 5) { [itemData.w, itemData.h] = [itemData.h, itemData.w]; } - itemData.msrc = file.thumbnailUrl; + itemData.msrc = file.thumbnailUrl ?? undefined; itemData.alt = file.comment ?? file.name; itemData.comment = file.comment ?? file.name; itemData.thumbCropped = true; }); lightbox.on('uiRegister', () => { - lightbox.pswp.ui.registerElement({ + lightbox?.pswp?.ui?.registerElement({ name: 'altText', className: 'pwsp__alt-text-container', appendTo: 'wrapper', @@ -163,8 +166,8 @@ onMounted(() => { textBox.className = 'pwsp__alt-text _acrylic'; el.appendChild(textBox); - pwsp.on('change', (a) => { - textBox.textContent = pwsp.currSlide.data.comment; + pwsp.on('change', () => { + textBox.textContent = pwsp.currSlide?.data.comment; }); }, }); diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue index 962dcd91eb..929d39519b 100644 --- a/packages/frontend/src/components/MkMenu.child.vue +++ b/packages/frontend/src/components/MkMenu.child.vue @@ -33,6 +33,7 @@ const align = 'left'; const SCROLLBAR_THICKNESS = 16; function setPosition() { + if (el.value == null) return; const rootRect = props.rootElement.getBoundingClientRect(); const parentRect = props.targetElement.getBoundingClientRect(); const myRect = el.value.getBoundingClientRect(); @@ -66,7 +67,7 @@ const ro = new ResizeObserver((entries, observer) => { }); onMounted(() => { - ro.observe(el.value); + if (el.value) ro.observe(el.value); setPosition(); nextTick(() => { setPosition(); @@ -79,7 +80,7 @@ onUnmounted(() => { defineExpose({ checkHit: (ev: MouseEvent) => { - return (ev.target === el.value || el.value.contains(ev.target)); + return (ev.target === el.value || el.value?.contains(ev.target as Node)); }, }); </script> diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 17ace227ff..b8706231ee 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only :style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }" @contextmenu.self="e => e.preventDefault()" > - <template v-for="(item, i) in items2"> + <template v-for="(item, i) in (items2 ?? [])"> <div v-if="item.type === 'divider'" role="separator" :class="$style.divider"></div> <span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]"> <span style="opacity: 0.7;">{{ item.text }}</span> @@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span> </div> </button> - <button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: getValue(item.active) }]" :disabled="getValue(item.active)" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/> <div :class="$style.item_content"> @@ -63,18 +63,18 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </button> </template> - <span v-if="items2.length === 0" :class="[$style.none, $style.item]"> + <span v-if="items2 == null || items2.length === 0" :class="[$style.none, $style.item]"> <span>{{ i18n.ts.none }}</span> </span> </div> <div v-if="childMenu"> - <XChild ref="child" :items="childMenu" :targetElement="childTarget" :rootElement="itemsEl" showing @actioned="childActioned" @close="close(false)"/> + <XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" showing @actioned="childActioned" @close="close(false)"/> </div> </div> </template> <script lang="ts"> -import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; +import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; import { focusPrev, focusNext } from '@/scripts/focus.js'; import MkSwitchButton from '@/components/MkSwitch.button.vue'; import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuParent } from '@/types/menu.js'; @@ -104,7 +104,7 @@ const emit = defineEmits<{ const itemsEl = shallowRef<HTMLDivElement>(); -const items2 = ref<InnerMenuItem[]>([]); +const items2 = ref<InnerMenuItem[]>(); const child = shallowRef<InstanceType<typeof XChild>>(); @@ -119,15 +119,15 @@ const childShowingItem = ref<MenuItem | null>(); let preferClick = isTouchUsing || props.asDrawer; watch(() => props.items, () => { - const items: (MenuItem | MenuPending)[] = [...props.items].filter(item => item !== undefined); + const items = [...props.items].filter(item => item !== undefined) as (NonNullable<MenuItem> | MenuPending)[]; for (let i = 0; i < items.length; i++) { const item = items[i]; - if (item && 'then' in item) { // if item is Promise + if ('then' in item) { // if item is Promise items[i] = { type: 'pending' }; item.then(actualItem => { - items2.value[i] = actualItem; + if (items2.value?.[i]) items2.value[i] = actualItem; }); } } @@ -151,7 +151,7 @@ function childActioned() { } const onGlobalMousedown = (event: MouseEvent) => { - if (childTarget.value && (event.target === childTarget.value || childTarget.value.contains(event.target))) return; + if (childTarget.value && (event.target === childTarget.value || childTarget.value.contains(event.target as Node))) return; if (child.value && child.value.checkHit(event)) return; closeChild(); }; @@ -169,7 +169,7 @@ function onItemMouseLeave(item) { } async function showChildren(item: MenuParent, ev: MouseEvent) { - const children = await (async () => { + const children: MenuItem[] = await (async () => { if (childrenCache.has(item)) { return childrenCache.get(item)!; } else { @@ -189,7 +189,7 @@ async function showChildren(item: MenuParent, ev: MouseEvent) { }); emit('hide'); } else { - childTarget.value = ev.currentTarget ?? ev.target; + childTarget.value = (ev.currentTarget ?? ev.target) as HTMLElement; // これでもリアクティビティは保たれる childMenu.value = children; childShowingItem.value = item; @@ -218,6 +218,10 @@ function switchItem(item: MenuSwitch & { ref: any }) { item.ref = !item.ref; } +function getValue<T>(item?: ComputedRef<T> | T) { + return isRef(item) ? item.value : item; +} + onMounted(() => { if (props.viaKeyboard) { nextTick(() => { diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue index f0a2c232bd..bf36c230c9 100644 --- a/packages/frontend/src/components/MkMiniChart.vue +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -22,8 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only stroke-width="2" /> <circle - :cx="headX" - :cy="headY" + :cx="headX ?? undefined" + :cy="headY ?? undefined" r="3" :fill="color" /> diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index 7e185e8453..5308630feb 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -51,7 +51,7 @@ const bodyWidth = ref(0); const bodyHeight = ref(0); const close = () => { - modal.value.close(); + modal.value?.close(); }; const onBgClick = () => { @@ -67,11 +67,13 @@ const onKeydown = (evt) => { }; const ro = new ResizeObserver((entries, observer) => { + if (rootEl.value == null || headerEl.value == null) return; bodyWidth.value = rootEl.value.offsetWidth; bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight; }); onMounted(() => { + if (rootEl.value == null || headerEl.value == null) return; bodyWidth.value = rootEl.value.offsetWidth; bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight; ro.observe(rootEl.value); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 7448fd34e3..7ed8e51d39 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="!hardMuted && muted === false" v-show="!isDeleted" - ref="el" + ref="rootEl" v-hotkey="keymap" :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]" :tabindex="!isDeleted ? '-1' : undefined" @@ -72,16 +72,16 @@ SPDX-License-Identifier: AGPL-3.0-only /> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else> + <div v-else-if="translation"> <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> </div> - <div v-if="appearNote.files.length > 0"> + <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files"/> </div> - <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll"/> + <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false"> @@ -126,7 +126,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()"> <i class="ti ti-paperclip"></i> </button> - <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="menu()"> + <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="showMenu()"> <i class="ti ti-dots"></i> </button> </footer> @@ -222,7 +222,7 @@ if (noteViewInterruptors.length > 0) { let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { try { - result = await interruptor.handler(result); + result = await interruptor.handler(result!) as Misskey.entities.Note | null; if (result === null) { isDeleted.value = true; return; @@ -231,7 +231,7 @@ if (noteViewInterruptors.length > 0) { console.error(err); } } - note.value = result; + note.value = result as Misskey.entities.Note; }); } @@ -239,11 +239,11 @@ const isRenote = ( note.value.renote != null && note.value.text == null && note.value.cw == null && - note.value.fileIds.length === 0 && + note.value.fileIds && note.value.fileIds.length === 0 && note.value.poll == null ); -const el = shallowRef<HTMLElement>(); +const rootEl = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>(); const renoteTime = shallowRef<HTMLElement>(); @@ -262,8 +262,8 @@ const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hard const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i.id)); -const renoteCollapsed = ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || (appearNote.value.myReaction != null))); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); +const renoteCollapsed = ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) ?? (appearNote.value.myReaction != null))); /* Overload FunctionにLintが対応していないのでコメントアウト function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean; @@ -285,11 +285,11 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), - 'q': () => renoteButton.value.renote(true), + 'q': () => renote(true), 'up|k|shift+tab': focusBefore, 'down|j|tab': focusAfter, 'esc': blur, - 'm|o': () => menu(true), + 'm|o': () => showMenu(true), 's': () => showContent.value !== showContent.value, }; @@ -306,7 +306,7 @@ if (props.mock) { }, { deep: true }); } else { useNoteCapture({ - rootEl: el, + rootEl: rootEl, note: appearNote, pureNote: note, isDeletedRef: isDeleted, @@ -352,7 +352,7 @@ function reply(viaKeyboard = false): void { reply: appearNote.value, channel: appearNote.value.channel, animation: !viaKeyboard, - }, () => { + }).then(() => { focus(); }); } @@ -371,7 +371,7 @@ function react(viaKeyboard = false): void { noteId: appearNote.value.id, reaction: '❤️', }); - const el = reactButton.value as HTMLElement | null | undefined; + const el = reactButton.value; if (el) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); @@ -380,7 +380,7 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value, reaction => { + reactionPicker.show(reactButton.value ?? null, reaction => { sound.playMisskeySfx('reaction'); if (props.mock) { @@ -401,8 +401,8 @@ function react(viaKeyboard = false): void { } } -function undoReact(note): void { - const oldReaction = note.myReaction; +function undoReact(targetNote: Misskey.entities.Note): void { + const oldReaction = targetNote.myReaction; if (!oldReaction) return; if (props.mock) { @@ -411,7 +411,7 @@ function undoReact(note): void { } misskeyApi('notes/reactions/delete', { - noteId: note.id, + noteId: targetNote.id, }); } @@ -420,32 +420,34 @@ function onContextmenu(ev: MouseEvent): void { return; } - const isLink = (el: HTMLElement) => { + const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; // 再生速度の選択などのために、Audio要素のコンテキストメニューはブラウザデフォルトとする。 if (el.tagName === 'AUDIO') return true; if (el.parentElement) { return isLink(el.parentElement); } + return false; }; - if (isLink(ev.target)) return; - if (window.getSelection().toString() !== '') return; + + if (ev.target && isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== '') return; if (defaultStore.state.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } -function menu(viaKeyboard = false): void { +function showMenu(viaKeyboard = false): void { if (props.mock) { return; } - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); @@ -492,7 +494,7 @@ function showRenoteMenu(viaKeyboard = false): void { getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), { type: 'divider' }, getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote), - $i.isModerator || $i.isAdmin ? getUnrenote() : undefined, + ($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined, ], renoteTime.value, { viaKeyboard: viaKeyboard, }); @@ -500,19 +502,19 @@ function showRenoteMenu(viaKeyboard = false): void { } function focus() { - el.value.focus(); + rootEl.value?.focus(); } function blur() { - el.value.blur(); + rootEl.value?.blur(); } function focusBefore() { - focusPrev(el.value); + focusPrev(rootEl.value ?? null); } function focusAfter() { - focusNext(el.value); + focusNext(rootEl.value ?? null); } function readPromo() { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 1f5b283cfe..dd956b21ad 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="!muted" v-show="!isDeleted" - ref="el" + ref="rootEl" v-hotkey="keymap" :class="$style.root" > @@ -86,15 +86,15 @@ SPDX-License-Identifier: AGPL-3.0-only <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else> + <div v-else-if="translation"> <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> - <div v-if="appearNote.files.length > 0"> + <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files"/> </div> - <MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" :class="$style.poll"/> + <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> </div> @@ -134,7 +134,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()"> <i class="ti ti-paperclip"></i> </button> - <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="menu()"> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="showMenu()"> <i class="ti ti-dots"></i> </button> </footer> @@ -225,7 +225,7 @@ import { claimAchievement } from '@/scripts/achievements.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import MkPagination from '@/components/MkPagination.vue'; +import MkPagination, { type Paging } from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; @@ -243,7 +243,7 @@ if (noteViewInterruptors.length > 0) { let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { try { - result = await interruptor.handler(result); + result = await interruptor.handler(result!) as Misskey.entities.Note | null; if (result === null) { isDeleted.value = true; return; @@ -252,18 +252,18 @@ if (noteViewInterruptors.length > 0) { console.error(err); } } - note.value = result; + note.value = result as Misskey.entities.Note; }); } const isRenote = ( note.value.renote != null && note.value.text == null && - note.value.fileIds.length === 0 && + note.value.fileIds && note.value.fileIds.length === 0 && note.value.poll == null ); -const el = shallowRef<HTMLElement>(); +const rootEl = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>(); const renoteTime = shallowRef<HTMLElement>(); @@ -281,14 +281,14 @@ const urls = parsed ? extractUrlFromMfm(parsed) : null; const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); const conversation = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i.id); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id); const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), - 'q': () => renoteButton.value.renote(true), + 'q': () => renote(true), 'esc': blur, - 'm|o': () => menu(true), + 'm|o': () => showMenu(true), 's': () => showContent.value !== showContent.value, }; @@ -302,7 +302,7 @@ provide('react', (reaction: string) => { const tab = ref('replies'); const reactionTabType = ref<string | null>(null); -const renotesPagination = computed(() => ({ +const renotesPagination = computed<Paging>(() => ({ endpoint: 'notes/renotes', limit: 10, params: { @@ -310,7 +310,7 @@ const renotesPagination = computed(() => ({ }, })); -const reactionsPagination = computed(() => ({ +const reactionsPagination = computed<Paging>(() => ({ endpoint: 'notes/reactions', limit: 10, params: { @@ -320,7 +320,7 @@ const reactionsPagination = computed(() => ({ })); useNoteCapture({ - rootEl: el, + rootEl: rootEl, note: appearNote, pureNote: note, isDeletedRef: isDeleted, @@ -361,7 +361,7 @@ function reply(viaKeyboard = false): void { reply: appearNote.value, channel: appearNote.value.channel, animation: !viaKeyboard, - }, () => { + }).then(() => { focus(); }); } @@ -385,7 +385,7 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value, reaction => { + reactionPicker.show(reactButton.value ?? null, reaction => { sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { @@ -410,26 +410,28 @@ function undoReact(note): void { } function onContextmenu(ev: MouseEvent): void { - const isLink = (el: HTMLElement) => { + const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; if (el.parentElement) { return isLink(el.parentElement); } + return false; }; - if (isLink(ev.target)) return; - if (window.getSelection().toString() !== '') return; + + if (ev.target && isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== '') return; if (defaultStore.state.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } -function menu(viaKeyboard = false): void { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }); +function showMenu(viaKeyboard = false): void { + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); @@ -458,11 +460,11 @@ function showRenoteMenu(viaKeyboard = false): void { } function focus() { - el.value.focus(); + rootEl.value?.focus(); } function blur() { - el.value.blur(); + rootEl.value?.blur(); } const repliesLoaded = ref(false); @@ -481,6 +483,7 @@ const conversationLoaded = ref(false); function loadConversation() { conversationLoaded.value = true; + if (appearNote.value.replyId == null) return; misskeyApi('notes/conversation', { noteId: appearNote.value.replyId, }).then(res => { diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index b2236b99c2..3164be0330 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="note.user.isBot" :class="$style.isBot">bot</div> <div :class="$style.username"><MkAcct :user="note.user"/></div> <div v-if="note.user.badgeRoles" :class="$style.badgeRoles"> - <img v-for="role in note.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/> + <img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/> </div> <div :class="$style.info"> <div v-if="mock"> diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue index d664d88231..c0d29b1805 100644 --- a/packages/frontend/src/components/MkNotePreview.vue +++ b/packages/frontend/src/components/MkNotePreview.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div> <p v-if="useCw" :class="$style.cw"> - <Mfm v-if="cw != ''" :text="cw" :author="user" :nyaize="'respect'" :i="user" style="margin-right: 8px;"/> + <Mfm v-if="cw != null && cw != ''" :text="cw" :author="user" :nyaize="'respect'" :i="user" style="margin-right: 8px;"/> <MkCwButton v-model="showContent" :text="text.trim()" :files="files" :poll="poll" style="margin: 4px 0;"/> </p> <div v-show="!useCw || showContent"> @@ -26,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; +import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; import MkCwButton from '@/components/MkCwButton.vue'; const showContent = ref(false); @@ -33,12 +34,7 @@ const showContent = ref(false); const props = defineProps<{ text: string; files: Misskey.entities.DriveFile[]; - poll?: { - choices: string[]; - multiple: boolean; - expiresAt: string | null; - expiredAfter: string | null; - }; + poll?: PollEditorModelValue; useCw: boolean; cw: string | null; user: Misskey.entities.User; diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 92be20c6f6..2c7730528f 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -6,10 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> <div :class="$style.head"> - <MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/> - <MkAvatar v-else-if="notification.type === 'note'" :class="$style.icon" :user="notification.note.user" link preview/> - <MkAvatar v-else-if="notification.type === 'roleAssigned'" :class="$style.icon" :user="$i" link preview/> - <MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/> + <MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && notification.note" :class="$style.icon" :user="notification.note.user" link preview/> + <MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div> <img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/> @@ -26,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_quote]: notification.type === 'quote', [$style.t_pollEnded]: notification.type === 'pollEnded', [$style.t_achievementEarned]: notification.type === 'achievementEarned', + [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, }]" > <i v-if="notification.type === 'follow'" class="ti ti-plus"></i> @@ -37,12 +36,14 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i> <i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i> <i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i> - <img v-else-if="notification.type === 'roleAssigned'" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/> - <!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> + <template v-else-if="notification.type === 'roleAssigned'"> + <img v-if="notification.role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/> + <i v-else class="ti ti-badges"></i> + </template> <MkReactionIcon v-else-if="notification.type === 'reaction'" :withTooltip="true" - :reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction" + :reaction="notification.reaction.replace(/^:(\w+):$/, ':$1@.:')" :noStyle="true" style="width: 100%; height: 100%;" /> @@ -55,10 +56,10 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span> <span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span> <span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span> - <MkA v-else-if="notification.user" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> + <MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> <span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: notification.reactions.length }) }}</span> <span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span> - <span v-else>{{ notification.header }}</span> + <span v-else-if="notification.type === 'app'">{{ notification.header }}</span> <MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/> </header> <div> @@ -97,7 +98,6 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> <template v-else-if="notification.type === 'follow'"> <span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span> - <div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div> </template> <span v-else-if="notification.type === 'followRequestAccepted'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span> <template v-else-if="notification.type === 'receiveFollowRequest'"> @@ -113,12 +113,12 @@ SPDX-License-Identifier: AGPL-3.0-only </span> <div v-if="notification.type === 'reaction:grouped'"> - <div v-for="reaction of notification.reactions" :class="$style.reactionsItem"> + <div v-for="reaction of notification.reactions" :key="reaction.user.id + reaction.reaction" :class="$style.reactionsItem"> <MkAvatar :class="$style.reactionsItemAvatar" :user="reaction.user" link preview/> <div :class="$style.reactionsItemReaction"> <MkReactionIcon :withTooltip="true" - :reaction="reaction.reaction ? reaction.reaction.replace(/^:(\w+):$/, ':$1@.:') : reaction.reaction" + :reaction="reaction.reaction.replace(/^:(\w+):$/, ':$1@.:')" :noStyle="true" style="width: 100%; height: 100%;" /> @@ -126,7 +126,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-else-if="notification.type === 'renote:grouped'"> - <div v-for="user of notification.users" :class="$style.reactionsItem"> + <div v-for="user of notification.users" :key="user.id" :class="$style.reactionsItem"> <MkAvatar :class="$style.reactionsItemAvatar" :user="user" link preview/> </div> </div> @@ -139,16 +139,17 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; -import MkFollowButton from '@/components/MkFollowButton.vue'; import MkButton from '@/components/MkButton.vue'; import { getNoteSummary } from '@/scripts/get-note-summary.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; import { infoImageUrl } from '@/instance.js'; +const $i = signinRequired(); + const props = withDefaults(defineProps<{ notification: Misskey.entities.Notification; withTime?: boolean; @@ -161,11 +162,13 @@ const props = withDefaults(defineProps<{ const followRequestDone = ref(false); const acceptFollowRequest = () => { + if (props.notification.user == null) return; followRequestDone.value = true; misskeyApi('following/requests/accept', { userId: props.notification.user.id }); }; const rejectFollowRequest = () => { + if (props.notification.user == null) return; followRequestDone.value = true; misskeyApi('following/requests/reject', { userId: props.notification.user.id }); }; @@ -283,6 +286,12 @@ const rejectFollowRequest = () => { pointer-events: none; } +.t_roleAssigned { + padding: 3px; + background: #88a6b7; + pointer-events: none; +} + .tail { flex: 1; min-width: 0; diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index bb8a5d2e72..cd9b9fc115 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #default="{ items: notifications }"> <MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true"> - <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" :withHardMute="true"/> + <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id + ':note'" :note="notification.note" :withHardMute="true"/> <XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel"/> </MkDateSeparatedList> </template> @@ -63,7 +63,7 @@ function onNotification(notification) { } if (!isMuted) { - pagingComponent.value.prepend(notification); + pagingComponent.value?.prepend(notification); } } diff --git a/packages/frontend/src/components/MkOmit.vue b/packages/frontend/src/components/MkOmit.vue index 1b0ec72e41..0f214e4538 100644 --- a/packages/frontend/src/components/MkOmit.vue +++ b/packages/frontend/src/components/MkOmit.vue @@ -27,7 +27,7 @@ const omitted = ref(false); const ignoreOmit = ref(false); const calcOmit = () => { - if (omitted.value || ignoreOmit.value) return; + if (omitted.value || ignoreOmit.value || content.value == null) return; omitted.value = content.value.offsetHeight > props.maxHeight; }; @@ -37,7 +37,7 @@ const omitObserver = new ResizeObserver((entries, observer) => { onMounted(() => { calcOmit(); - omitObserver.observe(content.value); + omitObserver.observe(content.value as HTMLElement); }); onUnmounted(() => { diff --git a/packages/frontend/src/components/MkPagePreview.vue b/packages/frontend/src/components/MkPagePreview.vue index 6c8a0e56a6..f7fdf30322 100644 --- a/packages/frontend/src/components/MkPagePreview.vue +++ b/packages/frontend/src/components/MkPagePreview.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only </header> <p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p> <footer> - <img class="icon" :src="page.user.avatarUrl"/> + <img v-if="page.user.avatarUrl" class="icon" :src="page.user.avatarUrl"/> <p>{{ userName(page.user) }}</p> </footer> </article> diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index ccd9df83ed..67fc3e3186 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -55,7 +55,7 @@ defineEmits<{ const routerFactory = useRouterFactory(); const windowRouter = routerFactory(props.initialPath); -const contents = shallowRef<HTMLElement>(); +const contents = shallowRef<HTMLElement | null>(null); const pageMetadata = ref<null | ComputedRef<PageMetadata>>(); const windowEl = shallowRef<InstanceType<typeof MkWindow>>(); const history = ref<{ path: string; key: any; }[]>([{ @@ -63,7 +63,7 @@ const history = ref<{ path: string; key: any; }[]>([{ key: windowRouter.getCurrentKey(), }]); const buttonsLeft = computed(() => { - const buttons = []; + const buttons: Record<string, unknown>[] = []; if (history.value.length > 1) { buttons.push({ @@ -121,7 +121,7 @@ const contextmenu = computed(() => ([{ text: i18n.ts.openInNewTab, action: () => { window.open(url + windowRouter.getCurrentPath(), '_blank', 'noopener'); - windowEl.value.close(); + windowEl.value?.close(); }, }, { icon: 'ti ti-link', @@ -141,17 +141,17 @@ function reload() { } function close() { - windowEl.value.close(); + windowEl.value?.close(); } function expand() { mainRouter.push(windowRouter.getCurrentPath(), 'forcePage'); - windowEl.value.close(); + windowEl.value?.close(); } function popout() { - _popout(windowRouter.getCurrentPath(), windowEl.value.$el); - windowEl.value.close(); + _popout(windowRouter.getCurrentPath(), windowEl.value?.$el); + windowEl.value?.close(); } useScrollPositionManager(() => getScrollContainer(contents.value), windowRouter); diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index f5b238046a..4553156c25 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -204,7 +204,7 @@ async function init(): Promise<void> { queue.value = new Map(); fetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await misskeyApi(props.pagination.endpoint, { + await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { ...params, limit: props.pagination.limit ?? 10, allowPartial: true, @@ -240,7 +240,7 @@ const fetchMore = async (): Promise<void> => { if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; moreFetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await misskeyApi(props.pagination.endpoint, { + await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { ...params, limit: SECOND_FETCH_LIMIT, ...(props.pagination.offsetMode ? { @@ -304,7 +304,7 @@ const fetchMoreAhead = async (): Promise<void> => { if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; moreFetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await misskeyApi(props.pagination.endpoint, { + await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { ...params, limit: SECOND_FETCH_LIMIT, ...(props.pagination.offsetMode ? { diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index 7c58b58697..887371ab58 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="{ [$style.done]: closed || isVoted }"> <ul :class="$style.choices"> - <li v-for="(choice, i) in note.poll.choices" :key="i" :class="$style.choice" @click="vote(i)"> + <li v-for="(choice, i) in poll.choices" :key="i" :class="$style.choice" @click="vote(i)"> <div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> <span :class="$style.fg"> <template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--accent);"></i></template> @@ -35,35 +35,35 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { useInterval } from '@/scripts/use-interval.js'; -import { WithNonNullable } from '@/type.js'; const props = defineProps<{ - note: WithNonNullable<Misskey.entities.Note, 'poll'>; + noteId: string; + poll: NonNullable<Misskey.entities.Note['poll']>; readOnly?: boolean; }>(); const remaining = ref(-1); -const total = computed(() => sum(props.note.poll.choices.map(x => x.votes))); +const total = computed(() => sum(props.poll.choices.map(x => x.votes))); const closed = computed(() => remaining.value === 0); -const isVoted = computed(() => !props.note.poll.multiple && props.note.poll.choices.some(c => c.isVoted)); +const isVoted = computed(() => !props.poll.multiple && props.poll.choices.some(c => c.isVoted)); const timer = computed(() => i18n.tsx._poll[ - remaining.value >= 86400 ? 'remainingDays' : - remaining.value >= 3600 ? 'remainingHours' : - remaining.value >= 60 ? 'remainingMinutes' : 'remainingSeconds' - ]({ - s: Math.floor(remaining.value % 60), - m: Math.floor(remaining.value / 60) % 60, - h: Math.floor(remaining.value / 3600) % 24, - d: Math.floor(remaining.value / 86400), - })); + remaining.value >= 86400 ? 'remainingDays' : + remaining.value >= 3600 ? 'remainingHours' : + remaining.value >= 60 ? 'remainingMinutes' : 'remainingSeconds' +]({ + s: Math.floor(remaining.value % 60), + m: Math.floor(remaining.value / 60) % 60, + h: Math.floor(remaining.value / 3600) % 24, + d: Math.floor(remaining.value / 86400), +})); const showResult = ref(props.readOnly || isVoted.value); // 期限付きアンケート -if (props.note.poll.expiresAt) { +if (props.poll.expiresAt) { const tick = () => { - remaining.value = Math.floor(Math.max(new Date(props.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000); + remaining.value = Math.floor(Math.max(new Date(props.poll.expiresAt!).getTime() - Date.now(), 0) / 1000); if (remaining.value === 0) { showResult.value = true; } @@ -82,15 +82,15 @@ const vote = async (id) => { const { canceled } = await os.confirm({ type: 'question', - text: i18n.tsx.voteConfirm({ choice: props.note.poll.choices[id].text }), + text: i18n.tsx.voteConfirm({ choice: props.poll.choices[id].text }), }); if (canceled) return; await misskeyApi('notes/polls/vote', { - noteId: props.note.id, + noteId: props.noteId, choice: id, }); - if (!showResult.value) showResult.value = !props.note.poll.multiple; + if (!showResult.value) showResult.value = !props.poll.multiple; }; </script> diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index 34f6c72b6e..50a66c8437 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -62,21 +62,18 @@ import { formatDateTimeString } from '@/scripts/format-time-string.js'; import { addTime } from '@/scripts/time.js'; import { i18n } from '@/i18n.js'; +export type PollEditorModelValue = { + expiresAt: number | null; + expiredAfter: number | null; + choices: string[]; + multiple: boolean; +}; + const props = defineProps<{ - modelValue: { - expiresAt: string; - expiredAfter: number; - choices: string[]; - multiple: boolean; - }; + modelValue: PollEditorModelValue; }>(); const emit = defineEmits<{ - (ev: 'update:modelValue', v: { - expiresAt: string; - expiredAfter: number; - choices: string[]; - multiple: boolean; - }): void; + (ev: 'update:modelValue', v: PollEditorModelValue): void; }>(); const choices = ref(props.modelValue.choices); @@ -89,7 +86,9 @@ const unit = ref('second'); if (props.modelValue.expiresAt) { expiration.value = 'at'; - atDate.value = atTime.value = props.modelValue.expiresAt; + const expiresAt = new Date(props.modelValue.expiresAt); + atDate.value = formatDateTimeString(expiresAt, 'yyyy-MM-dd'); + atTime.value = formatDateTimeString(expiresAt, 'HH:mm'); } else if (typeof props.modelValue.expiredAfter === 'number') { expiration.value = 'after'; after.value = props.modelValue.expiredAfter / 1000; @@ -113,20 +112,21 @@ function remove(i) { choices.value = choices.value.filter((_, _i) => _i !== i); } -function get() { +function get(): PollEditorModelValue { const calcAt = () => { return new Date(`${atDate.value} ${atTime.value}`).getTime(); }; const calcAfter = () => { - let base = parseInt(after.value); + let base = parseInt(after.value.toString()); switch (unit.value) { + // @ts-expect-error fallthrough case 'day': base *= 24; - // fallthrough + // @ts-expect-error fallthrough case 'hour': base *= 60; - // fallthrough + // @ts-expect-error fallthrough case 'minute': base *= 60; - // fallthrough + // eslint-disable-next-line no-fallthrough case 'second': return base *= 1000; default: return null; } @@ -135,10 +135,8 @@ function get() { return { choices: choices.value, multiple: multiple.value, - ...( - expiration.value === 'at' ? { expiresAt: calcAt() } : - expiration.value === 'after' ? { expiredAfter: calcAfter() } : {} - ), + expiresAt: expiration.value === 'at' ? calcAt() : null, + expiredAfter: expiration.value === 'after' ? calcAfter() : null, }; } diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 1e073a7de9..47904a9acb 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -84,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button> <button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button> <button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button> - <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugin" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button> + <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button> <button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> <button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button> </div> @@ -108,7 +108,7 @@ import { toASCII } from 'punycode/'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; -import MkPollEditor from '@/components/MkPollEditor.vue'; +import MkPollEditor, { type PollEditorModelValue } from '@/components/MkPollEditor.vue'; import { host, url } from '@/config.js'; import { erase, unique } from '@/scripts/array.js'; import { extractMentions } from '@/scripts/extract-mentions.js'; @@ -139,13 +139,13 @@ const props = withDefaults(defineProps<{ renote?: Misskey.entities.Note; channel?: Misskey.entities.Channel; // TODO mention?: Misskey.entities.User; - specified?: Misskey.entities.User; + specified?: Misskey.entities.UserDetailed; initialText?: string; initialCw?: string; initialVisibility?: (typeof Misskey.noteVisibilities)[number]; initialFiles?: Misskey.entities.DriveFile[]; initialLocalOnly?: boolean; - initialVisibleUsers?: Misskey.entities.User[]; + initialVisibleUsers?: Misskey.entities.UserDetailed[]; initialNote?: Misskey.entities.Note; instant?: boolean; fixed?: boolean; @@ -178,12 +178,7 @@ const posting = ref(false); const posted = ref(false); const text = ref(props.initialText ?? ''); const files = ref(props.initialFiles ?? []); -const poll = ref<{ - choices: string[]; - multiple: boolean; - expiresAt: string | null; - expiredAfter: string | null; -} | null>(null); +const poll = ref<PollEditorModelValue | null>(null); const useCw = ref<boolean>(!!props.initialCw); const showPreview = ref(defaultStore.state.showPreview); watch(showPreview, () => defaultStore.set('showPreview', showPreview.value)); @@ -332,7 +327,7 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib if (visibility.value === 'specified') { if (props.reply.visibleUserIds) { misskeyApi('users/show', { - userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId), + userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply?.userId), }).then(users => { users.forEach(pushVisibleUser); }); @@ -534,7 +529,7 @@ async function toggleReactionAcceptance() { reactionAcceptance.value = select.result; } -function pushVisibleUser(user) { +function pushVisibleUser(user: Misskey.entities.UserDetailed) { if (!visibleUsers.value.some(u => u.username === user.username && u.host === user.host)) { visibleUsers.value.push(user); } @@ -576,10 +571,12 @@ function onCompositionEnd(ev: CompositionEvent) { async function onPaste(ev: ClipboardEvent) { if (props.mock) return; + if (!ev.clipboardData) return; - for (const { item, i } of Array.from(ev.clipboardData.items, (item, i) => ({ item, i }))) { + for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) { if (item.kind === 'file') { const file = item.getAsFile(); + if (!file) continue; const lio = file.name.lastIndexOf('.'); const ext = lio >= 0 ? file.name.slice(lio) : ''; const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; @@ -601,7 +598,7 @@ async function onPaste(ev: ClipboardEvent) { return; } - quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; + quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)?.[1] ?? null; }); } } @@ -632,26 +629,26 @@ function onDragover(ev) { } } -function onDragenter(ev) { +function onDragenter() { draghover.value = true; } -function onDragleave(ev) { +function onDragleave() { draghover.value = false; } -function onDrop(ev): void { +function onDrop(ev: DragEvent): void { draghover.value = false; // ファイルだったら - if (ev.dataTransfer.files.length > 0) { + if (ev.dataTransfer && ev.dataTransfer.files.length > 0) { ev.preventDefault(); for (const x of Array.from(ev.dataTransfer.files)) upload(x); return; } //#region ドライブのファイル - const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + const driveFile = ev.dataTransfer?.getData(_DATA_TRANSFER_DRIVE_FILE_); if (driveFile != null && driveFile !== '') { const file = JSON.parse(driveFile); files.value.push(file); @@ -699,11 +696,14 @@ async function post(ev?: MouseEvent) { } if (ev) { - const el = ev.currentTarget ?? ev.target; - const rect = el.getBoundingClientRect(); - const x = rect.left + (el.offsetWidth / 2); - const y = rect.top + (el.offsetHeight / 2); - os.popup(MkRippleEffect, { x, y }, {}, 'end'); + const el = (ev.currentTarget ?? ev.target) as HTMLElement | null; + + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } } if (props.mock) return; @@ -772,18 +772,18 @@ async function post(ev?: MouseEvent) { if (notePostInterruptors.length > 0) { for (const interruptor of notePostInterruptors) { try { - postData = await interruptor.handler(deepClone(postData)); + postData = await interruptor.handler(deepClone(postData)) as typeof postData; } catch (err) { console.error(err); } } } - let token = undefined; + let token: string | undefined = undefined; if (postAccount.value) { const storedAccounts = await getAccounts(); - token = storedAccounts.find(x => x.id === postAccount.value.id)?.token; + token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token; } posting.value = true; @@ -797,7 +797,7 @@ async function post(ev?: MouseEvent) { deleteDraft(); emit('posted'); if (postData.text && postData.text !== '') { - const hashtags_ = mfm.parse(postData.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag); + const hashtags_ = mfm.parse(postData.text).map(x => x.type === 'hashtag' && x.props.hashtag).filter(x => x) as string[]; const history = JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]') as string[]; miLocalStorage.setItem('hashtags', JSON.stringify(unique(hashtags_.concat(history)))); } @@ -867,9 +867,10 @@ function insertMention() { async function insertEmoji(ev: MouseEvent) { textAreaReadOnly.value = true; - + const target = ev.currentTarget ?? ev.target; + if (target == null) return; emojiPicker.show( - ev.currentTarget ?? ev.target, + target as HTMLElement, emoji => { insertTextAtCursor(textareaEl.value, emoji); }, @@ -881,6 +882,7 @@ async function insertEmoji(ev: MouseEvent) { } async function insertMfmFunction(ev: MouseEvent) { + if (textareaEl.value == null) return; mfmFunctionPicker( ev.currentTarget ?? ev.target, textareaEl.value, @@ -888,14 +890,15 @@ async function insertMfmFunction(ev: MouseEvent) { ); } -function showActions(ev) { +function showActions(ev: MouseEvent) { os.popupMenu(postFormActions.map(action => ({ text: action.title, action: () => { action.handler({ text: text.value, cw: cw.value, - }, (key, value) => { + }, (key, value: any) => { + if (typeof key !== 'string') return; if (key === 'text') { text.value = value; } if (key === 'cw') { useCw.value = value !== null; cw.value = value; } }); @@ -932,9 +935,9 @@ onMounted(() => { } // TODO: detach when unmount - new Autocomplete(textareaEl.value, text); - new Autocomplete(cwInputEl.value, cw); - new Autocomplete(hashtagsInputEl.value, hashtags); + if (textareaEl.value) new Autocomplete(textareaEl.value, text); + if (cwInputEl.value) new Autocomplete(cwInputEl.value, cw); + if (hashtagsInputEl.value) new Autocomplete(hashtagsInputEl.value, hashtags); nextTick(() => { // 書きかけの投稿を復元 @@ -957,19 +960,19 @@ onMounted(() => { if (props.initialNote) { const init = props.initialNote; text.value = init.text ? init.text : ''; - files.value = init.files; - cw.value = init.cw; + files.value = init.files ?? []; + cw.value = init.cw ?? null; useCw.value = init.cw != null; if (init.poll) { poll.value = { choices: init.poll.choices.map(x => x.text), multiple: init.poll.multiple, - expiresAt: init.poll.expiresAt, - expiredAfter: init.poll.expiredAfter, + expiresAt: init.poll.expiresAt ? (new Date(init.poll.expiresAt)).getTime() : null, + expiredAfter: null, }; } visibility.value = init.visibility; - localOnly.value = init.localOnly; + localOnly.value = init.localOnly ?? false; quoteId.value = init.renote ? init.renote.id : null; } diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index 7e8b3b1167..9922c778a0 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -93,7 +93,7 @@ async function rename(file) { const { canceled, result } = await os.inputText({ title: i18n.ts.enterFileName, default: file.name, - allowEmpty: false, + minLength: 1, }); if (canceled) return; misskeyApi('drive/files/update', { diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index 7734e5a6d1..69dd9847fa 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :preferType="'dialog'" @click="modal.close()" @closed="onModalClosed()"> - <MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal.close()" @esc="modal.close()"/> +<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()"> + <MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal?.close()" @esc="modal?.close()"/> </MkModal> </template> @@ -20,13 +20,13 @@ const props = defineProps<{ renote?: Misskey.entities.Note; channel?: any; // TODO mention?: Misskey.entities.User; - specified?: Misskey.entities.User; + specified?: Misskey.entities.UserDetailed; initialText?: string; initialCw?: string; - initialVisibility?: typeof Misskey.noteVisibilities; + initialVisibility?: (typeof Misskey.noteVisibilities)[number]; initialFiles?: Misskey.entities.DriveFile[]; initialLocalOnly?: boolean; - initialVisibleUsers?: Misskey.entities.User[]; + initialVisibleUsers?: Misskey.entities.UserDetailed[]; initialNote?: Misskey.entities.Note; instant?: boolean; fixed?: boolean; @@ -41,7 +41,7 @@ const modal = shallowRef<InstanceType<typeof MkModal>>(); const form = shallowRef<InstanceType<typeof MkPostForm>>(); function onPosted() { - modal.value.close({ + modal.value?.close({ useSendAnimation: true, }); } diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue index 1b8263ae67..de9f752c34 100644 --- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue +++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue @@ -126,7 +126,7 @@ async function unsubscribe() { } function encode(buffer: ArrayBuffer | null) { - return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); + return btoa(String.fromCharCode.apply(null, buffer ? new Uint8Array(buffer) as any : [])); } /** diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue index 22e7ed1ef7..01bc517057 100644 --- a/packages/frontend/src/components/MkRadios.vue +++ b/packages/frontend/src/components/MkRadios.vue @@ -38,7 +38,7 @@ export default defineComponent({ h('div', { class: 'body', }, options.map(option => h(MkRadio, { - key: option.key, + key: option.key as string, value: option.props?.value, modelValue: value.value, 'onUpdate:modelValue': _v => value.value = _v, diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index 1aee1aaac3..20cf9e8982 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -86,7 +86,7 @@ onMounted(() => { ro = new ResizeObserver((entries, observer) => { calcThumbPosition(); }); - ro.observe(containerEl.value); + if (containerEl.value) ro.observe(containerEl.value); }); onUnmounted(() => { @@ -122,7 +122,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => { const onDrag = (ev: MouseEvent | TouchEvent) => { ev.preventDefault(); const containerRect = containerEl.value!.getBoundingClientRect(); - const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX; + const pointerX = 'touches' in ev && ev.touches.length > 0 ? ev.touches[0].clientX : 'clientX' in ev ? ev.clientX : 0; const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth / 2)); rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth))); diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 330e54f08a..ffbf62a45c 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -20,9 +20,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject, onMounted, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue'; import XDetails from '@/components/MkReactionsViewer.details.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; -import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue'; import * as os from '@/os.js'; import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; @@ -102,7 +102,7 @@ async function toggleReaction() { async function menu(ev) { if (!canToggle.value) return; - if (!props.reaction.includes(":")) return; + if (!props.reaction.includes(':')) return; os.popupMenu([{ text: i18n.ts.info, icon: 'ti ti-info-circle', @@ -117,8 +117,7 @@ async function menu(ev) { } function anime() { - if (document.hidden) return; - if (!defaultStore.state.animation) return; + if (document.hidden || !defaultStore.state.animation || buttonEl.value == null) return; const rect = buttonEl.value.getBoundingClientRect(); const x = rect.left + 16; diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index ef497e0e82..c9cf79015a 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -23,10 +23,9 @@ import { initChart } from '@/scripts/init-chart.js'; initChart(); -const rootEl = shallowRef<HTMLDivElement>(null); -const chartEl = shallowRef<HTMLCanvasElement>(null); -const now = new Date(); -let chartInstance: Chart = null; +const rootEl = shallowRef<HTMLDivElement | null>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); +let chartInstance: Chart | null = null; const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip({ @@ -34,6 +33,7 @@ const { handler: externalTooltipHandler } = useChartTooltip({ }); async function renderChart() { + if (rootEl.value == null) return; if (chartInstance) { chartInstance.destroy(); } @@ -47,7 +47,12 @@ async function renderChart() { raw = raw.slice(0, maxDays + 1); - const data = []; + const data: { + x: number; + y: string; + v: number; + }[] = []; + for (const record of raw) { data.push({ x: 0, @@ -83,19 +88,20 @@ async function renderChart() { const marginEachCell = 12; + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'matrix', data: { datasets: [{ label: 'Active', - data: data, - pointRadius: 0, + data: data as any, borderWidth: 0, - borderJoinStyle: 'round', borderRadius: 3, backgroundColor(c) { - const value = c.dataset.data[c.dataIndex].v; - const m = max(c.dataset.data[c.dataIndex].y); + const v = c.dataset.data[c.dataIndex] as unknown as typeof data[0]; + const value = v.v; + const m = max(v.y); if (m === 0) { return alpha(color, 0); } else { @@ -103,7 +109,6 @@ async function renderChart() { return alpha(color, a); } }, - fill: true, width(c) { const a = c.chart.chartArea ?? {}; return (a.right - a.left) / maxDays - marginEachCell; @@ -146,7 +151,6 @@ async function renderChart() { }, y: { type: 'time', - min: new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate() - maxDays), offset: true, reverse: true, position: 'left', @@ -179,7 +183,7 @@ async function renderChart() { return getYYYYMMDD(new Date(new Date(v.y).getTime() + (v.x * 86400000))); }, label(context) { - const v = context.dataset.data[context.dataIndex]; + const v = context.dataset.data[context.dataIndex] as unknown as typeof data[0]; const m = max(v.y); if (m === 0) { return [`Active: ${v.v} (-%)`]; diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue index eb05878ae8..2d8b0714ed 100644 --- a/packages/frontend/src/components/MkRetentionLineChart.vue +++ b/packages/frontend/src/components/MkRetentionLineChart.vue @@ -20,11 +20,11 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; initChart(); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); const { handler: externalTooltipHandler } = useChartTooltip(); -let chartInstance: Chart; +let chartInstance: Chart | null = null; const getYYYYMMDD = (date: Date) => { const y = date.getFullYear().toString().padStart(2, '0'); @@ -47,6 +47,8 @@ onMounted(async () => { const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent')); const color = accent.toHex(); + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'line', data: { @@ -67,7 +69,7 @@ onMounted(async () => { x: (i + 1).toString(), y: (v / record.users) * 100, d: getYYYYMMDD(new Date(record.createdAt)), - }))], + }))] as any, })), }, options: { @@ -109,11 +111,11 @@ onMounted(async () => { enabled: false, callbacks: { title(context) { - const v = context[0].dataset.data[context[0].dataIndex]; + const v = context[0].dataset.data[context[0].dataIndex] as unknown as { x: string, y: number, d: string }; return `${v.x} days later`; }, label(context) { - const v = context.dataset.data[context.dataIndex]; + const v = context.dataset.data[context.dataIndex] as unknown as { x: string, y: number, d: string }; const p = Math.round(v.y) + '%'; return `${v.d} ${p}`; }, diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 16416fd2e4..8dd2b9129d 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -32,11 +32,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots } from 'vue'; +import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots, VNodeChild } from 'vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { useInterval } from '@/scripts/use-interval.js'; import { i18n } from '@/i18n.js'; +import { MenuItem } from '@/types/menu.js'; const props = defineProps<{ modelValue: string | null; @@ -52,7 +53,7 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'changeByUser'): void; + (ev: 'changeByUser', value: string | null): void; (ev: 'update:modelValue', value: string | null): void; }>(); @@ -74,7 +75,7 @@ const height = props.large ? 39 : 36; -const focus = () => inputEl.value.focus(); +const focus = () => inputEl.value?.focus(); const onInput = (ev) => { changed.value = true; }; @@ -88,17 +89,19 @@ watch(modelValue, newValue => { v.value = newValue; }); -watch(v, newValue => { +watch(v, () => { if (!props.manualSave) { updated(); } - invalid.value = inputEl.value.validity.badInput; + invalid.value = inputEl.value?.validity.badInput ?? true; }); // このコンポーネントが作成された時、非表示状態である場合がある // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する useInterval(() => { + if (inputEl.value == null) return; + if (prefixEl.value) { if (prefixEl.value.offsetWidth) { inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; @@ -122,36 +125,37 @@ onMounted(() => { }); }); -function show(ev: MouseEvent) { +function show() { focused.value = true; opening.value = true; - const menu = []; + const menu: MenuItem[] = []; let options = slots.default!(); const pushOption = (option: VNode) => { menu.push({ - text: option.children, - active: computed(() => v.value === option.props.value), + text: option.children as string, + active: computed(() => v.value === option.props?.value), action: () => { - v.value = option.props.value; + v.value = option.props?.value; emit('changeByUser', v.value); }, }); }; - const scanOptions = (options: VNode[]) => { + const scanOptions = (options: VNodeChild[]) => { for (const vnode of options) { + if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue; if (vnode.type === 'optgroup') { const optgroup = vnode; menu.push({ type: 'label', - text: optgroup.props.label, + text: optgroup.props?.label, }); - scanOptions(optgroup.children); + if (Array.isArray(optgroup.children)) scanOptions(optgroup.children); } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある const fragment = vnode; - scanOptions(fragment.children); + if (Array.isArray(fragment.children)) scanOptions(fragment.children); } else if (vnode.props == null) { // v-if で条件が false のときにこうなる // nop? } else { @@ -164,7 +168,7 @@ function show(ev: MouseEvent) { scanOptions(options); os.popupMenu(menu, container.value, { - width: container.value.offsetWidth, + width: container.value?.offsetWidth, onClosing: () => { opening.value = false; }, diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index 1c06cff9aa..39d45fe0f7 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -112,6 +112,7 @@ function onLogin(res: any): Promise<void> | void { } async function queryKey(): Promise<void> { + if (credentialRequest.value == null) return; queryingKey.value = true; await webAuthnRequest(credentialRequest.value) .catch(() => { diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index e27510fb34..77df59af4f 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -80,6 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; import { toUnicode } from 'punycode/'; +import * as Misskey from 'misskey-js'; import MkButton from './MkButton.vue'; import MkInput from './MkInput.vue'; import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue'; @@ -97,7 +98,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'signup', user: Record<string, any>): void; + (ev: 'signup', user: Misskey.entities.SigninResponse): void; (ev: 'signupEmailPending'): void; }>(); diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue index d42b496a34..43a6e5f523 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.vue +++ b/packages/frontend/src/components/MkSignupDialog.rules.vue @@ -34,8 +34,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ tosPrivacyPolicyLabel }}</template> <template #suffix><i v-if="agreeTosAndPrivacyPolicy" class="ti ti-check" style="color: var(--success)"></i></template> <div class="_gaps_s"> - <div v-if="availableTos"><a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ti ti-external-link"></i></a></div> - <div v-if="availablePrivacyPolicy"><a :href="instance.privacyPolicyUrl" class="_link" target="_blank">{{ i18n.ts.privacyPolicy }} <i class="ti ti-external-link"></i></a></div> + <div v-if="availableTos"><a :href="instance.tosUrl ?? undefined" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ti ti-external-link"></i></a></div> + <div v-if="availablePrivacyPolicy"><a :href="instance.privacyPolicyUrl ?? undefined" class="_link" target="_blank">{{ i18n.ts.privacyPolicy }} <i class="ti ti-external-link"></i></a></div> </div> <MkSwitch :modelValue="agreeTosAndPrivacyPolicy" style="margin-top: 16px;" @update:modelValue="updateAgreeTosAndPrivacyPolicy">{{ i18n.ts.agree }}</MkSwitch> @@ -96,7 +96,7 @@ const tosPrivacyPolicyLabel = computed(() => { } else if (availablePrivacyPolicy) { return i18n.ts.privacyPolicy; } else { - return ""; + return ''; } }); diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index b4fba114a6..a13fdf426d 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="dialog" :width="500" :height="600" - @close="dialog.close()" + @close="dialog?.close()" @closed="$emit('closed')" > <template #header>{{ i18n.ts.signup }}</template> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="$style.transition_x_leaveTo" > <template v-if="!isAcceptedServerRule"> - <XServerRules @done="isAcceptedServerRule = true" @cancel="dialog.close()"/> + <XServerRules @done="isAcceptedServerRule = true" @cancel="dialog?.close()"/> </template> <template v-else> <XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/> @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { shallowRef, ref } from 'vue'; - +import * as Misskey from 'misskey-js'; import XSignup from '@/components/MkSignupDialog.form.vue'; import XServerRules from '@/components/MkSignupDialog.rules.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; @@ -47,7 +47,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done'): void; + (ev: 'done', res: Misskey.entities.SigninResponse): void; (ev: 'closed'): void; }>(); @@ -55,13 +55,13 @@ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); const isAcceptedServerRule = ref(false); -function onSignup(res) { +function onSignup(res: Misskey.entities.SigninResponse) { emit('done', res); - dialog.value.close(); + dialog.value?.close(); } function onSignupEmailPending() { - dialog.value.close(); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkSparkle.vue b/packages/frontend/src/components/MkSparkle.vue index 269825e25e..fa7bd96b16 100644 --- a/packages/frontend/src/components/MkSparkle.vue +++ b/packages/frontend/src/components/MkSparkle.vue @@ -89,10 +89,11 @@ let ro: ResizeObserver | undefined; onMounted(() => { ro = new ResizeObserver((entries, observer) => { - width.value = el.value?.offsetWidth + 64; - height.value = el.value?.offsetHeight + 64; + if (el.value == null) return; + width.value = el.value.offsetWidth + 64; + height.value = el.value.offsetHeight + 64; }); - ro.observe(el.value); + if (el.value) ro.observe(el.value); const add = () => { if (stop) return; const x = (Math.random() * (width.value - 64)); diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index 13d0e6c2ca..21054ee634 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -7,18 +7,18 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="[$style.root, { [$style.collapsed]: collapsed }]"> <div> <span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> - <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span> + <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span> <MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> <Mfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> <MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> </div> - <details v-if="note.files.length > 0"> + <details v-if="note.files && note.files.length > 0"> <summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary> <MkMediaList :mediaList="note.files"/> </details> <details v-if="note.poll"> <summary>{{ i18n.ts.poll }}</summary> - <MkPoll :note="note"/> + <MkPoll :noteId="note.id" :poll="note.poll"/> </details> <button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click="collapsed = false"> <span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span> diff --git a/packages/frontend/src/components/MkSwitch.button.vue b/packages/frontend/src/components/MkSwitch.button.vue index a7e91acc39..14338fe4e8 100644 --- a/packages/frontend/src/components/MkSwitch.button.vue +++ b/packages/frontend/src/components/MkSwitch.button.vue @@ -24,7 +24,7 @@ import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ checked: boolean | Ref<boolean>; - disabled?: boolean; + disabled?: boolean | Ref<boolean>; }>(), { disabled: false, }); diff --git a/packages/frontend/src/components/MkTab.vue b/packages/frontend/src/components/MkTab.vue index 9785d89403..451e01d908 100644 --- a/packages/frontend/src/components/MkTab.vue +++ b/packages/frontend/src/components/MkTab.vue @@ -13,18 +13,18 @@ export default defineComponent({ }, }, setup(props, { emit, slots }) { - const options = slots.default(); + const options = slots.default?.() ?? []; return () => h('div', { class: 'pxhvhrfw', }, options.map(option => withDirectives(h('button', { - class: ['_button', { active: props.modelValue === option.props.value }], - key: option.key, - disabled: props.modelValue === option.props.value, + class: ['_button', { active: props.modelValue === option.props?.value }], + key: option.key as string, + disabled: props.modelValue === option.props?.value, onClick: () => { - emit('update:modelValue', option.props.value); + emit('update:modelValue', option.props?.value); }, - }, option.children), [ + }, option.children ?? []), [ [resolveDirective('click-anime')], ]))); }, diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue index 083c34906f..cae286058e 100644 --- a/packages/frontend/src/components/MkTagCloud.vue +++ b/packages/frontend/src/components/MkTagCloud.vue @@ -52,7 +52,7 @@ watch(available, () => { }); onMounted(() => { - width.value = rootEl.value.offsetWidth; + if (rootEl.value) width.value = rootEl.value.offsetWidth; if (loaded) { available.value = true; diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue index 3ee374300a..3fd277d34b 100644 --- a/packages/frontend/src/components/MkTextarea.vue +++ b/packages/frontend/src/components/MkTextarea.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only :readonly="readonly" :placeholder="placeholder" :pattern="pattern" - :autocomplete="props.autocomplete" + :autocomplete="autocomplete" :spellcheck="spellcheck" @focus="focused = true" @blur="focused = false" @@ -76,9 +76,9 @@ const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); const inputEl = shallowRef<HTMLTextAreaElement>(); const preview = ref(false); -let autocomplete: Autocomplete; +let autocompleteWorker: Autocomplete | null = null; -const focus = () => inputEl.value.focus(); +const focus = () => inputEl.value?.focus(); const onInput = (ev) => { changed.value = true; emit('change', ev); @@ -111,10 +111,10 @@ const updated = () => { const debouncedUpdated = debounce(1000, updated); watch(modelValue, newValue => { - v.value = newValue; + v.value = newValue ?? ''; }); -watch(v, newValue => { +watch(v, () => { if (!props.manualSave) { if (props.debounce) { debouncedUpdated(); @@ -123,7 +123,7 @@ watch(v, newValue => { } } - invalid.value = inputEl.value.validity.badInput; + invalid.value = inputEl.value?.validity.badInput ?? true; }); onMounted(() => { @@ -133,14 +133,14 @@ onMounted(() => { } }); - if (props.mfmAutocomplete) { - autocomplete = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? null : props.mfmAutocomplete); + if (props.mfmAutocomplete && inputEl.value) { + autocompleteWorker = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? undefined : props.mfmAutocomplete); } }); onUnmounted(() => { - if (autocomplete) { - autocomplete.detach(); + if (autocompleteWorker) { + autocompleteWorker.detach(); } }); </script> diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 9ea654213e..3c77878379 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -18,6 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, watch, onUnmounted, provide, ref, shallowRef } from 'vue'; +import Misskey from 'misskey-js'; import { Connection } from 'misskey-js/built/streaming.js'; import MkNotes from '@/components/MkNotes.vue'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; @@ -29,7 +30,7 @@ import { defaultStore } from '@/store.js'; import { Paging } from '@/components/MkPagination.vue'; const props = withDefaults(defineProps<{ - src: string; + src: 'home' | 'local' | 'social' | 'global' | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; list?: string; antenna?: string; channel?: string; @@ -94,6 +95,7 @@ const stream = useStream(); function connectChannel() { if (props.src === 'antenna') { + if (props.antenna == null) return; connection = stream.useChannel('antenna', { antennaId: props.antenna, }); @@ -132,21 +134,24 @@ function connectChannel() { connection = stream.useChannel('main'); connection.on('mention', onNote); } else if (props.src === 'list') { + if (props.list == null) return; connection = stream.useChannel('userList', { withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, listId: props.list, }); } else if (props.src === 'channel') { + if (props.channel == null) return; connection = stream.useChannel('channel', { channelId: props.channel, }); } else if (props.src === 'role') { + if (props.role == null) return; connection = stream.useChannel('roleTimeline', { roleId: props.role, }); } - if (props.src !== 'directs' || props.src !== 'mentions') connection.on('note', prepend); + if (props.src !== 'directs' && props.src !== 'mentions') connection.on('note', prepend); } function disconnectChannel() { @@ -155,7 +160,7 @@ function disconnectChannel() { } function updatePaginationQuery() { - let endpoint: string | null; + let endpoint: keyof Misskey.Endpoints | null; let query: TimelineQueryType | null; if (props.src === 'antenna') { diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue index d40cd95f3a..d8922af09b 100644 --- a/packages/frontend/src/components/MkTooltip.vue +++ b/packages/frontend/src/components/MkTooltip.vue @@ -55,7 +55,7 @@ const el = shallowRef<HTMLElement>(); const zIndex = os.claimZIndex('high'); function setPosition() { - if (!el.value || !props.targetElement) return; + if (el.value == null) return; const data = calcPopupPosition(el.value, { anchorElement: props.targetElement, direction: props.direction, diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue index c7df1a576e..34a0ae6f90 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Note.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="phase === 'howToReact'" class="_gaps"> <div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._reaction.description }}</div> <div>{{ i18n.ts._initialTutorial._reaction.letsTryReacting }}</div> - <MkNote :class="$style.exampleNoteRoot" :note="exampleNote" :mock="true" @reaction="addReaction" @removeReaction="removeReaction" @updateReaction="updateReaction"/> + <MkNote :class="$style.exampleNoteRoot" :note="exampleNote" :mock="true" @reaction="addReaction" @removeReaction="removeReaction"/> <div v-if="onceReacted"><b style="color: var(--accent);"><i class="ti ti-check"></i> {{ i18n.ts._initialTutorial.wellDone }}</b> {{ i18n.ts._initialTutorial._reaction.reactNotification }}<br>{{ i18n.ts._initialTutorial._reaction.reactDone }}</div> </div> </template> @@ -53,7 +53,7 @@ const exampleNote = reactive<Misskey.entities.Note>({ isBot: false, isCat: true, emojis: {}, - onlineStatus: null, + onlineStatus: 'unknown', badgeRoles: [], }, text: 'just setting up my msky', @@ -86,7 +86,6 @@ function doNotification(emoji: string): void { const notification: Misskey.entities.Notification = { id: Math.random().toString(), createdAt: new Date().toUTCString(), - isRead: false, type: 'reaction', reaction: emoji, user: $i, diff --git a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue index b395f64853..a5a88c3fda 100644 --- a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue +++ b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue @@ -58,7 +58,7 @@ const exampleCWNote = reactive<Misskey.entities.Note>({ isBot: false, isCat: true, emojis: {}, - onlineStatus: null, + onlineStatus: 'unknown', badgeRoles: [], }, text: i18n.ts._initialTutorial._postNote._cw._exampleNote.note, diff --git a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue index 896db5eb3a..4b3d181f92 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue @@ -40,7 +40,7 @@ const emit = defineEmits<{ const onceSucceeded = ref<boolean>(false); function doSucceeded(fileId: string, to: boolean) { - if (fileId === exampleNote.fileIds[0] && to) { + if (fileId === exampleNote.fileIds?.[0] && to) { onceSucceeded.value = true; emit('succeeded'); } diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index 9e3a78fe22..487a57535c 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkModalWindow ref="dialog" :width="400" - @close="dialog.close()" + @close="dialog?.close()" @closed="$emit('closed')" > <template v-if="announcement" #header>:{{ announcement.title }}:</template> @@ -64,14 +64,14 @@ import MkRadios from '@/components/MkRadios.vue'; const props = defineProps<{ user: Misskey.entities.User, - announcement?: any, + announcement?: Misskey.entities.Announcement, }>(); const dialog = ref<InstanceType<typeof MkModalWindow> | null>(null); -const title = ref<string>(props.announcement ? props.announcement.title : ''); -const text = ref<string>(props.announcement ? props.announcement.text : ''); -const icon = ref<string>(props.announcement ? props.announcement.icon : 'info'); -const display = ref<string>(props.announcement ? props.announcement.display : 'dialog'); +const title = ref(props.announcement ? props.announcement.title : ''); +const text = ref(props.announcement ? props.announcement.text : ''); +const icon = ref(props.announcement ? props.announcement.icon : 'info'); +const display = ref(props.announcement ? props.announcement.display : 'dialog'); const needConfirmationToRead = ref(props.announcement ? props.announcement.needConfirmationToRead : false); const emit = defineEmits<{ @@ -92,18 +92,18 @@ async function done() { if (props.announcement) { await os.apiWithDialog('admin/announcements/update', { - id: props.announcement.id, ...params, + id: props.announcement.id, }); emit('done', { updated: { - id: props.announcement.id, ...params, + id: props.announcement.id, }, }); - dialog.value.close(); + dialog.value?.close(); } else { const created = await os.apiWithDialog('admin/announcements/create', params); @@ -111,7 +111,7 @@ async function done() { created: created, }); - dialog.value.close(); + dialog.value?.close(); } } @@ -122,14 +122,16 @@ async function del() { }); if (canceled) return; - misskeyApi('admin/announcements/delete', { - id: props.announcement.id, - }).then(() => { - emit('done', { - deleted: true, + if (props.announcement) { + await misskeyApi('admin/announcements/delete', { + id: props.announcement.id, }); - dialog.value.close(); + } + + emit('done', { + deleted: true, }); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index 9ec5c7b5c7..aa76f21863 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -4,13 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-adaptive-bg :class="[$style.root, { yellow: user.isSilenced, red: user.isSuspended, gray: false }]"> - <MkAvatar class="avatar" :user="user" indicator/> - <div class="body"> - <span class="name"><MkUserName class="name" :user="user"/></span> - <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> +<div v-adaptive-bg :class="[$style.root]"> + <MkAvatar :class="$style.avatar" :user="user" indicator/> + <div :class="$style.body"> + <span :class="$style.name"><MkUserName :user="user"/></span> + <span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span> </div> - <MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/> + <MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/> </div> </template> @@ -42,71 +42,53 @@ onMounted(() => { </script> <style lang="scss" module> -.root { - $bodyTitleHieght: 18px; - $bodyInfoHieght: 16px; +$bodyTitleHieght: 18px; +$bodyInfoHieght: 16px; +.root { display: flex; align-items: center; padding: 16px; background: var(--panel); border-radius: 8px; +} - > :global(.avatar) { - display: block; - width: ($bodyTitleHieght + $bodyInfoHieght); - height: ($bodyTitleHieght + $bodyInfoHieght); - margin-right: 12px; - } +.avatar { + display: block; + width: ($bodyTitleHieght + $bodyInfoHieght); + height: ($bodyTitleHieght + $bodyInfoHieght); + margin-right: 12px; +} - > :global(.body) { - flex: 1; - overflow: hidden; - font-size: 0.9em; - color: var(--fg); - padding-right: 8px; +.body { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; +} - > :global(.name) { - display: block; - width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: $bodyTitleHieght; - } +.name { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: $bodyTitleHieght; +} - > :global(.sub) { - display: block; - width: 100%; - font-size: 95%; - opacity: 0.7; - line-height: $bodyInfoHieght; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } +.sub { + display: block; + width: 100%; + font-size: 95%; + opacity: 0.7; + line-height: $bodyInfoHieght; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} - > :global(.chart) { - height: 30px; - } - - &:global(.yellow) { - --c: rgb(255 196 0 / 15%); - background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); - background-size: 16px 16px; - } - - &:global(.red) { - --c: rgb(255 0 0 / 15%); - background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); - background-size: 16px 16px; - } - - &:global(.gray) { - --c: var(--bg); - background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); - background-size: 16px 16px; - } +.chart { + height: 30px; } </style> diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index 04244ac308..9a1dce2d8e 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -86,6 +86,7 @@ const top = ref(0); const left = ref(0); function showMenu(ev: MouseEvent) { + if (user.value == null) return; const { menu, cleanup } = getUserMenu(user.value); os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); } diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index ad11ba1940..d7bd73aa8a 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -80,9 +80,9 @@ const props = defineProps<{ const username = ref(''); const host = ref(''); -const users = ref<Misskey.entities.UserDetailed[]>([]); +const users = ref<Misskey.entities.UserLite[]>([]); const recentUsers = ref<Misskey.entities.UserDetailed[]>([]); -const selected = ref<Misskey.entities.UserDetailed | null>(null); +const selected = ref<Misskey.entities.UserLite | null>(null); const dialogEl = ref(); function search() { @@ -100,14 +100,19 @@ function search() { }); } -function ok() { +async function ok() { if (selected.value == null) return; - emit('ok', selected.value); + + const user = await misskeyApi('users/show', { + userId: selected.value.id, + }); + emit('ok', user); + dialogEl.value.close(); // 最近使ったユーザー更新 let recents = defaultStore.state.recentlyUsedUsers; - recents = recents.filter(x => x !== selected.value.id); + recents = recents.filter(x => x !== selected.value?.id); recents.unshift(selected.value.id); defaultStore.set('recentlyUsedUsers', recents.splice(0, 16)); } @@ -122,7 +127,7 @@ onMounted(() => { userIds: defaultStore.state.recentlyUsedUsers, }).then(users => { if (props.includeSelf && users.find(x => $i ? x.id === $i.id : true) == null) { - recentUsers.value = [$i, ...users]; + recentUsers.value = [$i!, ...users]; } else { recentUsers.value = users; } diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue index 5f3f5b81dd..46459df6a6 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :pagination="pinnedUsers"> <template #default="{ items }"> <div :class="$style.users"> - <XUser v-for="item in items" :key="item.id" :user="item"/> + <XUser v-for="item in (items as Misskey.entities.UserDetailed[])" :key="item.id" :user="item"/> </div> </template> </MkPagination> @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :pagination="popularUsers"> <template #default="{ items }"> <div :class="$style.users"> - <XUser v-for="item in items" :key="item.id" :user="item"/> + <XUser v-for="item in (items as Misskey.entities.UserDetailed[])" :key="item.id" :user="item"/> </div> </template> </MkPagination> @@ -34,18 +34,28 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import XUser from '@/components/MkUserSetupDialog.User.vue'; -import MkPagination from '@/components/MkPagination.vue'; +import MkPagination, { type Paging } from '@/components/MkPagination.vue'; -const pinnedUsers = { endpoint: 'pinned-users', noPaging: true }; +const pinnedUsers: Paging = { + endpoint: 'pinned-users', + noPaging: true, + limit: 10, +}; -const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { - state: 'alive', - origin: 'local', - sort: '+follower', -} }; +const popularUsers: Paging = { + endpoint: 'users', + limit: 10, + noPaging: true, + params: { + state: 'alive', + origin: 'local', + sort: '+follower', + }, +}; </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index f082833838..3242d59eda 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -39,7 +39,9 @@ import FormSlot from '@/components/form/slot.vue'; import MkInfo from '@/components/MkInfo.vue'; import { chooseFileFromPc } from '@/scripts/select-file.js'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; + +const $i = signinRequired(); const name = ref($i.name ?? ''); const description = ref($i.description ?? ''); diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 1324ed12e1..f1ef213eb2 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')"> +<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal?.close()" @closed="emit('closed')"> <div class="_popup" :class="{ [$style.root]: true, [$style.asDrawer]: type === 'drawer' }"> <div :class="[$style.label, $style.item]"> {{ i18n.ts.visibility }} diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue index e45d594f12..46d75da839 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, shallowRef, ref, nextTick } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; import tinycolor from 'tinycolor2'; @@ -25,9 +25,9 @@ import { initChart } from '@/scripts/init-chart.js'; initChart(); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); const now = new Date(); -let chartInstance: Chart = null; +let chartInstance: Chart | null = null; const chartLimit = 30; const fetching = ref(true); @@ -55,6 +55,10 @@ async function renderChart() { const raw = await misskeyApi('charts/active-users', { limit: chartLimit, span: 'day' }); + fetching.value = false; + + await nextTick(); + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const computedStyle = getComputedStyle(document.documentElement); @@ -65,6 +69,8 @@ async function renderChart() { const max = Math.max(...raw.read); + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'bar', data: { @@ -97,7 +103,6 @@ async function renderChart() { type: 'time', offset: true, time: { - stepSize: 1, unit: 'day', displayFormats: { day: 'M/d', @@ -108,6 +113,7 @@ async function renderChart() { display: false, }, ticks: { + stepSize: 1, display: true, maxRotation: 0, autoSkipPadding: 8, @@ -141,13 +147,10 @@ async function renderChart() { }, external: externalTooltipHandler, }, - gradient, }, }, plugins: [chartVLine(vLineColor)], }); - - fetching.value = false; } onMounted(async () => { diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index ac3d6cabd8..def5cf3d30 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div v-if="meta" :class="$style.root"> <div :class="[$style.main, $style.panel]"> - <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.mainIcon"/> + <img :src="instance.iconUrl || '/favicon.ico'" alt="" :class="$style.mainIcon"/> <button class="_button _acrylic" :class="$style.mainMenu" @click="showMenu"><i class="ti ti-dots"></i></button> <div :class="$style.mainFg"> <h1 :class="$style.mainTitle"> @@ -106,19 +106,19 @@ function showMenu(ev) { text: i18n.ts.impressum, icon: 'ti ti-file-invoice', action: () => { - window.open(instance.impressumUrl, '_blank', 'noopener'); + window.open(instance.impressumUrl!, '_blank', 'noopener'); }, } : undefined, (instance.tosUrl) ? { text: i18n.ts.termsOfService, icon: 'ti ti-notebook', action: () => { - window.open(instance.tosUrl, '_blank', 'noopener'); + window.open(instance.tosUrl!, '_blank', 'noopener'); }, } : undefined, (instance.privacyPolicyUrl) ? { text: i18n.ts.privacyPolicy, icon: 'ti ti-shield-lock', action: () => { - window.open(instance.privacyPolicyUrl, '_blank', 'noopener'); + window.open(instance.privacyPolicyUrl!, '_blank', 'noopener'); }, } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, { text: i18n.ts.help, diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue index 1326ca2693..9f0064f641 100644 --- a/packages/frontend/src/components/MkWaitingDialog.vue +++ b/packages/frontend/src/components/MkWaitingDialog.vue @@ -32,7 +32,7 @@ const emit = defineEmits<{ function done() { emit('done'); - modal.value.close(); + modal.value?.close(); } watch(() => props.showing, () => { diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index b8b253de06..008c7bd7ff 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -97,14 +97,16 @@ const updateWidget = (id, data) => { }; function onContextmenu(widget: Widget, ev: MouseEvent) { - const isLink = (el: HTMLElement) => { + const element = ev.target as HTMLElement | null; + const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; if (el.parentElement) { return isLink(el.parentElement); } + return false; }; - if (isLink(ev.target)) return; - if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; + if (element && isLink(element)) return; + if (element && (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(element.tagName) || element.attributes['contenteditable'])) return; if (window.getSelection()?.toString() !== '') return; os.contextMenu([{ diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index f23549efe4..cb72d01601 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -63,7 +63,7 @@ import { defaultStore } from '@/store.js'; const minHeight = 50; const minWidth = 250; -function dragListen(fn: (ev: MouseEvent) => void) { +function dragListen(fn: (ev: MouseEvent | TouchEvent) => void) { window.addEventListener('mousemove', fn); window.addEventListener('touchmove', fn); window.addEventListener('mouseleave', dragClear.bind(null, fn)); @@ -138,7 +138,7 @@ function onContextmenu(ev: MouseEvent) { // 最前面へ移動 function top() { if (rootEl.value) { - rootEl.value.style.zIndex = os.claimZIndex(props.front ? 'middle' : 'low'); + rootEl.value.style.zIndex = os.claimZIndex(props.front ? 'middle' : 'low').toString(); } } @@ -202,9 +202,17 @@ function onDblClick() { } } -function onHeaderMousedown(evt: MouseEvent) { +function getPositionX(event: MouseEvent | TouchEvent) { + return 'touches' in event && event.touches.length > 0 ? event.touches[0].clientX : 'clientX' in event ? event.clientX : 0; +} + +function getPositionY(event: MouseEvent | TouchEvent) { + return 'touches' in event && event.touches.length > 0 ? event.touches[0].clientY : 'clientY' in event ? event.clientY : 0; +} + +function onHeaderMousedown(evt: MouseEvent | TouchEvent) { // 右クリックはコンテキストメニューを開こうとした可能性が高いため無視 - if (evt.button === 2) return; + if ('button' in evt && evt.button === 2) return; let beforeMaximized = false; @@ -229,8 +237,8 @@ function onHeaderMousedown(evt: MouseEvent) { const position = main.getBoundingClientRect(); - const clickX = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientX : evt.clientX; - const clickY = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientY : evt.clientY; + const clickX = getPositionX(evt); + const clickY = getPositionY(evt); const moveBaseX = beforeMaximized ? parseInt(unResizedWidth, 10) / 2 : clickX - position.left; // TODO: parseIntやめる const moveBaseY = beforeMaximized ? 20 : clickY - position.top; const browserWidth = window.innerWidth; @@ -254,8 +262,10 @@ function onHeaderMousedown(evt: MouseEvent) { // 右はみ出し if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth; - rootEl.value.style.left = moveLeft + 'px'; - rootEl.value.style.top = moveTop + 'px'; + if (rootEl.value) { + rootEl.value.style.left = moveLeft + 'px'; + rootEl.value.style.top = moveTop + 'px'; + } } if (beforeMaximized) { @@ -264,26 +274,26 @@ function onHeaderMousedown(evt: MouseEvent) { // 動かした時 dragListen(me => { - const x = me.touches && me.touches.length > 0 ? me.touches[0].clientX : me.clientX; - const y = me.touches && me.touches.length > 0 ? me.touches[0].clientY : me.clientY; + const x = getPositionX(me); + const y = getPositionY(me); move(x, y); }); } // 上ハンドル掴み時 -function onTopHandleMousedown(evt) { +function onTopHandleMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; // どういうわけかnullになることがある if (main == null) return; - const base = evt.clientY; + const base = getPositionY(evt); const height = parseInt(getComputedStyle(main, '').height, 10); const top = parseInt(getComputedStyle(main, '').top, 10); // 動かした時 dragListen(me => { - const move = me.clientY - base; + const move = getPositionY(me) - base; if (top + move > 0) { if (height + -move > minHeight) { applyTransformHeight(height + -move); @@ -300,18 +310,18 @@ function onTopHandleMousedown(evt) { } // 右ハンドル掴み時 -function onRightHandleMousedown(evt) { +function onRightHandleMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; if (main == null) return; - const base = evt.clientX; + const base = getPositionX(evt); const width = parseInt(getComputedStyle(main, '').width, 10); const left = parseInt(getComputedStyle(main, '').left, 10); const browserWidth = window.innerWidth; // 動かした時 dragListen(me => { - const move = me.clientX - base; + const move = getPositionX(me) - base; if (left + width + move < browserWidth) { if (width + move > minWidth) { applyTransformWidth(width + move); @@ -325,18 +335,18 @@ function onRightHandleMousedown(evt) { } // 下ハンドル掴み時 -function onBottomHandleMousedown(evt) { +function onBottomHandleMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; if (main == null) return; - const base = evt.clientY; + const base = getPositionY(evt); const height = parseInt(getComputedStyle(main, '').height, 10); const top = parseInt(getComputedStyle(main, '').top, 10); const browserHeight = window.innerHeight; // 動かした時 dragListen(me => { - const move = me.clientY - base; + const move = getPositionY(me) - base; if (top + height + move < browserHeight) { if (height + move > minHeight) { applyTransformHeight(height + move); @@ -350,17 +360,17 @@ function onBottomHandleMousedown(evt) { } // 左ハンドル掴み時 -function onLeftHandleMousedown(evt) { +function onLeftHandleMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; if (main == null) return; - const base = evt.clientX; + const base = getPositionX(evt); const width = parseInt(getComputedStyle(main, '').width, 10); const left = parseInt(getComputedStyle(main, '').left, 10); // 動かした時 dragListen(me => { - const move = me.clientX - base; + const move = getPositionX(me) - base; if (left + move > 0) { if (width + -move > minWidth) { applyTransformWidth(width + -move); @@ -377,25 +387,25 @@ function onLeftHandleMousedown(evt) { } // 左上ハンドル掴み時 -function onTopLeftHandleMousedown(evt) { +function onTopLeftHandleMousedown(evt: MouseEvent | TouchEvent) { onTopHandleMousedown(evt); onLeftHandleMousedown(evt); } // 右上ハンドル掴み時 -function onTopRightHandleMousedown(evt) { +function onTopRightHandleMousedown(evt: MouseEvent | TouchEvent) { onTopHandleMousedown(evt); onRightHandleMousedown(evt); } // 右下ハンドル掴み時 -function onBottomRightHandleMousedown(evt) { +function onBottomRightHandleMousedown(evt: MouseEvent | TouchEvent) { onBottomHandleMousedown(evt); onRightHandleMousedown(evt); } // 左下ハンドル掴み時 -function onBottomLeftHandleMousedown(evt) { +function onBottomLeftHandleMousedown(evt: MouseEvent | TouchEvent) { onBottomHandleMousedown(evt); onLeftHandleMousedown(evt); } @@ -403,23 +413,23 @@ function onBottomLeftHandleMousedown(evt) { // 高さを適用 function applyTransformHeight(height) { if (height > window.innerHeight) height = window.innerHeight; - rootEl.value.style.height = height + 'px'; + if (rootEl.value) rootEl.value.style.height = height + 'px'; } // 幅を適用 function applyTransformWidth(width) { if (width > window.innerWidth) width = window.innerWidth; - rootEl.value.style.width = width + 'px'; + if (rootEl.value) rootEl.value.style.width = width + 'px'; } // Y座標を適用 function applyTransformTop(top) { - rootEl.value.style.top = top + 'px'; + if (rootEl.value) rootEl.value.style.top = top + 'px'; } // X座標を適用 function applyTransformLeft(left) { - rootEl.value.style.left = left + 'px'; + if (rootEl.value) rootEl.value.style.left = left + 'px'; } function onBrowserResize() { @@ -441,8 +451,10 @@ onMounted(() => { applyTransformWidth(props.initialWidth); if (props.initialHeight) applyTransformHeight(props.initialHeight); - applyTransformTop((window.innerHeight / 2) - (rootEl.value.offsetHeight / 2)); - applyTransformLeft((window.innerWidth / 2) - (rootEl.value.offsetWidth / 2)); + if (rootEl.value) { + applyTransformTop((window.innerHeight / 2) - (rootEl.value.offsetHeight / 2)); + applyTransformLeft((window.innerWidth / 2) - (rootEl.value.offsetWidth / 2)); + } // 他のウィンドウ内のボタンなどを押してこのウィンドウが開かれた場合、親が最前面になろうとするのでそれに隠されないようにする top(); diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue index c6b18aeceb..442619920f 100644 --- a/packages/frontend/src/components/MkYouTubePlayer.vue +++ b/packages/frontend/src/components/MkYouTubePlayer.vue @@ -39,7 +39,7 @@ if (!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid const fetching = ref(true); const title = ref<string | null>(null); const player = ref({ - url: null, + url: null as string | null, width: null, height: null, }); diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue index 594494f3c8..0171e22c79 100644 --- a/packages/frontend/src/components/global/MkAcct.vue +++ b/packages/frontend/src/components/global/MkAcct.vue @@ -21,7 +21,7 @@ import { host as hostRaw } from '@/config.js'; import { defaultStore } from '@/store.js'; defineProps<{ - user: Misskey.entities.UserDetailed; + user: Misskey.entities.User; detail?: boolean; }>(); diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index af5b6e44f5..c54dc18ab0 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -81,9 +81,11 @@ const bound = computed(() => props.link ? { to: userPage(props.user), target: props.target } : {}); -const url = computed(() => (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) - ? getStaticImageUrl(props.user.avatarUrl) - : props.user.avatarUrl); +const url = computed(() => { + if (props.user.avatarUrl == null) return null; + if (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) return getStaticImageUrl(props.user.avatarUrl); + return props.user.avatarUrl; +}); function onClick(ev: MouseEvent): void { if (props.link) return; @@ -109,6 +111,7 @@ function getDecorationOffset(decoration: Omit<Misskey.entities.UserDetailed['ava const color = ref<string | undefined>(); watch(() => props.user.avatarBlurhash, () => { + if (props.user.avatarBlurhash == null) return; color.value = extractAvgColorFromBlurhash(props.user.avatarBlurhash); }, { immediate: true, diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index b384e8afcb..7aa536e55c 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -57,7 +57,7 @@ const rawUrl = computed(() => { }); const url = computed(() => { - if (rawUrl.value == null) return null; + if (rawUrl.value == null) return undefined; const proxied = (rawUrl.value.startsWith('/emoji/') || (props.useOriginalSize && isLocal.value)) diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts index b234144c13..233ae6eeb2 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts @@ -20,6 +20,7 @@ import MkA from '@/components/global/MkA.vue'; import { host } from '@/config.js'; import { defaultStore } from '@/store.js'; import { nyaize as doNyaize } from '@/scripts/nyaize.js'; +import { safeParseFloat } from '@/scripts/safe-parse.js'; const QUOTE_STYLE = ` display: block; @@ -36,7 +37,7 @@ type MfmProps = { nowrap?: boolean; author?: Misskey.entities.UserLite; isNote?: boolean; - emojiUrls?: string[]; + emojiUrls?: Record<string, string>; rootScale?: number; nyaize?: boolean | 'respect'; parsedNodes?: mfm.MfmNode[] | null; @@ -49,7 +50,7 @@ type MfmEvents = { }; // eslint-disable-next-line import/no-default-export -export default function(props: MfmProps, context: SetupContext<MfmEvents>) { +export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEvents>['emit'] }) { const isNote = props.isNote ?? true; const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false; @@ -58,13 +59,14 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { const rootAst = props.parsedNodes ?? (props.plain ? mfm.parseSimple : mfm.parse)(props.text); - const validTime = (t: string | null | undefined) => { + const validTime = (t: string | boolean | null | undefined) => { if (t == null) return null; + if (typeof t === 'boolean') return null; return t.match(/^[0-9.]+s$/) ? t : null; }; - const validColor = (c: string | null | undefined): string | null => { - if (c == null) return null; + const validColor = (c: unknown): string | null => { + if (typeof c !== 'string') return null; return c.match(/^[0-9a-f]{3,6}$/i) ? c : null; }; @@ -223,14 +225,14 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { return h(MkSparkle, {}, genEl(token.children, scale)); } case 'rotate': { - const degrees = parseFloat(token.props.args.deg ?? '90'); + const degrees = safeParseFloat(token.props.args.deg) ?? 90; style = `transform: rotate(${degrees}deg); transform-origin: center center;`; break; } case 'position': { if (!defaultStore.state.advancedMfm) break; - const x = parseFloat(token.props.args.x ?? '0'); - const y = parseFloat(token.props.args.y ?? '0'); + const x = safeParseFloat(token.props.args.x) ?? 0; + const y = safeParseFloat(token.props.args.y) ?? 0; style = `transform: translateX(${x}em) translateY(${y}em);`; break; } @@ -239,8 +241,8 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { style = ''; break; } - const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5); - const y = Math.min(parseFloat(token.props.args.y ?? '1'), 5); + const x = Math.min(safeParseFloat(token.props.args.x) ?? 1, 5); + const y = Math.min(safeParseFloat(token.props.args.y) ?? 1, 5); style = `transform: scale(${x}, ${y});`; scale = scale * Math.max(x, y); break; @@ -262,11 +264,12 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { color = color ? `#${color}` : 'var(--accent)'; let b_style = token.props.args.style; if ( + typeof b_style !== 'string' || !['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'] .includes(b_style) ) b_style = 'solid'; - const width = parseFloat(token.props.args.width ?? '1'); - const radius = parseFloat(token.props.args.radius ?? '0'); + const width = safeParseFloat(token.props.args.width) ?? 1; + const radius = safeParseFloat(token.props.args.radius) ?? 0; style = `border: ${width}px ${b_style} ${color}; border-radius: ${radius}px;${token.props.args.noclip ? '' : ' overflow: clip;'}`; break; } @@ -308,7 +311,8 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { return h('span', { onClick(ev: MouseEvent): void { ev.stopPropagation(); ev.preventDefault(); - context.emit('clickEv', token.props.args.ev ?? ''); + const clickEv = typeof token.props.args.ev === 'string' ? token.props.args.ev : ''; + emit('clickEv', clickEv); } }, genEl(token.children, scale)); } } @@ -369,7 +373,7 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { return [h(MkCode, { key: Math.random(), code: token.props.code, - lang: token.props.lang, + lang: token.props.lang ?? undefined, })]; } @@ -412,8 +416,7 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { return [h(MkCustomEmoji, { key: Math.random(), name: token.props.name, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - url: props.emojiUrls ? props.emojiUrls[token.props.name] : null, + url: props.emojiUrls && props.emojiUrls[token.props.name], normal: props.plain, host: props.author.host, useOriginalSize: scale >= 2.5, diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index 24b92cb83a..a12a5f739d 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -38,6 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> export type Tab = { key: string; + title: string; onClick?: (ev: MouseEvent) => void; } & ( | { @@ -120,8 +121,9 @@ function onTabWheel(ev: WheelEvent) { let entering = false; -async function enter(el: HTMLElement) { +async function enter(element: Element) { entering = true; + const el = element as HTMLElement; const elementWidth = el.getBoundingClientRect().width; el.style.width = '0'; el.style.paddingLeft = '0'; @@ -135,11 +137,12 @@ async function enter(el: HTMLElement) { setTimeout(renderTab, 170); } -function afterEnter(el: HTMLElement) { +function afterEnter(element: Element) { //el.style.width = ''; } -async function leave(el: HTMLElement) { +async function leave(element: Element) { + const el = element as HTMLElement; const elementWidth = el.getBoundingClientRect().width; el.style.width = elementWidth + 'px'; el.style.paddingLeft = ''; @@ -148,7 +151,8 @@ async function leave(el: HTMLElement) { el.style.paddingLeft = '0'; } -function afterLeave(el: HTMLElement) { +function afterLeave(element: Element) { + const el = element as HTMLElement; el.style.width = ''; } diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index 70cc68b14c..c528b80285 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -63,27 +63,32 @@ onMounted(() => { watch([parentStickyTop, parentStickyBottom], calc); watch(childStickyTop, () => { + if (bodyEl.value == null) return; bodyEl.value.style.setProperty('--stickyTop', `${childStickyTop.value}px`); }, { immediate: true, }); watch(childStickyBottom, () => { + if (bodyEl.value == null) return; bodyEl.value.style.setProperty('--stickyBottom', `${childStickyBottom.value}px`); }, { immediate: true, }); - headerEl.value.style.position = 'sticky'; - headerEl.value.style.top = 'var(--stickyTop, 0)'; - headerEl.value.style.zIndex = '1000'; + if (headerEl.value != null) { + headerEl.value.style.position = 'sticky'; + headerEl.value.style.top = 'var(--stickyTop, 0)'; + headerEl.value.style.zIndex = '1000'; + observer.observe(headerEl.value); + } - footerEl.value.style.position = 'sticky'; - footerEl.value.style.bottom = 'var(--stickyBottom, 0)'; - footerEl.value.style.zIndex = '1000'; - - observer.observe(headerEl.value); - observer.observe(footerEl.value); + if (footerEl.value != null) { + footerEl.value.style.position = 'sticky'; + footerEl.value.style.bottom = 'var(--stickyBottom, 0)'; + footerEl.value.style.zIndex = '1000'; + observer.observe(footerEl.value); + } }); onUnmounted(() => { diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue index 2b0bf246ad..a81ba42a5b 100644 --- a/packages/frontend/src/components/global/MkTime.vue +++ b/packages/frontend/src/components/global/MkTime.vue @@ -24,7 +24,7 @@ const props = withDefaults(defineProps<{ mode?: 'relative' | 'absolute' | 'detail'; colored?: boolean; }>(), { - origin: isChromatic() ? new Date('2023-04-01T00:00:00Z') : null, + origin: isChromatic() ? () => new Date('2023-04-01T00:00:00Z') : null, mode: 'relative', }); diff --git a/packages/frontend/src/components/page/block.type.ts b/packages/frontend/src/components/page/block.type.ts deleted file mode 100644 index cdd39339e6..0000000000 --- a/packages/frontend/src/components/page/block.type.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export type BlockBase = { - id: string; - type: string; -}; - -export type TextBlock = BlockBase & { - type: 'text'; - text: string; -}; - -export type SectionBlock = BlockBase & { - type: 'section'; - title: string; - children: Block[]; -}; - -export type ImageBlock = BlockBase & { - type: 'image'; - fileId: string | null; -}; - -export type NoteBlock = BlockBase & { - type: 'note'; - detailed: boolean; - note: string | null; -}; - -export type Block = - TextBlock | SectionBlock | ImageBlock | NoteBlock; diff --git a/packages/frontend/src/components/page/page.block.vue b/packages/frontend/src/components/page/page.block.vue index 7dbbaa03b4..c53ca6519d 100644 --- a/packages/frontend/src/components/page/page.block.vue +++ b/packages/frontend/src/components/page/page.block.vue @@ -14,7 +14,6 @@ import XText from './page.text.vue'; import XSection from './page.section.vue'; import XImage from './page.image.vue'; import XNote from './page.note.vue'; -import { Block } from './block.type.js'; function getComponent(type: string) { switch (type) { @@ -27,7 +26,7 @@ function getComponent(type: string) { } defineProps<{ - block: Block, + block: Misskey.entities.PageBlock, h: number, page: Misskey.entities.Page, }>(); diff --git a/packages/frontend/src/components/page/page.image.vue b/packages/frontend/src/components/page/page.image.vue index 29aebf63e5..af37c7b1b3 100644 --- a/packages/frontend/src/components/page/page.image.vue +++ b/packages/frontend/src/components/page/page.image.vue @@ -14,15 +14,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { ImageBlock } from './block.type.js'; import MediaImage from '@/components/MkMediaImage.vue'; const props = defineProps<{ - block: ImageBlock, + block: Misskey.entities.PageBlock, page: Misskey.entities.Page, }>(); -const image = ref<Misskey.entities.DriveFile>(props.page.attachedFiles.find(x => x.id === props.block.fileId)); +const image = ref<Misskey.entities.DriveFile | null>(null); + +onMounted(() => { + image.value = props.page.attachedFiles.find(x => x.id === props.block.fileId) ?? null; +}); + </script> diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue index 83fdf24deb..5093ee9b79 100644 --- a/packages/frontend/src/components/page/page.note.vue +++ b/packages/frontend/src/components/page/page.note.vue @@ -13,19 +13,19 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { NoteBlock } from './block.type.js'; import MkNote from '@/components/MkNote.vue'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; const props = defineProps<{ - block: NoteBlock, + block: Misskey.entities.PageBlock, page: Misskey.entities.Page, }>(); const note = ref<Misskey.entities.Note | null>(null); onMounted(() => { + if (props.block.note == null) return; misskeyApi('notes/show', { noteId: props.block.note }) .then(result => { note.value = result; diff --git a/packages/frontend/src/components/page/page.section.vue b/packages/frontend/src/components/page/page.section.vue index e4e5a43b59..63c155ada6 100644 --- a/packages/frontend/src/components/page/page.section.vue +++ b/packages/frontend/src/components/page/page.section.vue @@ -25,12 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; -import { SectionBlock } from './block.type.js'; const XBlock = defineAsyncComponent(() => import('./page.block.vue')); defineProps<{ - block: SectionBlock, + block: Misskey.entities.PageBlock, h: number, page: Misskey.entities.Page, }>(); diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index ee6b2dca5b..3cdea7d669 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps"> - <Mfm :text="block.text" :isNote="false"/> + <Mfm :text="block.text ?? ''" :isNote="false"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url"/> </div> </template> @@ -14,13 +14,12 @@ SPDX-License-Identifier: AGPL-3.0-only import { defineAsyncComponent } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; -import { TextBlock } from './block.type.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')); const props = defineProps<{ - block: TextBlock, + block: Misskey.entities.PageBlock, page: Misskey.entities.Page, }>(); diff --git a/packages/frontend/src/nirax.ts b/packages/frontend/src/nirax.ts index ddb2a085db..878335b9f3 100644 --- a/packages/frontend/src/nirax.ts +++ b/packages/frontend/src/nirax.ts @@ -426,11 +426,12 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter { } } -export function useScrollPositionManager(getScrollContainer: () => HTMLElement, router: IRouter) { +export function useScrollPositionManager(getScrollContainer: () => HTMLElement | null, router: IRouter) { const scrollPosStore = new Map<string, number>(); onMounted(() => { const scrollContainer = getScrollContainer(); + if (scrollContainer == null) return; scrollContainer.addEventListener('scroll', () => { scrollPosStore.set(router.getCurrentKey(), scrollContainer.scrollTop); diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 9fc3603af0..b01e8a54f7 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -419,7 +419,7 @@ export function form(title, form) { }); } -export async function selectUser(opts: { includeSelf?: boolean } = {}): Promise<Misskey.entities.UserLite> { +export async function selectUser(opts: { includeSelf?: boolean } = {}): Promise<Misskey.entities.UserDetailed> { return new Promise((resolve, reject) => { popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), { includeSelf: opts.includeSelf, diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue index a614cacd45..365649aa0a 100644 --- a/packages/frontend/src/pages/admin/files.vue +++ b/packages/frontend/src/pages/admin/files.vue @@ -80,7 +80,7 @@ function show(file) { async function find() { const { canceled, result: q } = await os.inputText({ title: i18n.ts.fileIdOrUrl, - allowEmpty: false, + minLength: 1, }); if (canceled) return; diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue index 030cfbb905..2ecfec6b89 100644 --- a/packages/frontend/src/pages/share.vue +++ b/packages/frontend/src/pages/share.vue @@ -56,7 +56,7 @@ const renote = ref<Misskey.entities.Note | undefined>(); const visibility = ref(Misskey.noteVisibilities.includes(visibilityQuery) ? visibilityQuery : undefined); const localOnly = ref(localOnlyQuery === '0' ? false : localOnlyQuery === '1' ? true : undefined); const files = ref([] as Misskey.entities.DriveFile[]); -const visibleUsers = ref([] as Misskey.entities.User[]); +const visibleUsers = ref([] as Misskey.entities.UserDetailed[]); async function init() { let noteText = ''; diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue index 738b015a99..fe9e818da1 100644 --- a/packages/frontend/src/pages/theme-editor.vue +++ b/packages/frontend/src/pages/theme-editor.vue @@ -190,7 +190,7 @@ function applyThemeCode() { async function saveAs() { const { canceled, result: name } = await os.inputText({ title: i18n.ts.name, - allowEmpty: false, + minLength: 1, }); if (canceled) return; diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue index 07c98571e4..b4692742fb 100644 --- a/packages/frontend/src/pages/welcome.timeline.vue +++ b/packages/frontend/src/pages/welcome.timeline.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkMediaList :mediaList="note.files"/> </div> <div v-if="note.poll"> - <MkPoll :note="note" :readOnly="true"/> + <MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/> </div> </div> <MkReactionsViewer ref="reactionsViewer" :note="note"/> diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts index b0c36cb927..36264fc459 100644 --- a/packages/frontend/src/scripts/autocomplete.ts +++ b/packages/frontend/src/scripts/autocomplete.ts @@ -19,7 +19,7 @@ export class Autocomplete { } | null; private textarea: HTMLInputElement | HTMLTextAreaElement; private currentType: string; - private textRef: Ref<string>; + private textRef: Ref<string | number | null>; private opening: boolean; private onlyType: SuggestionType[]; @@ -38,7 +38,7 @@ export class Autocomplete { /** * 対象のテキストエリアを与えてインスタンスを初期化します。 */ - constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, onlyType?: SuggestionType[]) { + constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string | number | null>, onlyType?: SuggestionType[]) { //#region BIND this.onInput = this.onInput.bind(this); this.complete = this.complete.bind(this); diff --git a/packages/frontend/src/scripts/clone.ts b/packages/frontend/src/scripts/clone.ts index 96b53684f3..ac38faefaa 100644 --- a/packages/frontend/src/scripts/clone.ts +++ b/packages/frontend/src/scripts/clone.ts @@ -8,7 +8,7 @@ // あと、Vue RefをIndexedDBに保存しようとしてstructredCloneを使ったらエラーになった // https://github.com/misskey-dev/misskey/pull/8098#issuecomment-1114144045 -type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[]; +type Cloneable = string | number | boolean | null | undefined | { [key: string]: Cloneable } | Cloneable[]; export function deepClone<T extends Cloneable>(x: T): T { if (typeof x === 'object') { @@ -16,7 +16,7 @@ export function deepClone<T extends Cloneable>(x: T): T { if (Array.isArray(x)) return x.map(deepClone) as T; const obj = {} as Record<string, Cloneable>; for (const [k, v] of Object.entries(x)) { - obj[k] = deepClone(v); + obj[k] = v === undefined ? undefined : deepClone(v); } return obj as T; } else { diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index bfc3c4a8f1..a512bd8365 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { defineAsyncComponent, Ref } from 'vue'; +import { defineAsyncComponent, Ref, ShallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import { claimAchievement } from './achievements.js'; import { $i } from '@/account.js'; @@ -36,7 +36,7 @@ export async function getNoteClipMenu(props: { const appearNote = isRenote ? props.note.renote as Misskey.entities.Note : props.note; const clips = await clipsCache.fetch(); - return [...clips.map(clip => ({ + const menu: MenuItem[] = [...clips.map(clip => ({ text: clip.name, action: () => { claimAchievement('noteClipped1'); @@ -93,6 +93,8 @@ export async function getNoteClipMenu(props: { os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); }, }]; + + return menu; } export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): MenuItem { @@ -122,7 +124,6 @@ export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string): export function getNoteMenu(props: { note: Misskey.entities.Note; - menuButton: Ref<HTMLElement>; translation: Ref<Misskey.entities.NotesTranslateResponse | null>; translating: Ref<boolean>; isDeleted: Ref<boolean>; @@ -471,7 +472,7 @@ function smallerVisibility(a: Visibility | string, b: Visibility | string): Visi export function getRenoteMenu(props: { note: Misskey.entities.Note; - renoteButton: Ref<HTMLElement>; + renoteButton: ShallowRef<HTMLElement | undefined>; mock?: boolean; }) { const isRenote = ( @@ -491,7 +492,7 @@ export function getRenoteMenu(props: { text: i18n.ts.inChannelRenote, icon: 'ti ti-repeat', action: () => { - const el = props.renoteButton.value as HTMLElement | null | undefined; + const el = props.renoteButton.value; if (el) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); @@ -527,7 +528,7 @@ export function getRenoteMenu(props: { text: i18n.ts.renote, icon: 'ti ti-repeat', action: () => { - const el = props.renoteButton.value as HTMLElement | null | undefined; + const el = props.renoteButton.value; if (el) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); @@ -567,7 +568,7 @@ export function getRenoteMenu(props: { const renoteItems = [ ...normalRenoteItems, - ...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] : [], + ...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] as MenuItem[] : [], ...channelRenoteItems, ]; diff --git a/packages/frontend/src/scripts/get-note-summary.ts b/packages/frontend/src/scripts/get-note-summary.ts index 2007e0ea97..72153ceb75 100644 --- a/packages/frontend/src/scripts/get-note-summary.ts +++ b/packages/frontend/src/scripts/get-note-summary.ts @@ -10,7 +10,11 @@ import { i18n } from '@/i18n.js'; * 投稿を表す文字列を取得します。 * @param {*} note (packされた)投稿 */ -export const getNoteSummary = (note: Misskey.entities.Note): string => { +export const getNoteSummary = (note?: Misskey.entities.Note | null): string => { + if (note == null) { + return ''; + } + if (note.deletedAt) { return `(${i18n.ts.deletedNote})`; } diff --git a/packages/frontend/src/scripts/popup-position.ts b/packages/frontend/src/scripts/popup-position.ts index 0a799c5665..f36388b8f1 100644 --- a/packages/frontend/src/scripts/popup-position.ts +++ b/packages/frontend/src/scripts/popup-position.ts @@ -4,7 +4,7 @@ */ export function calcPopupPosition(el: HTMLElement, props: { - anchorElement: HTMLElement | null; + anchorElement?: HTMLElement | null; innerMargin: number; direction: 'top' | 'bottom' | 'left' | 'right'; align: 'top' | 'bottom' | 'left' | 'right' | 'center'; diff --git a/packages/frontend/src/scripts/reaction-picker.ts b/packages/frontend/src/scripts/reaction-picker.ts index 9b13e794f5..a13351b536 100644 --- a/packages/frontend/src/scripts/reaction-picker.ts +++ b/packages/frontend/src/scripts/reaction-picker.ts @@ -38,7 +38,7 @@ class ReactionPicker { }); } - public show(src: HTMLElement, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) { + public show(src: HTMLElement | null, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) { this.src.value = src; this.manualShowing.value = true; this.onChosen = onChosen; diff --git a/packages/frontend/src/scripts/safe-parse.ts b/packages/frontend/src/scripts/safe-parse.ts new file mode 100644 index 0000000000..7bce1f79ca --- /dev/null +++ b/packages/frontend/src/scripts/safe-parse.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function safeParseFloat(str: unknown): number | null { + if (typeof str !== 'string' || str === '') return null; + const num = parseFloat(str); + if (isNaN(num)) return null; + return num; +} diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts index bda9c04ea4..d4e7e8104f 100644 --- a/packages/frontend/src/scripts/use-note-capture.ts +++ b/packages/frontend/src/scripts/use-note-capture.ts @@ -3,13 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { onUnmounted, Ref } from 'vue'; +import { onUnmounted, Ref, ShallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import { useStream } from '@/stream.js'; import { $i } from '@/account.js'; export function useNoteCapture(props: { - rootEl: Ref<HTMLElement>; + rootEl: ShallowRef<HTMLElement | undefined>; note: Ref<Misskey.entities.Note>; pureNote: Ref<Misskey.entities.Note>; isDeletedRef: Ref<boolean>; @@ -83,7 +83,7 @@ export function useNoteCapture(props: { function capture(withHandler = false): void { if (connection) { // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する - connection.send(document.body.contains(props.rootEl.value) ? 'sr' : 's', { id: note.value.id }); + connection.send(document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id }); if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id }); if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); } diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts index f4516bbe5b..d5bea5c01c 100644 --- a/packages/frontend/src/types/menu.ts +++ b/packages/frontend/src/types/menu.ts @@ -4,7 +4,7 @@ */ import * as Misskey from 'misskey-js'; -import { Ref } from 'vue'; +import { ComputedRef, Ref } from 'vue'; export type MenuAction = (ev: MouseEvent) => void; @@ -15,7 +15,7 @@ export type MenuLink = { type: 'link', to: string, text: string, icon?: string, export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean }; export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction }; export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, disabled?: boolean | Ref<boolean> }; -export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean, avatar?: Misskey.entities.User; action: MenuAction }; +export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction }; export type MenuParent = { type: 'parent', text: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) }; export type MenuPending = { type: 'pending' }; diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 089a01f19a..06a64aa363 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -241,7 +241,7 @@ function changeProfile(ev: MouseEvent) { action: async () => { const { canceled, result: name } = await os.inputText({ title: i18n.ts._deck.profile, - allowEmpty: false, + minLength: 1, }); if (canceled) return; diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 26f100e452..dae98d58da 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -520,10 +520,10 @@ export type Channels = { mention: (payload: Note) => void; reply: (payload: Note) => void; renote: (payload: Note) => void; - follow: (payload: User) => void; - followed: (payload: User) => void; - unfollow: (payload: User) => void; - meUpdated: (payload: MeDetailed) => void; + follow: (payload: UserDetailedNotMe) => void; + followed: (payload: UserDetailed | UserLite) => void; + unfollow: (payload: UserDetailed) => void; + meUpdated: (payload: UserDetailed) => void; pageEvent: (payload: PageEvent) => void; urlUploadFinished: (payload: { marker: string; @@ -598,6 +598,7 @@ export type Channels = { params: { listId: string; withFiles?: boolean; + withRenotes?: boolean; }; events: { note: (payload: Note) => void; @@ -648,7 +649,7 @@ export type Channels = { fileUpdated: (payload: DriveFile) => void; folderCreated: (payload: DriveFolder) => void; folderDeleted: (payload: DriveFolder['id']) => void; - folderUpdated: (payload: DriveFile) => void; + folderUpdated: (payload: DriveFolder) => void; }; receives: null; }; @@ -1660,6 +1661,7 @@ declare namespace entities { Hashtag, InviteCode, Page, + PageBlock, Channel, QueueCount, Antenna, @@ -1672,6 +1674,7 @@ declare namespace entities { Signin, RoleLite, Role, + RolePolicies, ReversiGameLite, ReversiGameDetailed } @@ -2510,6 +2513,9 @@ export const notificationTypes: readonly ["note", "follow", "mention", "reply", // @public (undocumented) type Page = components['schemas']['Page']; +// @public (undocumented) +type PageBlock = components['schemas']['PageBlock']; + // @public (undocumented) type PageEvent = { pageId: Page['id']; @@ -2658,6 +2664,9 @@ type Role = components['schemas']['Role']; // @public (undocumented) type RoleLite = components['schemas']['RoleLite']; +// @public (undocumented) +type RolePolicies = components['schemas']['RolePolicies']; + // @public (undocumented) type RolesListResponse = operations['roles/list']['responses']['200']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index c97b95e536..98bb066229 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -1,6 +1,6 @@ /* - * version: 2024.2.0-beta.6 - * generatedAt: 2024-01-24T07:32:10.455Z + * version: 2024.2.0-beta.7 + * generatedAt: 2024-01-29T09:40:51.624Z */ import type { SwitchCaseResponseType } from '../api.js'; diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index e356de3453..3966410498 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -1,6 +1,6 @@ /* - * version: 2024.2.0-beta.6 - * generatedAt: 2024-01-24T07:32:10.453Z + * version: 2024.2.0-beta.7 + * generatedAt: 2024-01-29T09:40:51.621Z */ import type { diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index bfe40dc947..d9b003173c 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -1,6 +1,6 @@ /* - * version: 2024.2.0-beta.6 - * generatedAt: 2024-01-24T07:32:10.452Z + * version: 2024.2.0-beta.7 + * generatedAt: 2024-01-29T09:40:51.620Z */ import { operations } from './types.js'; diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index b7dcbfd951..c1c0684871 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -1,6 +1,6 @@ /* - * version: 2024.2.0-beta.6 - * generatedAt: 2024-01-24T07:32:10.450Z + * version: 2024.2.0-beta.7 + * generatedAt: 2024-01-29T09:40:51.618Z */ import { components } from './types.js'; @@ -29,6 +29,7 @@ export type Blocking = components['schemas']['Blocking']; export type Hashtag = components['schemas']['Hashtag']; export type InviteCode = components['schemas']['InviteCode']; export type Page = components['schemas']['Page']; +export type PageBlock = components['schemas']['PageBlock']; export type Channel = components['schemas']['Channel']; export type QueueCount = components['schemas']['QueueCount']; export type Antenna = components['schemas']['Antenna']; @@ -41,5 +42,6 @@ export type Flash = components['schemas']['Flash']; export type Signin = components['schemas']['Signin']; export type RoleLite = components['schemas']['RoleLite']; export type Role = components['schemas']['Role']; +export type RolePolicies = components['schemas']['RolePolicies']; export type ReversiGameLite = components['schemas']['ReversiGameLite']; export type ReversiGameDetailed = components['schemas']['ReversiGameDetailed']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index b5eca12a19..64c54cb42c 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -2,8 +2,8 @@ /* eslint @typescript-eslint/no-explicit-any: 0 */ /* - * version: 2024.2.0-beta.6 - * generatedAt: 2024-01-24T07:32:10.370Z + * version: 2024.2.0-beta.7 + * generatedAt: 2024-01-29T09:40:51.532Z */ /** @@ -3744,32 +3744,7 @@ export type components = { unlockedAt: number; }[]; loggedInDays: number; - policies: { - gtlAvailable: boolean; - ltlAvailable: boolean; - canPublicNote: boolean; - canInvite: boolean; - inviteLimit: number; - inviteLimitCycle: number; - inviteExpirationTime: number; - canManageCustomEmojis: boolean; - canManageAvatarDecorations: boolean; - canSearchNotes: boolean; - canUseTranslator: boolean; - canHideAds: boolean; - driveCapacityMb: number; - alwaysMarkNsfw: boolean; - pinLimit: number; - antennaLimit: number; - wordMuteLimit: number; - webhookLimit: number; - clipLimit: number; - noteEachClipsLimit: number; - userListLimit: number; - userEachUserListsLimit: number; - rateLimitFactor: number; - avatarDecorationLimit: number; - }; + policies: components['schemas']['RolePolicies']; email?: string | null; emailVerified?: boolean | null; securityKeysList?: { @@ -3830,8 +3805,10 @@ export type components = { text: string; title: string; imageUrl: string | null; - icon: string; - display: string; + /** @enum {string} */ + icon: 'info' | 'warning' | 'error' | 'success'; + /** @enum {string} */ + display: 'dialog' | 'normal' | 'banner'; needConfirmationToRead: boolean; silence: boolean; forYou: boolean; @@ -3873,13 +3850,26 @@ export type components = { reply?: components['schemas']['Note'] | null; renote?: components['schemas']['Note'] | null; isHidden?: boolean; - visibility: string; + /** @enum {string} */ + visibility: 'public' | 'home' | 'followers' | 'specified'; mentions?: string[]; visibleUserIds?: string[]; fileIds?: string[]; files?: components['schemas']['DriveFile'][]; tags?: string[]; - poll?: Record<string, never> | null; + poll?: ({ + /** Format: date-time */ + expiresAt?: string | null; + multiple: boolean; + choices: { + isVoted: boolean; + text: string; + votes: number; + }[]; + }) | null; + emojis?: { + [key: string]: string; + }; /** * Format: id * @example xxxxxxxxxx @@ -3895,14 +3885,19 @@ export type components = { }) | null; localOnly?: boolean; reactionAcceptance: string | null; - reactions: Record<string, never>; + reactionEmojis: { + [key: string]: string; + }; + reactions: { + [key: string]: number; + }; renoteCount: number; repliesCount: number; uri?: string; url?: string; reactionAndUserPairCache?: string[]; clippedCount?: number; - myReaction?: Record<string, never> | null; + myReaction?: string | null; }; NoteReaction: { /** @@ -3933,21 +3928,162 @@ export type components = { /** Format: date-time */ createdAt: string; /** @enum {string} */ - type: 'note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped'; - user?: components['schemas']['UserLite'] | null; + type: 'note'; + user: components['schemas']['UserLite']; /** Format: id */ - userId?: string | null; - note?: components['schemas']['Note'] | null; - reaction?: string | null; - achievement?: string; - body?: string | null; - header?: string | null; - icon?: string | null; - reactions?: { + userId: string; + note: components['schemas']['Note']; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'mention'; + user: components['schemas']['UserLite']; + /** Format: id */ + userId: string; + note: components['schemas']['Note']; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'reply'; + user: components['schemas']['UserLite']; + /** Format: id */ + userId: string; + note: components['schemas']['Note']; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'renote'; + user: components['schemas']['UserLite']; + /** Format: id */ + userId: string; + note: components['schemas']['Note']; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'quote'; + user: components['schemas']['UserLite']; + /** Format: id */ + userId: string; + note: components['schemas']['Note']; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'reaction'; + user: components['schemas']['UserLite']; + /** Format: id */ + userId: string; + note: components['schemas']['Note']; + reaction: string; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'pollEnded'; + user: components['schemas']['UserLite']; + /** Format: id */ + userId: string; + note: components['schemas']['Note']; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'follow'; + user: components['schemas']['UserLite']; + /** Format: id */ + userId: string; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'receiveFollowRequest'; + user: components['schemas']['UserLite']; + /** Format: id */ + userId: string; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'followRequestAccepted'; + user: components['schemas']['UserLite']; + /** Format: id */ + userId: string; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'roleAssigned'; + role: components['schemas']['Role']; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'achievementEarned'; + achievement: string; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'app'; + body: string; + header: string; + icon: string; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'reaction:grouped'; + note: components['schemas']['Note']; + reactions: { user: components['schemas']['UserLite']; reaction: string; - }[] | null; - users?: components['schemas']['UserLite'][] | null; + }[]; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'renote:grouped'; + note: components['schemas']['Note']; + users: components['schemas']['UserLite'][]; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'test'; }; DriveFile: { /** @@ -4110,7 +4246,7 @@ export type components = { /** Format: id */ userId: string; user: components['schemas']['UserLite']; - content: Record<string, never>[]; + content: components['schemas']['PageBlock'][]; variables: Record<string, never>[]; title: string; name: string; @@ -4125,6 +4261,29 @@ export type components = { likedCount: number; isLiked?: boolean; }; + PageBlock: OneOf<[{ + id: string; + /** @enum {string} */ + type: 'text'; + text: string; + }, { + id: string; + /** @enum {string} */ + type: 'section'; + title: string; + children: components['schemas']['PageBlock'][]; + }, { + id: string; + /** @enum {string} */ + type: 'image'; + fileId: string | null; + }, { + id: string; + /** @enum {string} */ + type: 'note'; + detailed: boolean; + note: string | null; + }]>; Channel: { /** * Format: id @@ -4344,129 +4503,40 @@ export type components = { /** @example false */ canEditMembersByModerator: boolean; policies: { - pinLimit: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - canInvite: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - clipLimit: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - canHideAds: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - inviteLimit: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - antennaLimit: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - gtlAvailable: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - ltlAvailable: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - webhookLimit: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - canPublicNote: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - userListLimit: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - wordMuteLimit: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - alwaysMarkNsfw: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - canSearchNotes: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - driveCapacityMb: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - rateLimitFactor: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - inviteLimitCycle: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - noteEachClipsLimit: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - inviteExpirationTime: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - canManageCustomEmojis: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - userEachUserListsLimit: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - canManageAvatarDecorations: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - canUseTranslator: { - value: number | boolean; - priority: number; - useDefault: boolean; - }; - avatarDecorationLimit: { - value: number | boolean; - priority: number; - useDefault: boolean; + [key: string]: { + value?: number | boolean; + priority?: number; + useDefault?: boolean; }; }; usersCount: number; }); + RolePolicies: { + gtlAvailable: boolean; + ltlAvailable: boolean; + canPublicNote: boolean; + canInvite: boolean; + inviteLimit: number; + inviteLimitCycle: number; + inviteExpirationTime: number; + canManageCustomEmojis: boolean; + canManageAvatarDecorations: boolean; + canSearchNotes: boolean; + canUseTranslator: boolean; + canHideAds: boolean; + driveCapacityMb: number; + alwaysMarkNsfw: boolean; + pinLimit: number; + antennaLimit: number; + wordMuteLimit: number; + webhookLimit: number; + clipLimit: number; + noteEachClipsLimit: number; + userListLimit: number; + userEachUserListsLimit: number; + rateLimitFactor: number; + avatarDecorationLimit: number; + }; ReversiGameLite: { /** Format: id */ id: string; @@ -11030,14 +11100,18 @@ export type operations = { 200: { content: { 'application/json': { - 'local.incCount': number[]; - 'local.incSize': number[]; - 'local.decCount': number[]; - 'local.decSize': number[]; - 'remote.incCount': number[]; - 'remote.incSize': number[]; - 'remote.decCount': number[]; - 'remote.decSize': number[]; + local: { + incCount: number[]; + incSize: number[]; + decCount: number[]; + decSize: number[]; + }; + remote: { + incCount: number[]; + incSize: number[]; + decCount: number[]; + decSize: number[]; + }; }; }; }; @@ -11165,30 +11239,44 @@ export type operations = { 200: { content: { 'application/json': { - 'requests.failed': number[]; - 'requests.succeeded': number[]; - 'requests.received': number[]; - 'notes.total': number[]; - 'notes.inc': number[]; - 'notes.dec': number[]; - 'notes.diffs.normal': number[]; - 'notes.diffs.reply': number[]; - 'notes.diffs.renote': number[]; - 'notes.diffs.withFile': number[]; - 'users.total': number[]; - 'users.inc': number[]; - 'users.dec': number[]; - 'following.total': number[]; - 'following.inc': number[]; - 'following.dec': number[]; - 'followers.total': number[]; - 'followers.inc': number[]; - 'followers.dec': number[]; - 'drive.totalFiles': number[]; - 'drive.incFiles': number[]; - 'drive.decFiles': number[]; - 'drive.incUsage': number[]; - 'drive.decUsage': number[]; + requests: { + failed: number[]; + succeeded: number[]; + received: number[]; + }; + notes: { + total: number[]; + inc: number[]; + dec: number[]; + diffs: { + normal: number[]; + reply: number[]; + renote: number[]; + withFile: number[]; + }; + }; + users: { + total: number[]; + inc: number[]; + dec: number[]; + }; + following: { + total: number[]; + inc: number[]; + dec: number[]; + }; + followers: { + total: number[]; + inc: number[]; + dec: number[]; + }; + drive: { + totalFiles: number[]; + incFiles: number[]; + decFiles: number[]; + incUsage: number[]; + decUsage: number[]; + }; }; }; }; @@ -11248,20 +11336,28 @@ export type operations = { 200: { content: { 'application/json': { - 'local.total': number[]; - 'local.inc': number[]; - 'local.dec': number[]; - 'local.diffs.normal': number[]; - 'local.diffs.reply': number[]; - 'local.diffs.renote': number[]; - 'local.diffs.withFile': number[]; - 'remote.total': number[]; - 'remote.inc': number[]; - 'remote.dec': number[]; - 'remote.diffs.normal': number[]; - 'remote.diffs.reply': number[]; - 'remote.diffs.renote': number[]; - 'remote.diffs.withFile': number[]; + local: { + total: number[]; + inc: number[]; + dec: number[]; + diffs: { + normal: number[]; + reply: number[]; + renote: number[]; + withFile: number[]; + }; + }; + remote: { + total: number[]; + inc: number[]; + dec: number[]; + diffs: { + normal: number[]; + reply: number[]; + renote: number[]; + withFile: number[]; + }; + }; }; }; }; @@ -11390,18 +11486,30 @@ export type operations = { 200: { content: { 'application/json': { - 'local.followings.total': number[]; - 'local.followings.inc': number[]; - 'local.followings.dec': number[]; - 'local.followers.total': number[]; - 'local.followers.inc': number[]; - 'local.followers.dec': number[]; - 'remote.followings.total': number[]; - 'remote.followings.inc': number[]; - 'remote.followings.dec': number[]; - 'remote.followers.total': number[]; - 'remote.followers.inc': number[]; - 'remote.followers.dec': number[]; + local: { + followings: { + total: number[]; + inc: number[]; + dec: number[]; + }; + followers: { + total: number[]; + inc: number[]; + dec: number[]; + }; + }; + remote: { + followings: { + total: number[]; + inc: number[]; + dec: number[]; + }; + followers: { + total: number[]; + inc: number[]; + dec: number[]; + }; + }; }; }; }; @@ -11466,10 +11574,12 @@ export type operations = { total: number[]; inc: number[]; dec: number[]; - 'diffs.normal': number[]; - 'diffs.reply': number[]; - 'diffs.renote': number[]; - 'diffs.withFile': number[]; + diffs: { + normal: number[]; + reply: number[]; + renote: number[]; + withFile: number[]; + }; }; }; }; @@ -11531,10 +11641,14 @@ export type operations = { 200: { content: { 'application/json': { - 'upv.user': number[]; - 'pv.user': number[]; - 'upv.visitor': number[]; - 'pv.visitor': number[]; + upv: { + user: number[]; + visitor: number[]; + }; + pv: { + user: number[]; + visitor: number[]; + }; }; }; }; @@ -11596,8 +11710,12 @@ export type operations = { 200: { content: { 'application/json': { - 'local.count': number[]; - 'remote.count': number[]; + local: { + count: number[]; + }; + remote: { + count: number[]; + }; }; }; }; @@ -11657,12 +11775,16 @@ export type operations = { 200: { content: { 'application/json': { - 'local.total': number[]; - 'local.inc': number[]; - 'local.dec': number[]; - 'remote.total': number[]; - 'remote.inc': number[]; - 'remote.dec': number[]; + local: { + total: number[]; + inc: number[]; + dec: number[]; + }; + remote: { + total: number[]; + inc: number[]; + dec: number[]; + }; }; }; }; @@ -18987,6 +19109,7 @@ export type operations = { privacyPolicyUrl: string | null; serverRules: string[]; themeColor: string | null; + policies: components['schemas']['RolePolicies']; }; }; }; @@ -25519,7 +25642,14 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': unknown; + 'application/json': { + /** Format: date-time */ + createdAt: string; + users: number; + data: { + [key: string]: number; + }; + }[]; }; }; /** @description Client error */ diff --git a/packages/misskey-js/src/streaming.types.ts b/packages/misskey-js/src/streaming.types.ts index 6f575ce585..06b76929ad 100644 --- a/packages/misskey-js/src/streaming.types.ts +++ b/packages/misskey-js/src/streaming.types.ts @@ -2,11 +2,13 @@ import { Antenna, DriveFile, DriveFolder, - MeDetailed, Note, Notification, Signin, User, + UserDetailed, + UserDetailedNotMe, + UserLite, } from './autogen/models.js'; import { AnnouncementCreated, @@ -27,10 +29,10 @@ export type Channels = { mention: (payload: Note) => void; reply: (payload: Note) => void; renote: (payload: Note) => void; - follow: (payload: User) => void; // 自分が他人をフォローしたとき - followed: (payload: User) => void; // 他人が自分をフォローしたとき - unfollow: (payload: User) => void; // 自分が他人をフォロー解除したとき - meUpdated: (payload: MeDetailed) => void; + follow: (payload: UserDetailedNotMe) => void; // 自分が他人をフォローしたとき + followed: (payload: UserDetailed | UserLite) => void; // 他人が自分をフォローしたとき + unfollow: (payload: UserDetailed) => void; // 自分が他人をフォロー解除したとき + meUpdated: (payload: UserDetailed) => void; pageEvent: (payload: PageEvent) => void; urlUploadFinished: (payload: { marker: string; file: DriveFile; }) => void; readAllNotifications: () => void; @@ -102,6 +104,7 @@ export type Channels = { params: { listId: string; withFiles?: boolean; + withRenotes?: boolean; }; events: { note: (payload: Note) => void; @@ -152,7 +155,7 @@ export type Channels = { fileUpdated: (payload: DriveFile) => void; folderCreated: (payload: DriveFolder) => void; folderDeleted: (payload: DriveFolder['id']) => void; - folderUpdated: (payload: DriveFile) => void; + folderUpdated: (payload: DriveFolder) => void; }; receives: null; };