: any) | null;
readonly maxLength?: number;
readonly minLength?: number;
@@ -159,7 +161,7 @@ export type SchemaTypeDef =
p['type'] extends 'integer' ? number :
p['type'] extends 'number' ? number :
p['type'] extends 'string' ? (
- p['enum'] extends readonly string[] ?
+ p['enum'] extends readonly (string | null)[] ?
p['enum'][number] :
p['format'] extends 'date-time' ? string : // Dateにする??
string
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index 311f875ba..d29b07b02 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
-import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } from './index.js';
+import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } from './index.js';
import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common';
@@ -190,6 +190,12 @@ const $mutingsRepository: Provider = {
inject: [DI.db],
};
+const $renoteMutingsRepository: Provider = {
+ provide: DI.renoteMutingsRepository,
+ useFactory: (db: DataSource) => db.getRepository(RenoteMuting),
+ inject: [DI.db],
+};
+
const $blockingsRepository: Provider = {
provide: DI.blockingsRepository,
useFactory: (db: DataSource) => db.getRepository(Blocking),
@@ -423,6 +429,7 @@ const $roleAssignmentsRepository: Provider = {
$notificationsRepository,
$metasRepository,
$mutingsRepository,
+ $renoteMutingsRepository,
$blockingsRepository,
$swSubscriptionsRepository,
$hashtagsRepository,
@@ -489,6 +496,7 @@ const $roleAssignmentsRepository: Provider = {
$notificationsRepository,
$metasRepository,
$mutingsRepository,
+ $renoteMutingsRepository,
$blockingsRepository,
$swSubscriptionsRepository,
$hashtagsRepository,
diff --git a/packages/backend/src/models/entities/Note.ts b/packages/backend/src/models/entities/Note.ts
index 82d042f0c..df508b4dc 100644
--- a/packages/backend/src/models/entities/Note.ts
+++ b/packages/backend/src/models/entities/Note.ts
@@ -87,6 +87,11 @@ export class Note {
})
public localOnly: boolean;
+ @Column('varchar', {
+ length: 64, nullable: true,
+ })
+ public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | null;
+
@Column('smallint', {
default: 0,
})
diff --git a/packages/backend/src/models/entities/RenoteMuting.ts b/packages/backend/src/models/entities/RenoteMuting.ts
new file mode 100644
index 000000000..2f803a5fa
--- /dev/null
+++ b/packages/backend/src/models/entities/RenoteMuting.ts
@@ -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;
+}
diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts
index 25ed9b89d..4acb958b0 100644
--- a/packages/backend/src/models/index.ts
+++ b/packages/backend/src/models/index.ts
@@ -26,6 +26,7 @@ import { Meta } from '@/models/entities/Meta.js';
import { ModerationLog } from '@/models/entities/ModerationLog.js';
import { MutedNote } from '@/models/entities/MutedNote.js';
import { Muting } from '@/models/entities/Muting.js';
+import { RenoteMuting } from '@/models/entities/RenoteMuting.js';
import { Note } from '@/models/entities/Note.js';
import { NoteFavorite } from '@/models/entities/NoteFavorite.js';
import { NoteReaction } from '@/models/entities/NoteReaction.js';
@@ -93,6 +94,7 @@ export {
ModerationLog,
MutedNote,
Muting,
+ RenoteMuting,
Note,
NoteFavorite,
NoteReaction,
@@ -159,6 +161,7 @@ export type MetasRepository = Repository;
export type ModerationLogsRepository = Repository;
export type MutedNotesRepository = Repository;
export type MutingsRepository = Repository;
+export type RenoteMutingsRepository = Repository;
export type NotesRepository = Repository;
export type NoteFavoritesRepository = Repository;
export type NoteReactionsRepository = Repository;
diff --git a/packages/backend/src/models/schema/note.ts b/packages/backend/src/models/schema/note.ts
index 72c0c6228..58ef425dc 100644
--- a/packages/backend/src/models/schema/note.ts
+++ b/packages/backend/src/models/schema/note.ts
@@ -141,6 +141,10 @@ export const packedNoteSchema = {
type: 'boolean',
optional: true, nullable: false,
},
+ reactionAcceptance: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
reactions: {
type: 'object',
optional: false, nullable: false,
diff --git a/packages/backend/src/models/schema/renote-muting.ts b/packages/backend/src/models/schema/renote-muting.ts
new file mode 100644
index 000000000..69ed8510d
--- /dev/null
+++ b/packages/backend/src/models/schema/renote-muting.ts
@@ -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;
diff --git a/packages/backend/src/models/schema/user.ts b/packages/backend/src/models/schema/user.ts
index c390018b4..8c61ee1f5 100644
--- a/packages/backend/src/models/schema/user.ts
+++ b/packages/backend/src/models/schema/user.ts
@@ -234,6 +234,10 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'boolean',
nullable: false, optional: true,
},
+ isRenoteMuted: {
+ type: 'boolean',
+ nullable: false, optional: true,
+ },
//#endregion
},
} as const;
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index c2ee14b0f..741985f3a 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -34,6 +34,7 @@ import { Meta } from '@/models/entities/Meta.js';
import { ModerationLog } from '@/models/entities/ModerationLog.js';
import { MutedNote } from '@/models/entities/MutedNote.js';
import { Muting } from '@/models/entities/Muting.js';
+import { RenoteMuting } from '@/models/entities/RenoteMuting.js';
import { Note } from '@/models/entities/Note.js';
import { NoteFavorite } from '@/models/entities/NoteFavorite.js';
import { NoteReaction } from '@/models/entities/NoteReaction.js';
@@ -139,6 +140,7 @@ export const entities = [
Following,
FollowRequest,
Muting,
+ RenoteMuting,
Blocking,
Note,
NoteFavorite,
diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
index c65f0a97a..e9330772b 100644
--- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
@@ -148,6 +148,7 @@ function serialize(favorite: NoteFavorite & { note: Note & { user: User } }, pol
visibility: favorite.note.visibility,
visibleUserIds: favorite.note.visibleUserIds,
localOnly: favorite.note.localOnly,
+ reactionAcceptance: favorite.note.reactionAcceptance,
uri: favorite.note.uri,
url: favorite.note.url,
user: {
diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
index 3f4f16a2e..2f74dd63c 100644
--- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
@@ -10,10 +10,10 @@ import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js';
import type { Poll } from '@/models/entities/Poll.js';
import type { Note } from '@/models/entities/Note.js';
+import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull';
import type { DbUserJobData } from '../types.js';
-import { bindThis } from '@/decorators.js';
@Injectable()
export class ExportNotesProcessorService {
@@ -141,5 +141,6 @@ function serialize(note: Note, poll: Poll | null = null): Record {
- this.logger.error(err);
- reply.code(500);
- reply.header('Cache-Control', 'max-age=300');
- };
- }
-
@bindThis
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
fastify.addHook('onRequest', (request, reply, done) => {
@@ -140,7 +133,7 @@ export class FileServerService {
let image: IImageStreamable | null = null;
if (file.fileRole === 'thumbnail') {
- if (isMimeImage(file.mime, 'sharp-convertible-image')) {
+ if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) {
reply.header('Cache-Control', 'max-age=31536000, immutable');
const url = new URL(`${this.config.mediaProxy}/static.webp`);
@@ -190,13 +183,19 @@ export class FileServerService {
}
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream');
+ reply.header('Content-Disposition',
+ contentDisposition(
+ 'inline',
+ correctFilename(file.filename, image.ext)
+ )
+ );
return image.data;
}
if (file.fileRole !== 'original') {
- const filename = rename(file.file.name, {
+ const filename = rename(file.filename, {
suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web',
- extname: file.ext ? `.${file.ext}` : undefined,
+ extname: file.ext ? `.${file.ext}` : '.unknown',
}).toString();
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream');
@@ -204,12 +203,10 @@ export class FileServerService {
reply.header('Content-Disposition', contentDisposition('inline', filename));
return fs.createReadStream(file.path);
} else {
- const stream = fs.createReadStream(file.path);
- stream.on('error', this.commonReadableHandlerGenerator(reply));
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream');
reply.header('Cache-Control', 'max-age=31536000, immutable');
- reply.header('Content-Disposition', contentDisposition('inline', file.file.name));
- return stream;
+ reply.header('Content-Disposition', contentDisposition('inline', file.filename));
+ return fs.createReadStream(file.path);
}
} catch (e) {
if ('cleanup' in file) file.cleanup();
@@ -261,8 +258,8 @@ export class FileServerService {
}
try {
- const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image');
- const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image');
+ const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp');
+ const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp');
if (
'emoji' in request.query ||
@@ -286,7 +283,7 @@ export class FileServerService {
type: file.mime,
};
} else {
- const data = sharp(file.path, { animated: !('static' in request.query) })
+ const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) }))
.resize({
height: 'emoji' in request.query ? 128 : 320,
withoutEnlargement: true,
@@ -300,11 +297,11 @@ export class FileServerService {
};
}
} else if ('static' in request.query) {
- image = this.imageProcessingService.convertToWebpStream(file.path, 498, 280);
+ image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 280);
} else if ('preview' in request.query) {
- image = this.imageProcessingService.convertToWebpStream(file.path, 200, 200);
+ image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200);
} else if ('badge' in request.query) {
- const mask = sharp(file.path)
+ const mask = (await sharpBmp(file.path, file.mime))
.resize(96, 96, {
fit: 'inside',
withoutEnlargement: false,
@@ -360,6 +357,12 @@ export class FileServerService {
reply.header('Content-Type', image.type);
reply.header('Cache-Control', 'max-age=31536000, immutable');
+ reply.header('Content-Disposition',
+ contentDisposition(
+ 'inline',
+ correctFilename(file.filename, image.ext)
+ )
+ );
return image.data;
} catch (e) {
if ('cleanup' in file) file.cleanup();
@@ -369,8 +372,8 @@ export class FileServerService {
@bindThis
private async getStreamAndTypeFromUrl(url: string): Promise<
- { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; }
- | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; }
+ { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
+ | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; }
| '404'
| '204'
> {
@@ -386,11 +389,11 @@ export class FileServerService {
@bindThis
private async downloadAndDetectTypeFromUrl(url: string): Promise<
- { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; }
+ { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
> {
const [path, cleanup] = await createTemp();
try {
- await this.downloadService.downloadUrl(url, path);
+ const { filename } = await this.downloadService.downloadUrl(url, path);
const { mime, ext } = await this.fileInfoService.detectType(path);
@@ -398,6 +401,7 @@ export class FileServerService {
state: 'remote',
mime, ext,
path, cleanup,
+ filename,
};
} catch (e) {
cleanup();
@@ -407,8 +411,8 @@ export class FileServerService {
@bindThis
private async getFileFromKey(key: string): Promise<
- { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; }
- | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; }
+ { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; }
+ | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; }
| '404'
| '204'
> {
@@ -432,6 +436,7 @@ export class FileServerService {
url: file.uri,
fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
file,
+ filename: file.name,
};
}
@@ -443,6 +448,7 @@ export class FileServerService {
state: 'stored_internal',
fileRole: isThumbnail ? 'thumbnail' : 'webpublic',
file,
+ filename: file.name,
mime, ext,
path,
};
@@ -452,6 +458,7 @@ export class FileServerService {
state: 'stored_internal',
fileRole: 'original',
file,
+ filename: file.name,
mime: file.type,
ext: null,
path,
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index f84a3aa59..bf5cb2091 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -75,7 +75,7 @@ export class ApiCallService implements OnApplicationShutdown {
}
this.send(reply, res);
}).catch((err: ApiError) => {
- this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err);
+ this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err);
});
if (user) {
@@ -129,7 +129,7 @@ export class ApiCallService implements OnApplicationShutdown {
}, request).then((res) => {
this.send(reply, res);
}).catch((err: ApiError) => {
- this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err);
+ this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err);
});
if (user) {
@@ -321,7 +321,7 @@ export class ApiCallService implements OnApplicationShutdown {
// API invoking
return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => {
- if (err instanceof ApiError) {
+ if (err instanceof ApiError || err instanceof AuthenticationError) {
throw err;
} else {
const errId = uuid();
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index d3e2219bd..272464959 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -224,6 +224,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_delete from './endpoints/mute/delete.js';
import * as ep___mute_list from './endpoints/mute/list.js';
+import * as ep___renoteMute_create from './endpoints/renote-mute/create.js';
+import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js';
+import * as ep___renoteMute_list from './endpoints/renote-mute/list.js';
import * as ep___my_apps from './endpoints/my/apps.js';
import * as ep___notes from './endpoints/notes.js';
import * as ep___notes_children from './endpoints/notes/children.js';
@@ -545,6 +548,9 @@ const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: e
const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default };
const $mute_delete: Provider = { provide: 'ep:mute/delete', useClass: ep___mute_delete.default };
const $mute_list: Provider = { provide: 'ep:mute/list', useClass: ep___mute_list.default };
+const $renoteMute_create: Provider = { provide: 'ep:renote-mute/create', useClass: ep___renoteMute_create.default };
+const $renoteMute_delete: Provider = { provide: 'ep:renote-mute/delete', useClass: ep___renoteMute_delete.default };
+const $renoteMute_list: Provider = { provide: 'ep:renote-mute/list', useClass: ep___renoteMute_list.default };
const $my_apps: Provider = { provide: 'ep:my/apps', useClass: ep___my_apps.default };
const $notes: Provider = { provide: 'ep:notes', useClass: ep___notes.default };
const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep___notes_children.default };
@@ -870,6 +876,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$mute_create,
$mute_delete,
$mute_list,
+ $renoteMute_create,
+ $renoteMute_delete,
+ $renoteMute_list,
$my_apps,
$notes,
$notes_children,
@@ -1189,6 +1198,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$mute_create,
$mute_delete,
$mute_list,
+ $renoteMute_create,
+ $renoteMute_delete,
+ $renoteMute_list,
$my_apps,
$notes,
$notes_children,
diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts
index 41e8365d0..fbabf47af 100644
--- a/packages/backend/src/server/api/SignupApiService.ts
+++ b/packages/backend/src/server/api/SignupApiService.ts
@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import rndstr from 'rndstr';
import bcrypt from 'bcryptjs';
import { DI } from '@/di-symbols.js';
-import type { RegistrationTicketsRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
+import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import { MetaService } from '@/core/MetaService.js';
import { CaptchaService } from '@/core/CaptchaService.js';
@@ -15,6 +15,7 @@ import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
import { bindThis } from '@/decorators.js';
import { SigninService } from './SigninService.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
+import { IsNull } from 'typeorm';
@Injectable()
export class SignupApiService {
@@ -31,6 +32,9 @@ export class SignupApiService {
@Inject(DI.userPendingsRepository)
private userPendingsRepository: UserPendingsRepository,
+ @Inject(DI.usedUsernamesRepository)
+ private usedUsernamesRepository: UsedUsernamesRepository,
+
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
@@ -124,12 +128,21 @@ export class SignupApiService {
}
if (instance.emailRequiredForSignup) {
+ if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) {
+ throw new FastifyReplyError(400, 'DUPLICATED_USERNAME');
+ }
+
+ // Check deleted username duplication
+ if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) {
+ throw new FastifyReplyError(400, 'USED_USERNAME');
+ }
+
const code = rndstr('a-z0-9', 16);
-
+
// Generate hash of password
const salt = await bcrypt.genSalt(8);
const hash = await bcrypt.hash(password, salt);
-
+
await this.userPendingsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
@@ -138,13 +151,13 @@ export class SignupApiService {
username: username,
password: hash,
});
-
+
const link = `${this.config.url}/signup-complete/${code}`;
-
+
this.emailService.sendEmail(emailAddress!, 'Signup',
`To complete signup, please click this link:
${link}`,
`To complete signup, please click this link: ${link}`);
-
+
reply.code(204);
return;
} else {
diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts
index 487eef2d5..13526f277 100644
--- a/packages/backend/src/server/api/StreamingApiServerService.ts
+++ b/packages/backend/src/server/api/StreamingApiServerService.ts
@@ -3,17 +3,17 @@ import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import * as websocket from 'websocket';
import { DI } from '@/di-symbols.js';
-import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository } from '@/models/index.js';
+import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import { NoteReadService } from '@/core/NoteReadService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { NotificationService } from '@/core/NotificationService.js';
+import { bindThis } from '@/decorators.js';
import { AuthenticateService } from './AuthenticateService.js';
import MainStreamConnection from './stream/index.js';
import { ChannelsService } from './stream/ChannelsService.js';
import type { ParsedUrlQuery } from 'querystring';
import type * as http from 'node:http';
-import { bindThis } from '@/decorators.js';
@Injectable()
export class StreamingApiServerService {
@@ -33,6 +33,9 @@ export class StreamingApiServerService {
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
+ @Inject(DI.renoteMutingsRepository)
+ private renoteMutingsRepository: RenoteMutingsRepository,
+
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@@ -84,6 +87,7 @@ export class StreamingApiServerService {
const main = new MainStreamConnection(
this.followingsRepository,
this.mutingsRepository,
+ this.renoteMutingsRepository,
this.blockingsRepository,
this.channelFollowingsRepository,
this.userProfilesRepository,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 4f521148e..1f01865e0 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -224,6 +224,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_delete from './endpoints/mute/delete.js';
import * as ep___mute_list from './endpoints/mute/list.js';
+import * as ep___renoteMute_create from './endpoints/renote-mute/create.js';
+import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js';
+import * as ep___renoteMute_list from './endpoints/renote-mute/list.js';
import * as ep___my_apps from './endpoints/my/apps.js';
import * as ep___notes from './endpoints/notes.js';
import * as ep___notes_children from './endpoints/notes/children.js';
@@ -543,6 +546,9 @@ const eps = [
['mute/create', ep___mute_create],
['mute/delete', ep___mute_delete],
['mute/list', ep___mute_list],
+ ['renote-mute/create', ep___renoteMute_create],
+ ['renote-mute/delete', ep___renoteMute_delete],
+ ['renote-mute/list', ep___renoteMute_list],
['my/apps', ep___my_apps],
['notes', ep___notes],
['notes/children', ep___notes_children],
diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts
index d006e89bd..a86cc2565 100644
--- a/packages/backend/src/server/api/endpoints/channels/update.ts
+++ b/packages/backend/src/server/api/endpoints/channels/update.ts
@@ -4,6 +4,7 @@ import type { DriveFilesRepository, ChannelsRepository } from '@/models/index.js
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
+import { RoleService } from '@/core/RoleService.js';
export const meta = {
tags: ['channels'],
@@ -61,7 +62,9 @@ export default class extends Endpoint {
private driveFilesRepository: DriveFilesRepository,
private channelEntityService: ChannelEntityService,
- ) {
+
+ private roleService: RoleService,
+ ) {
super(meta, paramDef, async (ps, me) => {
const channel = await this.channelsRepository.findOneBy({
id: ps.channelId,
@@ -71,7 +74,8 @@ export default class extends Endpoint {
throw new ApiError(meta.errors.noSuchChannel);
}
- if (channel.userId !== me.id) {
+ const iAmModerator = await this.roleService.isModerator(me);
+ if (channel.userId !== me.id && !iAmModerator) {
throw new ApiError(meta.errors.accessDenied);
}
diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts
index 60b24e958..061c6eb5b 100644
--- a/packages/backend/src/server/api/endpoints/federation/instances.ts
+++ b/packages/backend/src/server/api/endpoints/federation/instances.ts
@@ -76,9 +76,9 @@ export default class extends Endpoint {
if (typeof ps.blocked === 'boolean') {
const meta = await this.metaService.fetch(true);
if (ps.blocked) {
- query.andWhere('instance.host IN (:...blocks)', { blocks: meta.blockedHosts });
+ query.andWhere(meta.blockedHosts.length === 0 ? '1=0' : 'instance.host IN (:...blocks)', { blocks: meta.blockedHosts });
} else {
- query.andWhere('instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts });
+ query.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts });
}
}
diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts
index 6beef5ab8..a3e3e02a1 100644
--- a/packages/backend/src/server/api/endpoints/i.ts
+++ b/packages/backend/src/server/api/endpoints/i.ts
@@ -3,6 +3,7 @@ import type { UserProfilesRepository, UsersRepository } from '@/models/index.js'
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js';
+import { ApiError } from '../error.js';
export const meta = {
tags: ['account'],
@@ -14,6 +15,15 @@ export const meta = {
optional: false, nullable: false,
ref: 'MeDetailed',
},
+
+ errors: {
+ userIsDeleted: {
+ message: 'User is deleted.',
+ code: 'USER_IS_DELETED',
+ id: 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a',
+ kind: 'permission',
+ },
+ }
} as const;
export const paramDef = {
@@ -41,13 +51,17 @@ export default class extends Endpoint {
const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`;
// 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得
- const userProfile = await this.userProfilesRepository.findOneOrFail({
+ const userProfile = await this.userProfilesRepository.findOne({
where: {
userId: user.id,
},
relations: ['user'],
});
+ if (userProfile == null) {
+ throw new ApiError(meta.errors.userIsDeleted);
+ }
+
if (!userProfile.loggedInDates.includes(today)) {
this.userProfilesRepository.update({ userId: user.id }, {
loggedInDates: [...userProfile.loggedInDates, today],
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index 786ad103b..69fafcb9c 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -97,6 +97,7 @@ export const paramDef = {
} },
cw: { type: 'string', nullable: true, maxLength: 100 },
localOnly: { type: 'boolean', default: false },
+ reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote'], default: null },
noExtractMentions: { type: 'boolean', default: false },
noExtractHashtags: { type: 'boolean', default: false },
noExtractEmojis: { type: 'boolean', default: false },
@@ -110,7 +111,7 @@ export const paramDef = {
type: 'string',
minLength: 1,
maxLength: MAX_NOTE_TEXT_LENGTH,
- nullable: false
+ nullable: false,
},
fileIds: {
type: 'array',
@@ -280,6 +281,7 @@ export default class extends Endpoint {
renote,
cw: ps.cw,
localOnly: ps.localOnly,
+ reactionAcceptance: ps.reactionAcceptance,
visibility: ps.visibility,
visibleUsers,
channel,
diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts
index cf939f663..6bf17b222 100644
--- a/packages/backend/src/server/api/endpoints/notes/featured.ts
+++ b/packages/backend/src/server/api/endpoints/notes/featured.ts
@@ -71,7 +71,7 @@ export default class extends Endpoint {
let notes = await query
.orderBy('note.score', 'DESC')
- .take(50)
+ .take(100)
.getMany();
notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
index 5d0cdc3fc..9118d3393 100644
--- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
@@ -89,6 +89,7 @@ export default class extends Endpoint {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
if (ps.withFiles) {
diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
index 2819abb12..3802ae540 100644
--- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
@@ -107,6 +107,7 @@ export default class extends Endpoint {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
index 18ed6d4e2..381001695 100644
--- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
@@ -95,6 +95,7 @@ export default class extends Endpoint {
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateMutedNoteQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me);
+ if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts
index e6de087c4..5ce436ee1 100644
--- a/packages/backend/src/server/api/endpoints/notes/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts
@@ -93,6 +93,7 @@ export default class extends Endpoint {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts
new file mode 100644
index 000000000..051a005b6
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts
@@ -0,0 +1,99 @@
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { IdService } from '@/core/IdService.js';
+import type { RenoteMutingsRepository } from '@/models/index.js';
+import type { RenoteMuting } from '@/models/entities/RenoteMuting.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { DI } from '@/di-symbols.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+ tags: ['account'],
+
+ requireCredential: true,
+
+ kind: 'write:mutes',
+
+ limit: {
+ duration: ms('1hour'),
+ max: 20,
+ },
+
+ errors: {
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '5e0a5dff-1e94-4202-87ae-4d9c89eb2271',
+ },
+
+ muteeIsYourself: {
+ message: 'Mutee is yourself.',
+ code: 'MUTEE_IS_YOURSELF',
+ id: '37285718-52f7-4aef-b7de-c38b8e8a8420',
+ },
+
+ alreadyMuting: {
+ message: 'You are already muting that user.',
+ code: 'ALREADY_MUTING',
+ id: 'ccfecbe4-1f1c-4fc2-8a3d-c3ffee61cb7b',
+ },
+ },
+} 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
+@Injectable()
+export default class extends Endpoint {
+ constructor(
+ @Inject(DI.renoteMutingsRepository)
+ private renoteMutingsRepository: RenoteMutingsRepository,
+
+ private globalEventService: GlobalEventService,
+ private getterService: GetterService,
+ private idService: IdService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const muter = me;
+
+ // 自分自身
+ if (me.id === ps.userId) {
+ throw new ApiError(meta.errors.muteeIsYourself);
+ }
+
+ // Get mutee
+ const mutee = await getterService.getUser(ps.userId).catch(err => {
+ if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw err;
+ });
+
+ // Check if already muting
+ const exist = await this.renoteMutingsRepository.findOneBy({
+ muterId: muter.id,
+ muteeId: mutee.id,
+ });
+
+ if (exist != null) {
+ throw new ApiError(meta.errors.alreadyMuting);
+ }
+
+ // Create mute
+ await this.renoteMutingsRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ muterId: muter.id,
+ muteeId: mutee.id,
+ } as RenoteMuting);
+
+ // publishUserEvent(user.id, 'mute', mutee);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts
new file mode 100644
index 000000000..51a895fb7
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts
@@ -0,0 +1,87 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { RenoteMutingsRepository } from '@/models/index.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { DI } from '@/di-symbols.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+ tags: ['account'],
+
+ requireCredential: true,
+
+ kind: 'write:mutes',
+
+ errors: {
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '9b6728cf-638c-4aa1-bedb-e07d8101474d',
+ },
+
+ muteeIsYourself: {
+ message: 'Mutee is yourself.',
+ code: 'MUTEE_IS_YOURSELF',
+ id: '619b1314-0850-4597-a242-e245f3da42af',
+ },
+
+ notMuting: {
+ message: 'You are not muting that user.',
+ code: 'NOT_MUTING',
+ id: '2e4ef874-8bf0-4b4b-b069-4598f6d05817',
+ },
+ },
+} 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
+@Injectable()
+export default class extends Endpoint {
+ constructor(
+ @Inject(DI.renoteMutingsRepository)
+ private renoteMutingsRepository: RenoteMutingsRepository,
+
+ private globalEventService: GlobalEventService,
+ private getterService: GetterService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const muter = me;
+
+ // Check if the mutee is yourself
+ if (me.id === ps.userId) {
+ throw new ApiError(meta.errors.muteeIsYourself);
+ }
+
+ // Get mutee
+ const mutee = await this.getterService.getUser(ps.userId).catch(err => {
+ if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw err;
+ });
+
+ // Check not muting
+ const exist = await this.renoteMutingsRepository.findOneBy({
+ muterId: muter.id,
+ muteeId: mutee.id,
+ });
+
+ if (exist == null) {
+ throw new ApiError(meta.errors.notMuting);
+ }
+
+ // Delete mute
+ await this.renoteMutingsRepository.delete({
+ id: exist.id,
+ });
+
+ // publishUserEvent(user.id, 'unmute', mutee);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/renote-mute/list.ts b/packages/backend/src/server/api/endpoints/renote-mute/list.ts
new file mode 100644
index 000000000..b2d7addb6
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/renote-mute/list.ts
@@ -0,0 +1,57 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { RenoteMutingsRepository } from '@/models/index.js';
+import { QueryService } from '@/core/QueryService.js';
+import { RenoteMutingEntityService } from '@/core/entities/RenoteMutingEntityService.js';
+import { DI } from '@/di-symbols.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
+@Injectable()
+export default class extends Endpoint {
+ constructor(
+ @Inject(DI.renoteMutingsRepository)
+ private renoteMutingsRepository: RenoteMutingsRepository,
+
+ private renoteMutingEntityService: RenoteMutingEntityService,
+ private queryService: QueryService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const query = this.queryService.makePaginationQuery(this.renoteMutingsRepository.createQueryBuilder('muting'), ps.sinceId, ps.untilId)
+ .andWhere('muting.muterId = :meId', { meId: me.id });
+
+ const mutings = await query
+ .take(ps.limit)
+ .getMany();
+
+ return await this.renoteMutingEntityService.packMany(mutings, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts
index ac9104bf9..3267c1884 100644
--- a/packages/backend/src/server/api/endpoints/users/relation.ts
+++ b/packages/backend/src/server/api/endpoints/users/relation.ts
@@ -50,6 +50,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
+ isRenoteMuted: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
},
},
{
@@ -91,6 +95,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
+ isRenoteMuted: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
},
},
},
diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts
index 347d5650a..34f452160 100644
--- a/packages/backend/src/server/api/error.ts
+++ b/packages/backend/src/server/api/error.ts
@@ -1,4 +1,4 @@
-type E = { message: string, code: string, id: string, kind?: 'client' | 'server', httpStatusCode?: number };
+type E = { message: string, code: string, id: string, kind?: 'client' | 'server' | 'permission', httpStatusCode?: number };
export class ApiError extends Error {
public message: string;
diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts
index 3e67880b4..32935325a 100644
--- a/packages/backend/src/server/api/stream/channel.ts
+++ b/packages/backend/src/server/api/stream/channel.ts
@@ -27,6 +27,10 @@ export default abstract class Channel {
return this.connection.muting;
}
+ protected get renoteMuting() {
+ return this.connection.renoteMuting;
+ }
+
protected get blocking() {
return this.connection.blocking;
}
diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts
index 18604d94f..e2a42fbfe 100644
--- a/packages/backend/src/server/api/stream/channels/antenna.ts
+++ b/packages/backend/src/server/api/stream/channels/antenna.ts
@@ -39,6 +39,8 @@ class AntennaChannel extends Channel {
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
+ if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
+
this.connection.cacheNote(note);
this.send('note', note);
diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts
index f5ef1d110..1234738ce 100644
--- a/packages/backend/src/server/api/stream/channels/channel.ts
+++ b/packages/backend/src/server/api/stream/channels/channel.ts
@@ -51,6 +51,8 @@ class ChannelChannel extends Channel {
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
+ if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
+
this.connection.cacheNote(note);
this.send('note', note);
diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts
index b8c0076ed..ab439a171 100644
--- a/packages/backend/src/server/api/stream/channels/global-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts
@@ -68,6 +68,8 @@ class GlobalTimelineChannel extends Channel {
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
+ if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
+
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts
index 00f8d8ecd..63a2dd5b3 100644
--- a/packages/backend/src/server/api/stream/channels/hashtag.ts
+++ b/packages/backend/src/server/api/stream/channels/hashtag.ts
@@ -49,6 +49,8 @@ class HashtagChannel extends Channel {
if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
+
+ if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
this.connection.cacheNote(note);
diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts
index 04a9f2968..678fbe12d 100644
--- a/packages/backend/src/server/api/stream/channels/home-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts
@@ -75,6 +75,8 @@ class HomeTimelineChannel extends Channel {
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
+ if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
+
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
index ab52aabb3..e33a28049 100644
--- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
@@ -86,6 +86,8 @@ class HybridTimelineChannel extends Channel {
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
+ if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
+
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts
index d8532c477..341c4e32c 100644
--- a/packages/backend/src/server/api/stream/channels/local-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts
@@ -65,6 +65,8 @@ class LocalTimelineChannel extends Channel {
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
+ if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
+
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts
index 7254d0a6d..e7899245b 100644
--- a/packages/backend/src/server/api/stream/channels/user-list.ts
+++ b/packages/backend/src/server/api/stream/channels/user-list.ts
@@ -93,6 +93,8 @@ class UserListChannel extends Channel {
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
+ if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
+
this.send('note', note);
}
diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts
index d3056aca5..0a4fd8393 100644
--- a/packages/backend/src/server/api/stream/index.ts
+++ b/packages/backend/src/server/api/stream/index.ts
@@ -1,6 +1,6 @@
import type { User } from '@/models/entities/User.js';
import type { Channel as ChannelModel } from '@/models/entities/Channel.js';
-import type { FollowingsRepository, MutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js';
+import type { FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js';
import type { AccessToken } from '@/models/entities/AccessToken.js';
import type { UserProfile } from '@/models/entities/UserProfile.js';
import type { Packed } from '@/misc/schema.js';
@@ -22,6 +22,7 @@ export default class Connection {
public userProfile?: UserProfile | null;
public following: Set = new Set();
public muting: Set = new Set();
+ public renoteMuting: Set = new Set();
public blocking: Set = new Set(); // "被"blocking
public followingChannels: Set = new Set();
public token?: AccessToken;
@@ -34,6 +35,7 @@ export default class Connection {
constructor(
private followingsRepository: FollowingsRepository,
private mutingsRepository: MutingsRepository,
+ private renoteMutingsRepository: RenoteMutingsRepository,
private blockingsRepository: BlockingsRepository,
private channelFollowingsRepository: ChannelFollowingsRepository,
private userProfilesRepository: UserProfilesRepository,
@@ -66,6 +68,7 @@ export default class Connection {
if (this.user) {
this.updateFollowing();
this.updateMuting();
+ this.updateRenoteMuting();
this.updateBlocking();
this.updateFollowingChannels();
this.updateUserProfile();
@@ -93,6 +96,7 @@ export default class Connection {
this.muting.delete(data.body.id);
break;
+ // TODO: renote mute events
// TODO: block events
case 'followChannel':
@@ -342,6 +346,18 @@ export default class Connection {
this.muting = new Set(mutings.map(x => x.muteeId));
}
+ @bindThis
+ private async updateRenoteMuting() {
+ const renoteMutings = await this.renoteMutingsRepository.find({
+ where: {
+ muterId: this.user!.id,
+ },
+ select: ['muteeId'],
+ });
+
+ this.renoteMuting = new Set(renoteMutings.map(x => x.muteeId));
+ }
+
@bindThis
private async updateBlocking() { // ここでいうBlockingは被Blockingの意
const blockings = await this.blockingsRepository.find({
diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js
index c6cb25e43..fd7f54da5 100644
--- a/packages/backend/src/server/web/boot.js
+++ b/packages/backend/src/server/web/boot.js
@@ -61,6 +61,13 @@
renderError('META_FETCH_V');
return;
}
+
+ // for https://github.com/misskey-dev/misskey/issues/10202
+ if (lang == null || lang.toString == null || lang.toString() === 'null') {
+ console.error('invalid lang value detected!!!', typeof lang, lang);
+ lang = 'en-US';
+ }
+
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
if (localRes.status === 200) {
localStorage.setItem('lang', lang);
diff --git a/packages/backend/test/e2e/block.ts b/packages/backend/test/e2e/block.ts
index 4e9030f85..5fee2b93a 100644
--- a/packages/backend/test/e2e/block.ts
+++ b/packages/backend/test/e2e/block.ts
@@ -70,9 +70,9 @@ describe('Block', () => {
// TODO: ユーザーリストから除外されるテスト
test('タイムライン(LTL)にブロックされているユーザーの投稿が含まれない', async () => {
- const aliceNote = await post(alice);
- const bobNote = await post(bob);
- const carolNote = await post(carol);
+ const aliceNote = await post(alice, { text: 'hi' });
+ const bobNote = await post(bob, { text: 'hi' });
+ const carolNote = await post(carol, { text: 'hi' });
const res = await api('/notes/local-timeline', {}, bob);
diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts
index e864eab6c..c13009373 100644
--- a/packages/backend/test/e2e/endpoints.ts
+++ b/packages/backend/test/e2e/endpoints.ts
@@ -206,7 +206,7 @@ describe('Endpoints', () => {
describe('notes/reactions/create', () => {
test('リアクションできる', async () => {
- const bobPost = await post(bob);
+ const bobPost = await post(bob, { text: 'hi' });
const res = await api('/notes/reactions/create', {
noteId: bobPost.id,
@@ -224,7 +224,7 @@ describe('Endpoints', () => {
});
test('自分の投稿にもリアクションできる', async () => {
- const myPost = await post(alice);
+ const myPost = await post(alice, { text: 'hi' });
const res = await api('/notes/reactions/create', {
noteId: myPost.id,
@@ -235,7 +235,7 @@ describe('Endpoints', () => {
});
test('二重にリアクションすると上書きされる', async () => {
- const bobPost = await post(bob);
+ const bobPost = await post(bob, { text: 'hi' });
await api('/notes/reactions/create', {
noteId: bobPost.id,
@@ -410,11 +410,19 @@ describe('Endpoints', () => {
});
test('ファイルに名前を付けられる', async () => {
+ const res = await uploadFile(alice, { name: 'Belmond.jpg' });
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
+ assert.strictEqual(res.body.name, 'Belmond.jpg');
+ });
+
+ test('ファイルに名前を付けられるが、拡張子は正しいものになる', async () => {
const res = await uploadFile(alice, { name: 'Belmond.png' });
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
- assert.strictEqual(res.body.name, 'Belmond.png');
+ assert.strictEqual(res.body.name, 'Belmond.png.jpg');
});
test('ファイル無しで怒られる', async () => {
diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts
index 6654a290b..5811d6baf 100644
--- a/packages/backend/test/e2e/mute.ts
+++ b/packages/backend/test/e2e/mute.ts
@@ -76,9 +76,9 @@ describe('Mute', () => {
describe('Timeline', () => {
test('タイムラインにミュートしているユーザーの投稿が含まれない', async () => {
- const aliceNote = await post(alice);
- const bobNote = await post(bob);
- const carolNote = await post(carol);
+ const aliceNote = await post(alice, { text: 'hi' });
+ const bobNote = await post(bob, { text: 'hi' });
+ const carolNote = await post(carol, { text: 'hi' });
const res = await api('/notes/local-timeline', {}, alice);
@@ -90,8 +90,8 @@ describe('Mute', () => {
});
test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => {
- const aliceNote = await post(alice);
- const carolNote = await post(carol);
+ const aliceNote = await post(alice, { text: 'hi' });
+ const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, {
renoteId: carolNote.id,
});
@@ -108,7 +108,7 @@ describe('Mute', () => {
describe('Notification', () => {
test('通知にミュートしているユーザーの通知が含まれない(リアクション)', async () => {
- const aliceNote = await post(alice);
+ const aliceNote = await post(alice, { text: 'hi' });
await react(bob, aliceNote, 'like');
await react(carol, aliceNote, 'like');
diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts
index 98ee34d8d..1b5f9580d 100644
--- a/packages/backend/test/e2e/note.ts
+++ b/packages/backend/test/e2e/note.ts
@@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { Note } from '@/models/entities/Note.js';
-import { signup, post, uploadUrl, startServer, initTestDb, api } from '../utils.js';
+import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common';
describe('Note', () => {
@@ -213,6 +213,122 @@ describe('Note', () => {
assert.deepStrictEqual(noteDoc.mentions, [bob.id]);
});
+ describe('添付ファイル情報', () => {
+ test('ファイルを添付した場合、投稿成功時にファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const res = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
+ assert.strictEqual(res.body.createdNote.files.length, 1);
+ assert.strictEqual(res.body.createdNote.files[0].id, file.body.id);
+ });
+
+ test('ファイルを添付した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const res = await api('/notes', {
+ withFiles: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.files.length, 1);
+ assert.strictEqual(myNote.files[0].id, file.body.id);
+ });
+
+ test('ファイルが添付されたノートをリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const renoted = await api('/notes/create', {
+ renoteId: createdNote.body.createdNote.id,
+ }, alice);
+ assert.strictEqual(renoted.status, 200);
+
+ const res = await api('/notes', {
+ renote: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.renote.files.length, 1);
+ assert.strictEqual(myNote.renote.files[0].id, file.body.id);
+ });
+
+ test('ファイルが添付されたノートに返信した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const reply = await api('/notes/create', {
+ replyId: createdNote.body.createdNote.id,
+ text: 'this is reply',
+ }, alice);
+ assert.strictEqual(reply.status, 200);
+
+ const res = await api('/notes', {
+ reply: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.reply.files.length, 1);
+ assert.strictEqual(myNote.reply.files[0].id, file.body.id);
+ });
+
+ test('ファイルが添付されたノートへの返信をリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const reply = await api('/notes/create', {
+ replyId: createdNote.body.createdNote.id,
+ text: 'this is reply',
+ }, alice);
+ assert.strictEqual(reply.status, 200);
+
+ const renoted = await api('/notes/create', {
+ renoteId: reply.body.createdNote.id,
+ }, alice);
+ assert.strictEqual(renoted.status, 200);
+
+ const res = await api('/notes', {
+ renote: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.renote.reply.files.length, 1);
+ assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id);
+ });
+ });
+
describe('notes/create', () => {
test('投票を添付できる', async () => {
const res = await api('/notes/create', {
diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts
new file mode 100644
index 000000000..32c8ebe2c
--- /dev/null
+++ b/packages/backend/test/e2e/renote-mute.ts
@@ -0,0 +1,85 @@
+process.env.NODE_ENV = 'test';
+
+import * as assert from 'assert';
+import { signup, api, post, react, startServer, waitFire } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
+
+describe('Renote Mute', () => {
+ let p: INestApplicationContext;
+
+ // alice mutes carol
+ let alice: any;
+ let bob: any;
+ let carol: any;
+
+ beforeAll(async () => {
+ p = await startServer();
+ alice = await signup({ username: 'alice' });
+ bob = await signup({ username: 'bob' });
+ carol = await signup({ username: 'carol' });
+ }, 1000 * 60 * 2);
+
+ afterAll(async () => {
+ await p.close();
+ });
+
+ test('ミュート作成', async () => {
+ const res = await api('/renote-mute/create', {
+ userId: carol.id,
+ }, alice);
+
+ assert.strictEqual(res.status, 204);
+ });
+
+ test('タイムラインにリノートミュートしているユーザーのリノートが含まれない', async () => {
+ const bobNote = await post(bob, { text: 'hi' });
+ const carolRenote = await post(carol, { renoteId: bobNote.id });
+ const carolNote = await post(carol, { text: 'hi' });
+
+ const res = await api('/notes/local-timeline', {}, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
+ assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), false);
+ assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
+ });
+
+ test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => {
+ const bobNote = await post(bob, { text: 'hi' });
+ const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' });
+ const carolNote = await post(carol, { text: 'hi' });
+
+ const res = await api('/notes/local-timeline', {}, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
+ assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), true);
+ assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
+ });
+
+ test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => {
+ const bobNote = await post(bob, { text: 'hi' });
+
+ const fired = await waitFire(
+ alice, 'localTimeline',
+ () => api('notes/create', { renoteId: bobNote.id }, carol),
+ msg => msg.type === 'note' && msg.body.userId === carol.id,
+ );
+
+ assert.strictEqual(fired, false);
+ });
+
+ test('ストリームにリノートミュートしているユーザーの引用が流れる', async () => {
+ const bobNote = await post(bob, { text: 'hi' });
+
+ const fired = await waitFire(
+ alice, 'localTimeline',
+ () => api('notes/create', { renoteId: bobNote.id, text: 'kore' }, carol),
+ msg => msg.type === 'note' && msg.body.userId === carol.id,
+ );
+
+ assert.strictEqual(fired, true);
+ });
+});
diff --git a/packages/backend/test/unit/misc/others.ts b/packages/backend/test/unit/misc/others.ts
new file mode 100644
index 000000000..c476aef33
--- /dev/null
+++ b/packages/backend/test/unit/misc/others.ts
@@ -0,0 +1,42 @@
+import { describe, test, expect } from '@jest/globals';
+import { contentDisposition } from '@/misc/content-disposition.js';
+import { correctFilename } from '@/misc/correct-filename.js';
+
+describe('misc:content-disposition', () => {
+ test('inline', () => {
+ expect(contentDisposition('inline', 'foo bar')).toBe('inline; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar');
+ });
+ test('attachment', () => {
+ expect(contentDisposition('attachment', 'foo bar')).toBe('attachment; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar');
+ });
+ test('non ascii', () => {
+ expect(contentDisposition('attachment', 'ファイル名')).toBe('attachment; filename=\"_____\"; filename*=UTF-8\'\'%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D');
+ });
+});
+
+describe('misc:correct-filename', () => {
+ test('simple', () => {
+ expect(correctFilename('filename', 'jpg')).toBe('filename.jpg');
+ });
+ test('with same ext', () => {
+ expect(correctFilename('filename.jpg', 'jpg')).toBe('filename.jpg');
+ });
+ test('.ext', () => {
+ expect(correctFilename('filename.jpg', '.jpg')).toBe('filename.jpg');
+ });
+ test('with different ext', () => {
+ expect(correctFilename('filename.webp', 'jpg')).toBe('filename.webp.jpg');
+ });
+ test('non ascii with space', () => {
+ expect(correctFilename('ファイル 名前', 'jpg')).toBe('ファイル 名前.jpg');
+ });
+ test('jpeg', () => {
+ expect(correctFilename('filename.jpeg', 'jpg')).toBe('filename.jpeg');
+ });
+ test('tiff', () => {
+ expect(correctFilename('filename.tiff', 'tif')).toBe('filename.tiff');
+ });
+ test('null ext', () => {
+ expect(correctFilename('filename', null)).toBe('filename.unknown');
+ });
+});
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index 8203e4935..37e5ae10d 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -57,9 +57,7 @@ export const signup = async (params?: any): Promise => {
};
export const post = async (user: any, params?: misskey.Endpoints['notes/create']['req']): Promise => {
- const q = Object.assign({
- text: 'test',
- }, params);
+ const q = params;
const res = await api('notes/create', q, user);
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index e4c04f593..add5eabdd 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -4,6 +4,8 @@
"scripts": {
"watch": "vite",
"build": "vite build",
+ "test": "vitest --run",
+ "test-and-coverage": "vitest --run --coverage",
"typecheck": "vue-tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.{ts,vue}\"",
"lint": "pnpm typecheck && pnpm eslint"
@@ -70,6 +72,7 @@
"vuedraggable": "next"
},
"devDependencies": {
+ "@testing-library/vue": "^6.6.1",
"@types/escape-regexp": "0.0.1",
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1",
@@ -85,13 +88,16 @@
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.53.0",
"@typescript-eslint/parser": "5.53.0",
+ "@vitest/coverage-c8": "^0.29.2",
"@vue/runtime-core": "3.2.47",
"cross-env": "7.0.3",
"cypress": "12.7.0",
"eslint": "8.35.0",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-vue": "9.9.0",
+ "happy-dom": "8.9.0",
"start-server-and-test": "1.15.4",
+ "vitest": "^0.29.2",
"vue-eslint-parser": "9.1.0",
"vue-tsc": "1.2.0"
}
diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts
index 610212b6e..9b104391d 100644
--- a/packages/frontend/src/account.ts
+++ b/packages/frontend/src/account.ts
@@ -1,4 +1,4 @@
-import { defineAsyncComponent, reactive } from 'vue';
+import { defineAsyncComponent, reactive, ref } from 'vue';
import * as misskey from 'misskey-js';
import { showSuspendedDialog } from './scripts/show-suspended-dialog';
import { i18n } from './i18n';
@@ -7,6 +7,7 @@ import { del, get, set } from '@/scripts/idb-proxy';
import { apiUrl } from '@/config';
import { waiting, api, popup, popupMenu, success, alert } from '@/os';
import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
+import { MenuButton } from './types/menu';
// TODO: 他のタブと永続化されたstateを同期
@@ -26,11 +27,11 @@ export function incNotesCount() {
}
export async function signout() {
+ if (!$i) return;
+
waiting();
miLocalStorage.removeItem('account');
-
await removeAccount($i.id);
-
const accounts = await getAccounts();
//#region Remove service worker registration
@@ -76,15 +77,19 @@ export async function addAccount(id: Account['id'], token: Account['token']) {
}
}
-export async function removeAccount(id: Account['id']) {
+export async function removeAccount(idOrToken: Account['id']) {
const accounts = await getAccounts();
- accounts.splice(accounts.findIndex(x => x.id === id), 1);
+ const i = accounts.findIndex(x => x.id === idOrToken || x.token === idOrToken);
+ if (i !== -1) accounts.splice(i, 1);
- if (accounts.length > 0) await set('accounts', accounts);
- else await del('accounts');
+ if (accounts.length > 0) {
+ await set('accounts', accounts);
+ } else {
+ await del('accounts');
+ }
}
-function fetchAccount(token: string): Promise {
+function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise {
return new Promise((done, fail) => {
// Fetch user
window.fetch(`${apiUrl}/i`, {
@@ -96,44 +101,94 @@ function fetchAccount(token: string): Promise {
'Content-Type': 'application/json',
},
})
- .then(res => res.json())
- .then(res => {
- if (res.error) {
- if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
- showSuspendedDialog().then(() => {
- signout();
- });
- } else {
- alert({
+ .then(res => new Promise }>((done2, fail2) => {
+ if (res.status >= 500 && res.status < 600) {
+ // サーバーエラー(5xx)の場合をrejectとする
+ // (認証エラーなど4xxはresolve)
+ return fail2(res);
+ }
+ res.json().then(done2, fail2);
+ }))
+ .then(async res => {
+ if (res.error) {
+ if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
+ // SUSPENDED
+ if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
+ await showSuspendedDialog();
+ }
+ } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') {
+ // USER_IS_DELETED
+ // アカウントが削除されている
+ if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
+ await alert({
type: 'error',
- title: i18n.ts.failedToFetchAccountInformation,
- text: JSON.stringify(res.error),
+ title: i18n.ts.accountDeleted,
+ text: i18n.ts.accountDeletedDescription,
+ });
+ }
+ } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') {
+ // AUTHENTICATION_FAILED
+ // トークンが無効化されていたりアカウントが削除されたりしている
+ if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
+ await alert({
+ type: 'error',
+ title: i18n.ts.tokenRevoked,
+ text: i18n.ts.tokenRevokedDescription,
});
}
} else {
- res.token = token;
- done(res);
+ await alert({
+ type: 'error',
+ title: i18n.ts.failedToFetchAccountInformation,
+ text: JSON.stringify(res.error),
+ });
}
- })
- .catch(fail);
+
+ // rejectかつ理由がtrueの場合、削除対象であることを示す
+ fail(true);
+ } else {
+ (res as Account).token = token;
+ done(res as Account);
+ }
+ })
+ .catch(fail);
});
}
-export function updateAccount(accountData) {
+export function updateAccount(accountData: Partial) {
+ if (!$i) return;
for (const [key, value] of Object.entries(accountData)) {
$i[key] = value;
}
miLocalStorage.setItem('account', JSON.stringify($i));
}
-export function refreshAccount() {
- return fetchAccount($i.token).then(updateAccount);
+export async function refreshAccount() {
+ if (!$i) return;
+ return fetchAccount($i.token, $i.id)
+ .then(updateAccount, reason => {
+ if (reason === true) return signout();
+ return;
+ });
}
export async function login(token: Account['token'], redirect?: string) {
- waiting();
+ const showing = ref(true);
+ popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
+ success: false,
+ showing: showing,
+ }, {}, 'closed');
if (_DEV_) console.log('logging as token ', token);
- const me = await fetchAccount(token);
+ const me = await fetchAccount(token, undefined, true)
+ .catch(reason => {
+ if (reason === true) {
+ // 削除対象の場合
+ removeAccount(token);
+ }
+
+ showing.value = false;
+ throw reason;
+ });
miLocalStorage.setItem('account', JSON.stringify(me));
document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う
await addAccount(me.id, token);
@@ -155,6 +210,8 @@ export async function openAccountMenu(opts: {
active?: misskey.entities.UserDetailed['id'];
onChoose?: (account: misskey.entities.UserDetailed) => void;
}, ev: MouseEvent) {
+ if (!$i) return;
+
function showSigninDialog() {
popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
done: res => {
@@ -175,8 +232,9 @@ export async function openAccountMenu(opts: {
async function switchAccount(account: misskey.entities.UserDetailed) {
const storedAccounts = await getAccounts();
- const token = storedAccounts.find(x => x.id === account.id).token;
- switchAccountWithToken(token);
+ const found = storedAccounts.find(x => x.id === account.id);
+ if (found == null) return;
+ switchAccountWithToken(found.token);
}
function switchAccountWithToken(token: string) {
@@ -188,7 +246,7 @@ export async function openAccountMenu(opts: {
function createItem(account: misskey.entities.UserDetailed) {
return {
- type: 'user',
+ type: 'user' as const,
user: account,
active: opts.active != null ? opts.active === account.id : false,
action: () => {
@@ -201,22 +259,29 @@ export async function openAccountMenu(opts: {
};
}
- const accountItemPromises = storedAccounts.map(a => new Promise(res => {
+ const accountItemPromises = storedAccounts.map(a => new Promise | MenuButton>(res => {
accountsPromise.then(accounts => {
const account = accounts.find(x => x.id === a.id);
- if (account == null) return res(null);
+ if (account == null) return res({
+ type: 'button' as const,
+ text: a.id,
+ action: () => {
+ switchAccountWithToken(a.token);
+ },
+ });
+
res(createItem(account));
});
}));
if (opts.withExtraOperation) {
popupMenu([...[{
- type: 'link',
+ type: 'link' as const,
text: i18n.ts.profile,
to: `/@${ $i.username }`,
avatar: $i,
}, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
- type: 'parent',
+ type: 'parent' as const,
icon: 'ti ti-plus',
text: i18n.ts.addAccount,
children: [{
@@ -227,7 +292,7 @@ export async function openAccountMenu(opts: {
action: () => { createAccount(); },
}],
}, {
- type: 'link',
+ type: 'link' as const,
icon: 'ti ti-users',
text: i18n.ts.manageAccounts,
to: '/settings/accounts',
diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue
index 4525d3a00..d6303f967 100644
--- a/packages/frontend/src/components/MkDateSeparatedList.vue
+++ b/packages/frontend/src/components/MkDateSeparatedList.vue
@@ -1,7 +1,9 @@
diff --git a/packages/frontend/src/components/MkSignup.vue b/packages/frontend/src/components/MkSignup.vue
index d8703a0b1..22a806380 100644
--- a/packages/frontend/src/components/MkSignup.vue
+++ b/packages/frontend/src/components/MkSignup.vue
@@ -9,6 +9,7 @@
@
@{{ host }}
+ {{ i18n.ts.cannotBeChangedLater }}
{{ i18n.ts.checking }}
{{ i18n.ts.available }}
{{ i18n.ts.unavailable }}
@@ -109,6 +110,8 @@ let ToSAgreement: boolean = $ref(false);
let hCaptchaResponse = $ref(null);
let reCaptchaResponse = $ref(null);
let turnstileResponse = $ref(null);
+let usernameAbortController: null | AbortController = $ref(null);
+let emailAbortController: null | AbortController = $ref(null);
const shouldDisableSubmitting = $computed((): boolean => {
return submitting ||
@@ -116,7 +119,9 @@ const shouldDisableSubmitting = $computed((): boolean => {
instance.enableHcaptcha && !hCaptchaResponse ||
instance.enableRecaptcha && !reCaptchaResponse ||
instance.enableTurnstile && !turnstileResponse ||
- passwordRetypeState === 'not-match';
+ instance.emailRequiredForSignup && emailState !== 'ok' ||
+ usernameState !== 'ok' ||
+ passwordRetypeState !== 'match';
});
function onChangeUsername(): void {
@@ -138,14 +143,20 @@ function onChangeUsername(): void {
}
}
+ if (usernameAbortController != null) {
+ usernameAbortController.abort();
+ }
usernameState = 'wait';
+ usernameAbortController = new AbortController();
os.api('username/available', {
username,
- }).then(result => {
+ }, undefined, usernameAbortController.signal).then(result => {
usernameState = result.available ? 'ok' : 'unavailable';
- }).catch(() => {
- usernameState = 'error';
+ }).catch((err) => {
+ if (err.name !== 'AbortError') {
+ usernameState = 'error';
+ }
});
}
@@ -155,11 +166,15 @@ function onChangeEmail(): void {
return;
}
+ if (emailAbortController != null) {
+ emailAbortController.abort();
+ }
emailState = 'wait';
+ emailAbortController = new AbortController();
os.api('email-address/available', {
emailAddress: email,
- }).then(result => {
+ }, undefined, emailAbortController.signal).then(result => {
emailState = result.available ? 'ok' :
result.reason === 'used' ? 'unavailable:used' :
result.reason === 'format' ? 'unavailable:format' :
@@ -167,8 +182,10 @@ function onChangeEmail(): void {
result.reason === 'mx' ? 'unavailable:mx' :
result.reason === 'smtp' ? 'unavailable:smtp' :
'unavailable';
- }).catch(() => {
- emailState = 'error';
+ }).catch((err) => {
+ if (err.name !== 'AbortError') {
+ emailState = 'error';
+ }
});
}
diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue
index 19c735c5f..d074fdd15 100644
--- a/packages/frontend/src/components/MkWidgets.vue
+++ b/packages/frontend/src/components/MkWidgets.vue
@@ -19,9 +19,9 @@
@update:model-value="v => emit('updateWidgets', v)"
>
-
+
-
+
diff --git a/packages/frontend/src/components/form/section.vue b/packages/frontend/src/components/form/section.vue
index a83816497..55308b9c8 100644
--- a/packages/frontend/src/components/form/section.vue
+++ b/packages/frontend/src/components/form/section.vue
@@ -1,7 +1,7 @@
-
-
-
+
@@ -13,31 +13,31 @@ defineProps<{
}>();
-
diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue
index 2bb432e15..e0304c8bc 100644
--- a/packages/frontend/src/components/global/MkAd.vue
+++ b/packages/frontend/src/components/global/MkAd.vue
@@ -82,7 +82,7 @@ const choseAd = (): Ad | null => {
};
const chosen = ref(choseAd());
-const shouldHide = $ref($i && $i.policies.canHideAds);
+const shouldHide = $ref($i && $i.policies.canHideAds && (props.specify == null));
function reduceFrequency(): void {
if (chosen.value == null) return;
diff --git a/packages/frontend/src/debug.ts b/packages/frontend/src/debug.ts
new file mode 100644
index 000000000..5715acf67
--- /dev/null
+++ b/packages/frontend/src/debug.ts
@@ -0,0 +1,27 @@
+import { type ComponentInternalInstance, getCurrentInstance } from 'vue';
+
+export function isDebuggerEnabled(id: number): boolean {
+ try {
+ return localStorage.getItem(`DEBUG_${id}`) !== null;
+ } catch {
+ return false;
+ }
+}
+
+export function switchDebuggerEnabled(id: number, enabled: boolean): void {
+ if (enabled) {
+ localStorage.setItem(`DEBUG_${id}`, '');
+ } else {
+ localStorage.removeItem(`DEBUG_${id}`);
+ }
+}
+
+export function stackTraceInstances(): ComponentInternalInstance[] {
+ let instance = getCurrentInstance();
+ const stack: ComponentInternalInstance[] = [];
+ while (instance) {
+ stack.push(instance);
+ instance = instance.parent;
+ }
+ return stack;
+}
diff --git a/packages/frontend/src/directives/index.ts b/packages/frontend/src/directives/index.ts
index 854f0a544..064ee4f64 100644
--- a/packages/frontend/src/directives/index.ts
+++ b/packages/frontend/src/directives/index.ts
@@ -14,17 +14,23 @@ import adaptiveBg from './adaptive-bg';
import container from './container';
export default function(app: App) {
- app.directive('userPreview', userPreview);
- app.directive('user-preview', userPreview);
- app.directive('get-size', getSize);
- app.directive('ripple', ripple);
- app.directive('tooltip', tooltip);
- app.directive('hotkey', hotkey);
- app.directive('appear', appear);
- app.directive('anim', anim);
- app.directive('click-anime', clickAnime);
- app.directive('panel', panel);
- app.directive('adaptive-border', adaptiveBorder);
- app.directive('adaptive-bg', adaptiveBg);
- app.directive('container', container);
+ for (const [key, value] of Object.entries(directives)) {
+ app.directive(key, value);
+ }
}
+
+export const directives = {
+ 'userPreview': userPreview,
+ 'user-preview': userPreview,
+ 'get-size': getSize,
+ 'ripple': ripple,
+ 'tooltip': tooltip,
+ 'hotkey': hotkey,
+ 'appear': appear,
+ 'anim': anim,
+ 'click-anime': clickAnime,
+ 'panel': panel,
+ 'adaptive-border': adaptiveBorder,
+ 'adaptive-bg': adaptiveBg,
+ 'container': container,
+};
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
index 51c3de43f..b10e7df71 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -84,6 +84,12 @@
{{ i18n.ts._aboutMisskey.morePatrons }}
+
+ Special thanks
+
+
+
+
@@ -120,6 +126,9 @@ const patronsWithIcon = [{
}, {
name: 'ぱーこ',
icon: 'https://misskey-hub.net/patrons/79c6602ffade489e8df2fcf2c2bc5d9d.jpg',
+}, {
+ name: 'わっほー☆',
+ icon: 'https://misskey-hub.net/patrons/d31d5d13924443a082f3da7966318a0a.jpg',
}];
const patrons = [
@@ -203,6 +212,8 @@ const patrons = [
'pixeldesu',
'あめ玉',
'氷月氷華里',
+ 'Ebise Lutica',
+ '巣黒るい@リスケモ男の娘VTuber!',
];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));
diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue
index 2a65a7518..ac6cca84c 100644
--- a/packages/frontend/src/pages/admin/roles.edit.vue
+++ b/packages/frontend/src/pages/admin/roles.edit.vue
@@ -46,7 +46,8 @@ if (props.id) {
data = {
name: 'New Role',
description: '',
- rolePermission: 'normal',
+ isAdministrator: false,
+ isModerator: false,
color: null,
iconUrl: null,
target: 'manual',
diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue
index e09f22e34..6eac90257 100644
--- a/packages/frontend/src/pages/admin/roles.role.vue
+++ b/packages/frontend/src/pages/admin/roles.role.vue
@@ -11,7 +11,7 @@
{{ i18n.ts.info }}
-
+
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index d89f0d2a7..25d8f3ad6 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -4,7 +4,6 @@
-
{{ i18n.ts._role.new }}
{{ i18n.ts._role.baseRole }}
@@ -132,8 +131,20 @@
{{ i18n.ts.save }}
+
{{ i18n.ts._role.new }}
-
+
+ Manual roles
+
+
+
+
+
+ Conditional roles
+
+
+
+
@@ -155,6 +166,7 @@ import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { instance } from '@/instance';
import { useRouter } from '@/router';
+import MkFoldableSection from '@/components/MkFoldableSection.vue';
const ROLE_POLICIES = [
'gtlAvailable',
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index 6b4fcb32f..76f11faab 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -1,30 +1,25 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
{{ channel.usersCount }}
{{ channel.notesCount }}
-
+
-
-
+
+
-
+
@@ -32,6 +27,15 @@
+
+
+
+
+ {{ i18n.ts.postToTheChannel }}
+
+
+
+
@@ -42,11 +46,14 @@ import MkTimeline from '@/components/MkTimeline.vue';
import XChannelFollowButton from '@/components/MkChannelFollowButton.vue';
import * as os from '@/os';
import { useRouter } from '@/router';
-import { $i } from '@/account';
+import { $i, iAmModerator } from '@/account';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { deviceKind } from '@/scripts/device-kind';
import MkNotes from '@/components/MkNotes.vue';
+import { url } from '@/config';
+import MkButton from '@/components/MkButton.vue';
+import { defaultStore } from '@/store';
const router = useRouter();
@@ -56,7 +63,6 @@ const props = defineProps<{
let tab = $ref('timeline');
let channel = $ref(null);
-let showBanner = $ref(true);
const featuredPagination = $computed(() => ({
endpoint: 'notes/featured' as const,
limit: 10,
@@ -76,13 +82,44 @@ function edit() {
router.push(`/channels/${channel.id}/edit`);
}
-const headerActions = $computed(() => channel && channel.userId ? [{
- icon: 'ti ti-settings',
- text: i18n.ts.edit,
- handler: edit,
-}] : null);
+function openPostForm() {
+ os.post({
+ channel: {
+ id: channel.id,
+ },
+ });
+}
+
+const headerActions = $computed(() => {
+ if (channel && channel.userId) {
+ const share = {
+ icon: 'ti ti-share',
+ text: i18n.ts.share,
+ handler: async (): Promise
=> {
+ navigator.share({
+ title: channel.name,
+ text: channel.description,
+ url: `${url}/channels/${channel.id}`,
+ });
+ },
+ };
+
+ const canEdit = ($i && $i.id === channel.userId) || iAmModerator;
+ return canEdit ? [share, {
+ icon: 'ti ti-settings',
+ text: i18n.ts.edit,
+ handler: edit,
+ }] : [share];
+ } else {
+ return null;
+ }
+});
const headerTabs = $computed(() => [{
+ key: 'overview',
+ title: i18n.ts.overview,
+ icon: 'ti ti-info-circle',
+}, {
key: 'timeline',
title: i18n.ts.timeline,
icon: 'ti ti-home',
@@ -98,102 +135,57 @@ definePageMetadata(computed(() => channel ? {
} : null));
-
diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue
index d4e8f2700..d66088d33 100644
--- a/packages/frontend/src/pages/clip.vue
+++ b/packages/frontend/src/pages/clip.vue
@@ -26,6 +26,7 @@ import { $i } from '@/account';
import { i18n } from '@/i18n';
import * as os from '@/os';
import { definePageMetadata } from '@/scripts/page-metadata';
+import { url } from '@/config';
const props = defineProps<{
clipId: string,
@@ -82,7 +83,17 @@ const headerActions = $computed(() => clip && isOwned ? [{
...result,
});
},
-}, {
+}, ...(clip.isPublic ? [{
+ icon: 'ti ti-share',
+ text: i18n.ts.share,
+ handler: async (): Promise => {
+ navigator.share({
+ title: clip.name,
+ text: clip.description,
+ url: `${url}/clips/${clip.id}`,
+ });
+ },
+}] : []), {
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
diff --git a/packages/frontend/src/pages/explore.roles.vue b/packages/frontend/src/pages/explore.roles.vue
index 8be11008c..51177d079 100644
--- a/packages/frontend/src/pages/explore.roles.vue
+++ b/packages/frontend/src/pages/explore.roles.vue
@@ -16,7 +16,7 @@ let roles = $ref();
os.api('roles/list', {
limit: 30,
}).then(res => {
- roles = res;
+ roles = res.filter(x => x.target === 'manual');
});
diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue
index 835dd0b54..a51d1c78a 100644
--- a/packages/frontend/src/pages/follow-requests.vue
+++ b/packages/frontend/src/pages/follow-requests.vue
@@ -18,12 +18,9 @@
@{{ acct(req.follower) }}
-
-
-
-
-
-
+
+ {{ i18n.ts.accept }}
+ {{ i18n.ts.reject }}
@@ -37,6 +34,7 @@
-
diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue
index 1734dcfe4..b1e6f223b 100644
--- a/packages/frontend/src/pages/settings/email.vue
+++ b/packages/frontend/src/pages/settings/email.vue
@@ -1,5 +1,5 @@
-
+
{{ i18n.ts.emailAddress }}
@@ -37,17 +37,22 @@
+
+ {{ i18n.ts.emailNotSupported }}
+
+
+
diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue
index e3cf1aefb..28e39236f 100644
--- a/packages/frontend/src/pages/settings/preferences-backups.vue
+++ b/packages/frontend/src/pages/settings/preferences-backups.vue
@@ -73,6 +73,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'useBlurEffectForModal',
'useBlurEffect',
'showFixedPostForm',
+ 'showFixedPostFormInChannel',
'enableInfiniteScroll',
'useReactionPickerForContextMenu',
'showGapBetweenNotesInTimeline',
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index 41563c441..4776a87d5 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -64,12 +64,19 @@
+
+ {{ i18n.ts.reactionAcceptance }}
+
+
+
+
+
{{ i18n.ts.flagShowTimelineReplies }}{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}