Merge remote-tracking branch 'mi-dev/develop' into emoji

# Conflicts:
#	packages/backend/src/models/RepositoryModule.ts
#	packages/misskey-js/etc/misskey-js.api.md
This commit is contained in:
mattyatea 2023-10-21 19:56:27 +09:00
commit 27a7d0bbf7
No known key found for this signature in database
GPG key ID: 068E54E2C33BEF9A
75 changed files with 2799 additions and 686 deletions

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AvatarDecoration1697847397844 {
name = 'AvatarDecoration1697847397844'
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "avatar_decoration" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE, "url" character varying(1024) NOT NULL, "name" character varying(256) NOT NULL, "description" character varying(2048) NOT NULL, "roleIdsThatCanBeUsedThisDecoration" character varying(128) array NOT NULL DEFAULT '{}', CONSTRAINT "PK_b6de9296f6097078e1dc53f7603" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "user" ADD "avatarDecorations" character varying(512) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarDecorations"`);
await queryRunner.query(`DROP TABLE "avatar_decoration"`);
}
}

View file

@ -186,7 +186,7 @@
"@types/js-yaml": "4.0.8",
"@types/jsdom": "21.1.4",
"@types/jsonld": "1.5.11",
"@types/jsrsasign": "10.5.10",
"@types/jsrsasign": "10.5.11",
"@types/mime-types": "2.1.3",
"@types/ms": "0.7.33",
"@types/node": "20.8.7",

View file

@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { AvatarDecorationsRepository, MiAvatarDecoration, MiUser } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { MemorySingleCache } from '@/misc/cache.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
@Injectable()
export class AvatarDecorationService implements OnApplicationShutdown {
public cache: MemorySingleCache<MiAvatarDecoration[]>;
constructor(
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.avatarDecorationsRepository)
private avatarDecorationsRepository: AvatarDecorationsRepository,
private idService: IdService,
private moderationLogService: ModerationLogService,
private globalEventService: GlobalEventService,
) {
this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30);
this.redisForSub.on('message', this.onMessage);
}
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'avatarDecorationCreated':
case 'avatarDecorationUpdated':
case 'avatarDecorationDeleted': {
this.cache.delete();
break;
}
default:
break;
}
}
}
@bindThis
public async create(options: Partial<MiAvatarDecoration>, moderator?: MiUser): Promise<MiAvatarDecoration> {
const created = await this.avatarDecorationsRepository.insert({
id: this.idService.gen(),
...options,
}).then(x => this.avatarDecorationsRepository.findOneByOrFail(x.identifiers[0]));
this.globalEventService.publishInternalEvent('avatarDecorationCreated', created);
if (moderator) {
this.moderationLogService.log(moderator, 'createAvatarDecoration', {
avatarDecorationId: created.id,
avatarDecoration: created,
});
}
return created;
}
@bindThis
public async update(id: MiAvatarDecoration['id'], params: Partial<MiAvatarDecoration>, moderator?: MiUser): Promise<void> {
const avatarDecoration = await this.avatarDecorationsRepository.findOneByOrFail({ id });
const date = new Date();
await this.avatarDecorationsRepository.update(avatarDecoration.id, {
updatedAt: date,
...params,
});
const updated = await this.avatarDecorationsRepository.findOneByOrFail({ id: avatarDecoration.id });
this.globalEventService.publishInternalEvent('avatarDecorationUpdated', updated);
if (moderator) {
this.moderationLogService.log(moderator, 'updateAvatarDecoration', {
avatarDecorationId: avatarDecoration.id,
before: avatarDecoration,
after: updated,
});
}
}
@bindThis
public async delete(id: MiAvatarDecoration['id'], moderator?: MiUser): Promise<void> {
const avatarDecoration = await this.avatarDecorationsRepository.findOneByOrFail({ id });
await this.avatarDecorationsRepository.delete({ id: avatarDecoration.id });
this.globalEventService.publishInternalEvent('avatarDecorationDeleted', avatarDecoration);
if (moderator) {
this.moderationLogService.log(moderator, 'deleteAvatarDecoration', {
avatarDecorationId: avatarDecoration.id,
avatarDecoration: avatarDecoration,
});
}
}
@bindThis
public async getAll(noCache = false): Promise<MiAvatarDecoration[]> {
if (noCache) {
this.cache.delete();
}
return this.cache.fetch(() => this.avatarDecorationsRepository.find());
}
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View file

@ -11,6 +11,7 @@ import { AnnouncementService } from './AnnouncementService.js';
import { AntennaService } from './AntennaService.js';
import { AppLockService } from './AppLockService.js';
import { AchievementService } from './AchievementService.js';
import { AvatarDecorationService } from './AvatarDecorationService.js';
import { CaptchaService } from './CaptchaService.js';
import { CreateSystemUserService } from './CreateSystemUserService.js';
import { CustomEmojiService } from './CustomEmojiService.js';
@ -141,6 +142,7 @@ const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExis
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService };
const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService };
const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService };
const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService };
const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService };
const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService };
@ -275,6 +277,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AntennaService,
AppLockService,
AchievementService,
AvatarDecorationService,
CaptchaService,
CreateSystemUserService,
CustomEmojiService,
@ -402,6 +405,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AntennaService,
$AppLockService,
$AchievementService,
$AvatarDecorationService,
$CaptchaService,
$CreateSystemUserService,
$CustomEmojiService,
@ -530,6 +534,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AntennaService,
AppLockService,
AchievementService,
AvatarDecorationService,
CaptchaService,
CreateSystemUserService,
CustomEmojiService,
@ -656,6 +661,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AntennaService,
$AppLockService,
$AchievementService,
$AvatarDecorationService,
$CaptchaService,
$CreateSystemUserService,
$CustomEmojiService,

View file

@ -18,7 +18,7 @@ import type { MiSignin } from '@/models/Signin.js';
import type { MiPage } from '@/models/Page.js';
import type { MiWebhook } from '@/models/Webhook.js';
import type { MiMeta } from '@/models/Meta.js';
import { MiRole, MiRoleAssignment } from '@/models/_.js';
import { MiAvatarDecoration, MiRole, MiRoleAssignment } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@ -188,6 +188,9 @@ export interface InternalEventTypes {
antennaCreated: MiAntenna;
antennaDeleted: MiAntenna;
antennaUpdated: MiAntenna;
avatarDecorationCreated: MiAvatarDecoration;
avatarDecorationDeleted: MiAvatarDecoration;
avatarDecorationUpdated: MiAvatarDecoration;
metaUpdated: MiMeta;
followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };

View file

@ -55,7 +55,6 @@ import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { nyaize } from '@/misc/nyaize.js';
import { UtilityService } from '@/core/UtilityService.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';

View file

