Merge branch 'main' into snug.moe

This commit is contained in:
vib 2022-10-11 17:19:09 +03:00
commit fe99f4de6f
51 changed files with 527 additions and 114 deletions

View file

@ -121,6 +121,8 @@ unmarkAsSensitive: "Unmark as NSFW"
enterFileName: "Enter filename" enterFileName: "Enter filename"
mute: "Mute" mute: "Mute"
unmute: "Unmute" unmute: "Unmute"
renoteMute: "Hide renotes"
renoteUnmute: "Show renotes"
block: "Block" block: "Block"
unblock: "Unblock" unblock: "Unblock"
suspend: "Suspend" suspend: "Suspend"

View file

@ -0,0 +1,20 @@
export class addRenoteMuting1665091090561 {
constructor() {
this.name = 'addRenoteMuting1665091090561';
}
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "renote_muting" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "muteeId" character varying(32) NOT NULL, "muterId" character varying(32) NOT NULL, CONSTRAINT "PK_renoteMuting_id" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `);
await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `);
await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "IDX_renote_muting_createdAt"`);
await queryRunner.query(`DROP INDEX "IDX_renote_muting_muteeId"`);
await queryRunner.query(`DROP INDEX "IDX_renote_muting_muterId"`);
await queryRunner.query(`DROP TABLE "renote_muting"`);
}
}

View file

@ -22,6 +22,7 @@ import { Meta } from '@/models/entities/meta.js';
import { Following } from '@/models/entities/following.js'; import { Following } from '@/models/entities/following.js';
import { Instance } from '@/models/entities/instance.js'; import { Instance } from '@/models/entities/instance.js';
import { Muting } from '@/models/entities/muting.js'; import { Muting } from '@/models/entities/muting.js';
import { RenoteMuting } from '@/models/entities/renote-muting.js';
import { SwSubscription } from '@/models/entities/sw-subscription.js'; import { SwSubscription } from '@/models/entities/sw-subscription.js';
import { Blocking } from '@/models/entities/blocking.js'; import { Blocking } from '@/models/entities/blocking.js';
import { UserList } from '@/models/entities/user-list.js'; import { UserList } from '@/models/entities/user-list.js';
@ -130,6 +131,7 @@ export const entities = [
Following, Following,
FollowRequest, FollowRequest,
Muting, Muting,
RenoteMuting,
Blocking, Blocking,
Note, Note,
NoteFavorite, NoteFavorite,

View file

@ -7,8 +7,8 @@ import * as crypto from 'node:crypto';
const TIME2000 = 946684800000; const TIME2000 = 946684800000;
let counter = crypto.randomBytes(2).readUInt16LE(0); let counter = crypto.randomBytes(2).readUInt16LE(0);
export function genId(date?: Date = new Date()): string { export function genId(date: Date = new Date()): string {
let t = Math.min(date, new Date()); let t = Math.min(date.valueOf(), new Date().valueOf());
t -= TIME2000; t -= TIME2000;
if (t < 0) t = 0; if (t < 0) t = 0;
if (isNaN(t)) throw new Error('Failed to create AID: Invalid Date'); if (isNaN(t)) throw new Error('Failed to create AID: Invalid Date');

View file

@ -16,6 +16,7 @@ import { packedDriveFileSchema } from '@/models/schema/drive-file.js';
import { packedDriveFolderSchema } from '@/models/schema/drive-folder.js'; import { packedDriveFolderSchema } from '@/models/schema/drive-folder.js';
import { packedFollowingSchema } from '@/models/schema/following.js'; import { packedFollowingSchema } from '@/models/schema/following.js';
import { packedMutingSchema } from '@/models/schema/muting.js'; import { packedMutingSchema } from '@/models/schema/muting.js';
import { packedRenoteMutingSchema } from '@/models/schema/renote-muting.js';
import { packedBlockingSchema } from '@/models/schema/blocking.js'; import { packedBlockingSchema } from '@/models/schema/blocking.js';
import { packedNoteReactionSchema } from '@/models/schema/note-reaction.js'; import { packedNoteReactionSchema } from '@/models/schema/note-reaction.js';
import { packedHashtagSchema } from '@/models/schema/hashtag.js'; import { packedHashtagSchema } from '@/models/schema/hashtag.js';
@ -51,6 +52,7 @@ export const refs = {
DriveFolder: packedDriveFolderSchema, DriveFolder: packedDriveFolderSchema,
Following: packedFollowingSchema, Following: packedFollowingSchema,
Muting: packedMutingSchema, Muting: packedMutingSchema,
RenoteMuting: packedRenoteMutingSchema,
Blocking: packedBlockingSchema, Blocking: packedBlockingSchema,
Hashtag: packedHashtagSchema, Hashtag: packedHashtagSchema,
Page: packedPageSchema, Page: packedPageSchema,

View file

@ -0,0 +1,42 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from '../id.js';
import { User } from './user.js';
@Entity()
@Index(['muterId', 'muteeId'], { unique: true })
export class RenoteMuting {
@PrimaryColumn(id())
public id: string;
@Index()
@Column('timestamp with time zone', {
comment: 'The created date of the Muting.',
})
public createdAt: Date;
@Index()
@Column({
...id(),
comment: 'The mutee user ID.',
})
public muteeId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public mutee: User | null;
@Index()
@Column({
...id(),
comment: 'The muter user ID.',
})
public muterId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public muter: User | null;
}

View file

@ -25,6 +25,7 @@ import { UserGroupJoining } from './entities/user-group-joining.js';
import { UserGroupInvitationRepository } from './repositories/user-group-invitation.js'; import { UserGroupInvitationRepository } from './repositories/user-group-invitation.js';
import { FollowRequestRepository } from './repositories/follow-request.js'; import { FollowRequestRepository } from './repositories/follow-request.js';
import { MutingRepository } from './repositories/muting.js'; import { MutingRepository } from './repositories/muting.js';
import { RenoteMutingRepository } from './repositories/renote-muting.js';
import { BlockingRepository } from './repositories/blocking.js'; import { BlockingRepository } from './repositories/blocking.js';
import { NoteReactionRepository } from './repositories/note-reaction.js'; import { NoteReactionRepository } from './repositories/note-reaction.js';
import { NotificationRepository } from './repositories/notification.js'; import { NotificationRepository } from './repositories/notification.js';
@ -95,6 +96,7 @@ export const DriveFolders = (DriveFolderRepository);
export const Notifications = (NotificationRepository); export const Notifications = (NotificationRepository);
export const Metas = db.getRepository(Meta); export const Metas = db.getRepository(Meta);
export const Mutings = (MutingRepository); export const Mutings = (MutingRepository);
export const RenoteMutings = (RenoteMutingRepository);
export const Blockings = (BlockingRepository); export const Blockings = (BlockingRepository);
export const SwSubscriptions = db.getRepository(SwSubscription); export const SwSubscriptions = db.getRepository(SwSubscription);
export const Hashtags = (HashtagRepository); export const Hashtags = (HashtagRepository);

View file

@ -0,0 +1,31 @@
import { db } from '@/db/postgre.js';
import { Packed } from '@/misc/schema.js';
import { RenoteMuting } from '@/models/entities/renote-muting.js';
import { User } from '@/models/entities/user.js';
import { awaitAll } from '@/prelude/await-all.js';
import { Users } from '../index.js';
export const RenoteMutingRepository = db.getRepository(RenoteMuting).extend({
async pack(
src: RenoteMuting['id'] | RenoteMuting,
me?: { id: User['id'] } | null | undefined,
): Promise<Packed<'RenoteMuting'>> {
const muting = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src });
return await awaitAll({
id: muting.id,
createdAt: muting.createdAt.toISOString(),
muteeId: muting.muteeId,
mutee: Users.pack(muting.muteeId, me, {
detail: true,
}),
});
},
packMany(
mutings: any[],
me: { id: User['id'] },
) {
return Promise.all(mutings.map(x => this.pack(x, me)));
},
});

View file

@ -10,7 +10,7 @@ import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import { Cache } from '@/misc/cache.js'; import { Cache } from '@/misc/cache.js';
import { db } from '@/db/postgre.js'; import { db } from '@/db/postgre.js';
import { Instance } from '../entities/instance.js'; import { Instance } from '../entities/instance.js';
import { Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, AntennaNotes, ChannelFollowings, Instances, DriveFiles } from '../index.js'; import { Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, RenoteMutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, AntennaNotes, ChannelFollowings, Instances, DriveFiles } from '../index.js';
const userInstanceCache = new Cache<Instance | null>(1000 * 60 * 60 * 3); const userInstanceCache = new Cache<Instance | null>(1000 * 60 * 60 * 3);
@ -112,6 +112,13 @@ export const UserRepository = db.getRepository(User).extend({
}, },
take: 1, take: 1,
}).then(n => n > 0), }).then(n => n > 0),
isRenoteMuted: RenoteMutings.count({
where: {
muterId: me,
muteeId: target,
},
take: 1,
}).then(n => n > 0),
}); });
}, },
@ -412,6 +419,7 @@ export const UserRepository = db.getRepository(User).extend({
isBlocking: relation.isBlocking, isBlocking: relation.isBlocking,
isBlocked: relation.isBlocked, isBlocked: relation.isBlocked,
isMuted: relation.isMuted, isMuted: relation.isMuted,
isRenoteMuted: relation.isRenoteMuted,
} : {}), } : {}),
} as Promiseable<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>; } as Promiseable<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>;

View file

@ -0,0 +1,26 @@
export const packedRenoteMutingSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
example: 'xxxxxxxxxx',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
muteeId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
mutee: {
type: 'object',
optional: false, nullable: false,
ref: 'UserDetailed',
},
},
} as const;

View file

@ -263,6 +263,10 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'boolean', type: 'boolean',
nullable: false, optional: true, nullable: false, optional: true,
}, },
isRenoteMuted: {
type: 'boolean',
nullable: false, optional: true,
},
//#endregion //#endregion
}, },
} as const; } as const;

View file

@ -0,0 +1,22 @@
import { Brackets, SelectQueryBuilder } from 'typeorm';
import { User } from '@/models/entities/user.js';
import { RenoteMutings } from '@/models/index.js';
export function generateMutedRenotesQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
const mutingQuery = RenoteMutings.createQueryBuilder('renote_muting')
.select('renote_muting.muteeId')
.where('renote_muting.muterId = :muterId', { muterId: me.id });
q.andWhere(new Brackets(qb => {
qb
.where(new Brackets(qb => {
qb.where('note.renoteId IS NOT NULL');
qb.andWhere('note.text IS NULL');
qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`);
}))
.orWhere('note.renoteId IS NULL')
.orWhere('note.text IS NOT NULL');
}));
q.setParameters(mutingQuery.getParameters());
}