@ -229,6 +229,12 @@ export class RoleService implements OnApplicationShutdown {
}
}
@bindThis
public async getRoles() {
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
return roles;
}
@bindThis
public async getUserAssigns(userId: MiUser['id']) {
const now = Date.now();

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
import type { MiInstance } from '@/models/Instance.js';
import { MetaService } from '@/core/MetaService.js';

View file

@ -73,7 +73,7 @@ export class NoteEntityService implements OnModuleInit {
@bindThis
private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null) {
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
let hide = false;
// visibility が specified かつ自分が指定されていなかったら非表示
@ -83,7 +83,7 @@ export class NoteEntityService implements OnModuleInit {
} else if (meId === packedNote.userId) {
hide = false;
} else {
// 指定されているかどうか
// 指定されているかどうか
const specified = packedNote.visibleUserIds!.some((id: any) => meId === id);
if (specified) {
@ -360,12 +360,14 @@ export class NoteEntityService implements OnModuleInit {
reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, {
detail: false,
skipHide: opts.skipHide,
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
_hint_: options?._hint_,
}) : undefined,
renote: note.renoteId ? this.pack(note.renote ?? note.renoteId, me, {
detail: true,
skipHide: opts.skipHide,
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
_hint_: options?._hint_,
}) : undefined,

View file

@ -21,9 +21,10 @@ import { RoleService } from '@/core/RoleService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { IdService } from '@/core/IdService.js';
import type { AnnouncementService } from '@/core/AnnouncementService.js';
import type { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { AnnouncementService } from '../AnnouncementService.js';
import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { NoteEntityService } from './NoteEntityService.js';
import type { DriveFileEntityService } from './DriveFileEntityService.js';
import type { PageEntityService } from './PageEntityService.js';
@ -62,6 +63,7 @@ export class UserEntityService implements OnModuleInit {
private roleService: RoleService;
private federatedInstanceService: FederatedInstanceService;
private idService: IdService;
private avatarDecorationService: AvatarDecorationService;
constructor(
private moduleRef: ModuleRef,
@ -126,6 +128,7 @@ export class UserEntityService implements OnModuleInit {
this.roleService = this.moduleRef.get('RoleService');
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
this.idService = this.moduleRef.get('IdService');
this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService');
}
//#region Validators
@ -322,9 +325,11 @@ export class UserEntityService implements OnModuleInit {
const isModerator = isMe && opts.detail ? this.roleService.isModerator(user) : null;
const isAdmin = isMe && opts.detail ? this.roleService.isAdministrator(user) : null;
const unreadAnnouncements = isMe && opts.detail ? await this.announcementService.getUnreadAnnouncements(user) : null;
const falsy = opts.detail ? false : undefined;
const unreadAnnouncements = isMe && opts.detail ?
(await this.announcementService.getUnreadAnnouncements(user)).map((announcement) => ({
createdAt: this.idService.parse(announcement.id).date.toISOString(),
...announcement,
})) : null;
const packed = {
id: user.id,
@ -333,6 +338,10 @@ export class UserEntityService implements OnModuleInit {
host: user.host,
avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user),
avatarBlurhash: user.avatarBlurhash,
avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => decorations.filter(decoration => user.avatarDecorations.includes(decoration.id)).map(decoration => ({
id: decoration.id,
url: decoration.url,
}))) : [],
isBot: user.isBot,
isCat: user.isCat,
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {

View file

@ -18,6 +18,7 @@ export const DI = {
announcementsRepository: Symbol('announcementsRepository'),
announcementReadsRepository: Symbol('announcementReadsRepository'),
appsRepository: Symbol('appsRepository'),
avatarDecorationsRepository: Symbol('avatarDecorationsRepository'),
noteFavoritesRepository: Symbol('noteFavoritesRepository'),
noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'),
noteReactionsRepository: Symbol('noteReactionsRepository'),

View file

@ -1,20 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function nyaize(text: string): string {
return text
// ja-JP
.replaceAll('な', 'にゃ').replaceAll('ナ', 'ニャ').replaceAll('ナ', 'ニャ')
// en-US
.replace(/(?<=n)a/gi, x => x === 'A' ? 'YA' : 'ya')
.replace(/(?<=morn)ing/gi, x => x === 'ING' ? 'YAN' : 'yan')
.replace(/(?<=every)one/gi, x => x === 'ONE' ? 'NYAN' : 'nyan')
// ko-KR
.replace(/[나-낳]/g, match => String.fromCharCode(
match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0),
))
.replace(/(다$)|(다(?=\.))|(다(?= ))|(다(?=!))|(다(?=\?))/gm, '다냥')
.replace(/(야(?=\?))|(야$)|(야(?= ))/gm, '냥');
}

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm';
import { id } from './util/id.js';
@Entity('avatar_decoration')
export class MiAvatarDecoration {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone', {
nullable: true,
})
public updatedAt: Date | null;
@Column('varchar', {
length: 1024,
})
public url: string;
@Column('varchar', {
length: 256,
})
public name: string;
@Column('varchar', {
length: 2048,
})
public description: string;
// TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする
@Column('varchar', {
array: true, length: 128, default: '{}',
})
public roleIdsThatCanBeUsedThisDecoration: string[];
}

View file

@ -5,7 +5,7 @@
import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiEmojiDraft, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './_.js';
import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiEmojiDraft, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './_.js';
import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common';
@ -39,6 +39,12 @@ const $appsRepository: Provider = {
inject: [DI.db],
};
const $avatarDecorationsRepository: Provider = {
provide: DI.avatarDecorationsRepository,
useFactory: (db: DataSource) => db.getRepository(MiAvatarDecoration),
inject: [DI.db],
};
const $noteFavoritesRepository: Provider = {
provide: DI.noteFavoritesRepository,
useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite),
@ -408,6 +414,7 @@ const $userMemosRepository: Provider = {
$announcementsRepository,
$announcementReadsRepository,
$appsRepository,
$avatarDecorationsRepository,
$noteFavoritesRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
@ -475,6 +482,7 @@ const $userMemosRepository: Provider = {
$announcementsRepository,
$announcementReadsRepository,
$appsRepository,
$avatarDecorationsRepository,
$noteFavoritesRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,

View file

@ -138,6 +138,11 @@ export class MiUser {
})
public bannerBlurhash: string | null;
@Column('varchar', {
length: 512, array: true, default: '{}',
})
public avatarDecorations: string[];
@Index()
@Column('varchar', {
length: 128, array: true, default: '{}',

View file

@ -10,6 +10,7 @@ import { MiAnnouncement } from '@/models/Announcement.js';
import { MiAnnouncementRead } from '@/models/AnnouncementRead.js';
import { MiAntenna } from '@/models/Antenna.js';
import { MiApp } from '@/models/App.js';
import { MiAvatarDecoration } from '@/models/AvatarDecoration.js';
import { MiAuthSession } from '@/models/AuthSession.js';
import { MiBlocking } from '@/models/Blocking.js';
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
@ -78,6 +79,7 @@ export {
MiAnnouncementRead,
MiAntenna,
MiApp,
MiAvatarDecoration,
MiAuthSession,
MiBlocking,
MiChannelFollowing,
@ -145,6 +147,7 @@ export type AnnouncementsRepository = Repository<MiAnnouncement>;
export type AnnouncementReadsRepository = Repository<MiAnnouncementRead>;
export type AntennasRepository = Repository<MiAntenna>;
export type AppsRepository = Repository<MiApp>;
export type AvatarDecorationsRepository = Repository<MiAvatarDecoration>;
export type AuthSessionsRepository = Repository<MiAuthSession>;
export type BlockingsRepository = Repository<MiBlocking>;
export type ChannelFollowingsRepository = Repository<MiChannelFollowing>;

View file

@ -37,6 +37,26 @@ export const packedUserLiteSchema = {
type: 'string',
nullable: true, optional: false,
},
avatarDecorations: {
type: 'array',
nullable: false, optional: false,
items: {
type: 'object',
nullable: false, optional: false,
properties: {
id: {
type: 'string',
nullable: false, optional: false,
format: 'id',
},
url: {
type: 'string',
format: 'url',
nullable: false, optional: false,
},
},
},
},
isAdmin: {
type: 'boolean',
nullable: false, optional: true,

View file

@ -18,6 +18,7 @@ import { MiAnnouncement } from '@/models/Announcement.js';
import { MiAnnouncementRead } from '@/models/AnnouncementRead.js';
import { MiAntenna } from '@/models/Antenna.js';
import { MiApp } from '@/models/App.js';
import { MiAvatarDecoration } from '@/models/AvatarDecoration.js';
import { MiAuthSession } from '@/models/AuthSession.js';
import { MiBlocking } from '@/models/Blocking.js';
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
@ -130,6 +131,7 @@ export const entities = [
MiMeta,
MiInstance,
MiApp,
MiAvatarDecoration,
MiAuthSession,
MiAccessToken,
MiUser,

View file

@ -18,6 +18,10 @@ import * as ep___admin_announcements_create from './endpoints/admin/announcement
import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js';
import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js';
import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js';
import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-decorations/create.js';
import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js';
import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js';
import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js';
import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js';
import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js';
import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js';
@ -163,6 +167,7 @@ import * as ep___federation_stats from './endpoints/federation/stats.js';
import * as ep___following_create from './endpoints/following/create.js';
import * as ep___following_delete from './endpoints/following/delete.js';
import * as ep___following_update from './endpoints/following/update.js';
import * as ep___following_update_all from './endpoints/following/update-all.js';
import * as ep___following_invalidate from './endpoints/following/invalidate.js';
import * as ep___following_requests_accept from './endpoints/following/requests/accept.js';
import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js';
@ -178,6 +183,7 @@ import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js';
import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js';
import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js';
import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js';
import * as ep___getAvatarDecorations from './endpoints/get-avatar-decorations.js';
import * as ep___hashtags_list from './endpoints/hashtags/list.js';
import * as ep___hashtags_search from './endpoints/hashtags/search.js';
import * as ep___hashtags_show from './endpoints/hashtags/show.js';
@ -354,6 +360,7 @@ import * as ep___users_show from './endpoints/users/show.js';
import * as ep___users_achievements from './endpoints/users/achievements.js';
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
import * as ep___fetchRss from './endpoints/fetch-rss.js';
import * as ep___fetchExternalResources from './endpoints/fetch-external-resources.js';
import * as ep___retention from './endpoints/retention.js';
import { GetterService } from './GetterService.js';
import { ApiLoggerService } from './ApiLoggerService.js';
@ -371,6 +378,10 @@ const $admin_announcements_create: Provider = { provide: 'ep:admin/announcements
const $admin_announcements_delete: Provider = { provide: 'ep:admin/announcements/delete', useClass: ep___admin_announcements_delete.default };
const $admin_announcements_list: Provider = { provide: 'ep:admin/announcements/list', useClass: ep___admin_announcements_list.default };
const $admin_announcements_update: Provider = { provide: 'ep:admin/announcements/update', useClass: ep___admin_announcements_update.default };
const $admin_avatarDecorations_create: Provider = { provide: 'ep:admin/avatar-decorations/create', useClass: ep___admin_avatarDecorations_create.default };
const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default };
const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default };
const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default };
const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default };
const $admin_drive_cleanRemoteFiles: Provider = { provide: 'ep:admin/drive/clean-remote-files', useClass: ep___admin_drive_cleanRemoteFiles.default };
const $admin_drive_cleanup: Provider = { provide: 'ep:admin/drive/cleanup', useClass: ep___admin_drive_cleanup.default };
@ -516,6 +527,7 @@ const $federation_stats: Provider = { provide: 'ep:federation/stats', useClass:
const $following_create: Provider = { provide: 'ep:following/create', useClass: ep___following_create.default };
const $following_delete: Provider = { provide: 'ep:following/delete', useClass: ep___following_delete.default };
const $following_update: Provider = { provide: 'ep:following/update', useClass: ep___following_update.default };
const $following_update_all: Provider = { provide: 'ep:following/update-all', useClass: ep___following_update_all.default };
const $following_invalidate: Provider = { provide: 'ep:following/invalidate', useClass: ep___following_invalidate.default };
const $following_requests_accept: Provider = { provide: 'ep:following/requests/accept', useClass: ep___following_requests_accept.default };
const $following_requests_cancel: Provider = { provide: 'ep:following/requests/cancel', useClass: ep___following_requests_cancel.default };
@ -531,6 +543,7 @@ const $gallery_posts_show: Provider = { provide: 'ep:gallery/posts/show', useCla
const $gallery_posts_unlike: Provider = { provide: 'ep:gallery/posts/unlike', useClass: ep___gallery_posts_unlike.default };
const $gallery_posts_update: Provider = { provide: 'ep:gallery/posts/update', useClass: ep___gallery_posts_update.default };
const $getOnlineUsersCount: Provider = { provide: 'ep:get-online-users-count', useClass: ep___getOnlineUsersCount.default };
const $getAvatarDecorations: Provider = { provide: 'ep:get-avatar-decorations', useClass: ep___getAvatarDecorations.default };
const $hashtags_list: Provider = { provide: 'ep:hashtags/list', useClass: ep___hashtags_list.default };
const $hashtags_search: Provider = { provide: 'ep:hashtags/search', useClass: ep___hashtags_search.default };
const $hashtags_show: Provider = { provide: 'ep:hashtags/show', useClass: ep___hashtags_show.default };
@ -707,6 +720,7 @@ const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_s
const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default };
const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default };
const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default };
const $fetchExternalResources: Provider = { provide: 'ep:fetch-external-resources', useClass: ep___fetchExternalResources.default };
const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default };
@Module({
@ -728,6 +742,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_announcements_delete,
$admin_announcements_list,
$admin_announcements_update,
$admin_avatarDecorations_create,
$admin_avatarDecorations_delete,
$admin_avatarDecorations_list,
$admin_avatarDecorations_update,
$admin_deleteAllFilesOfAUser,
$admin_drive_cleanRemoteFiles,
$admin_drive_cleanup,
@ -873,6 +891,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$following_create,
$following_delete,
$following_update,
$following_update_all,
$following_invalidate,
$following_requests_accept,
$following_requests_cancel,
@ -888,6 +907,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$gallery_posts_unlike,
$gallery_posts_update,
$getOnlineUsersCount,
$getAvatarDecorations,
$hashtags_list,
$hashtags_search,
$hashtags_show,
@ -1064,6 +1084,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_achievements,
$users_updateMemo,
$fetchRss,
$fetchExternalResources,
$retention,
],
exports: [
@ -1079,6 +1100,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_announcements_delete,
$admin_announcements_list,
$admin_announcements_update,
$admin_avatarDecorations_create,
$admin_avatarDecorations_delete,
$admin_avatarDecorations_list,
$admin_avatarDecorations_update,
$admin_deleteAllFilesOfAUser,
$admin_drive_cleanRemoteFiles,
$admin_drive_cleanup,
@ -1224,6 +1249,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$following_create,
$following_delete,
$following_update,
$following_update_all,
$following_invalidate,
$following_requests_accept,
$following_requests_cancel,
@ -1239,6 +1265,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$gallery_posts_unlike,
$gallery_posts_update,
$getOnlineUsersCount,
$getAvatarDecorations,
$hashtags_list,
$hashtags_search,
$hashtags_show,
@ -1412,6 +1439,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_achievements,
$users_updateMemo,
$fetchRss,
$fetchExternalResources,
$retention,
],
})

View file

@ -18,6 +18,10 @@ import * as ep___admin_announcements_create from './endpoints/admin/announcement
import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js';
import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js';
import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js';
import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-decorations/create.js';
import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js';
import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js';
import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js';
import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js';
import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js';
import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js';
@ -163,6 +167,7 @@ import * as ep___federation_stats from './endpoints/federation/stats.js';
import * as ep___following_create from './endpoints/following/create.js';
import * as ep___following_delete from './endpoints/following/delete.js';
import * as ep___following_update from './endpoints/following/update.js';
import * as ep___following_update_all from './endpoints/following/update-all.js';
import * as ep___following_invalidate from './endpoints/following/invalidate.js';
import * as ep___following_requests_accept from './endpoints/following/requests/accept.js';
import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js';
@ -178,6 +183,7 @@ import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js';
import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js';
import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js';
import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js';
import * as ep___getAvatarDecorations from './endpoints/get-avatar-decorations.js';
import * as ep___hashtags_list from './endpoints/hashtags/list.js';
import * as ep___hashtags_search from './endpoints/hashtags/search.js';
import * as ep___hashtags_show from './endpoints/hashtags/show.js';
@ -354,6 +360,7 @@ import * as ep___users_show from './endpoints/users/show.js';
import * as ep___users_achievements from './endpoints/users/achievements.js';
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
import * as ep___fetchRss from './endpoints/fetch-rss.js';
import * as ep___fetchExternalResources from './endpoints/fetch-external-resources.js';
import * as ep___retention from './endpoints/retention.js';
const eps = [
@ -369,6 +376,10 @@ const eps = [
['admin/announcements/delete', ep___admin_announcements_delete],
['admin/announcements/list', ep___admin_announcements_list],
['admin/announcements/update', ep___admin_announcements_update],
['admin/avatar-decorations/create', ep___admin_avatarDecorations_create],
['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete],
['admin/avatar-decorations/list', ep___admin_avatarDecorations_list],
['admin/avatar-decorations/update', ep___admin_avatarDecorations_update],
['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser],
['admin/drive/clean-remote-files', ep___admin_drive_cleanRemoteFiles],
['admin/drive/cleanup', ep___admin_drive_cleanup],
@ -514,6 +525,7 @@ const eps = [
['following/create', ep___following_create],
['following/delete', ep___following_delete],
['following/update', ep___following_update],
['following/update-all', ep___following_update_all],
['following/invalidate', ep___following_invalidate],
['following/requests/accept', ep___following_requests_accept],
['following/requests/cancel', ep___following_requests_cancel],
@ -529,6 +541,7 @@ const eps = [
['gallery/posts/unlike', ep___gallery_posts_unlike],
['gallery/posts/update', ep___gallery_posts_update],
['get-online-users-count', ep___getOnlineUsersCount],
['get-avatar-decorations', ep___getAvatarDecorations],
['hashtags/list', ep___hashtags_list],
['hashtags/search', ep___hashtags_search],
['hashtags/show', ep___hashtags_show],
@ -705,6 +718,7 @@ const eps = [
['users/achievements', ep___users_achievements],
['users/update-memo', ep___users_updateMemo],
['fetch-rss', ep___fetchRss],
['fetch-external-resources', ep___fetchExternalResources],
['retention', ep___retention],
];

View file

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
} as const;
export const paramDef = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
description: { type: 'string' },
url: { type: 'string', minLength: 1 },
roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: {
type: 'string',
} },
},
required: ['name', 'description', 'url'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private avatarDecorationService: AvatarDecorationService,
) {
super(meta, paramDef, async (ps, me) => {
await this.avatarDecorationService.create({
name: ps.name,
description: ps.description,
url: ps.url,
roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration,
}, me);
});
}
}

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
id: { type: 'string', format: 'misskey:id' },
},
required: ['id'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private avatarDecorationService: AvatarDecorationService,
) {
super(meta, paramDef, async (ps, me) => {
await this.avatarDecorationService.delete(ps.id, me);
});
}
}

View file

@ -0,0 +1,101 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/_.js';
import type { MiAnnouncement } from '@/models/Announcement.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
example: 'xxxxxxxxxx',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
updatedAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
name: {
type: 'string',
optional: false, nullable: false,
},
description: {
type: 'string',
optional: false, nullable: false,
},
url: {
type: 'string',
optional: false, nullable: false,
},
roleIdsThatCanBeUsedThisDecoration: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
userId: { type: 'string', format: 'misskey:id', nullable: true },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private avatarDecorationService: AvatarDecorationService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const avatarDecorations = await this.avatarDecorationService.getAll(true);
return avatarDecorations.map(avatarDecoration => ({
id: avatarDecoration.id,
createdAt: this.idService.parse(avatarDecoration.id).date.toISOString(),
updatedAt: avatarDecoration.updatedAt?.toISOString() ?? null,
name: avatarDecoration.name,
description: avatarDecoration.description,
url: avatarDecoration.url,
roleIdsThatCanBeUsedThisDecoration: avatarDecoration.roleIdsThatCanBeUsedThisDecoration,
}));
});
}
}

View file

@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
id: { type: 'string', format: 'misskey:id' },
name: { type: 'string', minLength: 1 },
description: { type: 'string' },
url: { type: 'string', minLength: 1 },
roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: {
type: 'string',
} },
},
required: ['id'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private avatarDecorationService: AvatarDecorationService,
) {
super(meta, paramDef, async (ps, me) => {
await this.avatarDecorationService.update(ps.id, {
name: ps.name,
description: ps.description,
url: ps.url,
roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration,
}, me);
});
}
}