View file

@ -214,6 +214,9 @@ import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js';
import * as ep___mute_create from './endpoints/mute/create.js'; import * as ep___mute_create from './endpoints/mute/create.js';
import * as ep___mute_delete from './endpoints/mute/delete.js'; import * as ep___mute_delete from './endpoints/mute/delete.js';
import * as ep___mute_list from './endpoints/mute/list.js'; import * as ep___mute_list from './endpoints/mute/list.js';
import * as ep___renote_mute_create from './endpoints/renote-mute/create.js';
import * as ep___renote_mute_delete from './endpoints/renote-mute/delete.js';
import * as ep___renote_mute_list from './endpoints/renote-mute/list.js';
import * as ep___my_apps from './endpoints/my/apps.js'; import * as ep___my_apps from './endpoints/my/apps.js';
import * as ep___notes from './endpoints/notes.js'; import * as ep___notes from './endpoints/notes.js';
import * as ep___notes_children from './endpoints/notes/children.js'; import * as ep___notes_children from './endpoints/notes/children.js';
@ -521,6 +524,9 @@ const eps = [
['mute/create', ep___mute_create], ['mute/create', ep___mute_create],
['mute/delete', ep___mute_delete], ['mute/delete', ep___mute_delete],
['mute/list', ep___mute_list], ['mute/list', ep___mute_list],
['renote-mute/create', ep___renote_mute_create],
['renote-mute/delete', ep___renote_mute_delete],
['renote-mute/list', ep___renote_mute_list],
['my/apps', ep___my_apps], ['my/apps', ep___my_apps],
['notes', ep___notes], ['notes', ep___notes],
['notes/children', ep___notes_children], ['notes/children', ep___notes_children],

View file

@ -87,4 +87,6 @@ export default define(meta, paramDef, async (ps, user) => {
return await Users.pack(blockee.id, blocker, { return await Users.pack(blockee.id, blocker, {
detail: true, detail: true,
}); });
publishUserEvent(user.id, 'block', blockee);
}); });

View file

@ -83,4 +83,6 @@ export default define(meta, paramDef, async (ps, user) => {
return await Users.pack(blockee.id, blocker, { return await Users.pack(blockee.id, blocker, {
detail: true, detail: true,
}); });
publishUserEvent(user.id, 'unblock', blockee);
}); });

View file

@ -56,7 +56,7 @@ export default define(meta, paramDef, async (ps, user) => {
id: genId(), id: genId(),
createdAt: new Date(), createdAt: new Date(),
name: ps.name, name: ps.name,
parentId: parent !== null ? parent.id : null, parentId: parent?.id,
userId: user.id, userId: user.id,
}).then(x => DriveFolders.findOneByOrFail(x.identifiers[0])); }).then(x => DriveFolders.findOneByOrFail(x.identifiers[0]));

View file

@ -8,6 +8,7 @@ import { generateMutedUserQuery } from '../../common/generate-muted-user-query.j
import { generateRepliesQuery } from '../../common/generate-replies-query.js'; import { generateRepliesQuery } from '../../common/generate-replies-query.js';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
import { generateMutedRenotesQuery } from '../../common/generated-muted-renote-query.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -79,6 +80,7 @@ export default define(meta, paramDef, async (ps, user) => {
generateMutedUserQuery(query, user); generateMutedUserQuery(query, user);
generateMutedNoteQuery(query, user); generateMutedNoteQuery(query, user);
generateBlockedUserQuery(query, user); generateBlockedUserQuery(query, user);
generateMutedRenotesQuery(query, user);
} }
if (ps.withFiles) { if (ps.withFiles) {

View file

@ -11,6 +11,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query.js';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js';
import { generateChannelQuery } from '../../common/generate-channel-query.js'; import { generateChannelQuery } from '../../common/generate-channel-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
import { generateMutedRenotesQuery } from '../../common/generated-muted-renote-query.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -93,6 +94,7 @@ export default define(meta, paramDef, async (ps, user) => {
generateMutedUserQuery(query, user); generateMutedUserQuery(query, user);
generateMutedNoteQuery(query, user); generateMutedNoteQuery(query, user);
generateBlockedUserQuery(query, user); generateBlockedUserQuery(query, user);
generateMutedRenotesQuery(query, user);
if (ps.includeMyRenotes === false) { if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => { query.andWhere(new Brackets(qb => {

View file

@ -11,6 +11,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query.js';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js';
import { generateChannelQuery } from '../../common/generate-channel-query.js'; import { generateChannelQuery } from '../../common/generate-channel-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
import { generateMutedRenotesQuery } from '../../common/generated-muted-renote-query.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -86,6 +87,7 @@ export default define(meta, paramDef, async (ps, user) => {
if (user) generateMutedUserQuery(query, user); if (user) generateMutedUserQuery(query, user);
if (user) generateMutedNoteQuery(query, user); if (user) generateMutedNoteQuery(query, user);
if (user) generateBlockedUserQuery(query, user); if (user) generateBlockedUserQuery(query, user);
if (user) generateMutedRenotesQuery(query, user);
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');

View file

@ -9,6 +9,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query.js';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js';
import { generateChannelQuery } from '../../common/generate-channel-query.js'; import { generateChannelQuery } from '../../common/generate-channel-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
import { generateMutedRenotesQuery } from '../../common/generated-muted-renote-query.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -85,6 +86,7 @@ export default define(meta, paramDef, async (ps, user) => {
generateMutedUserQuery(query, user); generateMutedUserQuery(query, user);
generateMutedNoteQuery(query, user); generateMutedNoteQuery(query, user);
generateBlockedUserQuery(query, user); generateBlockedUserQuery(query, user);
generateMutedRenotesQuery(query, user);
if (ps.includeMyRenotes === false) { if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => { query.andWhere(new Brackets(qb => {

View file

@ -0,0 +1,79 @@
import { genId } from '@/misc/gen-id.js';
import { RenoteMutings } from '@/models/index.js';
import { RenoteMuting } from '@/models/entities/renote-muting.js';
import { publishUserEvent } from '@/services/stream.js';
import define from '../../define.js';
import { ApiError } from '../../error.js';
import { getUser } from '../../common/getters.js';
export const meta = {
tags: ['account'],
requireCredential: true,
kind: 'write:mutes',
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '6fef56f3-e765-4957-88e5-c6f65329b8a5',
},
muteeIsYourself: {
message: 'Mutee is yourself.',
code: 'MUTEE_IS_YOURSELF',
id: 'a4619cb2-5f23-484b-9301-94c903074e10',
},
alreadyMuting: {
message: 'You are already muting that user.',
code: 'ALREADY_MUTING',
id: '7e7359cb-160c-4956-b08f-4d1c653cd007',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const muter = user;
// Check if the mutee is yourself
if (user.id === ps.userId) {
throw new ApiError(meta.errors.muteeIsYourself);
}
// Get mutee
const mutee = await getUser(ps.userId).catch(e => {
if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw e;
});
// Check if already muting
const exist = await RenoteMutings.findOneBy({
muterId: muter.id,
muteeId: mutee.id,
});
if (exist != null) {
throw new ApiError(meta.errors.alreadyMuting);
}
// Create mute
await RenoteMutings.insert({
id: genId(),
createdAt: new Date(),
muterId: muter.id,
muteeId: mutee.id,
} as RenoteMuting);
publishUserEvent(user.id, 'muteRenote', mutee);
});

View file

@ -0,0 +1,74 @@
import { RenoteMutings } from '@/models/index.js';
import { publishUserEvent } from '@/services/stream.js';
import define from '../../define.js';
import { ApiError } from '../../error.js';
import { getUser } from '../../common/getters.js';
export const meta = {
tags: ['account'],
requireCredential: true,
kind: 'write:mutes',
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'b851d00b-8ab1-4a56-8b1b-e24187cb48ef',
},
muteeIsYourself: {
message: 'Mutee is yourself.',
code: 'MUTEE_IS_YOURSELF',
id: 'f428b029-6b39-4d48-a1d2-cc1ae6dd5cf9',
},
notMuting: {
message: 'You are not muting that user.',
code: 'NOT_MUTING',
id: '5467d020-daa9-4553-81e1-135c0c35a96d',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const muter = user;
// Check if the mutee is yourself
if (user.id === ps.userId) {
throw new ApiError(meta.errors.muteeIsYourself);
}
// Get mutee
const mutee = await getUser(ps.userId).catch(e => {
if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw e;
});
// Check not muting
const exist = await RenoteMutings.findOneBy({
muterId: muter.id,
muteeId: mutee.id,
});
if (exist == null) {
throw new ApiError(meta.errors.notMuting);
}
// Delete mute
await RenoteMutings.delete({
id: exist.id,
});
publishUserEvent(user.id, 'unmuteRenote', mutee);
});

View file

@ -0,0 +1,43 @@
import { RenoteMutings } from '@/models/index.js';
import define from '../../define.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
export const meta = {
tags: ['account'],
requireCredential: true,
kind: 'read:mutes',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'RenoteMuting',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, me) => {
const query = makePaginationQuery(RenoteMutings.createQueryBuilder('muting'), ps.sinceId, ps.untilId)
.andWhere('muting.muterId = :meId', { meId: me.id });
const mutings = await query
.take(ps.limit)
.getMany();
return await RenoteMutings.packMany(mutings, me);
});

View file

@ -47,6 +47,10 @@ export const meta = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
isRenoteMuted: {
type: 'boolean',
optional: false, nullable: false,
},
}, },
}, },
{ {
@ -88,6 +92,10 @@ export const meta = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
isRenoteMuted: {
type: 'boolean',
optional: false, nullable: false,
},
}, },
}, },
}, },

View file

@ -1,4 +1,4 @@
import * as sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import config from '@/config/index.js'; import config from '@/config/index.js';
import { publishAdminStream } from '@/services/stream.js'; import { publishAdminStream } from '@/services/stream.js';
import { AbuseUserReports, Users } from '@/models/index.js'; import { AbuseUserReports, Users } from '@/models/index.js';

View file

@ -2,7 +2,7 @@ import { Note } from '@/models/entities/note.js';
import { Notes } from '@/models/index.js'; import { Notes } from '@/models/index.js';
import { Packed } from '@/misc/schema.js'; import { Packed } from '@/misc/schema.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import Connection from '.'; import Connection from './index.js';
/** /**
* Stream channel * Stream channel
@ -30,6 +30,10 @@ export default abstract class Channel {
return this.connection.muting; return this.connection.muting;
} }
protected get renoteMuting() {
return this.connection.renoteMuting;
}
protected get blocking() { protected get blocking() {
return this.connection.blocking; return this.connection.blocking;
} }

View file

@ -31,6 +31,7 @@ export default class extends Channel {
if (isUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
if (note.renote && this.renoteMuting.has(note.userId)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);

View file

@ -34,6 +34,7 @@ export default class extends Channel {
if (isUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
if (note.renote && this.renoteMuting.has(note.userId)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);

View file

@ -43,6 +43,7 @@ export default class extends Channel {
if (isUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
if (note.renote && this.renoteMuting.has(note.userId)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する // 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)

View file

@ -32,6 +32,7 @@ export default class extends Channel {
if (isUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
if (note.renote && this.renoteMuting.has(note.userId)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);

View file

@ -41,6 +41,7 @@ export default class extends Channel {
if (isUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
if (note.renote && this.renoteMuting.has(note.userId)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する // 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)

View file

@ -49,6 +49,7 @@ export default class extends Channel {
if (isUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
if (note.renote && this.renoteMuting.has(note.userId)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する // 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)

View file

@ -40,6 +40,7 @@ export default class extends Channel {
if (isUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
if (note.renote && this.renoteMuting.has(note.userId)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する // 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)

View file

@ -55,6 +55,7 @@ export default class extends Channel {
if (isUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
if (note.renote && this.renoteMuting.has(note.userId)) return;
this.send('note', note); this.send('note', note);
} }

View file

@ -3,7 +3,7 @@ import * as websocket from 'websocket';
import readNote from '@/services/note/read.js'; import readNote from '@/services/note/read.js';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
import { Channel as ChannelModel } from '@/models/entities/channel.js'; import { Channel as ChannelModel } from '@/models/entities/channel.js';
import { Followings, Mutings, UserProfiles, ChannelFollowings, Blockings } from '@/models/index.js'; import { Followings, Mutings, RenoteMutings, UserProfiles, ChannelFollowings, Blockings } from '@/models/index.js';
import { AccessToken } from '@/models/entities/access-token.js'; import { AccessToken } from '@/models/entities/access-token.js';
import { UserProfile } from '@/models/entities/user-profile.js'; import { UserProfile } from '@/models/entities/user-profile.js';
import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '@/services/stream.js'; import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '@/services/stream.js';
@ -22,6 +22,7 @@ export default class Connection {
public userProfile?: UserProfile | null; public userProfile?: UserProfile | null;
public following: Set<User['id']> = new Set(); public following: Set<User['id']> = new Set();
public muting: Set<User['id']> = new Set(); public muting: Set<User['id']> = new Set();
public renoteMuting: Set<User['id']> = new Set();
public blocking: Set<User['id']> = new Set(); // "被"blocking public blocking: Set<User['id']> = new Set(); // "被"blocking
public followingChannels: Set<ChannelModel['id']> = new Set(); public followingChannels: Set<ChannelModel['id']> = new Set();
public token?: AccessToken; public token?: AccessToken;
@ -56,6 +57,7 @@ export default class Connection {
if (this.user) { if (this.user) {
this.updateFollowing(); this.updateFollowing();
this.updateMuting(); this.updateMuting();
this.updateRenoteMuting();
this.updateBlocking(); this.updateBlocking();
this.updateFollowingChannels(); this.updateFollowingChannels();
this.updateUserProfile(); this.updateUserProfile();
@ -82,7 +84,21 @@ export default class Connection {
this.muting.delete(data.body.id); this.muting.delete(data.body.id);
break; break;
// TODO: block events case 'block':
this.blocking.add(data.body.id);
break;
case 'unblock':
this.blocking.delete(data.body.id);
break;
case 'muteRenote':
this.renoteMuting.add(data.body.id);
break;
case 'unmuteRenote':
this.renoteMuting.delete(data.body.id);
break;
case 'followChannel': case 'followChannel':
this.followingChannels.add(data.body.id); this.followingChannels.add(data.body.id);
@ -333,6 +349,17 @@ export default class Connection {
this.muting = new Set<string>(mutings.map(x => x.muteeId)); this.muting = new Set<string>(mutings.map(x => x.muteeId));
} }
private async updateRenoteMuting() {
const renoteMutings = await RenoteMutings.find({
where: {
muterId: this.user!.id,
},
select: ['muteeId'],
});
this.renoteMuting = new Set<string>(renoteMutings.map(x => x.muteeId));
}
private async updateBlocking() { // ここでいうBlockingは被Blockingの意 private async updateBlocking() { // ここでいうBlockingは被Blockingの意
const blockings = await Blockings.find({ const blockings = await Blockings.find({
where: { where: {

View file

@ -14,7 +14,7 @@ import { AbuseUserReport } from '@/models/entities/abuse-user-report.js';
import { Signin } from '@/models/entities/signin.js'; import { Signin } from '@/models/entities/signin.js';
import { Page } from '@/models/entities/page.js'; import { Page } from '@/models/entities/page.js';
import { Packed } from '@/misc/schema.js'; import { Packed } from '@/misc/schema.js';
import { Webhook } from '@/models/entities/webhook'; import { Webhook } from '@/models/entities/webhook.js';
//#region Stream type-body definitions //#region Stream type-body definitions
export interface InternalStreamTypes { export interface InternalStreamTypes {
@ -44,6 +44,10 @@ export interface UserStreamTypes {
updateUserProfile: UserProfile; updateUserProfile: UserProfile;
mute: User; mute: User;
unmute: User; unmute: User;
muteRenote: User;
unmuteRenote: User;
block: User;
unblock: User;
follow: Packed<'UserDetailedNotMe'>; follow: Packed<'UserDetailedNotMe'>;
unfollow: Packed<'User'>; unfollow: Packed<'User'>;
userAdded: Packed<'User'>; userAdded: Packed<'User'>;

View file

@ -64,9 +64,6 @@ html
var VERSION = "#{version}"; var VERSION = "#{version}";
var CLIENT_ENTRY = "#{clientEntry.file}"; var CLIENT_ENTRY = "#{clientEntry.file}";
script
include ../boot.js
body body
noscript: p noscript: p
| JavaScriptを有効にしてください | JavaScriptを有効にしてください
@ -86,3 +83,6 @@ html
</g> </g>
</svg> </svg>
block content block content
script
include ../boot.js

View file

@ -425,7 +425,7 @@ export async function addFile({
file.createdAt = new Date(); file.createdAt = new Date();
file.userId = user ? user.id : null; file.userId = user ? user.id : null;
file.userHost = user ? user.host : null; file.userHost = user ? user.host : null;
file.folderId = folder !== null ? folder.id : null; file.folderId = folder?.id;
file.comment = comment; file.comment = comment;
file.properties = properties; file.properties = properties;
file.blurhash = info.blurhash || null; file.blurhash = info.blurhash || null;

View file

@ -21,7 +21,7 @@
"autwh": "0.1.0", "autwh": "0.1.0",
"blurhash": "1.1.5", "blurhash": "1.1.5",
"broadcast-channel": "4.13.0", "broadcast-channel": "4.13.0",
"browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.2", "browser-image-resizer": "2.4.1",
"chart.js": "3.8.0", "chart.js": "3.8.0",
"chartjs-adapter-date-fns": "2.0.0", "chartjs-adapter-date-fns": "2.0.0",
"chartjs-plugin-gradient": "0.5.0", "chartjs-plugin-gradient": "0.5.0",

View file

@ -9,7 +9,7 @@
@drop.stop="onDrop" @drop.stop="onDrop"
> >
<i v-if="folder == null" class="fas fa-cloud"></i> <i v-if="folder == null" class="fas fa-cloud"></i>
<span>{{ folder == null ? i18n.ts.drive : folder.name }}</span> <span>{{ folder?.name ?? i18n.ts.drive }}</span>
</div> </div>
</template> </template>

View file

@ -117,7 +117,7 @@ function showTabsPopup(ev: MouseEvent): void {
}, },
})); }));
popupMenu(menu, ev.currentTarget ?? ev.target); popupMenu(menu, ev.currentTarget ?? ev.target);
}; }
function preventDrag(ev: TouchEvent): void { function preventDrag(ev: TouchEvent): void {
ev.stopPropagation(); ev.stopPropagation();

View file

@ -60,7 +60,7 @@ const modal = $ref<InstanceType<typeof MkModal>>();
const menu = defaultStore.state.menu; const menu = defaultStore.state.menu;
const items = Object.keys(menuDef).filter(k => !menu.includes(k)).map(k => menuDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({ const items = Object.keys(menuDef).filter(k => !menu.includes(k)).map(k => menuDef[k]).filter(def => def.show ?? true).map(def => ({
type: def.to ? 'link' : 'button', type: def.to ? 'link' : 'button',
text: i18n.ts[def.title], text: i18n.ts[def.title],
icon: def.icon, icon: def.icon,

View file

@ -1,104 +1,75 @@
<script lang="ts"> <template>
import { h, onMounted, onUnmounted, ref, watch } from 'vue'; <div class="marquee">
<span ref="contentEl" :class="{ content: true, paused, reverse }">
<span v-for="i in repeat" :key="i" class="text">
<slot></slot>
</span>
</span>
</div>
</template>
export default { <script lang="ts" setup>
name: 'MarqueeText', import { watch, onMounted } from 'vue';
props: {
duration: {
type: Number,
default: 15,
},
repeat: {
type: Number,
default: 2,
},
paused: {
type: Boolean,
default: false,
},
reverse: {
type: Boolean,
default: false,
},
},
setup(props) {
const contentEl = ref();
function calc() { const props = withDefaults(defineProps<{
const eachLength = contentEl.value.offsetWidth / props.repeat; duration?: number;
const factor = 3000; repeat?: number;
const duration = props.duration / ((1 / eachLength) * factor); paused?: boolean;
reverse?: boolean;
}>(), {
duration: 15,
repeat: 2,
paused: false,
reverse: false,
});
contentEl.value.style.animationDuration = `${duration}s`; let contentEl: HTMLElement = $ref();
}
watch(() => props.duration, calc); function calc(): void {
const eachLength = contentEl.offsetWidth / props.repeat;
const factor = 3000;
const duration = props.duration / ((1 / eachLength) * factor);
onMounted(() => { contentEl.style.animationDuration = `${duration}s`;
calc(); }
});
onUnmounted(() => { watch(() => props.duration, calc);
}); onMounted(calc);
return {
contentEl,
};
},
render({
$slots, $style, $props: {
repeat, paused, reverse,
},
}) {
return h('div', { class: [$style.wrap] }, [
h('span', {
ref: 'contentEl',
class: [
paused
? $style.paused
: undefined,
$style.content,
],
}, Array(repeat).fill(
h('span', {
class: $style.text,
style: {
animationDirection: reverse
? 'reverse'
: undefined,
},
}, $slots.default()),
)),
]);
},
};
</script> </script>
<style lang="scss" module> <style lang="scss" scoped>
.wrap { .marquee {
overflow: clip; overflow: clip;
animation-play-state: running; animation-play-state: running;
&:hover { &:hover {
animation-play-state: paused; animation-play-state: paused;
} }
> .content {
display: inline-block;
white-space: nowrap;
animation-play-state: inherit;
&.paused {
animation-play-state: paused;
}
&.reverse {
animation-direction: reverse;
}
> .text {
display: inline-block;
animation-name: marquee;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: inherit;
animation-play-state: inherit;
}
}
} }
.content {
display: inline-block;
white-space: nowrap;
animation-play-state: inherit;
}
.text {
display: inline-block;
animation-name: marquee;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: inherit;
animation-play-state: inherit;
}
.paused .text {
animation-play-state: paused;
}
@keyframes marquee { @keyframes marquee {
0% { transform:translateX(0); } 0% { transform:translateX(0); }
100% { transform:translateX(-100%); } 100% { transform:translateX(-100%); }

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="pxhvhrfw" v-size="{ max: [500] }"> <div v-size="{ max: [500] }" class="pxhvhrfw">
<button <button
v-for="option in options" v-for="option in options"
:key="option.value" :key="option.value"
@ -17,12 +17,12 @@ const emit = defineEmits<{
(ev: 'update:modelValue', value: string): void; (ev: 'update:modelValue', value: string): void;
}>(); }>();
const props = defineProps<{ defineProps<{
modelValue: string; modelValue: string;
options: { options: {
value: string; value: string;
label: string; label: string;
}; }[];
}>(); }>();
</script> </script>

View file

@ -1 +1 @@
export default n => n == null ? 'N/A' : n.toLocaleString(); export default n => n?.toLocaleString() ?? 'N/A';

View file

@ -85,7 +85,7 @@ async function edit(type) {
type: 'enum', type: 'enum',
enum: soundsTypes.map(x => ({ enum: soundsTypes.map(x => ({
value: x, value: x,
label: x == null ? i18n.ts.none : x, label: x ?? i18n.ts.none,
})), })),
label: i18n.ts.sound, label: i18n.ts.sound,
default: sounds.value[type].type, default: sounds.value[type].type,

View file

@ -98,6 +98,14 @@ export function getUserMenu(user) {
} }
} }
async function toggleRenoteMute(): Promise<void> {
os.apiWithDialog(user.isRenoteMuted ? 'renote-mute/delete' : 'renote-mute/create', {
userId: user.id,
}).then(() => {
user.isRenoteMuted = !user.isRenoteMuted;
});
}
async function toggleBlock(): Promise<void> { async function toggleBlock(): Promise<void> {
if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return; if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return;
@ -187,6 +195,10 @@ export function getUserMenu(user) {
if ($i && meId !== user.id) { if ($i && meId !== user.id) {
menu = menu.concat([null, { menu = menu.concat([null, {
icon: user.isRenoteMuted ? 'fas fa-eye' : 'fas fa-eye-slash',
text: user.isRenoteMuted ? i18n.ts.renoteUnmute : i18n.ts.renoteMute,
action: toggleRenoteMute,
}, {
icon: user.isMuted ? 'fas fa-eye' : 'fas fa-eye-slash', icon: user.isMuted ? 'fas fa-eye' : 'fas fa-eye-slash',
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
action: toggleMute, action: toggleMute,

View file

@ -12,7 +12,7 @@ export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
export function getScrollPosition(el: Element | null): number { export function getScrollPosition(el: Element | null): number {
const container = getScrollContainer(el); const container = getScrollContainer(el);
return container == null ? window.scrollY : container.scrollTop; return container?.scrollTop ?? window.scrollY;
} }
export function isTopVisible(el: Element | null): boolean { export function isTopVisible(el: Element | null): boolean {

View file

@ -411,6 +411,9 @@ export type Endpoints = {
'mute/create': { req: TODO; res: TODO; }; 'mute/create': { req: TODO; res: TODO; };
'mute/delete': { req: { userId: User['id'] }; res: null; }; 'mute/delete': { req: { userId: User['id'] }; res: null; };
'mute/list': { req: TODO; res: TODO; }; 'mute/list': { req: TODO; res: TODO; };
'renote-mute/create': { req: TODO; res: TODO; };
'renote-mute/delete': { req: { userId: User['id'] }; res: null; };
'renote-mute/list': { req: TODO; res: TODO; };
'my/apps': { req: TODO; res: TODO; }; 'my/apps': { req: TODO; res: TODO; };
'notes': { req: { limit?: number; sinceId?: Note['id']; untilId?: Note['id']; }; res: Note[]; }; 'notes': { req: { limit?: number; sinceId?: Note['id']; untilId?: Note['id']; }; res: Note[]; };
'notes/children': { req: { noteId: Note['id']; limit?: number; sinceId?: Note['id']; untilId?: Note['id']; }; res: Note[]; }; 'notes/children': { req: { noteId: Note['id']; limit?: number; sinceId?: Note['id']; untilId?: Note['id']; }; res: Note[]; };

View file

@ -53,6 +53,7 @@ export type UserDetailed = UserLite & {
isLocked: boolean; isLocked: boolean;
isModerator: boolean; isModerator: boolean;
isMuted: boolean; isMuted: boolean;
isRenoteMuted: boolean;
isSilenced: boolean; isSilenced: boolean;
isSuspended: boolean; isSuspended: boolean;
lang: string | null; lang: string | null;

View file

@ -3989,10 +3989,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"browser-image-resizer@git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.2": "browser-image-resizer@npm:2.4.1":
version: 2.2.1-misskey.2 version: 2.4.1
resolution: "browser-image-resizer@https://github.com/misskey-dev/browser-image-resizer.git#commit=a58834f5fe2af9f9f31ff115121aef3de6f9d416" resolution: "browser-image-resizer@npm:2.4.1"
checksum: eb5ddfe7f6a2de96340ef420df9be03a75f2bfd8f568a60be22cc8f2ccdcb754105b7799cf706c09add3f9d82b7ce1c6f963842f80a85f234b26ef6bf1b8da09 checksum: b2f705696f2643240880314926f976b473452a7ef1335dcc888993281c8a8e57eba2cda255965843526511a206b722be0031df770bd31ddd944bc8730521ddb6
languageName: node languageName: node
linkType: hard linkType: hard
@ -4699,7 +4699,7 @@ __metadata:
autwh: 0.1.0 autwh: 0.1.0
blurhash: 1.1.5 blurhash: 1.1.5
broadcast-channel: 4.13.0 broadcast-channel: 4.13.0
browser-image-resizer: "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.2" browser-image-resizer: 2.4.1
chart.js: 3.8.0 chart.js: 3.8.0
chartjs-adapter-date-fns: 2.0.0 chartjs-adapter-date-fns: 2.0.0
chartjs-plugin-gradient: 0.5.0 chartjs-plugin-gradient: 0.5.0