View file

@ -0,0 +1,72 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createHash } from 'crypto';
import ms from 'ms';
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { ApiError } from '../error.js';
export const meta = {
tags: ['meta'],
requireCredential: true,
limit: {
duration: ms('1hour'),
max: 50,
},
errors: {
invalidSchema: {
message: 'External resource returned invalid schema.',
code: 'EXT_RESOURCE_RETURNED_INVALID_SCHEMA',
id: 'bb774091-7a15-4a70-9dc5-6ac8cf125856',
},
hashUnmached: {
message: 'Hash did not match.',
code: 'EXT_RESOURCE_HASH_DIDNT_MATCH',
id: '693ba8ba-b486-40df-a174-72f8279b56a4',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
url: { type: 'string' },
hash: { type: 'string' },
},
required: ['url', 'hash'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private httpRequestService: HttpRequestService,
) {
super(meta, paramDef, async (ps) => {
const res = await this.httpRequestService.getJson<{
type: string;
data: string;
}>(ps.url);
if (!res.data || !res.type) {
throw new ApiError(meta.errors.invalidSchema);
}
const resHash = createHash('sha512').update(res.data.replace(/\r\n/g, '\n')).digest('hex');
if (resHash !== ps.hash) {
throw new ApiError(meta.errors.hashUnmached);
}
return {
type: res.type,
data: res.data,
};
});
}
}

View file

@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import ms from 'ms';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { FollowingsRepository } from '@/models/_.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['following', 'users'],
limit: {
duration: ms('1hour'),
max: 10,
},
requireCredential: true,
kind: 'write:following',
} as const;
export const paramDef = {
type: 'object',
properties: {
notify: { type: 'string', enum: ['normal', 'none'] },
withReplies: { type: 'boolean' },
},
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
) {
super(meta, paramDef, async (ps, me) => {
await this.followingsRepository.update({
followerId: me.id,
}, {
notify: ps.notify != null ? (ps.notify === 'none' ? null : ps.notify) : undefined,
withReplies: ps.withReplies != null ? ps.withReplies : undefined,
});
return;
});
}
}

View file

@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { IsNull } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
export const meta = {
tags: ['users'],
requireCredential: false,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
example: 'xxxxxxxxxx',
},
name: {
type: 'string',
optional: false, nullable: false,
},
description: {
type: 'string',
optional: false, nullable: false,
},
url: {
type: 'string',
optional: false, nullable: false,
},
roleIdsThatCanBeUsedThisDecoration: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private avatarDecorationService: AvatarDecorationService,
) {
super(meta, paramDef, async (ps, me) => {
const decorations = await this.avatarDecorationService.getAll(true);
return decorations.map(decoration => ({
id: decoration.id,
name: decoration.name,
description: decoration.description,
url: decoration.url,
roleIdsThatCanBeUsedThisDecoration: decoration.roleIdsThatCanBeUsedThisDecoration,
}));
});
}
}

View file

@ -32,6 +32,7 @@ import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.j
import { HttpRequestService } from '@/core/HttpRequestService.js';
import type { Config } from '@/config.js';
import { safeForSql } from '@/misc/safe-for-sql.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
import { ApiLoggerService } from '../../ApiLoggerService.js';
import { ApiError } from '../../error.js';
@ -131,6 +132,9 @@ export const paramDef = {
birthday: { ...birthdaySchema, nullable: true },
lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true },
avatarId: { type: 'string', format: 'misskey:id', nullable: true },
avatarDecorations: { type: 'array', maxItems: 1, items: {
type: 'string',
} },
bannerId: { type: 'string', format: 'misskey:id', nullable: true },
fields: {
type: 'array',
@ -207,6 +211,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService,
private cacheService: CacheService,
private httpRequestService: HttpRequestService,
private avatarDecorationService: AvatarDecorationService,
) {
super(meta, paramDef, async (ps, _user, token) => {
const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser;
@ -296,6 +301,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
updates.bannerBlurhash = null;
}
if (ps.avatarDecorations) {
const decorations = await this.avatarDecorationService.getAll(true);
const myRoles = await this.roleService.getUserRoles(user.id);
const allRoles = await this.roleService.getRoles();
const decorationIds = decorations
.filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id)))
.map(d => d.id);
updates.avatarDecorations = ps.avatarDecorations.filter(id => decorationIds.includes(id));
}
if (ps.pinnedPageId) {
const page = await this.pagesRepository.findOneBy({ id: ps.pinnedPageId });
@ -421,9 +437,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const myLink = `${this.config.url}/@${user.username}`;
const includesMyLink = Array.from(doc.getElementsByTagName('a')).some(a => a.href === myLink);
const aEls = Array.from(doc.getElementsByTagName('a'));
const linkEls = Array.from(doc.getElementsByTagName('link'));
if (includesMyLink) {
const includesMyLink = aEls.some(a => a.href === myLink);
const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink);
if (includesMyLink || includesRelMeLinks) {
await this.userProfilesRepository.createQueryBuilder('profile').update()
.where('userId = :userId', { userId: user.id })
.set({

View file

@ -3,12 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { NotesRepository, UserListsRepository, UserListMembershipsRepository, MiNote } from '@/models/_.js';
import type { NotesRepository, UserListsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js';
@ -67,9 +64,6 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,

View file

@ -39,20 +39,22 @@ class HomeTimelineChannel extends Channel {
@bindThis
private async onNote(note: Packed<'Note'>) {
const isMe = this.user!.id === note.userId;
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (note.channelId) {
if (!this.followingChannels.has(note.channelId)) return;
} else {
// その投稿のユーザーをフォローしていなかったら弾く
if ((this.user!.id !== note.userId) && !Object.hasOwn(this.following, note.userId)) return;
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
}
// Ignore notes from instances the user has muted
if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances))) return;
if (note.visibility === 'followers') {
if (!Object.hasOwn(this.following, note.userId)) return;
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
} else if (note.visibility === 'specified') {
if (!note.visibleUserIds!.includes(this.user!.id)) return;
}
@ -61,7 +63,7 @@ class HomeTimelineChannel extends Channel {
if (note.reply && !this.following[note.userId]?.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;

View file

@ -49,6 +49,8 @@ class HybridTimelineChannel extends Channel {
@bindThis
private async onNote(note: Packed<'Note'>) {
const isMe = this.user!.id === note.userId;
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
// チャンネルの投稿ではなく、自分自身の投稿 または
@ -56,14 +58,14 @@ class HybridTimelineChannel extends Channel {
// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
// フォローしているチャンネルの投稿 の場合だけ
if (!(
(note.channelId == null && this.user!.id === note.userId) ||
(note.channelId == null && isMe) ||
(note.channelId == null && Object.hasOwn(this.following, note.userId)) ||
(note.channelId == null && (note.user.host == null && note.visibility === 'public')) ||
(note.channelId != null && this.followingChannels.has(note.channelId))
)) return;
if (note.visibility === 'followers') {
if (!Object.hasOwn(this.following, note.userId)) return;
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
} else if (note.visibility === 'specified') {
if (!note.visibleUserIds!.includes(this.user!.id)) return;
}
@ -75,7 +77,7 @@ class HybridTimelineChannel extends Channel {
if (note.reply && !this.following[note.userId]?.withReplies && !this.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;

View file

@ -78,12 +78,14 @@ class UserListChannel extends Channel {
@bindThis
private async onNote(note: Packed<'Note'>) {
const isMe = this.user!.id === note.userId;
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!Object.hasOwn(this.membershipsMap, note.userId)) return;
if (note.visibility === 'followers') {
if (!Object.hasOwn(this.following, note.userId)) return;
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
} else if (note.visibility === 'specified') {
if (!note.visibleUserIds!.includes(this.user!.id)) return;
}
@ -92,7 +94,7 @@ class UserListChannel extends Channel {
if (note.reply && !this.membershipsMap[note.userId]?.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する

View file

@ -61,6 +61,9 @@ export const moderationLogTypes = [
'createAd',
'updateAd',
'deleteAd',
'createAvatarDecoration',
'updateAvatarDecoration',
'deleteAvatarDecoration',
] as const;
export type ModerationLogPayloads = {
@ -226,6 +229,19 @@ export type ModerationLogPayloads = {
adId: string;
ad: any;
};
createAvatarDecoration: {
avatarDecorationId: string;
avatarDecoration: any;
};
updateAvatarDecoration: {
avatarDecorationId: string;
before: any;
after: any;
};
deleteAvatarDecoration: {
avatarDecorationId: string;
avatarDecoration: any;
};
};
export type Serialized<T> = {

View file

@ -115,6 +115,16 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
test('自分の visibility: followers な投稿が流れる', async () => {
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:Home
() => api('notes/create', { text: 'foo', visibility: 'followers' }, ayano), // ayano posts
msg => msg.type === 'note' && msg.body.text === 'foo',
);
assert.strictEqual(fired, true);
});
test('フォローしているユーザーの投稿が流れる', async () => {
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
@ -125,6 +135,30 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
test('フォローしているユーザーの visibility: followers な投稿が流れる', async () => {
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko), // kyoko posts
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
);
assert.strictEqual(fired, true);
});
/*
test('フォローしているユーザーの visibility: followers な投稿への返信が流れる', async () => {
const note = await api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko);
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { text: 'bar', visibility: 'followers', replyId: note.body.id }, kyoko), // kyoko posts
msg => msg.type === 'note' && msg.body.userId === kyoko.id && msg.body.reply.text === 'foo',
);
assert.strictEqual(fired, true);
});
*/
test('フォローしていないユーザーの投稿は流れない', async () => {
const fired = await waitFire(
kyoko, 'homeTimeline', // kyoko:home
@ -241,6 +275,16 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
test('自分の visibility: followers な投稿が流れる', async () => {
const fired = await waitFire(
ayano, 'hybridTimeline',
() => api('notes/create', { text: 'foo', visibility: 'followers' }, ayano), // ayano posts
msg => msg.type === 'note' && msg.body.text === 'foo',
);
assert.strictEqual(fired, true);
});
test('フォローしていないローカルユーザーの投稿が流れる', async () => {
const fired = await waitFire(
ayano, 'hybridTimeline', // ayano:Hybrid
@ -293,6 +337,16 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
test('フォローしているユーザーの visibility: followers な投稿が流れる', async () => {
const fired = await waitFire(
ayano, 'hybridTimeline', // ayano:Hybrid
() => api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko),
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
);
assert.strictEqual(fired, true);
});
test('フォローしていないローカルユーザーのホーム投稿は流れない', async () => {
const fired = await waitFire(
ayano, 'hybridTimeline', // ayano:Hybrid

View file

@ -68,6 +68,7 @@ describe('ユーザー', () => {
host: user.host,
avatarUrl: user.avatarUrl,
avatarBlurhash: user.avatarBlurhash,
avatarDecorations: user.avatarDecorations,
isBot: user.isBot,
isCat: user.isCat,
instance: user.instance,
@ -349,6 +350,7 @@ describe('ユーザー', () => {
assert.strictEqual(response.host, null);
assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
assert.strictEqual(response.avatarBlurhash, null);
assert.deepStrictEqual(response.avatarDecorations, []);
assert.strictEqual(response.isBot, false);
assert.strictEqual(response.isCat, false);
assert.strictEqual(response.instance, undefined);

View file

@ -74,6 +74,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
onlineStatus: 'unknown',
avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
avatarDecorations: [],
emojis: [],
bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog',
bannerColor: '#000000',

View file

@ -26,7 +26,7 @@
"@tabler/icons-webfont": "2.37.0",
"@vitejs/plugin-vue": "4.4.0",
"@vue-macros/reactivity-transform": "0.3.23",
"@vue/compiler-sfc": "3.3.4",
"@vue/compiler-sfc": "3.3.5",
"astring": "1.8.6",
"autosize": "6.0.1",
"broadcast-channel": "5.5.0",
@ -73,29 +73,29 @@
"v-code-diff": "1.7.1",
"vanilla-tilt": "1.8.1",
"vite": "4.5.0",
"vue": "3.3.4",
"vue": "3.3.5",
"vue-prism-editor": "2.0.0-alpha.2",
"vuedraggable": "next"
},
"devDependencies": {
"@storybook/addon-actions": "7.5.0",
"@storybook/addon-essentials": "7.5.0",
"@storybook/addon-interactions": "7.5.0",
"@storybook/addon-links": "7.5.0",
"@storybook/addon-storysource": "7.5.0",
"@storybook/addons": "7.5.0",
"@storybook/blocks": "7.5.0",
"@storybook/core-events": "7.5.0",
"@storybook/addon-actions": "7.5.1",
"@storybook/addon-essentials": "7.5.1",
"@storybook/addon-interactions": "7.5.1",
"@storybook/addon-links": "7.5.1",
"@storybook/addon-storysource": "7.5.1",
"@storybook/addons": "7.5.1",
"@storybook/blocks": "7.5.1",
"@storybook/core-events": "7.5.1",
"@storybook/jest": "0.2.3",
"@storybook/manager-api": "7.5.0",
"@storybook/preview-api": "7.5.0",
"@storybook/react": "7.5.0",
"@storybook/react-vite": "7.5.0",
"@storybook/manager-api": "7.5.1",
"@storybook/preview-api": "7.5.1",
"@storybook/react": "7.5.1",
"@storybook/react-vite": "7.5.1",
"@storybook/testing-library": "0.2.2",
"@storybook/theming": "7.5.0",
"@storybook/types": "7.5.0",
"@storybook/vue3": "7.5.0",
"@storybook/vue3-vite": "7.5.0",
"@storybook/theming": "7.5.1",
"@storybook/types": "7.5.1",
"@storybook/vue3": "7.5.1",
"@storybook/vue3-vite": "7.5.1",
"@testing-library/vue": "7.0.0",
"@types/escape-regexp": "0.0.2",
"@types/estree": "1.0.3",
@ -112,7 +112,7 @@
"@typescript-eslint/eslint-plugin": "6.8.0",
"@typescript-eslint/parser": "6.8.0",
"@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.3.4",
"@vue/runtime-core": "3.3.5",
"acorn": "8.10.0",
"cross-env": "7.0.3",
"cypress": "13.3.2",
@ -129,7 +129,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"start-server-and-test": "2.0.1",
"storybook": "7.5.0",
"storybook": "7.5.1",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly",
"vite-plugin-turbosnap": "1.0.3",

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :style="{ background: bgCss }">
<img :class="$style.icon" :src="`/avatar/@${username}@${host}`" alt="">
<img :class="$style.icon" :src="avatarUrl" alt="">
<span>
<span>@{{ username }}</span>
<span v-if="(host != localHost) || defaultStore.state.showFullAcct" :class="$style.host">@{{ toUnicode(host) }}</span>
@ -15,11 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { toUnicode } from 'punycode';
import { } from 'vue';
import { computed } from 'vue';
import tinycolor from 'tinycolor2';
import { host as localHost } from '@/config.js';
import { $i } from '@/account.js';
import { defaultStore } from '@/store.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
const props = defineProps<{
username: string;
@ -37,6 +38,11 @@ const isMe = $i && (
const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention'));
bg.setAlpha(0.1);
const bgCss = bg.toRgbString();
const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages
? getStaticImageUrl(`/avatar/@${props.username}@${props.host}`)
: `/avatar/@${props.username}@${props.host}`,
);
</script>
<style lang="scss" module>

View file

@ -94,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<footer>
<div :class="$style.noteFooterInfo">
<MkA :to="notePage(appearNote)">
<MkTime :time="appearNote.createdAt" mode="detail"/>
<MkTime :time="appearNote.createdAt" mode="detail" colored/>
</MkA>
</div>
<MkReactionsViewer ref="reactionsViewer" :note="appearNote"/>

View file

@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.info">
<MkA :to="notePage(note)">
<MkTime :time="note.createdAt"/>
<MkTime :time="note.createdAt" colored/>
</MkA>
<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
<i v-if="note.visibility === 'home'" class="ti ti-home"></i>

View file

@ -283,6 +283,12 @@ useTooltip(reactionRef, (showing) => {
.quote:first-child {
margin-right: 4px;
position: relative;
&:before {
position: absolute;
transform: rotate(180deg);
}
}
.quote:last-child {

View file

@ -41,7 +41,7 @@ const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
const pagination: Paging = {
endpoint: 'i/notifications' as const,
limit: 10,
limit: 20,
params: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined,
})),

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
<MkImgWithBlurhash :class="$style.inner" :src="url" :hash="user?.avatarBlurhash" :cover="true" :onlyAvgColor="true"/>
<MkImgWithBlurhash :class="$style.inner" :src="url" :hash="user.avatarBlurhash" :cover="true" :onlyAvgColor="true"/>
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
<div v-if="user.isCat" :class="[$style.ears]">
<div :class="$style.earLeft">
@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
<img v-if="decoration || user.avatarDecorations.length > 0" :class="[$style.decoration]" :src="decoration ?? user.avatarDecorations[0].url" alt="">
</component>
</template>
@ -47,6 +48,7 @@ const props = withDefaults(defineProps<{
link?: boolean;
preview?: boolean;
indicator?: boolean;
decoration?: string;
}>(), {
target: null,
link: false,
@ -134,7 +136,7 @@ watch(() => props.user.avatarBlurhash, () => {
.indicator {
position: absolute;
z-index: 1;
z-index: 2;
bottom: 0;
left: 0;
width: 20%;
@ -278,4 +280,13 @@ watch(() => props.user.avatarBlurhash, () => {
}
}
}
.decoration {
position: absolute;
z-index: 1;
top: -50%;
left: -50%;
width: 200%;
pointer-events: none;
}
</style>

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<time :title="absolute">
<time :title="absolute" :class="{ [$style.old1]: colored && (ago > 60 * 60 * 24 * 90), [$style.old2]: colored && (ago > 60 * 60 * 24 * 180) }">
<template v-if="invalid">{{ i18n.ts._ago.invalid }}</template>
<template v-else-if="mode === 'relative'">{{ relative }}</template>
<template v-else-if="mode === 'absolute'">{{ absolute }}</template>
@ -22,6 +22,7 @@ const props = withDefaults(defineProps<{
time: Date | string | number | null;
origin?: Date | null;
mode?: 'relative' | 'absolute' | 'detail';
colored?: boolean;
}>(), {
origin: isChromatic() ? new Date('2023-04-01T00:00:00Z') : null,
mode: 'relative',
@ -75,3 +76,13 @@ if (!invalid && props.origin === null && (props.mode === 'relative' || props.mod
});
}
</script>
<style lang="scss" module>
.old1 {
color: var(--warn);
}
.old1.old2 {
color: var(--error);
}
</style>

View file

@ -31,23 +31,28 @@ import * as os from '@/os.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
import { safeURIDecode } from '@/scripts/safe-uri-decode.js';
const props = defineProps<{
const props = withDefaults(defineProps<{
url: string;
rel?: string;
}>();
showUrlPreview?: boolean;
}>(), {
showUrlPreview: true,
});
const self = props.url.startsWith(local);
const url = new URL(props.url);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
const el = ref();
useTooltip(el, (showing) => {
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
showing,
url: props.url,
source: el.value,
}, {}, 'closed');
});
if (props.showUrlPreview) {
useTooltip(el, (showing) => {
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
showing,
url: props.url,
source: el.value,
}, {}, 'closed');
});
}
const schema = url.protocol;
const hostname = decodePunycode(url.hostname);

View file

@ -0,0 +1,103 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
<div class="_gaps">
<MkFolder v-for="avatarDecoration in avatarDecorations" :key="avatarDecoration.id ?? avatarDecoration._id" :defaultOpen="avatarDecoration.id == null">
<template #label>{{ avatarDecoration.name }}</template>
<template #caption>{{ avatarDecoration.description }}</template>
<div class="_gaps_m">
<MkInput v-model="avatarDecoration.name">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkTextarea v-model="avatarDecoration.description">
<template #label>{{ i18n.ts.description }}</template>
</MkTextarea>
<MkInput v-model="avatarDecoration.url">
<template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput>
<div class="buttons _buttons">
<MkButton class="button" inline primary @click="save(avatarDecoration)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="avatarDecoration.id != null" class="button" inline danger @click="del(avatarDecoration)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</div>
</MkFolder>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
import XHeader from './_header_.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkFolder from '@/components/MkFolder.vue';
let avatarDecorations: any[] = $ref([]);
function add() {
avatarDecorations.unshift({
_id: Math.random().toString(36),
id: null,
name: '',
description: '',
url: '',
});
}
function del(avatarDecoration) {
os.confirm({
type: 'warning',
text: i18n.t('deleteAreYouSure', { x: avatarDecoration.name }),
}).then(({ canceled }) => {
if (canceled) return;
avatarDecorations = avatarDecorations.filter(x => x !== avatarDecoration);
os.api('admin/avatar-decorations/delete', avatarDecoration);
});
}
async function save(avatarDecoration) {
if (avatarDecoration.id == null) {
await os.apiWithDialog('admin/avatar-decorations/create', avatarDecoration);
load();
} else {
os.apiWithDialog('admin/avatar-decorations/update', avatarDecoration);
}
}
function load() {
os.api('admin/avatar-decorations/list').then(_avatarDecorations => {
avatarDecorations = _avatarDecorations;
});
}
load();
const headerActions = $computed(() => [{
asFullButton: true,
icon: 'ti ti-plus',
text: i18n.ts.add,
handler: add,
}]);
const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.avatarDecorations,
icon: 'ti ti-sparkles',
});
</script>

View file

@ -115,6 +115,11 @@ const menuDef = $computed(() => [{
text: i18n.ts.customEmojis,
to: '/admin/emojis',
active: currentPage?.route.name === 'emojis',
}, {
icon: 'ti ti-sparkles',
text: i18n.ts.avatarDecorations,
to: '/admin/avatar-decorations',
active: currentPage?.route.name === 'avatarDecorations',
}, {
icon: 'ti ti-whirl',
text: i18n.ts.federation,

View file

@ -8,9 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>
<b
:class="{
[$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation'].includes(log.type),
[$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation', 'createAvatarDecoration'].includes(log.type),
[$style.logYellow]: ['markSensitiveDriveFile', 'resetPassword'].includes(log.type),
[$style.logRed]: ['suspend', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd'].includes(log.type)
[$style.logRed]: ['suspend', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd', 'deleteAvatarDecoration'].includes(log.type)
}"
>{{ i18n.ts._moderationLogTypes[log.type] }}</b>
<span v-if="log.type === 'updateUserNote'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
@ -37,6 +37,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'deleteUserAnnouncement'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'deleteNote'">: @{{ log.info.noteUserUsername }}{{ log.info.noteUserHost ? '@' + log.info.noteUserHost : '' }}</span>
<span v-else-if="log.type === 'deleteDriveFile'">: @{{ log.info.fileUserUsername }}{{ log.info.fileUserHost ? '@' + log.info.fileUserHost : '' }}</span>
<span v-else-if="log.type === 'createAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
<span v-else-if="log.type === 'updateAvatarDecoration'">: {{ log.info.before.name }}</span>
<span v-else-if="log.type === 'deleteAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
</template>
<template #icon>
<MkAvatar :user="log.user" :class="$style.avatar"/>
@ -102,6 +105,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
</div>
</template>
<template v-else-if="log.type === 'updateAvatarDecoration'">
<div :class="$style.diff">
<CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
</div>
</template>
<details>
<summary>raw</summary>

View file

@ -0,0 +1,354 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="500">
<MkLoading v-if="uiPhase === 'fetching'"/>
<div v-else-if="uiPhase === 'confirm' && data" class="_gaps_m" :class="$style.extInstallerRoot">
<div :class="$style.extInstallerIconWrapper">
<i v-if="data.type === 'plugin'" class="ti ti-plug"></i>
<i v-else-if="data.type === 'theme'" class="ti ti-palette"></i>
<i v-else class="ti ti-download"></i>
</div>
<h2 :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller[`_${data.type}`].title }}</h2>
<div :class="$style.extInstallerNormDesc">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</div>
<MkInfo v-if="data.type === 'plugin'" :warn="true">{{ i18n.ts._plugin.installWarn }}</MkInfo>
<FormSection>
<template #label>{{ i18n.ts._externalResourceInstaller[`_${data.type}`].metaTitle }}</template>
<div class="_gaps_s">
<FormSplit>
<MkKeyValue>
<template #key>{{ i18n.ts.name }}</template>
<template #value>{{ data.meta?.name }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.author }}</template>
<template #value>{{ data.meta?.author }}</template>
</MkKeyValue>
</FormSplit>
<MkKeyValue v-if="data.type === 'plugin'">
<template #key>{{ i18n.ts.description }}</template>
<template #value>{{ data.meta?.description }}</template>
</MkKeyValue>
<MkKeyValue v-if="data.type === 'plugin'">
<template #key>{{ i18n.ts.version }}</template>
<template #value>{{ data.meta?.version }}</template>
</MkKeyValue>
<MkKeyValue v-if="data.type === 'plugin'">
<template #key>{{ i18n.ts.permission }}</template>
<template #value>
<ul :class="$style.extInstallerKVList">
<li v-for="permission in data.meta?.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li>
</ul>
</template>
</MkKeyValue>
<MkKeyValue v-if="data.type === 'theme' && data.meta?.base">
<template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template>
<template #value>{{ i18n.ts[data.meta.base] }}</template>
</MkKeyValue>
<MkFolder>
<template #icon><i class="ti ti-code"></i></template>
<template #label>{{ i18n.ts._plugin.viewSource }}</template>
<MkCode :code="data.raw ?? ''"/>
</MkFolder>
</div>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts._externalResourceInstaller._vendorInfo.title }}</template>
<div class="_gaps_s">
<MkKeyValue>
<template #key>{{ i18n.ts._externalResourceInstaller._vendorInfo.endpoint }}</template>
<template #value><MkUrl :url="url ?? ''" :showUrlPreview="false"></MkUrl></template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts._externalResourceInstaller._vendorInfo.hashVerify }}</template>
<template #value>
<!--この画面が出ている時点でハッシュの検証には成功している-->
<i class="ti ti-check" style="color: var(--accent)"></i>
</template>
</MkKeyValue>
</div>
</FormSection>
<div class="_buttonsCenter">
<MkButton primary @click="install()"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton>
</div>
</div>
<div v-else-if="uiPhase === 'error'" class="_gaps_m" :class="[$style.extInstallerRoot, $style.error]">
<div :class="$style.extInstallerIconWrapper">
<i class="ti ti-circle-x"></i>
</div>
<h2 :class="$style.extInstallerTitle">{{ errorKV?.title }}</h2>
<div :class="$style.extInstallerNormDesc">{{ errorKV?.description }}</div>
<div class="_buttonsCenter">
<MkButton @click="goBack()">{{ i18n.ts.goBack }}</MkButton>
<MkButton @click="goToMisskey()">{{ i18n.ts.goToMisskey }}</MkButton>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, nextTick } from 'vue';
import MkLoading from '@/components/global/MkLoading.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import FormSplit from '@/components/form/split.vue';
import MkCode from '@/components/MkCode.vue';
import MkUrl from '@/components/global/MkUrl.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import * as os from '@/os.js';
import { AiScriptPluginMeta, parsePluginMeta, installPlugin } from '@/scripts/install-plugin.js';
import { parseThemeCode, installTheme } from '@/scripts/install-theme.js';
import { unisonReload } from '@/scripts/unison-reload.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
const uiPhase = ref<'fetching' | 'confirm' | 'error'>('fetching');
const errorKV = ref<{
title?: string;
description?: string;
}>({
title: '',
description: '',
});
const urlParams = new URLSearchParams(window.location.search);
const url = urlParams.get('url');
const hash = urlParams.get('hash');
const data = ref<{
type: 'plugin' | 'theme';
raw: string;
meta?: {
// Plugin & Theme Common
name: string;
author: string;
// Plugin
description?: string;
version?: string;
permissions?: string[];
config?: Record<string, any>;
// Theme
base?: 'light' | 'dark';
};
} | null>(null);
function goBack(): void {
history.back();
}
function goToMisskey(): void {
location.href = '/';
}
async function fetch() {
if (!url || !hash) {
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._invalidParams.title,
description: i18n.ts._externalResourceInstaller._errors._invalidParams.description,
};
uiPhase.value = 'error';
return;
}
const res = await os.api('fetch-external-resources', {
url,
hash,
}).catch((err) => {
switch (err.id) {
case 'bb774091-7a15-4a70-9dc5-6ac8cf125856':
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._failedToFetch.title,
description: i18n.ts._externalResourceInstaller._errors._failedToFetch.parseErrorDescription,
};
uiPhase.value = 'error';
break;
case '693ba8ba-b486-40df-a174-72f8279b56a4':
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._hashUnmatched.title,
description: i18n.ts._externalResourceInstaller._errors._hashUnmatched.description,
};
uiPhase.value = 'error';
break;
default:
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._failedToFetch.title,
description: i18n.ts._externalResourceInstaller._errors._failedToFetch.fetchErrorDescription,
};
uiPhase.value = 'error';
break;
}
throw new Error(err.code);
});
if (!res) {
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._failedToFetch.title,
description: i18n.ts._externalResourceInstaller._errors._failedToFetch.fetchErrorDescription,
};
uiPhase.value = 'error';
return;
}
switch (res.type) {
case 'plugin':
try {
const meta = await parsePluginMeta(res.data);
data.value = {
type: 'plugin',
meta,
raw: res.data,
};
} catch (err) {
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._pluginParseFailed.title,
description: i18n.ts._externalResourceInstaller._errors._pluginParseFailed.description,
};
console.error(err);
uiPhase.value = 'error';
return;
}
break;
case 'theme':
try {
const metaRaw = parseThemeCode(res.data);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, props, desc: description, ...meta } = metaRaw;
data.value = {
type: 'theme',
meta: {
description,
...meta,
},
raw: res.data,
};
} catch (err) {
switch (err.message.toLowerCase()) {
case 'this theme is already installed':
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._themeParseFailed.title,
description: i18n.ts._theme.alreadyInstalled,
};
break;
default:
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._themeParseFailed.title,
description: i18n.ts._externalResourceInstaller._errors._themeParseFailed.description,
};
break;
}
console.error(err);
uiPhase.value = 'error';
return;
}
break;
default:
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._resourceTypeNotSupported.title,
description: i18n.ts._externalResourceInstaller._errors._resourceTypeNotSupported.description,
};
uiPhase.value = 'error';
return;
}
uiPhase.value = 'confirm';
}
async function install() {
if (!data.value) return;
switch (data.value.type) {
case 'plugin':
if (!data.value.meta) return;
try {
await installPlugin(data.value.raw, data.value.meta as AiScriptPluginMeta);
os.success();
nextTick(() => {
unisonReload('/');
});
} catch (err) {
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._pluginInstallFailed.title,
description: i18n.ts._externalResourceInstaller._errors._pluginInstallFailed.description,
};
console.error(err);
uiPhase.value = 'error';
}
break;
case 'theme':
if (!data.value.meta) return;
await installTheme(data.value.raw);
os.success();
nextTick(() => {
location.href = '/settings/theme';
});
}
}
onMounted(() => {
fetch();
});
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata({
title: i18n.ts._externalResourceInstaller.title,
icon: 'ti ti-download',
});
</script>
<style lang="scss" module>
.extInstallerRoot {
border-radius: var(--radius);
background: var(--panel);
padding: 1.5rem;
}
.extInstallerIconWrapper {
width: 48px;
height: 48px;
font-size: 24px;
line-height: 48px;
text-align: center;
border-radius: 50%;
margin-left: auto;
margin-right: auto;
background-color: var(--accentedBg);
color: var(--accent);
}
.error .extInstallerIconWrapper {
background-color: rgba(255, 42, 42, .15);
color: #ff2a2a;
}
.extInstallerTitle {
font-size: 1.2rem;
text-align: center;
margin: 0;
}
.extInstallerNormDesc {
text-align: center;
}
.extInstallerKVList {
margin-top: 0;
margin-bottom: 0;
}
</style>

View file

@ -30,6 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
<MkSwitch v-model="defaultWithReplies">{{ i18n.ts.withRepliesByDefaultForNewlyFollowed }}</MkSwitch>
<MkButton danger @click="updateRepliesAll(true)"><i class="ti ti-messages"></i> {{ i18n.ts.showRepliesToOthersInTimelineAll }}</MkButton>
<MkButton danger @click="updateRepliesAll(false)"><i class="ti ti-messages-off"></i> {{ i18n.ts.hideRepliesToOthersInTimelineAll }}</MkButton>
<MkFolder>
<template #label>{{ i18n.ts.pinnedList }}</template>
<!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ -->
@ -332,6 +334,15 @@ async function setPinnedList() {
defaultStore.set('pinnedUserLists', [list]);
}
async function updateRepliesAll(withReplies: boolean) {
const { canceled } = os.confirm({
type: 'warning',
text: withReplies ? i18n.ts.confirmShowRepliesAll : i18n.ts.confirmHideRepliesAll,
});
if (canceled) return;
await os.api('following/update-all', { withReplies });
}
function removePinnedList() {
defaultStore.set('pinnedUserLists', []);
}

View file

@ -18,130 +18,35 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { defineAsyncComponent, nextTick, ref } from 'vue';
import { compareVersions } from 'compare-versions';
import { Interpreter, Parser, utils } from '@syuilo/aiscript';
import { v4 as uuid } from 'uuid';
import { nextTick, ref } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkButton from '@/components/MkButton.vue';
import FormInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { ColdDeviceStorage } from '@/store.js';
import { installPlugin } from '@/scripts/install-plugin.js';
import { unisonReload } from '@/scripts/unison-reload.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
const parser = new Parser();
const code = ref(null);
function installPlugin({ id, meta, src, token }) {
ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({
...meta,
id,
active: true,
configData: {},
token: token,
src: src,
}));
}
function isSupportedAiScriptVersion(version: string): boolean {
try {
return (compareVersions(version, '0.12.0') >= 0);
} catch (err) {
return false;
}
}
const code = ref<string | null>(null);
async function install() {
if (code.value == null) return;
if (!code.value) return;
const lv = utils.getLangVersion(code.value);
if (lv == null) {
os.alert({
type: 'error',
text: 'No language version annotation found :(',
});
return;
} else if (!isSupportedAiScriptVersion(lv)) {
os.alert({
type: 'error',
text: `aiscript version '${lv}' is not supported :(`,
});
return;
}
let ast;
try {
ast = parser.parse(code.value);
await installPlugin(code.value);
os.success();
nextTick(() => {
unisonReload();
});
} catch (err) {
os.alert({
type: 'error',
text: 'Syntax error :(',
title: 'Install failed',
text: err.toString() ?? null,
});
return;
}
const meta = Interpreter.collectMetadata(ast);
if (meta == null) {
os.alert({
type: 'error',
text: 'No metadata found :(',
});
return;
}
const metadata = meta.get(null);
if (metadata == null) {
os.alert({
type: 'error',
text: 'No metadata found :(',
});
return;
}
const { name, version, author, description, permissions, config } = metadata;
if (name == null || version == null || author == null) {
os.alert({
type: 'error',
text: 'Required property not found :(',
});
return;
}
const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => {
os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
title: i18n.ts.tokenRequested,
information: i18n.ts.pluginTokenRequestedDescription,
initialName: name,
initialPermissions: permissions,
}, {
done: async result => {
const { name, permissions } = result;
const { token } = await os.api('miauth/gen-token', {
session: null,
name: name,
permission: permissions,
});
res(token);
},
}, 'closed');
});
installPlugin({
id: uuid(),
meta: {
name, version, author, description, permissions, config,
},
token,
src: code.value,
});
os.success();
nextTick(() => {
unisonReload();
});
}
const headerActions = $computed(() => []);

View file

@ -83,6 +83,23 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts._profile.metadataDescription }}</template>
</FormSlot>
<MkFolder>
<template #icon><i class="ti ti-sparkles"></i></template>
<template #label>{{ i18n.ts.avatarDecorations }}</template>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-gap: 12px;">
<div
v-for="avatarDecoration in avatarDecorations"
:key="avatarDecoration.id"
:class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]"
@click="toggleDecoration(avatarDecoration)"
>
<div :class="$style.avatarDecorationName">{{ avatarDecoration.name }}</div>
<MkAvatar style="width: 64px; height: 64px;" :user="$i" :decoration="avatarDecoration.url"/>
</div>
</div>
</MkFolder>
<MkFolder>
<template #label>{{ i18n.ts.advancedSettings }}</template>
@ -126,6 +143,7 @@ import MkInfo from '@/components/MkInfo.vue';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance'));
let avatarDecorations: any[] = $ref([]);
const profile = reactive({
name: $i.name,
@ -146,6 +164,10 @@ watch(() => profile, () => {
const fields = ref($i?.fields.map(field => ({ id: Math.random().toString(), name: field.name, value: field.value })) ?? []);
const fieldEditMode = ref(false);
os.api('get-avatar-decorations').then(_avatarDecorations => {
avatarDecorations = _avatarDecorations;
});
function addField() {
fields.value.push({
id: Math.random().toString(),
@ -244,6 +266,20 @@ function changeBanner(ev) {
});
}
function toggleDecoration(avatarDecoration) {
if ($i.avatarDecorations.some(x => x.id === avatarDecoration.id)) {
os.apiWithDialog('i/update', {
avatarDecorations: [],
});
$i.avatarDecorations = [];
} else {
os.apiWithDialog('i/update', {
avatarDecorations: [avatarDecoration.id],
});
$i.avatarDecorations.push(avatarDecoration);
}
}
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
@ -338,4 +374,23 @@ definePageMetadata({
.dragItemForm {
flex-grow: 1;
}
.avatarDecoration {
cursor: pointer;
padding: 16px 16px 24px 16px;
border: solid 2px var(--divider);
border-radius: 8px;
text-align: center;
}
.avatarDecorationActive {
border-color: var(--accent);
}
.avatarDecorationName {
position: relative;
z-index: 10;
font-weight: bold;
margin-bottom: 16px;
}
</style>

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkTextarea>
<div class="_buttons">
<MkButton :disabled="installThemeCode == null" inline @click="() => preview(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton :disabled="installThemeCode == null" inline @click="() => previewTheme(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton :disabled="installThemeCode == null" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton>
</div>
</div>
@ -18,60 +18,41 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { } from 'vue';
import JSON5 from 'json5';
import MkTextarea from '@/components/MkTextarea.vue';
import MkButton from '@/components/MkButton.vue';
import { applyTheme, validateTheme } from '@/scripts/theme.js';
import { parseThemeCode, previewTheme, installTheme } from '@/scripts/install-theme.js';
import * as os from '@/os.js';
import { addTheme, getThemes } from '@/theme-store';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
let installThemeCode = $ref(null);
function parseThemeCode(code: string) {
let theme;
try {
theme = JSON5.parse(code);
} catch (err) {
os.alert({
type: 'error',
text: i18n.ts._theme.invalid,
});
return false;
}
if (!validateTheme(theme)) {
os.alert({
type: 'error',
text: i18n.ts._theme.invalid,
});
return false;
}
if (getThemes().some(t => t.id === theme.id)) {
os.alert({
type: 'info',
text: i18n.ts._theme.alreadyInstalled,
});
return false;
}
return theme;
}
function preview(code: string): void {
const theme = parseThemeCode(code);
if (theme) applyTheme(theme, false);
}
async function install(code: string): Promise<void> {
const theme = parseThemeCode(code);
if (!theme) return;
await addTheme(theme);
os.alert({
type: 'success',
text: i18n.t('_theme.installed', { name: theme.name }),
});
try {
const theme = parseThemeCode(code);
await installTheme(code);
os.alert({
type: 'success',
text: i18n.t('_theme.installed', { name: theme.name }),
});
} catch (err) {
switch (err.message.toLowerCase()) {
case 'this theme is already installed':
os.alert({
type: 'info',
text: i18n.ts._theme.alreadyInstalled,
});
break;
default:
os.alert({
type: 'error',
text: i18n.ts._theme.invalid,
});
break;
}
console.error(err);
}
}
const headerActions = $computed(() => []);

View file

@ -322,6 +322,10 @@ export const routes = [{
}, {
path: '/registry',
component: page(() => import('./pages/registry.vue')),
}, {
path: '/install-extentions',
component: page(() => import('./pages/install-extentions.vue')),
loginRequired: true,
}, {
path: '/admin/user/:userId',
component: iAmModerator ? page(() => import('./pages/admin-user.vue')) : page(() => import('./pages/not-found.vue')),
@ -343,6 +347,10 @@ export const routes = [{
path: '/emojis',
name: 'emojis',
component: page(() => import('./pages/custom-emojis-manager.vue')),
}, {
path: '/avatar-decorations',
name: 'avatarDecorations',
component: page(() => import('./pages/admin/avatar-decorations.vue')),
}, {
path: '/queue',
name: 'queue',

View file

@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent } from 'vue';
import { compareVersions } from 'compare-versions';
import { v4 as uuid } from 'uuid';
import { Interpreter, Parser, utils } from '@syuilo/aiscript';
import type { Plugin } from '@/store.js';
import { ColdDeviceStorage } from '@/store.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
export type AiScriptPluginMeta = {
name: string;
version: string;
author: string;
description?: string;
permissions?: string[];
config?: Record<string, any>;
};
const parser = new Parser();
export function savePlugin({ id, meta, src, token }: {
id: string;
meta: AiScriptPluginMeta;
src: string;
token: string;
}) {
ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({
...meta,
id,
active: true,
configData: {},
token: token,
src: src,
} as Plugin));
}
export function isSupportedAiScriptVersion(version: string): boolean {
try {
return (compareVersions(version, '0.12.0') >= 0);
} catch (err) {
return false;
}
}
export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> {
if (!code) {
throw new Error('code is required');
}
const lv = utils.getLangVersion(code);
if (lv == null) {
throw new Error('No language version annotation found');
} else if (!isSupportedAiScriptVersion(lv)) {
throw new Error(`Aiscript version '${lv}' is not supported`);
}
let ast;
try {
ast = parser.parse(code);
} catch (err) {
throw new Error('Aiscript syntax error');
}
const meta = Interpreter.collectMetadata(ast);
if (meta == null) {
throw new Error('Meta block not found');
}
const metadata = meta.get(null);
if (metadata == null) {
throw new Error('Metadata not found');
}
const { name, version, author, description, permissions, config } = metadata;
if (name == null || version == null || author == null) {
throw new Error('Required property not found');
}
return {
name,
version,
author,
description,
permissions,
config,
};
}
export async function installPlugin(code: string, meta?: AiScriptPluginMeta) {
if (!code) return;
let realMeta: AiScriptPluginMeta;
if (!meta) {
realMeta = await parsePluginMeta(code);
} else {
realMeta = meta;
}
const token = realMeta.permissions == null || realMeta.permissions.length === 0 ? null : await new Promise((res, rej) => {
os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
title: i18n.ts.tokenRequested,
information: i18n.ts.pluginTokenRequestedDescription,
initialName: realMeta.name,
initialPermissions: realMeta.permissions,
}, {
done: async result => {
const { name, permissions } = result;
const { token } = await os.api('miauth/gen-token', {
session: null,
name: name,
permission: permissions,
});
res(token);
},
}, 'closed');
});
savePlugin({
id: uuid(),
meta: realMeta,
token,
src: code,
});
}

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import JSON5 from 'json5';
import { addTheme, getThemes } from '@/theme-store.js';
import { Theme, applyTheme, validateTheme } from '@/scripts/theme.js';
export function parseThemeCode(code: string): Theme {
let theme;
try {
theme = JSON5.parse(code);
} catch (err) {
throw new Error('Failed to parse theme json');
}
if (!validateTheme(theme)) {
throw new Error('This theme is invaild');
}
if (getThemes().some(t => t.id === theme.id)) {
throw new Error('This theme is already installed');
}
return theme;
}
export function previewTheme(code: string): void {
const theme = parseThemeCode(code);
if (theme) applyTheme(theme, false);
}
export async function installTheme(code: string): Promise<void> {
const theme = parseThemeCode(code);
if (!theme) return;
await addTheme(theme);
}

View file

@ -7,8 +7,8 @@ import { describe, test, assert, afterEach } from 'vitest';
import { render, cleanup, type RenderResult } from '@testing-library/vue';
import './init';
import type * as Misskey from 'misskey-js';
import { directives } from '@/directives';
import { components } from '@/components/index';
import { directives } from '@/directives/index.js';
import { components } from '@/components/index.js';
import XHome from '@/pages/user/home.vue';
describe('XHome', () => {
@ -34,6 +34,8 @@ describe('XHome', () => {
createdAt: '1970-01-01T00:00:00.000Z',
fields: [],
pinnedNotes: [],
avatarUrl: 'https://example.com',
avatarDecorations: [],
});
const anchor = home.container.querySelector<HTMLAnchorElement>('a[href^="https://example.com/"]');
@ -54,6 +56,8 @@ describe('XHome', () => {
createdAt: '1970-01-01T00:00:00.000Z',
fields: [],
pinnedNotes: [],
avatarUrl: 'https://example.com',
avatarDecorations: [],
});
const anchor = home.container.querySelector<HTMLAnchorElement>('a[href^="https://example.com/"]');

View file

@ -7,8 +7,8 @@ import { describe, test, assert, afterEach } from 'vitest';
import { render, cleanup, type RenderResult } from '@testing-library/vue';
import './init';
import type * as Misskey from 'misskey-js';
import { components } from '@/components';
import { directives } from '@/directives';
import { components } from '@/components/index.js';
import { directives } from '@/directives/index.js';
import MkMediaImage from '@/components/MkMediaImage.vue';
describe('MkMediaImage', () => {

View file

@ -7,8 +7,8 @@ import { describe, test, assert, afterEach } from 'vitest';
import { render, cleanup, type RenderResult } from '@testing-library/vue';
import './init';
import type { summaly } from 'summaly';
import { components } from '@/components';
import { directives } from '@/directives';
import { components } from '@/components/index.js';
import { directives } from '@/directives/index.js';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
type SummalyResult = Awaited<ReturnType<typeof summaly>>;

View file

@ -2230,6 +2230,22 @@ export type Endpoints = {
};
};
};
'fetch-rss': {
req: {
url: string;
};
res: TODO;
};
'fetch-external-resources': {
req: {
url: string;
hash: string;
};
res: {
type: string;
data: string;
};
};
};
declare namespace entities {
@ -2635,10 +2651,22 @@ type ModerationLog = {
} | {
type: 'deleteAd';
info: ModerationLogPayloads['deleteAd'];
} | {
type: 'createAvatarDecoration';
info: ModerationLogPayloads['createAvatarDecoration'];
} | {
type: 'updateAvatarDecoration';
info: ModerationLogPayloads['updateAvatarDecoration'];
} | {
type: 'deleteAvatarDecoration';
info: ModerationLogPayloads['deleteAvatarDecoration'];
} | {
type: 'resolveAbuseReport';
info: ModerationLogPayloads['resolveAbuseReport'];
});
// @public (undocumented)
export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd"];
export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration"];
// @public (undocumented)
export const mutedNoteReasons: readonly ["word", "manual", "spam", "other"];
@ -2966,6 +2994,10 @@ type UserLite = {
onlineStatus: 'online' | 'active' | 'offline' | 'unknown';
avatarUrl: string;
avatarBlurhash: string;
avatarDecorations: {
id: ID;
url: string;
}[];
emojis: {
name: string;
url: string;
@ -2990,8 +3022,6 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
// src/api.types.ts:633:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/entities.ts:109:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
// src/entities.ts:606:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)

View file

@ -639,4 +639,11 @@ export type Endpoints = {
$default: UserDetailed;
};
}; };
// fetching external data
'fetch-rss': { req: { url: string; }; res: TODO; };
'fetch-external-resources': {
req: { url: string; hash: string; };
res: { type: string; data: string; };
};
};

View file

@ -78,6 +78,9 @@ export const moderationLogTypes = [
'createAd',
'updateAd',
'deleteAd',
'createAvatarDecoration',
'updateAvatarDecoration',
'deleteAvatarDecoration',
] as const;
export type ModerationLogPayloads = {
@ -239,4 +242,17 @@ export type ModerationLogPayloads = {
adId: string;
ad: any;
};
createAvatarDecoration: {
avatarDecorationId: string;
avatarDecoration: any;
};
updateAvatarDecoration: {
avatarDecorationId: string;
before: any;
after: any;
};
deleteAvatarDecoration: {
avatarDecorationId: string;
avatarDecoration: any;
};
};

View file

@ -16,6 +16,10 @@ export type UserLite = {
onlineStatus: 'online' | 'active' | 'offline' | 'unknown';
avatarUrl: string;
avatarBlurhash: string;
avatarDecorations: {
id: ID;
url: string;
}[];
emojis: {
name: string;
url: string;
@ -694,4 +698,16 @@ export type ModerationLog = {
} | {
type: 'deleteAd';
info: ModerationLogPayloads['deleteAd'];
} | {
type: 'createAvatarDecoration';
info: ModerationLogPayloads['createAvatarDecoration'];
} | {
type: 'updateAvatarDecoration';
info: ModerationLogPayloads['updateAvatarDecoration'];
} | {
type: 'deleteAvatarDecoration';
info: ModerationLogPayloads['deleteAvatarDecoration'];
} | {
type: 'resolveAbuseReport';
info: ModerationLogPayloads['resolveAbuseReport'];
});